@navette-js/panels 1.0.0-beta.2 → 1.0.0-beta.4

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.
@@ -3,6 +3,13 @@ export type NotificationActionData = {
3
3
  label: string;
4
4
  url: string | null;
5
5
  openUrlInNewTab: boolean;
6
+ close?: boolean;
7
+ dispatch?: {
8
+ event: string;
9
+ data: Record<string, unknown>;
10
+ } | null;
11
+ color?: string | null;
12
+ button?: boolean;
6
13
  };
7
14
  export type NotificationData = {
8
15
  id: string;
@@ -40,11 +47,22 @@ export declare class Notification {
40
47
  iconColor(color: string): this;
41
48
  duration(ms: number): this;
42
49
  persistent(): this;
43
- /** Links shown under the text. Only displayed in the database notifications panel. */
50
+ /**
51
+ * Actions under the text: links (`url`), or buttons that `close` the notification and/or `dispatch` a Livewire event
52
+ * (`{ event: 'undoDelete', data: { id: 5 } }`). `button: true` renders a button instead of a link. Buttons are only shown in
53
+ * flash notifications; the database notifications panel shows the links.
54
+ */
44
55
  actions(actions: {
45
56
  label: string;
46
57
  url?: string | null;
47
58
  openUrlInNewTab?: boolean;
59
+ close?: boolean;
60
+ dispatch?: {
61
+ event: string;
62
+ data?: Record<string, unknown>;
63
+ } | null;
64
+ color?: string | null;
65
+ button?: boolean;
48
66
  }[]): this;
49
67
  getData(): NotificationData;
50
68
  /** Stores the notification for this user (panel bell): `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Does nothing without a store. */
@@ -52,7 +70,7 @@ export declare class Notification {
52
70
  /** Queues the notification for the browser of the current request. */
53
71
  send(): this;
54
72
  }
55
- /** Renders a flash notification (no date, actions or inline mode). Title and body are escaped. */
73
+ /** Renders a flash notification (no date or inline mode). Title, body and action labels are escaped. */
56
74
  export declare function renderNotification(n: NotificationData): string;
57
75
  /** Container component for flash notifications. */
58
76
  export declare class Notifications extends Component {
@@ -53,8 +53,24 @@ export class Notification {
53
53
  iconColor(color) { this.data.iconColor = color; return this; }
54
54
  duration(ms) { this.data.duration = ms; return this; }
55
55
  persistent() { this.data.duration = 'persistent'; return this; }
56
- /** Links shown under the text. Only displayed in the database notifications panel. */
57
- actions(actions) { this.data.actions = actions.slice(0, 10).map((action) => ({ label: String(action.label), url: action.url ?? null, openUrlInNewTab: action.openUrlInNewTab === true })); return this; }
56
+ /**
57
+ * Actions under the text: links (`url`), or buttons that `close` the notification and/or `dispatch` a Livewire event
58
+ * (`{ event: 'undoDelete', data: { id: 5 } }`). `button: true` renders a button instead of a link. Buttons are only shown in
59
+ * flash notifications; the database notifications panel shows the links.
60
+ */
61
+ actions(actions) {
62
+ this.data.actions = actions.slice(0, 10).map((action) => {
63
+ const event = action.dispatch?.event;
64
+ if (event !== undefined && !/^[A-Za-z0-9_.:-]{1,100}$/.test(event))
65
+ throw new Error(`Invalid notification event name: ${event}`);
66
+ return {
67
+ label: String(action.label), url: action.url ?? null, openUrlInNewTab: action.openUrlInNewTab === true, close: action.close === true,
68
+ dispatch: event ? { event, data: action.dispatch?.data ?? {} } : null,
69
+ color: action.color && /^[a-z]+$/.test(action.color) ? action.color : null, button: action.button === true,
70
+ };
71
+ });
72
+ return this;
73
+ }
58
74
  getData() { return { ...this.data }; }
59
75
  /** Stores the notification for this user (panel bell): `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Does nothing without a store. */
60
76
  async sendToDatabase(store, user) { if (store)
@@ -68,7 +84,21 @@ export class Notification {
68
84
  return this;
69
85
  }
70
86
  }
71
- /** Renders a flash notification (no date, actions or inline mode). Title and body are escaped. */
87
+ /** Actions of a notification: links (only local or http(s) URLs) and buttons closing it or dispatching a Livewire event. */
88
+ function notificationActionsHtml(n) {
89
+ const safeUrl = (url) => typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(url);
90
+ const items = (n.actions ?? []).filter((action) => action.label !== '' && (safeUrl(action.url) || action.close || action.dispatch)).map((action) => {
91
+ const color = action.color ?? 'primary';
92
+ const classes = action.button ? `fi-ac-btn-action fi-btn fi-size-sm` : `fi-ac-link-action fi-link fi-size-sm fi-color fi-color-${color} fi-text-color-600 dark:fi-text-color-400`;
93
+ const label = action.button ? e(action.label) : `<span class="fi-link-label">${e(action.label)}</span>`;
94
+ const clicks = [action.dispatch ? `Livewire.dispatch(${js(action.dispatch.event)}, ${js(action.dispatch.data)})` : null, action.close ? 'close()' : null].filter(Boolean).join('; ');
95
+ if (safeUrl(action.url))
96
+ return `<a href="${e(action.url)}"${action.openUrlInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''}${clicks ? ` x-on:click="${e(clicks)}"` : ''} class="${classes}">${label}</a>`;
97
+ return `<button type="button" x-on:click="${e(clicks)}" class="${classes}">${label}</button>`;
98
+ });
99
+ return items.length ? `<div class="fi-ac fi-no-notification-actions">${items.join('')}</div>` : '';
100
+ }
101
+ /** Renders a flash notification (no date or inline mode). Title, body and action labels are escaped. */
72
102
  export function renderNotification(n) {
73
103
  const closeLabel = t('filament-notifications::notification.actions.close.label');
74
104
  const attributes = new Attributes()
@@ -77,7 +107,7 @@ export function renderNotification(n) {
77
107
  const iconName = n.icon ?? (n.status ? STATUS_ICON[n.status] : null);
78
108
  const iconColor = n.iconColor ?? n.status;
79
109
  const iconHtml = iconName ? icon(iconName, { size: 'lg', attributes: new Attributes().class([...(iconColor && iconColor !== 'gray' ? ['fi-color', `fi-color-${iconColor}`] : []), 'fi-no-notification-icon']) }) : '';
80
- return `<div x-data="notificationComponent({ notification: ${e(js({ id: n.id, duration: n.duration }))} })" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" x-on:mouseenter="pauseDuration('hover')" x-on:mouseleave="resumeDuration('hover')" x-on:focusin="handleFocusIn($event)" x-on:focusout="handleFocusOut($event)" x-on:keydown.escape="handleEscape($event)" ${attributes.toHtml().replace(/"if \(\$event\.detail\.id == '([^']*)'\) close\(\)"/, (_m, id) => `"if ($event.detail.id == '${id}') close()"`)}>${iconHtml}<div class="fi-no-notification-main">${n.title !== null || n.body !== null ? `<div class="fi-no-notification-text">${n.title !== null ? `<h3 class="fi-no-notification-title">${e(n.title)}</h3>` : ''}${n.body !== null ? `<div class="fi-no-notification-body">${e(n.body)}</div>` : ''}</div>` : ''}</div><button type="button" x-on:click="dismiss()" aria-label="${e(closeLabel)}" title="${e(closeLabel)}" class="fi-icon-btn fi-no-notification-close-btn">${icon('m-x-mark')}</button></div>`;
110
+ return `<div x-data="notificationComponent({ notification: ${e(js({ id: n.id, duration: n.duration }))} })" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" x-on:mouseenter="pauseDuration('hover')" x-on:mouseleave="resumeDuration('hover')" x-on:focusin="handleFocusIn($event)" x-on:focusout="handleFocusOut($event)" x-on:keydown.escape="handleEscape($event)" ${attributes.toHtml().replace(/"if \(\$event\.detail\.id == '([^']*)'\) close\(\)"/, (_m, id) => `"if ($event.detail.id == '${id}') close()"`)}>${iconHtml}<div class="fi-no-notification-main">${n.title !== null || n.body !== null ? `<div class="fi-no-notification-text">${n.title !== null ? `<h3 class="fi-no-notification-title">${e(n.title)}</h3>` : ''}${n.body !== null ? `<div class="fi-no-notification-body">${e(n.body)}</div>` : ''}</div>` : ''}${notificationActionsHtml(n)}</div><button type="button" x-on:click="dismiss()" aria-label="${e(closeLabel)}" title="${e(closeLabel)}" class="fi-icon-btn fi-no-notification-close-btn">${icon('m-x-mark')}</button></div>`;
81
111
  }
82
112
  /** Container component for flash notifications. */
83
113
  export class Notifications extends Component {
@@ -0,0 +1,21 @@
1
+ /** Storage of small per-session values. Implement it to keep them in a database or a cache shared by several processes. */
2
+ export interface SessionStore {
3
+ get(session: string, key: string): unknown | Promise<unknown>;
4
+ set(session: string, key: string, value: unknown): void | Promise<void>;
5
+ }
6
+ /**
7
+ * In-memory store: values are lost when the process restarts and are not shared between processes.
8
+ * Sessions idle for longer than `ttl` are dropped, and the oldest sessions go first beyond `maxSessions`.
9
+ */
10
+ export declare class MemorySessionStore implements SessionStore {
11
+ private sessions;
12
+ private readonly options;
13
+ constructor(options?: {
14
+ ttl?: number;
15
+ maxSessions?: number;
16
+ maxValueLength?: number;
17
+ });
18
+ private get ttl();
19
+ get(session: string, key: string): unknown;
20
+ set(session: string, key: string, value: unknown): void;
21
+ }
@@ -0,0 +1,35 @@
1
+ // Per-browser session data (persisted table filters, search and sort). The default store keeps it in memory.
2
+ /**
3
+ * In-memory store: values are lost when the process restarts and are not shared between processes.
4
+ * Sessions idle for longer than `ttl` are dropped, and the oldest sessions go first beyond `maxSessions`.
5
+ */
6
+ export class MemorySessionStore {
7
+ sessions = new Map();
8
+ options;
9
+ constructor(options = {}) { this.options = options; }
10
+ get ttl() { return this.options.ttl ?? 12 * 60 * 60 * 1000; }
11
+ get(session, key) {
12
+ const entry = this.sessions.get(session);
13
+ if (!entry || Date.now() - entry.at > this.ttl) {
14
+ this.sessions.delete(session);
15
+ return undefined;
16
+ }
17
+ const raw = entry.values.get(key);
18
+ return raw === undefined ? undefined : JSON.parse(raw);
19
+ }
20
+ set(session, key, value) {
21
+ const raw = JSON.stringify(value ?? null);
22
+ // Values come from the browser: an oversized one is not kept.
23
+ if (raw.length > (this.options.maxValueLength ?? 64 * 1024))
24
+ return;
25
+ const entry = this.sessions.get(session) ?? { at: Date.now(), values: new Map() };
26
+ entry.at = Date.now();
27
+ entry.values.set(key, raw);
28
+ // Most recently used last: re-inserting moves the session to the end.
29
+ this.sessions.delete(session);
30
+ this.sessions.set(session, entry);
31
+ const max = this.options.maxSessions ?? 10000;
32
+ while (this.sessions.size > max)
33
+ this.sessions.delete(this.sessions.keys().next().value);
34
+ }
35
+ }
package/lib/xlsx.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Workbook with a single sheet: the first row is bold (header). */
2
+ export declare function buildXlsx(rows: unknown[][], sheetName?: string): Buffer;
package/lib/xlsx.js ADDED
@@ -0,0 +1,69 @@
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 } 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/panels",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
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.2",
47
- "@navette-js/schemas": "1.0.0-beta.2",
48
- "@navette-js/forms": "1.0.0-beta.2",
49
- "@navette-js/actions": "1.0.0-beta.2",
50
- "@navette-js/wire": "1.0.0-beta.2",
51
- "@navette-js/tables": "1.0.0-beta.2",
52
- "@navette-js/infolists": "1.0.0-beta.2",
53
- "@navette-js/database": "1.0.0-beta.2",
46
+ "@navette-js/support": "1.0.0-beta.4",
47
+ "@navette-js/schemas": "1.0.0-beta.4",
48
+ "@navette-js/forms": "1.0.0-beta.4",
49
+ "@navette-js/actions": "1.0.0-beta.4",
50
+ "@navette-js/wire": "1.0.0-beta.4",
51
+ "@navette-js/tables": "1.0.0-beta.4",
52
+ "@navette-js/infolists": "1.0.0-beta.4",
53
+ "@navette-js/database": "1.0.0-beta.4",
54
54
  "express": "^5.1.0"
55
55
  },
56
56
  "publishConfig": {
@@ -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()). %>
@@ -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,6 +92,7 @@
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
 
@@ -42,19 +42,20 @@
42
42
  <%- renderHook('panels::sidebar.nav.start') %>
43
43
  <ul class="fi-sidebar-nav-groups">
44
44
  <% 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']) %>">
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': group.collapsible !== false }]) %>">
46
46
  <% if (group.label) { %>
47
- <div x-on:click="$store.sidebar.toggleCollapsedGroup(label)" class="fi-sidebar-group-btn">
47
+ <div<% if (group.collapsible !== false) { %> x-on:click="$store.sidebar.toggleCollapsedGroup(label)"<% } %> class="fi-sidebar-group-btn">
48
+ <% if (group.icon) { %><%- icon(group.icon, { size: 'lg' }) %><% } %>
48
49
  <span class="fi-sidebar-group-label">
49
50
  <%= group.label %>
50
51
  </span>
51
- <%- view('filament::components.icon-button', {
52
+ <% if (group.collapsible !== false) { %><%- view('filament::components.icon-button', {
52
53
  iconName: 'm-chevron-up', label: group.label, className: 'fi-sidebar-group-collapse-btn',
53
54
  extra: attrs({ 'aria-controls': group.itemsId, 'x-bind:aria-expanded': '! $store.sidebar.groupIsCollapsed(label)', 'x-on:click.stop': '$store.sidebar.toggleCollapsedGroup(label)' }),
54
- }) %>
55
+ }) %><% } %>
55
56
  </div>
56
57
  <% } %>
57
- <ul<% if (group.label) { %> id="<%= group.itemsId %>" x-show="! $store.sidebar.groupIsCollapsed(label)" x-collapse.duration.200ms<% } %> class="fi-sidebar-group-items">
58
+ <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">
58
59
  <% for (const item of group.items) { %>
59
60
  <li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.isActive }, 'fi-sidebar-item-has-url']) %>">
60
61
  <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">