@fleetbase/ember-ui 0.3.20 → 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.
- 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 +200 -0
- package/addon/components/layout/header/smart-nav-menu/dropdown.js +122 -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 +15 -24
- package/addon/components/phone-input.js +13 -5
- package/addon/styles/addon.css +1 -0
- package/addon/styles/components/smart-nav-menu.css +952 -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,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}}
|