@navette-js/panels 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -1
  2. package/dist/navette/navette/calendar.css +5 -5
  3. package/dist/navette/navette/motion.css +21 -21
  4. package/dist/navette/navette/motion.js +6 -6
  5. package/dist/navette/navette/security-keys.css +11 -0
  6. package/dist/navette/navette/security-keys.js +72 -0
  7. package/lib/auth.d.ts +17 -0
  8. package/lib/auth.js +70 -0
  9. package/lib/error-pages.d.ts +9 -0
  10. package/lib/error-pages.js +173 -0
  11. package/lib/import-export.d.ts +185 -0
  12. package/lib/import-export.js +543 -0
  13. package/lib/index.d.ts +629 -79
  14. package/lib/index.js +1945 -333
  15. package/lib/multi-factor.d.ts +348 -0
  16. package/lib/multi-factor.js +928 -0
  17. package/lib/notifications.d.ts +20 -2
  18. package/lib/notifications.js +34 -4
  19. package/lib/qr-code.d.ts +16 -0
  20. package/lib/qr-code.js +255 -0
  21. package/lib/session-store.d.ts +21 -0
  22. package/lib/session-store.js +35 -0
  23. package/lib/throttle.d.ts +10 -0
  24. package/lib/throttle.js +25 -0
  25. package/lib/topbar.d.ts +3 -1
  26. package/lib/topbar.js +18 -1
  27. package/lib/totp.d.ts +38 -0
  28. package/lib/totp.js +104 -0
  29. package/lib/webauthn.d.ts +155 -0
  30. package/lib/webauthn.js +287 -0
  31. package/lib/xlsx.d.ts +7 -0
  32. package/lib/xlsx.js +171 -0
  33. package/package.json +9 -9
  34. package/views/components/tenant-menu.ejs +3 -3
  35. package/views/components/user-menu.ejs +3 -2
  36. package/views/errors/page.ejs +87 -0
  37. package/views/layout/base.ejs +9 -1
  38. package/views/livewire/sidebar.ejs +35 -20
  39. package/views/livewire/topbar.ejs +1 -1
  40. package/views/pages/page.ejs +53 -35
  41. package/views/pages/simple.ejs +4 -0
package/lib/xlsx.js ADDED
@@ -0,0 +1,171 @@
1
+ // Minimal XLSX writer: one worksheet of text cells (inline strings), zipped with Node's zlib. Cells are never formulas, so
2
+ // spreadsheet software does not evaluate them; numbers stay text, as in Filament's export.
3
+ import { crc32, deflateRawSync, inflateRawSync } from 'node:zlib';
4
+ /** Escapes a value for XML; characters not allowed in XML 1.0 are dropped. */
5
+ const xml = (value) => value.replace(/[^\x09\x0A\x0D\x20-퟿-�\u{10000}-\u{10FFFF}]/gu, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6
+ /** Column letters of a 0-based index: 0 → A, 26 → AA. */
7
+ function columnName(index) {
8
+ let name = '';
9
+ for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26))
10
+ name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
11
+ return name;
12
+ }
13
+ /** Workbook with a single sheet: the first row is bold (header). */
14
+ export function buildXlsx(rows, sheetName = 'Sheet1') {
15
+ const sheetRows = rows.map((row, r) => `<row r="${r + 1}">${row.map((cell, c) => `<c r="${columnName(c)}${r + 1}" t="inlineStr"${r === 0 ? ' s="1"' : ''}><is><t xml:space="preserve">${xml(cell === null || cell === undefined ? '' : String(cell)).slice(0, 32767)}</t></is></c>`).join('')}</row>`).join('');
16
+ const files = [
17
+ ['[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>'],
18
+ ['_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'],
19
+ ['xl/workbook.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${xml(sheetName.replace(/[\\/?*[\]:]/g, ' ').slice(0, 31)) || 'Sheet1'}" sheetId="1" r:id="rId1"/></sheets></workbook>`],
20
+ ['xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'],
21
+ ['xl/styles.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>'],
22
+ ['xl/worksheets/sheet1.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${sheetRows}</sheetData></worksheet>`],
23
+ ];
24
+ return zip(files.map(([name, content]) => [name, Buffer.from(content, 'utf8')]));
25
+ }
26
+ /** ZIP archive (deflate), as required by the XLSX format. */
27
+ function zip(entries) {
28
+ const locals = [], centrals = [];
29
+ let offset = 0;
30
+ for (const [name, data] of entries) {
31
+ const nameBuffer = Buffer.from(name, 'utf8');
32
+ const compressed = deflateRawSync(data);
33
+ const crc = crc32(data);
34
+ const local = Buffer.alloc(30);
35
+ local.writeUInt32LE(0x04034b50, 0);
36
+ local.writeUInt16LE(20, 4);
37
+ local.writeUInt16LE(0x0800, 6);
38
+ local.writeUInt16LE(8, 8);
39
+ local.writeUInt32LE(0, 10);
40
+ local.writeUInt32LE(crc >>> 0, 14);
41
+ local.writeUInt32LE(compressed.length, 18);
42
+ local.writeUInt32LE(data.length, 22);
43
+ local.writeUInt16LE(nameBuffer.length, 26);
44
+ local.writeUInt16LE(0, 28);
45
+ const central = Buffer.alloc(46);
46
+ central.writeUInt32LE(0x02014b50, 0);
47
+ central.writeUInt16LE(20, 4);
48
+ central.writeUInt16LE(20, 6);
49
+ central.writeUInt16LE(0x0800, 8);
50
+ central.writeUInt16LE(8, 10);
51
+ central.writeUInt32LE(0, 12);
52
+ central.writeUInt32LE(crc >>> 0, 16);
53
+ central.writeUInt32LE(compressed.length, 20);
54
+ central.writeUInt32LE(data.length, 24);
55
+ central.writeUInt16LE(nameBuffer.length, 28);
56
+ central.writeUInt32LE(offset, 42);
57
+ locals.push(local, nameBuffer, compressed);
58
+ centrals.push(central, nameBuffer);
59
+ offset += local.length + nameBuffer.length + compressed.length;
60
+ }
61
+ const centralSize = centrals.reduce((size, buffer) => size + buffer.length, 0);
62
+ const end = Buffer.alloc(22);
63
+ end.writeUInt32LE(0x06054b50, 0);
64
+ end.writeUInt16LE(entries.length, 8);
65
+ end.writeUInt16LE(entries.length, 10);
66
+ end.writeUInt32LE(centralSize, 12);
67
+ end.writeUInt32LE(offset, 16);
68
+ return Buffer.concat([...locals, ...centrals, end]);
69
+ }
70
+ // ------------------------------------------------------------------ reading
71
+ /** Files of a ZIP archive (stored or deflated entries), read from its central directory. Each entry is limited in size. */
72
+ function unzip(archive, maxEntryBytes) {
73
+ const files = new Map();
74
+ let end = -1;
75
+ for (let i = archive.length - 22; i >= Math.max(0, archive.length - 65557); i--)
76
+ if (archive.readUInt32LE(i) === 0x06054b50) {
77
+ end = i;
78
+ break;
79
+ }
80
+ if (end < 0)
81
+ throw new Error('Not a ZIP archive.');
82
+ const count = archive.readUInt16LE(end + 10);
83
+ let pointer = archive.readUInt32LE(end + 16);
84
+ for (let index = 0; index < count && index < 10_000; index++) {
85
+ if (archive.readUInt32LE(pointer) !== 0x02014b50)
86
+ throw new Error('Damaged ZIP archive.');
87
+ const method = archive.readUInt16LE(pointer + 10), compressedSize = archive.readUInt32LE(pointer + 20), size = archive.readUInt32LE(pointer + 24);
88
+ const nameLength = archive.readUInt16LE(pointer + 28), extraLength = archive.readUInt16LE(pointer + 30), commentLength = archive.readUInt16LE(pointer + 32);
89
+ const localOffset = archive.readUInt32LE(pointer + 42);
90
+ const name = archive.subarray(pointer + 46, pointer + 46 + nameLength).toString('utf8');
91
+ pointer += 46 + nameLength + extraLength + commentLength;
92
+ if (archive.readUInt32LE(localOffset) !== 0x04034b50)
93
+ throw new Error('Damaged ZIP archive.');
94
+ const dataStart = localOffset + 30 + archive.readUInt16LE(localOffset + 26) + archive.readUInt16LE(localOffset + 28);
95
+ const data = archive.subarray(dataStart, dataStart + compressedSize);
96
+ if (size > maxEntryBytes)
97
+ throw new Error('XLSX file too large.');
98
+ if (method === 0)
99
+ files.set(name, Buffer.from(data));
100
+ else if (method === 8)
101
+ files.set(name, inflateRawSync(data, { maxOutputLength: maxEntryBytes }));
102
+ }
103
+ return files;
104
+ }
105
+ const unescapeXml = (text) => text.replace(/&(lt|gt|quot|apos|amp|#\d+|#x[0-9a-f]+);/gi, (_match, entity) => {
106
+ const lower = entity.toLowerCase();
107
+ if (lower === 'lt')
108
+ return '<';
109
+ if (lower === 'gt')
110
+ return '>';
111
+ if (lower === 'quot')
112
+ return '"';
113
+ if (lower === 'apos')
114
+ return "'";
115
+ if (lower === 'amp')
116
+ return '&';
117
+ const code = lower.startsWith('#x') ? parseInt(lower.slice(2), 16) : parseInt(lower.slice(1), 10);
118
+ return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : '';
119
+ });
120
+ /** Text of the `<t>` elements of a shared or inline string (rich text runs joined, phonetic runs skipped). */
121
+ const stringOf = (xmlText) => [...xmlText.replace(/<rPh\b[\s\S]*?<\/rPh>/g, '').matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>|<t(?:\s[^>]*)?\/>/g)].map((match) => unescapeXml(match[1] ?? '')).join('');
122
+ const columnIndex = (reference) => { let index = 0; for (const char of reference.replace(/\d+$/, '').toUpperCase())
123
+ index = index * 26 + (char.charCodeAt(0) - 64); return index - 1; };
124
+ /**
125
+ * Rows of the first worksheet of an XLSX file, as text (numbers as written, booleans as `1`/`0`, formulas as their cached
126
+ * value). Stops after `maxRows` rows. Throws on a file that is not a valid workbook.
127
+ */
128
+ export function readXlsx(archive, maxRows = Infinity, maxEntryBytes = 100 * 1024 * 1024) {
129
+ const files = unzip(archive, maxEntryBytes);
130
+ const text = (name) => files.get(name)?.toString('utf8') ?? null;
131
+ const workbook = text('xl/workbook.xml');
132
+ if (!workbook)
133
+ throw new Error('Not an XLSX workbook.');
134
+ const firstSheet = /<sheet\b[^>]*\br:id="([^"]+)"/.exec(workbook)?.[1];
135
+ const relations = text('xl/_rels/workbook.xml.rels') ?? '';
136
+ // The relationship of the first sheet, whatever the order of its attributes.
137
+ const relation = firstSheet ? [...relations.matchAll(/<Relationship\s([^>]*)>/g)].map((match) => match[1]).find((attributes) => /\bId="([^"]+)"/.exec(attributes)?.[1] === firstSheet) : undefined;
138
+ let target = relation ? /\bTarget="([^"]+)"/.exec(relation)?.[1] ?? null : null;
139
+ target ??= 'worksheets/sheet1.xml';
140
+ const sheetPath = target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`;
141
+ const sheet = text(sheetPath);
142
+ if (!sheet)
143
+ throw new Error('The workbook has no worksheet.');
144
+ const shared = [...(text('xl/sharedStrings.xml') ?? '').matchAll(/<si>([\s\S]*?)<\/si>/g)].map((match) => stringOf(match[1]));
145
+ const rows = [];
146
+ for (const rowMatch of sheet.matchAll(/<row\b[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/g)) {
147
+ if (rows.length >= maxRows)
148
+ break;
149
+ const row = [];
150
+ let next = 0;
151
+ for (const cell of (rowMatch[1] ?? '').matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
152
+ const attributes = cell[1], body = cell[2] ?? '';
153
+ const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
154
+ const index = reference ? columnIndex(reference) : next;
155
+ if (index > 16384)
156
+ continue;
157
+ const type = /\bt="([^"]+)"/.exec(attributes)?.[1] ?? 'n';
158
+ const raw = /<v>([\s\S]*?)<\/v>/.exec(body)?.[1];
159
+ const value = type === 's' ? (shared[Number(raw)] ?? '') : type === 'inlineStr' ? stringOf(body) : type === 'b' ? (raw === '1' ? '1' : '0') : unescapeXml(raw ?? '');
160
+ while (row.length < index)
161
+ row.push('');
162
+ row[index] = value;
163
+ next = index + 1;
164
+ }
165
+ rows.push(row);
166
+ }
167
+ // Trailing empty rows are dropped.
168
+ while (rows.length && rows[rows.length - 1].every((cell) => cell === ''))
169
+ rows.pop();
170
+ return rows;
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/panels",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Admin panels for Node.js: resources, pages, navigation, authentication, tenancy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,14 +43,14 @@
43
43
  "node"
44
44
  ],
45
45
  "dependencies": {
46
- "@navette-js/support": "1.0.0-beta.3",
47
- "@navette-js/schemas": "1.0.0-beta.3",
48
- "@navette-js/forms": "1.0.0-beta.3",
49
- "@navette-js/actions": "1.0.0-beta.3",
50
- "@navette-js/wire": "1.0.0-beta.3",
51
- "@navette-js/tables": "1.0.0-beta.3",
52
- "@navette-js/infolists": "1.0.0-beta.3",
53
- "@navette-js/database": "1.0.0-beta.3",
46
+ "@navette-js/support": "1.0.0-beta.5",
47
+ "@navette-js/schemas": "1.0.0-beta.5",
48
+ "@navette-js/forms": "1.0.0-beta.5",
49
+ "@navette-js/actions": "1.0.0-beta.5",
50
+ "@navette-js/wire": "1.0.0-beta.5",
51
+ "@navette-js/tables": "1.0.0-beta.5",
52
+ "@navette-js/infolists": "1.0.0-beta.5",
53
+ "@navette-js/database": "1.0.0-beta.5",
54
54
  "express": "^5.1.0"
55
55
  },
56
56
  "publishConfig": {
@@ -1,6 +1,6 @@
1
- <%# Tenant menu (no search, even beyond ten tenants) %>
1
+ <%# Tenant menu; with many tenants (or `searchableTenantMenu()`), a search field filters them and the dropdown is not a menu %>
2
2
  <%- renderHook('panels::tenant-menu.before') %>
3
- <div x-data="filamentDropdown({ isMenu: true })" class="fi-dropdown fi-tenant-menu" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><button<% if (isSidebarCollapsibleOnDesktop) { %> x-data="{ tooltip: false }" x-effect="
3
+ <div x-data="filamentDropdown({ isMenu: <%= locals.isSearchable ? 'false' : 'true' %> })" class="fi-dropdown fi-tenant-menu" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><button<% if (isSidebarCollapsibleOnDesktop) { %> x-data="{ tooltip: false }" x-effect="
4
4
  tooltip = $store.sidebar.isOpen
5
5
  ? false
6
6
  : {
@@ -9,5 +9,5 @@
9
9
  placement: document.dir === 'rtl' ? 'left' : 'right',
10
10
  theme: $store.theme,
11
11
  }
12
- " x-tooltip="tooltip"<% } %> type="button" class="fi-tenant-menu-trigger" ><img alt="<%= t('filament-panels::layout.avatar.alt', { name: tenant.name }) %>" class="fi-avatar fi-size-md fi-tenant-avatar" src="<%= tenant.avatarUrl %>" loading="lazy" /><span<% if (isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-tenant-menu-trigger-text" ><% if (tenant.label) { %><span class="fi-tenant-menu-trigger-current-tenant-label"> <%= tenant.label %> </span><% } %><span class="fi-tenant-menu-trigger-tenant-name"> <%= tenant.name %> </span></span><%- icon('m-chevron-down', isSidebarCollapsibleOnDesktop ? { attributes: attrs({ 'x-show': '$store.sidebar.isOpen' }) } : {}) %></button></div><div x-cloak x-float.placement.bottom-start.size.flip<% if (teleport) { %>.teleport<% } %>.offset="{ offset: 8, size: {&quot;padding&quot;:16} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel fi-scrollable" style="" ><% if (itemsBefore.length) { %><div class="fi-dropdown-list"><%- itemsBefore.join('') %></div><% } %><% if (switchable.length) { %><div x-data="filamentTenantMenu"><div class="fi-dropdown-list"><% for (const other of switchable) { %><div class="fi-tenant-menu-item" x-show="matchesSearch(<%= js(other.name) %>)"><a href="<%= other.url %>" class="fi-dropdown-list-item" role="menuitem" tabindex="-1"><div class="fi-dropdown-list-item-image" style="background-image: url('<%= other.avatarUrl %>')"></div><span class="fi-dropdown-list-item-label"><%= other.name %></span></a></div><% } %></div></div><% } %><% if (itemsAfter.length) { %><div class="fi-dropdown-list"><%- itemsAfter.join('') %></div><% } %></div></div>
12
+ " x-tooltip="tooltip"<% } %> type="button" class="fi-tenant-menu-trigger" ><img alt="<%= t('filament-panels::layout.avatar.alt', { name: tenant.name }) %>" class="fi-avatar fi-size-md fi-tenant-avatar" src="<%= tenant.avatarUrl %>" loading="lazy" /><span<% if (isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-tenant-menu-trigger-text" ><% if (tenant.label) { %><span class="fi-tenant-menu-trigger-current-tenant-label"> <%= tenant.label %> </span><% } %><span class="fi-tenant-menu-trigger-tenant-name"> <%= tenant.name %> </span></span><%- icon('m-chevron-down', isSidebarCollapsibleOnDesktop ? { attributes: attrs({ 'x-show': '$store.sidebar.isOpen' }) } : {}) %></button></div><div x-cloak x-float.placement.bottom-start.size.flip<% if (teleport) { %>.teleport<% } %>.offset="{ offset: 8, size: {&quot;padding&quot;:16} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0"<% if (!locals.isSearchable) { %> role="menu"<% } %> class="fi-dropdown-panel fi-scrollable" style="" ><% if (itemsBefore.length) { %><div class="fi-dropdown-list"><%- itemsBefore.join('') %></div><% } %><% if (switchable.length) { %><div x-data="filamentTenantMenu"><div class="fi-dropdown-list"><% if (locals.isSearchable) { %><div x-id="['input']"><label x-bind:for="$id('input')" class="fi-sr-only"><%= t('filament-panels::layout.tenant_menu.search_field.label') %></label><input x-bind:id="$id('input')" x-model="search" placeholder="<%= t('filament-panels::layout.tenant_menu.search_field.placeholder') %>" type="search" class="fi-input" /></div><% } %><% for (const other of switchable) { %><div class="fi-tenant-menu-item" x-show="matchesSearch(<%= js(other.name) %>)"><a href="<%= other.url %>" class="fi-dropdown-list-item"<% if (!locals.isSearchable) { %> role="menuitem" tabindex="-1"<% } %>><div class="fi-dropdown-list-item-image" style="background-image: url('<%= other.avatarUrl %>')"></div><span class="fi-dropdown-list-item-label"><%= other.name %></span></a></div><% } %></div></div><% } %><% if (itemsAfter.length) { %><div class="fi-dropdown-list"><%- itemsAfter.join('') %></div><% } %></div></div>
13
13
  <%- renderHook('panels::tenant-menu.after') %>
@@ -31,13 +31,13 @@
31
31
  <% } %>
32
32
  <% if (before.length) { %> <div class="fi-dropdown-list"><%- before.join('') %></div>
33
33
  <% } %>
34
- <div class="fi-dropdown-list">
34
+ <% if (locals.hasThemeSwitcher !== false) { %> <div class="fi-dropdown-list">
35
35
  <div x-data="{ theme: null }" x-init="
36
36
  $watch('theme', () => {
37
37
  $dispatch('theme-changed', theme)
38
38
  })
39
39
 
40
- theme = localStorage.getItem('filament:theme:mode') || 'system' " role="group" aria-label="<%= t('filament-panels::layout.actions.theme_switcher.label') %>" class="fi-theme-switcher">
40
+ theme = localStorage.getItem('filament:theme:mode') || <%- js(locals.defaultThemeMode ?? 'system') %> " role="group" aria-label="<%= t('filament-panels::layout.actions.theme_switcher.label') %>" class="fi-theme-switcher">
41
41
  <% for (const [theme, iconName] of [['light', 'm-sun'], ['dark', 'm-moon'], ['system', 'm-computer-desktop']]) { %>
42
42
  <% const label = t('filament-panels::layout.actions.theme_switcher.' + theme + '.label') %>
43
43
  <button aria-label="<%= label %>" role="menuitemradio" tabindex="-1" type="button" x-on:click="(theme = <%- js(theme) %>) && close()" x-tooltip="{
@@ -49,6 +49,7 @@
49
49
  <% } %>
50
50
  </div>
51
51
  </div>
52
+ <% } %>
52
53
 
53
54
  <% if (locals.themes && themes.length > 1) { %>
54
55
  <%# Choice of one of the panel's predefined themes (Panel.themes()). %>
@@ -0,0 +1,87 @@
1
+ <%# Error page (404, 403, 500…): a standalone document, so that it renders even when components cannot %>
2
+ <!DOCTYPE html>
3
+ <html lang="<%= locale %>" dir="ltr" class="fi">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="robots" content="noindex" />
8
+ <% if (locals.favicon) { %><link rel="icon" href="<%= favicon %>" /><% } %>
9
+
10
+ <title>
11
+ <%= title %>
12
+ -
13
+ <%= brandName %>
14
+ </title>
15
+
16
+ <link href="<%- assetUrl('fonts/filament/filament/inter/index.css') %>" rel="stylesheet" />
17
+
18
+ <%- colorVariables %>
19
+
20
+ <link rel="stylesheet" href="<%- locals.theme ? theme : assetUrl('css/filament/filament/app.css') %>" />
21
+
22
+ <% if (font.url) { %><link href="<%= font.url %>" rel="stylesheet" /><% } %>
23
+
24
+ <style>
25
+ :root {
26
+ --font-family: '<%- font.family %>';
27
+ --font-family-mono: 'ui-monospace';
28
+ --font-family-serif: 'ui-serif';
29
+ --default-theme-mode: system;
30
+ }
31
+ <%- css %>
32
+ </style>
33
+
34
+ <% if (locals.darkMode === false) { %><% } else if (locals.darkModeForced) { %>
35
+ <script>
36
+ document.documentElement.classList.add('dark')
37
+ </script>
38
+ <% } else { %>
39
+ <script>
40
+ const theme = localStorage.getItem('filament:theme:mode') ?? <%- js(locals.defaultThemeMode ?? 'system') %>
41
+
42
+ if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
43
+ document.documentElement.classList.add('dark')
44
+ }
45
+ </script>
46
+ <% } %>
47
+
48
+ <% if (locals.themeCss) { %><style data-navette-theme><%- themeCss %></style><% } %>
49
+ </head>
50
+
51
+ <body class="<%- cls(['fi-body', 'fi-panel-' + panelId, 'fi-error-body', 'fi-error-' + status]) %>">
52
+ <div class="fi-simple-layout">
53
+ <div class="fi-simple-main-ctn">
54
+ <main id="fi-main-content" class="fi-simple-main fi-width-lg fi-error-page">
55
+ <div class="fi-error-illustration">
56
+ <%- illustration %>
57
+ </div>
58
+
59
+ <div class="fi-error-badge-ctn">
60
+ <%- badge %>
61
+ </div>
62
+
63
+ <header class="fi-simple-header">
64
+ <h1 class="fi-simple-header-heading">
65
+ <%= heading %>
66
+ </h1>
67
+
68
+ <p class="fi-simple-header-subheading">
69
+ <%= description %>
70
+ </p>
71
+ </header>
72
+
73
+ <div class="fi-error-actions">
74
+ <%- actions.join('') %>
75
+ </div>
76
+
77
+ <% if (locals.details) { %>
78
+ <details class="fi-error-details">
79
+ <summary><%= t('filament-panels::errors.details') %></summary>
80
+ <pre><%= details %></pre>
81
+ </details>
82
+ <% } %>
83
+ </main>
84
+ </div>
85
+ </div>
86
+ </body>
87
+ </html>
@@ -68,10 +68,15 @@
68
68
  </style>
69
69
 
70
70
  <%- renderHook('panels::styles.after', renderHookScopes) %>
71
+ <% if (locals.darkMode === false) { %><% } else if (locals.darkModeForced) { %>
72
+ <script>
73
+ document.documentElement.classList.add('dark')
74
+ </script>
75
+ <% } else { %>
71
76
  <script>
72
77
  const loadDarkMode = () => {
73
78
  window.theme =
74
- localStorage.getItem('filament:theme:mode') ?? 'system'
79
+ localStorage.getItem('filament:theme:mode') ?? <%- js(locals.defaultThemeMode ?? 'system') %>
75
80
 
76
81
  if (
77
82
  window.theme === 'dark' ||
@@ -87,11 +92,13 @@
87
92
 
88
93
  document.addEventListener('livewire:navigated', loadDarkMode)
89
94
  </script>
95
+ <% } %>
90
96
 
91
97
  <% if (locals.themeCss) { %><style data-navette-theme><%- themeCss %></style><% } %>
92
98
 
93
99
  <% if (locals.motion) { %><link rel="stylesheet" href="<%- assetUrl('navette/motion.css') %>" data-navigate-track /><% } %>
94
100
  <link rel="stylesheet" href="<%- assetUrl('navette/calendar.css') %>" data-navigate-track />
101
+ <% if (locals.securityKeys) { %><link rel="stylesheet" href="<%- assetUrl('navette/security-keys.css') %>" data-navigate-track /><% } %>
95
102
 
96
103
  <%- wireStyles %>
97
104
  <%- renderHook('panels::head.end', renderHookScopes) %>
@@ -121,6 +128,7 @@
121
128
  loadDarkMode()
122
129
  </script>
123
130
 
131
+ <% if (locals.securityKeys) { %><script src="<%- assetUrl('navette/security-keys.js') %>" data-navigate-once></script><% } %>
124
132
  <%- wireScripts %>
125
133
  <%- renderHook('panels::scripts.after', renderHookScopes) %>
126
134
  <%- renderHook('panels::body.end', renderHookScopes) %>
@@ -1,5 +1,33 @@
1
1
  <%# Sidebar: navigation groups and items %>
2
2
  <% const collapsible = isSidebarCollapsibleOnDesktop || isSidebarFullyCollapsibleOnDesktop; %>
3
+ <%# One item, with its child items below it when it has no URL, is active, or one of them is. %>
4
+ <% const sidebarItem = (item, { grouped, subGrouped = false, first, last }) => { %>
5
+ <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.isActive, 'fi-sidebar-item-has-active-child-items': item.activeChildItems, 'fi-sidebar-item-has-url': Boolean(item.url) }]) %>">
6
+ <a<% if (item.url) { %> href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% } %><% if (item.isActive) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
7
+ <% if (item.icon && (!subGrouped || isSidebarCollapsibleOnDesktop)) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon', 'x-show': subGrouped ? '! $store.sidebar.isOpen' : false }) }) %><% } %>
8
+ <% if ((!item.icon && grouped) || subGrouped) { %>
9
+ <div<% if (item.icon && subGrouped && isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-sidebar-item-grouped-border">
10
+ <% if (!first) { %><div class="fi-sidebar-item-grouped-border-part-not-first"></div><% } %>
11
+ <% if (!last) { %><div class="fi-sidebar-item-grouped-border-part-not-last"></div><% } %>
12
+ <div class="fi-sidebar-item-grouped-border-part"></div>
13
+ </div>
14
+ <% } %>
15
+ <span class="fi-sidebar-item-label">
16
+ <%= item.label %>
17
+ </span>
18
+ <% if (item.badge !== null) { %>
19
+ <span class="fi-sidebar-item-badge-ctn">
20
+ <%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %>
21
+ </span>
22
+ <% } %>
23
+ </a>
24
+ <% if (item.childItems?.length && (!item.url || item.isActive || item.activeChildItems)) { %>
25
+ <ul class="fi-sidebar-sub-group-items">
26
+ <% item.childItems.forEach((child, index) => sidebarItem(child, { grouped: true, subGrouped: true, first: index === 0, last: index === item.childItems.length - 1 })) %>
27
+ </ul>
28
+ <% } %>
29
+ </li>
30
+ <% } %>
3
31
  <div>
4
32
  <div x-data="filamentSidebar({
5
33
  hasTopNavigation: <%= hasTopNavigation ? 'true' : 'false' %>,
@@ -42,34 +70,21 @@
42
70
  <%- renderHook('panels::sidebar.nav.start') %>
43
71
  <ul class="fi-sidebar-nav-groups">
44
72
  <% for (const group of groups) { %>
45
- <li x-data="{ label: <%- js(group.label ?? '') %> }" data-group-label="<%= group.label ?? '' %>" x-bind:class="{ 'fi-collapsed': $store.sidebar.groupIsCollapsed(label) }" class="<%- cls(['fi-sidebar-group', { 'fi-active': group.isActive }, 'fi-collapsible']) %>">
73
+ <li x-data="{ label: <%- js(group.label ?? '') %> }" data-group-label="<%= group.label ?? '' %>" x-bind:class="{ 'fi-collapsed': $store.sidebar.groupIsCollapsed(label) }" class="<%- cls(['fi-sidebar-group', { 'fi-active': group.isActive, 'fi-collapsible': group.collapsible !== false }]) %>">
46
74
  <% if (group.label) { %>
47
- <div x-on:click="$store.sidebar.toggleCollapsedGroup(label)" class="fi-sidebar-group-btn">
75
+ <div<% if (group.collapsible !== false) { %> x-on:click="$store.sidebar.toggleCollapsedGroup(label)"<% } %> class="fi-sidebar-group-btn">
76
+ <% if (group.icon) { %><%- icon(group.icon, { size: 'lg' }) %><% } %>
48
77
  <span class="fi-sidebar-group-label">
49
78
  <%= group.label %>
50
79
  </span>
51
- <%- view('filament::components.icon-button', {
80
+ <% if (group.collapsible !== false) { %><%- view('filament::components.icon-button', {
52
81
  iconName: 'm-chevron-up', label: group.label, className: 'fi-sidebar-group-collapse-btn',
53
82
  extra: attrs({ 'aria-controls': group.itemsId, 'x-bind:aria-expanded': '! $store.sidebar.groupIsCollapsed(label)', 'x-on:click.stop': '$store.sidebar.toggleCollapsedGroup(label)' }),
54
- }) %>
83
+ }) %><% } %>
55
84
  </div>
56
85
  <% } %>
57
- <ul<% if (group.label) { %> id="<%= group.itemsId %>" x-show="! $store.sidebar.groupIsCollapsed(label)" x-collapse.duration.200ms<% } %> class="fi-sidebar-group-items">
58
- <% for (const item of group.items) { %>
59
- <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.isActive }, 'fi-sidebar-item-has-url']) %>">
60
- <a href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% if (item.isActive) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
61
- <% if (item.icon) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon' }) }) %><% } %>
62
- <span class="fi-sidebar-item-label">
63
- <%= item.label %>
64
- </span>
65
- <% if (item.badge !== null) { %>
66
- <span class="fi-sidebar-item-badge-ctn">
67
- <%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %>
68
- </span>
69
- <% } %>
70
- </a>
71
- </li>
72
- <% } %>
86
+ <ul<% if (group.label) { %> id="<%= group.itemsId %>"<% if (group.collapsible !== false) { %> x-show="! $store.sidebar.groupIsCollapsed(label)" x-collapse.duration.200ms<% } %><% } %> class="fi-sidebar-group-items">
87
+ <% group.items.forEach((item, index) => sidebarItem(item, { grouped: Boolean(group.label), first: index === 0, last: index === group.items.length - 1 })) %>
73
88
  </ul>
74
89
  </li>
75
90
  <% } %>
@@ -29,7 +29,7 @@
29
29
  <ul class="fi-topbar-nav-groups">
30
30
  <% for (const group of topNavigation) { %>
31
31
  <% if (group.label) { %>
32
- <div x-data="filamentDropdown({ isMenu: true })" class="fi-dropdown" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><li class="<%- cls(['fi-topbar-item', { 'fi-active': group.isActive }]) %>"><button type="button" class="fi-topbar-item-btn"><span class="fi-topbar-item-label"> <%= group.label %> </span><%- icon('m-chevron-down', { attributes: attrs({ class: 'fi-topbar-group-toggle-icon' }) }) %></button></li></div><div x-cloak x-float.placement.bottom-start.flip.teleport.offset="{ offset: 8, }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel " style="" ><div class="fi-dropdown-list"><% for (const item of group.items) { %><a href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% if (item.isActive) { %> aria-current="page"<% } %> class="<%- cls(['fi-dropdown-list-item', ...item.colorClasses]) %>" role="menuitem" tabindex="-1"><% if (item.icon) { %><%- icon(item.icon, item.isActive ? { attributes: attrs({ class: 'fi-color fi-color-primary' }) } : {}) %><% } %><span class="fi-dropdown-list-item-label"><%= item.label %></span><% if (item.badge !== null) { %><%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %><% } %></a><% } %></div></div></div>
32
+ <div x-data="filamentDropdown({ isMenu: true })" class="fi-dropdown" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><li class="<%- cls(['fi-topbar-item', { 'fi-active': group.isActive }]) %>"><button type="button" class="fi-topbar-item-btn"><span class="fi-topbar-item-label"> <%= group.label %> </span><%- icon('m-chevron-down', { attributes: attrs({ class: 'fi-topbar-group-toggle-icon' }) }) %></button></li></div><div x-cloak x-float.placement.bottom-start.flip.teleport.offset="{ offset: 8, }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel " style="" ><% for (const list of group.lists) { %><div class="fi-dropdown-list"><% for (const item of list) { %><a href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% if (item.isActive) { %> aria-current="page"<% } %> class="<%- cls(['fi-dropdown-list-item', ...item.colorClasses]) %>" role="menuitem" tabindex="-1"><% if (item.icon) { %><%- icon(item.icon, item.isActive ? { attributes: attrs({ class: 'fi-color fi-color-primary' }) } : {}) %><% } %><span class="fi-dropdown-list-item-label"><%= item.label %></span><% if (item.badge !== null) { %><%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %><% } %></a><% } %></div><% } %></div></div>
33
33
  <% } else { %>
34
34
  <% for (const item of group.items) { %><li class="<%- cls(['fi-topbar-item', { 'fi-active': item.isActive }]) %>"><a href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% if (item.isActive) { %> aria-current="page"<% } %> class="fi-topbar-item-btn"><% if (item.icon) { %><%- icon(item.icon, { attributes: attrs({ class: 'fi-topbar-item-icon' }) }) %><% } %><span class="fi-topbar-item-label"> <%= item.label %> </span><% if (item.badge !== null) { %><%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %><% } %></a></li><% } %>
35
35
  <% } %>
@@ -1,6 +1,48 @@
1
1
  <%# Panel page: header, breadcrumbs, sub-navigation and content %>
2
2
  <% const beforeHeaderActions = renderHook('panels::page.header.actions.before', renderHookScopes), afterHeaderActions = renderHook('panels::page.header.actions.after', renderHookScopes); %>
3
3
  <% const subNavigation = locals.subNavigation ?? []; const activeSubItem = subNavigation.find((item) => item.active) ?? subNavigation[0]; const subNavigationPosition = locals.subNavigationPosition ?? 'top'; %>
4
+ <%# Sub-navigation groups: items without a group first, then each group in order of appearance. %>
5
+ <% const subNavigationGroups = []; for (const item of subNavigation) { const label = item.group || null; let group = subNavigationGroups.find((candidate) => candidate.label === label); if (!group) { group = { label, items: [] }; if (label === null) subNavigationGroups.unshift(group); else subNavigationGroups.push(group); } group.items.push(item); } %>
6
+ <% const subNavigationBadge = (item, wrap) => { if (item.badge === null || item.badge === undefined || item.badge === '') return; %><% if (wrap) { %><span class="fi-sidebar-item-badge-ctn"><% } %><%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses ?? [] }) %><% if (wrap) { %></span><% } %><% } %>
7
+ <% const subNavigationSidebar = () => { %>
8
+ <ul class="fi-page-sub-navigation-sidebar">
9
+ <% for (const group of subNavigationGroups) { %>
10
+ <li x-data="{ label: <% if (group.label) { %><%- js('sub_navigation_' + group.label) %><% } else { %>'sub_navigation_'<% } %> }" data-group-label="sub_navigation_<%= group.label ?? '' %>" x-bind:class="{ 'fi-collapsed': $store.sidebar.groupIsCollapsed(label) }" class="<%- cls(['fi-sidebar-group', { 'fi-active': group.items.some((item) => item.active) }, 'fi-collapsible']) %>">
11
+ <% if (group.label) { %>
12
+ <div x-on:click="$store.sidebar.toggleCollapsedGroup(label)" class="fi-sidebar-group-btn">
13
+ <span class="fi-sidebar-group-label">
14
+ <%= group.label %>
15
+ </span>
16
+ <%- view('filament::components.icon-button', {
17
+ iconName: 'm-chevron-up', label: group.label, className: 'fi-sidebar-group-collapse-btn',
18
+ extra: attrs({ 'x-bind:aria-expanded': '! $store.sidebar.groupIsCollapsed(label)', 'x-on:click.stop': '$store.sidebar.toggleCollapsedGroup(label)' }),
19
+ }) %>
20
+ </div>
21
+ <% } %>
22
+ <ul<% if (group.label) { %> x-show="! $store.sidebar.groupIsCollapsed(label)" x-collapse.duration.200ms<% } %> class="fi-sidebar-group-items">
23
+ <% group.items.forEach((item, index) => { %>
24
+ <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.active }, 'fi-sidebar-item-has-url']) %>">
25
+ <a href="<%= item.url %>" wire:navigate<% if (item.active) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
26
+ <% if (item.icon) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon' }) }) %><% } %>
27
+ <% if (!item.icon && group.label) { %>
28
+ <div class="fi-sidebar-item-grouped-border">
29
+ <% if (index > 0) { %><div class="fi-sidebar-item-grouped-border-part-not-first"></div><% } %>
30
+ <% if (index < group.items.length - 1) { %><div class="fi-sidebar-item-grouped-border-part-not-last"></div><% } %>
31
+ <div class="fi-sidebar-item-grouped-border-part"></div>
32
+ </div>
33
+ <% } %>
34
+ <span class="fi-sidebar-item-label">
35
+ <%= item.label %>
36
+ </span>
37
+ <% subNavigationBadge(item, true) %>
38
+ </a>
39
+ </li>
40
+ <% }) %>
41
+ </ul>
42
+ </li>
43
+ <% } %>
44
+ </ul>
45
+ <% } %>
4
46
  <div class="<%- cls(['fi-page', subNavigation.length ? `fi-page-has-sub-navigation fi-page-has-sub-navigation-${subNavigationPosition}` : null, ...(locals.pageClasses ?? [])]) %>">
5
47
  <%- renderHook('panels::page.start', renderHookScopes) %>
6
48
  <div class="fi-page-header-main-ctn">
@@ -14,7 +56,7 @@
14
56
  <div x-cloak x-float.placement.bottom-start.flip.offset="{ offset: 8, }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel fi-width-xs" style="">
15
57
  <div class="fi-dropdown-list">
16
58
  <% for (const item of subNavigation) { %>
17
- <a href="<%= item.url %>" wire:navigate class="fi-dropdown-list-item" role="menuitem" tabindex="-1"<% if (item.active) { %> aria-current="page"<% } %>><% if (item.icon) { %><%- icon(item.icon) %><% } %><span class="fi-dropdown-list-item-label"><%= item.label %></span></a>
59
+ <a href="<%= item.url %>" wire:navigate class="fi-dropdown-list-item" role="menuitem" tabindex="-1"<% if (item.active) { %> aria-current="page"<% } %>><% if (item.icon) { %><%- icon(item.icon) %><% } %><span class="fi-dropdown-list-item-label"><%= item.label %></span><% subNavigationBadge(item, false) %></a>
18
60
  <% } %>
19
61
  </div>
20
62
  </div>
@@ -73,22 +115,7 @@
73
115
  <%- renderHook('panels::page.sub-navigation.start.before', renderHookScopes) %>
74
116
  <div class="fi-page-sub-navigation-sidebar-ctn">
75
117
  <%- renderHook('panels::page.sub-navigation.sidebar.before', renderHookScopes) %>
76
- <ul class="fi-page-sub-navigation-sidebar">
77
- <li x-data="{ label: 'sub_navigation_' }" data-group-label="sub_navigation_" x-bind:class="{ 'fi-collapsed': $store.sidebar.groupIsCollapsed(label) }" class="<%- cls(['fi-sidebar-group', { 'fi-active': subNavigation.some((item) => item.active) }, 'fi-collapsible']) %>">
78
- <ul class="fi-sidebar-group-items">
79
- <% for (const item of subNavigation) { %>
80
- <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.active }, 'fi-sidebar-item-has-url']) %>">
81
- <a href="<%= item.url %>" wire:navigate<% if (item.active) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
82
- <% if (item.icon) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon' }) }) %><% } %>
83
- <span class="fi-sidebar-item-label">
84
- <%= item.label %>
85
- </span>
86
- </a>
87
- </li>
88
- <% } %>
89
- </ul>
90
- </li>
91
- </ul>
118
+ <% subNavigationSidebar() %>
92
119
  <%- renderHook('panels::page.sub-navigation.sidebar.after', renderHookScopes) %>
93
120
  </div>
94
121
  <%- renderHook('panels::page.sub-navigation.start.after', renderHookScopes) %>
@@ -96,8 +123,14 @@
96
123
  <% if (subNavigation.length && subNavigationPosition === 'top') { %>
97
124
  <%- renderHook('panels::page.sub-navigation.top.before', renderHookScopes) %>
98
125
  <nav class="fi-tabs fi-page-sub-navigation-tabs" aria-label="<%= t('filament::components/tabs.label') %>">
99
- <% for (const item of subNavigation) { %>
100
- <a href="<%= item.url %>" wire:navigate class="<%- cls(['fi-tabs-item', { 'fi-active': item.active }]) %>"<% if (item.active) { %> aria-current="page"<% } %>><% if (item.icon) { %><%- icon(item.icon) %><% } %><span class="fi-tabs-item-label"><%= item.label %></span></a>
126
+ <% for (const group of subNavigationGroups) { %>
127
+ <% if (group.label) { %>
128
+ <div x-data="filamentDropdown({ isMenu: true })" class="fi-dropdown"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger"><button type="button" class="<%- cls(['fi-tabs-item', { 'fi-active': group.items.some((item) => item.active) }]) %>"><span class="fi-tabs-item-label"><%= group.label %></span></button></div><div x-cloak x-float.placement.bottom-start.flip.offset="{ offset: 8, }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel" style=""><div class="fi-dropdown-list"><% for (const item of group.items) { %><a href="<%= item.url %>" wire:navigate class="fi-dropdown-list-item" role="menuitem" tabindex="-1"<% if (item.active) { %> aria-current="page"<% } %>><% if (item.icon) { %><%- icon(item.icon) %><% } %><span class="fi-dropdown-list-item-label"><%= item.label %></span><% subNavigationBadge(item, false) %></a><% } %></div></div></div>
129
+ <% } else { %>
130
+ <% for (const item of group.items) { %>
131
+ <a href="<%= item.url %>" wire:navigate class="<%- cls(['fi-tabs-item', { 'fi-active': item.active }]) %>"<% if (item.active) { %> aria-current="page"<% } %>><% if (item.icon) { %><%- icon(item.icon) %><% } %><span class="fi-tabs-item-label"><%= item.label %></span><% subNavigationBadge(item, false) %></a>
132
+ <% } %>
133
+ <% } %>
101
134
  <% } %>
102
135
  </nav>
103
136
  <%- renderHook('panels::page.sub-navigation.top.after', renderHookScopes) %>
@@ -115,22 +148,7 @@
115
148
  <%- renderHook('panels::page.sub-navigation.end.before', renderHookScopes) %>
116
149
  <div class="fi-page-sub-navigation-sidebar-ctn">
117
150
  <%- renderHook('panels::page.sub-navigation.sidebar.before', renderHookScopes) %>
118
- <ul class="fi-page-sub-navigation-sidebar">
119
- <li x-data="{ label: 'sub_navigation_' }" data-group-label="sub_navigation_" x-bind:class="{ 'fi-collapsed': $store.sidebar.groupIsCollapsed(label) }" class="<%- cls(['fi-sidebar-group', { 'fi-active': subNavigation.some((item) => item.active) }, 'fi-collapsible']) %>">
120
- <ul class="fi-sidebar-group-items">
121
- <% for (const item of subNavigation) { %>
122
- <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.active }, 'fi-sidebar-item-has-url']) %>">
123
- <a href="<%= item.url %>" wire:navigate<% if (item.active) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
124
- <% if (item.icon) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon' }) }) %><% } %>
125
- <span class="fi-sidebar-item-label">
126
- <%= item.label %>
127
- </span>
128
- </a>
129
- </li>
130
- <% } %>
131
- </ul>
132
- </li>
133
- </ul>
151
+ <% subNavigationSidebar() %>
134
152
  <%- renderHook('panels::page.sub-navigation.sidebar.after', renderHookScopes) %>
135
153
  </div>
136
154
  <%- renderHook('panels::page.sub-navigation.end.after', renderHookScopes) %>