Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
68 changes: 68 additions & 0 deletions resources/js/evo-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -3303,6 +3368,9 @@
window.EvoUI.manager = {
open: openManagerLink
};
window.EvoUI.moduleTabs = {
syncUrl: syncModuleTabUrl
};

window.setParent = function (id, title) {
applyResourceParentSelection(id, title);
Expand Down
8 changes: 8 additions & 0 deletions tests/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions views/components/module-tab-shell.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,15 +30,22 @@
? 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);
}
},
refreshModuleTab(tab) {
this.activeTab = tab;
this.syncModuleTabUrl(tab);

if (@js($model !== '')) {
Promise.resolve(this.$wire.set(@js($model), tab)).then(() => {
Expand Down