@astryxdesign/core 0.4.2 → 0.4.3-canary.07f9998

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 (203) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +381 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +395 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +25 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +384 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +881 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Markdown/parser.d.ts.map +1 -1
  53. package/dist/Markdown/parser.js +55 -12
  54. package/dist/MobileNav/MobileNav.d.ts +12 -0
  55. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  56. package/dist/MobileNav/MobileNav.js +102 -30
  57. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  58. package/dist/MultiSelector/MultiSelector.js +10 -1
  59. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  60. package/dist/NumberInput/NumberInput.js +9 -1
  61. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  62. package/dist/PowerSearch/PowerSearch.js +4 -1
  63. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  64. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  65. package/dist/PowerSearch/formatFilterValue.js +2 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +10 -1
  68. package/dist/Switch/Switch.d.ts.map +1 -1
  69. package/dist/Switch/Switch.js +5 -1
  70. package/dist/Table/columnUtils.d.ts.map +1 -1
  71. package/dist/Table/columnUtils.js +4 -1
  72. package/dist/TextArea/TextArea.d.ts +6 -3
  73. package/dist/TextArea/TextArea.d.ts.map +1 -1
  74. package/dist/TextArea/TextArea.js +17 -6
  75. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  76. package/dist/TimeInput/TimeInput.js +8 -1
  77. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  78. package/dist/Tooltip/useTooltip.js +4 -1
  79. package/dist/TreeList/TreeList.js +2 -1
  80. package/dist/Typeahead/BaseTypeahead.js +1 -1
  81. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  82. package/dist/Typeahead/Typeahead.js +10 -1
  83. package/dist/astryx.css +25 -2
  84. package/dist/hooks/index.d.ts +8 -1
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +8 -1
  87. package/dist/hooks/useFocusTrap.d.ts +0 -11
  88. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.js +1 -11
  90. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  91. package/dist/hooks/useInputStatusIcon.js +1 -0
  92. package/dist/index.d.ts +1 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +1 -0
  95. package/dist/theme/defineTheme.d.ts +30 -7
  96. package/dist/theme/defineTheme.d.ts.map +1 -1
  97. package/dist/theme/defineTheme.js +36 -47
  98. package/dist/theme/expandColorScale.d.ts +17 -2
  99. package/dist/theme/expandColorScale.d.ts.map +1 -1
  100. package/dist/theme/expandColorScale.js +63 -33
  101. package/dist/theme/mergeComponents.d.ts +20 -0
  102. package/dist/theme/mergeComponents.d.ts.map +1 -0
  103. package/dist/theme/mergeComponents.js +56 -0
  104. package/dist/theme/onMediaTokens.d.ts +6 -1
  105. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  106. package/dist/theme/onMediaTokens.js +11 -3
  107. package/dist/utils/characters.d.ts +27 -0
  108. package/dist/utils/characters.d.ts.map +1 -0
  109. package/dist/utils/characters.js +83 -0
  110. package/dist/utils/ime.d.ts +5 -0
  111. package/dist/utils/ime.d.ts.map +1 -0
  112. package/dist/utils/ime.js +63 -0
  113. package/dist/utils/index.d.ts +2 -0
  114. package/dist/utils/index.d.ts.map +1 -1
  115. package/dist/utils/index.js +3 -1
  116. package/package.json +9 -8
  117. package/src/Avatar/Avatar.test.tsx +6 -1
  118. package/src/Avatar/Avatar.tsx +3 -21
  119. package/src/Banner/Banner.doc.mjs +12 -10
  120. package/src/Banner/Banner.test.tsx +108 -0
  121. package/src/Banner/Banner.tsx +124 -38
  122. package/src/Banner/index.ts +15 -13
  123. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  124. package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
  125. package/src/BottomSheet/BottomSheet.tsx +539 -0
  126. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  127. package/src/BottomSheet/BottomSheetPanel.tsx +542 -0
  128. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  129. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  130. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  131. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  132. package/src/BottomSheet/index.ts +15 -0
  133. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  134. package/src/BottomSheet/snapOffsets.ts +159 -0
  135. package/src/BottomSheet/useMobileKeyboard.ts +566 -0
  136. package/src/BottomSheet/useSheetGestures.test.ts +833 -0
  137. package/src/BottomSheet/useSheetGestures.ts +1333 -0
  138. package/src/Chat/ChatComposerInput.tsx +3 -4
  139. package/src/Chat/ChatTokenizedText.tsx +1 -1
  140. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  141. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  142. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  143. package/src/ComplexSelector/index.ts +3 -1
  144. package/src/ContextMenu/ContextMenu.tsx +4 -2
  145. package/src/DateInput/DateInput.test.tsx +21 -0
  146. package/src/DateInput/DateInput.tsx +9 -1
  147. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  148. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  149. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  150. package/src/Dialog/Dialog.tsx +2 -1
  151. package/src/Markdown/parser.test.ts +53 -0
  152. package/src/Markdown/parser.ts +53 -12
  153. package/src/MobileNav/MobileNav.tsx +128 -37
  154. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  155. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  156. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  157. package/src/MultiSelector/MultiSelector.test.tsx +31 -0
  158. package/src/MultiSelector/MultiSelector.tsx +10 -1
  159. package/src/NumberInput/NumberInput.test.tsx +18 -0
  160. package/src/NumberInput/NumberInput.tsx +9 -1
  161. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  162. package/src/PowerSearch/PowerSearch.tsx +4 -1
  163. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  164. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  165. package/src/PowerSearch/formatFilterValue.ts +2 -4
  166. package/src/Selector/Selector.test.tsx +33 -0
  167. package/src/Selector/Selector.tsx +10 -1
  168. package/src/Switch/Switch.test.tsx +16 -0
  169. package/src/Switch/Switch.tsx +7 -0
  170. package/src/Table/Table.test.tsx +6 -0
  171. package/src/Table/columnUtils.ts +3 -1
  172. package/src/TextArea/TextArea.doc.mjs +1 -1
  173. package/src/TextArea/TextArea.test.tsx +72 -0
  174. package/src/TextArea/TextArea.tsx +26 -8
  175. package/src/TimeInput/TimeInput.test.tsx +23 -0
  176. package/src/TimeInput/TimeInput.tsx +8 -0
  177. package/src/Tooltip/useTooltip.tsx +4 -1
  178. package/src/TreeList/TreeList.doc.mjs +2 -2
  179. package/src/TreeList/TreeList.tsx +1 -1
  180. package/src/Typeahead/BaseTypeahead.tsx +1 -1
  181. package/src/Typeahead/Typeahead.test.tsx +35 -0
  182. package/src/Typeahead/Typeahead.tsx +10 -1
  183. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  184. package/src/__tests__/stubMatchMedia.ts +95 -0
  185. package/src/hooks/index.ts +9 -5
  186. package/src/hooks/useFocusTrap.ts +1 -14
  187. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  188. package/src/hooks/useInputStatusIcon.tsx +7 -0
  189. package/src/index.ts +1 -0
  190. package/src/theme/defineTheme.test.ts +127 -0
  191. package/src/theme/defineTheme.ts +70 -55
  192. package/src/theme/expandColorScale.test.ts +138 -0
  193. package/src/theme/expandColorScale.ts +89 -44
  194. package/src/theme/extensibleAxes.test.ts +365 -0
  195. package/src/theme/mergeComponents.ts +59 -0
  196. package/src/theme/onMediaTokens.ts +9 -2
  197. package/src/utils/characters.test.ts +141 -0
  198. package/src/utils/characters.ts +88 -0
  199. package/src/utils/ime.test.ts +32 -0
  200. package/src/utils/ime.ts +69 -0
  201. package/src/utils/index.ts +4 -0
  202. package/dist/astryx.umd.js +0 -147
  203. package/dist/astryx.umd.js.map +0 -7
@@ -30,7 +30,7 @@ import * as stylex from '@stylexjs/stylex';
30
30
  import type {StyleXStyles} from '@stylexjs/stylex';
31
31
  import {usePopover} from '../Popover/usePopover';
32
32
  import {useAnnounce} from '../hooks/useAnnounce';
33
- import {isImeKeyEvent} from '../hooks/useFocusTrap';
33
+ import {isImeKeyEvent} from '../utils/ime';
34
34
  import {TypeaheadItem} from './TypeaheadItem';
35
35
  import {Icon} from '../Icon';
36
36
  import {
@@ -796,6 +796,41 @@ describe('Typeahead edit mode', () => {
796
796
  // onChange should not have been called — value restored
797
797
  expect(onChange).not.toHaveBeenCalled();
798
798
  });
799
+
800
+ it('does not exit edit mode on a composing Escape (IME)', async () => {
801
+ const onChange = vi.fn();
802
+ render(
803
+ <Typeahead
804
+ label="Fruit"
805
+ searchSource={fruitSource}
806
+ value={fruits[0]}
807
+ onChange={onChange}
808
+ />,
809
+ );
810
+
811
+ // Enter edit mode by clicking the token; the input uncollapses and
812
+ // rejoins the Tab order (tabindex is cleared).
813
+ const tokenText = screen.getByText(fruits[0].label);
814
+ fireEvent.click(tokenText.closest('div')!);
815
+ await act(async () => {
816
+ await new Promise(r => requestAnimationFrame(r));
817
+ });
818
+ const input = screen.getByRole('combobox');
819
+ expect(input).not.toHaveAttribute('tabindex', '-1');
820
+
821
+ // BaseTypeahead invokes this external handler before its own IME guard, so
822
+ // a composing Escape (isComposing / legacy keyCode 229) — which an IME uses
823
+ // to cancel the pending candidate — must not exit edit mode here.
824
+ fireEvent.keyDown(input, {key: 'Escape', isComposing: true});
825
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
826
+ fireEvent.keyDown(input, {key: 'Escape', keyCode: 229});
827
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
828
+
829
+ // A real, non-composing Escape still exits edit mode: the token is
830
+ // restored and the collapsed input drops back out of the Tab order.
831
+ fireEvent.keyDown(input, {key: 'Escape'});
832
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
833
+ });
799
834
  });
800
835
 
801
836
  describe('Typeahead collapsed input tab order', () => {
@@ -45,7 +45,7 @@ import {VisuallyHidden} from '../VisuallyHidden';
45
45
  import {spacingVars, sizeVars} from '../theme/tokens.stylex';
46
46
  import {groupStyles} from '../InputGroup/groupStyles';
47
47
  import {useInputGroup} from '../InputGroup/InputGroupContext';
48
- import {getInputARIA, mergeProps, mergeRefs} from '../utils';
48
+ import {getInputARIA, isImeKeyEvent, mergeProps, mergeRefs} from '../utils';
49
49
  import type {BaseProps} from '../BaseProps';
50
50
  import type {SizeValue} from '../utils/types';
51
51
  import type {SearchableItem, SearchSource} from './types';
@@ -369,6 +369,15 @@ export function Typeahead<T extends SearchableItem>({
369
369
  // Handle Escape during edit mode — restore token
370
370
  const handleKeyDown = useCallback(
371
371
  (e: React.KeyboardEvent<HTMLInputElement>) => {
372
+ // BaseTypeahead invokes this external handler *before* its own IME
373
+ // guard, so we must guard here too: an IME candidate window uses Escape
374
+ // to cancel the pending composition, and that composing Escape fires
375
+ // before compositionend. Without this, a Korean/Japanese/Chinese user
376
+ // cancelling a candidate would instead exit edit mode and blur the
377
+ // field. See utils/ime.ts.
378
+ if (isImeKeyEvent(e.nativeEvent)) {
379
+ return;
380
+ }
372
381
  if (e.key === 'Escape' && editingValue) {
373
382
  e.preventDefault();
374
383
  setIsEditing(false);
@@ -0,0 +1,79 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file stubMatchMedia.test.ts
5
+ * @input Uses vitest, stubMatchMedia
6
+ * @output Unit coverage for the shared query-aware matchMedia stub
7
+ * @position Testing; guards the helper other suites trust to route queries
8
+ *
9
+ * The helper exists because a stub that ignores its query silently reroutes
10
+ * code branching on a different one — the failure mode leaves every test
11
+ * green. A helper sold on that promise has to be held to it, so these assert
12
+ * the routing itself rather than any component behaviour.
13
+ */
14
+
15
+ import {describe, it, expect, afterEach, vi} from 'vitest';
16
+ import {stubMatchMedia} from './stubMatchMedia';
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ describe('stubMatchMedia', () => {
23
+ it('routes prefers-reduced-motion independently of every other query', () => {
24
+ // The exact combination the blanket stub could not express: below a
25
+ // breakpoint (matches) while motion is NOT reduced.
26
+ stubMatchMedia({reduceMotion: false, matches: true});
27
+
28
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
29
+ expect(matchMedia('(max-width: 768px)').matches).toBe(true);
30
+ });
31
+
32
+ it('answers no-preference as the opposite of reduce', () => {
33
+ // Two spellings, opposite meanings. A substring test on
34
+ // 'prefers-reduced-motion' alone would hand both the same answer.
35
+ stubMatchMedia({reduceMotion: true});
36
+
37
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
38
+ expect(matchMedia('(prefers-reduced-motion: no-preference)').matches).toBe(
39
+ false,
40
+ );
41
+ });
42
+
43
+ it('treats the bare preference query as reduce', () => {
44
+ stubMatchMedia({reduceMotion: true});
45
+ expect(matchMedia('(prefers-reduced-motion)').matches).toBe(true);
46
+
47
+ stubMatchMedia({reduceMotion: false});
48
+ expect(matchMedia('(prefers-reduced-motion)').matches).toBe(false);
49
+ });
50
+
51
+ it('defaults to full motion and matching everything else', () => {
52
+ stubMatchMedia();
53
+
54
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
55
+ expect(matchMedia('(max-width: 768px)').matches).toBe(true);
56
+ });
57
+
58
+ it('lets a non-motion query be answered false', () => {
59
+ // Above the breakpoint, motion still reduced — the other axis of the
60
+ // combination, so neither knob can be quietly driving both.
61
+ stubMatchMedia({reduceMotion: true, matches: false});
62
+
63
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
64
+ expect(matchMedia('(max-width: 768px)').matches).toBe(false);
65
+ });
66
+
67
+ it('echoes the query and exposes the listener API components subscribe to', () => {
68
+ stubMatchMedia();
69
+ const mql = matchMedia('(max-width: 768px)');
70
+
71
+ // useMediaQuery subscribes; a stub without these throws on mount rather
72
+ // than failing an assertion, which reads as an unrelated crash.
73
+ expect(mql.media).toBe('(max-width: 768px)');
74
+ expect(typeof mql.addEventListener).toBe('function');
75
+ expect(typeof mql.removeEventListener).toBe('function');
76
+ expect(typeof mql.addListener).toBe('function');
77
+ expect(typeof mql.removeListener).toBe('function');
78
+ });
79
+ });
@@ -0,0 +1,95 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file stubMatchMedia.ts
5
+ * @input Uses vitest
6
+ * @output stubMatchMedia — a query-aware window.matchMedia stub
7
+ * @position Shared test helper; imported by component tests that render below
8
+ * a breakpoint while also exercising motion-sensitive behaviour
9
+ *
10
+ * jsdom has no matchMedia, so tests stub it. The usual stub answers every query
11
+ * with one boolean:
12
+ *
13
+ * vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({matches: true, ...}))
14
+ *
15
+ * That is enough to put AppShell below its breakpoint — and it silently also
16
+ * matches `prefers-reduced-motion`, rerouting any code that branches on motion.
17
+ * MobileNav's close delay is capped at 0 under reduced motion, so a suite using
18
+ * the blanket stub runs against an immediate close while its test names claim
19
+ * it is exercising the real delay. The failure mode is invisible: every test
20
+ * still passes.
21
+ *
22
+ * So the two answers are separate knobs here. `reduceMotion` answers
23
+ * `prefers-reduced-motion` only; `matches` answers everything else (breakpoint
24
+ * width queries, which tests below a breakpoint want to be true).
25
+ *
26
+ * SYNC: When modified, update this header.
27
+ */
28
+
29
+ import {vi} from 'vitest';
30
+
31
+ export interface StubMatchMediaOptions {
32
+ /** Whether the user prefers reduced motion. */
33
+ reduceMotion?: boolean;
34
+ /** Answer for every other query — width breakpoints, hover, etc. */
35
+ matches?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Answer a `prefers-reduced-motion` query, or null if it isn't one.
40
+ *
41
+ * The preference has two spellings and they are opposites, so a single
42
+ * `includes('prefers-reduced-motion')` would answer `no-preference` with the
43
+ * value meant for `reduce` — the same answer-without-reading mistake this
44
+ * helper exists to prevent. Core only queries `reduce` today (MobileNav,
45
+ * Carousel); handling both is what keeps that true if one changes.
46
+ */
47
+ function answerReducedMotion(
48
+ query: string,
49
+ reduceMotion: boolean,
50
+ ): boolean | null {
51
+ if (query.includes('prefers-reduced-motion: reduce')) {
52
+ return reduceMotion;
53
+ }
54
+ if (query.includes('prefers-reduced-motion: no-preference')) {
55
+ return !reduceMotion;
56
+ }
57
+ // Bare `(prefers-reduced-motion)` is true for any value other than
58
+ // no-preference, i.e. the same as `reduce`.
59
+ if (query.includes('prefers-reduced-motion')) {
60
+ return reduceMotion;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Install a `window.matchMedia` stub whose answer depends on the query.
67
+ *
68
+ * Call from `beforeEach` (or inside a test that needs different motion
69
+ * settings) and pair with `vi.unstubAllGlobals()` in `afterEach`.
70
+ *
71
+ * @example
72
+ * ```
73
+ * beforeEach(() => {
74
+ * stubMatchMedia({reduceMotion: false});
75
+ * });
76
+ * ```
77
+ */
78
+ export function stubMatchMedia({
79
+ reduceMotion = false,
80
+ matches = true,
81
+ }: StubMatchMediaOptions = {}): void {
82
+ vi.stubGlobal(
83
+ 'matchMedia',
84
+ vi.fn((query: string) => ({
85
+ matches: answerReducedMotion(query, reduceMotion) ?? matches,
86
+ media: query,
87
+ onchange: null,
88
+ addEventListener: vi.fn(),
89
+ removeEventListener: vi.fn(),
90
+ addListener: vi.fn(),
91
+ removeListener: vi.fn(),
92
+ dispatchEvent: vi.fn(),
93
+ })),
94
+ );
95
+ }
@@ -11,13 +11,17 @@
11
11
  * SYNC: When modified, update this header
12
12
  */
13
13
 
14
- export {
15
- hasActiveFocusTrapEscape,
16
- isImeKeyEvent,
17
- useFocusTrap,
18
- } from './useFocusTrap';
14
+ export {hasActiveFocusTrapEscape, useFocusTrap} from './useFocusTrap';
19
15
  export type {UseFocusTrapOptions, UseFocusTrapReturn} from './useFocusTrap';
20
16
 
17
+ /**
18
+ * @deprecated Import from `@astryxdesign/core/utils` instead — this is a pure
19
+ * predicate, not a hook, and the `hooks` barrel is a `'use client'` boundary.
20
+ * Re-exported here for one release so consumers can move; will be removed in
21
+ * an upcoming major.
22
+ */
23
+ export {isImeKeyEvent} from '../utils/ime';
24
+
21
25
  export {useAnnounce} from './useAnnounce';
22
26
  export type {AnnounceFn, AnnouncePoliteness} from './useAnnounce';
23
27
 
@@ -19,6 +19,7 @@
19
19
  import {useCallback, useEffect, useRef} from 'react';
20
20
 
21
21
  import {FOCUSABLE_SELECTOR} from './focusableSelector';
22
+ import {isImeKeyEvent} from '../utils/ime';
22
23
 
23
24
  /**
24
25
  * Module-level stack of active focus-trap Escape handlers.
@@ -94,20 +95,6 @@ export function hasActiveFocusTrapEscape(): boolean {
94
95
  return escapeStack.length > 0;
95
96
  }
96
97
 
97
- /**
98
- * Whether an Escape keydown should be ignored because it is cancelling an
99
- * in-progress IME composition. CJK/IME users press Escape to cancel
100
- * composition; that must not close the surrounding overlay. `keyCode === 229`
101
- * covers browsers that fire keydown before `isComposing` is set. Exported so
102
- * other overlays (Dialog, Drawer, CommandPalette) share one definition.
103
- */
104
- export function isImeKeyEvent(event: {
105
- isComposing?: boolean;
106
- keyCode?: number;
107
- }): boolean {
108
- return event.isComposing === true || event.keyCode === 229;
109
- }
110
-
111
98
  /**
112
99
  * Whether an element is currently perceivable/focusable — excludes ones hidden
113
100
  * via `display:none`/`visibility:hidden` or inside an `inert`/`hidden` subtree,
@@ -159,6 +159,25 @@ describe('useInputStatusIcon — no dangling aria-describedby (WCAG 1.3.1)', ()
159
159
  ).not.toBeInTheDocument();
160
160
  expectNoDanglingDescribedBy(container);
161
161
  });
162
+
163
+ it('keeps the tooltip status button interactive inside a non-interactive trailing slot', () => {
164
+ // TextArea positions its trailing slot as an absolute overlay with
165
+ // `pointer-events: none` (correct for the decorative spinner/icon it was
166
+ // built for). The focusable status button owns its own interactivity so
167
+ // hover still opens the tooltip there — keyboard focus always worked, the
168
+ // pointer path did not until the button set `pointer-events: auto`.
169
+ render(
170
+ <TextArea
171
+ label="Field"
172
+ value=""
173
+ onChange={() => {}}
174
+ status={{type: 'error', message: 'Message'}}
175
+ statusVariant="tooltip"
176
+ />,
177
+ );
178
+ const statusButton = screen.getByRole('button', {name: 'Error details'});
179
+ expect(statusButton).toHaveStyle({pointerEvents: 'auto'});
180
+ });
162
181
  });
163
182
 
164
183
  describe('useInputStatusIcon — input-status-icon theme target', () => {
@@ -82,6 +82,13 @@ const styles = stylex.create({
82
82
  background: 'none',
83
83
  color: 'inherit',
84
84
  cursor: 'pointer',
85
+ // Own the interactivity so the tooltip opens on hover even when the field
86
+ // renders this affordance inside a non-interactive trailing slot. TextArea
87
+ // positions its end slot as an absolute overlay with `pointer-events: none`
88
+ // (right for the decorative spinner/icon it was built for), which otherwise
89
+ // swallows this button's hover — keyboard focus still worked, pointer did
90
+ // not.
91
+ pointerEvents: 'auto',
85
92
  borderRadius: radiusVars['--radius-full'],
86
93
  // Not the shared offset: this button sits inside the field, and measured at
87
94
  // the standard 3px its ring crosses the field border.
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export * from './AvatarGroup';
22
22
  export * from './Badge';
23
23
  export * from './Banner';
24
24
  export * from './Blockquote';
25
+ export * from './BottomSheet';
25
26
  export * from './Breadcrumbs';
26
27
  export * from './Button';
27
28
  export * from './ButtonGroup';
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
4
  import type {IconRegistry} from '../Icon/globalIconRegistry';
5
+ import type {DefinedTheme} from './defineTheme';
5
6
  import {defineTheme, generateThemeCSS, isDefinedTheme} from './defineTheme';
6
7
 
7
8
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
@@ -1126,4 +1127,130 @@ describe('defineTheme extends', () => {
1126
1127
  base.tokens['--font-size-base'],
1127
1128
  );
1128
1129
  });
1130
+
1131
+ it('inherits indicators when the child has none', () => {
1132
+ const indicator = (() => null) as unknown as NonNullable<
1133
+ DefinedTheme['indicators']
1134
+ >['check'];
1135
+ const base = defineTheme({name: 'base', indicators: {check: indicator}});
1136
+ const child = defineTheme({name: 'child', extends: base});
1137
+ expect(child.indicators?.check).toBe(indicator);
1138
+ });
1139
+
1140
+ it('inherits onDark token overrides from base theme', () => {
1141
+ const base = defineTheme({
1142
+ name: 'base',
1143
+ onDark: {tokens: {'--color-border': '#ffffff'}},
1144
+ });
1145
+ const child = defineTheme({name: 'child', extends: base});
1146
+ expect(child.__onDark?.tokens['--color-border']).toBe('#ffffff');
1147
+ });
1148
+
1149
+ it('inherits onLight token overrides from base theme', () => {
1150
+ const base = defineTheme({
1151
+ name: 'base',
1152
+ onLight: {tokens: {'--color-border': '#000000'}},
1153
+ });
1154
+ const child = defineTheme({name: 'child', extends: base});
1155
+ expect(child.__onLight?.tokens['--color-border']).toBe('#000000');
1156
+ });
1157
+
1158
+ it('lets the child override an inherited onDark token', () => {
1159
+ const base = defineTheme({
1160
+ name: 'base',
1161
+ onDark: {tokens: {'--color-border': '#ffffff', '--color-track': '#eee'}},
1162
+ });
1163
+ const child = defineTheme({
1164
+ name: 'child',
1165
+ extends: base,
1166
+ onDark: {tokens: {'--color-border': '#cccccc'}},
1167
+ });
1168
+ expect(child.__onDark?.tokens['--color-border']).toBe('#cccccc');
1169
+ expect(child.__onDark?.tokens['--color-track']).toBe('#eee');
1170
+ });
1171
+
1172
+ it('inherits and deep-merges onDark component overrides', () => {
1173
+ const base = defineTheme({
1174
+ name: 'base',
1175
+ onDark: {
1176
+ components: {
1177
+ card: {base: {borderColor: '#fff', backgroundColor: '#111'}},
1178
+ badge: {base: {color: '#fff'}},
1179
+ },
1180
+ },
1181
+ });
1182
+ const child = defineTheme({
1183
+ name: 'child',
1184
+ extends: base,
1185
+ onDark: {components: {card: {base: {borderColor: '#ccc'}}}},
1186
+ });
1187
+ expect(child.__onDark?.components?.card?.base).toEqual({
1188
+ borderColor: '#ccc',
1189
+ backgroundColor: '#111',
1190
+ });
1191
+ expect(child.__onDark?.components?.badge?.base).toEqual({color: '#fff'});
1192
+ });
1193
+
1194
+ it('inherits __inputTokens so inherited [light, dark] tuples survive', () => {
1195
+ const base = defineTheme({
1196
+ name: 'base',
1197
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1198
+ });
1199
+ const child = defineTheme({
1200
+ name: 'child',
1201
+ extends: base,
1202
+ tokens: {'--color-border': '#cccccc'},
1203
+ });
1204
+ expect(child.__inputTokens?.['--color-accent']).toEqual([
1205
+ '#111111',
1206
+ '#eeeeee',
1207
+ ]);
1208
+ expect(child.__inputTokens?.['--color-border']).toBe('#cccccc');
1209
+ });
1210
+
1211
+ it('__inputTokens from the child win over the base', () => {
1212
+ const base = defineTheme({
1213
+ name: 'base',
1214
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1215
+ });
1216
+ const child = defineTheme({
1217
+ name: 'child',
1218
+ extends: base,
1219
+ tokens: {'--color-accent': '#ff0000'},
1220
+ });
1221
+ expect(child.__inputTokens?.['--color-accent']).toBe('#ff0000');
1222
+ });
1223
+
1224
+ // The failure that motivated these: `extends` resolving to `undefined` (a
1225
+ // named import that silently missed — see the theme-build resolution tests)
1226
+ // used to inherit nothing and build a stylesheet that looked fine.
1227
+ it('throws when extends is present but undefined', () => {
1228
+ expect(() =>
1229
+ defineTheme({
1230
+ name: 'child',
1231
+ extends: undefined,
1232
+ tokens: {'--color-accent': '#ff0000'},
1233
+ }),
1234
+ ).toThrow(/extends/);
1235
+ });
1236
+
1237
+ it('throws when extends is not a theme', () => {
1238
+ expect(() =>
1239
+ defineTheme({
1240
+ name: 'child',
1241
+ // A module namespace object is the shape a bad import hands over.
1242
+ extends: {foo: 'bar'} as unknown as DefinedTheme,
1243
+ }),
1244
+ ).toThrow(/extends/);
1245
+ });
1246
+
1247
+ it('accepts a pre-built theme module as a base', () => {
1248
+ const built = {
1249
+ name: 'built-base',
1250
+ __built: true,
1251
+ tokens: {'--color-accent': '#111111'},
1252
+ } as DefinedTheme;
1253
+ const child = defineTheme({name: 'child', extends: built});
1254
+ expect(child.tokens['--color-accent']).toBe('#111111');
1255
+ });
1129
1256
  });