@fleetbase/ember-ui 0.3.21 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }