@astryxdesign/core 0.4.0 → 0.4.1

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 (194) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +2 -2
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
@@ -90,6 +90,7 @@ export {
90
90
  spacingDefaults,
91
91
  sizeDefaults,
92
92
  borderDefaults,
93
+ focusDefaults,
93
94
  radiusDefaults,
94
95
  shadowDefaults,
95
96
  durationDefaults,
@@ -102,6 +103,7 @@ export {
102
103
  spacingVars,
103
104
  sizeVars,
104
105
  borderVars,
106
+ focusVars,
105
107
  radiusVars,
106
108
  shadowVars,
107
109
  durationVars,
@@ -193,6 +193,24 @@ export const borderVars = stylex.defineVars(borderDefaults);
193
193
 
194
194
  export type BorderVarName = keyof typeof borderDefaults;
195
195
 
196
+ // =============================================================================
197
+ // Focus Tokens
198
+ // =============================================================================
199
+ // The keyboard focus ring, as one definition for the whole system. Values come
200
+ // from Design Conventions §User Interaction States: 2px --color-accent at 3px
201
+ // offset. Components read these through utils/focusOutline.stylex.ts; the
202
+ // `:focus-visible` condition stays in core, so a theme can restyle the ring
203
+ // but cannot show it to pointer users.
204
+
205
+ export const focusDefaults = {
206
+ '--focus-outline-width': '2px',
207
+ '--focus-outline-style': 'solid',
208
+ '--focus-outline-color': 'var(--color-accent)',
209
+ '--focus-outline-offset': '3px',
210
+ } as const;
211
+
212
+ export const focusVars = stylex.defineVars(focusDefaults);
213
+
196
214
  // =============================================================================
197
215
  // Radius Tokens
198
216
  // =============================================================================
@@ -280,3 +280,40 @@ describe('resolveThemeTokens — generated themes end to end', () => {
280
280
  expect(light['--color-icon-accent']).toBe(light['--color-accent']);
281
281
  });
282
282
  });
283
+
284
+ describe('focus outline tokens', () => {
285
+ it('default to the accent ring from Design Conventions', () => {
286
+ const tokens = resolveThemeTokens(null, {mode: 'light'});
287
+
288
+ expect(tokens['--focus-outline-width']).toBe('2px');
289
+ expect(tokens['--focus-outline-style']).toBe('solid');
290
+ expect(tokens['--focus-outline-offset']).toBe('3px');
291
+ // Tracks the theme's accent unless a theme says otherwise.
292
+ expect(tokens['--focus-outline-color']).toBe(tokens['--color-accent']);
293
+ });
294
+
295
+ it('follows a theme accent without an explicit focus override', () => {
296
+ const theme = defineTheme({name: 'brand', color: {accent: '#AA0000'}});
297
+ const tokens = resolveThemeTokens(theme, {mode: 'light'});
298
+
299
+ expect(tokens['--focus-outline-color']).toBe(tokens['--color-accent']);
300
+ expect(tokens['--focus-outline-color']).not.toContain('var(');
301
+ });
302
+
303
+ it('takes an explicit ring over the accent', () => {
304
+ const theme = defineTheme({
305
+ name: 'brand',
306
+ color: {accent: '#AA0000'},
307
+ tokens: {
308
+ '--focus-outline-color': '#00FF00',
309
+ '--focus-outline-width': '4px',
310
+ '--focus-outline-offset': '0px',
311
+ },
312
+ });
313
+ const tokens = resolveThemeTokens(theme, {mode: 'light'});
314
+
315
+ expect(tokens['--focus-outline-color']).toBe('#00FF00');
316
+ expect(tokens['--focus-outline-width']).toBe('4px');
317
+ expect(tokens['--focus-outline-offset']).toBe('0px');
318
+ });
319
+ });
@@ -7,28 +7,42 @@
7
7
  * @position Internal utility for consistent keyboard focus outlines across core components
8
8
  *
9
9
  * Centralizes the standard Astryx focus outline, shown only for keyboard focus.
10
- * Every focusable surface drew the same 2px accent ring at 2px offset already;
11
- * writing it per component meant five identical definitions to keep in step.
10
+ * Every focusable surface drew the same 2px accent ring already; writing it per
11
+ * component meant dozens of identical definitions to keep in step, and they did
12
+ * not stay in step — offsets drifted and one ring was a border-width thick.
12
13
  *
13
- * These are utility styles, deliberately NOT a theme target. A shared
14
- * `astryx-focus-outline` class was tried and pulled: a theme can only override
15
- * the ring unconditionally through it (`generateThemeRules` mangles
16
- * `:focus-visible` into `.focus-visible` and truncates `:has(:focus-visible)`
17
- * at the paren), so the one thing such a target exists for — restyling a
18
- * STATE — is exactly what it cannot express. Making focus outlines themeable
19
- * is worth doing on top of this consolidation, once that is fixed.
14
+ * Every ring in core and lab is drawn from here, with one exception recorded in
15
+ * Switch: its condition is a component-scoped ancestor marker, which cannot be
16
+ * shared without leaking focus state between components. A component may still
17
+ * override the OFFSET (a ring that must sit inset, or clear of a field border);
18
+ * width, style and color are not restated anywhere.
19
+ *
20
+ * Every value comes from the `--focus-outline-*` tokens, which is how a theme
21
+ * restyles the ring: one override reaches every component at once. The
22
+ * `:focus-visible` condition is not themeable and stays here, so a theme can
23
+ * change what the ring looks like but cannot show it to pointer users.
20
24
  */
21
25
 
22
26
  import * as stylex from '@stylexjs/stylex';
23
- import {colorVars} from '../theme/tokens.stylex';
27
+ import {focusVars} from '../theme/tokens.stylex';
28
+
29
+ const FOCUS_OUTLINE_WIDTH = focusVars['--focus-outline-width'];
30
+ const FOCUS_OUTLINE_STYLE = focusVars['--focus-outline-style'];
31
+ const FOCUS_OUTLINE_COLOR = focusVars['--focus-outline-color'];
32
+
33
+ const FOCUS_OUTLINE_OFFSET = focusVars['--focus-outline-offset'];
24
34
 
25
- const FOCUS_OUTLINE_WIDTH = '2px';
26
- // 3px, per Design Conventions §User Interaction States: "2px --color-accent
27
- // outline at 3px offset". Most components had drifted to 2px; Button, Calendar,
28
- // Dialog and Pagination were the ones matching the spec, so the consolidation
29
- // takes their value rather than the majority's.
30
- const FOCUS_OUTLINE_OFFSET = '3px';
31
- const FOCUS_OUTLINE_COLOR = colorVars['--color-accent'];
35
+ /**
36
+ * The ring as a single `outline` value, for reading it at runtime an
37
+ * assertion, or an element styled imperatively.
38
+ *
39
+ * NOT usable inside another file's `stylex.create`: StyleX resolves imported
40
+ * `defineVars` and nothing else, so a component whose condition
41
+ * {@link focusOutlineStyles} cannot express (Switch, whose ring keys off a
42
+ * component-scoped ancestor marker) has to compose the `focusVars` tokens
43
+ * itself.
44
+ */
45
+ export const FOCUS_OUTLINE = `${FOCUS_OUTLINE_WIDTH} ${FOCUS_OUTLINE_STYLE} ${FOCUS_OUTLINE_COLOR}`;
32
46
 
33
47
  /**
34
48
  * Written as longhands rather than the `outline` shorthand.
@@ -40,7 +54,7 @@ const FOCUS_OUTLINE_COLOR = colorVars['--color-accent'];
40
54
  */
41
55
  const focusOutlineLonghands = {
42
56
  outlineWidth: FOCUS_OUTLINE_WIDTH,
43
- outlineStyle: 'solid',
57
+ outlineStyle: FOCUS_OUTLINE_STYLE,
44
58
  outlineColor: FOCUS_OUTLINE_COLOR,
45
59
  } as const;
46
60
 
@@ -53,8 +67,8 @@ const focusOutlineLonghands = {
53
67
  * Longhands, not the `outline` shorthand, for the same reason the styles are:
54
68
  * a shorthand resets every longhand it covers, so a caller could not re-color
55
69
  * the ring without restating its width and style. Split, each part is
56
- * independently overridable — and `--color-accent` stays a var, so a theme's
57
- * accent still flows through.
70
+ * independently overridable — and every part stays a var, so a theme's
71
+ * `--focus-outline-*` overrides still flow through.
58
72
  *
59
73
  * Keys are camelCase to match `HTMLElement.style`, so this spreads straight
60
74
  * onto an element:
@@ -66,7 +80,7 @@ const focusOutlineLonghands = {
66
80
  */
67
81
  export const FOCUS_OUTLINE_PARTS = {
68
82
  outlineWidth: FOCUS_OUTLINE_WIDTH,
69
- outlineStyle: 'solid',
83
+ outlineStyle: FOCUS_OUTLINE_STYLE,
70
84
  outlineColor: FOCUS_OUTLINE_COLOR,
71
85
  outlineOffset: FOCUS_OUTLINE_OFFSET,
72
86
  } as const;
@@ -113,7 +127,7 @@ export const focusOutlineStyles = stylex.create({
113
127
  publishFocusVisibleVars: {
114
128
  '--_focus-outline': {
115
129
  default: 'none',
116
- ':focus-visible': `${FOCUS_OUTLINE_WIDTH} solid ${FOCUS_OUTLINE_COLOR}`,
130
+ ':focus-visible': FOCUS_OUTLINE,
117
131
  },
118
132
  '--_focus-outline-offset': {
119
133
  default: '0',
@@ -123,7 +137,7 @@ export const focusOutlineStyles = stylex.create({
123
137
  focusWithinOrPublished: {
124
138
  outline: {
125
139
  default: 'var(--_focus-outline, none)',
126
- ':has(:focus-visible)': `${FOCUS_OUTLINE_WIDTH} solid ${FOCUS_OUTLINE_COLOR}`,
140
+ ':has(:focus-visible)': FOCUS_OUTLINE,
127
141
  },
128
142
  outlineOffset: {
129
143
  default: 'var(--_focus-outline-offset, 0)',
@@ -101,3 +101,7 @@ export type {RGBA} from './color';
101
101
  export {devWarn, devError, warnOnce, formatDevMessage} from './devWarning';
102
102
 
103
103
  export {rtlStyles} from './rtlStyles';
104
+
105
+ // The shared focus ring, exported for @astryxdesign/lab — same reason
106
+ // rtlStyles is: a style that must be identical across packages, not copied.
107
+ export {focusOutlineStyles, focusOutlineProps} from './focusOutline.stylex';
@@ -0,0 +1,74 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file interactionModality.ts
5
+ * @input Global pointerdown/keydown events
6
+ * @output `getInteractionModality()` — how the user last interacted
7
+ * @position Internal utility; used where `:focus-visible` alone is too broad
8
+ *
9
+ * `:focus-visible` is the right selector for a focus ring and should stay the
10
+ * condition that draws one. It is not, on its own, "focused by keyboard":
11
+ * per CSS Selectors 4 a pointer-focused element that supports text entry
12
+ * matches it too, deliberately, so that a clicked text field still shows where
13
+ * typing will go. Measured in Chromium — a bare `<input>` clicked with a mouse
14
+ * matches `:focus-visible`.
15
+ *
16
+ * A component that wants a ring for keyboard focus ONLY therefore needs one bit
17
+ * `:focus-visible` cannot give it: which device moved focus. This records that,
18
+ * to be used as a gate ALONGSIDE `:focus-visible`, never as a replacement for
19
+ * it — the browser's heuristic still decides everything else, including
20
+ * `focus({focusVisible: true})`.
21
+ *
22
+ * Listeners are registered once for the document, in the capture phase so they
23
+ * run before the focus they explain, and are never removed: two passive
24
+ * listeners for the whole app, versus a pair per mounted input.
25
+ */
26
+
27
+ export type InteractionModality = 'keyboard' | 'pointer';
28
+
29
+ // Default to keyboard: with no interaction yet, focus arrived programmatically
30
+ // or from the browser restoring it, and showing a ring is the safe error.
31
+ let modality: InteractionModality = 'keyboard';
32
+ let isListening = false;
33
+
34
+ function onPointerDown(): void {
35
+ modality = 'pointer';
36
+ }
37
+
38
+ function onKeyDown(event: KeyboardEvent): void {
39
+ // Modifier-only presses are not navigation — holding Shift before a click
40
+ // must not turn that click into "keyboard".
41
+ if (event.metaKey || event.altKey || event.ctrlKey) {
42
+ return;
43
+ }
44
+ modality = 'keyboard';
45
+ }
46
+
47
+ /**
48
+ * Start tracking, once per document. Safe to call repeatedly and on the server
49
+ * (where it does nothing).
50
+ */
51
+ export function trackInteractionModality(): void {
52
+ if (isListening || typeof document === 'undefined') {
53
+ return;
54
+ }
55
+ isListening = true;
56
+ document.addEventListener('pointerdown', onPointerDown, {
57
+ capture: true,
58
+ passive: true,
59
+ });
60
+ document.addEventListener('keydown', onKeyDown, {
61
+ capture: true,
62
+ passive: true,
63
+ });
64
+ }
65
+
66
+ /** How the user last interacted with the page. */
67
+ export function getInteractionModality(): InteractionModality {
68
+ return modality;
69
+ }
70
+
71
+ /** Test-only: restore the initial state between cases. */
72
+ export function __resetInteractionModalityForTest(): void {
73
+ modality = 'keyboard';
74
+ }