Add col-5ths responsive columns and Width_Group JS utility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-29 23:34:56 +00:00
parent 3afb345fbc
commit d41a534744
4 changed files with 514 additions and 0 deletions

256
app/RSpade/Core/Js/Width_Group.js Executable file
View File

@@ -0,0 +1,256 @@
/**
* Width_Group - Synchronized min-width for element groups
*
* Makes multiple elements share the same min-width, determined by the widest
* element in the group. Useful for button groups, card layouts, or any UI
* where elements should have consistent widths.
*
* =============================================================================
* API
* =============================================================================
*
* $(".selector").width_group("group-name")
* Adds matched elements to a named width group. All elements in the group
* will have their min-width set to match the widest element.
*
* - Elements are tracked individually (not by selector)
* - Calling again with same group name adds more elements to the group
* - Recalculates immediately and on window resize (debounced 100ms)
* - Returns the jQuery object for chaining
*
* $.width_group_destroy("group-name")
* Removes all tracking for the named group. Clears min-width styles from
* elements still in the DOM and removes the resize listener if no groups
* remain.
*
* $.width_group_recalculate("group-name")
* Manually triggers recalculation for a group. Useful after dynamically
* changing element content.
*
* $.width_group_recalculate()
* Recalculates all groups.
*
* =============================================================================
* AUTOMATIC CLEANUP
* =============================================================================
*
* On each resize (or manual recalculate), elements are checked for DOM presence
* using element.isConnected. Disconnected elements are automatically removed
* from tracking. When all elements in a group are gone, the group is destroyed.
* When all groups are destroyed, the resize listener is removed.
*
* This means SPA navigation that removes elements will automatically clean up
* without explicit destroy calls.
*
* =============================================================================
* EXAMPLE USAGE
* =============================================================================
*
* // Make all action buttons the same width
* $(".action-buttons .btn").width_group("action-buttons");
*
* // Add more buttons to the same group later
* $(".extra-btn").width_group("action-buttons");
*
* // Explicit cleanup (optional - auto-cleans when elements removed)
* $.width_group_destroy("action-buttons");
*
* // After changing button text, recalculate
* $(".action-buttons .btn").text("New Label");
* $.width_group_recalculate("action-buttons");
*
* =============================================================================
* HOW IT WORKS
* =============================================================================
*
* 1. On width_group() call: elements are added to the named group registry
* 2. Calculation runs immediately:
* a. Remove min-width from all elements in group (measure natural width)
* b. Find max scrollWidth across all connected elements
* c. Apply max as min-width to all connected elements
* 3. On window resize (debounced 100ms): recalculate all groups
* 4. Disconnected elements are pruned on each calculation
*
*/
// @JS-THIS-01-EXCEPTION
class Width_Group {
// Registry of groups: { "group-name": [element, element, ...] }
static _groups = {};
// Debounced resize handler (created on first use)
static _resize_handler = null;
// Whether resize listener is attached
static _resize_attached = false;
/**
* Initialize jQuery extensions
*/
static _on_framework_core_define() {
/**
* Add elements to a named width group
* @param {string} group_name - Name of the width group
* @returns {jQuery} The jQuery object for chaining
*/
$.fn.width_group = function (group_name) {
if (!group_name || typeof group_name !== 'string') {
console.error('width_group() requires a group name string');
return this;
}
// Initialize group if needed
if (!Width_Group._groups[group_name]) {
Width_Group._groups[group_name] = [];
}
// Add each element to the group (avoid duplicates)
this.each(function () {
const element = this;
if (!Width_Group._groups[group_name].includes(element)) {
Width_Group._groups[group_name].push(element);
}
});
// Ensure resize listener is attached
Width_Group._attach_resize_listener();
// Calculate immediately
Width_Group._calculate_group(group_name);
return this;
};
/**
* Destroy a width group, removing tracking and styles
* @param {string} group_name - Name of the width group to destroy
*/
$.width_group_destroy = function (group_name) {
Width_Group.destroy(group_name);
};
/**
* Manually recalculate a group or all groups
* @param {string} [group_name] - Optional group name. If omitted, recalculates all.
*/
$.width_group_recalculate = function (group_name) {
if (group_name) {
Width_Group._calculate_group(group_name);
} else {
Width_Group._calculate_all();
}
};
}
/**
* Destroy a named group
* @param {string} group_name
*/
static destroy(group_name) {
const elements = Width_Group._groups[group_name];
if (!elements) {
return;
}
// Clear min-width from elements still in DOM
for (const element of elements) {
if (element.isConnected) {
element.style.minWidth = '';
}
}
// Remove group from registry
delete Width_Group._groups[group_name];
// Detach resize listener if no groups remain
Width_Group._maybe_detach_resize_listener();
}
/**
* Attach the resize listener if not already attached
* @private
*/
static _attach_resize_listener() {
if (Width_Group._resize_attached) {
return;
}
// Create debounced handler on first use
if (!Width_Group._resize_handler) {
Width_Group._resize_handler = debounce(() => {
Width_Group._calculate_all();
}, 100);
}
$(window).on('resize.width_group', Width_Group._resize_handler);
Width_Group._resize_attached = true;
}
/**
* Detach resize listener if no groups remain
* @private
*/
static _maybe_detach_resize_listener() {
if (Object.keys(Width_Group._groups).length === 0 && Width_Group._resize_attached) {
$(window).off('resize.width_group');
Width_Group._resize_attached = false;
}
}
/**
* Calculate and apply min-width for all groups
* @private
*/
static _calculate_all() {
for (const group_name of Object.keys(Width_Group._groups)) {
Width_Group._calculate_group(group_name);
}
}
/**
* Calculate and apply min-width for a specific group
* @param {string} group_name
* @private
*/
static _calculate_group(group_name) {
let elements = Width_Group._groups[group_name];
if (!elements || elements.length === 0) {
return;
}
// Filter to only connected elements
elements = elements.filter(el => el.isConnected);
// Update registry with pruned list
Width_Group._groups[group_name] = elements;
// If all elements are gone, destroy the group
if (elements.length === 0) {
delete Width_Group._groups[group_name];
Width_Group._maybe_detach_resize_listener();
return;
}
// Step 1: Remove min-width to measure natural width
for (const element of elements) {
element.style.minWidth = '';
}
// Step 2: Find the maximum natural width
let max_width = 0;
for (const element of elements) {
// scrollWidth gives the full content width including overflow
const width = element.scrollWidth;
if (width > max_width) {
max_width = width;
}
}
// Step 3: Apply max width as min-width to all elements
if (max_width > 0) {
for (const element of elements) {
element.style.minWidth = max_width + 'px';
}
}
}
}