@fleetbase/ember-ui 0.3.21 → 0.3.22

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.
@@ -0,0 +1,157 @@
1
+ {{! Layout::Header::SmartNavMenu::Customizer
2
+ ─────────────────────────────────────────────────────────────────────────
3
+ Modal-style panel for customising which extensions are pinned to the
4
+ header bar. Uses a backdrop overlay so it feels like a focused dialog.
5
+ ─────────────────────────────────────────────────────────────────────────
6
+ }}
7
+ <div class="snm-customizer-backdrop" role="presentation" {{on "click" this.cancel}}></div>
8
+
9
+ <div
10
+ class="snm-customizer-panel"
11
+ role="dialog"
12
+ aria-modal="true"
13
+ aria-label="Customise navigation"
14
+ >
15
+ {{! ── Header ──────────────────────────────────────────────────────────── }}
16
+ <div class="snm-customizer-header">
17
+ <div class="snm-customizer-header-left">
18
+ <FaIcon @icon="sliders" @size="sm" class="mr-2 text-blue-400" />
19
+ <h2 class="snm-customizer-title">Customise Navigation</h2>
20
+ </div>
21
+ <button
22
+ type="button"
23
+ class="snm-customizer-close"
24
+ aria-label="Close"
25
+ {{on "click" this.cancel}}
26
+ >
27
+ <FaIcon @icon="xmark" @size="sm" />
28
+ </button>
29
+ </div>
30
+
31
+ {{! ── Body ───────────────────────────────────────────────────────────── }}
32
+ <div class="snm-customizer-body">
33
+ {{! Left column – pinned items (drag-sortable) }}
34
+ <div class="snm-customizer-col snm-customizer-col-pinned">
35
+ <div class="snm-customizer-col-header">
36
+ <span class="snm-customizer-col-title">
37
+ <FaIcon @icon="thumbtack" @size="xs" class="mr-1.5 text-blue-400" />
38
+ Pinned to bar
39
+ </span>
40
+ <span class="snm-customizer-col-badge {{if this.atPinnedLimit 'at-limit'}}">
41
+ {{this.workingPinned.length}} / {{@maxVisible}}
42
+ </span>
43
+ </div>
44
+
45
+ {{#if this.workingPinned.length}}
46
+ <DragSortList
47
+ @items={{this.workingPinned}}
48
+ @dragEndAction={{this.reorderPinned}}
49
+ @group="snm-pinned"
50
+ @dragHandle=".snm-drag-handle"
51
+ class="snm-customizer-drag-list"
52
+ as |item|
53
+ >
54
+ <div class="snm-customizer-pinned-item">
55
+ <span class="snm-drag-handle" title="Drag to reorder">
56
+ <FaIcon @icon="grip-vertical" @size="sm" />
57
+ </span>
58
+ <div class="snm-customizer-item-icon">
59
+ {{#if item.iconComponent}}
60
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
61
+ {{else}}
62
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
63
+ {{/if}}
64
+ </div>
65
+ <span class="snm-customizer-item-title truncate">{{item.title}}</span>
66
+ {{#if item._parentTitle}}
67
+ <span class="snm-customizer-item-parent-label">· {{item._parentTitle}}</span>
68
+ {{/if}}
69
+ <button
70
+ type="button"
71
+ class="snm-customizer-unpin-btn"
72
+ title="Remove from bar"
73
+ {{on "click" (fn this.togglePin item)}}
74
+ >
75
+ <FaIcon @icon="xmark" @size="xs" />
76
+ </button>
77
+ </div>
78
+ </DragSortList>
79
+ {{else}}
80
+ <div class="snm-customizer-empty-state">
81
+ <FaIcon @icon="inbox" @size="lg" class="mb-2 text-gray-500" />
82
+ <p>No extensions pinned yet.</p>
83
+ <p class="text-xs">Add extensions from the list on the right.</p>
84
+ </div>
85
+ {{/if}}
86
+ </div>
87
+
88
+ {{! Divider }}
89
+ <div class="snm-customizer-divider"></div>
90
+
91
+ {{! Right column – all available extensions }}
92
+ <div class="snm-customizer-col snm-customizer-col-all">
93
+ <div class="snm-customizer-col-header">
94
+ <span class="snm-customizer-col-title">
95
+ <FaIcon @icon="grid-2" @size="xs" class="mr-1.5 text-gray-400" />
96
+ All extensions
97
+ </span>
98
+ </div>
99
+
100
+ <div class="snm-customizer-all-list">
101
+ {{#each @allItems as |item|}}
102
+ <button
103
+ type="button"
104
+ class="snm-customizer-all-item {{if (this.isPinned item) 'is-pinned'}} {{if (and this.atPinnedLimit (not (this.isPinned item))) 'is-disabled'}}"
105
+ title={{if (this.isPinned item) "Click to unpin" "Click to pin to bar"}}
106
+ disabled={{and this.atPinnedLimit (not (this.isPinned item))}}
107
+ {{on "click" (fn this.togglePin item)}}
108
+ >
109
+ <div class="snm-customizer-item-icon">
110
+ {{#if item.iconComponent}}
111
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
112
+ {{else}}
113
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
114
+ {{/if}}
115
+ </div>
116
+ <span class="snm-customizer-item-title truncate">{{item.title}}</span>
117
+ {{#if item._parentTitle}}
118
+ <span class="snm-customizer-item-parent-label">· {{item._parentTitle}}</span>
119
+ {{/if}}
120
+ {{#if (this.isPinned item)}}
121
+ <FaIcon @icon="check" @size="xs" class="snm-customizer-pinned-check ml-auto flex-shrink-0" />
122
+ {{/if}}
123
+ </button>
124
+ {{/each}}
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ {{! ── Footer ─────────────────────────────────────────────────────────── }}
130
+ <div class="snm-customizer-footer">
131
+ <button
132
+ type="button"
133
+ class="snm-customizer-reset-btn"
134
+ {{on "click" this.resetToDefault}}
135
+ >
136
+ <FaIcon @icon="rotate-left" @size="xs" class="mr-1.5" />
137
+ Reset to default
138
+ </button>
139
+ <div class="snm-customizer-footer-actions">
140
+ <button
141
+ type="button"
142
+ class="snm-btn snm-btn-secondary"
143
+ {{on "click" this.cancel}}
144
+ >
145
+ Cancel
146
+ </button>
147
+ <button
148
+ type="button"
149
+ class="snm-btn snm-btn-primary"
150
+ {{on "click" this.apply}}
151
+ >
152
+ <FaIcon @icon="check" @size="xs" class="mr-1.5" />
153
+ Apply
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
@@ -0,0 +1,132 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { A } from '@ember/array';
5
+
6
+ /**
7
+ * `Layout::Header::SmartNavMenu::Customizer`
8
+ *
9
+ * A modal-style panel that allows users to:
10
+ * - Select which extensions are pinned to the header bar (up to `@maxVisible`).
11
+ * - Drag-and-drop to reorder pinned extensions.
12
+ * - Preview which items will appear in the bar vs. the overflow dropdown.
13
+ *
14
+ * The component is intentionally stateless with respect to persistence –
15
+ * it delegates saving to the parent `SmartNavMenu` via `@onApply`.
16
+ *
17
+ * @class LayoutHeaderSmartNavMenuCustomizerComponent
18
+ * @extends Component
19
+ */
20
+ export default class LayoutHeaderSmartNavMenuCustomizerComponent extends Component {
21
+ /**
22
+ * Working copy of the pinned items list, mutated locally until the user
23
+ * clicks "Apply". Initialised from `@pinnedIds` (or all items if none
24
+ * have been saved yet).
25
+ *
26
+ * @type {Array<Object>}
27
+ */
28
+ @tracked workingPinned = A([]);
29
+
30
+ constructor(owner, args) {
31
+ super(owner, args);
32
+ this._initWorkingState();
33
+ }
34
+
35
+ // ─── Computed ─────────────────────────────────────────────────────────────
36
+
37
+ /** Items that are NOT in the working pinned list. */
38
+ get unpinnedItems() {
39
+ const pinnedIds = this.workingPinned.map((i) => i.id);
40
+ return (this.args.allItems ?? []).filter((i) => !pinnedIds.includes(i.id));
41
+ }
42
+
43
+ /** True when the user has reached the maximum allowed pinned items. */
44
+ get atPinnedLimit() {
45
+ return this.workingPinned.length >= (this.args.maxVisible ?? 5);
46
+ }
47
+
48
+ // ─── Setup ────────────────────────────────────────────────────────────────
49
+
50
+ _initWorkingState() {
51
+ const { allItems = [], pinnedIds } = this.args;
52
+
53
+ if (pinnedIds && pinnedIds.length > 0) {
54
+ // Restore saved order.
55
+ const ordered = [];
56
+ for (const id of pinnedIds) {
57
+ const item = allItems.find((i) => i.id === id);
58
+ if (item) ordered.push(item);
59
+ }
60
+ this.workingPinned = A(ordered);
61
+ } else {
62
+ // Default: first `maxVisible` items are pinned.
63
+ const cap = this.args.maxVisible ?? 5;
64
+ this.workingPinned = A(allItems.slice(0, cap));
65
+ }
66
+ }
67
+
68
+ // ─── Actions ──────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Toggle an item's pinned state.
72
+ *
73
+ * @param {Object} item
74
+ */
75
+ @action togglePin(item) {
76
+ const idx = this.workingPinned.findIndex((i) => i.id === item.id);
77
+ if (idx >= 0) {
78
+ // Unpin.
79
+ this.workingPinned.removeAt(idx);
80
+ // Trigger reactivity.
81
+ this.workingPinned = A([...this.workingPinned]);
82
+ } else if (!this.atPinnedLimit) {
83
+ // Pin.
84
+ this.workingPinned = A([...this.workingPinned, item]);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Whether a given item is currently in the working pinned list.
90
+ * Decorated with @action so it can be called from the template.
91
+ *
92
+ * @param {Object} item
93
+ * @returns {boolean}
94
+ */
95
+ @action isPinned(item) {
96
+ return this.workingPinned.some((i) => i.id === item.id);
97
+ }
98
+
99
+ /**
100
+ * Drag-sort reorder handler for the pinned items list.
101
+ */
102
+ @action reorderPinned({ sourceList, sourceIndex, targetList, targetIndex }) {
103
+ if (sourceList === targetList && sourceIndex === targetIndex) return;
104
+ const item = sourceList.objectAt(sourceIndex);
105
+ sourceList.removeAt(sourceIndex);
106
+ targetList.insertAt(targetIndex, item);
107
+ this.workingPinned = A([...this.workingPinned]);
108
+ }
109
+
110
+ /**
111
+ * Confirm and apply the customisation.
112
+ */
113
+ @action apply() {
114
+ const orderedIds = this.workingPinned.map((i) => i.id);
115
+ if (typeof this.args.onApply === 'function') {
116
+ this.args.onApply(orderedIds);
117
+ }
118
+ }
119
+
120
+ /** Discard changes and close the panel. */
121
+ @action cancel() {
122
+ if (typeof this.args.onClose === 'function') {
123
+ this.args.onClose();
124
+ }
125
+ }
126
+
127
+ /** Reset to default (first `maxVisible` items in universe order). */
128
+ @action resetToDefault() {
129
+ const cap = this.args.maxVisible ?? 5;
130
+ this.workingPinned = A((this.args.allItems ?? []).slice(0, cap));
131
+ }
132
+ }
@@ -0,0 +1,200 @@
1
+ {{!
2
+ Layout::Header::SmartNavMenu::Dropdown
3
+ Phase 2: multi-column card grid with search filter.
4
+
5
+ Shortcuts are expanded as independent sibling items in the grid (AWS-style).
6
+ The JS getter `expandedItems` interleaves parent MenuItems and their
7
+ shortcut objects so each appears as its own flat card.
8
+
9
+ Args:
10
+ @items - Array of MenuItem objects to display
11
+ @top - Fixed-position top offset (px, from JS)
12
+ @left - Fixed-position left offset (px, from JS)
13
+ @onClose - Action to close this dropdown
14
+ @onOpenCustomizer - Action to open the customiser panel
15
+ @onQuickPin - Action to pin an item directly from the dropdown
16
+ @atPinnedLimit - Boolean: true when the bar is full (pin button hidden)
17
+
18
+ NOTE: Route-based cards use <LinkToExternal> with no click handler.
19
+ The dropdown is closed via the routeDidChange listener in smart-nav-menu.js
20
+ which sets isMoreOpen = false on every route transition.
21
+ }}
22
+ <div
23
+ class="snm-dropdown snm-dropdown--wide"
24
+ style={{this.positionStyle}}
25
+ role="dialog"
26
+ aria-label="More extensions"
27
+ >
28
+ {{! ── Header: title + close ────────────────────────────────────────────── }}
29
+ <div class="snm-dropdown-header">
30
+ <span class="snm-dropdown-title">Extensions</span>
31
+ <button
32
+ type="button"
33
+ class="snm-dropdown-close"
34
+ aria-label="Close"
35
+ {{on "click" @onClose}}
36
+ >
37
+ <FaIcon @icon="xmark" @size="sm" />
38
+ </button>
39
+ </div>
40
+
41
+ {{! ── Search bar (own row) ────────────────────────────────────────────── }}
42
+ <div class="snm-dropdown-search-bar">
43
+ <span class="snm-dropdown-search-icon" aria-hidden="true">
44
+ <FaIcon @icon="magnifying-glass" @size="xs" />
45
+ </span>
46
+ <input
47
+ type="text"
48
+ class="snm-dropdown-search-input"
49
+ placeholder="Search extensions..."
50
+ value={{this.searchQuery}}
51
+ aria-label="Search extensions"
52
+ {{on "input" this.updateSearch}}
53
+ />
54
+ {{#if this.searchQuery}}
55
+ <button
56
+ type="button"
57
+ class="snm-dropdown-search-clear"
58
+ aria-label="Clear search"
59
+ {{on "click" this.clearSearch}}
60
+ >
61
+ <FaIcon @icon="xmark" @size="xs" />
62
+ </button>
63
+ {{/if}}
64
+ </div>
65
+
66
+ {{! ── Card grid ───────────────────────────────────────────────────────── }}
67
+ <div class="snm-dropdown-grid" aria-label="Extension list">
68
+ {{#if this.hasNoResults}}
69
+ <div class="snm-dropdown-empty">
70
+ <FaIcon @icon="magnifying-glass" @size="lg" class="snm-dropdown-empty-icon" />
71
+ <p class="snm-dropdown-empty-text">
72
+ No extensions match
73
+ <strong>{{this.searchQuery}}</strong>
74
+ </p>
75
+ </div>
76
+ {{else}}
77
+ {{#each this.filteredItems as |item|}}
78
+ {{#if item._isShortcut}}
79
+ {{! ── Shortcut sibling card (AWS-style flat item) ─────── }}
80
+ <div class="snm-dropdown-card" role="presentation">
81
+ <div class="snm-dropdown-card-header">
82
+ <LinkToExternal
83
+ @route={{item.route}}
84
+ id={{concat (dasherize (or item.route item.id "sc")) "-dropdown-card"}}
85
+ class="snm-dropdown-card-link"
86
+ title={{item.title}}
87
+ >
88
+ <span class="snm-dropdown-card-icon">
89
+ {{#if item.iconComponent}}
90
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
91
+ {{else}}
92
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
93
+ {{/if}}
94
+ </span>
95
+ {{! Title row: shortcut name + always-visible muted parent attribution }}
96
+ <span class="snm-dropdown-card-title-group">
97
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
98
+ {{#if item._parentTitle}}
99
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
100
+ {{/if}}
101
+ </span>
102
+ </LinkToExternal>
103
+ {{#unless @atPinnedLimit}}
104
+ <button
105
+ type="button"
106
+ class="snm-dropdown-pin-btn"
107
+ title="Pin to navigation bar"
108
+ aria-label="Pin {{item.title}} to navigation bar"
109
+ {{on "click" (fn @onQuickPin item)}}
110
+ >
111
+ <FaIcon @icon="thumbtack" @size="xs" />
112
+ </button>
113
+ {{/unless}}
114
+ </div>
115
+ {{#if item.description}}
116
+ <p class="snm-dropdown-card-description">{{item.description}}</p>
117
+ {{else if item._parentTitle}}
118
+ <p class="snm-dropdown-card-description snm-dropdown-card-description--from"><em>from {{item._parentTitle}}</em></p>
119
+ {{/if}}
120
+ </div>
121
+ {{else}}
122
+ {{! ── Primary extension card ──────────────────────────── }}
123
+ <div class="snm-dropdown-card" role="presentation">
124
+ <div class="snm-dropdown-card-header">
125
+ {{#if item.onClick}}
126
+ <a
127
+ href="javascript:;"
128
+ class="snm-dropdown-card-link"
129
+ title={{item.title}}
130
+ {{on "click" (fn this.handleItemClick item)}}
131
+ >
132
+ <span class="snm-dropdown-card-icon">
133
+ {{#if item.iconComponent}}
134
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
135
+ {{else}}
136
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
137
+ {{/if}}
138
+ </span>
139
+ <span class="snm-dropdown-card-title-group">
140
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
141
+ {{#if item._parentTitle}}
142
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
143
+ {{/if}}
144
+ </span>
145
+ </a>
146
+ {{else}}
147
+ <LinkToExternal
148
+ @route={{item.route}}
149
+ id={{concat (dasherize (or item.route item.id "nav")) "-dropdown-card"}}
150
+ class="snm-dropdown-card-link"
151
+ title={{item.title}}
152
+ >
153
+ <span class="snm-dropdown-card-icon">
154
+ {{#if item.iconComponent}}
155
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
156
+ {{else}}
157
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
158
+ {{/if}}
159
+ </span>
160
+ <span class="snm-dropdown-card-title-group">
161
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
162
+ {{#if item._parentTitle}}
163
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
164
+ {{/if}}
165
+ </span>
166
+ </LinkToExternal>
167
+ {{/if}}
168
+ {{#unless @atPinnedLimit}}
169
+ <button
170
+ type="button"
171
+ class="snm-dropdown-pin-btn"
172
+ title="Pin to navigation bar"
173
+ aria-label="Pin {{item.title}} to navigation bar"
174
+ {{on "click" (fn @onQuickPin item)}}
175
+ >
176
+ <FaIcon @icon="thumbtack" @size="xs" />
177
+ </button>
178
+ {{/unless}}
179
+ </div>
180
+ {{#if item.description}}
181
+ <p class="snm-dropdown-card-description">{{item.description}}</p>
182
+ {{/if}}
183
+ </div>
184
+ {{/if}}
185
+ {{/each}}
186
+ {{/if}}
187
+ </div>
188
+
189
+ {{! ── Footer ──────────────────────────────────────────────────────────── }}
190
+ <div class="snm-dropdown-footer">
191
+ <button
192
+ type="button"
193
+ class="snm-dropdown-customise-link"
194
+ {{on "click" @onOpenCustomizer}}
195
+ >
196
+ <FaIcon @icon="sliders" @size="xs" class="mr-1.5" />
197
+ Customise navigation
198
+ </button>
199
+ </div>
200
+ </div>
@@ -0,0 +1,122 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { dasherize } from '@ember/string';
5
+ import { isArray } from '@ember/array';
6
+ import { htmlSafe } from '@ember/template';
7
+
8
+ /**
9
+ * Layout::Header::SmartNavMenu::Dropdown
10
+ *
11
+ * Phase 2: multi-column card grid + search filter.
12
+ *
13
+ * Shortcuts are expanded as independent sibling items in the grid (AWS-style).
14
+ * Each shortcut is normalised into a flat display item with `_isShortcut: true`
15
+ * so the template can render it with a slightly different visual treatment
16
+ * (muted style, no pin button).
17
+ */
18
+ export default class LayoutHeaderSmartNavMenuDropdownComponent extends Component {
19
+ @tracked searchQuery = '';
20
+
21
+ get positionStyle() {
22
+ const top = this.args.top ?? 0;
23
+ const left = this.args.left ?? 0;
24
+ return htmlSafe('top: ' + top + 'px; left: ' + left + 'px;');
25
+ }
26
+
27
+ /**
28
+ * Expand every MenuItem's shortcuts array into sibling flat items.
29
+ * The resulting array interleaves parent items and their shortcuts in
30
+ * registration order, matching the AWS Console pattern.
31
+ *
32
+ * Each shortcut is normalised to:
33
+ * { title, route, icon, iconPrefix, id, _isShortcut: true, _parentTitle }
34
+ */
35
+ get expandedItems() {
36
+ const items = this.args.items ?? [];
37
+ const result = [];
38
+ for (const item of items) {
39
+ result.push(item);
40
+ if (isArray(item.shortcuts)) {
41
+ for (const sc of item.shortcuts) {
42
+ const scId = sc.id ?? dasherize(item.id + '-sc-' + sc.title);
43
+ result.push({
44
+ // ── Identity ────────────────────────────────────────
45
+ id: scId,
46
+ slug: sc.slug ?? scId,
47
+ title: sc.title,
48
+ text: sc.text ?? sc.title,
49
+ label: sc.label ?? sc.title,
50
+
51
+ // ── Routing ──────────────────────────────────────────
52
+ route: sc.route ?? item.route,
53
+ queryParams: sc.queryParams ?? {},
54
+ routeParams: sc.routeParams ?? [],
55
+
56
+ // ── Icons (full surface) ─────────────────────────────
57
+ icon: sc.icon ?? item.icon ?? 'arrow-right',
58
+ iconPrefix: sc.iconPrefix ?? item.iconPrefix ?? null,
59
+ iconSize: sc.iconSize ?? null,
60
+ iconClass: sc.iconClass ?? null,
61
+ iconComponent: sc.iconComponent ?? null,
62
+ iconComponentOptions: sc.iconComponentOptions ?? {},
63
+
64
+ // ── Metadata ─────────────────────────────────────────
65
+ description: sc.description ?? null,
66
+ tags: isArray(sc.tags) ? sc.tags : isArray(item.tags) ? item.tags : null,
67
+
68
+ // ── Behaviour ────────────────────────────────────────
69
+ onClick: sc.onClick ?? null,
70
+ disabled: sc.disabled ?? false,
71
+
72
+ // ── Styling ───────────────────────────────────────────
73
+ class: sc.class ?? null,
74
+
75
+ // ── Internal flags ────────────────────────────────────
76
+ _isShortcut: true,
77
+ _parentTitle: item.title,
78
+ _parentId: item.id,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+
86
+ get filteredItems() {
87
+ const query = (this.searchQuery || '').trim().toLowerCase();
88
+ if (!query) {
89
+ return this.expandedItems;
90
+ }
91
+ return this.expandedItems.filter((item) => {
92
+ if ((item.title || '').toLowerCase().includes(query)) return true;
93
+ if (item.description && item.description.toLowerCase().includes(query)) return true;
94
+ if (item._parentTitle && item._parentTitle.toLowerCase().includes(query)) return true;
95
+ // Match against any of the item's tags
96
+ if (isArray(item.tags) && item.tags.some((t) => t.toLowerCase().includes(query))) return true;
97
+ return false;
98
+ });
99
+ }
100
+
101
+ get hasNoResults() {
102
+ return this.searchQuery.trim().length > 0 && this.filteredItems.length === 0;
103
+ }
104
+
105
+ @action updateSearch(event) {
106
+ this.searchQuery = event.target.value;
107
+ }
108
+
109
+ @action clearSearch() {
110
+ this.searchQuery = '';
111
+ }
112
+
113
+ @action handleItemClick(menuItem, event) {
114
+ event?.preventDefault();
115
+ if (menuItem && typeof menuItem.onClick === 'function') {
116
+ menuItem.onClick(menuItem);
117
+ }
118
+ if (typeof this.args.onClose === 'function') {
119
+ this.args.onClose();
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,49 @@
1
+ {{! Layout::Header::SmartNavMenu::Item
2
+ ─────────────────────────────────────────────────────────────────────────
3
+ Renders a single extension link in the header bar.
4
+
5
+ If the item defines an `onClick` handler it is used directly (for
6
+ programmatic navigation or custom actions). Otherwise the item renders
7
+ as a `<LinkToExternal />` route link — the standard pattern used by the
8
+ original next-catalog-menu-items implementation.
9
+
10
+ Supports icon components (lazy-loaded engine components) or FontAwesome
11
+ icons, falling back to "circle-dot" if no icon is specified.
12
+ ─────────────────────────────────────────────────────────────────────────
13
+ }}
14
+ {{#if @item.onClick}}
15
+ <a
16
+ href="javascript:;"
17
+ id={{concat (dasherize (or @item.route @item.id "nav")) "-header-button"}}
18
+ role="menuitem"
19
+ class="snm-item next-view-header-item truncate {{@item.class}} {{if this.isActive 'active'}}"
20
+ title={{@item.title}}
21
+ {{on "click" @item.onClick}}
22
+ >
23
+ <div class="w-6 flex-shrink-0">
24
+ {{#if @item.iconComponent}}
25
+ {{component (lazy-engine-component @item.iconComponent) options=@item.iconComponentOptions}}
26
+ {{else}}
27
+ <FaIcon @icon={{or @item.icon "circle-dot"}} @prefix={{@item.iconPrefix}} @size="sm" />
28
+ {{/if}}
29
+ </div>
30
+ <span class="truncate">{{@item.title}}</span>
31
+ </a>
32
+ {{else}}
33
+ <LinkToExternal
34
+ @route={{@item.route}}
35
+ id={{concat (dasherize (or @item.route @item.id "nav")) "-header-button"}}
36
+ class="snm-item next-view-header-item truncate {{@item.class}} {{if this.isActive 'active'}}"
37
+ role="menuitem"
38
+ title={{@item.title}}
39
+ >
40
+ <div class="w-6 flex-shrink-0">
41
+ {{#if @item.iconComponent}}
42
+ {{component (lazy-engine-component @item.iconComponent) options=@item.iconComponentOptions}}
43
+ {{else}}
44
+ <FaIcon @icon={{or @item.icon "circle-dot"}} @prefix={{@item.iconPrefix}} @size="sm" />
45
+ {{/if}}
46
+ </div>
47
+ <span class="truncate">{{@item.title}}</span>
48
+ </LinkToExternal>
49
+ {{/if}}