diff --git a/app/RSpade/Core/Js/Width_Group.js b/app/RSpade/Core/Js/Width_Group.js new file mode 100755 index 000000000..c6a2fd555 --- /dev/null +++ b/app/RSpade/Core/Js/Width_Group.js @@ -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'; + } + } + } +} diff --git a/app/RSpade/man/width_group.txt b/app/RSpade/man/width_group.txt new file mode 100755 index 000000000..2e26bee8f --- /dev/null +++ b/app/RSpade/man/width_group.txt @@ -0,0 +1,130 @@ +NAME + width_group - synchronized min-width for element groups + +SYNOPSIS + $(".selector").width_group("group-name") + $.width_group_destroy("group-name") + $.width_group_recalculate("group-name") + $.width_group_recalculate() + +DESCRIPTION + Makes multiple elements share the same min-width, determined by the widest + element in the group. Useful for button groups, card layouts, navigation + items, or any UI where elements should have consistent widths. + + Elements are tracked individually by DOM reference, not by selector. The + same group name can be used across multiple width_group() calls to add + more elements to an existing group. + + Calculation occurs immediately on call and on window resize (debounced + 100ms). Elements removed from the DOM are automatically pruned from + tracking on each calculation cycle. + +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. + Returns the jQuery object for chaining. + + Example: + $(".toolbar .btn").width_group("toolbar-buttons"); + + $.width_group_destroy("group-name") + + Removes all tracking for the named group. Clears min-width styles + from elements still in the DOM. Removes the resize listener if no + groups remain. + + Example: + $.width_group_destroy("toolbar-buttons"); + + $.width_group_recalculate("group-name") + + Manually triggers recalculation for a specific group. Useful after + dynamically changing element content (text, icons, etc.). + + Example: + $(".btn-save").text("Saving..."); + $.width_group_recalculate("toolbar-buttons"); + + $.width_group_recalculate() + + Recalculates all width groups. + +AUTOMATIC CLEANUP + On each resize or manual recalculate, elements are checked for DOM + presence using element.isConnected. Disconnected elements are removed + from tracking automatically. + + When all elements in a group are gone, the group is destroyed. When all + groups are destroyed, the resize listener is removed. This prevents + memory leaks during SPA navigation. + + Explicit $.width_group_destroy() calls are optional but can be used for + immediate cleanup. + +HOW IT WORKS + 1. On width_group() call, elements are added to a named registry + 2. Calculation runs: + a. Remove min-width from all elements (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 + +EXAMPLES + Button group with consistent widths: + +
+ + // All buttons match width of "Delete Forever" + $(".action-buttons .btn").width_group("actions"); + + Adding elements to existing group: + + // Initial buttons + $(".primary-actions .btn").width_group("nav-buttons"); + + // Later, add secondary buttons to same group + $(".secondary-actions .btn").width_group("nav-buttons"); + + Recalculate after content change: + + async function save() { + const $btn = $(".btn-save"); + const original = $btn.text(); + + $btn.text("Saving..."); + $.width_group_recalculate("form-buttons"); + + await Controller.save(data); + + $btn.text(original); + $.width_group_recalculate("form-buttons"); + } + + Card layout with equal-width headers: + +