@ds-mo/ui 2.1.1 → 2.2.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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-stamp +1 -1
  3. package/dist/components/ds-bar-nav.js +1 -1
  4. package/dist/components/ds-bar-nav.js.map +1 -1
  5. package/dist/components/ds-breadcrumb.js +1 -1
  6. package/dist/components/ds-button-group.js +1 -1
  7. package/dist/components/ds-button-group.js.map +1 -1
  8. package/dist/components/ds-button-unfilled-icon.d.ts +11 -0
  9. package/dist/components/ds-button-unfilled-icon.js +2 -0
  10. package/dist/components/ds-button.js +1 -1
  11. package/dist/components/ds-checkbox.js +1 -1
  12. package/dist/components/ds-field.js +1 -1
  13. package/dist/components/ds-menu.js +1 -1
  14. package/dist/components/ds-panel-nav.js +1 -1
  15. package/dist/components/ds-panel-nav.js.map +1 -1
  16. package/dist/components/ds-panel-tools.js +1 -1
  17. package/dist/components/ds-panel-tools.js.map +1 -1
  18. package/dist/components/ds-select.js +1 -1
  19. package/dist/components/ds-slider.js +1 -1
  20. package/dist/components/ds-tab-group-nav.js +1 -1
  21. package/dist/components/ds-tab-group.js +1 -1
  22. package/dist/components/ds-tab-group.js.map +1 -1
  23. package/dist/components/ds-toggle.js +1 -1
  24. package/dist/components/{p-CsO880L-.js → p-CD3yg7ue.js} +2 -2
  25. package/dist/components/p-CSc44rQs.js +2 -0
  26. package/dist/components/p-CSc44rQs.js.map +1 -0
  27. package/dist/components/p-CZpCmCiq.js +2 -0
  28. package/dist/components/p-CZpCmCiq.js.map +1 -0
  29. package/dist/components/p-YzIqPOqL.js +2 -0
  30. package/dist/components/p-YzIqPOqL.js.map +1 -0
  31. package/dist/types/components/BarNav/BarNav.d.ts +8 -7
  32. package/dist/types/components/BarNav/bar-nav-tabs-menu-utils.d.ts +3 -0
  33. package/dist/types/components/BarNav/bar-nav-types.d.ts +0 -11
  34. package/dist/types/components/BarNav/index.d.ts +1 -1
  35. package/dist/types/components/ButtonUnfilledIcon/ButtonUnfilledIcon.d.ts +33 -0
  36. package/dist/types/components/ButtonUnfilledIcon/index.d.ts +2 -0
  37. package/dist/types/components/Menu/Menu.d.ts +3 -0
  38. package/dist/types/components.d.ts +154 -98
  39. package/package.json +1 -1
  40. package/src/angular/index.ts +1 -1
  41. package/src/angular/proxies.ts +34 -32
  42. package/src/react/components.ts +1 -1
  43. package/src/react/ds-button-unfilled-icon.ts +33 -0
  44. package/src/wc/components/BarNav/BarNav.tsx +126 -133
  45. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +55 -0
  46. package/src/wc/components/BarNav/bar-nav-types.ts +0 -12
  47. package/src/wc/components/BarNav/index.ts +1 -1
  48. package/src/wc/components/ButtonUnfilledIcon/ButtonUnfilledIcon.tsx +113 -0
  49. package/src/wc/components/ButtonUnfilledIcon/index.ts +1 -0
  50. package/src/wc/components/Menu/Menu.tsx +18 -7
  51. package/src/wc/components/PanelNav/PanelNav.tsx +12 -9
  52. package/src/wc/components/PanelTools/PanelTools.tsx +4 -2
  53. package/src/wc/components/TabGroup/TabGroup.tsx +1 -0
  54. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +1 -0
  55. package/src/wc/components.d.ts +154 -98
  56. package/src/wc/utils/focus-ring.css +33 -0
  57. package/dist/components/ds-bar-nav-action.d.ts +0 -11
  58. package/dist/components/ds-bar-nav-action.js +0 -2
  59. package/dist/components/p-CCZFMcLe.js +0 -2
  60. package/dist/components/p-CCZFMcLe.js.map +0 -1
  61. package/dist/components/p-CuuuDjJJ.js +0 -2
  62. package/dist/components/p-CuuuDjJJ.js.map +0 -1
  63. package/dist/components/p-DlsexsFg.js +0 -2
  64. package/dist/components/p-DlsexsFg.js.map +0 -1
  65. package/dist/types/components/BarNavAction/BarNavAction.d.ts +0 -21
  66. package/dist/types/components/BarNavAction/index.d.ts +0 -2
  67. package/src/react/ds-bar-nav-action.ts +0 -27
  68. package/src/wc/components/BarNavAction/BarNavAction.tsx +0 -78
  69. package/src/wc/components/BarNavAction/index.ts +0 -1
  70. /package/dist/components/{ds-bar-nav-action.js.map → ds-button-unfilled-icon.js.map} +0 -0
  71. /package/dist/components/{p-CsO880L-.js.map → p-CD3yg7ue.js.map} +0 -0
@@ -35,6 +35,10 @@ export function tabsToMenuSections(tabs: TabItem[], selectedId: string): MenuSec
35
35
  return sections;
36
36
  }
37
37
 
38
+ export function tabsToOverflowMenuSections(tabs: TabItem[], selectedId: string): MenuSection[] {
39
+ return tabsToMenuSections(trimLeadingDividers(tabs), selectedId);
40
+ }
41
+
38
42
  export function getActiveTab(tabs: TabItem[], selectedId: string): TabItemTab | undefined {
39
43
  const selectable = getSelectableTabs(tabs);
40
44
  return selectable.find(tab => tab.id === selectedId) ?? selectable[0];
@@ -57,3 +61,54 @@ export function tabsOverflowContainer(
57
61
  }
58
62
  return diff > hysteresis;
59
63
  }
64
+
65
+ export function visibleTabCountForWidth(
66
+ itemWidths: number[],
67
+ availableWidth: number,
68
+ overflowTriggerWidth: number,
69
+ itemGap: number,
70
+ listExtraWidth: number,
71
+ ): number {
72
+ if (itemWidths.length === 0 || availableWidth <= 0) return 0;
73
+
74
+ const fullWidth = widthForItemCount(itemWidths, itemWidths.length, itemGap, listExtraWidth);
75
+ if (fullWidth <= availableWidth) return itemWidths.length;
76
+
77
+ const availableForTabs = Math.max(0, availableWidth - overflowTriggerWidth);
78
+ let count = 0;
79
+
80
+ for (let i = 1; i <= itemWidths.length; i++) {
81
+ const width = widthForItemCount(itemWidths, i, itemGap, listExtraWidth);
82
+ if (width > availableForTabs) break;
83
+ count = i;
84
+ }
85
+
86
+ return count;
87
+ }
88
+
89
+ export function trimTrailingDividers(tabs: TabItem[]): TabItem[] {
90
+ let end = tabs.length;
91
+ while (end > 0 && isTabDivider(tabs[end - 1])) {
92
+ end -= 1;
93
+ }
94
+ return tabs.slice(0, end);
95
+ }
96
+
97
+ function trimLeadingDividers(tabs: TabItem[]): TabItem[] {
98
+ let start = 0;
99
+ while (start < tabs.length && isTabDivider(tabs[start])) {
100
+ start += 1;
101
+ }
102
+ return tabs.slice(start);
103
+ }
104
+
105
+ function widthForItemCount(
106
+ itemWidths: number[],
107
+ count: number,
108
+ itemGap: number,
109
+ listExtraWidth: number,
110
+ ): number {
111
+ if (count <= 0) return 0;
112
+ const itemsWidth = itemWidths.slice(0, count).reduce((sum, width) => sum + width, 0);
113
+ return listExtraWidth + itemsWidth + itemGap * Math.max(0, count - 1);
114
+ }
@@ -1,15 +1,3 @@
1
1
  import type { TabItem } from '../TabGroup/tab-item-utils';
2
2
 
3
3
  export type BarNavTab = TabItem;
4
-
5
- export interface BarNavActionItem {
6
- id: string;
7
- /** Icon name for <ds-icon>. */
8
- icon: string;
9
- /** Whether this action button is currently pressed/active. */
10
- selected?: boolean;
11
- /** Show a notification dot. */
12
- dot?: boolean;
13
- inactive?: boolean;
14
- ariaLabel?: string;
15
- }
@@ -1,2 +1,2 @@
1
- export type { BarNavTab, BarNavActionItem } from './bar-nav-types';
1
+ export type { BarNavTab } from './bar-nav-types';
2
2
  export type { NavChromeStyle } from '../../nav/nav-chrome';
@@ -0,0 +1,113 @@
1
+ import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core';
2
+
3
+ export type ButtonUnfilledIconBackground =
4
+ | 'faint'
5
+ | 'medium'
6
+ | 'bold'
7
+ | 'strong'
8
+ | 'always-dark'
9
+ | 'navigation';
10
+
11
+ @Component({
12
+ tag: 'ds-button-unfilled-icon',
13
+ styleUrl: 'ButtonUnfilledIcon.css',
14
+ scoped: true,
15
+ })
16
+ export class ButtonUnfilledIcon {
17
+ @Element() el!: HTMLElement;
18
+
19
+ /** Icon name passed to <ds-icon>. */
20
+ @Prop() icon: string = '';
21
+
22
+ /** Active/selected visual state. */
23
+ @Prop() isActive: boolean = false;
24
+
25
+ /** When active, render the active interaction fill. Shell chrome can disable this while keeping active icon colour. */
26
+ @Prop() activeFill: boolean = true;
27
+
28
+ /** Show a 1px tertiary border without changing the interaction model. */
29
+ @Prop() hasBorder: boolean = false;
30
+
31
+ /** Show a notification dot at the top-right of the icon zone. */
32
+ @Prop() dot: boolean = false;
33
+
34
+ /** Disables interaction. */
35
+ @Prop() inactive: boolean = false;
36
+
37
+ /** Native button type. */
38
+ @Prop() type: 'button' | 'submit' | 'reset' = 'button';
39
+
40
+ /** Parent surface context — adjusts hover/press/focus colours for coloured backgrounds. */
41
+ @Prop() background: ButtonUnfilledIconBackground | undefined;
42
+
43
+ @Prop({ attribute: 'aria-label' }) ariaLabel: string = 'action';
44
+ @Prop() controls: string | undefined;
45
+ @Prop() expanded: boolean | undefined;
46
+ @Prop() haspopup: string | undefined;
47
+ @Prop() pressed: boolean | undefined;
48
+
49
+ @Event() dsClick!: EventEmitter<MouseEvent>;
50
+ @Event() dsChange!: EventEmitter<boolean>;
51
+
52
+ private buttonEl: HTMLButtonElement | null = null;
53
+
54
+ @Method()
55
+ async setFocus() {
56
+ this.buttonEl?.focus();
57
+ }
58
+
59
+ private handleClick = (event: MouseEvent) => {
60
+ if (this.inactive) return;
61
+ this.dsClick.emit(event);
62
+ this.dsChange.emit(!this.isActive);
63
+ };
64
+
65
+ render() {
66
+ const bg = this.background;
67
+
68
+ const cls: Record<string, boolean> = {
69
+ 'button-icon': true,
70
+ 'button-icon--active': this.isActive,
71
+ 'button-icon--active-fill': this.isActive && this.activeFill,
72
+ 'button-icon--bordered': this.hasBorder,
73
+ 'button-icon--inactive': this.inactive,
74
+ 'button-icon--on-medium': bg === 'medium',
75
+ 'button-icon--on-bold': bg === 'bold',
76
+ 'button-icon--on-strong': bg === 'strong',
77
+ 'button-icon--on-always-dark': bg === 'always-dark',
78
+ 'button-icon--on-navigation': bg === 'navigation',
79
+ };
80
+
81
+ return (
82
+ <Host tabIndex={-1}>
83
+ <button
84
+ ref={el => {
85
+ this.buttonEl = el ?? null;
86
+ }}
87
+ type={this.type}
88
+ class={cls}
89
+ disabled={this.inactive}
90
+ aria-label={this.ariaLabel}
91
+ aria-controls={this.controls}
92
+ aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}
93
+ aria-haspopup={this.haspopup}
94
+ aria-pressed={this.pressed === undefined ? undefined : String(this.pressed)}
95
+ onClick={this.handleClick}
96
+ >
97
+ <span class="button-icon__icon-wrap">
98
+ <ds-icon name={this.icon} size="md" color="inherit" />
99
+ {this.dot && (
100
+ <ds-badge
101
+ class="button-icon__dot"
102
+ variant="dot"
103
+ background="var(--ds-button-unfilled-icon-dot-ring)"
104
+ label=""
105
+ aria-hidden="true"
106
+ />
107
+ )}
108
+ </span>
109
+ </button>
110
+ </Host>
111
+ );
112
+ }
113
+ }
@@ -0,0 +1 @@
1
+ export type { ButtonUnfilledIconBackground } from './ButtonUnfilledIcon';
@@ -29,12 +29,15 @@ export class Menu {
29
29
  @Prop() anchor: HTMLElement | undefined;
30
30
  /** ID of the external trigger element for positioning */
31
31
  @Prop() anchorId: string | undefined;
32
+ /** Show a visible ring on the initially focused menu item. Use only when the opener was keyboard-driven. */
33
+ @Prop() initialFocusVisible: boolean = false;
32
34
 
33
35
  @State() private shouldRender: boolean = false;
34
36
  @State() private closing: boolean = false;
35
37
  @State() private pos: { x: number; y: number } = { x: 0, y: 0 };
36
38
  @State() private focusedIndex: number = 0;
37
39
  @State() private positionReady: boolean = false;
40
+ @State() private focusRingVisible: boolean = false;
38
41
 
39
42
  @Event() dsClose!: EventEmitter<void>;
40
43
  @Event() dsSelect!: EventEmitter<MenuItemData>;
@@ -58,16 +61,16 @@ export class Menu {
58
61
  @Watch('open')
59
62
  onOpenChange(isOpen: boolean) {
60
63
  if (isOpen) {
64
+ this.teardownListeners();
61
65
  this.shouldRender = true;
62
66
  this.closing = false;
63
67
  this.positionReady = false;
64
68
  this.listenersReady = false;
69
+ this.focusRingVisible = this.initialFocusVisible;
70
+ this.listenersReady = true;
71
+ this.setupListeners();
65
72
  this.schedulePositionUpdate(() => {
66
- if (!this.listenersReady) {
67
- this.listenersReady = true;
68
- this.focusInitialItem();
69
- this.setupListeners();
70
- }
73
+ this.focusInitialItem();
71
74
  });
72
75
  } else if (this.shouldRender) {
73
76
  this.cancelPositionRetry();
@@ -218,7 +221,7 @@ export class Menu {
218
221
  };
219
222
 
220
223
  this.scrollResizeHandler = () => {
221
- if (this.open) this.calculatePosition();
224
+ if (this.shouldRender && !this.closing) this.calculatePosition();
222
225
  };
223
226
 
224
227
  document.addEventListener('mousedown', this.clickOutsideHandler, true);
@@ -246,11 +249,12 @@ export class Menu {
246
249
  this.resolvedAnchor?.focus();
247
250
  this.dsClose.emit();
248
251
  this.open = false;
252
+ this.onOpenChange(false);
249
253
  }
250
254
 
251
255
  @Listen('keydown')
252
256
  handleKeyDown(e: KeyboardEvent) {
253
- if (!this.open) return;
257
+ if (!this.shouldRender || this.closing) return;
254
258
  const flat = this.flatItems;
255
259
  const enabled = flat.map((it, i) => ({ it, i })).filter(({ it }) => !it.isInactive).map(({ i }) => i);
256
260
  if (!enabled.length) return;
@@ -261,21 +265,25 @@ export class Menu {
261
265
  switch (e.key) {
262
266
  case 'ArrowDown':
263
267
  e.preventDefault(); e.stopPropagation();
268
+ this.focusRingVisible = true;
264
269
  this.focusedIndex = enabled[(safe + 1) % enabled.length];
265
270
  this.focusItem(this.focusedIndex);
266
271
  break;
267
272
  case 'ArrowUp':
268
273
  e.preventDefault(); e.stopPropagation();
274
+ this.focusRingVisible = true;
269
275
  this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];
270
276
  this.focusItem(this.focusedIndex);
271
277
  break;
272
278
  case 'Home':
273
279
  e.preventDefault(); e.stopPropagation();
280
+ this.focusRingVisible = true;
274
281
  this.focusedIndex = enabled[0];
275
282
  this.focusItem(this.focusedIndex);
276
283
  break;
277
284
  case 'End':
278
285
  e.preventDefault(); e.stopPropagation();
286
+ this.focusRingVisible = true;
279
287
  this.focusedIndex = enabled[enabled.length - 1];
280
288
  this.focusItem(this.focusedIndex);
281
289
  break;
@@ -349,6 +357,8 @@ export class Menu {
349
357
  type="button"
350
358
  class={{
351
359
  'menu-item': true,
360
+ 'ds-focus-ring-inset': true,
361
+ 'ds-focus-ring--visible': isFocused && this.focusRingVisible,
352
362
  'menu-item--selected': !!item.isSelected,
353
363
  'menu-item--inactive': !!item.isInactive,
354
364
  'menu-item--destructive': !!item.isDestructive,
@@ -358,6 +368,7 @@ export class Menu {
358
368
  aria-current={item.isSelected ? 'true' : undefined}
359
369
  disabled={item.isInactive}
360
370
  tabIndex={isFocused ? 0 : -1}
371
+ onMouseDown={() => { this.focusRingVisible = false; }}
361
372
  onClick={() => this.handleItemClick(item)}
362
373
  onFocus={() => { this.focusedIndex = idx; }}
363
374
  >
@@ -439,7 +439,11 @@ export class PanelNav {
439
439
  ];
440
440
 
441
441
  const sharedProps = {
442
- class: { 'panel-nav__item': true, 'panel-nav__item--active': isActive },
442
+ class: {
443
+ 'panel-nav__item': true,
444
+ 'panel-nav__item--active': isActive,
445
+ 'ds-focus-ring-inset': true,
446
+ },
443
447
  'aria-current': isActive ? ('page' as const) : undefined,
444
448
  title: collapsed ? item.label : undefined,
445
449
  tabIndex: idx === this.rovingIndex ? 0 : -1,
@@ -482,7 +486,7 @@ export class PanelNav {
482
486
  <div class="panel-nav__header">
483
487
  <button
484
488
  type="button"
485
- class="panel-nav__header-btn"
489
+ class="panel-nav__header-btn ds-focus-ring-inset"
486
490
  onClick={() => this.handleToggle()}
487
491
  aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
488
492
  aria-expanded={collapsed ? 'false' : 'true'}
@@ -535,21 +539,20 @@ export class PanelNav {
535
539
 
536
540
  {/* ── Footer ── */}
537
541
  <div class="panel-nav__footer">
538
- <button
539
- type="button"
542
+ <ds-button-unfilled-icon
540
543
  class="panel-nav__footer-btn"
544
+ icon={isDashboardChrome ? 'Gear' : 'Dashboard'}
545
+ activeFill={false}
541
546
  title={isDashboardChrome ? 'Settings' : 'Dashboard'}
542
547
  aria-label={isDashboardChrome ? 'Open settings' : 'Go to dashboard'}
543
- onClick={() => this.handleFooterAction()}
544
- >
545
- <ds-icon name={isDashboardChrome ? 'Gear' : 'Dashboard'} size="md" color="inherit" />
546
- </button>
548
+ onDsClick={() => this.handleFooterAction()}
549
+ />
547
550
 
548
551
  {/* Right user — label fades like nav items; right icon cross-fades chevron ↔ circle+initial */}
549
552
  <button
550
553
  type="button"
551
554
  id={PANEL_NAV_USER_MENU_ANCHOR_ID}
552
- class="panel-nav__item panel-nav__footer-user"
555
+ class="panel-nav__item panel-nav__footer-user ds-focus-ring-inset"
553
556
  aria-label={collapsed ? `User: ${userName}` : `User menu for ${userName}`}
554
557
  onClick={(e) => this.handleUserAction(e)}
555
558
  >
@@ -144,14 +144,16 @@ export class PanelTools {
144
144
 
145
145
  private renderRailAction(item: PanelToolsItem) {
146
146
  return (
147
- <ds-bar-nav-action
147
+ <ds-button-unfilled-icon
148
148
  key={item.id}
149
149
  class="panel-tools__rail-action"
150
150
  icon={item.icon}
151
- selected={this.isRailSelected(item.id)}
151
+ isActive={this.isRailSelected(item.id)}
152
+ activeFill={false}
152
153
  dot={item.dot ?? false}
153
154
  inactive={item.inactive}
154
155
  aria-label={item.ariaLabel ?? PANEL_TOOLS_LABELS[item.id]}
156
+ pressed={this.isRailSelected(item.id)}
155
157
  onDsChange={() => this.handleToolChange(item.id)}
156
158
  />
157
159
  );
@@ -161,6 +161,7 @@ export class TabGroup {
161
161
  class={{
162
162
  tab: true,
163
163
  'tab--selected': isSelected,
164
+ 'ds-focus-ring-inset': true,
164
165
  [bgClass]: !!bgClass,
165
166
  }}
166
167
  aria-selected={isSelected}
@@ -167,6 +167,7 @@ export class TabGroupNav {
167
167
  class={{
168
168
  tab: true,
169
169
  'tab--selected': isSelected,
170
+ 'ds-focus-ring-inset': true,
170
171
  [bgClass]: !!bgClass,
171
172
  }}
172
173
  aria-selected={isSelected}