@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.
- 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 +1 -24
- 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,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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{{! Layout::Header::SmartNavMenu
|
|
2
|
+
─────────────────────────────────────────────────────────────────────────
|
|
3
|
+
Smart, overflow-aware extension navigation bar.
|
|
4
|
+
Renders up to `@maxVisible` (default 5) extension items inline. Any
|
|
5
|
+
additional items – or items that do not fit the available width – are
|
|
6
|
+
collected inside a "More" dropdown. A gear icon opens the customiser
|
|
7
|
+
panel where users can choose which extensions are pinned to the bar.
|
|
8
|
+
|
|
9
|
+
The overflow dropdown is rendered via EmberWormhole into
|
|
10
|
+
#application-root-wormhole so it escapes the 57px header height
|
|
11
|
+
constraint and uses position:fixed for correct screen placement.
|
|
12
|
+
─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
}}
|
|
14
|
+
<div
|
|
15
|
+
class="snm-container flex items-center"
|
|
16
|
+
role="menubar"
|
|
17
|
+
aria-label="Extension navigation"
|
|
18
|
+
{{did-insert this.setupContainer}}
|
|
19
|
+
...attributes
|
|
20
|
+
>
|
|
21
|
+
{{! ── Visible (pinned) items ─────────────────────────────────────────── }}
|
|
22
|
+
{{#each this.visibleItems as |menuItem|}}
|
|
23
|
+
<Layout::Header::SmartNavMenu::Item @item={{menuItem}} />
|
|
24
|
+
{{/each}}
|
|
25
|
+
|
|
26
|
+
{{! ── "More" overflow button ─────────────────────────────────────────── }}
|
|
27
|
+
{{#if this.hasOverflow}}
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="snm-more-btn next-view-header-item {{if this.isMoreOpen 'is-open'}}"
|
|
31
|
+
aria-haspopup="true"
|
|
32
|
+
aria-expanded={{if this.isMoreOpen "true" "false"}}
|
|
33
|
+
aria-label="More extensions"
|
|
34
|
+
title="More extensions"
|
|
35
|
+
{{did-insert this.registerMoreBtn}}
|
|
36
|
+
{{on "click" this.toggleMore}}
|
|
37
|
+
>
|
|
38
|
+
<FaIcon @icon="ellipsis" @size="sm" />
|
|
39
|
+
</button>
|
|
40
|
+
{{/if}}
|
|
41
|
+
|
|
42
|
+
{{! ── Customise button (always visible when there are items) ─────────── }}
|
|
43
|
+
{{#if this.allItems.length}}
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
class="snm-customise-btn next-view-header-item ml-0.5"
|
|
47
|
+
title="Customise navigation"
|
|
48
|
+
aria-label="Customise navigation"
|
|
49
|
+
{{on "click" this.openCustomizer}}
|
|
50
|
+
>
|
|
51
|
+
<FaIcon @icon="sliders" @size="sm" />
|
|
52
|
+
</button>
|
|
53
|
+
{{/if}}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{{! ── Overflow dropdown – rendered via wormhole to escape the 57px header ── }}
|
|
57
|
+
{{#if this.isMoreOpen}}
|
|
58
|
+
<EmberWormhole @to="application-root-wormhole">
|
|
59
|
+
<Layout::Header::SmartNavMenu::Dropdown
|
|
60
|
+
@items={{this.allItems}}
|
|
61
|
+
@top={{this.dropdownTop}}
|
|
62
|
+
@left={{this.dropdownLeft}}
|
|
63
|
+
@onClose={{this.closeMore}}
|
|
64
|
+
@onOpenCustomizer={{this.openCustomizer}}
|
|
65
|
+
@onQuickPin={{this.quickPin}}
|
|
66
|
+
@atPinnedLimit={{this.atPinnedLimit}}
|
|
67
|
+
/>
|
|
68
|
+
</EmberWormhole>
|
|
69
|
+
{{/if}}
|
|
70
|
+
|
|
71
|
+
{{! ── Customiser panel ────────────────────────────────────────────────── }}
|
|
72
|
+
{{#if this.isCustomizerOpen}}
|
|
73
|
+
<Layout::Header::SmartNavMenu::Customizer
|
|
74
|
+
@allItems={{this.allItems}}
|
|
75
|
+
@pinnedIds={{this.pinnedIds}}
|
|
76
|
+
@maxVisible={{this.maxVisible}}
|
|
77
|
+
@onApply={{this.applyCustomization}}
|
|
78
|
+
@onClose={{this.closeCustomizer}}
|
|
79
|
+
@onReorder={{this.reorderPinned}}
|
|
80
|
+
/>
|
|
81
|
+
{{/if}}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { action } from '@ember/object';
|
|
5
|
+
import { later, scheduleOnce } from '@ember/runloop';
|
|
6
|
+
import { A } from '@ember/array';
|
|
7
|
+
import { bind } from '@ember/runloop';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default maximum number of extensions that may be pinned to the header bar
|
|
11
|
+
* before the overflow dropdown is activated.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_MAX_VISIBLE = 5;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* localStorage key suffix used when persisting per-user navigation preferences.
|
|
17
|
+
* The full key is prefixed with the user ID by the `currentUser` service.
|
|
18
|
+
*/
|
|
19
|
+
const NAV_PREFS_KEY = 'smart-nav-menu-prefs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `Layout::Header::SmartNavMenu`
|
|
23
|
+
*
|
|
24
|
+
* A smart, self-managing extension navigation component that replaces the
|
|
25
|
+
* static `next-catalog-menu-items` div in `<Layout::Header />`.
|
|
26
|
+
*
|
|
27
|
+
* ## Features
|
|
28
|
+
* - **Reactive items** – reads directly from `universe.headerMenuItems` via a
|
|
29
|
+
* getter so the component automatically re-renders whenever a new extension
|
|
30
|
+
* registers its menu item (no manual event wiring needed).
|
|
31
|
+
* - **Priority+ overflow** – items that do not fit the available header width
|
|
32
|
+
* are automatically moved into a "More" dropdown. A `ResizeObserver` on the
|
|
33
|
+
* host container triggers re-evaluation whenever the viewport changes.
|
|
34
|
+
* - **Hard cap** – by default no more than `DEFAULT_MAX_VISIBLE` extensions
|
|
35
|
+
* are ever shown in the bar; the rest always live in the dropdown.
|
|
36
|
+
* - **User customisation** – a gear-icon customiser panel lets users choose
|
|
37
|
+
* which extensions are pinned to the bar and drag-reorder them.
|
|
38
|
+
* - **Persistence** – preferences are written to `localStorage` via the
|
|
39
|
+
* `currentUser` service's `setOption` / `getOption` helpers so they survive
|
|
40
|
+
* page refreshes and are scoped per user.
|
|
41
|
+
*
|
|
42
|
+
* @class LayoutHeaderSmartNavMenuComponent
|
|
43
|
+
* @extends Component
|
|
44
|
+
*/
|
|
45
|
+
export default class LayoutHeaderSmartNavMenuComponent extends Component {
|
|
46
|
+
@service universe;
|
|
47
|
+
@service currentUser;
|
|
48
|
+
@service abilities;
|
|
49
|
+
@service router;
|
|
50
|
+
@service hostRouter;
|
|
51
|
+
|
|
52
|
+
// ─── Tracked state ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ordered list of item IDs the user has explicitly pinned to the bar.
|
|
56
|
+
* `null` means "no preference saved yet" – fall back to default ordering.
|
|
57
|
+
*/
|
|
58
|
+
@tracked pinnedIds = null;
|
|
59
|
+
|
|
60
|
+
/** Items currently rendered in the visible bar (respects cap + width). */
|
|
61
|
+
@tracked visibleItems = A([]);
|
|
62
|
+
|
|
63
|
+
/** Items that have been pushed into the overflow "More" dropdown. */
|
|
64
|
+
@tracked overflowItems = A([]);
|
|
65
|
+
|
|
66
|
+
/** Controls visibility of the "More" dropdown. */
|
|
67
|
+
@tracked isMoreOpen = false;
|
|
68
|
+
|
|
69
|
+
/** Controls visibility of the customiser panel. */
|
|
70
|
+
@tracked isCustomizerOpen = false;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fixed-position coordinates for the overflow dropdown panel.
|
|
74
|
+
* Calculated from the "More" button's getBoundingClientRect() when opened.
|
|
75
|
+
* The dropdown is rendered via EmberWormhole into #application-root-wormhole
|
|
76
|
+
* so it escapes the 57px header height constraint entirely.
|
|
77
|
+
*/
|
|
78
|
+
@tracked dropdownTop = 0;
|
|
79
|
+
@tracked dropdownLeft = 0;
|
|
80
|
+
|
|
81
|
+
// ─── Private internals ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** Reference to the flex container element observed by ResizeObserver. */
|
|
84
|
+
_containerEl = null;
|
|
85
|
+
|
|
86
|
+
/** Active ResizeObserver instance. */
|
|
87
|
+
_resizeObserver = null;
|
|
88
|
+
|
|
89
|
+
/** Reference to the "More" button element for position calculation. */
|
|
90
|
+
_moreBtnEl = null;
|
|
91
|
+
|
|
92
|
+
/** Bound outside-click handler for cleanup. */
|
|
93
|
+
_outsideClickHandler = null;
|
|
94
|
+
|
|
95
|
+
/** Bound routeDidChange handler for cleanup. */
|
|
96
|
+
_routeDidChangeHandler = null;
|
|
97
|
+
|
|
98
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
constructor(owner, args) {
|
|
101
|
+
super(owner, args);
|
|
102
|
+
this._loadPreferences();
|
|
103
|
+
// Listen for new menu items being registered after boot so we
|
|
104
|
+
// re-distribute items without requiring a full re-render.
|
|
105
|
+
try {
|
|
106
|
+
this.universe.menuService.on('menuItem.registered', this._onMenuItemRegistered);
|
|
107
|
+
} catch (_) {
|
|
108
|
+
// Non-fatal – service may not be available in all environments.
|
|
109
|
+
}
|
|
110
|
+
// Close the overflow dropdown automatically after any route transition so
|
|
111
|
+
// we never need to attach a click handler to <LinkToExternal /> elements
|
|
112
|
+
// (which would destroy the element mid-transition and cause a page reload).
|
|
113
|
+
this._routeDidChangeHandler = () => {
|
|
114
|
+
this.isMoreOpen = false;
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
this._getRouter().on('routeDidChange', this._routeDidChangeHandler);
|
|
118
|
+
} catch (_) {
|
|
119
|
+
// Non-fatal – router may not be available in test environments.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
willDestroy() {
|
|
124
|
+
super.willDestroy(...arguments);
|
|
125
|
+
this._teardownObserver();
|
|
126
|
+
this._unregisterMoreBtn();
|
|
127
|
+
// Clean up the universe event listener.
|
|
128
|
+
try {
|
|
129
|
+
this.universe.menuService.off('menuItem.registered', this._onMenuItemRegistered);
|
|
130
|
+
} catch (_) {
|
|
131
|
+
// Non-fatal – service may already be torn down.
|
|
132
|
+
}
|
|
133
|
+
// Clean up the routeDidChange listener.
|
|
134
|
+
try {
|
|
135
|
+
if (this._routeDidChangeHandler) {
|
|
136
|
+
this._getRouter().off('routeDidChange', this._routeDidChangeHandler);
|
|
137
|
+
this._routeDidChangeHandler = null;
|
|
138
|
+
}
|
|
139
|
+
} catch (_) {
|
|
140
|
+
// Non-fatal.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Router helper ────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Returns whichever router service is available, matching mobile-navbar pattern. */
|
|
147
|
+
_getRouter() {
|
|
148
|
+
return this.router ?? this.hostRouter;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Reactive computed properties ─────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* All permission-filtered header menu items sourced from the universe
|
|
155
|
+
* service. Defined as a **getter** (not a @tracked property) so that
|
|
156
|
+
* Glimmer's auto-tracking picks up changes to the underlying
|
|
157
|
+
* `TrackedMap`-backed registry whenever a new extension registers its
|
|
158
|
+
* menu item – no manual event wiring required for the initial render.
|
|
159
|
+
*/
|
|
160
|
+
get allItems() {
|
|
161
|
+
const raw = this.universe.headerMenuItems ?? [];
|
|
162
|
+
const visible = [];
|
|
163
|
+
for (const item of raw) {
|
|
164
|
+
try {
|
|
165
|
+
if (this.abilities.can(`${item.id} see extension`)) {
|
|
166
|
+
visible.push(item);
|
|
167
|
+
}
|
|
168
|
+
} catch (_) {
|
|
169
|
+
// Ability not defined – include the item by default so
|
|
170
|
+
// extensions that haven't registered an ability are still shown.
|
|
171
|
+
visible.push(item);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Apply mutateMenuItems callback if provided.
|
|
175
|
+
if (typeof this.args.mutateMenuItems === 'function') {
|
|
176
|
+
this.args.mutateMenuItems(visible);
|
|
177
|
+
}
|
|
178
|
+
return A(visible);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Maximum number of items that may sit in the bar. Consumers can override
|
|
183
|
+
* via `@maxVisible={{n}}`.
|
|
184
|
+
*/
|
|
185
|
+
get maxVisible() {
|
|
186
|
+
return this.args.maxVisible ?? DEFAULT_MAX_VISIBLE;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* True when the More/Extensions dropdown button should be shown.
|
|
191
|
+
* Always shown when there are any registered items so the panel acts as
|
|
192
|
+
* a permanent app-launcher (not just a pure overflow mechanism).
|
|
193
|
+
*/
|
|
194
|
+
get hasOverflow() {
|
|
195
|
+
return this.allItems.length > 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Setup ────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Called whenever a new menu item is registered with the universe service.
|
|
202
|
+
* Bound arrow function so `this` is preserved when used as an event handler.
|
|
203
|
+
*/
|
|
204
|
+
_onMenuItemRegistered = (_menuItem, registryName) => {
|
|
205
|
+
if (registryName === 'header') {
|
|
206
|
+
scheduleOnce('afterRender', this, this._distributeFromAllItems);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load the user's saved navigation preferences from localStorage.
|
|
212
|
+
* Falls back gracefully when no preferences have been stored yet.
|
|
213
|
+
*/
|
|
214
|
+
_loadPreferences() {
|
|
215
|
+
try {
|
|
216
|
+
const raw = this.currentUser.getOption(NAV_PREFS_KEY);
|
|
217
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw.pinnedIds)) {
|
|
218
|
+
this.pinnedIds = raw.pinnedIds;
|
|
219
|
+
}
|
|
220
|
+
} catch (_) {
|
|
221
|
+
// Preferences unavailable – use defaults.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Persist the current preferences to localStorage via the currentUser service.
|
|
227
|
+
*/
|
|
228
|
+
_savePreferences() {
|
|
229
|
+
try {
|
|
230
|
+
this.currentUser.setOption(NAV_PREFS_KEY, {
|
|
231
|
+
pinnedIds: this.pinnedIds ?? [],
|
|
232
|
+
});
|
|
233
|
+
} catch (_) {
|
|
234
|
+
// Non-fatal – silently ignore storage errors.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Distribute `allItems` into `visibleItems` (bar) and `overflowItems` (dropdown).
|
|
240
|
+
*
|
|
241
|
+
* Key behaviour:
|
|
242
|
+
* - When the user has explicitly saved a pinned list, ONLY those pinned
|
|
243
|
+
* items appear in the bar (in saved order). Everything else goes to
|
|
244
|
+
* overflow regardless of `maxVisible`. The cap still applies as an
|
|
245
|
+
* upper bound in case the user somehow saved more than `maxVisible` IDs.
|
|
246
|
+
* - When no preference has been saved yet (`pinnedIds` is null/empty),
|
|
247
|
+
* the first `maxVisible` items from the universe registry are shown in
|
|
248
|
+
* the bar by default, and the rest go to overflow.
|
|
249
|
+
*/
|
|
250
|
+
_distributeFromAllItems() {
|
|
251
|
+
const { pinnedIds, allItems, maxVisible } = this;
|
|
252
|
+
|
|
253
|
+
if (!pinnedIds || pinnedIds.length === 0) {
|
|
254
|
+
// No saved preference – show first `maxVisible` items by default.
|
|
255
|
+
this.visibleItems = A(allItems.slice(0, maxVisible));
|
|
256
|
+
this.overflowItems = A(allItems.slice(maxVisible));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// User has an explicit pinned list.
|
|
261
|
+
// Build the pinned array in the user's saved order (skip stale IDs).
|
|
262
|
+
const pinned = [];
|
|
263
|
+
for (const id of pinnedIds) {
|
|
264
|
+
const item = allItems.find((i) => i.id === id);
|
|
265
|
+
if (item) pinned.push(item);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Respect the hard cap (in case maxVisible was reduced after saving).
|
|
269
|
+
const barItems = pinned.slice(0, maxVisible);
|
|
270
|
+
|
|
271
|
+
// Everything not in the bar goes to overflow.
|
|
272
|
+
const barIds = new Set(barItems.map((i) => i.id));
|
|
273
|
+
const overflow = allItems.filter((i) => !barIds.has(i.id));
|
|
274
|
+
|
|
275
|
+
this.visibleItems = A(barItems);
|
|
276
|
+
this.overflowItems = A(overflow);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── ResizeObserver ───────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Called by the `{{did-insert}}` modifier when the container element mounts.
|
|
283
|
+
* Sets up a ResizeObserver so the component can react to width changes and
|
|
284
|
+
* move items in/out of the overflow dropdown dynamically.
|
|
285
|
+
*/
|
|
286
|
+
@action setupContainer(element) {
|
|
287
|
+
this._containerEl = element;
|
|
288
|
+
this._setupObserver(element);
|
|
289
|
+
// Run an initial distribution pass once the DOM has settled.
|
|
290
|
+
scheduleOnce('afterRender', this, this._distributeFromAllItems);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_setupObserver(element) {
|
|
294
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
295
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
296
|
+
// Guard against re-entrancy: if we are already in the middle of a
|
|
297
|
+
// recalculate pass triggered by this same observer, skip.
|
|
298
|
+
if (this._isRecalculating) return;
|
|
299
|
+
scheduleOnce('afterRender', this, this._recalculate);
|
|
300
|
+
});
|
|
301
|
+
this._resizeObserver.observe(element);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_teardownObserver() {
|
|
305
|
+
if (this._resizeObserver) {
|
|
306
|
+
this._resizeObserver.disconnect();
|
|
307
|
+
this._resizeObserver = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Measure the available container width and determine how many items fit
|
|
313
|
+
* without overflowing. Items beyond the hard cap are always in overflow
|
|
314
|
+
* regardless of available space.
|
|
315
|
+
*
|
|
316
|
+
* When the user has an explicit pinned list, only pinned items are
|
|
317
|
+
* candidates for the bar – unpinned items always stay in overflow.
|
|
318
|
+
*/
|
|
319
|
+
_recalculate() {
|
|
320
|
+
const container = this._containerEl;
|
|
321
|
+
if (!container) return;
|
|
322
|
+
// Prevent the ResizeObserver from re-firing while we are mutating the DOM.
|
|
323
|
+
this._isRecalculating = true;
|
|
324
|
+
|
|
325
|
+
const { pinnedIds, allItems, maxVisible } = this;
|
|
326
|
+
|
|
327
|
+
// Determine which items are candidates for the bar.
|
|
328
|
+
let barCandidates;
|
|
329
|
+
let alwaysOverflow;
|
|
330
|
+
|
|
331
|
+
if (pinnedIds && pinnedIds.length > 0) {
|
|
332
|
+
// Only pinned items can appear in the bar.
|
|
333
|
+
const pinned = [];
|
|
334
|
+
for (const id of pinnedIds) {
|
|
335
|
+
const item = allItems.find((i) => i.id === id);
|
|
336
|
+
if (item) pinned.push(item);
|
|
337
|
+
}
|
|
338
|
+
barCandidates = pinned.slice(0, maxVisible);
|
|
339
|
+
const barIds = new Set(barCandidates.map((i) => i.id));
|
|
340
|
+
alwaysOverflow = allItems.filter((i) => !barIds.has(i.id));
|
|
341
|
+
} else {
|
|
342
|
+
barCandidates = allItems.slice(0, maxVisible);
|
|
343
|
+
alwaysOverflow = allItems.slice(maxVisible);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Measure rendered item widths from the DOM.
|
|
347
|
+
const itemEls = Array.from(container.querySelectorAll('.snm-item'));
|
|
348
|
+
|
|
349
|
+
// If no items have rendered yet, fall back to the simple distribution
|
|
350
|
+
// so we don't incorrectly overflow items based on zero-width measurements.
|
|
351
|
+
if (itemEls.length === 0) {
|
|
352
|
+
this._distributeFromAllItems();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const itemWidths = itemEls.map((el) => el.offsetWidth + 8); // 8px gap
|
|
357
|
+
|
|
358
|
+
// Measure available width from the PARENT element (.next-view-header-left),
|
|
359
|
+
// not from the container itself. The container is flex:1 so its offsetWidth
|
|
360
|
+
// shrinks as items are moved to overflow – measuring it creates a
|
|
361
|
+
// chicken-and-egg collapse loop. The parent is stable (flex:1 of the full
|
|
362
|
+
// header) so its width is independent of how many items are visible.
|
|
363
|
+
const parent = container.closest('.next-view-header-left') || container.parentElement;
|
|
364
|
+
const parentWidth = parent ? parent.offsetWidth : container.offsetWidth;
|
|
365
|
+
|
|
366
|
+
// Subtract fixed siblings that are always present in .next-view-header-left:
|
|
367
|
+
// • Logo + margin: ~60px
|
|
368
|
+
// • Sidebar toggle (when visible): ~36px
|
|
369
|
+
// We measure them directly from the DOM so the number stays accurate
|
|
370
|
+
// across different configurations.
|
|
371
|
+
let fixedSiblingsWidth = 0;
|
|
372
|
+
if (parent) {
|
|
373
|
+
for (const child of parent.children) {
|
|
374
|
+
// Skip the snm-container itself – we want sibling widths only.
|
|
375
|
+
if (child === container) continue;
|
|
376
|
+
// Also skip zero-width wormhole targets and hidden elements.
|
|
377
|
+
const w = child.offsetWidth;
|
|
378
|
+
if (w > 0) fixedSiblingsWidth += w + 4; // 4px gap allowance
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Reserve space for the customise button (always rendered inside the container).
|
|
383
|
+
const CUSTOMISE_BTN_WIDTH = 44;
|
|
384
|
+
const availableWidth = parentWidth - fixedSiblingsWidth - CUSTOMISE_BTN_WIDTH;
|
|
385
|
+
|
|
386
|
+
let cumulative = 0;
|
|
387
|
+
let cutoff = 0;
|
|
388
|
+
for (let i = 0; i < barCandidates.length; i++) {
|
|
389
|
+
const w = itemWidths[i] ?? 0;
|
|
390
|
+
// Skip items that haven't painted yet (zero width) to avoid
|
|
391
|
+
// incorrectly cutting them to overflow.
|
|
392
|
+
if (w > 0 && cumulative + w > availableWidth) break;
|
|
393
|
+
cumulative += w;
|
|
394
|
+
cutoff = i + 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// If everything fits (or nothing was measured), show all bar candidates.
|
|
398
|
+
if (cutoff === 0 && barCandidates.length > 0) {
|
|
399
|
+
cutoff = barCandidates.length;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const fitsInBar = barCandidates.slice(0, cutoff);
|
|
403
|
+
const widthOverflow = barCandidates.slice(cutoff);
|
|
404
|
+
|
|
405
|
+
// Only mutate tracked state when the distribution actually changes.
|
|
406
|
+
// This prevents the DOM mutation from triggering the ResizeObserver
|
|
407
|
+
// again, which would cause an infinite flicker loop.
|
|
408
|
+
const newVisibleIds = fitsInBar.map((i) => i.id).join(',');
|
|
409
|
+
const newOverflowIds = [...widthOverflow, ...alwaysOverflow].map((i) => i.id).join(',');
|
|
410
|
+
const curVisibleIds = this.visibleItems.map((i) => i.id).join(',');
|
|
411
|
+
const curOverflowIds = this.overflowItems.map((i) => i.id).join(',');
|
|
412
|
+
if (newVisibleIds !== curVisibleIds || newOverflowIds !== curOverflowIds) {
|
|
413
|
+
this.visibleItems = A(fitsInBar);
|
|
414
|
+
this.overflowItems = A([...widthOverflow, ...alwaysOverflow]);
|
|
415
|
+
}
|
|
416
|
+
this._isRecalculating = false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ─── "More" button registration ───────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Register the "More" button element so we can:
|
|
423
|
+
* 1. Calculate its screen position for the fixed-position dropdown.
|
|
424
|
+
* 2. Detect outside-clicks to close the dropdown.
|
|
425
|
+
*/
|
|
426
|
+
@action registerMoreBtn(element) {
|
|
427
|
+
this._moreBtnEl = element;
|
|
428
|
+
this._outsideClickHandler = bind(this, this._handleOutsideClick);
|
|
429
|
+
document.addEventListener('mousedown', this._outsideClickHandler, true);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Clean up the outside-click listener when the button is destroyed. */
|
|
433
|
+
_unregisterMoreBtn() {
|
|
434
|
+
if (this._outsideClickHandler) {
|
|
435
|
+
document.removeEventListener('mousedown', this._outsideClickHandler, true);
|
|
436
|
+
this._outsideClickHandler = null;
|
|
437
|
+
}
|
|
438
|
+
this._moreBtnEl = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Close the dropdown when a click occurs outside the button and dropdown portal. */
|
|
442
|
+
_handleOutsideClick(event) {
|
|
443
|
+
// Allow clicks inside the wormhole portal (the dropdown itself) to pass through.
|
|
444
|
+
const portal = document.getElementById('application-root-wormhole');
|
|
445
|
+
if (portal && portal.contains(event.target)) return;
|
|
446
|
+
if (this._moreBtnEl && !this._moreBtnEl.contains(event.target)) {
|
|
447
|
+
this.isMoreOpen = false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Calculate the fixed-position coordinates for the dropdown panel
|
|
453
|
+
* based on the "More" button's current screen position.
|
|
454
|
+
*/
|
|
455
|
+
_calculateDropdownPosition() {
|
|
456
|
+
if (!this._moreBtnEl) return;
|
|
457
|
+
const rect = this._moreBtnEl.getBoundingClientRect();
|
|
458
|
+
// Position the dropdown below the button, aligned to its left edge.
|
|
459
|
+
this.dropdownTop = rect.bottom + 6;
|
|
460
|
+
// Ensure the dropdown doesn't overflow the right edge of the viewport.
|
|
461
|
+
// Wide multi-column dropdown (Phase 2: 2 card columns + search bar)
|
|
462
|
+
const dropdownWidth = 680;
|
|
463
|
+
const rightEdge = rect.left + dropdownWidth;
|
|
464
|
+
if (rightEdge > window.innerWidth - 8) {
|
|
465
|
+
this.dropdownLeft = window.innerWidth - dropdownWidth - 8;
|
|
466
|
+
} else {
|
|
467
|
+
this.dropdownLeft = rect.left;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
/** Toggle the "More" overflow dropdown open/closed. */
|
|
474
|
+
@action toggleMore() {
|
|
475
|
+
if (!this.isMoreOpen) {
|
|
476
|
+
// Calculate position before opening so the panel renders in the right place.
|
|
477
|
+
this._calculateDropdownPosition();
|
|
478
|
+
}
|
|
479
|
+
this.isMoreOpen = !this.isMoreOpen;
|
|
480
|
+
if (this.isCustomizerOpen) this.isCustomizerOpen = false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Close the "More" dropdown (called on outside-click or item selection). */
|
|
484
|
+
@action closeMore() {
|
|
485
|
+
this.isMoreOpen = false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Open the customiser panel. */
|
|
489
|
+
@action openCustomizer() {
|
|
490
|
+
this.isMoreOpen = false;
|
|
491
|
+
this.isCustomizerOpen = true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Close the customiser panel without saving. */
|
|
495
|
+
@action closeCustomizer() {
|
|
496
|
+
this.isCustomizerOpen = false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Called by `NavMenuCustomizer` when the user confirms their selection.
|
|
501
|
+
*
|
|
502
|
+
* @param {string[]} orderedIds - Ordered array of pinned item IDs.
|
|
503
|
+
*/
|
|
504
|
+
@action applyCustomization(orderedIds) {
|
|
505
|
+
this.pinnedIds = orderedIds;
|
|
506
|
+
this._savePreferences();
|
|
507
|
+
this._distributeFromAllItems();
|
|
508
|
+
this.isCustomizerOpen = false;
|
|
509
|
+
// Allow the DOM to update then re-measure.
|
|
510
|
+
later(this, this._recalculate, 50);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* True when the bar is at or over the maxVisible cap.
|
|
515
|
+
* Passed to the dropdown so the pin button is disabled when the bar is full.
|
|
516
|
+
*/
|
|
517
|
+
get atPinnedLimit() {
|
|
518
|
+
const pinned = this.pinnedIds ?? [];
|
|
519
|
+
return pinned.length >= this.maxVisible;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Quick-pin an overflow item directly from the dropdown.
|
|
523
|
+
* Only allowed when the bar has capacity (pinnedIds.length < maxVisible).
|
|
524
|
+
* Adds the item's ID to pinnedIds, saves preferences, and re-distributes
|
|
525
|
+
* so the item immediately moves from the overflow list to the bar.
|
|
526
|
+
*
|
|
527
|
+
* @param {Object} menuItem
|
|
528
|
+
*/
|
|
529
|
+
@action quickPin(menuItem) {
|
|
530
|
+
if (this.atPinnedLimit) return; // bar is full
|
|
531
|
+
const currentPinned = this.pinnedIds ? [...this.pinnedIds] : [];
|
|
532
|
+
const id = menuItem.id ?? menuItem.route;
|
|
533
|
+
if (!id || currentPinned.includes(id)) return; // already pinned
|
|
534
|
+
|
|
535
|
+
// Shortcuts are now registered as first-class header menu items at boot
|
|
536
|
+
// time by registerHeaderMenuItem in ember-core, so they are already in
|
|
537
|
+
// allItems – no manual registration needed here.
|
|
538
|
+
|
|
539
|
+
this.pinnedIds = [...currentPinned, id];
|
|
540
|
+
this._savePreferences();
|
|
541
|
+
this._distributeFromAllItems();
|
|
542
|
+
later(this, this._recalculate, 50);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Reorder handler for drag-sort within the customiser.
|
|
546
|
+
* Kept here so the customiser sub-component stays stateless.
|
|
547
|
+
*/
|
|
548
|
+
@action reorderPinned({ sourceList, sourceIndex, targetList, targetIndex }) {
|
|
549
|
+
if (sourceList === targetList && sourceIndex === targetIndex) return;
|
|
550
|
+
const item = sourceList.objectAt(sourceIndex);
|
|
551
|
+
sourceList.removeAt(sourceIndex);
|
|
552
|
+
targetList.insertAt(targetIndex, item);
|
|
553
|
+
}
|
|
554
|
+
}
|