@ds-mo/ui 0.2.4 → 0.3.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 (215) hide show
  1. package/dist/components/Accordion/Accordion.d.ts +29 -0
  2. package/dist/components/Accordion/Accordion.d.ts.map +1 -0
  3. package/dist/components/Accordion/index.d.ts +3 -0
  4. package/dist/components/Accordion/index.d.ts.map +1 -0
  5. package/dist/components/Badge/Badge.d.ts +18 -0
  6. package/dist/components/Badge/Badge.d.ts.map +1 -0
  7. package/dist/components/Badge/index.d.ts +3 -0
  8. package/dist/components/Badge/index.d.ts.map +1 -0
  9. package/dist/components/Banner/Banner.d.ts +16 -0
  10. package/dist/components/Banner/Banner.d.ts.map +1 -0
  11. package/dist/components/Banner/index.d.ts +3 -0
  12. package/dist/components/Banner/index.d.ts.map +1 -0
  13. package/dist/components/Breadcrumb/Breadcrumb.d.ts +15 -0
  14. package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
  15. package/dist/components/Breadcrumb/index.d.ts +3 -0
  16. package/dist/components/Breadcrumb/index.d.ts.map +1 -0
  17. package/dist/components/Button/Button.d.ts +85 -0
  18. package/dist/components/Button/Button.d.ts.map +1 -0
  19. package/dist/components/Button/index.d.ts +3 -0
  20. package/dist/components/Button/index.d.ts.map +1 -0
  21. package/dist/components/ButtonGroup/ButtonGroup.d.ts +26 -0
  22. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  23. package/dist/components/ButtonGroup/index.d.ts +3 -0
  24. package/dist/components/ButtonGroup/index.d.ts.map +1 -0
  25. package/dist/components/Card/Card.d.ts +20 -0
  26. package/dist/components/Card/Card.d.ts.map +1 -0
  27. package/dist/components/Card/index.d.ts +3 -0
  28. package/dist/components/Card/index.d.ts.map +1 -0
  29. package/dist/components/Checkbox/Checkbox.d.ts +18 -0
  30. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
  31. package/dist/components/Checkbox/index.d.ts +3 -0
  32. package/dist/components/Checkbox/index.d.ts.map +1 -0
  33. package/dist/components/Divider/Divider.d.ts +9 -0
  34. package/dist/components/Divider/Divider.d.ts.map +1 -0
  35. package/dist/components/Divider/index.d.ts +3 -0
  36. package/dist/components/Divider/index.d.ts.map +1 -0
  37. package/dist/components/EmptyState/EmptyState.d.ts +9 -0
  38. package/dist/components/EmptyState/EmptyState.d.ts.map +1 -0
  39. package/dist/components/EmptyState/index.d.ts +3 -0
  40. package/dist/components/EmptyState/index.d.ts.map +1 -0
  41. package/dist/components/ErrorBoundary/ErrorBoundary.d.ts +18 -0
  42. package/dist/components/ErrorBoundary/ErrorBoundary.d.ts.map +1 -0
  43. package/dist/components/ErrorBoundary/index.d.ts +3 -0
  44. package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
  45. package/dist/components/Fade/Fade.d.ts +11 -0
  46. package/dist/components/Fade/Fade.d.ts.map +1 -0
  47. package/dist/components/Fade/index.d.ts +3 -0
  48. package/dist/components/Fade/index.d.ts.map +1 -0
  49. package/dist/components/Field/Field.d.ts +9 -0
  50. package/dist/components/Field/Field.d.ts.map +1 -0
  51. package/dist/components/Field/index.d.ts +3 -0
  52. package/dist/components/Field/index.d.ts.map +1 -0
  53. package/dist/components/Header/Header.d.ts +12 -0
  54. package/dist/components/Header/Header.d.ts.map +1 -0
  55. package/dist/components/Header/index.d.ts +3 -0
  56. package/dist/components/Header/index.d.ts.map +1 -0
  57. package/dist/components/Input/Input.d.ts +27 -0
  58. package/dist/components/Input/Input.d.ts.map +1 -0
  59. package/dist/components/Input/index.d.ts +3 -0
  60. package/dist/components/Input/index.d.ts.map +1 -0
  61. package/dist/components/LabelWrap/LabelWrap.d.ts +27 -0
  62. package/dist/components/LabelWrap/LabelWrap.d.ts.map +1 -0
  63. package/dist/components/LabelWrap/index.d.ts +2 -0
  64. package/dist/components/LabelWrap/index.d.ts.map +1 -0
  65. package/dist/components/Loader/Loader.d.ts +33 -0
  66. package/dist/components/Loader/Loader.d.ts.map +1 -0
  67. package/dist/components/Loader/index.d.ts +3 -0
  68. package/dist/components/Loader/index.d.ts.map +1 -0
  69. package/dist/components/Menu/DestructiveMenuItem.d.ts +12 -0
  70. package/dist/components/Menu/DestructiveMenuItem.d.ts.map +1 -0
  71. package/dist/components/Menu/Menu.d.ts +55 -0
  72. package/dist/components/Menu/Menu.d.ts.map +1 -0
  73. package/dist/components/Menu/MenuItem.d.ts +33 -0
  74. package/dist/components/Menu/MenuItem.d.ts.map +1 -0
  75. package/dist/components/Menu/index.d.ts +7 -0
  76. package/dist/components/Menu/index.d.ts.map +1 -0
  77. package/dist/components/Modal/Modal.d.ts +15 -0
  78. package/dist/components/Modal/Modal.d.ts.map +1 -0
  79. package/dist/components/Modal/index.d.ts +3 -0
  80. package/dist/components/Modal/index.d.ts.map +1 -0
  81. package/dist/components/Pagination/Pagination.d.ts +16 -0
  82. package/dist/components/Pagination/Pagination.d.ts.map +1 -0
  83. package/dist/components/Pagination/index.d.ts +3 -0
  84. package/dist/components/Pagination/index.d.ts.map +1 -0
  85. package/dist/components/Radio/Radio.d.ts +35 -0
  86. package/dist/components/Radio/Radio.d.ts.map +1 -0
  87. package/dist/components/Radio/index.d.ts +3 -0
  88. package/dist/components/Radio/index.d.ts.map +1 -0
  89. package/dist/components/Scrollbar/Scrollbar.d.ts +13 -0
  90. package/dist/components/Scrollbar/Scrollbar.d.ts.map +1 -0
  91. package/dist/components/Scrollbar/index.d.ts +3 -0
  92. package/dist/components/Scrollbar/index.d.ts.map +1 -0
  93. package/dist/components/Select/Select.d.ts +22 -0
  94. package/dist/components/Select/Select.d.ts.map +1 -0
  95. package/dist/components/Select/index.d.ts +3 -0
  96. package/dist/components/Select/index.d.ts.map +1 -0
  97. package/dist/components/Sidebar/Sidebar.d.ts +15 -0
  98. package/dist/components/Sidebar/Sidebar.d.ts.map +1 -0
  99. package/dist/components/Sidebar/SidebarItem.d.ts +14 -0
  100. package/dist/components/Sidebar/SidebarItem.d.ts.map +1 -0
  101. package/dist/components/Sidebar/SidebarSection.d.ts +10 -0
  102. package/dist/components/Sidebar/SidebarSection.d.ts.map +1 -0
  103. package/dist/components/Sidebar/index.d.ts +7 -0
  104. package/dist/components/Sidebar/index.d.ts.map +1 -0
  105. package/dist/components/Skeleton/Skeleton.d.ts +17 -0
  106. package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
  107. package/dist/components/Skeleton/index.d.ts +3 -0
  108. package/dist/components/Skeleton/index.d.ts.map +1 -0
  109. package/dist/components/Slider/Slider.d.ts +16 -0
  110. package/dist/components/Slider/Slider.d.ts.map +1 -0
  111. package/dist/components/Slider/index.d.ts +3 -0
  112. package/dist/components/Slider/index.d.ts.map +1 -0
  113. package/dist/components/Surface/Surface.d.ts +59 -0
  114. package/dist/components/Surface/Surface.d.ts.map +1 -0
  115. package/dist/components/Surface/index.d.ts +3 -0
  116. package/dist/components/Surface/index.d.ts.map +1 -0
  117. package/dist/components/Tab/Tab.d.ts +21 -0
  118. package/dist/components/Tab/Tab.d.ts.map +1 -0
  119. package/dist/components/Tab/index.d.ts +3 -0
  120. package/dist/components/Tab/index.d.ts.map +1 -0
  121. package/dist/components/TabGroup/TabGroup.d.ts +26 -0
  122. package/dist/components/TabGroup/TabGroup.d.ts.map +1 -0
  123. package/dist/components/TabGroup/index.d.ts +3 -0
  124. package/dist/components/TabGroup/index.d.ts.map +1 -0
  125. package/dist/components/Table/Table.d.ts +46 -0
  126. package/dist/components/Table/Table.d.ts.map +1 -0
  127. package/dist/components/Table/index.d.ts +3 -0
  128. package/dist/components/Table/index.d.ts.map +1 -0
  129. package/dist/components/Tag/Tag.d.ts +38 -0
  130. package/dist/components/Tag/Tag.d.ts.map +1 -0
  131. package/dist/components/Tag/index.d.ts +3 -0
  132. package/dist/components/Tag/index.d.ts.map +1 -0
  133. package/dist/components/Text/Text.d.ts +41 -0
  134. package/dist/components/Text/Text.d.ts.map +1 -0
  135. package/dist/components/Text/index.d.ts +3 -0
  136. package/dist/components/Text/index.d.ts.map +1 -0
  137. package/dist/components/Toast/Toast.d.ts +33 -0
  138. package/dist/components/Toast/Toast.d.ts.map +1 -0
  139. package/dist/components/Toast/index.d.ts +3 -0
  140. package/dist/components/Toast/index.d.ts.map +1 -0
  141. package/dist/components/Toggle/Toggle.d.ts +17 -0
  142. package/dist/components/Toggle/Toggle.d.ts.map +1 -0
  143. package/dist/components/Toggle/index.d.ts +3 -0
  144. package/dist/components/Toggle/index.d.ts.map +1 -0
  145. package/dist/components/ToggleButton/ToggleButton.d.ts +35 -0
  146. package/dist/components/ToggleButton/ToggleButton.d.ts.map +1 -0
  147. package/dist/components/ToggleButton/index.d.ts +3 -0
  148. package/dist/components/ToggleButton/index.d.ts.map +1 -0
  149. package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts +24 -0
  150. package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts.map +1 -0
  151. package/dist/components/ToggleButtonGroup/index.d.ts +3 -0
  152. package/dist/components/ToggleButtonGroup/index.d.ts.map +1 -0
  153. package/dist/components/Tooltip/Tooltip.d.ts +17 -0
  154. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
  155. package/dist/components/Tooltip/index.d.ts +3 -0
  156. package/dist/components/Tooltip/index.d.ts.map +1 -0
  157. package/dist/index.css +1 -1
  158. package/dist/index.d.ts +82 -969
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +1641 -1165
  161. package/dist/index.js.map +1 -1
  162. package/dist/r/badge.json +1 -1
  163. package/dist/r/banner.json +2 -2
  164. package/dist/r/loader.json +2 -2
  165. package/dist/r/menu.json +4 -4
  166. package/dist/r/select.json +1 -1
  167. package/dist/r/tab-group.json +1 -1
  168. package/dist/r/tab.json +1 -1
  169. package/dist/r/toggle.json +1 -1
  170. package/dist/types/icons.d.ts +14 -0
  171. package/dist/types/icons.d.ts.map +1 -0
  172. package/dist/types/index.d.ts +2 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/utils/cn.d.ts +5 -0
  175. package/dist/utils/cn.d.ts.map +1 -0
  176. package/dist/utils/css-tokens.d.ts +11 -0
  177. package/dist/utils/css-tokens.d.ts.map +1 -0
  178. package/package.json +17 -12
  179. package/src/components/Accordion/Accordion.tsx +10 -1
  180. package/src/components/Badge/Badge.stories.tsx +32 -0
  181. package/src/components/Badge/Badge.tsx +19 -2
  182. package/src/components/Banner/Banner.module.css +38 -0
  183. package/src/components/Banner/Banner.tsx +34 -4
  184. package/src/components/Button/Button.stories.tsx +1 -1
  185. package/src/components/Button/Button.tsx +42 -11
  186. package/src/components/Checkbox/Checkbox.tsx +6 -3
  187. package/src/components/Field/Field.tsx +20 -7
  188. package/src/components/Input/Input.module.css +21 -0
  189. package/src/components/Input/Input.tsx +62 -21
  190. package/src/components/Loader/Loader.module.css +17 -0
  191. package/src/components/Loader/Loader.stories.tsx +9 -0
  192. package/src/components/Loader/Loader.tsx +42 -20
  193. package/src/components/Menu/DestructiveMenuItem.tsx +59 -6
  194. package/src/components/Menu/Menu.tsx +160 -11
  195. package/src/components/Menu/MenuItem.module.css +2 -0
  196. package/src/components/Menu/MenuItem.tsx +48 -5
  197. package/src/components/Modal/Modal.tsx +81 -4
  198. package/src/components/Radio/Radio.tsx +117 -29
  199. package/src/components/Scrollbar/Scrollbar.module.css +5 -0
  200. package/src/components/Scrollbar/Scrollbar.tsx +18 -4
  201. package/src/components/Select/Select.tsx +139 -9
  202. package/src/components/Sidebar/Sidebar.module.css +7 -0
  203. package/src/components/Sidebar/Sidebar.tsx +102 -4
  204. package/src/components/Slider/Slider.module.css +5 -1
  205. package/src/components/Slider/Slider.stories.tsx +25 -0
  206. package/src/components/Slider/Slider.tsx +4 -4
  207. package/src/components/Tab/Tab.tsx +32 -2
  208. package/src/components/TabGroup/TabGroup.stories.tsx +46 -15
  209. package/src/components/TabGroup/TabGroup.tsx +77 -10
  210. package/src/components/Table/Table.module.css +10 -0
  211. package/src/components/Table/Table.tsx +58 -29
  212. package/src/components/Tag/Tag.tsx +5 -2
  213. package/src/components/Toast/Toast.tsx +44 -8
  214. package/src/components/Toggle/Toggle.tsx +23 -22
  215. package/src/components/Tooltip/Tooltip.tsx +42 -6
@@ -1,4 +1,4 @@
1
- import React, { forwardRef } from 'react';
1
+ import React, { forwardRef, useRef } from 'react';
2
2
  import { cn } from '@/utils/cn';
3
3
  import { Text } from '@/components/Text';
4
4
  import styles from './Radio.module.css';
@@ -16,8 +16,10 @@ export interface RadioGroupProps {
16
16
  onChange: (value: string) => void;
17
17
  /** Radio options to render. */
18
18
  options: RadioOption[];
19
- /** Group label for accessibility. */
19
+ /** Accessible name for the group. One of `aria-label` or `aria-labelledby` is required. */
20
20
  'aria-label'?: string;
21
+ /** ID of an element labeling the group. One of `aria-label` or `aria-labelledby` is required. */
22
+ 'aria-labelledby'?: string;
21
23
  /** Layout direction. */
22
24
  direction?: 'vertical' | 'horizontal';
23
25
  /** Disables the entire group. */
@@ -32,33 +34,111 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
32
34
  onChange,
33
35
  options,
34
36
  'aria-label': ariaLabel,
37
+ 'aria-labelledby': ariaLabelledBy,
35
38
  direction = 'vertical',
36
39
  inactive = false,
37
40
  className,
38
41
  },
39
42
  ref
40
- ) => (
41
- <div
42
- ref={ref}
43
- role="radiogroup"
44
- aria-label={ariaLabel}
45
- className={cn(
46
- styles.group,
47
- direction === 'horizontal' && styles.horizontal,
48
- className
49
- )}
50
- >
51
- {options.map(opt => (
52
- <RadioItem
53
- key={opt.value}
54
- label={opt.label}
55
- checked={value === opt.value}
56
- inactive={inactive || opt.inactive}
57
- onChange={() => onChange(opt.value)}
58
- />
59
- ))}
60
- </div>
61
- )
43
+ ) => {
44
+ if (!ariaLabel && !ariaLabelledBy) {
45
+ const env = (globalThis as { process?: { env?: { NODE_ENV?: string } } })
46
+ .process?.env?.NODE_ENV;
47
+ if (env !== 'production') {
48
+ console.warn(
49
+ 'RadioGroup: an accessible name is required. Pass either `aria-label` or `aria-labelledby`.'
50
+ );
51
+ }
52
+ }
53
+
54
+ const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
55
+
56
+ const isActive = (i: number) =>
57
+ i >= 0 && i < options.length && !inactive && !options[i].inactive;
58
+
59
+ const activeIndices = options.reduce<number[]>((acc, _, i) => {
60
+ if (isActive(i)) acc.push(i);
61
+ return acc;
62
+ }, []);
63
+
64
+ const selectedIdx = options.findIndex(o => o.value === value);
65
+ const focusableIdx = isActive(selectedIdx)
66
+ ? selectedIdx
67
+ : (activeIndices[0] ?? -1);
68
+
69
+ const focusAndSelect = (i: number) => {
70
+ itemRefs.current[i]?.focus();
71
+ if (options[i].value !== value) onChange(options[i].value);
72
+ };
73
+
74
+ const moveFocus = (from: number, delta: 1 | -1) => {
75
+ if (activeIndices.length === 0) return;
76
+ const pos = activeIndices.indexOf(from);
77
+ // If focus somehow lands on an inactive radio, anchor at the boundary so
78
+ // the first arrow keystroke moves to the nearest active option.
79
+ const startPos = pos === -1 ? (delta === 1 ? -1 : 0) : pos;
80
+ const nextPos =
81
+ (startPos + delta + activeIndices.length) % activeIndices.length;
82
+ focusAndSelect(activeIndices[nextPos]);
83
+ };
84
+
85
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
86
+ const target = e.target as HTMLElement;
87
+ const idx = itemRefs.current.findIndex(el => el === target);
88
+ if (idx === -1) return;
89
+
90
+ switch (e.key) {
91
+ case 'ArrowDown':
92
+ case 'ArrowRight':
93
+ e.preventDefault();
94
+ moveFocus(idx, 1);
95
+ break;
96
+ case 'ArrowUp':
97
+ case 'ArrowLeft':
98
+ e.preventDefault();
99
+ moveFocus(idx, -1);
100
+ break;
101
+ case 'Home':
102
+ e.preventDefault();
103
+ if (activeIndices.length) focusAndSelect(activeIndices[0]);
104
+ break;
105
+ case 'End':
106
+ e.preventDefault();
107
+ if (activeIndices.length)
108
+ focusAndSelect(activeIndices[activeIndices.length - 1]);
109
+ break;
110
+ }
111
+ };
112
+
113
+ return (
114
+ <div
115
+ ref={ref}
116
+ role="radiogroup"
117
+ aria-label={ariaLabel}
118
+ aria-labelledby={ariaLabelledBy}
119
+ onKeyDown={handleKeyDown}
120
+ className={cn(
121
+ styles.group,
122
+ direction === 'horizontal' && styles.horizontal,
123
+ className
124
+ )}
125
+ >
126
+ {options.map((opt, i) => (
127
+ <RadioItem
128
+ key={opt.value}
129
+ ref={el => {
130
+ itemRefs.current[i] = el;
131
+ }}
132
+ label={opt.label}
133
+ checked={value === opt.value}
134
+ inactive={inactive || opt.inactive}
135
+ tabIndex={i === focusableIdx ? 0 : -1}
136
+ onChange={() => onChange(opt.value)}
137
+ />
138
+ ))}
139
+ </div>
140
+ );
141
+ }
62
142
  );
63
143
 
64
144
  RadioGroup.displayName = 'RadioGroup';
@@ -70,27 +150,35 @@ export interface RadioItemProps {
70
150
  checked?: boolean;
71
151
  onChange?: () => void;
72
152
  inactive?: boolean;
153
+ /** Roving tabindex value. RadioGroup manages this; defaults to -1 when inactive, else 0. */
154
+ tabIndex?: number;
73
155
  className?: string;
74
156
  }
75
157
 
76
158
  export const RadioItem = forwardRef<HTMLDivElement, RadioItemProps>(
77
- ({ label, checked = false, onChange, inactive = false, className }, ref) => {
78
- const handleClick = () => {
159
+ (
160
+ { label, checked = false, onChange, inactive = false, tabIndex, className },
161
+ ref
162
+ ) => {
163
+ const handleActivate = () => {
79
164
  if (!inactive && !checked) onChange?.();
80
165
  };
81
166
 
167
+ const resolvedTabIndex = tabIndex ?? (inactive ? -1 : 0);
168
+
82
169
  return (
83
170
  <div
84
171
  ref={ref}
85
172
  role="radio"
86
173
  aria-checked={checked}
87
- tabIndex={inactive ? -1 : 0}
174
+ aria-disabled={inactive || undefined}
175
+ tabIndex={resolvedTabIndex}
88
176
  className={cn(styles.radio, inactive && styles.inactive, className)}
89
- onClick={handleClick}
177
+ onClick={handleActivate}
90
178
  onKeyDown={e => {
91
179
  if (e.key === ' ' || e.key === 'Enter') {
92
180
  e.preventDefault();
93
- handleClick();
181
+ handleActivate();
94
182
  }
95
183
  }}
96
184
  >
@@ -17,6 +17,11 @@
17
17
 
18
18
  .content::-webkit-scrollbar { display: none; }
19
19
 
20
+ .content:focus-visible {
21
+ outline: var(--dimension-stroke-width-025) solid var(--color-interaction-focus);
22
+ outline-offset: calc(var(--dimension-space-025) * -1);
23
+ }
24
+
20
25
  .track {
21
26
  position: absolute;
22
27
  top: 0; right: 0; bottom: 0;
@@ -158,7 +158,15 @@ export const Scrollbar: React.FC<ScrollbarProps> = ({
158
158
  const showThumb = thumbHeight > 0;
159
159
  const showThumbH = thumbWidth > 0;
160
160
  const showTrack = showTrackOnHover && (isPointerInside || isDragging || isDraggingH);
161
-
161
+ const isScrollable = showThumb || showThumbH;
162
+
163
+ // A11y model: the custom thumbs are a visual reskin of the native scrollbar,
164
+ // not a separate widget. The `.content` div retains native `overflow: auto`,
165
+ // so when it is focusable the browser handles arrow keys, Page Up/Down,
166
+ // Home/End, and Space for free — implementing the full `role="scrollbar"`
167
+ // ARIA pattern would just re-create that behavior. We therefore expose the
168
+ // scroll container itself as the keyboard target (tabIndex=0 only when
169
+ // content overflows) and hide the decorative thumbs from assistive tech.
162
170
  return (
163
171
  <div
164
172
  ref={containerRef}
@@ -168,15 +176,21 @@ export const Scrollbar: React.FC<ScrollbarProps> = ({
168
176
  data-dragging={isDragging || isDraggingH}
169
177
  data-show-track={showTrack ? 'true' : 'false'}
170
178
  >
171
- <div ref={contentRef} className={styles.content}>{children}</div>
179
+ <div
180
+ ref={contentRef}
181
+ className={styles.content}
182
+ tabIndex={isScrollable ? 0 : undefined}
183
+ >
184
+ {children}
185
+ </div>
172
186
  {fadeOverlay}
173
187
  {showThumb && (
174
- <div className={styles.track}>
188
+ <div className={styles.track} aria-hidden="true">
175
189
  <div ref={thumbRef} className={styles.thumb} style={{ height: `${thumbHeight}px`, top: `${thumbTop}px` }} onMouseDown={handleThumbMouseDown} />
176
190
  </div>
177
191
  )}
178
192
  {showThumbH && (
179
- <div className={styles.trackHorizontal}>
193
+ <div className={styles.trackHorizontal} aria-hidden="true">
180
194
  <div ref={thumbHRef} className={styles.thumbHorizontal} style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }} onMouseDown={handleThumbHMouseDown} />
181
195
  </div>
182
196
  )}
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, useState, useRef } from 'react';
1
+ import React, { forwardRef, useId, useState, useRef, useEffect, useCallback } from 'react';
2
2
  import { Surface } from '@/components/Surface';
3
3
  import { Text } from '@/components/Text';
4
4
  import { Menu } from '@/components/Menu';
@@ -20,33 +20,152 @@ export interface SelectProps {
20
20
  inactive?: boolean;
21
21
  id?: string;
22
22
  chevronIcon?: IconComponent;
23
+ /** Accessible name. Required when no associated `<label htmlFor>` element exists. */
24
+ 'aria-label'?: string;
25
+ /** Accessible name reference. Use when a visible label already exists outside the trigger. */
26
+ 'aria-labelledby'?: string;
23
27
  }
24
28
 
25
29
  export const Select = forwardRef<HTMLDivElement, SelectProps>(
26
- ({ value, onChange, options, placeholder = 'Select option', className, inactive = false, id, chevronIcon: ChevronIcon }, ref) => {
30
+ (
31
+ {
32
+ value,
33
+ onChange,
34
+ options,
35
+ placeholder = 'Select option',
36
+ className,
37
+ inactive = false,
38
+ id,
39
+ chevronIcon: ChevronIcon,
40
+ 'aria-label': ariaLabel,
41
+ 'aria-labelledby': ariaLabelledBy,
42
+ },
43
+ ref
44
+ ) => {
27
45
  const [isMenuOpen, setIsMenuOpen] = useState(false);
46
+ const [activeIndex, setActiveIndex] = useState(-1);
28
47
  const buttonRef = useRef<HTMLButtonElement>(null);
29
48
 
30
- const selectedOption = options.find(opt => opt.value === value);
49
+ const reactId = useId();
50
+ const triggerId = id ?? `select-${reactId}`;
51
+ const listboxId = `${triggerId}-listbox`;
52
+ const getOptionId = useCallback((index: number) => `${triggerId}-option-${index}`, [triggerId]);
53
+
54
+ const selectedIndex = options.findIndex(opt => opt.value === value);
55
+ const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
31
56
  const selectedLabel = selectedOption?.label || placeholder;
32
57
 
33
- const menuItems: MenuItemData[] = options.map(option => ({
34
- label: option.label,
35
- onClick: () => {
58
+ const commitSelection = useCallback(
59
+ (index: number) => {
60
+ const option = options[index];
61
+ if (!option) return;
36
62
  onChange(option.value);
37
63
  setIsMenuOpen(false);
38
64
  },
65
+ [options, onChange]
66
+ );
67
+
68
+ const openWith = useCallback(
69
+ (initialIndex: number) => {
70
+ if (inactive || options.length === 0) return;
71
+ const clamped = Math.max(0, Math.min(options.length - 1, initialIndex));
72
+ setActiveIndex(clamped);
73
+ setIsMenuOpen(true);
74
+ },
75
+ [inactive, options.length]
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (isMenuOpen) {
80
+ setActiveIndex(prev => {
81
+ if (prev >= 0 && prev < options.length) return prev;
82
+ return selectedIndex >= 0 ? selectedIndex : 0;
83
+ });
84
+ } else {
85
+ setActiveIndex(-1);
86
+ }
87
+ }, [isMenuOpen, options.length, selectedIndex]);
88
+
89
+ const menuItems: MenuItemData[] = options.map((option, index) => ({
90
+ label: option.label,
91
+ onClick: () => commitSelection(index),
39
92
  isSelected: option.value === value,
40
93
  }));
41
94
 
42
95
  const handleToggle = () => {
43
- if (!inactive) setIsMenuOpen(prev => !prev);
96
+ if (inactive) return;
97
+ if (isMenuOpen) {
98
+ setIsMenuOpen(false);
99
+ } else {
100
+ openWith(selectedIndex >= 0 ? selectedIndex : 0);
101
+ }
44
102
  };
45
103
 
104
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
105
+ if (inactive || options.length === 0) return;
106
+ const last = options.length - 1;
107
+
108
+ if (!isMenuOpen) {
109
+ switch (event.key) {
110
+ case 'ArrowDown':
111
+ case 'ArrowUp':
112
+ case 'Enter':
113
+ case ' ':
114
+ event.preventDefault();
115
+ openWith(selectedIndex >= 0 ? selectedIndex : 0);
116
+ return;
117
+ case 'Home':
118
+ event.preventDefault();
119
+ openWith(0);
120
+ return;
121
+ case 'End':
122
+ event.preventDefault();
123
+ openWith(last);
124
+ return;
125
+ default:
126
+ return;
127
+ }
128
+ }
129
+
130
+ switch (event.key) {
131
+ case 'ArrowDown':
132
+ event.preventDefault();
133
+ setActiveIndex(prev => Math.min(last, (prev < 0 ? -1 : prev) + 1));
134
+ break;
135
+ case 'ArrowUp':
136
+ event.preventDefault();
137
+ setActiveIndex(prev => Math.max(0, (prev < 0 ? options.length : prev) - 1));
138
+ break;
139
+ case 'Home':
140
+ event.preventDefault();
141
+ setActiveIndex(0);
142
+ break;
143
+ case 'End':
144
+ event.preventDefault();
145
+ setActiveIndex(last);
146
+ break;
147
+ case 'Enter':
148
+ case ' ':
149
+ event.preventDefault();
150
+ if (activeIndex >= 0) commitSelection(activeIndex);
151
+ break;
152
+ case 'Escape':
153
+ event.preventDefault();
154
+ setIsMenuOpen(false);
155
+ break;
156
+ case 'Tab':
157
+ setIsMenuOpen(false);
158
+ break;
159
+ }
160
+ };
161
+
162
+ const activeDescendantId =
163
+ isMenuOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined;
164
+
46
165
  return (
47
166
  <div ref={ref} className={`${styles.selectWrapper} ${className || ''}`}>
48
167
  <Surface
49
- id={id}
168
+ id={triggerId}
50
169
  ref={buttonRef as React.RefObject<HTMLElement>}
51
170
  as="button"
52
171
  type="button"
@@ -56,12 +175,19 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
56
175
  selected={isMenuOpen}
57
176
  className={styles.selectButton}
58
177
  onClick={handleToggle}
178
+ onKeyDown={handleKeyDown}
59
179
  inactive={inactive}
180
+ role="combobox"
181
+ aria-haspopup="listbox"
182
+ aria-expanded={isMenuOpen}
183
+ aria-controls={isMenuOpen ? listboxId : undefined}
184
+ aria-activedescendant={activeDescendantId}
185
+ aria-label={ariaLabel}
186
+ aria-labelledby={ariaLabelledBy}
60
187
  >
61
188
  <Text
62
189
  variant="text-body-medium"
63
190
  as="span"
64
-
65
191
  color={selectedOption ? 'primary' : 'secondary'}
66
192
  >
67
193
  {selectedLabel}
@@ -83,6 +209,10 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
83
209
  alignOffset={-4}
84
210
  matchAnchorWidth
85
211
  matchAnchorWidthOffset={8}
212
+ role="listbox"
213
+ id={listboxId}
214
+ activeIndex={activeIndex}
215
+ getOptionId={getOptionId}
86
216
  />
87
217
  </div>
88
218
  );
@@ -66,6 +66,13 @@
66
66
  opacity: 0.5;
67
67
  }
68
68
 
69
+ .resizeHandle:focus-visible {
70
+ outline: var(--dimension-stroke-width-025) solid var(--color-border-brand);
71
+ outline-offset: var(--dimension-stroke-width-015);
72
+ background-color: var(--color-border-brand);
73
+ opacity: 0.5;
74
+ }
75
+
69
76
  /* Sections */
70
77
  .section {
71
78
  display: flex;
@@ -18,8 +18,19 @@ export interface SidebarProps {
18
18
 
19
19
  const MINI_WIDTH = 56;
20
20
  const DEFAULT_WIDTH = 240;
21
+ const MAX_WIDTH = 480;
21
22
  const MIN_DRAG_WIDTH = 180;
22
23
  const SNAP_THRESHOLD = 140;
24
+ const KEYBOARD_STEP = 16;
25
+
26
+ const FOCUSABLE_SELECTOR = [
27
+ 'a[href]',
28
+ 'button:not([disabled])',
29
+ 'input:not([disabled])',
30
+ 'select:not([disabled])',
31
+ 'textarea:not([disabled])',
32
+ '[tabindex]:not([tabindex="-1"])',
33
+ ].join(',');
23
34
 
24
35
  export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
25
36
  ({
@@ -35,6 +46,7 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
35
46
  }, ref) => {
36
47
  const internalRef = useRef<HTMLElement>(null);
37
48
  const sidebarRef = (ref as React.RefObject<HTMLElement>) || internalRef;
49
+ const triggerRef = useRef<HTMLElement | null>(null);
38
50
  const [isResizing, setIsResizing] = useState(false);
39
51
 
40
52
  const isMini = width === 'mini';
@@ -44,7 +56,6 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
44
56
  if (!resizable || !onWidthChange) return;
45
57
  e.preventDefault();
46
58
  setIsResizing(true);
47
- const startX = e.clientX;
48
59
 
49
60
  const handleMouseMove = (e: MouseEvent) => {
50
61
  const newWidth = e.clientX;
@@ -71,6 +82,39 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
71
82
  document.addEventListener('mouseup', handleMouseUp);
72
83
  }, [resizable, onWidthChange]);
73
84
 
85
+ // Keyboard resize: arrows step, Home → mini, End → max.
86
+ // Stepping below MIN_DRAG_WIDTH snaps to 'mini'; stepping up from 'mini' snaps to 'default'.
87
+ const handleResizeKeyDown = useCallback((e: React.KeyboardEvent) => {
88
+ if (!resizable || !onWidthChange) return;
89
+ switch (e.key) {
90
+ case 'ArrowLeft': {
91
+ e.preventDefault();
92
+ if (width === 'mini') return;
93
+ const next = resolvedWidth - KEYBOARD_STEP;
94
+ onWidthChange(next < MIN_DRAG_WIDTH ? 'mini' : next);
95
+ break;
96
+ }
97
+ case 'ArrowRight': {
98
+ e.preventDefault();
99
+ if (width === 'mini') {
100
+ onWidthChange('default');
101
+ return;
102
+ }
103
+ const next = Math.min(resolvedWidth + KEYBOARD_STEP, MAX_WIDTH);
104
+ onWidthChange(next);
105
+ break;
106
+ }
107
+ case 'Home':
108
+ e.preventDefault();
109
+ onWidthChange('mini');
110
+ break;
111
+ case 'End':
112
+ e.preventDefault();
113
+ onWidthChange(MAX_WIDTH);
114
+ break;
115
+ }
116
+ }, [resizable, onWidthChange, width, resolvedWidth]);
117
+
74
118
  // Close sidebar on Escape (mobile)
75
119
  useEffect(() => {
76
120
  if (!isMobile || isCollapsed) return;
@@ -81,13 +125,62 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
81
125
  return () => document.removeEventListener('keydown', handleEscape);
82
126
  }, [isMobile, isCollapsed, onToggle]);
83
127
 
128
+ // Mobile dialog: capture trigger, focus first element, trap Tab, restore focus on close.
129
+ const isMobileOpen = isMobile && !isCollapsed;
130
+ useEffect(() => {
131
+ if (!isMobileOpen) return;
132
+ triggerRef.current = (document.activeElement as HTMLElement) ?? null;
133
+
134
+ const container = sidebarRef.current;
135
+ if (!container) return;
136
+
137
+ const focusables = () =>
138
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
139
+ (el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
140
+ );
141
+
142
+ const initial = focusables();
143
+ (initial[0] ?? container).focus();
144
+
145
+ const handleTab = (e: KeyboardEvent) => {
146
+ if (e.key !== 'Tab') return;
147
+ const items = focusables();
148
+ if (items.length === 0) {
149
+ e.preventDefault();
150
+ container.focus();
151
+ return;
152
+ }
153
+ const first = items[0];
154
+ const last = items[items.length - 1];
155
+ const active = document.activeElement as HTMLElement | null;
156
+ if (e.shiftKey && (active === first || !container.contains(active))) {
157
+ e.preventDefault();
158
+ last.focus();
159
+ } else if (!e.shiftKey && (active === last || !container.contains(active))) {
160
+ e.preventDefault();
161
+ first.focus();
162
+ }
163
+ };
164
+
165
+ document.addEventListener('keydown', handleTab);
166
+ return () => {
167
+ document.removeEventListener('keydown', handleTab);
168
+ triggerRef.current?.focus?.();
169
+ triggerRef.current = null;
170
+ };
171
+ }, [isMobileOpen, sidebarRef]);
172
+
84
173
  if (isCollapsed && !isMobile) return null;
85
174
 
175
+ const dialogProps = isMobileOpen
176
+ ? { role: 'dialog' as const, 'aria-modal': true, tabIndex: -1 }
177
+ : { role: 'navigation' as const };
178
+
86
179
  return (
87
180
  <>
88
- {/* Mobile backdrop */}
181
+ {/* Mobile backdrop — mouse-only convenience; Escape handler covers keyboard. */}
89
182
  {isMobile && !isCollapsed && (
90
- <div className={styles.backdrop} onClick={onToggle} aria-hidden />
183
+ <div className={styles.backdrop} onClick={onToggle} aria-hidden="true" />
91
184
  )}
92
185
  <aside
93
186
  ref={sidebarRef}
@@ -100,8 +193,8 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
100
193
  className
101
194
  )}
102
195
  style={{ width: isCollapsed ? 0 : resolvedWidth }}
103
- role="navigation"
104
196
  aria-label="Sidebar"
197
+ {...dialogProps}
105
198
  >
106
199
  <div className={styles.content}>
107
200
  {children}
@@ -115,9 +208,14 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
115
208
  <div
116
209
  className={styles.resizeHandle}
117
210
  onMouseDown={handleMouseDown}
211
+ onKeyDown={handleResizeKeyDown}
118
212
  role="separator"
119
213
  aria-orientation="vertical"
120
214
  aria-label="Resize sidebar"
215
+ aria-valuenow={resolvedWidth}
216
+ aria-valuemin={MINI_WIDTH}
217
+ aria-valuemax={MAX_WIDTH}
218
+ tabIndex={0}
121
219
  />
122
220
  )}
123
221
  </aside>
@@ -13,7 +13,10 @@
13
13
  box-shadow: var(--effect-elevation-depressed-sm); pointer-events: none; z-index: 1;
14
14
  }
15
15
 
16
- .fillTrack { position: absolute; top: var(--dimension-space-025); right: 0; bottom: var(--dimension-space-025); left: 0; z-index: 1; }
16
+ .fillTrack {
17
+ position: absolute; top: var(--dimension-space-025); right: 0; bottom: var(--dimension-space-025); left: 0;
18
+ z-index: 1; pointer-events: none;
19
+ }
17
20
 
18
21
  .fill {
19
22
  position: absolute; left: var(--dimension-space-025); top: 0; bottom: 0;
@@ -22,6 +25,7 @@
22
25
  box-shadow: var(--effect-elevation-elevated-sm);
23
26
  border-radius: var(--dimension-radius-050);
24
27
  transition: opacity var(--effect-motion-short-2);
28
+ pointer-events: none;
25
29
  }
26
30
 
27
31
  .trackAtMin .fill { opacity: 0; }
@@ -110,6 +110,31 @@ export const CustomRange: Story = {
110
110
  ),
111
111
  };
112
112
 
113
+ const SIGNAL_LABELS = ['Off', 'Low', 'Medium', 'High', 'Max'];
114
+
115
+ const SignalSlider = () => {
116
+ const [value, setValue] = useState(2);
117
+ return (
118
+ <Slider
119
+ label="Signal strength"
120
+ min={0}
121
+ max={4}
122
+ step={1}
123
+ value={value}
124
+ onChange={setValue}
125
+ valueText={SIGNAL_LABELS[value]}
126
+ />
127
+ );
128
+ };
129
+
130
+ export const NonNumericValueText: Story = {
131
+ render: () => (
132
+ <div style={{ width: 280 }}>
133
+ <SignalSlider />
134
+ </div>
135
+ ),
136
+ };
137
+
113
138
  export const Inactive: Story = {
114
139
  render: () => (
115
140
  <div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: 280 }}>