@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
@@ -16,9 +16,15 @@ export {
16
16
 
17
17
  export type {
18
18
  InlineNode,
19
+ InlineNodeWithMath,
20
+ MathInlineNode,
19
21
  BlockNode,
22
+ BlockNodeWithMath,
23
+ MathBlockNode,
20
24
  ListItemNode,
21
25
  TableCellNode,
22
26
  TableAlignment,
27
+ ParseOptions,
28
+ MathParseOptions,
23
29
  IncrementalState,
24
30
  } from './parser';
@@ -12,7 +12,7 @@ owners: [cixzhang]
12
12
  review_triggers: [theming]
13
13
  verified_by:
14
14
  [packages/core/src/Outline/Outline.test.tsx, scripts/check-knowledge.mjs]
15
- modules: []
15
+ modules: [module:Outline/parseOutlineFromMarkdown]
16
16
  families: []
17
17
  design_specs: []
18
18
  architecture: [architecture:component-theming-surface]
@@ -0,0 +1,142 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 1
4
+ kind: module
5
+ id: module:Outline/parseOutlineFromMarkdown
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-09-15
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior]
13
+ verified_by: [packages/core/src/Outline/parseOutlineFromMarkdown.test.ts]
14
+ parent_component: component:Outline
15
+ references:
16
+ [architecture:public-component-api, component:Markdown, spec:AST-036]
17
+ ---
18
+
19
+ # parseOutlineFromMarkdown module contract
20
+
21
+ ## Intent
22
+
23
+ `parseOutlineFromMarkdown` and `useOutlineFromMarkdown` derive same-document
24
+ heading navigation from the same Markdown parse configuration, ordered immutable
25
+ transforms, text projection, and collision allocator used by the rendered document.
26
+ This focused module owns that utility behavior without deciding the draft Outline
27
+ component's anatomy or theming.
28
+
29
+ ## Compatibility and migration
30
+
31
+ - Released default preserved: `yes`
32
+ - Compatibility class: additive parser options; calls without plugins preserve
33
+ released labels, levels, IDs, and return types
34
+ - Migration decision: `spec:AST-036`
35
+
36
+ ## Ownership boundary
37
+
38
+ **Owns**
39
+
40
+ - Deriving ordered `OutlineItem` values from Markdown headings.
41
+ - Accepting the same parser-affecting options and ordered plugin list as Markdown.
42
+ - Applying the same validated immutable transforms before selecting headings.
43
+ - Using the same extension text projection and collision-safe slug allocation as
44
+ Markdown heading rendering.
45
+
46
+ **Does not own / non-goals**
47
+
48
+ - Outline component structure, active state, targets, layout, or theming.
49
+ - Plugin rendering, block-extension navigation entries, or renderer-derived text.
50
+ - A second parser, plugin registry, or independent heading-ID policy.
51
+
52
+ ## Public API and concepts
53
+
54
+ `parseOutlineFromMarkdown(source, options?)` performs direct derivation.
55
+ `useOutlineFromMarkdown(source, options?)` memoizes the same derivation for React
56
+ callers. `options.plugins` uses the canonical opaque Markdown plugin entries. The
57
+ same ordered transforms run before heading selection; inline extension-node `toText`
58
+ projections then contribute to heading labels and IDs.
59
+
60
+ ## Behavioral contract
61
+
62
+ | ID | Invariant | Basis | Acceptance and implementation state |
63
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- |
64
+ | FR1 | Both utilities accept the same Markdown parser options and ordered plugin list as the rendered Markdown document. | `spec:AST-036` FR13, FR16 | pending implementation |
65
+ | FR2 | Both utilities apply the same validated immutable transforms before selecting headings, without allowing renderer output to affect identity. | `spec:AST-036` FR13, FR16 | pending implementation |
66
+ | FR3 | Plugin-enabled labels and IDs use the same extension `toText` projection, slugger, and cross-base collision allocator as Markdown, producing unique matching targets. | `spec:AST-036` FR16 | pending implementation |
67
+ | FR4 | An extension node never creates an Outline entry of its own, and extension containers do not change heading traversal: entries come from the same blocks Markdown assigns heading IDs to, so a heading nested in a container is omitted exactly as one nested in a blockquote or list item is. A container never restarts, extends, or re-scopes heading identity or slug allocation. | `spec:AST-036` FR16, FR25 | pending implementation |
68
+ | FR5 | Omitting plugins and passing an empty list preserve released heading selection, labels, levels, IDs, return type, and memoization behavior. | `spec:AST-036` FR2 | pending implementation |
69
+ | FR6 | The hook memoizes against source and every parse-affecting or transform-affecting option so changed configuration cannot return stale headings. | `component:Markdown` FR15–FR16 | pending implementation |
70
+ | FR7 | A transform that removes a heading removes its Outline entry, and a transform that inserts a synthetic heading adds one, in both surfaces identically. Source-backed heading depth cannot change, so an entry's level always matches the rendered heading. | `spec:AST-036` FR26 | pending implementation |
71
+ | FR8 | When a plugin fails, derivation uses the same last valid root the rendered document uses, so labels, levels, and IDs still agree. Failures are reported through the shared diagnostic channel; this module adds no failure mode, fallback, or throw of its own. | `spec:AST-036` FR12, FR29 | pending implementation |
72
+ | FR9 | Derivation runs on the canonical tree through the shared parse path and requires no DOM, renderer, client boundary, or second parser. Because transforms are idempotent under streaming, an outline derived from a growing source prefix converges rather than oscillates. | `spec:AST-036` FR32, FR33, FR36 | pending implementation |
73
+
74
+ ### Transformation and precedence order
75
+
76
+ Markdown parse configuration → ordered immutable transforms → heading nodes →
77
+ shared perceivable-text projection → shared slug normalization → document-order
78
+ collision allocation → Outline items.
79
+
80
+ ### Performance and resources
81
+
82
+ - The hook recomputes only when source or parser/transform configuration changes.
83
+ - Renderer output does not enter heading identity.
84
+ - No DOM, renderer mount, registry, Remark runtime, or optional plugin resource is
85
+ required.
86
+
87
+ ## Accessibility contract
88
+
89
+ Each derived label is the same perceivable heading text used for Markdown identity,
90
+ and each derived ID targets the matching rendered heading. This module adds no
91
+ interaction or ARIA behavior; the Outline component owns navigation presentation.
92
+
93
+ ## Design relationships
94
+
95
+ No visual representation is owned by this parser utility.
96
+
97
+ ## Parent and system relationships
98
+
99
+ - `component:Markdown` owns aggregate parsing, heading rendering, and the shared
100
+ projection/collision rules.
101
+ - `spec:AST-036` owns the canonical plugin protocol and cross-surface parity.
102
+ - Draft `component:Outline` owns presentation only after its separate anatomy and
103
+ theming decisions become current.
104
+
105
+ ## Verification map
106
+
107
+ | Contract | Verification | Representative states | Mutation or failure expectation |
108
+ | -------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
109
+ | FR1–FR6 | Outline parser tests, plugin transform parity tests, and Core typecheck | transformed, formatted, and extension headings; headings nested in extension containers, blockquotes, and list items; duplicate and cross-base slugs; omitted/empty plugins | A transform is skipped, a label/ID diverges from Markdown, a container changes which headings have identity, or default output changes. |
110
+ | FR7–FR9 | Heading insert/remove parity, failure parity, and streaming derivation | headings removed with/without sections; inserted synthetic headings; failing transform; growing source prefix; server derivation | Outline/document disagreement, divergent fallback, a throw, stale IDs, or streamed oscillation. |
111
+
112
+ ## Decision log
113
+
114
+ ### DEC-1 — Markdown owns heading identity; Outline projects it
115
+
116
+ **Reference:** `module:Outline/parseOutlineFromMarkdown/DEC-1`
117
+ **Decider:** cixzhang, 2026-09-15
118
+
119
+ Outline utilities consume Markdown's parse configuration, ordered transforms, text
120
+ projection, and collision allocator rather than defining a second heading-identity
121
+ system. This keeps labels and link targets aligned without coupling plugin renderer
122
+ output to navigation identity.
123
+
124
+ ### DEC-2 — One traversal survives containers and heading edits
125
+
126
+ **Reference:** `module:Outline/parseOutlineFromMarkdown/DEC-2`
127
+ **Decider:** cixzhang, 2026-09-16
128
+
129
+ Extension containers hold ordinary Markdown but do not change which headings have identity. Markdown assigns IDs by one traversal and this module reads the same one, so Outline never links to a heading Markdown did not identify. A heading inside a container remains omitted like one inside a blockquote or list item, and the extension node is never an Outline entry. Widening traversal is a separate decision.
130
+
131
+ Transforms may remove and insert headings, so Outline follows the rendered document rather than source: a dropped heading disappears, an inserted heading appears, and immutable source-backed depth keeps levels aligned. Both surfaces run the same pipeline rather than independent rules.
132
+
133
+ Rejected: a container-only traversal exception; extension nodes as Outline entries; source-only derivation; module-local plugin failure fallback.
134
+
135
+ ## Open questions
136
+
137
+ None.
138
+
139
+ ## Content boundary
140
+
141
+ This file does not duplicate plugin protocol details, Markdown parser mechanics,
142
+ consumer examples, or the draft Outline component's anatomy and theming contract.
@@ -10,7 +10,14 @@ import {
10
10
  afterAll,
11
11
  afterEach,
12
12
  } from 'vitest';
13
- import {render, screen, fireEvent, act} from '@testing-library/react';
13
+ import {
14
+ render,
15
+ screen,
16
+ fireEvent,
17
+ act,
18
+ waitFor,
19
+ within,
20
+ } from '@testing-library/react';
14
21
  import React, {useState} from 'react';
15
22
  import * as stylex from '@stylexjs/stylex';
16
23
  import {spacingVars} from '../theme/tokens.stylex';
@@ -170,6 +177,148 @@ describe('PowerSearch', () => {
170
177
  expect(popoverText).toContain('equals');
171
178
  });
172
179
 
180
+ describe('value editor identity follows the field and operator', () => {
181
+ // Two fields with the same value type, so an unkeyed editor would be
182
+ // reused across the switch and keep the first field's menu contents.
183
+ const sameTypeConfig: PowerSearchConfig = {
184
+ name: 'test-same-type',
185
+ fields: [
186
+ {
187
+ key: 'creator',
188
+ label: 'Creator',
189
+ operators: [
190
+ {
191
+ key: 'is_any_of',
192
+ label: 'is any of',
193
+ value: {
194
+ type: 'enum_list',
195
+ values: [{value: 'creator-1', label: 'Creator Person'}],
196
+ },
197
+ },
198
+ ],
199
+ },
200
+ {
201
+ key: 'owner',
202
+ label: 'Owner',
203
+ operators: [
204
+ {
205
+ key: 'is_any_of',
206
+ label: 'is any of',
207
+ value: {
208
+ type: 'enum_list',
209
+ values: [{value: 'owner-1', label: 'Owner Person'}],
210
+ },
211
+ },
212
+ ],
213
+ },
214
+ ],
215
+ };
216
+
217
+ const creatorOption = () =>
218
+ screen.queryByRole('option', {name: 'Creator Person', hidden: true});
219
+ const ownerOption = () =>
220
+ screen.queryByRole('option', {name: 'Owner Person', hidden: true});
221
+
222
+ it('replaces an open value menu when the field changes', async () => {
223
+ function Harness() {
224
+ const internalConfig = useInternalConfig(sameTypeConfig);
225
+ return (
226
+ <PowerSearchEditPopover
227
+ config={internalConfig}
228
+ filter={{
229
+ field: 'creator',
230
+ operator: 'is_any_of',
231
+ value: {type: 'enum_list', value: []},
232
+ }}
233
+ mode="edit"
234
+ onSave={() => {}}
235
+ onCancel={() => {}}
236
+ />
237
+ );
238
+ }
239
+
240
+ render(<Harness />);
241
+
242
+ // Open the Creator value menu and leave it open.
243
+ fireEvent.focus(screen.getByRole('combobox', {name: 'Values'}));
244
+ await waitFor(() => expect(creatorOption()).toBeInTheDocument());
245
+
246
+ // Switch the field underneath the open menu.
247
+ fireEvent.click(screen.getByRole('combobox', {name: 'Field'}));
248
+ fireEvent.click(
249
+ screen.getByRole('option', {name: 'Owner', hidden: true}),
250
+ );
251
+ act(() => flushRAF());
252
+
253
+ // The old field's options are gone, the new field's are shown.
254
+ expect(creatorOption()).not.toBeInTheDocument();
255
+ await waitFor(() => expect(ownerOption()).toBeInTheDocument());
256
+ });
257
+
258
+ it('replaces an open value menu on a nested sub-filter row', async () => {
259
+ const nestedConfig: PowerSearchConfig = {
260
+ name: 'test-nested-same-type',
261
+ fields: [
262
+ {
263
+ key: 'group',
264
+ label: 'Group',
265
+ operators: [
266
+ {key: 'all_of', label: 'all of', value: {type: 'nested'}},
267
+ ],
268
+ },
269
+ ...sameTypeConfig.fields,
270
+ ],
271
+ };
272
+
273
+ function Harness() {
274
+ const internalConfig = useInternalConfig(nestedConfig);
275
+ return (
276
+ <PowerSearchEditPopover
277
+ config={internalConfig}
278
+ filter={{
279
+ field: 'group',
280
+ operator: 'all_of',
281
+ value: {
282
+ type: 'nested',
283
+ value: [
284
+ {
285
+ field: 'creator',
286
+ operator: 'is_any_of',
287
+ value: {type: 'enum_list', value: []},
288
+ },
289
+ ],
290
+ },
291
+ }}
292
+ mode="edit"
293
+ onSave={() => {}}
294
+ onCancel={() => {}}
295
+ />
296
+ );
297
+ }
298
+
299
+ render(<Harness />);
300
+
301
+ fireEvent.focus(screen.getByRole('combobox', {name: 'Values'}));
302
+ await waitFor(() => expect(creatorOption()).toBeInTheDocument());
303
+
304
+ // The nested row's own field selector is the one currently on
305
+ // "Creator"; the root selector stays on "Group".
306
+ const rowField = screen
307
+ .getAllByRole('combobox', {name: 'Field'})
308
+ .find(el => el.textContent === 'Creator');
309
+ expect(rowField).toBeDefined();
310
+ fireEvent.click(rowField!);
311
+ const rowListbox = document.getElementById(
312
+ rowField!.getAttribute('aria-controls')!,
313
+ )!;
314
+ fireEvent.click(
315
+ within(rowListbox).getByRole('option', {name: 'Owner', hidden: true}),
316
+ );
317
+
318
+ expect(creatorOption()).not.toBeInTheDocument();
319
+ });
320
+ });
321
+
173
322
  it('edit popover shows correct filter after removing a preceding filter', () => {
174
323
  const filters: PowerSearchFilter[] = [
175
324
  {field: 'status', operator: 'is', value: {type: 'string', value: 'open'}},
@@ -22,7 +22,10 @@ import {TreeList, type TreeListItemData} from '../TreeList';
22
22
  import {useTranslator} from '../i18n';
23
23
  import {isImeKeyEvent} from '../utils/ime';
24
24
  import {spacingVars, typeScaleVars} from '../theme/tokens.stylex';
25
- import {PowerSearchValueEditor} from './PowerSearchValueEditor';
25
+ import {
26
+ PowerSearchValueEditor,
27
+ type PowerSearchValueEditorProps,
28
+ } from './PowerSearchValueEditor';
26
29
  import {resolveOperatorLabel} from './resolveOperatorLabel';
27
30
  import type {InternalConfig} from './useInternalConfig';
28
31
  import type {
@@ -356,6 +359,7 @@ function NestedSubFilterRow({
356
359
  {operatorValue && !isEmptyType && !isNestedType && (
357
360
  <div {...stylex.props(styles.nestedRowValueEditor)}>
358
361
  <PowerSearchValueEditor
362
+ key={valueEditorKey(subFilter)}
359
363
  operatorValue={operatorValue}
360
364
  filterValue={subFilter.value}
361
365
  onChange={handleValueChange}
@@ -606,6 +610,42 @@ function NestedEditor({
606
610
  return <TreeList items={items} density="balanced" />;
607
611
  }
608
612
 
613
+ // =============================================================================
614
+ // Value editor cell
615
+ // =============================================================================
616
+
617
+ // A field switch must replace the editor, not update it in place: a reused
618
+ // editor keeps its open menu and old results.
619
+ function valueEditorKey(filter: {field: string; operator?: string}): string {
620
+ return `${filter.field}\u0000${filter.operator ?? ''}`;
621
+ }
622
+
623
+ // Keyed on field + operator, so this also mounts on a field switch, not just
624
+ // when the popover opens; the focus handoff below runs for both.
625
+ function ValueEditorCell(props: PowerSearchValueEditorProps) {
626
+ const ref = useRef<HTMLDivElement>(null);
627
+
628
+ useEffect(() => {
629
+ const frame = requestAnimationFrame(() => {
630
+ const container = ref.current;
631
+ if (!container) {
632
+ return;
633
+ }
634
+ const focusable = container.querySelector<HTMLElement>(
635
+ 'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
636
+ );
637
+ focusable?.focus();
638
+ });
639
+ return () => cancelAnimationFrame(frame);
640
+ }, []);
641
+
642
+ return (
643
+ <div ref={ref} {...stylex.props(styles.valueEditor)}>
644
+ <PowerSearchValueEditor {...props} />
645
+ </div>
646
+ );
647
+ }
648
+
609
649
  // =============================================================================
610
650
  // Main popover
611
651
  // =============================================================================
@@ -625,22 +665,6 @@ export function PowerSearchEditPopover({
625
665
  saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
626
666
  const [partialFilter, setPartialFilter] =
627
667
  useState<PartialFilter>(initialFilter);
628
- const valueEditorRef = useRef<HTMLDivElement>(null);
629
-
630
- // Focus the first focusable element inside the value editor after mount
631
- useEffect(() => {
632
- const frame = requestAnimationFrame(() => {
633
- const container = valueEditorRef.current;
634
- if (!container) {
635
- return;
636
- }
637
- const focusable = container.querySelector<HTMLElement>(
638
- 'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
639
- );
640
- focusable?.focus();
641
- });
642
- return () => cancelAnimationFrame(frame);
643
- }, []);
644
668
 
645
669
  const currentOperator = partialFilter.operator
646
670
  ? config.getOperator(partialFilter.field, partialFilter.operator)
@@ -854,17 +878,16 @@ export function PowerSearchEditPopover({
854
878
  </div>
855
879
  )}
856
880
  {operatorValue && !isEmptyType && (
857
- <div ref={valueEditorRef} {...stylex.props(styles.valueEditor)}>
858
- <PowerSearchValueEditor
859
- operatorValue={operatorValue}
860
- filterValue={partialFilter.value}
861
- onChange={handleValueChange}
862
- onEnter={handleSave}
863
- config={config}
864
- maxMenuItems={maxMenuItems}
865
- isDisabled={isReadOnly}
866
- />
867
- </div>
881
+ <ValueEditorCell
882
+ key={valueEditorKey(partialFilter)}
883
+ operatorValue={operatorValue}
884
+ filterValue={partialFilter.value}
885
+ onChange={handleValueChange}
886
+ onEnter={handleSave}
887
+ config={config}
888
+ maxMenuItems={maxMenuItems}
889
+ isDisabled={isReadOnly}
890
+ />
868
891
  )}
869
892
  </HStack>
870
893
  </div>
@@ -132,6 +132,17 @@ export const docs = {
132
132
  {name: 'RadioListItem'},
133
133
  ],
134
134
  usage: {
135
+ accessibility: [
136
+ {
137
+ name: 'Radio circle',
138
+ category: 'Color contrast',
139
+ criterion: '1.4.11 Non-text Contrast',
140
+ requirement: '3:1',
141
+ states: ['Rest', 'Hover', 'Pointer down', 'Selected'],
142
+ description:
143
+ 'The circle edge (unselected) and fill (selected) must have at least 3:1 contrast with the surface behind them. For Hover and Pointer down, measure the final colors after the tint and the pressed overlay are applied.',
144
+ },
145
+ ],
135
146
  description:
136
147
  'A group of options where only one can be selected at a time. All options are visible at once, making it easy to compare choices. Use it when users need to pick one option from a small set.',
137
148
  bestPractices: [
@@ -15,6 +15,7 @@
15
15
  import {describe, it, expect, vi, beforeEach} from 'vitest';
16
16
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
17
17
  import userEvent from '@testing-library/user-event';
18
+ import {hasPressedArm} from '../__tests__/pressState';
18
19
  import {RadioList} from './RadioList';
19
20
  import {RadioListItem} from './RadioListItem';
20
21
  import {getForcedColorsRules} from '../__tests__/forcedColors';
@@ -856,3 +857,34 @@ describe('forced colors (WCAG 1.4.11)', () => {
856
857
  expect(getForcedColorsRules()).toContain('background-color: canvastext;');
857
858
  });
858
859
  });
860
+
861
+ describe('pressed state', () => {
862
+ it('paints the pressed overlay over the radio indicator while the row is pressed', () => {
863
+ const {container} = render(
864
+ <RadioList label="Plan" value="a" onChange={() => {}}>
865
+ <RadioListItem label="Option A" value="a" />
866
+ <RadioListItem label="Option B" value="b" />
867
+ </RadioList>,
868
+ );
869
+ const circle = container.querySelector('.astryx-radio-indicator');
870
+ const wrapper = circle?.parentElement?.parentElement;
871
+ if (wrapper == null) {
872
+ throw new Error('the radio has no indicator wrapper to press');
873
+ }
874
+ expect(hasPressedArm(wrapper)).toBe(true);
875
+ });
876
+
877
+ it('does not expose a pressed arm on a disabled radio', () => {
878
+ const {container} = render(
879
+ <RadioList label="Plan" value="a" onChange={() => {}}>
880
+ <RadioListItem label="Unavailable" value="b" isDisabled />
881
+ </RadioList>,
882
+ );
883
+ const circle = container.querySelector('.astryx-radio-indicator');
884
+ const wrapper = circle?.parentElement?.parentElement;
885
+ if (wrapper == null) {
886
+ throw new Error('the radio has no indicator wrapper');
887
+ }
888
+ expect(hasPressedArm(wrapper)).toBe(false);
889
+ });
890
+ });
@@ -23,6 +23,7 @@ import * as stylex from '@stylexjs/stylex';
23
23
  import type {StyleXStyles} from '@stylexjs/stylex';
24
24
  import type {BaseProps} from '../BaseProps';
25
25
  import {RadioListContext} from './RadioList';
26
+ import {colorVars, radiusVars} from '../theme/tokens.stylex';
26
27
  import {mergeProps, isRenderable, rtlStyles} from '../utils';
27
28
  import {indicatorScope} from '../Indicator/indicator.markers.stylex';
28
29
  import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing';
@@ -39,6 +40,25 @@ const styles = stylex.create({
39
40
  flexShrink: 0,
40
41
  isolation: 'isolate',
41
42
  },
43
+ // The row remains the large press target, but nested links and buttons keep
44
+ // their independent action and do not make the radio appear pressed. This
45
+ // owner-drawn layer also survives a theme replacing the indicator component.
46
+ indicatorPressOverlay: {
47
+ '::after': {
48
+ content: '""',
49
+ position: 'absolute',
50
+ inset: 0,
51
+ borderRadius: radiusVars['--radius-full'],
52
+ pointerEvents: 'none',
53
+ backgroundColor: {
54
+ default: 'transparent',
55
+ [stylex.when.ancestor(
56
+ ':active:not(:has(a:active,button:active,[role="button"]:active,[role="link"]:active))',
57
+ indicatorScope,
58
+ )]: colorVars['--color-overlay-pressed'],
59
+ },
60
+ },
61
+ },
42
62
  input: {
43
63
  position: 'absolute',
44
64
  top: '50%',
@@ -222,7 +242,11 @@ export function RadioListItem({
222
242
 
223
243
  const radioCircle = (
224
244
  <div
225
- {...stylex.props(styles.radioWrapper, wrapperSizeStyles[size])}
245
+ {...stylex.props(
246
+ styles.radioWrapper,
247
+ wrapperSizeStyles[size],
248
+ !isDisabled && styles.indicatorPressOverlay,
249
+ )}
226
250
  {...focusProps}>
227
251
  <input
228
252
  ref={radioRef}