diff --git a/app/RSpade/Core/Dispatch/Dispatcher.php b/app/RSpade/Core/Dispatch/Dispatcher.php index bcc5cf09d..6b418bad5 100644 --- a/app/RSpade/Core/Dispatch/Dispatcher.php +++ b/app/RSpade/Core/Dispatch/Dispatcher.php @@ -165,8 +165,8 @@ class Dispatcher // For GET requests: redirect to the proper route $params = array_merge($extra_params, $request->query->all()); - // Generate proper URL using Rsx::Route - $proper_url = Rsx::Route($controller_name, $action_name, $params); + // Generate proper URL using Rsx::Route (signature: "Controller::method", $params) + $proper_url = Rsx::Route($controller_name . '::' . $action_name, $params); console_debug('DISPATCH', 'Redirecting GET to proper route:', $proper_url); @@ -209,8 +209,9 @@ class Dispatcher if ($route_pattern) { // Generate proper URL for the SPA action + // Note: SPA actions use class name only (action_name is ignored for SPA routes) $params = array_merge($extra_params, $request->query->all()); - $proper_url = Rsx::Route($controller_name, $action_name, $params); + $proper_url = Rsx::Route($controller_name, $params); console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url); diff --git a/app/RSpade/Core/Js/Width_Group.js b/app/RSpade/Core/Js/Width_Group.js index c6a2fd555..9cf4a290b 100755 --- a/app/RSpade/Core/Js/Width_Group.js +++ b/app/RSpade/Core/Js/Width_Group.js @@ -15,6 +15,8 @@ * * - Elements are tracked individually (not by selector) * - Calling again with same group name adds more elements to the group + * - If element is already in the same group, no-op (idempotent) + * - If element is in a different group, it's moved to the new group * - Recalculates immediately and on window resize (debounced 100ms) * - Returns the jQuery object for chaining * @@ -68,8 +70,10 @@ * 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 + * 3. Child components are found via shallowFind('.Component') and their + * ready events trigger debounced recalculation + * 4. On window resize (debounced 100ms): recalculate all groups + * 5. Disconnected elements are pruned on each calculation * */ @@ -78,8 +82,8 @@ class Width_Group { // Registry of groups: { "group-name": [element, element, ...] } static _groups = {}; - // Debounced resize handler (created on first use) - static _resize_handler = null; + // Debounced recalculate handler (shared by resize and component ready) + static _debounced_recalculate = null; // Whether resize listener is attached static _resize_attached = false; @@ -104,17 +108,50 @@ class Width_Group { Width_Group._groups[group_name] = []; } - // Add each element to the group (avoid duplicates) + // Add each element to the group this.each(function () { const element = this; - if (!Width_Group._groups[group_name].includes(element)) { - Width_Group._groups[group_name].push(element); + const current_group = element._width_group; + + // Already in this group - no-op + if (current_group === group_name) { + return; } + + // In a different group - remove from old group first + if (current_group && Width_Group._groups[current_group]) { + const old_group = Width_Group._groups[current_group]; + const index = old_group.indexOf(element); + if (index !== -1) { + old_group.splice(index, 1); + element.style.minWidth = ''; + } + // Clean up empty group + if (old_group.length === 0) { + delete Width_Group._groups[current_group]; + } + } + + // Add to new group + Width_Group._groups[group_name].push(element); + element._width_group = group_name; }); // Ensure resize listener is attached Width_Group._attach_resize_listener(); + // Listen for child component ready events + this.each(function () { + $(this).shallowFind('.Component').each(function () { + const component = $(this).component(); + if (component) { + component.on('ready', () => { + Width_Group._schedule_recalculate(); + }); + } + }); + }); + // Calculate immediately Width_Group._calculate_group(group_name); @@ -152,11 +189,12 @@ class Width_Group { return; } - // Clear min-width from elements still in DOM + // Clear min-width and group tracking from elements for (const element of elements) { if (element.isConnected) { element.style.minWidth = ''; } + delete element._width_group; } // Remove group from registry @@ -166,6 +204,20 @@ class Width_Group { Width_Group._maybe_detach_resize_listener(); } + /** + * Schedule a debounced recalculation of all groups + * Used by resize handler and component ready events + * @private + */ + static _schedule_recalculate() { + if (!Width_Group._debounced_recalculate) { + Width_Group._debounced_recalculate = debounce(() => { + Width_Group._calculate_all(); + }, 100); + } + Width_Group._debounced_recalculate(); + } + /** * Attach the resize listener if not already attached * @private @@ -175,14 +227,9 @@ class Width_Group { 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); + $(window).on('resize.width_group', () => { + Width_Group._schedule_recalculate(); + }); Width_Group._resize_attached = true; } @@ -218,8 +265,14 @@ class Width_Group { return; } - // Filter to only connected elements - elements = elements.filter(el => el.isConnected); + // Filter to only connected elements, clean up disconnected ones + elements = elements.filter(el => { + if (el.isConnected) { + return true; + } + delete el._width_group; + return false; + }); // Update registry with pruned list Width_Group._groups[group_name] = elements; diff --git a/app/RSpade/man/width_group.txt b/app/RSpade/man/width_group.txt index 2e26bee8f..69ced4dd5 100755 --- a/app/RSpade/man/width_group.txt +++ b/app/RSpade/man/width_group.txt @@ -27,6 +27,10 @@ API group will have their min-width set to match the widest element. Returns the jQuery object for chaining. + Each element can only belong to one width group at a time: + - If already in the same group: no-op (idempotent) + - If in a different group: moved to the new group + Example: $(".toolbar .btn").width_group("toolbar-buttons"); @@ -64,14 +68,29 @@ AUTOMATIC CLEANUP Explicit $.width_group_destroy() calls are optional but can be used for immediate cleanup. +COMPONENT INTEGRATION + When width_group() is called, it automatically finds child components + within each element using shallowFind('.Component'). For each component + found, it listens for the 'ready' event and triggers a debounced + recalculation when the component becomes ready. + + This handles cases where a width group contains components that load + asynchronously - the widths will be recalculated once the component + content is rendered. + + All recalculation triggers (resize, component ready) share the same + debounced function (100ms) to prevent excessive recalculations. + HOW IT WORKS 1. On width_group() call, elements are added to a named registry - 2. Calculation runs: + 2. Calculation runs immediately: 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 + 3. Child components found via shallowFind('.Component') have their + ready events listened to for debounced recalculation + 4. On window resize (debounced 100ms), recalculate all groups + 5. Disconnected elements are pruned on each calculation EXAMPLES Button group with consistent widths: diff --git a/app/RSpade/upstream_changes/scss_loop_refactor_12_31.txt b/app/RSpade/upstream_changes/scss_loop_refactor_12_31.txt new file mode 100755 index 000000000..f35039efb --- /dev/null +++ b/app/RSpade/upstream_changes/scss_loop_refactor_12_31.txt @@ -0,0 +1,111 @@ +SCSS Loop Refactoring for Responsive Styles +============================================ +Date: 2024-12-31 +Affects: rsx/theme/responsive.scss (and any project-specific responsive SCSS) + +SUMMARY +------- +The framework's responsive.scss has been refactored to use SCSS loops (@each) +for repetitive class generation. Projects with custom responsive SCSS should +analyze their files for similar optimization opportunities. + +CHANGES IN FRAMEWORK +-------------------- +1. col-5ths responsive classes now generated via @each loop +2. CSS custom properties (--bp-*) now generated via @each loop +3. $breakpoints-up map added for consistency in -up mixins +4. Reduced code from ~55 lines to ~30 lines for col-5ths section + +ANALYSIS GUIDE FOR PROJECT SCSS +------------------------------- +Review your responsive SCSS files for these patterns: + +PATTERN 1: Repeated classes with only breakpoint name changing +-------------------------------------------------------------- +Before: + .my-thing-mobile { @include mobile { ... } } + .my-thing-tablet { @include tablet-up { ... } } + .my-thing-desktop { @include desktop { ... } } + +After: + $my-thing-breakpoints: ( + 'mobile': 'max' $bp-desktop, + 'tablet': 'min' $bp-tablet, + 'desktop': 'min' $bp-desktop + ); + + @each $name, $config in $my-thing-breakpoints { + $type: nth($config, 1); + $value: nth($config, 2); + + .my-thing-#{$name} { + @if $type == 'max' { + @media (max-width: #{$value - 0.02px}) { ... } + } @else { + @media (min-width: $value) { ... } + } + } + } + +PATTERN 2: CSS custom properties for each breakpoint +---------------------------------------------------- +Before: + :root { + --my-bp-tablet: #{$bp-tablet}; + --my-bp-desktop: #{$bp-desktop}; + --my-bp-desktop-lg: #{$bp-desktop-lg}; + } + +After: + $my-breakpoints: ( + 'tablet': $bp-tablet, + 'desktop': $bp-desktop, + 'desktop-lg': $bp-desktop-lg + ); + + :root { + @each $name, $value in $my-breakpoints { + --my-bp-#{$name}: #{$value}; + } + } + +PATTERN 3: Shared styles in a mixin +----------------------------------- +If multiple classes share identical styles, extract to a mixin: + + @mixin my-shared-styles { + display: flex; + align-items: center; + gap: 8px; + } + + .base-class { @include my-shared-styles; } + + @each $name, $config in $breakpoints { + .responsive-#{$name} { + @media (...) { @include my-shared-styles; } + } + } + +WHEN NOT TO USE LOOPS +--------------------- +- When classes have different internal logic (not just breakpoint differences) +- When the repetition is 2-3 items (loop overhead not worth it) +- When the pattern would obscure the code's intent + +LIMITATION: DYNAMIC MIXIN CALLS +------------------------------- +SCSS does not support: @include #{$variable} + +This means you cannot dynamically call mixins by name. The workaround is to +define breakpoint values in a map and generate media queries directly, as +shown in Pattern 1 above. + +ACTION REQUIRED +--------------- +1. Review project-specific responsive SCSS files +2. Identify repeated patterns (3+ similar blocks) +3. Refactor using @each loops where appropriate +4. Verify compiled CSS output matches previous version + +NO BREAKING CHANGES - this is a code organization improvement only.