@astryxdesign/core 0.6.1 → 0.6.2-canary.0faf070

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 (166) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +3 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -19
  8. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  9. package/dist/CheckboxInput/CheckboxInput.js +12 -2
  10. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  11. package/dist/Collapsible/Collapsible.js +6 -1
  12. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  13. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  14. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  15. package/dist/Dialog/DialogHeader.d.ts +1 -1
  16. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  17. package/dist/Dialog/DialogHeader.js +10 -7
  18. package/dist/FileInput/FileInput.d.ts.map +1 -1
  19. package/dist/FileInput/FileInput.js +9 -3
  20. package/dist/Kbd/Kbd.d.ts +5 -3
  21. package/dist/Kbd/Kbd.d.ts.map +1 -1
  22. package/dist/Kbd/Kbd.js +36 -42
  23. package/dist/Link/Link.d.ts.map +1 -1
  24. package/dist/Link/Link.js +6 -2
  25. package/dist/Markdown/Markdown.d.ts +10 -2
  26. package/dist/Markdown/Markdown.d.ts.map +1 -1
  27. package/dist/Markdown/Markdown.js +58 -14
  28. package/dist/Markdown/index.d.ts +1 -1
  29. package/dist/Markdown/index.d.ts.map +1 -1
  30. package/dist/Markdown/parser.d.ts +126 -12
  31. package/dist/Markdown/parser.d.ts.map +1 -1
  32. package/dist/Markdown/parser.js +369 -34
  33. package/dist/Markdown/utils.d.ts +1 -1
  34. package/dist/Markdown/utils.d.ts.map +1 -1
  35. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  36. package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
  37. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  38. package/dist/RadioList/RadioListItem.js +13 -1
  39. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  40. package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
  41. package/dist/SideNav/SideNav.d.ts +2 -1
  42. package/dist/SideNav/SideNav.d.ts.map +1 -1
  43. package/dist/SideNav/SideNav.js +9 -3
  44. package/dist/Slider/Slider.d.ts.map +1 -1
  45. package/dist/Slider/Slider.js +19 -10
  46. package/dist/Spinner/Spinner.d.ts +1 -1
  47. package/dist/Spinner/Spinner.d.ts.map +1 -1
  48. package/dist/Spinner/Spinner.js +23 -15
  49. package/dist/Switch/Switch.d.ts.map +1 -1
  50. package/dist/Switch/Switch.js +11 -0
  51. package/dist/TabList/Tab.d.ts +1 -1
  52. package/dist/TabList/Tab.d.ts.map +1 -1
  53. package/dist/TabList/Tab.js +20 -7
  54. package/dist/ToggleButton/ToggleButton.d.ts +2 -1
  55. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  56. package/dist/ToggleButton/ToggleButton.js +7 -1
  57. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  58. package/dist/Typeahead/BaseTypeahead.js +15 -6
  59. package/dist/astryx.css +13 -2
  60. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  61. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  62. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  63. package/dist/hooks/useScrollableArea.d.ts +6 -2
  64. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  65. package/dist/hooks/useScrollableArea.js +17 -5
  66. package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
  67. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  68. package/dist/utils/interactionOverlay.stylex.js +9 -0
  69. package/locales/en.json +16 -0
  70. package/locales/pseudo.json +12 -0
  71. package/package.json +7 -5
  72. package/scripts/agent-doc-state.mjs +1 -1
  73. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  74. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  75. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  76. package/src/BottomSheet/BottomSheet.tsx +3 -1
  77. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  78. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  79. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  80. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  81. package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
  82. package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
  83. package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
  84. package/src/Button/__tests__/Button.a11y.states.ts +9 -0
  85. package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
  86. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  87. package/src/CheckboxInput/CheckboxInput.tsx +21 -1
  88. package/src/ClickableCard/ClickableCard.test.tsx +102 -5
  89. package/src/Collapsible/Collapsible.doc.mjs +11 -0
  90. package/src/Collapsible/Collapsible.test.tsx +21 -0
  91. package/src/Collapsible/Collapsible.tsx +5 -0
  92. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  93. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  94. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  95. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  96. package/src/Dialog/Dialog.doc.mjs +3 -0
  97. package/src/Dialog/Dialog.spec.md +1 -1
  98. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  99. package/src/Dialog/DialogHeader.test.tsx +49 -0
  100. package/src/Dialog/DialogHeader.tsx +23 -4
  101. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  102. package/src/FileInput/FileInput.doc.mjs +2 -0
  103. package/src/FileInput/FileInput.spec.md +199 -0
  104. package/src/FileInput/FileInput.test.tsx +14 -0
  105. package/src/FileInput/FileInput.tsx +13 -3
  106. package/src/Kbd/Kbd.doc.mjs +3 -3
  107. package/src/Kbd/Kbd.test.tsx +57 -1
  108. package/src/Kbd/Kbd.tsx +57 -37
  109. package/src/Link/Link.doc.mjs +11 -0
  110. package/src/Link/Link.test.tsx +24 -0
  111. package/src/Link/Link.tsx +5 -0
  112. package/src/Markdown/Markdown.doc.mjs +167 -42
  113. package/src/Markdown/Markdown.public.test.ts +157 -0
  114. package/src/Markdown/Markdown.spec.md +255 -71
  115. package/src/Markdown/Markdown.test.tsx +107 -3
  116. package/src/Markdown/Markdown.tsx +116 -35
  117. package/src/Markdown/incremental.test.ts +175 -7
  118. package/src/Markdown/index.ts +6 -0
  119. package/src/Markdown/parser.perf.test.ts +3 -1
  120. package/src/Markdown/parser.test.ts +122 -0
  121. package/src/Markdown/parser.ts +609 -81
  122. package/src/Markdown/utils.ts +6 -0
  123. package/src/Outline/Outline.spec.md +1 -1
  124. package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
  125. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
  126. package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
  127. package/src/RadioList/RadioList.doc.mjs +11 -0
  128. package/src/RadioList/RadioList.test.tsx +32 -0
  129. package/src/RadioList/RadioListItem.tsx +25 -1
  130. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
  132. package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
  133. package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
  134. package/src/SideNav/SideNav.doc.mjs +1 -1
  135. package/src/SideNav/SideNav.test.tsx +10 -0
  136. package/src/SideNav/SideNav.tsx +14 -2
  137. package/src/Slider/Slider.doc.mjs +27 -0
  138. package/src/Slider/Slider.spec.md +61 -47
  139. package/src/Slider/Slider.test.tsx +146 -0
  140. package/src/Slider/Slider.tsx +37 -13
  141. package/src/Spinner/Spinner.doc.mjs +6 -3
  142. package/src/Spinner/Spinner.test.tsx +37 -0
  143. package/src/Spinner/Spinner.tsx +31 -14
  144. package/src/Switch/Switch.doc.mjs +11 -0
  145. package/src/Switch/Switch.test.tsx +16 -0
  146. package/src/Switch/Switch.tsx +28 -0
  147. package/src/TabList/Tab.tsx +35 -7
  148. package/src/TabList/TabList.doc.mjs +11 -0
  149. package/src/TabList/TabList.test.tsx +66 -0
  150. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
  151. package/src/ToggleButton/ToggleButton.test.tsx +133 -0
  152. package/src/ToggleButton/ToggleButton.tsx +9 -2
  153. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
  154. package/src/Tokenizer/Tokenizer.spec.md +142 -75
  155. package/src/Typeahead/BaseTypeahead.spec.md +4 -3
  156. package/src/Typeahead/BaseTypeahead.tsx +15 -6
  157. package/src/Typeahead/Typeahead.test.tsx +53 -0
  158. package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
  159. package/src/__tests__/pressState.ts +93 -0
  160. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  161. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  162. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  163. package/src/hooks/useScrollableArea.test.tsx +59 -1
  164. package/src/hooks/useScrollableArea.ts +34 -10
  165. package/src/theme/derivedVarRegistry.test.ts +6 -4
  166. package/src/utils/interactionOverlay.stylex.ts +20 -0
@@ -0,0 +1,199 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 4
4
+ kind: component
5
+ id: component:FileInput
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-09-14
11
+ owners: [cixzhang, imdreamrunner]
12
+ review_triggers: [theming]
13
+ verified_by:
14
+ [
15
+ packages/core/src/FileInput/FileInput.test.tsx,
16
+ packages/core/src/theme/themingTargets.test.ts,
17
+ scripts/check-knowledge.mjs,
18
+ ]
19
+ modules: []
20
+ families: [family:input-fields]
21
+ design_specs: []
22
+ architecture: [architecture:component-theming-surface]
23
+ contributing: []
24
+ system_specs: []
25
+ ---
26
+
27
+ # FileInput component contract
28
+
29
+ ## Intent
30
+
31
+ FileInput presents a labelled file-selection field in compact input or dropzone
32
+ form. This contract records its current consumer anatomy and approves separate theme
33
+ ownership for the upload affordance that FileInput paints through Icon.
34
+
35
+ ## Compatibility and migration
36
+
37
+ - Released default preserved: `yes`
38
+ - Compatibility class: additive public theming target; no existing target,
39
+ runtime default, DOM, prop, interaction, or accessibility behavior changes
40
+ - Controlled/uncontrolled behavior: unchanged; FileInput remains controlled
41
+ - Migration decision: `component:FileInput/DEC-1`
42
+
43
+ Consumer migration instructions belong in consumer docs and release notes.
44
+
45
+ ## Ownership boundary
46
+
47
+ **Owns**
48
+
49
+ - The visible file-selection surface and its input/dropzone mode.
50
+ - Whether, where, and at what default size the upload affordance appears.
51
+ - Reflecting FileInput's mode on its locally owned theme targets.
52
+
53
+ **Does not own / non-goals**
54
+
55
+ - The upload artwork or Icon's base color, size, and accessibility semantics —
56
+ owned by `component:Icon`.
57
+ - Label, description, clear-control, and validation-message presentation — owned
58
+ by `component:Field` and `component:FieldStatus`.
59
+ - Loading-indicator presentation — owned by `component:Spinner`.
60
+ - A new prop, variant, icon slot, or custom property.
61
+
62
+ ## Public concepts
63
+
64
+ No consumer prop changes. The `file-input-icon` target gives themes a
65
+ same-element seam for the Upload icon and reflects the existing `mode` axis. The
66
+ existing `file-input` target remains on the visible selection surface and keeps
67
+ its `mode` and `status` axes.
68
+
69
+ ## Behavioral and layout contract
70
+
71
+ | ID | Candidate invariant | Basis | Review state |
72
+ | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------- |
73
+ | FR1 | The visible selection surface carries `file-input` and reflects the existing `mode` and resolved status. | Current source, public docs, and focused tests | Verified current behavior |
74
+ | FR2 | When not loading, input mode renders an upload affordance at the small Icon size. Dropzone mode renders it at the medium Icon size only while no file is selected. | Current source and focused tests | Verified current behavior |
75
+ | FR3 | Icon owns the rendered glyph's base size, color, and accessibility semantics; FileInput owns the affordance's mode-dependent placement and default size. | Current composition and component boundaries | Verified current composition |
76
+ | FR4 | The rendered upload affordance MUST carry `file-input-icon` with the existing `mode` reflected, so a theme can restyle the glyph box without structural selectors or changing every Icon that uses the same artwork. | Owner-approved target contract | Approved additive contract |
77
+ | FR5 | Adding the target MUST NOT change the default artwork, computed layout, interaction, file-selection behavior, accessible name, or decorative Icon semantics. | Compatibility policy and focused regression tests | Required compatibility behavior |
78
+
79
+ ### Allowed variation
80
+
81
+ - **AV1 — Theme paint.** A theme may change standard visual
82
+ properties such as the upload glyph's size or color through
83
+ `file-input-icon`; FileInput still owns whether and where the affordance renders.
84
+ - **AV2 — Artwork.** Icon registry and future icon-slot decisions may change the
85
+ artwork without changing this CSS target's ownership of the painted glyph box.
86
+
87
+ ### Representative states
88
+
89
+ | State | Required invariant | Allowed variation |
90
+ | ------------------------ | ------------------------------------------------------------------------ | --------------------------------------- |
91
+ | Input, empty or selected | Small upload affordance renders on the input surface when not loading. | Files, placeholder, status, theme paint |
92
+ | Dropzone, empty | Medium upload affordance renders above the placeholder when not loading. | Drag state, placeholder, theme paint |
93
+ | Dropzone, selected | File names replace the upload affordance. | File names and status |
94
+ | Loading | Spinner replaces the upload affordance. | Mode and loading presentation |
95
+
96
+ ### Transformation and precedence order
97
+
98
+ - **ORD1 — Content selection.** Resolve loading and selected-file state, choose
99
+ input or dropzone content, then render the mode-sized upload affordance only in
100
+ the states recorded by FR2.
101
+ - **ORD2 — Theme composition.** Icon applies its base size and color, then the
102
+ same-element FileInput target participates in the existing theme layer and
103
+ standard Icon styling merge order.
104
+
105
+ ### Performance and resources
106
+
107
+ - **PR1 — No new work.** The additive target performs no measurement, listener,
108
+ observer, state update, or additional render pass.
109
+
110
+ ## Accessibility contract
111
+
112
+ The upload affordance remains decorative. The existing focusable file-selection
113
+ trigger, label, description, required/invalid state, disabled explanation, and
114
+ selection announcements remain unchanged.
115
+
116
+ ## Design relationships
117
+
118
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
119
+ | ---------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | -------------- | ------------------ |
120
+ | Drop zone | Presents the visible file-selection surface in input or dropzone form. | Current source and public docs | Prominent | FR1 |
121
+ | Upload icon | Hints at the upload action and changes default size with the selected mode. | Current source and owner-approved target | Supporting | FR2, FR3, FR4 |
122
+ | Shared feedback | Uses Field, FieldStatus, and Spinner for labels, validation, and loading treatment. | Current shared composition | Supporting | FR5 |
123
+
124
+ ### Theming anatomy
125
+
126
+ <!-- anatomy-theming:v1 -->
127
+
128
+ ```json
129
+ {
130
+ "Label": {
131
+ "delegatesTo": {"owner": "component:Field", "target": "field-label"}
132
+ },
133
+ "Description": {
134
+ "none": {
135
+ "reason": "unsettled: No current public target reaches the stable Description; future exposure still needs an owner decision"
136
+ }
137
+ },
138
+ "Drop zone": {"target": "file-input"},
139
+ "Upload icon": {"target": "file-input-icon"},
140
+ "Placeholder": {"inherits": "file-input"},
141
+ "File name display": {"inherits": "file-input"},
142
+ "Clear button": {
143
+ "delegatesTo": {
144
+ "owner": "component:Field",
145
+ "target": "input-clear-button"
146
+ }
147
+ },
148
+ "Spinner": {
149
+ "delegatesTo": {"owner": "component:Spinner", "target": "spinner"}
150
+ },
151
+ "Status message": {
152
+ "delegatesTo": {
153
+ "owner": "component:FieldStatus",
154
+ "target": "field-status"
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ The `file-input-icon` disposition records the approved target state. Icon still
161
+ owns the general `icon` target and base glyph semantics; FileInput's narrower
162
+ target owns only this stable upload position and its existing mode distinction.
163
+
164
+ ## Family and system relationships
165
+
166
+ - `family:input-fields` owns shared labelled-field and validation behavior.
167
+ - `architecture:component-theming-surface` owns target qualification, anatomy
168
+ mapping, and the requirement that public targets sit on stable painted parts.
169
+ - Field, FieldStatus, Icon, and Spinner retain their existing public target
170
+ contracts when composed by FileInput.
171
+
172
+ ## Verification map
173
+
174
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
175
+ | ------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------- |
176
+ | FR1, FR2 | `FileInput.test.tsx` rendering and target suites | Input/dropzone; empty/selected/loading | Moving the root target or changing when/at what size the affordance renders breaks focused assertions. | `audit:FileInput/theming` |
177
+ | FR3, FR4, FR5 | `FileInput.test.tsx`, `themingTargets.test.ts`, probe-theme check | Both modes and same-element Icon target | Missing the target, reflecting the wrong mode, or moving it off the glyph fails source/docs/probe coverage. | `audit:FileInput/theming` |
178
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | Nine anatomy entries and two locally owned targets | Missing, extra, prefixed, stale, or unclaimed current mappings fail validation. | `audit:FileInput/anatomy` |
179
+
180
+ ## Decision log
181
+
182
+ ### DEC-1 — Upload icon is stable FileInput theme anatomy
183
+
184
+ **Reference:** `component:FileInput/DEC-1`
185
+ **Decider:** cixzhang, 2026-09-14
186
+
187
+ The upload icon is a stable, consumer-recognizable FileInput affordance whose
188
+ mode-dependent placement and default size belong to FileInput. It receives the
189
+ `file-input-icon` target on the same Icon element that paints the glyph, while
190
+ Icon retains its general target and base glyph semantics.
191
+
192
+ ## Open questions
193
+
194
+ None.
195
+
196
+ ## Content boundary
197
+
198
+ This file does not duplicate consumer prop tables, examples, implementation
199
+ steps, or shared-component contracts. It links to their owners.
@@ -119,6 +119,20 @@ describe('FileInput', () => {
119
119
  expect(screen.getByText('Drop here')).toBeInTheDocument();
120
120
  });
121
121
 
122
+ it.each([
123
+ {mode: 'input' as const, size: 'sm'},
124
+ {mode: 'dropzone' as const, size: 'md'},
125
+ ])('exposes the upload icon as a $mode theme target', ({mode, size}) => {
126
+ render(
127
+ <FileInput label="Upload" mode={mode} value={null} onChange={() => {}} />,
128
+ );
129
+
130
+ const icon = document.querySelector('.astryx-file-input-icon');
131
+ expect(icon).toHaveClass('astryx-icon');
132
+ expect(icon).toHaveAttribute('data-mode', mode);
133
+ expect(icon).toHaveAttribute('data-size', size);
134
+ });
135
+
122
136
  it('displays selected file name', () => {
123
137
  const file = createFile('report.pdf', 1024, 'application/pdf');
124
138
  render(<FileInput label="Document" value={file} onChange={() => {}} />);
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @file FileInput.tsx
7
7
  * @input Uses React, useId, Field, Icon, Spinner, VisuallyHidden
8
- * @output Exports FileInput component, FileInputProps, FileInputStatus
8
+ * @output Exports FileInput component, public types, and its root/icon theme targets
9
9
  * @position Core implementation; consumed by index.ts, tested by FileInput.test.tsx
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
@@ -707,7 +707,12 @@ export function FileInput({
707
707
  }
708
708
  return (
709
709
  <>
710
- <Icon icon="arrowUp" size="md" color="secondary" />
710
+ <Icon
711
+ icon="arrowUp"
712
+ size="md"
713
+ color="secondary"
714
+ {...themeProps('file-input-icon', {mode})}
715
+ />
711
716
  <span {...stylex.props(styles.placeholderText)}>
712
717
  {isDragOver ? t('@astryx.fileInput.dropHint') : displayPlaceholder}
713
718
  </span>
@@ -728,7 +733,12 @@ export function FileInput({
728
733
  }
729
734
  return (
730
735
  <>
731
- <Icon icon="arrowUp" size="sm" color="secondary" />
736
+ <Icon
737
+ icon="arrowUp"
738
+ size="sm"
739
+ color="secondary"
740
+ {...themeProps('file-input-icon', {mode})}
741
+ />
732
742
  <span
733
743
  {...stylex.props(
734
744
  hasFiles ? styles.fileNameText : styles.placeholderText,
@@ -27,7 +27,7 @@ export const docs = {
27
27
  name: 'keys',
28
28
  type: 'string',
29
29
  description:
30
- 'Keyboard shortcut string. Use "+" to separate keys. Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right.',
30
+ 'Keyboard shortcut string. Use "+" to separate keys. Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right, plus. Aliases: esc for escape and return for enter.',
31
31
  required: true,
32
32
  },
33
33
  {
@@ -72,7 +72,7 @@ export const docsZh = {
72
72
  name: 'keys',
73
73
  type: 'string',
74
74
  description:
75
- '键盘快捷键字符串。使用 "+" 分隔各按键。特殊按键:mod(Mac 上为 Cmd)、ctrl、alt、shift、enter、backspace、escape、tab、up、down、left、right。',
75
+ '键盘快捷键字符串。使用 "+" 分隔各按键。特殊按键:mod(Mac 上为 Cmd)、ctrl、alt、shift、enter、backspace、escape、tab、up、down、left、right、plus。别名:esc 等同于 escape,return 等同于 enter。',
76
76
  required: true,
77
77
  },
78
78
  {
@@ -122,7 +122,7 @@ export const docsDense = {
122
122
  ],
123
123
  },
124
124
  propDescriptions: {
125
- keys: 'Shortcut string. "+" separates keys. Special: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right.',
125
+ keys: 'Shortcut string. "+" separates keys. Special: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right, plus. Aliases: esc for escape, return for enter.',
126
126
  xstyle: 'StyleX styles for layout customization. Must be stylex.create() value.',
127
127
  className: 'CSS class for root element. Prefer xstyle; className for non-StyleX integration.',
128
128
  style: 'Inline styles for root element. Prefer xstyle; inline styles bypass StyleX optimization.',
@@ -10,13 +10,14 @@
10
10
  */
11
11
 
12
12
  import {render, screen} from '@testing-library/react';
13
- import {describe, it, expect, afterEach} from 'vitest';
13
+ import {describe, it, expect, afterEach, vi} from 'vitest';
14
14
  import {Kbd} from './Kbd';
15
15
 
16
16
  describe('Kbd', () => {
17
17
  const originalPlatform = navigator.platform;
18
18
 
19
19
  afterEach(() => {
20
+ vi.restoreAllMocks();
20
21
  // Restore platform after any test that spoofs it \u2014 ensures no
21
22
  // test pollution even if an assertion fails mid-test.
22
23
  Object.defineProperty(navigator, 'platform', {
@@ -72,6 +73,61 @@ describe('Kbd', () => {
72
73
  expect(screen.getByText('Esc')).toBeInTheDocument();
73
74
  });
74
75
 
76
+ it.each([
77
+ {alias: 'esc', display: 'Esc', label: 'Escape'},
78
+ {alias: 'return', display: '\u21B5', label: 'Enter'},
79
+ ])('normalizes the $alias key alias', ({alias, display, label}) => {
80
+ render(<Kbd keys={alias} />);
81
+ expect(screen.getByText(display)).toBeInTheDocument();
82
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', label);
83
+ });
84
+
85
+ it.each([
86
+ {alias: 'esc', canonical: 'escape'},
87
+ {alias: 'return', canonical: 'enter'},
88
+ ])('$alias renders identically to $canonical', ({alias, canonical}) => {
89
+ const {container, rerender} = render(<Kbd keys={canonical} />);
90
+ const text = container.textContent;
91
+ const label = screen.getByRole('img').getAttribute('aria-label');
92
+ rerender(<Kbd keys={alias} />);
93
+ expect(container.textContent).toBe(text);
94
+ expect(screen.getByRole('img').getAttribute('aria-label')).toBe(label);
95
+ });
96
+
97
+ it.each([
98
+ {keys: 'ctrl + ESC', display: 'Esc', label: 'Control + Escape'},
99
+ {keys: 'shift + RETURN', display: '\u21B5', label: 'Shift + Enter'},
100
+ ])('normalizes aliases inside the $keys combo', ({keys, display, label}) => {
101
+ render(<Kbd keys={keys} />);
102
+ expect(screen.getByText(display)).toBeInTheDocument();
103
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', label);
104
+ });
105
+
106
+ it('keeps child keys unique after alias normalization', () => {
107
+ const consoleError = vi
108
+ .spyOn(console, 'error')
109
+ .mockImplementation(() => {});
110
+ const {container} = render(
111
+ <Kbd keys="escape+esc+ESC+enter+return+return" />,
112
+ );
113
+ expect(consoleError).not.toHaveBeenCalled();
114
+ expect(
115
+ Array.from(container.querySelectorAll('kbd'), key => key.textContent),
116
+ ).toEqual(['Esc', 'Esc', 'Esc', '↵', '↵', '↵']);
117
+ });
118
+
119
+ it.each(['constructor', '__proto__'])(
120
+ 'renders the unknown %s key without consulting object prototypes',
121
+ key => {
122
+ render(<Kbd keys={key} />);
123
+ expect(screen.getByText(key.toUpperCase())).toBeInTheDocument();
124
+ expect(screen.getByRole('img')).toHaveAttribute(
125
+ 'aria-label',
126
+ key.toUpperCase(),
127
+ );
128
+ },
129
+ );
130
+
75
131
  it('exposes a spoken accessible name and hides the glyphs (obs-1)', () => {
76
132
  render(<Kbd keys="mod+shift+k" />);
77
133
  // The wrapper carries a screen-reader name built from spoken key labels
package/src/Kbd/Kbd.tsx CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file Kbd.tsx
7
- * @input Uses React, StyleX, theme tokens
8
- * @output Exports Kbd component and KbdProps
7
+ * @input Uses React, StyleX, theme tokens, shortcut key names and aliases
8
+ * @output Exports Kbd component and KbdProps with canonical shortcut labels
9
9
  * @position Core implementation; renders styled keyboard shortcut indicators
10
10
  *
11
11
  * SYNC: When modified, update:
@@ -61,20 +61,25 @@ const styles = stylex.create({
61
61
  * Note: `mod` is not in this map — it resolves dynamically via platform
62
62
  * detection inside the component.
63
63
  */
64
- const KEY_DISPLAY: Record<string, string> = {
65
- ctrl: '\u2303', // ⌃
66
- alt: '\u2325', // ⌥
67
- shift: '\u21E7', // ⇧
68
- enter: '\u21B5', // ↵
69
- backspace: '\u232B', // ⌫
70
- escape: 'Esc',
71
- tab: '\u21E5', // ⇥
72
- up: '\u2191',
73
- down: '\u2193',
74
- left: '\u2190',
75
- right: '\u2192',
76
- plus: '+',
77
- };
64
+ const KEY_DISPLAY = new Map<string, string>([
65
+ ['ctrl', '\u2303'], // ⌃
66
+ ['alt', '\u2325'], // ⌥
67
+ ['shift', '\u21E7'], // ⇧
68
+ ['enter', '\u21B5'], // ↵
69
+ ['backspace', '\u232B'], // ⌫
70
+ ['escape', 'Esc'],
71
+ ['tab', '\u21E5'], // ⇥
72
+ ['up', '\u2191'],
73
+ ['down', '\u2193'],
74
+ ['left', '\u2190'],
75
+ ['right', '\u2192'],
76
+ ['plus', '+'],
77
+ ]);
78
+
79
+ const KBD_KEY_ALIASES = new Map<string, string>([
80
+ ['esc', 'escape'],
81
+ ['return', 'enter'],
82
+ ]);
78
83
 
79
84
  /**
80
85
  * Resolves a key name to its display string. Handles the platform-aware
@@ -85,7 +90,7 @@ function getKeyDisplay(key: string, isMac: boolean): string {
85
90
  if (key === 'mod') {
86
91
  return isMac ? '\u2318' : 'Ctrl';
87
92
  }
88
- return KEY_DISPLAY[key] ?? key.toUpperCase();
93
+ return KEY_DISPLAY.get(key) ?? key.toUpperCase();
89
94
  }
90
95
 
91
96
  /**
@@ -93,26 +98,26 @@ function getKeyDisplay(key: string, isMac: boolean): string {
93
98
  * (⌘, ⇧, ↵, …) that assistive tech cannot announce meaningfully, so the
94
99
  * accessible name for the shortcut is built from these words instead.
95
100
  */
96
- const KEY_LABEL: Record<string, string> = {
97
- ctrl: 'Control',
98
- alt: 'Alt',
99
- shift: 'Shift',
100
- enter: 'Enter',
101
- backspace: 'Backspace',
102
- escape: 'Escape',
103
- tab: 'Tab',
104
- up: 'Up arrow',
105
- down: 'Down arrow',
106
- left: 'Left arrow',
107
- right: 'Right arrow',
108
- plus: 'Plus',
109
- };
101
+ const KEY_LABEL = new Map<string, string>([
102
+ ['ctrl', 'Control'],
103
+ ['alt', 'Alt'],
104
+ ['shift', 'Shift'],
105
+ ['enter', 'Enter'],
106
+ ['backspace', 'Backspace'],
107
+ ['escape', 'Escape'],
108
+ ['tab', 'Tab'],
109
+ ['up', 'Up arrow'],
110
+ ['down', 'Down arrow'],
111
+ ['left', 'Left arrow'],
112
+ ['right', 'Right arrow'],
113
+ ['plus', 'Plus'],
114
+ ]);
110
115
 
111
116
  function getKeyLabel(key: string, isMac: boolean): string {
112
117
  if (key === 'mod') {
113
118
  return isMac ? 'Command' : 'Control';
114
119
  }
115
- return KEY_LABEL[key] ?? key.toUpperCase();
120
+ return KEY_LABEL.get(key) ?? key.toUpperCase();
116
121
  }
117
122
 
118
123
  function subscribeToPlatformChanges(): () => void {
@@ -127,7 +132,9 @@ export interface KbdProps extends BaseProps<HTMLSpanElement> {
127
132
  ref?: React.Ref<HTMLSpanElement>;
128
133
  /**
129
134
  * Keyboard shortcut string. Use "+" to separate keys.
130
- * Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape.
135
+ * Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape,
136
+ * tab, up, down, left, and right.
137
+ * Aliases: "esc" for "escape" and "return" for "enter".
131
138
  * Use "plus" to render a literal "+" key (e.g. "shift+plus").
132
139
  *
133
140
  * @example
@@ -163,11 +170,24 @@ export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
163
170
  getServerPlatformSnapshot,
164
171
  );
165
172
 
166
- const parts = keys.split('+').map(key => key.trim().toLowerCase());
173
+ const keyOccurrences = new Map<string, number>();
174
+ const parts = keys
175
+ .split('+')
176
+ .map(key => key.trim().toLowerCase())
177
+ .map(sourceKey => {
178
+ const occurrence = keyOccurrences.get(sourceKey) ?? 0;
179
+ keyOccurrences.set(sourceKey, occurrence + 1);
180
+ return {
181
+ key: KBD_KEY_ALIASES.get(sourceKey) ?? sourceKey,
182
+ reactKey: `${sourceKey}:${occurrence}`,
183
+ };
184
+ });
167
185
 
168
186
  // Screen-reader name: the joined spoken labels (e.g. "Command + K"), since
169
187
  // the visual glyphs below are announced meaninglessly by assistive tech.
170
- const accessibleName = parts.map(key => getKeyLabel(key, isMac)).join(' + ');
188
+ const accessibleName = parts
189
+ .map(({key}) => getKeyLabel(key, isMac))
190
+ .join(' + ');
171
191
 
172
192
  return (
173
193
  <span
@@ -181,8 +201,8 @@ export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
181
201
  className,
182
202
  style,
183
203
  )}>
184
- {parts.map(key => (
185
- <kbd key={key} aria-hidden="true" {...stylex.props(styles.kbd)}>
204
+ {parts.map(({key, reactKey}) => (
205
+ <kbd key={reactKey} aria-hidden="true" {...stylex.props(styles.kbd)}>
186
206
  {getKeyDisplay(key, isMac)}
187
207
  </kbd>
188
208
  ))}
@@ -164,6 +164,17 @@ export const docs = {
164
164
  },
165
165
  ],
166
166
  usage: {
167
+ accessibility: [
168
+ {
169
+ name: 'Link text',
170
+ category: 'Color contrast',
171
+ criterion: '1.4.3 Contrast (Minimum)',
172
+ requirement: '4.5:1',
173
+ states: ['Rest', 'Hover', 'Pointer down'],
174
+ description:
175
+ 'Link text must have at least 4.5:1 contrast with the background behind it. For Pointer down, measure against the pressed overlay the link paints behind its text.',
176
+ },
177
+ ],
167
178
  description:
168
179
  'A styled anchor for inline and standalone text navigation. Supports external links, underline variants, tooltips, and custom link components for router integration. Use it for navigating between pages or to external URLs.',
169
180
  bestPractices: [
@@ -12,6 +12,7 @@
12
12
  import {describe, it, expect, vi} from 'vitest';
13
13
  import {fireEvent, render, screen} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import {hasPressedArm} from '../__tests__/pressState';
15
16
  import {Link} from './Link';
16
17
  import {LinkProvider} from './LinkProvider';
17
18
 
@@ -417,3 +418,26 @@ describe('Link', () => {
417
418
  expect(link).toHaveAttribute('data-color', 'secondary');
418
419
  });
419
420
  });
421
+
422
+ describe('pressed state', () => {
423
+ it('paints the pressed overlay behind a link while it is pressed', () => {
424
+ render(<Link href="/docs">Docs</Link>);
425
+ expect(hasPressedArm(screen.getByRole('link', {name: 'Docs'}))).toBe(true);
426
+ });
427
+
428
+ it('paints it on the button form too', () => {
429
+ render(<Link onClick={() => {}}>Open</Link>);
430
+ expect(hasPressedArm(screen.getByRole('button', {name: 'Open'}))).toBe(
431
+ true,
432
+ );
433
+ });
434
+
435
+ it('does not press a disabled link', () => {
436
+ render(
437
+ <Link href="/docs" isDisabled>
438
+ Docs
439
+ </Link>,
440
+ );
441
+ expect(hasPressedArm(screen.getByText('Docs').closest('a')!)).toBe(false);
442
+ });
443
+ });
package/src/Link/Link.tsx CHANGED
@@ -45,6 +45,7 @@ import {computeTargetAndRel} from './computeTargetAndRel';
45
45
  import {useInteractiveRole} from '../hooks/useInteractiveRole';
46
46
  import {themeProps} from '../utils/themeProps';
47
47
  import {focusOutlineProps} from '../utils/focusOutline.stylex';
48
+ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
48
49
  import {useTranslator} from '../i18n';
49
50
 
50
51
  /**
@@ -368,6 +369,9 @@ export function Link({
368
369
  styles.base,
369
370
  styles.buttonReset,
370
371
  linkColorStyles[color],
372
+ // The system's pressed overlay behind the text; the hover stays the
373
+ // colour change above, so a press is the one background it paints.
374
+ !isDisabled && interactionOverlayStyles.pressedBackgroundColor,
371
375
  hasUnderline && styles.hasUnderline,
372
376
  isStandalone && styles.standalone,
373
377
  isDisabled && styles.disabled,
@@ -427,6 +431,7 @@ export function Link({
427
431
  focusOutlineProps.focusVisible(
428
432
  styles.base,
429
433
  linkColorStyles[color],
434
+ !isDisabled && interactionOverlayStyles.pressedBackgroundColor,
430
435
  hasUnderline && styles.hasUnderline,
431
436
  isStandalone && styles.standalone,
432
437
  isDisabled && styles.disabled,