@fleetbase/ember-ui 0.3.21 → 0.3.23

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,209 @@
1
+ {{!
2
+ Layout::Header::SmartNavMenu::Dropdown
3
+ Phase 2: multi-column card grid with search filter.
4
+
5
+ Shortcuts are already registered as first-class items in the universe
6
+ registry by menu-service.registerHeaderMenuItem(), so @items already
7
+ contains both parent extension items and their shortcut siblings.
8
+ No client-side expansion is needed.
9
+
10
+ Args:
11
+ @items - Array of MenuItem objects to display
12
+ @top - Fixed-position top offset (px, from JS)
13
+ @left - Fixed-position left offset (px, from JS)
14
+ @onClose - Action to close this dropdown
15
+ @onOpenCustomizer - Action to open the customiser panel
16
+ @onQuickPin - Action to pin an item directly from the dropdown
17
+ @atPinnedLimit - Boolean: true when the bar is full (pin button hidden)
18
+
19
+ Click-area pattern:
20
+ Each card is wrapped in a `snm-dropdown-card-wrap` div (position: relative).
21
+ The card itself is the <LinkToExternal> (or <a> for onClick items), making
22
+ the entire card surface clickable. The pin button is a sibling of the card
23
+ inside the wrapper, absolutely positioned in the top-right corner so it
24
+ sits above the card link and receives its own clicks without violating the
25
+ no-nested-interactive rule.
26
+ }}
27
+ <div
28
+ class="snm-dropdown snm-dropdown--wide"
29
+ style={{this.positionStyle}}
30
+ role="dialog"
31
+ aria-label="More extensions"
32
+ >
33
+ {{! ── Header: title + close ────────────────────────────────────────────── }}
34
+ <div class="snm-dropdown-header">
35
+ <span class="snm-dropdown-title">Extensions</span>
36
+ <button
37
+ type="button"
38
+ class="snm-dropdown-close"
39
+ aria-label="Close"
40
+ {{on "click" @onClose}}
41
+ >
42
+ <FaIcon @icon="xmark" @size="sm" />
43
+ </button>
44
+ </div>
45
+
46
+ {{! ── Search bar ──────────────────────────────────────────────────────── }}
47
+ <div class="snm-dropdown-search-bar">
48
+ <span class="snm-dropdown-search-icon" aria-hidden="true">
49
+ <FaIcon @icon="magnifying-glass" @size="xs" />
50
+ </span>
51
+ <input
52
+ type="text"
53
+ class="snm-dropdown-search-input"
54
+ placeholder="Search extensions..."
55
+ value={{this.searchQuery}}
56
+ aria-label="Search extensions"
57
+ {{on "input" this.updateSearch}}
58
+ />
59
+ {{#if this.searchQuery}}
60
+ <button
61
+ type="button"
62
+ class="snm-dropdown-search-clear"
63
+ aria-label="Clear search"
64
+ {{on "click" this.clearSearch}}
65
+ >
66
+ <FaIcon @icon="xmark" @size="xs" />
67
+ </button>
68
+ {{/if}}
69
+ </div>
70
+
71
+ {{! ── Card grid ───────────────────────────────────────────────────────── }}
72
+ <div class="snm-dropdown-grid" aria-label="Extension list">
73
+ {{#if this.hasNoResults}}
74
+ <div class="snm-dropdown-empty">
75
+ <FaIcon @icon="magnifying-glass" @size="lg" class="snm-dropdown-empty-icon" />
76
+ <p class="snm-dropdown-empty-text">
77
+ No extensions match
78
+ <strong>{{this.searchQuery}}</strong>
79
+ </p>
80
+ </div>
81
+ {{else}}
82
+ {{#each this.filteredItems as |item|}}
83
+ {{#if item._isShortcut}}
84
+ {{! ── Shortcut card ───────────────────────────────────── }}
85
+ <div class="snm-dropdown-card-wrap">
86
+ <LinkToExternal
87
+ @route={{item.route}}
88
+ id={{concat (dasherize (or item.route item.id "sc")) "-dropdown-card"}}
89
+ class="snm-dropdown-card"
90
+ title={{item.title}}
91
+ >
92
+ <div class="snm-dropdown-card-header">
93
+ <span class="snm-dropdown-card-icon">
94
+ {{#if item.iconComponent}}
95
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
96
+ {{else}}
97
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
98
+ {{/if}}
99
+ </span>
100
+ <span class="snm-dropdown-card-title-group">
101
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
102
+ {{#if item._parentTitle}}
103
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
104
+ {{/if}}
105
+ </span>
106
+ </div>
107
+ {{#if item.description}}
108
+ <p class="snm-dropdown-card-description">{{item.description}}</p>
109
+ {{else if item._parentTitle}}
110
+ <p class="snm-dropdown-card-description snm-dropdown-card-description--from"><em>from {{item._parentTitle}}</em></p>
111
+ {{/if}}
112
+ </LinkToExternal>
113
+ {{#unless @atPinnedLimit}}
114
+ <button
115
+ type="button"
116
+ class="snm-dropdown-pin-btn"
117
+ title="Pin to navigation bar"
118
+ aria-label="Pin {{item.title}} to navigation bar"
119
+ {{on "click" (fn @onQuickPin item)}}
120
+ >
121
+ <FaIcon @icon="thumbtack" @size="xs" />
122
+ </button>
123
+ {{/unless}}
124
+ </div>
125
+ {{else}}
126
+ {{! ── Primary extension card ──────────────────────────── }}
127
+ <div class="snm-dropdown-card-wrap">
128
+ {{#if item.onClick}}
129
+ <a
130
+ href="javascript:;"
131
+ class="snm-dropdown-card"
132
+ title={{item.title}}
133
+ {{on "click" (fn this.handleItemClick item)}}
134
+ >
135
+ <div class="snm-dropdown-card-header">
136
+ <span class="snm-dropdown-card-icon">
137
+ {{#if item.iconComponent}}
138
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
139
+ {{else}}
140
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
141
+ {{/if}}
142
+ </span>
143
+ <span class="snm-dropdown-card-title-group">
144
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
145
+ {{#if item._parentTitle}}
146
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
147
+ {{/if}}
148
+ </span>
149
+ </div>
150
+ {{#if item.description}}
151
+ <p class="snm-dropdown-card-description">{{item.description}}</p>
152
+ {{/if}}
153
+ </a>
154
+ {{else}}
155
+ <LinkToExternal
156
+ @route={{item.route}}
157
+ id={{concat (dasherize (or item.route item.id "nav")) "-dropdown-card"}}
158
+ class="snm-dropdown-card"
159
+ title={{item.title}}
160
+ >
161
+ <div class="snm-dropdown-card-header">
162
+ <span class="snm-dropdown-card-icon">
163
+ {{#if item.iconComponent}}
164
+ {{component (lazy-engine-component item.iconComponent) options=item.iconComponentOptions}}
165
+ {{else}}
166
+ <FaIcon @icon={{or item.icon "circle-dot"}} @prefix={{item.iconPrefix}} @size="sm" />
167
+ {{/if}}
168
+ </span>
169
+ <span class="snm-dropdown-card-title-group">
170
+ <span class="snm-dropdown-card-title">{{item.title}}</span>
171
+ {{#if item._parentTitle}}
172
+ <span class="snm-dropdown-card-parent-label" aria-label="from {{item._parentTitle}}">· {{item._parentTitle}}</span>
173
+ {{/if}}
174
+ </span>
175
+ </div>
176
+ {{#if item.description}}
177
+ <p class="snm-dropdown-card-description">{{item.description}}</p>
178
+ {{/if}}
179
+ </LinkToExternal>
180
+ {{/if}}
181
+ {{#unless @atPinnedLimit}}
182
+ <button
183
+ type="button"
184
+ class="snm-dropdown-pin-btn"
185
+ title="Pin to navigation bar"
186
+ aria-label="Pin {{item.title}} to navigation bar"
187
+ {{on "click" (fn @onQuickPin item)}}
188
+ >
189
+ <FaIcon @icon="thumbtack" @size="xs" />
190
+ </button>
191
+ {{/unless}}
192
+ </div>
193
+ {{/if}}
194
+ {{/each}}
195
+ {{/if}}
196
+ </div>
197
+
198
+ {{! ── Footer ──────────────────────────────────────────────────────────── }}
199
+ <div class="snm-dropdown-footer">
200
+ <button
201
+ type="button"
202
+ class="snm-dropdown-customise-link"
203
+ {{on "click" @onOpenCustomizer}}
204
+ >
205
+ <FaIcon @icon="sliders" @size="xs" class="mr-1.5" />
206
+ Customise navigation
207
+ </button>
208
+ </div>
209
+ </div>
@@ -0,0 +1,75 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { isArray } from '@ember/array';
5
+ import { htmlSafe } from '@ember/template';
6
+
7
+ /**
8
+ * Layout::Header::SmartNavMenu::Dropdown
9
+ *
10
+ * Phase 2: multi-column card grid + search filter.
11
+ *
12
+ * Shortcuts are expanded as independent sibling items in the grid (AWS-style).
13
+ * Each shortcut is normalised into a flat display item with `_isShortcut: true`
14
+ * so the template can render it with a slightly different visual treatment
15
+ * (muted style, no pin button).
16
+ */
17
+ export default class LayoutHeaderSmartNavMenuDropdownComponent extends Component {
18
+ @tracked searchQuery = '';
19
+
20
+ get positionStyle() {
21
+ const top = this.args.top ?? 0;
22
+ const left = this.args.left ?? 0;
23
+ return htmlSafe('top: ' + top + 'px; left: ' + left + 'px;');
24
+ }
25
+
26
+ /**
27
+ * Returns the items array as-is for filtering.
28
+ *
29
+ * Shortcuts are already registered as first-class items in the universe
30
+ * registry (with `_isShortcut: true` and `_parentTitle` set) by
31
+ * `menu-service.registerHeaderMenuItem()` at boot time. There is no need
32
+ * to expand `item.shortcuts` here — doing so would produce a duplicate card
33
+ * for every shortcut (one from the registry, one from the expansion).
34
+ */
35
+ get expandedItems() {
36
+ return this.args.items ?? [];
37
+ }
38
+
39
+ get filteredItems() {
40
+ const query = (this.searchQuery || '').trim().toLowerCase();
41
+ if (!query) {
42
+ return this.expandedItems;
43
+ }
44
+ return this.expandedItems.filter((item) => {
45
+ if ((item.title || '').toLowerCase().includes(query)) return true;
46
+ if (item.description && item.description.toLowerCase().includes(query)) return true;
47
+ if (item._parentTitle && item._parentTitle.toLowerCase().includes(query)) return true;
48
+ // Match against any of the item's tags
49
+ if (isArray(item.tags) && item.tags.some((t) => t.toLowerCase().includes(query))) return true;
50
+ return false;
51
+ });
52
+ }
53
+
54
+ get hasNoResults() {
55
+ return this.searchQuery.trim().length > 0 && this.filteredItems.length === 0;
56
+ }
57
+
58
+ @action updateSearch(event) {
59
+ this.searchQuery = event.target.value;
60
+ }
61
+
62
+ @action clearSearch() {
63
+ this.searchQuery = '';
64
+ }
65
+
66
+ @action handleItemClick(menuItem, event) {
67
+ event?.preventDefault();
68
+ if (menuItem && typeof menuItem.onClick === 'function') {
69
+ menuItem.onClick(menuItem);
70
+ }
71
+ if (typeof this.args.onClose === 'function') {
72
+ this.args.onClose();
73
+ }
74
+ }
75
+ }
@@ -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}}
@@ -0,0 +1,30 @@
1
+ import Component from '@glimmer/component';
2
+ import { inject as service } from '@ember/service';
3
+
4
+ /**
5
+ * `Layout::Header::SmartNavMenu::Item`
6
+ *
7
+ * Renders a single extension navigation link inside the SmartNavMenu bar
8
+ * using `<LinkToExternal />`, matching the original next-catalog-menu-items
9
+ * implementation exactly.
10
+ *
11
+ * @class LayoutHeaderSmartNavMenuItemComponent
12
+ * @extends Component
13
+ */
14
+ export default class LayoutHeaderSmartNavMenuItemComponent extends Component {
15
+ @service router;
16
+ @service hostRouter;
17
+
18
+ /**
19
+ * Whether the item's route matches the current route, making it "active".
20
+ * Defined as a native getter so Glimmer's auto-tracking picks up router
21
+ * changes without needing the classic `@computed` decorator.
22
+ */
23
+ get isActive() {
24
+ const route = this.args.item?.route;
25
+ if (!route) return false;
26
+ const r = this.router ?? this.hostRouter;
27
+ const current = r?.currentRouteName ?? '';
28
+ return current.startsWith(route);
29
+ }
30
+ }