@ds-mo/ui 2.12.0 → 2.13.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 (145) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-stamp +1 -1
  3. package/dist/components/ds-app-shell.js +1 -1
  4. package/dist/components/ds-banner.js +1 -1
  5. package/dist/components/ds-bar-nav.js +1 -1
  6. package/dist/components/ds-bar-nav.js.map +1 -1
  7. package/dist/components/ds-button-filled.js +1 -1
  8. package/dist/components/ds-button-unfilled.js +1 -1
  9. package/dist/components/{ds-card-data-viz.d.ts → ds-card-data-viz-donut.d.ts} +4 -4
  10. package/dist/components/ds-card-data-viz-donut.js +2 -0
  11. package/dist/components/ds-card-data-viz-donut.js.map +1 -0
  12. package/dist/components/ds-card-setting.js +1 -1
  13. package/dist/components/ds-card-setting.js.map +1 -1
  14. package/dist/components/{ds-header.d.ts → ds-card.d.ts} +4 -4
  15. package/dist/components/ds-card.js +2 -0
  16. package/dist/components/ds-card.js.map +1 -0
  17. package/dist/components/ds-chart-bar.js +1 -1
  18. package/dist/components/ds-chart-bar.js.map +1 -1
  19. package/dist/components/ds-chart-donut.js +1 -1
  20. package/dist/components/ds-chart-donut.js.map +1 -1
  21. package/dist/components/ds-chart-legend.js +1 -1
  22. package/dist/components/ds-chart-line.js +1 -1
  23. package/dist/components/ds-checkbox.js +1 -1
  24. package/dist/components/ds-chip.js +1 -1
  25. package/dist/components/ds-divider.js +1 -1
  26. package/dist/components/ds-empty-state.js +1 -1
  27. package/dist/components/ds-empty-state.js.map +1 -1
  28. package/dist/components/ds-field.js +1 -1
  29. package/dist/components/ds-field.js.map +1 -1
  30. package/dist/components/ds-icon.js +1 -1
  31. package/dist/components/ds-input.js +1 -1
  32. package/dist/components/ds-input.js.map +1 -1
  33. package/dist/components/ds-loader.js +1 -1
  34. package/dist/components/ds-menu.js +1 -1
  35. package/dist/components/ds-modal.js +1 -1
  36. package/dist/components/ds-panel-nav.js +1 -1
  37. package/dist/components/ds-panel-tools.js +1 -1
  38. package/dist/components/ds-panel-tools.js.map +1 -1
  39. package/dist/components/ds-radio-group.js +1 -1
  40. package/dist/components/ds-select.js +1 -1
  41. package/dist/components/ds-select.js.map +1 -1
  42. package/dist/components/ds-shell-gradient-picker.js +1 -1
  43. package/dist/components/ds-shell-gradient-swatch.js +1 -1
  44. package/dist/components/ds-slider.js +1 -1
  45. package/dist/components/ds-tab-group-nav.js +1 -1
  46. package/dist/components/ds-tab-group.js +1 -1
  47. package/dist/components/ds-table.js +1 -1
  48. package/dist/components/ds-tag.js +1 -1
  49. package/dist/components/ds-text.js +1 -1
  50. package/dist/components/ds-toggle.js +1 -1
  51. package/dist/components/ds-tooltip-data-viz.js +1 -1
  52. package/dist/components/ds-tooltip.js +1 -1
  53. package/dist/components/{p-BPK76IsW.js → p-1m8r0wFg.js} +2 -2
  54. package/dist/components/{p-BZIOTcnM.js → p-3FLHei19.js} +2 -2
  55. package/dist/components/p-B2_D4IDN.js +2 -0
  56. package/dist/components/p-B2_D4IDN.js.map +1 -0
  57. package/dist/components/p-B5NcWtpp.js +2 -0
  58. package/dist/components/p-B5NcWtpp.js.map +1 -0
  59. package/dist/components/{p-CyFVn2Sc.js → p-B82KZX9D.js} +2 -2
  60. package/dist/components/p-B_g8X0eh.js +2 -0
  61. package/dist/components/p-B_g8X0eh.js.map +1 -0
  62. package/dist/components/{p-C3ORQUOp.js → p-Be3PHv34.js} +2 -2
  63. package/dist/components/{p-DkcH8mRl.js → p-CQmf2CeW.js} +2 -2
  64. package/dist/components/{p-DRqxVEye.js → p-CtwNUCnp.js} +2 -2
  65. package/dist/components/p-CuRjYmBk.js +2 -0
  66. package/dist/components/p-CuRjYmBk.js.map +1 -0
  67. package/dist/components/{p-DKdI8T8v.js → p-CwARVsZQ.js} +2 -2
  68. package/dist/components/{p-DKdI8T8v.js.map → p-CwARVsZQ.js.map} +1 -1
  69. package/dist/components/{p-UbKTMQjv.js → p-DohXlNCJ.js} +2 -2
  70. package/dist/components/p-DuFm-ieS.js +2 -0
  71. package/dist/components/p-DuFm-ieS.js.map +1 -0
  72. package/dist/lib/nav/index.js.map +2 -2
  73. package/dist/lib/utils/control-width.d.ts +4 -0
  74. package/dist/lib/utils/index.d.ts +2 -0
  75. package/dist/lib/utils/index.js +14 -1
  76. package/dist/lib/utils/index.js.map +3 -3
  77. package/dist/lib/utils/token-defaults.d.ts +4 -0
  78. package/dist/types/components/ButtonFilled/ButtonFilled.d.ts +4 -0
  79. package/dist/types/components/ButtonFilled/index.d.ts +1 -1
  80. package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +7 -3
  81. package/dist/types/components/ButtonUnfilled/index.d.ts +1 -1
  82. package/dist/types/components/Card/Card.d.ts +20 -0
  83. package/dist/types/components/Card/index.d.ts +2 -0
  84. package/dist/types/components/CardDataVizDonut/CardDataVizDonut.d.ts +26 -0
  85. package/dist/types/components/CardDataVizDonut/index.d.ts +2 -0
  86. package/dist/types/components/CardSetting/CardSetting.d.ts +7 -2
  87. package/dist/types/components/ChartDonut/ChartDonut.d.ts +24 -1
  88. package/dist/types/components/Select/Select.d.ts +32 -0
  89. package/dist/types/components/Select/index.d.ts +2 -0
  90. package/dist/types/components.d.ts +207 -110
  91. package/dist/types/utils/control-width.d.ts +5 -0
  92. package/dist/types/utils/index.d.ts +2 -0
  93. package/dist/types/utils/token-defaults.d.ts +4 -0
  94. package/package.json +2 -2
  95. package/src/angular/index.ts +2 -3
  96. package/src/angular/proxies.ts +40 -56
  97. package/src/react/components.ts +2 -3
  98. package/src/react/ds-card-data-viz-donut.ts +27 -0
  99. package/src/react/{ds-header.ts → ds-card.ts} +7 -7
  100. package/src/wc/components/ButtonFilled/ButtonFilled.tsx +7 -0
  101. package/src/wc/components/ButtonFilled/index.ts +1 -0
  102. package/src/wc/components/ButtonUnfilled/ButtonUnfilled.tsx +10 -3
  103. package/src/wc/components/ButtonUnfilled/index.ts +1 -0
  104. package/src/wc/components/Card/Card.tsx +79 -0
  105. package/src/wc/components/Card/index.ts +1 -0
  106. package/src/wc/components/{CardDataViz/CardDataViz.tsx → CardDataVizDonut/CardDataVizDonut.tsx} +47 -40
  107. package/src/wc/components/CardDataVizDonut/index.ts +1 -0
  108. package/src/wc/components/CardSetting/CardSetting.tsx +22 -35
  109. package/src/wc/components/ChartDonut/ChartDonut.tsx +112 -11
  110. package/src/wc/components/Select/Select.tsx +141 -60
  111. package/src/wc/components/Select/index.ts +1 -0
  112. package/src/wc/components.d.ts +207 -113
  113. package/src/wc/utils/control-width.css +28 -0
  114. package/src/wc/utils/control-width.ts +10 -0
  115. package/src/wc/utils/index.ts +2 -0
  116. package/src/wc/utils/token-defaults.ts +4 -0
  117. package/dist/components/ds-card-data-viz.js +0 -2
  118. package/dist/components/ds-card-data-viz.js.map +0 -1
  119. package/dist/components/ds-header.js +0 -2
  120. package/dist/components/ds-header.js.map +0 -1
  121. package/dist/components/ds-scrollbar.d.ts +0 -11
  122. package/dist/components/ds-scrollbar.js +0 -2
  123. package/dist/components/ds-scrollbar.js.map +0 -1
  124. package/dist/components/p-B9dHCgY-.js +0 -2
  125. package/dist/components/p-B9dHCgY-.js.map +0 -1
  126. package/dist/components/p-BTC9Njj6.js +0 -2
  127. package/dist/components/p-BTC9Njj6.js.map +0 -1
  128. package/dist/components/p-DusYu-if.js +0 -2
  129. package/dist/components/p-DusYu-if.js.map +0 -1
  130. package/dist/types/components/CardDataViz/CardDataViz.d.ts +0 -22
  131. package/dist/types/components/CardDataViz/index.d.ts +0 -2
  132. package/dist/types/components/Header/Header.d.ts +0 -7
  133. package/dist/types/components/Scrollbar/Scrollbar.d.ts +0 -27
  134. package/src/react/ds-card-data-viz.ts +0 -26
  135. package/src/react/ds-scrollbar.ts +0 -26
  136. package/src/wc/components/CardDataViz/index.ts +0 -1
  137. package/src/wc/components/Header/Header.tsx +0 -33
  138. package/src/wc/components/Scrollbar/Scrollbar.tsx +0 -189
  139. /package/dist/components/{p-BPK76IsW.js.map → p-1m8r0wFg.js.map} +0 -0
  140. /package/dist/components/{p-BZIOTcnM.js.map → p-3FLHei19.js.map} +0 -0
  141. /package/dist/components/{p-CyFVn2Sc.js.map → p-B82KZX9D.js.map} +0 -0
  142. /package/dist/components/{p-C3ORQUOp.js.map → p-Be3PHv34.js.map} +0 -0
  143. /package/dist/components/{p-DkcH8mRl.js.map → p-CQmf2CeW.js.map} +0 -0
  144. /package/dist/components/{p-DRqxVEye.js.map → p-CtwNUCnp.js.map} +0 -0
  145. /package/dist/components/{p-UbKTMQjv.js.map → p-DohXlNCJ.js.map} +0 -0
@@ -1,14 +1,7 @@
1
1
  import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';
2
+ import type { CardWidth } from '../Card/Card';
2
3
 
3
- export type CardSettingWidth = 'sm' | 'md' | 'lg';
4
-
5
- const CARD_WIDTH_VARS: Record<CardSettingWidth, string> = {
6
- sm: 'var(--dimension-card-width-sm)',
7
- md: 'var(--dimension-card-width-md)',
8
- lg: 'var(--dimension-card-width-lg)',
9
- };
10
-
11
- const FAINT_BRAND_TITLE_COLOR = 'var(--color-foreground-faint-brand)';
4
+ export type CardSettingWidth = CardWidth;
12
5
 
13
6
  @Component({
14
7
  tag: 'ds-card-setting',
@@ -19,7 +12,11 @@ export class CardSetting {
19
12
  /** Section heading shown in the card header. */
20
13
  @Prop() heading!: string;
21
14
 
22
- /** Card width token. */
15
+ /**
16
+ * Card width token (`sm` / `md` / `lg`). Also sets host `min-height` to the
17
+ * matching `--dimension-card-height-*` so the body fills available space
18
+ * even when the slot is empty.
19
+ */
23
20
  @Prop() cardWidth: CardSettingWidth = 'md';
24
21
 
25
22
  /** Controlled edit state — parent owns single-edit orchestration. */
@@ -40,41 +37,33 @@ export class CardSetting {
40
37
  const editing = this.editing;
41
38
 
42
39
  return (
43
- <Host
44
- class={{
45
- 'card-setting': true,
46
- 'card-setting--editing': editing,
47
- }}
48
- style={{ '--_card-setting-width': CARD_WIDTH_VARS[this.cardWidth] }}
49
- >
50
- <header class="card-setting__header">
51
- <div class="card-setting__title-wrap">
52
- <ds-text
53
- class="card-setting__title"
54
- variant="text-title-small"
55
- color={editing ? FAINT_BRAND_TITLE_COLOR : 'primary'}
56
- as="h2"
57
- >
58
- {this.heading}
59
- </ds-text>
60
- </div>
40
+ <Host class="card-setting">
41
+ <ds-card
42
+ heading={this.heading}
43
+ cardWidth={this.cardWidth}
44
+ appearance={editing ? 'editing' : 'default'}
45
+ >
61
46
  {!editing ? (
62
- <ds-button-unfilled variant="icon"
47
+ <ds-button-unfilled
48
+ slot="actions"
49
+ variant="icon"
63
50
  type="button"
64
51
  icon="Pencil"
65
52
  aria-label="Edit"
66
53
  onDsClick={this.enterEdit}
67
54
  />
68
55
  ) : (
69
- <div class="card-setting__actions">
70
- <ds-button-unfilled variant="icon"
56
+ <div slot="actions" class="card-setting__actions">
57
+ <ds-button-unfilled
58
+ variant="icon"
71
59
  type="button"
72
60
  icon="Cross"
73
61
  backgroundContrast="bold"
74
62
  aria-label="Cancel"
75
63
  onDsClick={this.exitEdit}
76
64
  />
77
- <ds-button-filled variant="icon"
65
+ <ds-button-filled
66
+ variant="icon"
78
67
  type="button"
79
68
  icon="Check"
80
69
  intent="brand"
@@ -84,10 +73,8 @@ export class CardSetting {
84
73
  />
85
74
  </div>
86
75
  )}
87
- </header>
88
- <div class="card-setting__body">
89
76
  <slot />
90
- </div>
77
+ </ds-card>
91
78
  </Host>
92
79
  );
93
80
  }
@@ -1,4 +1,4 @@
1
- import { Component, Prop, State, Event, EventEmitter, h, Host } from '@stencil/core';
1
+ import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';
2
2
  import { arc, pie } from 'd3-shape';
3
3
  import { categoryColor } from '../../utils/chart-colors';
4
4
  import { formatCompactNumber, resolveCssLengthPx, truncateSvgTextToWidth, TOKEN_DEFAULTS } from '../../utils';
@@ -9,6 +9,8 @@ const DIMMED_OPACITY = 0.25;
9
9
  const CENTER_GAP_PX = 0;
10
10
  /** Fraction of the inner-circle diameter center text is allowed to use before truncating. */
11
11
  const CENTER_TEXT_SAFE_WIDTH_RATIO = 0.82;
12
+ /** Fallback when filling before the first ResizeObserver measurement (matches fill min). */
13
+ const FILL_FALLBACK_PX = 128;
12
14
 
13
15
  type DonutTooltipState = {
14
16
  datum: ChartDatum;
@@ -22,9 +24,17 @@ type DonutTooltipState = {
22
24
  scoped: true,
23
25
  })
24
26
  export class ChartDonut {
27
+ @Element() el!: HTMLElement;
28
+
25
29
  /** Slices to render. Set as a JS property (not an HTML attribute). */
26
30
  @Prop() data: ChartDatum[] = [];
27
- @Prop() size: number = 175;
31
+ /**
32
+ * Explicit diameter in px. When unset, the donut sizes to its container
33
+ * (ResizeObserver) clamped between `--dimension-size-base * 16` (128px) and
34
+ * `* 24` (192px), and stays centered in the leftover space. Prefer unset
35
+ * inside card layouts.
36
+ */
37
+ @Prop() size: number | undefined;
28
38
  /** Ring thickness — number (px) or TokoMo length. Defaults to `--dimension-size-200` (16px). */
29
39
  @Prop() thickness: number | string = TOKEN_DEFAULTS.size200;
30
40
  /** Corner radius on each slice — number (px) or TokoMo length. Defaults to `--dimension-radius-025` (2px). */
@@ -50,6 +60,8 @@ export class ChartDonut {
50
60
  /** Center text, truncated with an ellipsis if it doesn't fit inside the inner circle. */
51
61
  @State() private displayedValueText: string = '';
52
62
  @State() private displayedCaptionText: string = '';
63
+ /** Measured fill diameter when `size` is unset. */
64
+ @State() private measuredSize: number = FILL_FALLBACK_PX;
53
65
 
54
66
  /** Fires with the hovered/focused slice's datum, or `null` on leave/blur. */
55
67
  @Event() dsSliceHover!: EventEmitter<ChartDatum | null>;
@@ -57,6 +69,79 @@ export class ChartDonut {
57
69
  private valueTextEl?: SVGTextElement;
58
70
  private captionTextEl?: SVGTextElement;
59
71
  private wrapperEl?: HTMLDivElement;
72
+ private resizeObserver: ResizeObserver | null = null;
73
+
74
+ private get isFillMode(): boolean {
75
+ return this.size == null || !Number.isFinite(this.size) || this.size <= 0;
76
+ }
77
+
78
+ private get fillMinPx(): number {
79
+ return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMin, FILL_FALLBACK_PX);
80
+ }
81
+
82
+ private get fillMaxPx(): number {
83
+ return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMax, 192);
84
+ }
85
+
86
+ /** Clamp available square side into the fill min/max band. */
87
+ private clampFillSize(availablePx: number): number {
88
+ const minPx = this.fillMinPx;
89
+ const maxPx = this.fillMaxPx;
90
+ if (!(availablePx > 0)) return minPx;
91
+ return Math.round(Math.min(maxPx, Math.max(minPx, availablePx)));
92
+ }
93
+
94
+ private get resolvedSize(): number {
95
+ if (!this.isFillMode) return this.size as number;
96
+ return this.clampFillSize(this.measuredSize);
97
+ }
98
+
99
+ componentDidLoad() {
100
+ this.syncResizeObserver();
101
+ }
102
+
103
+ @Watch('size')
104
+ onSizePropChange() {
105
+ this.syncResizeObserver();
106
+ }
107
+
108
+ disconnectedCallback() {
109
+ this.teardownResizeObserver();
110
+ }
111
+
112
+ private syncResizeObserver() {
113
+ this.teardownResizeObserver();
114
+ if (!this.isFillMode) return;
115
+ // Observe the host (which fills its parent), not the wrapper — the wrapper
116
+ // only wraps the SVG and would collapse to the clamped size, locking fill at min.
117
+ const target = this.el;
118
+ if (!target || typeof ResizeObserver === 'undefined') return;
119
+
120
+ this.resizeObserver = new ResizeObserver(entries => {
121
+ const entry = entries[0];
122
+ if (!entry) return;
123
+ this.applyAvailableSize(entry.contentRect.width, entry.contentRect.height);
124
+ });
125
+ this.resizeObserver.observe(target);
126
+ this.measureHost();
127
+ }
128
+
129
+ private measureHost() {
130
+ const rect = this.el.getBoundingClientRect();
131
+ this.applyAvailableSize(rect.width, rect.height);
132
+ }
133
+
134
+ private applyAvailableSize(width: number, height: number) {
135
+ const available = Math.floor(Math.min(width, height));
136
+ if (available > 0 && available !== this.measuredSize) {
137
+ this.measuredSize = available;
138
+ }
139
+ }
140
+
141
+ private teardownResizeObserver() {
142
+ this.resizeObserver?.disconnect();
143
+ this.resizeObserver = null;
144
+ }
60
145
 
61
146
  private handleHover(datum: ChartDatum | null, event?: MouseEvent) {
62
147
  this.hoveredLabel = datum?.label ?? null;
@@ -76,14 +161,21 @@ export class ChartDonut {
76
161
  private updateTooltip(datum: ChartDatum, event?: MouseEvent) {
77
162
  const wrapper = this.wrapperEl;
78
163
  if (!wrapper) return;
164
+ const size = this.resolvedSize;
79
165
  const rect = wrapper.getBoundingClientRect();
80
- const x = event ? event.clientX - rect.left : this.size / 2;
81
- const y = event ? event.clientY - rect.top : this.size / 2;
166
+ const x = event ? event.clientX - rect.left : size / 2;
167
+ const y = event ? event.clientY - rect.top : size / 2;
82
168
  this.tooltip = { datum, x, y };
83
169
  }
84
170
 
85
171
  componentDidRender() {
86
- const radius = this.size / 2;
172
+ // Re-bind after HMR when DidLoad's observer was dropped.
173
+ if (this.isFillMode && !this.resizeObserver) {
174
+ this.syncResizeObserver();
175
+ }
176
+
177
+ const size = this.resolvedSize;
178
+ const radius = size / 2;
87
179
  const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);
88
180
  const innerRadius = radius - thicknessPx;
89
181
  const maxWidth = innerRadius * 2 * CENTER_TEXT_SAFE_WIDTH_RATIO;
@@ -105,7 +197,8 @@ export class ChartDonut {
105
197
  }
106
198
 
107
199
  render() {
108
- const radius = this.size / 2;
200
+ const size = this.resolvedSize;
201
+ const radius = size / 2;
109
202
  const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);
110
203
  const cornerRadiusPx = resolveCssLengthPx(this.cornerRadius, TOKEN_DEFAULTS.radius025);
111
204
  const innerRadius = radius - thicknessPx;
@@ -146,21 +239,29 @@ export class ChartDonut {
146
239
  const fullValueText = this.centerValue ?? formatCompactNumber(total);
147
240
  const fullCaptionText = this.centerCaption ?? '';
148
241
  const tip = this.tooltip;
242
+ const fill = this.isFillMode;
149
243
 
150
244
  return (
151
- <Host class="chart-donut">
245
+ <Host
246
+ class={{
247
+ 'chart-donut': true,
248
+ 'chart-donut--fill': fill,
249
+ 'chart-donut--fixed': !fill,
250
+ }}
251
+ style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}
252
+ >
152
253
  <div
153
254
  class="chart-donut__wrapper"
154
- style={{ width: `${this.size}px`, height: `${this.size}px` }}
255
+ style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}
155
256
  ref={el => {
156
257
  this.wrapperEl = (el as HTMLDivElement) ?? undefined;
157
258
  }}
158
259
  >
159
260
  <svg
160
261
  class="chart-donut__svg"
161
- viewBox={`0 0 ${this.size} ${this.size}`}
162
- width={this.size}
163
- height={this.size}
262
+ viewBox={`0 0 ${size} ${size}`}
263
+ width={size}
264
+ height={size}
164
265
  >
165
266
  <g transform={`translate(${radius}, ${radius})`}>
166
267
  {isEmpty ? (
@@ -1,4 +1,6 @@
1
1
  import { Component, Prop, State, Event, EventEmitter, Element, Watch, Listen, h, Host } from '@stencil/core';
2
+ import { controlWidthClass, type ControlWidth } from '../../utils/control-width';
3
+ import { resolveCssLengthPx, TOKEN_DEFAULTS } from '../../utils';
2
4
  import type { MenuItemData } from '../Menu/menu-types';
3
5
 
4
6
  export interface SelectOption {
@@ -6,6 +8,28 @@ export interface SelectOption {
6
8
  value: string;
7
9
  }
8
10
 
11
+ export type SelectSize = 'md' | 'sm' | 'xs';
12
+
13
+ export type SelectWidth = ControlWidth;
14
+
15
+ /** Regular (non-emphasis) type per density — selects stay visually lighter than buttons. */
16
+ const TEXT_VARIANT: Record<SelectSize, string> = {
17
+ md: 'text-body-medium',
18
+ sm: 'text-body-small',
19
+ xs: 'text-caption',
20
+ };
21
+
22
+ /** `ds-icon` size matching control-density icon metrics. */
23
+ const ICON_SIZE: Record<SelectSize, 'md' | 'sm' | 'xs'> = {
24
+ md: 'md',
25
+ sm: 'sm',
26
+ xs: 'xs',
27
+ };
28
+
29
+ /**
30
+ * Dropdown select — unfilled-button chrome (label + trailing ChevronDown) in
31
+ * control-density sizes, with `ds-menu` for the option list.
32
+ */
9
33
  @Component({
10
34
  tag: 'ds-select',
11
35
  styleUrl: 'Select.css',
@@ -24,11 +48,28 @@ export class Select {
24
48
  @Prop({ mutable: true }) value: string = '';
25
49
 
26
50
  /** Placeholder shown when no value is selected. */
27
- @Prop() placeholder: string = 'Select option';
51
+ @Prop() placeholder: string = 'Select';
52
+
53
+ /** Control density (height, padding, icon, type). */
54
+ @Prop() size: SelectSize = 'md';
55
+
56
+ /** Width fit — fill the parent (default) or hug content. */
57
+ @Prop() width: SelectWidth = 'fill';
28
58
 
29
59
  /** Disables interaction (25% opacity via ds-control-inactive). */
30
60
  @Prop() isInactive: boolean = false;
31
61
 
62
+ /**
63
+ * When a value is selected, render the selected interaction fill (same recipe
64
+ * as unfilled-button `activeFill`). Default `true`. Pass `false` for
65
+ * foreground-only selection (primary label, no fill).
66
+ * Selected/open promotes the label to primary; chevron stays secondary.
67
+ */
68
+ @Prop() activeFill: boolean = true;
69
+
70
+ /** Show a 1px secondary inset border. Default on (matches unfilled button). */
71
+ @Prop() hasBorder: boolean = true;
72
+
32
73
  @Prop({ attribute: 'aria-label' }) ariaLabel: string | undefined;
33
74
  @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;
34
75
 
@@ -39,32 +80,28 @@ export class Select {
39
80
 
40
81
  private triggerEl: HTMLButtonElement | null = null;
41
82
  private menuEl: HTMLDsMenuElement | null = null;
83
+ /** True when the open was keyboard-driven — menu shows initial focus ring. */
84
+ private openWithFocusVisible = false;
42
85
 
43
86
  componentDidLoad() {
44
- // Wire up the menu anchor to the trigger button
45
87
  if (this.menuEl && this.triggerEl) {
46
88
  this.menuEl.anchor = this.triggerEl;
47
89
  }
48
- // Listen for menu selection events
49
- this.menuEl?.addEventListener('dsSelect', (e: Event) => {
50
- const detail = (e as CustomEvent<MenuItemData>).detail;
51
- if (detail?.value !== undefined) {
52
- this.value = String(detail.value);
53
- this.dsChange.emit(this.value);
54
- }
55
- this.isOpen = false;
56
- });
57
- // Listen for menu close event
58
- this.menuEl?.addEventListener('dsClose', () => {
59
- this.isOpen = false;
60
- });
90
+ this.menuEl?.addEventListener('dsSelect', this.handleMenuSelect);
91
+ this.menuEl?.addEventListener('dsClose', this.handleMenuClose);
92
+ this.syncMenuItems();
93
+ }
94
+
95
+ disconnectedCallback() {
96
+ this.menuEl?.removeEventListener('dsSelect', this.handleMenuSelect);
97
+ this.menuEl?.removeEventListener('dsClose', this.handleMenuClose);
61
98
  }
62
99
 
63
100
  @Watch('isOpen')
64
101
  onIsOpenChange(open: boolean) {
65
- if (this.menuEl) {
66
- this.menuEl.open = open;
67
- }
102
+ if (!this.menuEl) return;
103
+ this.menuEl.initialFocusVisible = open && this.openWithFocusVisible;
104
+ this.menuEl.open = open;
68
105
  }
69
106
 
70
107
  @Watch('options')
@@ -77,6 +114,19 @@ export class Select {
77
114
  this.syncMenuItems();
78
115
  }
79
116
 
117
+ private handleMenuSelect = (e: Event) => {
118
+ const detail = (e as CustomEvent<MenuItemData>).detail;
119
+ if (detail?.value !== undefined) {
120
+ this.value = String(detail.value);
121
+ this.dsChange.emit(this.value);
122
+ }
123
+ this.close();
124
+ };
125
+
126
+ private handleMenuClose = () => {
127
+ this.close();
128
+ };
129
+
80
130
  private syncMenuItems() {
81
131
  if (!this.menuEl) return;
82
132
  this.menuEl.items = this.options.map(opt => ({
@@ -86,32 +136,46 @@ export class Select {
86
136
  }));
87
137
  }
88
138
 
89
- private open() {
139
+ /**
140
+ * Size/place the menu so option labels line up with the select label.
141
+ * Menu section padding insets items; we expand min-width by that chrome and
142
+ * shift left by the same amount (`alignOffset`). Menu can grow wider than
143
+ * the field (left-bottom / `align="start"`) when labels need more room.
144
+ */
145
+ private syncMenuLayout() {
146
+ if (!this.menuEl || !this.triggerEl) return;
147
+ const sectionPadPx = resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);
148
+ const triggerW = this.triggerEl.offsetWidth;
149
+ // Prefer min-width over fixed width so long options can grow the menu.
150
+ this.menuEl.menuWidth = '';
151
+ this.menuEl.minWidth = `${triggerW + sectionPadPx * 2}px`;
152
+ this.menuEl.alignOffset = -sectionPadPx;
153
+ this.menuEl.anchor = this.triggerEl;
154
+ }
155
+
156
+ private open(opts: { focusVisible: boolean } = { focusVisible: false }) {
90
157
  if (this.isInactive || !this.options.length) return;
91
- // Sync menu width to trigger width
92
- if (this.menuEl && this.triggerEl) {
93
- this.menuEl.menuWidth = `${this.triggerEl.offsetWidth}px`;
94
- this.menuEl.anchor = this.triggerEl;
95
- }
158
+ this.openWithFocusVisible = opts.focusVisible;
159
+ this.syncMenuLayout();
96
160
  this.syncMenuItems();
97
161
  this.isOpen = true;
98
162
  }
99
163
 
100
164
  private close() {
101
165
  this.isOpen = false;
166
+ this.openWithFocusVisible = false;
102
167
  }
103
168
 
104
- private toggle() {
169
+ private toggle(opts: { focusVisible: boolean }) {
105
170
  if (this.isOpen) {
106
171
  this.close();
107
172
  } else {
108
- this.open();
173
+ this.open(opts);
109
174
  }
110
175
  }
111
176
 
112
177
  @Listen('keydown')
113
178
  handleKeyDown(e: KeyboardEvent) {
114
- // Only handle keydown on the trigger button
115
179
  if (document.activeElement !== this.triggerEl) return;
116
180
 
117
181
  if (!this.isOpen) {
@@ -121,67 +185,84 @@ export class Select {
121
185
  case 'Enter':
122
186
  case ' ':
123
187
  e.preventDefault();
124
- this.open();
188
+ this.open({ focusVisible: true });
125
189
  break;
126
190
  }
127
- } else {
128
- if (e.key === 'Escape') {
129
- e.preventDefault();
130
- this.close();
131
- this.triggerEl?.focus();
132
- }
191
+ } else if (e.key === 'Escape') {
192
+ e.preventDefault();
193
+ this.close();
194
+ this.triggerEl?.focus();
133
195
  }
134
196
  }
135
197
 
136
198
  private get selectedLabel(): string {
137
- const found = this.options.find(o => o.value === this.value);
138
- return found?.label ?? '';
199
+ return this.options.find(o => o.value === this.value)?.label ?? '';
139
200
  }
140
201
 
141
202
  private get hasSelection(): boolean {
142
- return !!this.options.find(o => o.value === this.value);
203
+ return this.options.some(o => o.value === this.value);
143
204
  }
144
205
 
145
206
  render() {
146
- const label = this.hasSelection ? this.selectedLabel : this.placeholder;
147
207
  const showPlaceholder = !this.hasSelection;
208
+ const hasValue = !showPlaceholder;
209
+ const label = showPlaceholder ? this.placeholder : this.selectedLabel;
210
+ const textVariant = TEXT_VARIANT[this.size];
211
+ const iconSize = ICON_SIZE[this.size];
212
+ const density = `ds-control--${this.size}`;
213
+ // Selected fill when a value is set (or while open), gated by `activeFill`.
214
+ const showSelectedFill =
215
+ !this.isInactive && this.activeFill && (hasValue || this.isOpen);
148
216
 
149
217
  return (
150
- <Host class={{ 'select-host': true, 'ds-control-inactive': this.isInactive }}>
218
+ <Host
219
+ class={{
220
+ 'select-host': true,
221
+ 'ds-control-inactive': this.isInactive,
222
+ 'ds-control--md': this.size === 'md',
223
+ 'ds-control--sm': this.size === 'sm',
224
+ 'ds-control--xs': this.size === 'xs',
225
+ ...controlWidthClass(this.width),
226
+ }}
227
+ >
151
228
  <button
152
229
  ref={el => {
153
230
  this.triggerEl = (el as HTMLButtonElement) ?? null;
154
231
  }}
155
232
  type="button"
156
- class={{ trigger: true, 'trigger--open': this.isOpen }}
233
+ class={{
234
+ trigger: true,
235
+ 'ds-focus-ring-inset': true,
236
+ 'ds-interaction-fill': true,
237
+ 'ds-interaction-fill--selected': showSelectedFill,
238
+ 'trigger--open': this.isOpen,
239
+ 'trigger--bordered': this.hasBorder,
240
+ 'trigger--placeholder': showPlaceholder,
241
+ 'trigger--has-value': hasValue,
242
+ [density]: true,
243
+ }}
157
244
  disabled={this.isInactive}
158
245
  role="combobox"
159
- aria-haspopup="listbox"
160
- aria-expanded={this.isOpen}
246
+ aria-haspopup="menu"
247
+ aria-expanded={String(this.isOpen)}
161
248
  aria-label={this.ariaLabel}
162
249
  aria-labelledby={this.ariaLabelledby}
163
- onClick={() => this.toggle()}
250
+ onClick={() => this.toggle({ focusVisible: false })}
164
251
  >
165
- <span class={{ 'trigger__label': true, 'trigger__label--placeholder': showPlaceholder }}>
252
+ <span
253
+ class={{
254
+ 'trigger__label': true,
255
+ [textVariant]: true,
256
+ 'ds-interaction-fill__content': true,
257
+ }}
258
+ >
166
259
  {label}
167
260
  </span>
168
- <svg
169
- class="trigger__chevron"
170
- width="16"
171
- height="16"
172
- viewBox="0 0 16 16"
173
- fill="none"
174
- aria-hidden="true"
175
- >
176
- <path
177
- d="M4 6L8 10L12 6"
178
- stroke="currentColor"
179
- stroke-width="1.25"
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- />
183
- </svg>
261
+ <span class="trigger__chevron ds-interaction-fill__content" aria-hidden="true">
262
+ <ds-icon name="ChevronDown" size={iconSize} color="inherit" />
263
+ </span>
184
264
  </button>
265
+ {/* bottom + start: left-aligned under the field; wider menus grow right. */}
185
266
  <ds-menu
186
267
  ref={el => {
187
268
  this.menuEl = (el as HTMLDsMenuElement) ?? null;
@@ -0,0 +1 @@
1
+ export type { SelectOption, SelectSize, SelectWidth } from './Select';