@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.
- package/addon/components/layout/header/smart-nav-menu/customizer.hbs +157 -0
- package/addon/components/layout/header/smart-nav-menu/customizer.js +132 -0
- package/addon/components/layout/header/smart-nav-menu/dropdown.hbs +209 -0
- package/addon/components/layout/header/smart-nav-menu/dropdown.js +75 -0
- package/addon/components/layout/header/smart-nav-menu/item.hbs +49 -0
- package/addon/components/layout/header/smart-nav-menu/item.js +30 -0
- package/addon/components/layout/header/smart-nav-menu.hbs +81 -0
- package/addon/components/layout/header/smart-nav-menu.js +554 -0
- package/addon/components/layout/header.hbs +16 -21
- package/addon/components/layout/header.js +1 -24
- package/addon/styles/addon.css +1 -0
- package/addon/styles/components/smart-nav-menu.css +958 -0
- package/addon/styles/layout/next.css +1 -1
- package/app/components/layout/header/smart-nav-menu/customizer.js +1 -0
- package/app/components/layout/header/smart-nav-menu/dropdown.js +1 -0
- package/app/components/layout/header/smart-nav-menu/item.js +1 -0
- package/app/components/layout/header/smart-nav-menu.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|