@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f

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 (200) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -33,6 +33,12 @@ export const docs = {
33
33
  description:
34
34
  'Tooltip text shown on hover to explain the status meaning.',
35
35
  },
36
+ {
37
+ name: 'icon',
38
+ type: 'ReactNode',
39
+ description:
40
+ 'Optional icon rendered centered inside the dot, painted in currentColor (the variant\'s ink). Gives the status a non-color mark, so use a different icon per status. Booleans and empty strings are ignored, so `cond && <Icon />` is safe. Same contract as AvatarStatusDot.',
41
+ },
36
42
  {
37
43
  name: 'xstyle',
38
44
  type: 'StyleXStyles',
@@ -84,6 +90,12 @@ export const docsZh = {
84
90
  '启用脉冲动画;尊重 prefers-reduced-motion: reduce 设置。',
85
91
  default: 'false',
86
92
  },
93
+ {
94
+ name: 'icon',
95
+ type: 'ReactNode',
96
+ description:
97
+ '可选图标,居中渲染于圆点内,以 currentColor(变体的前景色)着色。为状态提供非颜色标记,请为每个状态使用不同图标。布尔值和空字符串会被忽略,因此 `cond && <Icon />` 是安全的。与 AvatarStatusDot 的契约一致。',
98
+ },
87
99
  {
88
100
  name: 'xstyle',
89
101
  type: 'StyleXStyles',
@@ -132,6 +144,7 @@ export const docsDense = {
132
144
  label: 'Accessible label via aria-label.',
133
145
  isPulsing: 'Pulse animation; respects prefers-reduced-motion: reduce.',
134
146
  tooltip: 'Tooltip text on hover to explain status meaning.',
147
+ icon: 'Optional ReactNode rendered centered in the dot (currentColor ink); a non-color mark for the status, use a different icon per status. Booleans/empty strings ignored (safe for cond && <Icon/>). Same contract as AvatarStatusDot.',
135
148
  xstyle: 'StyleX layout styles; must be stylex.create() value.',
136
149
  },
137
150
  };
@@ -2,8 +2,16 @@
2
2
 
3
3
  import {describe, it, expect} from 'vitest';
4
4
  import {render, screen} from '@testing-library/react';
5
+ import * as stylex from '@stylexjs/stylex';
6
+ import {colorVars} from '../theme/tokens.stylex';
5
7
  import {StatusDot} from './StatusDot';
6
8
 
9
+ /** Renders a StatusDot and returns its root element. */
10
+ function renderDot(props: React.ComponentProps<typeof StatusDot>) {
11
+ render(<StatusDot {...props} />);
12
+ return screen.getByRole('img', {name: props.label});
13
+ }
14
+
7
15
  describe('StatusDot', () => {
8
16
  it('renders with role="img" and aria-label', () => {
9
17
  render(<StatusDot variant="success" label="Online" />);
@@ -27,9 +35,7 @@ describe('StatusDot', () => {
27
35
  ] as const;
28
36
 
29
37
  for (const variant of variants) {
30
- const {unmount} = render(
31
- <StatusDot variant={variant} label={variant} />,
32
- );
38
+ const {unmount} = render(<StatusDot variant={variant} label={variant} />);
33
39
  expect(screen.getByRole('img', {name: variant})).toBeInTheDocument();
34
40
  unmount();
35
41
  }
@@ -49,11 +55,7 @@ describe('StatusDot', () => {
49
55
 
50
56
  it('supports data-testid', () => {
51
57
  render(
52
- <StatusDot
53
- variant="success"
54
- label="Online"
55
- data-testid="status-dot"
56
- />,
58
+ <StatusDot variant="success" label="Online" data-testid="status-dot" />,
57
59
  );
58
60
  expect(screen.getByTestId('status-dot')).toBeInTheDocument();
59
61
  });
@@ -81,4 +83,109 @@ describe('StatusDot', () => {
81
83
  const dot = screen.getByRole('img', {name: 'Online'});
82
84
  expect(dot).toBeInTheDocument();
83
85
  });
86
+
87
+ it('renders every variant as a plain childless dot by default (design review #4373)', () => {
88
+ // The dot is deliberately a colour-only signal by default — no built-in
89
+ // per-variant glyph. Making the status accessible in context (label,
90
+ // icon, or an accessible alternative) is the builder's responsibility;
91
+ // see the usage guidance.
92
+ const variants = [
93
+ 'success',
94
+ 'warning',
95
+ 'error',
96
+ 'accent',
97
+ 'neutral',
98
+ ] as const;
99
+ for (const variant of variants) {
100
+ const dot = renderDot({variant, label: `plain-${variant}`});
101
+ expect(dot.childElementCount, variant).toBe(0);
102
+ }
103
+ });
104
+
105
+ describe('custom icon (parity with AvatarStatusDot)', () => {
106
+ const ICON_TESTID = 'custom-icon';
107
+ function Icon() {
108
+ return <svg data-testid={ICON_TESTID} />;
109
+ }
110
+
111
+ it('renders a provided icon inside the dot', () => {
112
+ renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
113
+ expect(screen.getByTestId(ICON_TESTID)).toBeInTheDocument();
114
+ });
115
+
116
+ it('hides the icon wrapper from assistive tech (the label carries the status)', () => {
117
+ renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
118
+ const iconEl = screen.getByTestId(ICON_TESTID);
119
+ expect(iconEl.closest('[aria-hidden="true"]')).not.toBeNull();
120
+ });
121
+
122
+ it.each([false, true, null, undefined, ''])(
123
+ 'ignores %s and keeps the plain dot (safe for `cond && <Icon />`)',
124
+ value => {
125
+ const dot = renderDot({
126
+ variant: 'error',
127
+ label: 'Offline',
128
+ icon: value,
129
+ });
130
+ expect(dot.childElementCount).toBe(0);
131
+ },
132
+ );
133
+
134
+ it('keeps the accessible name on the dot when an icon renders', () => {
135
+ const dot = renderDot({
136
+ variant: 'success',
137
+ label: 'Verified',
138
+ icon: <Icon />,
139
+ });
140
+ expect(dot).toHaveAttribute('role', 'img');
141
+ expect(dot).toHaveAttribute('aria-label', 'Verified');
142
+ });
143
+ });
144
+
145
+ describe('variant ink (a passed icon paints from currentColor)', () => {
146
+ it('pairs the warning plate with the dedicated dark on-warning ink', () => {
147
+ // The regression this guards: a light surface ink on the yellow warning
148
+ // plate lands near 2:1, while `--color-on-warning` is the fixed dark
149
+ // ink (~9.6:1). An icon inherits the ink via `currentColor`, so the
150
+ // pairing is what keeps custom icons legible.
151
+ const probe = stylex.create({
152
+ warning: {
153
+ backgroundColor: colorVars['--color-warning'],
154
+ color: colorVars['--color-on-warning'],
155
+ },
156
+ });
157
+ const dot = renderDot({variant: 'warning', label: 'Degraded'});
158
+ const atomicClasses = (stylex.props(probe.warning).className ?? '')
159
+ .split(' ')
160
+ // Dev mode prepends a per-file `File__style.key` debug class that
161
+ // legitimately differs between the probe and the component.
162
+ .filter(cls => !cls.includes('__'));
163
+ expect(atomicClasses.length).toBeGreaterThan(0);
164
+ for (const cls of atomicClasses) {
165
+ expect(dot.className).toContain(cls);
166
+ }
167
+ });
168
+ });
169
+
170
+ describe('accessible name (label reaches AT without hover)', () => {
171
+ it('exposes the status label as the accessible name for every variant, without a tooltip', () => {
172
+ const variants = [
173
+ 'success',
174
+ 'warning',
175
+ 'error',
176
+ 'accent',
177
+ 'neutral',
178
+ ] as const;
179
+ for (const variant of variants) {
180
+ const {unmount} = render(
181
+ <StatusDot variant={variant} label={`Status: ${variant}`} />,
182
+ );
183
+ const dot = screen.getByRole('img', {name: `Status: ${variant}`});
184
+ expect(dot).toHaveAttribute('aria-label', `Status: ${variant}`);
185
+ // The name must not depend on hover or focus.
186
+ expect(dot.getAttribute('tabindex')).toBeNull();
187
+ unmount();
188
+ }
189
+ });
190
+ });
84
191
  });
@@ -16,11 +16,13 @@
16
16
  * - /packages/cli/assets/templates/blocks/components/StatusDot/ (showcase blocks)
17
17
  */
18
18
 
19
+ import type {ReactNode} from 'react';
20
+
19
21
  import * as stylex from '@stylexjs/stylex';
20
22
  import {colorVars} from '../theme/tokens.stylex';
21
23
  import type {BaseProps} from '../BaseProps';
22
24
  import {Tooltip} from '../Tooltip/Tooltip';
23
- import {mergeProps} from '../utils';
25
+ import {isRenderable, mergeProps} from '../utils';
24
26
  import {themeProps} from '../utils/themeProps';
25
27
  import type {StatusDotVariantMap} from './index';
26
28
 
@@ -33,16 +35,21 @@ const pulseKeyframes = stylex.keyframes({
33
35
  '100%': {opacity: 1},
34
36
  });
35
37
 
38
+ /** Fixed dot size in px. A user-supplied icon is drawn into this same field. */
39
+ const DOT_SIZE = 8;
40
+
36
41
  /**
37
42
  * Base styles
38
43
  */
39
44
  const styles = stylex.create({
40
45
  base: {
41
- display: 'inline-block',
46
+ display: 'inline-flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
42
49
  borderRadius: '50%',
43
50
  flexShrink: 0,
44
- width: '8px',
45
- height: '8px',
51
+ width: `${DOT_SIZE}px`,
52
+ height: `${DOT_SIZE}px`,
46
53
  },
47
54
  pulsing: {
48
55
  animationName: pulseKeyframes,
@@ -55,26 +62,49 @@ const styles = stylex.create({
55
62
  animationName: 'none',
56
63
  },
57
64
  },
65
+ // A user-supplied icon fills the whole 8px field and paints from
66
+ // `currentColor` (the variant's ink).
67
+ icon: {
68
+ display: 'flex',
69
+ alignItems: 'center',
70
+ justifyContent: 'center',
71
+ lineHeight: 0,
72
+ width: `${DOT_SIZE}px`,
73
+ height: `${DOT_SIZE}px`,
74
+ },
58
75
  });
59
76
 
60
77
  /**
61
- * Variant styles mapping to theme color tokens
78
+ * Variant styles mapping to theme color tokens.
79
+ *
80
+ * Each variant sets both the plate colour and an ink colour: a user-supplied
81
+ * `icon` paints from `currentColor`, so plate and ink can never drift out of
82
+ * contrast (same contract as AvatarStatusDot). The ink is each plate's
83
+ * dedicated `--color-on-*` pairing (the Badge precedent), not the surface
84
+ * colour: on-warning is a fixed dark ink, which keeps an icon legible on the
85
+ * yellow plate (~9.6:1) where a light surface ink lands near 2:1. Neutral has
86
+ * no `--color-on-*` token; its mid-grey plate takes the surface ink.
62
87
  */
63
88
  const variants = stylex.create({
64
89
  success: {
65
90
  backgroundColor: colorVars['--color-success'],
91
+ color: colorVars['--color-on-success'],
66
92
  },
67
93
  warning: {
68
94
  backgroundColor: colorVars['--color-warning'],
95
+ color: colorVars['--color-on-warning'],
69
96
  },
70
97
  error: {
71
98
  backgroundColor: colorVars['--color-error'],
99
+ color: colorVars['--color-on-error'],
72
100
  },
73
101
  accent: {
74
102
  backgroundColor: colorVars['--color-accent'],
103
+ color: colorVars['--color-on-accent'],
75
104
  },
76
105
  neutral: {
77
106
  backgroundColor: colorVars['--color-icon-secondary'],
107
+ color: colorVars['--color-background-surface'],
78
108
  },
79
109
  });
80
110
 
@@ -92,7 +122,8 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
92
122
  */
93
123
  variant: StatusDotVariant;
94
124
  /**
95
- * Accessible label describing the status. Required for a11y.
125
+ * Accessible label describing the status. Required for a11y — it is the
126
+ * dot's `aria-label`, so the status reaches screen readers without hover.
96
127
  */
97
128
  label: string;
98
129
  /**
@@ -106,12 +137,36 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
106
137
  * When omitted, no tooltip is rendered.
107
138
  */
108
139
  tooltip?: string;
140
+ /**
141
+ * Optional icon to render centered inside the dot. Accepts any ReactNode
142
+ * (typically an SVG icon), painted in the dot's `currentColor` ink. Same
143
+ * contract as `AvatarStatusDot`'s `icon`.
144
+ *
145
+ * By default the dot is a colour-only signal; an icon gives the status a
146
+ * non-colour mark, so use a different icon per status — the same icon on
147
+ * every variant leaves the statuses distinguishable by colour alone
148
+ * (WCAG 2.1 SC 1.4.1). Booleans and empty strings are ignored (safe for
149
+ * `cond && <Icon />`).
150
+ *
151
+ * @example
152
+ * ```
153
+ * <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
154
+ * ```
155
+ */
156
+ icon?: ReactNode;
109
157
  }
110
158
 
111
159
  /**
112
160
  * A small colored dot indicator for status display (online/offline, severity, etc).
113
161
  *
114
- * Fixed 8px size. Renders as a non-focusable `<span>` with `role="img"` and
162
+ * Fixed 8px size. By default the dot conveys status by colour only, which is
163
+ * not accessible in isolation (WCAG 2.1 SC 1.4.1) — treat it as a signal the
164
+ * surrounding UI must make accessible: use it as a binary present/absent
165
+ * signal, pair it with a visible label, pass `icon` so the status carries a
166
+ * non-colour mark, or convey the status accessibly elsewhere. See the usage
167
+ * guidance in the component docs.
168
+ *
169
+ * Renders as a non-focusable `<span>` with `role="img"` and
115
170
  * `aria-label` for accessibility. Styles use Astryx theme tokens via StyleX.
116
171
  * Wrap your app in `<Theme>` to apply a theme.
117
172
  *
@@ -121,6 +176,7 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
121
176
  * <StatusDot variant="error" label="Offline" />
122
177
  * <StatusDot variant="success" label="Live" isPulsing />
123
178
  * <StatusDot variant="success" label="Online" tooltip="Online" />
179
+ * <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
124
180
  * ```
125
181
  */
126
182
  export function StatusDot({
@@ -128,12 +184,16 @@ export function StatusDot({
128
184
  label,
129
185
  isPulsing = false,
130
186
  tooltip,
187
+ icon,
131
188
  xstyle,
132
189
  className,
133
190
  style,
134
191
  ref,
135
192
  ...props
136
193
  }: StatusDotProps) {
194
+ // Booleans and empty renders don't count (safe for `cond && <Icon />`) —
195
+ // same contract as AvatarStatusDot.
196
+ const showsIcon = isRenderable(icon);
137
197
  const dot = (
138
198
  <span
139
199
  ref={ref}
@@ -151,8 +211,13 @@ export function StatusDot({
151
211
  className,
152
212
  style,
153
213
  )}
154
- {...props}
155
- />
214
+ {...props}>
215
+ {showsIcon && (
216
+ <span aria-hidden="true" {...stylex.props(styles.icon)}>
217
+ {icon}
218
+ </span>
219
+ )}
220
+ </span>
156
221
  );
157
222
 
158
223
  if (tooltip) {
@@ -262,6 +262,22 @@ describe('Switch', () => {
262
262
  expect(screen.getByLabelText('Toggle row')).toBeInTheDocument();
263
263
  });
264
264
 
265
+ it('drops the label gap when isLabelHidden so the row is only as wide as the track', () => {
266
+ const {rerender} = render(
267
+ <Switch
268
+ label="Toggle row"
269
+ isLabelHidden
270
+ value={false}
271
+ onChange={() => {}}
272
+ />,
273
+ );
274
+ const row = screen.getByRole('switch').parentElement!.parentElement!;
275
+ expect(getComputedStyle(row).gap).toMatch(/^0(px)?$/);
276
+
277
+ rerender(<Switch label="Toggle row" value={false} onChange={() => {}} />);
278
+ expect(getComputedStyle(row).gap).not.toMatch(/^0(px)?$/);
279
+ });
280
+
265
281
  it('keeps description linked via aria-describedby when isLabelHidden', () => {
266
282
  render(
267
283
  <Switch
@@ -135,6 +135,12 @@ const styles = stylex.create({
135
135
  alignItems: 'center',
136
136
  gap: spacingVars['--spacing-2'],
137
137
  },
138
+ // A hidden label is sr-only, so its wrapper is a zero-width flex item — the
139
+ // row gap would still be painted beside the track, making the field box
140
+ // wider than the control it contains. Matches CheckboxInput.
141
+ containerLabelHidden: {
142
+ gap: 0,
143
+ },
138
144
  containerSpread: {
139
145
  justifyContent: 'space-between',
140
146
  width: '100%',
@@ -655,6 +661,7 @@ export function Switch({
655
661
  }}
656
662
  {...stylex.props(
657
663
  styles.container,
664
+ isLabelHidden && styles.containerLabelHidden,
658
665
  labelSpacing === 'spread' && styles.containerSpread,
659
666
  !isDisabled && switchScope,
660
667
  )}>
@@ -23,6 +23,7 @@ import {spacingVars, colorVars, radiusVars} from '../../../theme/tokens.stylex';
23
23
  import {Icon} from '../../../Icon';
24
24
  import {resolveContextActions} from '../../tableContextMenu';
25
25
  import {useTranslator} from '../../../i18n';
26
+ import {rtlStyles} from '../../../utils';
26
27
  import type {
27
28
  TablePlugin,
28
29
  TableColumn,
@@ -92,8 +93,20 @@ const expansionStyles = stylex.create({
92
93
  color: colorVars['--color-icon-primary'],
93
94
  },
94
95
  },
96
+ // The RTL mirror is folded into each state's transform rather than living
97
+ // on a parent span, matching TreeListItem's chevron (both are `transform`,
98
+ // so on one element the later value would win).
95
99
  chevronExpanded: {
96
- transform: 'rotate(90deg)',
100
+ transform: {
101
+ default: 'rotate(90deg)',
102
+ ':is([dir="rtl"] *)': 'scaleX(-1) rotate(90deg)',
103
+ },
104
+ },
105
+ chevronCollapsed: {
106
+ transform: {
107
+ default: 'rotate(0deg)',
108
+ ':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)',
109
+ },
97
110
  },
98
111
  expandedRow: {
99
112
  backgroundColor: colorVars['--color-background-muted'],
@@ -121,7 +134,9 @@ function ExpansionChevron({
121
134
  type="button"
122
135
  {...stylex.props(
123
136
  expansionStyles.chevronButton,
124
- isExpanded && expansionStyles.chevronExpanded,
137
+ isExpanded
138
+ ? expansionStyles.chevronExpanded
139
+ : expansionStyles.chevronCollapsed,
125
140
  )}
126
141
  onClick={e => {
127
142
  e.stopPropagation();
@@ -249,6 +264,9 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
249
264
  icon={isExpanded ? 'chevronDown' : 'chevronRight'}
250
265
  size="xsm"
251
266
  aria-hidden
267
+ // chevronDown needs no mirroring; chevronRight (collapsed,
268
+ // pointing toward the reveal direction) does.
269
+ xstyle={!isExpanded && rtlStyles.mirror}
252
270
  />
253
271
  ),
254
272
  onSelect: () => onToggle(key),
@@ -42,6 +42,29 @@ describe('TimeInput', () => {
42
42
  expect(screen.getByPlaceholderText('Pick a time')).toBeInTheDocument();
43
43
  });
44
44
 
45
+ it('does not step the time on a composing ArrowUp/ArrowDown (IME)', () => {
46
+ const onChange = vi.fn();
47
+ render(
48
+ <TimeInput
49
+ label="Time"
50
+ value={'14:30' as ISOTimeString}
51
+ onChange={onChange}
52
+ />,
53
+ );
54
+ const input = screen.getByLabelText('Time');
55
+
56
+ // An IME candidate window navigates with the arrows; a composing keydown
57
+ // (isComposing / legacy keyCode 229) must not step the time value.
58
+ fireEvent.keyDown(input, {key: 'ArrowUp', isComposing: true});
59
+ expect(onChange).not.toHaveBeenCalled();
60
+ fireEvent.keyDown(input, {key: 'ArrowDown', keyCode: 229});
61
+ expect(onChange).not.toHaveBeenCalled();
62
+
63
+ // A real, non-composing ArrowUp still steps the time by one minute.
64
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
65
+ expect(onChange).toHaveBeenCalledWith('14:31');
66
+ });
67
+
45
68
  it('displays formatted time in 12h format', () => {
46
69
  render(
47
70
  <TimeInput
@@ -55,6 +55,7 @@ import {
55
55
  formatDisplayTime24h,
56
56
  formatISOTime,
57
57
  adjustTime,
58
+ isImeKeyEvent,
58
59
  isTimeInRange,
59
60
  mergeProps,
60
61
  mergeRefs,
@@ -530,6 +531,13 @@ export function TimeInput({
530
531
  // Handle keyboard navigation on input
531
532
  const handleInputKeyDown = useCallback(
532
533
  (e: KeyboardEvent<HTMLInputElement>) => {
534
+ // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
535
+ // window uses those same arrows to navigate candidates, so guard the
536
+ // composing keydown (fires before compositionend) to avoid stealing them
537
+ // mid-composition. See utils/ime.ts.
538
+ if (isImeKeyEvent(e.nativeEvent)) {
539
+ return;
540
+ }
533
541
  // Arrow-key adjustment mutates the value; block it while showing a
534
542
  // disabled reason (the input keeps focusability via aria-disabled).
535
543
  if (isDisabled) {
@@ -16,6 +16,16 @@ import {Tokenizer} from './Tokenizer';
16
16
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17
17
  import type {SearchSource, SearchableItem} from '../Typeahead/types';
18
18
  import {TestIcon} from '../__tests__/TestIcon';
19
+ import {InternationalizationProvider} from '../i18n';
20
+
21
+ // Test-supplied announcement strings: the assertions below depend on no
22
+ // catalog, and no hardcoded English in the component can satisfy them.
23
+ const TOKEN_MESSAGES = {
24
+ fr: {
25
+ '@astryx.tokenizer.tokenAdded': 'Ajouté : {label}',
26
+ '@astryx.tokenizer.tokenRemoved': 'Retiré : {label}',
27
+ },
28
+ };
19
29
 
20
30
  function politeRegion(): HTMLElement | null {
21
31
  return document.querySelector('[data-astryx-live-region="polite"]');
@@ -980,12 +990,14 @@ describe('Tokenizer', () => {
980
990
  it('announces removal politely on Backspace with an empty input', async () => {
981
991
  const onChange = vi.fn();
982
992
  render(
983
- <Tokenizer
984
- label="Members"
985
- searchSource={userSource}
986
- value={[users[0], users[1]]}
987
- onChange={onChange}
988
- />,
993
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
994
+ <Tokenizer
995
+ label="Members"
996
+ searchSource={userSource}
997
+ value={[users[0], users[1]]}
998
+ onChange={onChange}
999
+ />
1000
+ </InternationalizationProvider>,
989
1001
  );
990
1002
  const input = screen.getByRole('combobox');
991
1003
  fireEvent.keyDown(input, {key: 'Backspace'});
@@ -994,35 +1006,39 @@ describe('Tokenizer', () => {
994
1006
  type: 'remove',
995
1007
  });
996
1008
  await waitFor(() => {
997
- expect(politeRegion()).toHaveTextContent('Removed Bob');
1009
+ expect(politeRegion()?.textContent).toBe('Retiré : Bob');
998
1010
  });
999
1011
  });
1000
1012
 
1001
1013
  it("announces removal politely when clicking a token's remove button", async () => {
1002
1014
  render(
1003
- <Tokenizer
1004
- label="Members"
1005
- searchSource={userSource}
1006
- value={[users[0], users[1]]}
1007
- onChange={() => {}}
1008
- />,
1015
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1016
+ <Tokenizer
1017
+ label="Members"
1018
+ searchSource={userSource}
1019
+ value={[users[0], users[1]]}
1020
+ onChange={() => {}}
1021
+ />
1022
+ </InternationalizationProvider>,
1009
1023
  );
1010
1024
  fireEvent.click(screen.getByRole('button', {name: 'Remove Alice'}));
1011
1025
  await waitFor(() => {
1012
- expect(politeRegion()).toHaveTextContent('Removed Alice');
1026
+ expect(politeRegion()?.textContent).toBe('Retiré : Alice');
1013
1027
  });
1014
1028
  });
1015
1029
 
1016
1030
  it('announces addition politely when selecting a search result', async () => {
1017
1031
  render(
1018
- <Tokenizer
1019
- label="Members"
1020
- searchSource={userSource}
1021
- value={[]}
1022
- onChange={() => {}}
1023
- hasEntriesOnFocus
1024
- debounceMs={0}
1025
- />,
1032
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1033
+ <Tokenizer
1034
+ label="Members"
1035
+ searchSource={userSource}
1036
+ value={[]}
1037
+ onChange={() => {}}
1038
+ hasEntriesOnFocus
1039
+ debounceMs={0}
1040
+ />
1041
+ </InternationalizationProvider>,
1026
1042
  );
1027
1043
  const input = screen.getByRole('combobox');
1028
1044
  fireEvent.focus(input);
@@ -1031,7 +1047,7 @@ describe('Tokenizer', () => {
1031
1047
  });
1032
1048
  fireEvent.click(screen.getByText('Alice'));
1033
1049
  await waitFor(() => {
1034
- expect(politeRegion()).toHaveTextContent('Added Alice');
1050
+ expect(politeRegion()?.textContent).toBe('Ajouté : Alice');
1035
1051
  });
1036
1052
  });
1037
1053
 
@@ -1041,14 +1057,16 @@ describe('Tokenizer', () => {
1041
1057
  bootstrap: () => [],
1042
1058
  };
1043
1059
  render(
1044
- <Tokenizer
1045
- label="Tags"
1046
- searchSource={emptySource}
1047
- value={[]}
1048
- onChange={() => {}}
1049
- hasCreate
1050
- debounceMs={0}
1051
- />,
1060
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1061
+ <Tokenizer
1062
+ label="Tags"
1063
+ searchSource={emptySource}
1064
+ value={[]}
1065
+ onChange={() => {}}
1066
+ hasCreate
1067
+ debounceMs={0}
1068
+ />
1069
+ </InternationalizationProvider>,
1052
1070
  );
1053
1071
  const input = screen.getByRole('combobox');
1054
1072
  await act(async () => {
@@ -1059,7 +1077,7 @@ describe('Tokenizer', () => {
1059
1077
  });
1060
1078
  fireEvent.click(screen.getByText('Create "new-tag"'));
1061
1079
  await waitFor(() => {
1062
- expect(politeRegion()).toHaveTextContent('Added new-tag');
1080
+ expect(politeRegion()?.textContent).toBe('Ajouté : new-tag');
1063
1081
  });
1064
1082
  });
1065
1083
 
@@ -600,7 +600,7 @@ export function Tokenizer<T extends SearchableItem>({
600
600
  const realItem = base as T;
601
601
  const newItems = [...value, realItem];
602
602
  onChange(newItems, {item: realItem, type: 'create'});
603
- announce(`Added ${createdValue}`);
603
+ announce(t('@astryx.tokenizer.tokenAdded', {label: createdValue}));
604
604
  return;
605
605
  }
606
606
 
@@ -609,9 +609,9 @@ export function Tokenizer<T extends SearchableItem>({
609
609
  }
610
610
  const newItems = [...value, item];
611
611
  onChange(newItems, {item, type: 'add'});
612
- announce(`Added ${item.label}`);
612
+ announce(t('@astryx.tokenizer.tokenAdded', {label: item.label}));
613
613
  },
614
- [value, onChange, isAtMax, selectedIds, hasCreate, announce],
614
+ [value, onChange, isAtMax, selectedIds, hasCreate, announce, t],
615
615
  );
616
616
 
617
617
  // Handle removing an item. Single removal path: both Backspace on an empty
@@ -621,10 +621,10 @@ export function Tokenizer<T extends SearchableItem>({
621
621
  (item: T) => {
622
622
  const newItems = value.filter(v => v.id !== item.id);
623
623
  onChange(newItems, {item, type: 'remove'});
624
- announce(`Removed ${item.label}`);
624
+ announce(t('@astryx.tokenizer.tokenRemoved', {label: item.label}));
625
625
  inputRef.current?.focus();
626
626
  },
627
- [value, onChange, announce],
627
+ [value, onChange, announce, t],
628
628
  );
629
629
 
630
630
  // Handle clearing all items