@ds-mo/ui 2.2.1 → 2.4.0

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.
Files changed (46) hide show
  1. package/dist/.build-stamp +1 -1
  2. package/dist/components/ds-app-shell.js +1 -1
  3. package/dist/components/ds-app-shell.js.map +1 -1
  4. package/dist/components/ds-bar-nav.js +1 -1
  5. package/dist/components/ds-bar-nav.js.map +1 -1
  6. package/dist/components/ds-button-unfilled-icon.js +1 -1
  7. package/dist/components/ds-checkbox.js +1 -1
  8. package/dist/components/ds-field.js +1 -1
  9. package/dist/components/ds-field.js.map +1 -1
  10. package/dist/components/ds-panel-nav.js +1 -1
  11. package/dist/components/ds-panel-nav.js.map +1 -1
  12. package/dist/components/ds-panel-tools.js +1 -1
  13. package/dist/components/ds-panel-tools.js.map +1 -1
  14. package/dist/components/ds-slider.js +1 -1
  15. package/dist/components/ds-tab-group-nav.js +1 -1
  16. package/dist/components/ds-toggle.js +1 -1
  17. package/dist/components/index.js +1 -1
  18. package/dist/components/index.js.map +1 -1
  19. package/dist/components/{p-Bl-GTn9s.js → p-BTndnbR_.js} +2 -2
  20. package/dist/components/p-BTndnbR_.js.map +1 -0
  21. package/dist/components/p-D4zKc1RW.js +2 -0
  22. package/dist/components/p-D4zKc1RW.js.map +1 -0
  23. package/dist/types/components/AppShell/AppShell.d.ts +3 -0
  24. package/dist/types/components/BarNav/BarNav.d.ts +4 -0
  25. package/dist/types/components/ButtonUnfilledIcon/ButtonUnfilledIcon.d.ts +5 -0
  26. package/dist/types/components/PanelNav/PanelNav.d.ts +8 -2
  27. package/dist/types/components/PanelTools/PanelTools.d.ts +8 -0
  28. package/dist/types/components/PanelTools/panel-tools-utils.d.ts +7 -1
  29. package/dist/types/components/TabGroupNav/TabGroupNav.d.ts +22 -3
  30. package/dist/types/components.d.ts +62 -0
  31. package/dist/types/nav/shell-shortcuts.d.ts +10 -0
  32. package/package.json +2 -2
  33. package/src/angular/proxies.ts +17 -9
  34. package/src/react/ds-tab-group-nav.ts +8 -2
  35. package/src/wc/components/AppShell/AppShell.tsx +43 -8
  36. package/src/wc/components/BarNav/BarNav.tsx +33 -2
  37. package/src/wc/components/ButtonUnfilledIcon/ButtonUnfilledIcon.tsx +7 -0
  38. package/src/wc/components/PanelNav/PanelNav.tsx +116 -22
  39. package/src/wc/components/PanelTools/PanelTools.tsx +91 -23
  40. package/src/wc/components/PanelTools/panel-tools-utils.ts +13 -1
  41. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +164 -22
  42. package/src/wc/components.d.ts +62 -0
  43. package/src/wc/nav/shell-shortcuts.ts +63 -0
  44. package/dist/components/p-Bl-GTn9s.js.map +0 -1
  45. package/dist/components/p-CZpCmCiq.js +0 -2
  46. package/dist/components/p-CZpCmCiq.js.map +0 -1
@@ -1,5 +1,10 @@
1
- import { Component, Prop, Element, Watch, h, Host } from '@stencil/core';
1
+ import { Component, Prop, Element, Watch, Listen, h, Host } from '@stencil/core';
2
2
  import type { NavChromeStyle } from '../../nav/nav-chrome';
3
+ import {
4
+ isEditableShortcutTarget,
5
+ resolveShellShortcut,
6
+ } from '../../nav/shell-shortcuts';
7
+ import type { PanelToolsToolId } from '../PanelTools/panel-tools-types';
3
8
  import {
4
9
  CHROME_TRANSITION_END,
5
10
  CHROME_TRANSITION_START,
@@ -48,6 +53,9 @@ export class AppShell {
48
53
  */
49
54
  @Prop() gradientSrc: string = '';
50
55
 
56
+ /** When `true` (default), registers global shell keyboard shortcuts. Tool chords (⌘/Ctrl+K/A/S/M/N) toggle their drawer open and closed; ⌘/Ctrl+[ toggles panel nav; ⌘/Ctrl+] closes any open tool drawer. */
57
+ @Prop({ attribute: 'shortcuts-enabled' }) shortcutsEnabled: boolean = true;
58
+
51
59
  @Element() el!: HTMLElement;
52
60
 
53
61
  private resizeObserver: ResizeObserver | null = null;
@@ -242,6 +250,35 @@ export class AppShell {
242
250
  return this.gradient || this.grid;
243
251
  }
244
252
 
253
+ @Listen('keydown', { target: 'window', capture: true })
254
+ handleWindowKeyDown(e: KeyboardEvent) {
255
+ if (!this.shortcutsEnabled) return;
256
+ if (isEditableShortcutTarget(e.target)) return;
257
+
258
+ const action = resolveShellShortcut(e);
259
+ if (!action) return;
260
+
261
+ e.preventDefault();
262
+
263
+ const panel = this.el.querySelector('ds-panel-nav') as HTMLDsPanelNavElement | null;
264
+ const tools = this.el.querySelector('ds-panel-tools') as HTMLDsPanelToolsElement | null;
265
+
266
+ if (action === 'toggle-panel-nav') {
267
+ void panel?.toggleCollapsed();
268
+ return;
269
+ }
270
+
271
+ if (action === 'close-panel-tools') {
272
+ void tools?.closeDrawer();
273
+ return;
274
+ }
275
+
276
+ if (action.startsWith('open-tool:') && tools) {
277
+ const toolId = action.slice('open-tool:'.length) as PanelToolsToolId;
278
+ void tools.activateTool(toolId);
279
+ }
280
+ }
281
+
245
282
  private syncChrome() {
246
283
  const panelNav = this.el.querySelector('ds-panel-nav') as HTMLElement | null;
247
284
  const bar = this.el.querySelector('ds-bar-nav') as HTMLElement | null;
@@ -322,17 +359,15 @@ export class AppShell {
322
359
  <slot name="panel" />
323
360
  </div>
324
361
  <div class="app-shell__main">
325
- <div class="app-shell__stack">
326
- <div class="app-shell__bar">
327
- <slot name="bar" />
328
- </div>
329
- <div class="app-shell__content">
330
- <slot />
331
- </div>
362
+ <div class="app-shell__bar">
363
+ <slot name="bar" />
332
364
  </div>
333
365
  <div class="app-shell__tools">
334
366
  <slot name="tools" />
335
367
  </div>
368
+ <div class="app-shell__content">
369
+ <slot />
370
+ </div>
336
371
  </div>
337
372
  </div>
338
373
  </Host>
@@ -86,6 +86,7 @@ export class BarNav {
86
86
  @State() private visibleTabs: BarNavTab[] = [];
87
87
  @State() private overflowTabs: BarNavTab[] = [];
88
88
  @State() private menuInitialFocusVisible = false;
89
+ @State() private overflowRovingFocused = false;
89
90
 
90
91
  private static readonly HOST_PROP_SYNC_BUDGET = 8;
91
92
  private static readonly INTRINSIC_WIDTH_RETRY_MAX = 3;
@@ -95,7 +96,7 @@ export class BarNav {
95
96
  private headerEl: HTMLElement | null = null;
96
97
  private triggerEl: (HTMLElement & { setFocus?: () => Promise<void> }) | null = null;
97
98
  private menuEl: HTMLDsMenuElement | null = null;
98
- private visibleTabGroupEl: QueryableHost | null = null;
99
+ private visibleTabGroupEl: HTMLDsTabGroupNavElement | null = null;
99
100
  private probeTabGroupEl: QueryableHost | null = null;
100
101
  private resizeObserver: ResizeObserver | null = null;
101
102
  private intrinsicWidthRetryCount = 0;
@@ -571,11 +572,32 @@ export class BarNav {
571
572
  this.menuOpen = true;
572
573
  }
573
574
 
575
+ private handleTabRovingExit(e: CustomEvent<'start' | 'end'>) {
576
+ if (e.detail !== 'end' || !this.hasOverflowTabs) return;
577
+ this.overflowRovingFocused = true;
578
+ void this.triggerEl?.setFocus?.();
579
+ }
580
+
581
+ private handleOverflowFocus = () => {
582
+ this.overflowRovingFocused = true;
583
+ };
584
+
585
+ private handleOverflowBlur = () => {
586
+ this.overflowRovingFocused = false;
587
+ };
588
+
574
589
  private handleTriggerKeyDown(e: KeyboardEvent) {
575
590
  if (!this.hasOverflowTabs) return;
576
591
 
577
592
  if (!this.menuOpen) {
578
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
593
+ if (e.key === 'ArrowLeft') {
594
+ e.preventDefault();
595
+ this.overflowRovingFocused = false;
596
+ void this.visibleTabGroupEl?.focusLastTab();
597
+ return;
598
+ }
599
+
600
+ if (e.key === 'Enter' || e.key === ' ') {
579
601
  e.preventDefault();
580
602
  this.toggleTabMenu({ focusVisible: true });
581
603
  }
@@ -645,7 +667,13 @@ export class BarNav {
645
667
  }}
646
668
  tabs={renderedTabs}
647
669
  value={this.effectiveValue}
670
+ selectionFollowsFocus={false}
671
+ rovingEnabled={!this.overflowRovingFocused}
648
672
  onDsChange={(e: Event) => this.handleTabChange(e)}
673
+ onDsRovingExit={(e: CustomEvent<'start' | 'end'>) => this.handleTabRovingExit(e)}
674
+ onFocusin={() => {
675
+ this.overflowRovingFocused = false;
676
+ }}
649
677
  />
650
678
  </div>
651
679
  )}
@@ -666,6 +694,7 @@ export class BarNav {
666
694
  icon="Ellipses"
667
695
  isActive={this.menuOpen}
668
696
  activeFill={false}
697
+ focusTabIndex={this.overflowRovingFocused ? 0 : -1}
669
698
  ref={(el?: HTMLDsButtonUnfilledIconElement) => {
670
699
  this.triggerEl = (el as (HTMLElement & { setFocus?: () => Promise<void> })) ?? null;
671
700
  }}
@@ -673,6 +702,8 @@ export class BarNav {
673
702
  expanded={this.menuOpen}
674
703
  aria-label="More tabs"
675
704
  onDsClick={() => this.toggleTabMenu({ focusVisible: false })}
705
+ onFocusin={this.handleOverflowFocus}
706
+ onFocusout={this.handleOverflowBlur}
676
707
  onKeyDown={(e: KeyboardEvent) => this.handleTriggerKeyDown(e)}
677
708
  />
678
709
  )}
@@ -46,6 +46,12 @@ export class ButtonUnfilledIcon {
46
46
  @Prop() haspopup: string | undefined;
47
47
  @Prop() pressed: boolean | undefined;
48
48
 
49
+ /**
50
+ * Native `tabindex` for roving keyboard groups in shell chrome.
51
+ * Omit for the default button tab stop (`0`).
52
+ */
53
+ @Prop({ attribute: 'tab-index' }) focusTabIndex?: number;
54
+
49
55
  @Event() dsClick!: EventEmitter<MouseEvent>;
50
56
  @Event() dsChange!: EventEmitter<boolean>;
51
57
 
@@ -88,6 +94,7 @@ export class ButtonUnfilledIcon {
88
94
  type={this.type}
89
95
  class={cls}
90
96
  disabled={this.inactive}
97
+ tabIndex={this.focusTabIndex ?? 0}
91
98
  aria-label={this.ariaLabel}
92
99
  aria-controls={this.controls}
93
100
  aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}
@@ -1,4 +1,4 @@
1
- import { Component, Prop, Event, EventEmitter, Watch, State, Element, h, Host } from '@stencil/core';
1
+ import { Component, Prop, Event, EventEmitter, Watch, State, Element, Method, h, Host } from '@stencil/core';
2
2
  import type { ChromeTransitionDetail } from '../../nav/chrome-transition';
3
3
  import type { NavChromeStyle } from '../../nav/nav-chrome';
4
4
  import {
@@ -143,14 +143,14 @@ export class PanelNav {
143
143
  @Watch('groups')
144
144
  onGroupsChange(val: string | PanelNavGroup[]) {
145
145
  this.parsedGroups = parsePanelNavGroups(val);
146
- this.syncRovingIndex();
146
+ this.rovingIndex = 0;
147
147
  this.syncActiveFromUrl();
148
148
  }
149
149
 
150
150
  @Watch('activeId')
151
151
  @Watch('urlDerivedActiveId')
152
152
  onActiveIdChange() {
153
- this.syncRovingIndex();
153
+ /* Active route updates aria-current only — roving tab stop stays user-controlled. */
154
154
  }
155
155
 
156
156
  @Watch('currentUrl')
@@ -288,26 +288,104 @@ export class PanelNav {
288
288
  return this.parsedGroups.flatMap(g => g.items);
289
289
  }
290
290
 
291
- private syncRovingIndex() {
292
- const idx = this.getAllItems().findIndex(i => i.id === this.effectiveActiveId);
293
- this.rovingIndex = idx >= 0 ? idx : 0;
291
+ private getFooterRovingIndex(): number {
292
+ return 1 + this.getAllItems().length;
294
293
  }
295
294
 
296
- private handleItemKeyDown(e: KeyboardEvent, index: number) {
297
- const total = this.getAllItems().length;
298
- let next: number;
295
+ private getUserRovingIndex(): number {
296
+ return this.getFooterRovingIndex() + 1;
297
+ }
298
+
299
+ private getRovingElement(index: number): HTMLElement | null {
300
+ const itemCount = this.getAllItems().length;
301
+ if (index === 0) {
302
+ return this.el.querySelector('.panel-nav__header-btn');
303
+ }
304
+ if (index >= 1 && index <= itemCount) {
305
+ const items = this.el.querySelectorAll<HTMLElement>('.panel-nav__body .panel-nav__item');
306
+ return items[index - 1] ?? null;
307
+ }
308
+ if (index === this.getFooterRovingIndex()) {
309
+ return this.el.querySelector('.panel-nav__footer-btn .button-icon');
310
+ }
311
+ if (index === this.getUserRovingIndex()) {
312
+ return this.el.querySelector('.panel-nav__footer-user');
313
+ }
314
+ return null;
315
+ }
316
+
317
+ private focusRovingAt(index: number) {
318
+ this.rovingIndex = index;
319
+ this.getRovingElement(index)?.focus({ preventScroll: true });
320
+ }
321
+
322
+ private activateRovingIndex(index: number) {
323
+ const itemCount = this.getAllItems().length;
324
+ const items = this.getAllItems();
325
+ if (index === 0) {
326
+ this.handleToggle();
327
+ return;
328
+ }
329
+ if (index >= 1 && index <= itemCount) {
330
+ this.handleItemClick(items[index - 1].id);
331
+ return;
332
+ }
333
+ if (index === this.getFooterRovingIndex()) {
334
+ this.handleFooterAction();
335
+ return;
336
+ }
337
+ if (index === this.getUserRovingIndex()) {
338
+ const anchor = this.el.querySelector(`#${PANEL_NAV_USER_MENU_ANCHOR_ID}`) as HTMLElement | null;
339
+ if (anchor) this.dsNavUserAction.emit({ anchor });
340
+ }
341
+ }
342
+
343
+ private handleRovingKeyDown(e: KeyboardEvent, index: number) {
344
+ if (e.key === 'Enter' || e.key === ' ') {
345
+ e.preventDefault();
346
+ this.activateRovingIndex(index);
347
+ return;
348
+ }
349
+
350
+ const footerIdx = this.getFooterRovingIndex();
351
+ const userIdx = this.getUserRovingIndex();
352
+ let next: number | undefined;
353
+
299
354
  switch (e.key) {
300
- case 'ArrowDown': e.preventDefault(); next = (index + 1) % total; break;
301
- case 'ArrowUp': e.preventDefault(); next = (index - 1 + total) % total; break;
302
- case 'Home': e.preventDefault(); next = 0; break;
303
- case 'End': e.preventDefault(); next = total - 1; break;
304
- default: return;
355
+ case 'ArrowDown':
356
+ if (index === userIdx) return;
357
+ if (index === footerIdx) next = userIdx;
358
+ else next = index + 1;
359
+ break;
360
+ case 'ArrowUp':
361
+ if (index === 0) return;
362
+ if (index === userIdx) next = footerIdx;
363
+ else if (index === footerIdx) next = footerIdx - 1;
364
+ else next = index - 1;
365
+ break;
366
+ case 'ArrowRight':
367
+ if (index === footerIdx) next = userIdx;
368
+ else return;
369
+ break;
370
+ case 'ArrowLeft':
371
+ if (index === userIdx) next = footerIdx;
372
+ else return;
373
+ break;
374
+ case 'Home':
375
+ e.preventDefault();
376
+ next = 0;
377
+ break;
378
+ case 'End':
379
+ e.preventDefault();
380
+ next = userIdx;
381
+ break;
382
+ default:
383
+ return;
305
384
  }
306
- this.rovingIndex = next;
307
- const items = Array.from(
308
- this.el.querySelectorAll<HTMLElement>('.panel-nav__body .panel-nav__item')
309
- );
310
- items[next]?.focus();
385
+
386
+ if (next === undefined || next === index) return;
387
+ e.preventDefault();
388
+ this.focusRovingAt(next);
311
389
  }
312
390
 
313
391
  private checkScroll() {
@@ -331,6 +409,12 @@ export class PanelNav {
331
409
  this.applyToggle(!this.collapsed);
332
410
  }
333
411
 
412
+ /** Toggle expanded/collapsed panel nav — used by shell keyboard shortcuts. */
413
+ @Method()
414
+ async toggleCollapsed() {
415
+ this.handleToggle();
416
+ }
417
+
334
418
  private handleFooterAction() {
335
419
  this.dsNavFooterAction.emit();
336
420
  }
@@ -411,6 +495,7 @@ export class PanelNav {
411
495
 
412
496
  private renderNavItem(item: PanelNavItem, idx: number, collapsed: boolean) {
413
497
  const isActive = item.id === this.effectiveActiveId;
498
+ const rovingPosition = idx + 1;
414
499
 
415
500
  const itemContent = [
416
501
  <span class="panel-nav__item-icon">
@@ -446,10 +531,10 @@ export class PanelNav {
446
531
  },
447
532
  'aria-current': isActive ? ('page' as const) : undefined,
448
533
  title: collapsed ? item.label : undefined,
449
- tabIndex: idx === this.rovingIndex ? 0 : -1,
534
+ tabIndex: rovingPosition === this.rovingIndex ? 0 : -1,
450
535
  onClick: () => this.handleItemClick(item.id),
451
- onKeyDown: (e: KeyboardEvent) => this.handleItemKeyDown(e, idx),
452
- onFocus: () => { this.rovingIndex = idx; },
536
+ onKeyDown: (e: KeyboardEvent) => this.handleRovingKeyDown(e, rovingPosition),
537
+ onFocus: () => { this.rovingIndex = rovingPosition; },
453
538
  };
454
539
 
455
540
  const useAnchor = this.routerMode === 'anchor' && item.href;
@@ -487,7 +572,10 @@ export class PanelNav {
487
572
  <button
488
573
  type="button"
489
574
  class="panel-nav__header-btn ds-focus-ring-inset"
575
+ tabIndex={this.rovingIndex === 0 ? 0 : -1}
490
576
  onClick={() => this.handleToggle()}
577
+ onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, 0)}
578
+ onFocus={() => { this.rovingIndex = 0; }}
491
579
  aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
492
580
  aria-expanded={collapsed ? 'false' : 'true'}
493
581
  >
@@ -543,9 +631,12 @@ export class PanelNav {
543
631
  class="panel-nav__footer-btn"
544
632
  icon={isDashboardChrome ? 'Gear' : 'Dashboard'}
545
633
  activeFill={false}
634
+ focusTabIndex={this.rovingIndex === this.getFooterRovingIndex() ? 0 : -1}
546
635
  title={isDashboardChrome ? 'Settings' : 'Dashboard'}
547
636
  aria-label={isDashboardChrome ? 'Open settings' : 'Go to dashboard'}
548
637
  onDsClick={() => this.handleFooterAction()}
638
+ onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getFooterRovingIndex())}
639
+ onFocusin={() => { this.rovingIndex = this.getFooterRovingIndex(); }}
549
640
  />
550
641
 
551
642
  {/* Right user — label fades like nav items; right icon cross-fades chevron ↔ circle+initial */}
@@ -553,8 +644,11 @@ export class PanelNav {
553
644
  type="button"
554
645
  id={PANEL_NAV_USER_MENU_ANCHOR_ID}
555
646
  class="panel-nav__item panel-nav__footer-user ds-focus-ring-inset"
647
+ tabIndex={this.rovingIndex === this.getUserRovingIndex() ? 0 : -1}
556
648
  aria-label={collapsed ? `User: ${userName}` : `User menu for ${userName}`}
557
649
  onClick={(e) => this.handleUserAction(e)}
650
+ onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getUserRovingIndex())}
651
+ onFocus={() => { this.rovingIndex = this.getUserRovingIndex(); }}
558
652
  >
559
653
  <span class="panel-nav__item-label panel-nav__footer-user-label">
560
654
  <span class="panel-nav__item-label-text">
@@ -1,4 +1,4 @@
1
- import { Component, Prop, Event, EventEmitter, Element, State, Watch, h, Host } from '@stencil/core';
1
+ import { Component, Prop, Event, EventEmitter, Element, State, Watch, Method, h, Host } from '@stencil/core';
2
2
  import type { ChromeTransitionDetail } from '../../nav/chrome-transition';
3
3
  import {
4
4
  PANEL_TOOLS_LABELS,
@@ -6,7 +6,7 @@ import {
6
6
  type PanelToolsItem,
7
7
  type PanelToolsToolId,
8
8
  } from './panel-tools-types';
9
- import { parsePanelToolsItems, panelToolsDrawerResting } from './panel-tools-utils';
9
+ import { parsePanelToolsItems, panelToolsDrawerResting, resolvePanelToolActivation } from './panel-tools-utils';
10
10
 
11
11
  @Component({
12
12
  tag: 'ds-panel-tools',
@@ -51,12 +51,21 @@ export class PanelTools {
51
51
  /** Suppresses width transition until the host has painted its initial open state. */
52
52
  @State() private readyForMotion = false;
53
53
 
54
+ @State() private rovingIndex = 0;
55
+
54
56
  private motionEnableGeneration = 0;
55
57
 
56
58
  private get railItems(): PanelToolsItem[] {
57
59
  return parsePanelToolsItems(this.items, this.itemsJson);
58
60
  }
59
61
 
62
+ private get orderedRailItems(): PanelToolsItem[] {
63
+ const railItems = this.railItems;
64
+ const headerItem = railItems.find(item => item.id === PANEL_TOOLS_PRIMARY_TOOL_ID);
65
+ const bodyItems = railItems.filter(item => item.id !== PANEL_TOOLS_PRIMARY_TOOL_ID);
66
+ return headerItem ? [headerItem, ...bodyItems] : bodyItems;
67
+ }
68
+
60
69
  disconnectedCallback() {
61
70
  this.el.removeEventListener('transitionend', this.handleTransitionEnd);
62
71
  this.motionEnableGeneration += 1;
@@ -102,6 +111,7 @@ export class PanelTools {
102
111
  @Watch('items')
103
112
  @Watch('itemsJson')
104
113
  itemsChanged() {
114
+ this.rovingIndex = 0;
105
115
  this.deferMotionEnable();
106
116
  }
107
117
 
@@ -132,17 +142,68 @@ export class PanelTools {
132
142
  }
133
143
 
134
144
  private handleToolChange = (id: PanelToolsToolId) => {
135
- const selected = !this.isRailSelected(id);
136
- if (selected) {
137
- this.open = true;
138
- this.activeTool = id;
139
- } else {
140
- this.open = false;
145
+ const next = resolvePanelToolActivation(this.open, this.activeTool, id);
146
+ this.open = next.open;
147
+ this.activeTool = next.activeTool;
148
+ this.dsToolChange.emit({ id, selected: next.selected });
149
+ };
150
+
151
+ /** Toggle any rail tool open/closed — shell shortcuts ⌘/Ctrl+K/A/S/M/N call this. */
152
+ @Method()
153
+ async activateTool(id: PanelToolsToolId) {
154
+ const item = this.railItems.find(entry => entry.id === id);
155
+ if (!item || item.inactive) return;
156
+ this.handleToolChange(id);
157
+ }
158
+
159
+ /** Close the tools drawer when open — used by shell keyboard shortcuts. */
160
+ @Method()
161
+ async closeDrawer() {
162
+ if (!this.open) return;
163
+ const id = this.activeTool;
164
+ this.open = false;
165
+ if (id) {
166
+ this.dsToolChange.emit({ id, selected: false });
167
+ }
168
+ }
169
+
170
+ private focusRailAt(index: number) {
171
+ const items = this.orderedRailItems;
172
+ if (!items.length) return;
173
+ const bounded = Math.max(0, Math.min(index, items.length - 1));
174
+ if (bounded === this.rovingIndex) return;
175
+ this.rovingIndex = bounded;
176
+ const actions = Array.from(
177
+ this.el.querySelectorAll<HTMLElement>('.panel-tools__rail-action .button-icon'),
178
+ );
179
+ actions[bounded]?.focus({ preventScroll: true });
180
+ }
181
+
182
+ private handleRailKeyDown = (e: KeyboardEvent, index: number) => {
183
+ const items = this.orderedRailItems;
184
+ if (!items.length) return;
185
+
186
+ if (e.key === 'Enter' || e.key === ' ') {
187
+ e.preventDefault();
188
+ this.handleToolChange(items[index].id);
189
+ return;
190
+ }
191
+
192
+ if (e.key === 'ArrowDown') {
193
+ if (index >= items.length - 1) return;
194
+ e.preventDefault();
195
+ this.focusRailAt(index + 1);
196
+ return;
197
+ }
198
+
199
+ if (e.key === 'ArrowUp') {
200
+ if (index <= 0) return;
201
+ e.preventDefault();
202
+ this.focusRailAt(index - 1);
141
203
  }
142
- this.dsToolChange.emit({ id, selected });
143
204
  };
144
205
 
145
- private renderRailAction(item: PanelToolsItem) {
206
+ private renderRailAction(item: PanelToolsItem, index: number) {
146
207
  return (
147
208
  <ds-button-unfilled-icon
148
209
  key={item.id}
@@ -152,18 +213,21 @@ export class PanelTools {
152
213
  activeFill={false}
153
214
  dot={item.dot ?? false}
154
215
  inactive={item.inactive}
216
+ focusTabIndex={index === this.rovingIndex ? 0 : -1}
155
217
  aria-label={item.ariaLabel ?? PANEL_TOOLS_LABELS[item.id]}
156
218
  pressed={this.isRailSelected(item.id)}
157
- onDsChange={() => this.handleToolChange(item.id)}
219
+ onFocusin={() => { this.rovingIndex = index; }}
220
+ onKeyDown={(e: KeyboardEvent) => this.handleRailKeyDown(e, index)}
221
+ onDsClick={() => this.handleToolChange(item.id)}
158
222
  />
159
223
  );
160
224
  }
161
225
 
162
226
  render() {
163
227
  const headerLabel = this.headerLabel();
164
- const railItems = this.railItems;
165
- const headerItem = railItems.find(item => item.id === PANEL_TOOLS_PRIMARY_TOOL_ID);
166
- const bodyItems = railItems.filter(item => item.id !== PANEL_TOOLS_PRIMARY_TOOL_ID);
228
+ const orderedRailItems = this.orderedRailItems;
229
+ const headerItem = orderedRailItems.find(item => item.id === PANEL_TOOLS_PRIMARY_TOOL_ID);
230
+ const bodyItems = orderedRailItems.filter(item => item.id !== PANEL_TOOLS_PRIMARY_TOOL_ID);
167
231
  const showDrawerChrome = this.isDrawerPresent();
168
232
  const drawerResting = panelToolsDrawerResting(this.open, this.motion);
169
233
 
@@ -181,6 +245,19 @@ export class PanelTools {
181
245
  aria-label="Tools"
182
246
  >
183
247
  <div class="panel-tools__layout">
248
+ <nav class="panel-tools__rail" aria-label="Tool shortcuts">
249
+ {headerItem ? (
250
+ <div class="panel-tools__rail-header">
251
+ {this.renderRailAction(headerItem, 0)}
252
+ </div>
253
+ ) : null}
254
+ <div class="panel-tools__rail-body">
255
+ {bodyItems.map((item, bodyIdx) =>
256
+ this.renderRailAction(item, headerItem ? bodyIdx + 1 : bodyIdx),
257
+ )}
258
+ </div>
259
+ </nav>
260
+
184
261
  <div
185
262
  class={{
186
263
  'panel-tools__drawer': true,
@@ -247,15 +324,6 @@ export class PanelTools {
247
324
  </div>
248
325
  </div>
249
326
  </div>
250
-
251
- <nav class="panel-tools__rail" aria-label="Tool shortcuts">
252
- {headerItem ? (
253
- <div class="panel-tools__rail-header">{this.renderRailAction(headerItem)}</div>
254
- ) : null}
255
- <div class="panel-tools__rail-body">
256
- {bodyItems.map(item => this.renderRailAction(item))}
257
- </div>
258
- </nav>
259
327
  </div>
260
328
  </Host>
261
329
  );
@@ -1,5 +1,5 @@
1
1
  import { parseJsonArrayProp } from '../BarNav/bar-nav-utils';
2
- import type { PanelToolsItem } from './panel-tools-types';
2
+ import type { PanelToolsItem, PanelToolsToolId } from './panel-tools-types';
3
3
 
4
4
  export function parsePanelToolsItems(
5
5
  items: PanelToolsItem[],
@@ -36,3 +36,15 @@ export type PanelToolsMotion = 'opening' | 'closing' | 'idle';
36
36
  export function panelToolsDrawerResting(open: boolean, motion: PanelToolsMotion): boolean {
37
37
  return !open && motion === 'idle';
38
38
  }
39
+
40
+ /** Toggle or switch rail tool selection — repeat activation closes the active tool. */
41
+ export function resolvePanelToolActivation(
42
+ open: boolean,
43
+ activeTool: PanelToolsToolId | '',
44
+ id: PanelToolsToolId,
45
+ ): { open: boolean; activeTool: PanelToolsToolId; selected: boolean } {
46
+ if (open && activeTool === id) {
47
+ return { open: false, activeTool: id, selected: false };
48
+ }
49
+ return { open: true, activeTool: id, selected: true };
50
+ }