@ds-mo/ui 0.2.3 → 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 (217) 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/toast.json +1 -1
  170. package/dist/r/toggle.json +1 -1
  171. package/dist/types/icons.d.ts +14 -0
  172. package/dist/types/icons.d.ts.map +1 -0
  173. package/dist/types/index.d.ts +2 -0
  174. package/dist/types/index.d.ts.map +1 -0
  175. package/dist/utils/cn.d.ts +5 -0
  176. package/dist/utils/cn.d.ts.map +1 -0
  177. package/dist/utils/css-tokens.d.ts +11 -0
  178. package/dist/utils/css-tokens.d.ts.map +1 -0
  179. package/package.json +17 -12
  180. package/src/components/Accordion/Accordion.tsx +10 -1
  181. package/src/components/Badge/Badge.stories.tsx +32 -0
  182. package/src/components/Badge/Badge.tsx +19 -2
  183. package/src/components/Banner/Banner.module.css +38 -0
  184. package/src/components/Banner/Banner.tsx +34 -4
  185. package/src/components/Button/Button.mdx +143 -0
  186. package/src/components/Button/Button.stories.tsx +1 -1
  187. package/src/components/Button/Button.tsx +42 -11
  188. package/src/components/Checkbox/Checkbox.tsx +6 -3
  189. package/src/components/Field/Field.tsx +20 -7
  190. package/src/components/Input/Input.module.css +21 -0
  191. package/src/components/Input/Input.tsx +62 -21
  192. package/src/components/Loader/Loader.module.css +17 -0
  193. package/src/components/Loader/Loader.stories.tsx +9 -0
  194. package/src/components/Loader/Loader.tsx +42 -20
  195. package/src/components/Menu/DestructiveMenuItem.tsx +59 -6
  196. package/src/components/Menu/Menu.tsx +160 -11
  197. package/src/components/Menu/MenuItem.module.css +2 -0
  198. package/src/components/Menu/MenuItem.tsx +48 -5
  199. package/src/components/Modal/Modal.tsx +81 -4
  200. package/src/components/Radio/Radio.tsx +117 -29
  201. package/src/components/Scrollbar/Scrollbar.module.css +5 -0
  202. package/src/components/Scrollbar/Scrollbar.tsx +18 -4
  203. package/src/components/Select/Select.tsx +139 -9
  204. package/src/components/Sidebar/Sidebar.module.css +7 -0
  205. package/src/components/Sidebar/Sidebar.tsx +102 -4
  206. package/src/components/Slider/Slider.module.css +5 -1
  207. package/src/components/Slider/Slider.stories.tsx +25 -0
  208. package/src/components/Slider/Slider.tsx +4 -4
  209. package/src/components/Tab/Tab.tsx +32 -2
  210. package/src/components/TabGroup/TabGroup.stories.tsx +46 -15
  211. package/src/components/TabGroup/TabGroup.tsx +77 -10
  212. package/src/components/Table/Table.module.css +10 -0
  213. package/src/components/Table/Table.tsx +58 -29
  214. package/src/components/Tag/Tag.tsx +5 -2
  215. package/src/components/Toast/Toast.tsx +44 -8
  216. package/src/components/Toggle/Toggle.tsx +23 -22
  217. package/src/components/Tooltip/Tooltip.tsx +42 -6
@@ -13,10 +13,12 @@ export interface SliderProps {
13
13
  id?: string;
14
14
  inactive?: boolean;
15
15
  className?: string;
16
+ /** Human-readable text for assistive tech when the numeric value alone isn't meaningful (e.g. "Low", "Medium", "High"). Maps to `aria-valuetext`. */
17
+ valueText?: string;
16
18
  }
17
19
 
18
20
  export const Slider = forwardRef<HTMLDivElement, SliderProps>(
19
- ({ value, onChange, min = 0, max = 100, step = 1, label, id: idProp, inactive = false, className }, ref) => {
21
+ ({ value, onChange, min = 0, max = 100, step = 1, label, id: idProp, inactive = false, className, valueText }, ref) => {
20
22
  const generatedId = useId();
21
23
  const id = idProp ?? generatedId;
22
24
  const containerRef = useRef<HTMLDivElement>(null);
@@ -61,9 +63,7 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
61
63
  onChange={handleInput}
62
64
  disabled={inactive}
63
65
  className={styles.input}
64
- aria-valuemin={min}
65
- aria-valuemax={max}
66
- aria-valuenow={value}
66
+ aria-valuetext={valueText}
67
67
  />
68
68
  </div>
69
69
  </div>
@@ -6,29 +6,59 @@ import styles from './Tab.module.css';
6
6
  export type TabBackground = 'faint' | 'medium' | 'bold' | 'strong' | 'always-dark';
7
7
 
8
8
  export interface TabProps {
9
+ /** id of this tab. Pair with the tabpanel's `aria-labelledby` to wire SR semantics. */
9
10
  id?: string;
11
+ /** id of the tabpanel this tab controls. Sets `aria-controls`. */
12
+ controls?: string;
10
13
  label: string;
11
14
  isSelected?: boolean;
15
+ disabled?: boolean;
12
16
  /** Parent surface context. Adjusts hover tokens and fg for tabs on colored backgrounds. */
13
17
  background?: TabBackground;
14
18
  onClick?: () => void;
19
+ onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
20
+ onFocus?: React.FocusEventHandler<HTMLButtonElement>;
21
+ /** Override tab order. Defaults to a roving pattern (0 when selected, -1 otherwise). */
22
+ tabIndex?: number;
15
23
  className?: string;
16
24
  }
17
25
 
18
26
  export const Tab = forwardRef<HTMLButtonElement, TabProps>(
19
- ({ id, label, isSelected = false, background, onClick, className }, ref) => {
27
+ (
28
+ {
29
+ id,
30
+ controls,
31
+ label,
32
+ isSelected = false,
33
+ disabled = false,
34
+ background,
35
+ onClick,
36
+ onKeyDown,
37
+ onFocus,
38
+ tabIndex,
39
+ className,
40
+ },
41
+ ref,
42
+ ) => {
20
43
  const bgClass = background && background !== 'faint'
21
44
  ? styles[background === 'always-dark' ? 'onAlwaysDark' : `on${background.charAt(0).toUpperCase() + background.slice(1)}`]
22
45
  : undefined;
46
+ const effectiveTabIndex = tabIndex ?? (isSelected ? 0 : -1);
23
47
  return (
24
48
  <button
25
49
  ref={ref}
26
50
  id={id}
27
51
  className={cn(styles.tab, isSelected && styles.tabSelected, bgClass, className)}
28
52
  onClick={onClick}
53
+ onKeyDown={onKeyDown}
54
+ onFocus={onFocus}
29
55
  type="button"
30
- aria-selected={isSelected}
31
56
  role="tab"
57
+ aria-selected={isSelected}
58
+ aria-controls={controls}
59
+ aria-disabled={disabled || undefined}
60
+ disabled={disabled}
61
+ tabIndex={effectiveTabIndex}
32
62
  >
33
63
  <Text variant={isSelected ? 'text-body-medium-emphasis' : 'text-body-medium'} as="span">
34
64
  {label}
@@ -11,19 +11,33 @@ export default meta;
11
11
  type Story = StoryObj<typeof TabGroup>;
12
12
 
13
13
  const TABS = [
14
- { label: 'Overview', id: 'overview' },
15
- { label: 'Activity', id: 'activity' },
16
- { label: 'Settings', id: 'settings' },
17
- { label: 'Members', id: 'members' },
14
+ { label: 'Overview', id: 'tab-overview', panelId: 'panel-overview' },
15
+ { label: 'Activity', id: 'tab-activity', panelId: 'panel-activity' },
16
+ { label: 'Settings', id: 'tab-settings', panelId: 'panel-settings' },
17
+ { label: 'Members', id: 'tab-members', panelId: 'panel-members' },
18
18
  ];
19
19
 
20
20
  const Controlled = (props: Partial<React.ComponentProps<typeof TabGroup>>) => {
21
+ const tabs = props.tabs ?? TABS;
21
22
  const [active, setActive] = useState(0);
23
+ const current = tabs[active];
22
24
  return (
23
25
  <div>
24
- <TabGroup tabs={TABS} activeIndex={active} onTabChange={setActive} {...props} />
25
- <div style={{ padding: '16px 0', color: 'var(--color-foreground-secondary)', fontSize: 14 }}>
26
- Content for: <strong>{TABS[active].label}</strong>
26
+ <TabGroup
27
+ tabs={tabs}
28
+ activeIndex={active}
29
+ onTabChange={setActive}
30
+ aria-label="Section navigation"
31
+ {...props}
32
+ />
33
+ <div
34
+ role="tabpanel"
35
+ id={current.panelId}
36
+ aria-labelledby={current.id}
37
+ tabIndex={0}
38
+ style={{ padding: '16px 0', color: 'var(--color-foreground-secondary)', fontSize: 14 }}
39
+ >
40
+ Content for: <strong>{current.label}</strong>
27
41
  </div>
28
42
  </div>
29
43
  );
@@ -41,13 +55,13 @@ export const ManyTabs: Story = {
41
55
  render: () => (
42
56
  <Controlled
43
57
  tabs={[
44
- { label: 'Dashboard' },
45
- { label: 'Analytics' },
46
- { label: 'Reports' },
47
- { label: 'Users' },
48
- { label: 'Settings' },
49
- { label: 'Billing' },
50
- { label: 'Integrations' },
58
+ { label: 'Dashboard', id: 'tab-dashboard', panelId: 'panel-dashboard' },
59
+ { label: 'Analytics', id: 'tab-analytics', panelId: 'panel-analytics' },
60
+ { label: 'Reports', id: 'tab-reports', panelId: 'panel-reports' },
61
+ { label: 'Users', id: 'tab-users', panelId: 'panel-users' },
62
+ { label: 'Settings', id: 'tab-settings-many', panelId: 'panel-settings-many' },
63
+ { label: 'Billing', id: 'tab-billing', panelId: 'panel-billing' },
64
+ { label: 'Integrations', id: 'tab-integrations', panelId: 'panel-integrations' },
51
65
  ]}
52
66
  />
53
67
  ),
@@ -55,6 +69,23 @@ export const ManyTabs: Story = {
55
69
 
56
70
  export const TwoTabs: Story = {
57
71
  render: () => (
58
- <Controlled tabs={[{ label: 'Active' }, { label: 'Archived' }]} />
72
+ <Controlled
73
+ tabs={[
74
+ { label: 'Active', id: 'tab-active', panelId: 'panel-active' },
75
+ { label: 'Archived', id: 'tab-archived', panelId: 'panel-archived' },
76
+ ]}
77
+ />
78
+ ),
79
+ };
80
+
81
+ export const WithDisabledTab: Story = {
82
+ render: () => (
83
+ <Controlled
84
+ tabs={[
85
+ { label: 'Overview', id: 'tab-d-overview', panelId: 'panel-d-overview' },
86
+ { label: 'Activity', id: 'tab-d-activity', panelId: 'panel-d-activity', disabled: true },
87
+ { label: 'Settings', id: 'tab-d-settings', panelId: 'panel-d-settings' },
88
+ ]}
89
+ />
59
90
  ),
60
91
  };
@@ -5,7 +5,11 @@ import styles from './TabGroup.module.css';
5
5
 
6
6
  export interface TabGroupTab {
7
7
  label: string;
8
+ /** id for the tab. Pair with `panelId` to wire SR semantics on the consumer's tabpanel. */
8
9
  id?: string;
10
+ /** id of the tabpanel this tab controls. Forwarded to the Tab as `aria-controls`. */
11
+ panelId?: string;
12
+ disabled?: boolean;
9
13
  }
10
14
 
11
15
  export interface TabGroupProps {
@@ -13,26 +17,40 @@ export interface TabGroupProps {
13
17
  tabs: TabGroupTab[];
14
18
  /** Index of the active tab. */
15
19
  activeIndex?: number;
16
- /** Called when a tab is clicked. */
20
+ /** Called when a tab is activated (click or keyboard). */
17
21
  onTabChange?: (index: number) => void;
22
+ /** Tablist orientation. Determines which arrow keys navigate. */
23
+ orientation?: 'horizontal' | 'vertical';
24
+ /** Accessible label for the tablist. */
25
+ 'aria-label'?: string;
26
+ /** id of an external label for the tablist. */
27
+ 'aria-labelledby'?: string;
18
28
  className?: string;
19
29
  }
20
30
 
21
31
  export const TabGroup = forwardRef<HTMLDivElement, TabGroupProps>(
22
- ({ tabs, activeIndex = 0, onTabChange, className }, ref) => {
32
+ (
33
+ {
34
+ tabs,
35
+ activeIndex = 0,
36
+ onTabChange,
37
+ orientation = 'horizontal',
38
+ className,
39
+ 'aria-label': ariaLabel,
40
+ 'aria-labelledby': ariaLabelledBy,
41
+ },
42
+ ref,
43
+ ) => {
23
44
  const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
24
45
  const [indicator, setIndicator] = useState({ left: 0, width: 0 });
25
46
 
26
47
  const updateIndicator = useCallback(() => {
27
48
  const el = tabsRef.current[activeIndex];
28
49
  if (el) {
29
- const parent = el.parentElement;
30
- if (parent) {
31
- setIndicator({
32
- left: el.offsetLeft,
33
- width: el.offsetWidth,
34
- });
35
- }
50
+ setIndicator({
51
+ left: el.offsetLeft,
52
+ width: el.offsetWidth,
53
+ });
36
54
  }
37
55
  }, [activeIndex]);
38
56
 
@@ -40,7 +58,6 @@ export const TabGroup = forwardRef<HTMLDivElement, TabGroupProps>(
40
58
  updateIndicator();
41
59
  }, [updateIndicator]);
42
60
 
43
- // Recalculate on resize
44
61
  useEffect(() => {
45
62
  const observer = new ResizeObserver(updateIndicator);
46
63
  const container = tabsRef.current[0]?.parentElement;
@@ -48,10 +65,58 @@ export const TabGroup = forwardRef<HTMLDivElement, TabGroupProps>(
48
65
  return () => observer.disconnect();
49
66
  }, [updateIndicator]);
50
67
 
68
+ const findEnabled = useCallback(
69
+ (from: number, step: number): number => {
70
+ const len = tabs.length;
71
+ if (len === 0) return from;
72
+ let i = from;
73
+ for (let n = 0; n < len; n++) {
74
+ i = (i + step + len) % len;
75
+ if (!tabs[i].disabled) return i;
76
+ }
77
+ return from;
78
+ },
79
+ [tabs],
80
+ );
81
+
82
+ const activate = useCallback(
83
+ (index: number) => {
84
+ if (index !== activeIndex) onTabChange?.(index);
85
+ tabsRef.current[index]?.focus();
86
+ },
87
+ [activeIndex, onTabChange],
88
+ );
89
+
90
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
91
+ const isHorizontal = orientation !== 'vertical';
92
+ const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
93
+ const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
94
+
95
+ let nextIndex: number | null = null;
96
+ if (e.key === nextKey) {
97
+ nextIndex = findEnabled(activeIndex, 1);
98
+ } else if (e.key === prevKey) {
99
+ nextIndex = findEnabled(activeIndex, -1);
100
+ } else if (e.key === 'Home') {
101
+ nextIndex = findEnabled(-1, 1);
102
+ } else if (e.key === 'End') {
103
+ nextIndex = findEnabled(tabs.length, -1);
104
+ }
105
+
106
+ if (nextIndex !== null) {
107
+ e.preventDefault();
108
+ activate(nextIndex);
109
+ }
110
+ };
111
+
51
112
  return (
52
113
  <div
53
114
  ref={ref}
54
115
  role="tablist"
116
+ aria-label={ariaLabel}
117
+ aria-labelledby={ariaLabelledBy}
118
+ aria-orientation={orientation === 'vertical' ? 'vertical' : undefined}
119
+ onKeyDown={handleKeyDown}
55
120
  className={cn(styles.tabGroup, className)}
56
121
  >
57
122
  <div className={styles.tabsRow}>
@@ -60,8 +125,10 @@ export const TabGroup = forwardRef<HTMLDivElement, TabGroupProps>(
60
125
  key={tab.id ?? i}
61
126
  ref={el => { tabsRef.current[i] = el; }}
62
127
  id={tab.id}
128
+ controls={tab.panelId}
63
129
  label={tab.label}
64
130
  isSelected={i === activeIndex}
131
+ disabled={tab.disabled}
65
132
  onClick={() => onTabChange?.(i)}
66
133
  />
67
134
  ))}
@@ -43,6 +43,11 @@
43
43
  background-color: var(--color-background-translucent);
44
44
  }
45
45
 
46
+ .headerCell.sortable:focus-visible {
47
+ outline: var(--dimension-stroke-width-025) solid var(--color-interaction-focus);
48
+ outline-offset: calc(var(--dimension-space-025) * -1);
49
+ }
50
+
46
51
  .sortIndicator {
47
52
  font-size: 12px;
48
53
  color: var(--color-foreground-secondary);
@@ -64,6 +69,11 @@
64
69
  cursor: pointer;
65
70
  }
66
71
 
72
+ .rowClickable:focus-visible .cell {
73
+ background-color: var(--color-interaction-hover);
74
+ box-shadow: inset 0 0 0 var(--dimension-stroke-width-025) var(--color-interaction-focus);
75
+ }
76
+
67
77
  .cell {
68
78
  display: flex;
69
79
  align-items: center;
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useState, useCallback } from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { cn } from '@/utils/cn';
3
3
  import { Text } from '@/components/Text';
4
4
  import styles from './Table.module.css';
@@ -109,41 +109,61 @@ export function Table<T>({
109
109
  };
110
110
 
111
111
  return (
112
- <div className={cn(styles.tableWrapper, className)}>
112
+ <div className={cn(styles.tableWrapper, className)} aria-busy={loading || undefined}>
113
113
  <div className={styles.tableScroll}>
114
114
  <div className={styles.table} style={{ gridTemplateColumns }} role="table">
115
115
  {/* Header */}
116
116
  <div className={styles.headerRow} role="row">
117
- {visibleColumns.map(col => (
118
- <div
119
- key={col.id}
120
- className={cn(
121
- styles.headerCell,
122
- col.sortable && styles.sortable,
123
- col.align === 'right' && styles.alignRight,
124
- col.align === 'center' && styles.alignCenter
125
- )}
126
- role="columnheader"
127
- onClick={() => col.sortable && handleSort(col.id)}
128
- style={col.minWidth ? { minWidth: typeof col.minWidth === 'number' ? `${col.minWidth}px` : col.minWidth } : undefined}
129
- >
130
- {typeof col.header === 'string' ? (
131
- <Text variant="text-body-small-emphasis" as="span" color="secondary">
132
- {col.header}
133
- </Text>
134
- ) : col.header}
135
- {sortState?.columnId === col.id && (
136
- <span className={styles.sortIndicator} aria-hidden>
137
- {sortState.direction === 'asc' ? '\u2191' : '\u2193'}
138
- </span>
139
- )}
140
- </div>
141
- ))}
117
+ {visibleColumns.map(col => {
118
+ const isSorted = sortState?.columnId === col.id;
119
+ const ariaSort: 'ascending' | 'descending' | 'none' | undefined = col.sortable
120
+ ? (isSorted ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none')
121
+ : undefined;
122
+ const onHeaderActivate = () => col.sortable && handleSort(col.id);
123
+ return (
124
+ <div
125
+ key={col.id}
126
+ className={cn(
127
+ styles.headerCell,
128
+ col.sortable && styles.sortable,
129
+ col.align === 'right' && styles.alignRight,
130
+ col.align === 'center' && styles.alignCenter
131
+ )}
132
+ role="columnheader"
133
+ aria-sort={ariaSort}
134
+ tabIndex={col.sortable ? 0 : undefined}
135
+ onClick={onHeaderActivate}
136
+ onKeyDown={col.sortable ? (e) => {
137
+ if (e.key === 'Enter' || e.key === ' ') {
138
+ e.preventDefault();
139
+ onHeaderActivate();
140
+ }
141
+ } : undefined}
142
+ style={col.minWidth ? { minWidth: typeof col.minWidth === 'number' ? `${col.minWidth}px` : col.minWidth } : undefined}
143
+ >
144
+ {typeof col.header === 'string' ? (
145
+ <Text variant="text-body-small-emphasis" as="span" color="secondary">
146
+ {col.header}
147
+ </Text>
148
+ ) : col.header}
149
+ {isSorted && (
150
+ <span className={styles.sortIndicator} aria-hidden>
151
+ {sortState.direction === 'asc' ? '\u2191' : '\u2193'}
152
+ </span>
153
+ )}
154
+ </div>
155
+ );
156
+ })}
142
157
  </div>
143
158
 
144
159
  {/* Loading skeleton */}
145
160
  {loading && Array.from({ length: 5 }).map((_, i) => (
146
- <div key={`skeleton-${i}`} className={styles.row} role="row">
161
+ <div
162
+ key={`skeleton-${i}`}
163
+ className={styles.row}
164
+ role={i === 0 ? 'status' : 'row'}
165
+ aria-label={i === 0 ? 'Loading' : undefined}
166
+ >
147
167
  {visibleColumns.map(col => (
148
168
  <div key={col.id} className={styles.cell} role="cell">
149
169
  <div className={styles.skeleton} />
@@ -162,16 +182,25 @@ export function Table<T>({
162
182
  {/* Data rows */}
163
183
  {!loading && pagedData.map((row, rowIndex) => {
164
184
  const isSelected = selectedRows?.has(rowIndex);
185
+ const isClickable = Boolean(onRowClick);
165
186
  return (
166
187
  <div
167
188
  key={rowIndex}
168
189
  className={cn(
169
190
  styles.row,
170
191
  isSelected && styles.rowSelected,
171
- onRowClick && styles.rowClickable
192
+ isClickable && styles.rowClickable
172
193
  )}
173
194
  role="row"
195
+ tabIndex={isClickable ? 0 : undefined}
196
+ aria-selected={selectedRows ? Boolean(isSelected) : undefined}
174
197
  onClick={() => onRowClick?.(row, rowIndex)}
198
+ onKeyDown={isClickable ? (e) => {
199
+ if (e.key === 'Enter' || e.key === ' ') {
200
+ e.preventDefault();
201
+ onRowClick?.(row, rowIndex);
202
+ }
203
+ } : undefined}
175
204
  >
176
205
  {visibleColumns.map(col => (
177
206
  <div
@@ -125,6 +125,9 @@ export const Tag = forwardRef<HTMLDivElement, TagProps>(
125
125
  }
126
126
  : undefined;
127
127
 
128
+ // Rendered as <div role="button"> rather than a native <button> because
129
+ // removable tags contain an inner <button> for the remove affordance, and
130
+ // nesting <button> inside <button> is invalid HTML.
128
131
  return (
129
132
  <div
130
133
  ref={ref}
@@ -133,8 +136,8 @@ export const Tag = forwardRef<HTMLDivElement, TagProps>(
133
136
  onClick={handleInteract}
134
137
  onKeyDown={handleKeyDown}
135
138
  role={isInteractive ? 'button' : undefined}
136
- tabIndex={isInteractive && !inactive ? 0 : undefined}
137
- aria-pressed={onPressedChange != null ? pressed : undefined}
139
+ tabIndex={isInteractive ? (inactive ? -1 : 0) : undefined}
140
+ aria-pressed={isInteractive && pressed !== undefined ? !!pressed : undefined}
138
141
  aria-disabled={inactive || undefined}
139
142
  >
140
143
  {Icon && <Icon size={iconSize} />}
@@ -100,6 +100,9 @@ interface ToastItemProps {
100
100
  const ToastItem: React.FC<ToastItemProps> = ({ data }) => {
101
101
  const [exiting, setExiting] = useState(false);
102
102
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
103
+ const remainingRef = useRef<number>(data.duration ?? 4000);
104
+ const startedAtRef = useRef<number>(0);
105
+ const pausedRef = useRef<boolean>(false);
103
106
 
104
107
  const dismiss = useCallback(() => {
105
108
  setExiting(true);
@@ -109,13 +112,41 @@ const ToastItem: React.FC<ToastItemProps> = ({ data }) => {
109
112
  }, 200);
110
113
  }, [data]);
111
114
 
112
- useEffect(() => {
113
- const duration = data.duration ?? 4000;
114
- if (duration > 0) {
115
- timerRef.current = setTimeout(dismiss, duration);
115
+ const clearTimer = useCallback(() => {
116
+ if (timerRef.current) {
117
+ clearTimeout(timerRef.current);
118
+ timerRef.current = undefined;
116
119
  }
117
- return () => { if (timerRef.current) clearTimeout(timerRef.current); };
118
- }, [data.duration, dismiss]);
120
+ }, []);
121
+
122
+ const startTimer = useCallback((ms: number) => {
123
+ if (ms <= 0) return;
124
+ startedAtRef.current = Date.now();
125
+ timerRef.current = setTimeout(dismiss, ms);
126
+ }, [dismiss]);
127
+
128
+ const pause = useCallback(() => {
129
+ if (pausedRef.current || !timerRef.current) return;
130
+ pausedRef.current = true;
131
+ const elapsed = Date.now() - startedAtRef.current;
132
+ remainingRef.current = Math.max(0, remainingRef.current - elapsed);
133
+ clearTimer();
134
+ }, [clearTimer]);
135
+
136
+ const resume = useCallback(() => {
137
+ if (!pausedRef.current) return;
138
+ pausedRef.current = false;
139
+ startTimer(remainingRef.current);
140
+ }, [startTimer]);
141
+
142
+ useEffect(() => {
143
+ remainingRef.current = data.duration ?? 4000;
144
+ pausedRef.current = false;
145
+ startTimer(remainingRef.current);
146
+ return clearTimer;
147
+ }, [data.duration, startTimer, clearTimer]);
148
+
149
+ const isNegative = data.intent === 'negative';
119
150
 
120
151
  return (
121
152
  <Surface
@@ -124,8 +155,12 @@ const ToastItem: React.FC<ToastItemProps> = ({ data }) => {
124
155
  elevation="floating"
125
156
  radius="lg"
126
157
  className={cn(styles.toast, exiting && styles.toastExit)}
127
- role="status"
128
- aria-live="polite"
158
+ role={isNegative ? 'alert' : 'status'}
159
+ aria-live={isNegative ? 'assertive' : 'polite'}
160
+ onMouseEnter={pause}
161
+ onMouseLeave={resume}
162
+ onFocus={pause}
163
+ onBlur={resume}
129
164
  >
130
165
  <Text variant="text-body-medium" as="span" color="inherit">
131
166
  {data.message}
@@ -159,6 +194,7 @@ export const ToastContainer = forwardRef<HTMLDivElement, ToastContainerProps>(
159
194
  <div
160
195
  ref={ref}
161
196
  className={cn(styles.container, styles[positionClass(position)])}
197
+ role="region"
162
198
  aria-label="Notifications"
163
199
  >
164
200
  {items.map(item => (
@@ -1,35 +1,37 @@
1
- import React, { forwardRef, useState } from 'react';
1
+ import React, { forwardRef } from 'react';
2
2
  import { cn } from '@/utils/cn';
3
3
  import styles from './Toggle.module.css';
4
4
 
5
- export interface ToggleProps {
5
+ interface ToggleBaseProps {
6
6
  checked?: boolean;
7
7
  onChange?: (checked: boolean) => void;
8
8
  inactive?: boolean;
9
9
  className?: string;
10
- 'aria-label'?: string;
11
10
  }
12
11
 
13
- export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
14
- ({ checked = false, onChange, inactive = false, className, 'aria-label': ariaLabel }, ref) => {
15
- const [isPressed, setIsPressed] = useState(false);
12
+ export type ToggleProps = ToggleBaseProps &
13
+ (
14
+ | { 'aria-label': string; 'aria-labelledby'?: never }
15
+ | { 'aria-label'?: never; 'aria-labelledby': string }
16
+ );
16
17
 
17
- const handleMouseDown = (e: React.MouseEvent) => {
18
+ export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
19
+ (
20
+ {
21
+ checked = false,
22
+ onChange,
23
+ inactive = false,
24
+ className,
25
+ 'aria-label': ariaLabel,
26
+ 'aria-labelledby': ariaLabelledby,
27
+ },
28
+ ref,
29
+ ) => {
30
+ const handleClick = () => {
18
31
  if (inactive) return;
19
- e.preventDefault();
20
- setIsPressed(true);
21
- };
22
-
23
- const handleMouseUp = () => {
24
- if (inactive || !isPressed) return;
25
- setIsPressed(false);
26
32
  onChange?.(!checked);
27
33
  };
28
34
 
29
- const handleMouseLeave = () => {
30
- if (isPressed) setIsPressed(false);
31
- };
32
-
33
35
  return (
34
36
  <button
35
37
  ref={ref}
@@ -37,16 +39,15 @@ export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
37
39
  role="switch"
38
40
  aria-checked={checked}
39
41
  aria-label={ariaLabel}
42
+ aria-labelledby={ariaLabelledby}
40
43
  disabled={inactive}
41
44
  className={cn(styles.toggle, checked && styles.checked, inactive && styles.inactive, className)}
42
- onMouseDown={handleMouseDown}
43
- onMouseUp={handleMouseUp}
44
- onMouseLeave={handleMouseLeave}
45
+ onClick={handleClick}
45
46
  >
46
47
  <span className={styles.thumb} />
47
48
  </button>
48
49
  );
49
- }
50
+ },
50
51
  );
51
52
 
52
53
  Toggle.displayName = 'Toggle';