From 27c035e20a582067ff6d6438d5114842c04b6eca Mon Sep 17 00:00:00 2001 From: middleDuckAi Date: Wed, 9 Sep 2026 22:33:37 +0530 Subject: [PATCH] fix(tabs): persist active tab in manager URL --- docs/en/components/README.md | 1 + resources/js/evo-ui.js | 68 +++++++++++++++++++++ tests/run.php | 8 +++ views/components/module-tab-shell.blade.php | 9 +++ 4 files changed, 86 insertions(+) diff --git a/docs/en/components/README.md b/docs/en/components/README.md index a613f40..c709dfd 100644 --- a/docs/en/components/README.md +++ b/docs/en/components/README.md @@ -293,6 +293,7 @@ Parameters: | --- | --- | --- | --- | | `tabs` | array | `[]` | Module tab config. Supports `key`, `argument`, `label`, `icon`, `permission`, `hidden` and `data`. | | `model` | string | `activeTab` | Livewire property entangled with the selected tab. | +| `urlParameter` | string|null | `null` | Optional query key updated in both the iframe URL and Evolution Manager hash when the active tab changes. | | `label` | string|null | null | Accessible label for the tablist. | | `panelClass` | string | empty | Extra classes for the tab content wrapper. | | `surface` | bool | true | Wrap slot content in `evo-ui-surface`. | diff --git a/resources/js/evo-ui.js b/resources/js/evo-ui.js index 4e3f6d4..45d1ca7 100644 --- a/resources/js/evo-ui.js +++ b/resources/js/evo-ui.js @@ -1957,6 +1957,71 @@ }, 60); } + function replaceModuleTabSearchParameter(targetWindow, parameter, tab) { + try { + var url = new URL(targetWindow.location.href); + + if (url.searchParams.get(parameter) === tab) { + return; + } + + url.searchParams.set(parameter, tab); + targetWindow.history.replaceState( + targetWindow.history.state, + targetWindow.document.title, + url.pathname + url.search + url.hash + ); + } catch (error) { + // Embedded manager windows can deny location access. + } + } + + function replaceModuleTabHashParameter(targetWindow, parameter, tab) { + try { + var hash = targetWindow.location.hash || ''; + var queryIndex = hash.indexOf('?'); + + if (queryIndex === -1) { + return; + } + + var prefix = hash.slice(0, queryIndex + 1); + var params = new URLSearchParams(hash.slice(queryIndex + 1)); + + if (params.get(parameter) === tab) { + return; + } + + params.set(parameter, tab); + targetWindow.history.replaceState( + targetWindow.history.state, + targetWindow.document.title, + targetWindow.location.pathname + targetWindow.location.search + prefix + params.toString() + ); + } catch (error) { + // Parent manager access is best-effort for embedded consumers. + } + } + + function syncModuleTabUrl(parameter, tab) { + parameter = String(parameter || '').trim(); + tab = String(tab || '').trim(); + + if (!/^[a-z][a-z0-9_-]*$/i.test(parameter) || tab === '') { + return; + } + + replaceModuleTabSearchParameter(window, parameter, tab); + + try { + if (window.parent && window.parent !== window) { + replaceModuleTabHashParameter(window.parent, parameter, tab); + } + } catch (error) { + // Cross-origin parents are intentionally ignored. + } + } + function tableResponsiveStorageKey(surface) { var key = surface.getAttribute('data-evo-table') || surface.getAttribute('wire:key') || 'default'; @@ -3303,6 +3368,9 @@ window.EvoUI.manager = { open: openManagerLink }; + window.EvoUI.moduleTabs = { + syncUrl: syncModuleTabUrl + }; window.setParent = function (id, title) { applyResourceParentSelection(id, title); diff --git a/tests/run.php b/tests/run.php index 3d1a8c4..190ae8f 100644 --- a/tests/run.php +++ b/tests/run.php @@ -1480,6 +1480,8 @@ function evo_ui_config(string $path): array 'data-evo-module-tab-shell' => 'Module tab shell must expose a stable root marker.', 'data-evo-module-tab-model' => 'Module tab shell must expose the bound Livewire model marker.', '$wire.entangle(@js($model)).live' => 'Module tab shell must entangle the active tab with Livewire.', + "'urlParameter' => null" => 'Module tab URL persistence must remain opt-in for existing consumers.', + 'window.EvoUI?.moduleTabs?.syncUrl(@js($urlParameter), tab);' => 'Opted-in module tabs must synchronize their active key with the manager URL.', 'setActiveModuleTab(tab)' => 'Module tab shell must explicitly sync tab changes back to Livewire.', 'this.$wire.set(@js($model), tab);' => 'Module tab shell must force a Livewire update when switching tabs.', 'requestModuleTab(tab)' => 'Module tab shell must centralize tab change requests.', @@ -1513,6 +1515,12 @@ function evo_ui_config(string $path): array $shell, 'Module tab refresh events must not use dots because Alpine parses them as modifiers.' ); + + $js = evo_ui_read('resources/js/evo-ui.js'); + evo_ui_assert_contains('function syncModuleTabUrl(parameter, tab)', $js, 'EvoUI must expose one shared module-tab URL synchronizer.'); + evo_ui_assert_contains('replaceModuleTabSearchParameter(window, parameter, tab);', $js, 'Module tabs must update their iframe query state.'); + evo_ui_assert_contains('replaceModuleTabHashParameter(window.parent, parameter, tab);', $js, 'Module tabs must update the Evolution Manager hash query when embedded.'); + evo_ui_assert_contains('syncUrl: syncModuleTabUrl', $js, 'The URL synchronizer must be available to the module-tab shell.'); }); evo_ui_test('module table refreshes only for its owning module tab', function (): void { diff --git a/views/components/module-tab-shell.blade.php b/views/components/module-tab-shell.blade.php index f10d294..888e1c5 100644 --- a/views/components/module-tab-shell.blade.php +++ b/views/components/module-tab-shell.blade.php @@ -6,10 +6,12 @@ 'panelClass' => '', 'surface' => true, 'id' => null, + 'urlParameter' => null, ]) @php $model = (string) $model; + $urlParameter = trim((string) $urlParameter); $modalTitleId = \Illuminate\Support\Str::slug((string) ($id ?: 'evo-ui-module-tabs-' . $model . '-unsaved-title')); @endphp @@ -28,8 +30,14 @@ ? window.EvoUI.form.isDirty() : document.querySelector('[data-evo-form-dirty="true"]') !== null; }, + syncModuleTabUrl(tab) { + if (@js($urlParameter !== '')) { + window.EvoUI?.moduleTabs?.syncUrl(@js($urlParameter), tab); + } + }, setActiveModuleTab(tab) { this.activeTab = tab; + this.syncModuleTabUrl(tab); if (@js($model !== '')) { this.$wire.set(@js($model), tab); @@ -37,6 +45,7 @@ }, refreshModuleTab(tab) { this.activeTab = tab; + this.syncModuleTabUrl(tab); if (@js($model !== '')) { Promise.resolve(this.$wire.set(@js($model), tab)).then(() => {