@astryxdesign/core 0.6.2-canary.bfee8a4 → 0.6.2-canary.d4cc232

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 (103) hide show
  1. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  2. package/dist/CheckboxInput/CheckboxInput.js +12 -2
  3. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  4. package/dist/Collapsible/Collapsible.js +6 -1
  5. package/dist/Kbd/Kbd.d.ts +5 -3
  6. package/dist/Kbd/Kbd.d.ts.map +1 -1
  7. package/dist/Kbd/Kbd.js +36 -42
  8. package/dist/Link/Link.d.ts.map +1 -1
  9. package/dist/Link/Link.js +6 -2
  10. package/dist/Markdown/Markdown.d.ts.map +1 -1
  11. package/dist/Markdown/Markdown.js +63 -59
  12. package/dist/Markdown/ast.d.ts +124 -0
  13. package/dist/Markdown/ast.d.ts.map +1 -0
  14. package/dist/Markdown/ast.js +38 -0
  15. package/dist/Markdown/parser.d.ts +14 -8
  16. package/dist/Markdown/parser.d.ts.map +1 -1
  17. package/dist/Markdown/parser.js +352 -97
  18. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  19. package/dist/Outline/parseOutlineFromMarkdown.js +7 -7
  20. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  21. package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
  22. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  23. package/dist/RadioList/RadioListItem.js +13 -1
  24. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  25. package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
  26. package/dist/SideNav/SideNav.d.ts +2 -1
  27. package/dist/SideNav/SideNav.d.ts.map +1 -1
  28. package/dist/SideNav/SideNav.js +9 -3
  29. package/dist/Slider/Slider.d.ts.map +1 -1
  30. package/dist/Slider/Slider.js +14 -8
  31. package/dist/Switch/Switch.d.ts.map +1 -1
  32. package/dist/Switch/Switch.js +11 -0
  33. package/dist/TabList/Tab.d.ts +1 -1
  34. package/dist/TabList/Tab.d.ts.map +1 -1
  35. package/dist/TabList/Tab.js +20 -7
  36. package/dist/ToggleButton/ToggleButton.d.ts +2 -1
  37. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  38. package/dist/ToggleButton/ToggleButton.js +7 -1
  39. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  40. package/dist/Typeahead/BaseTypeahead.js +15 -6
  41. package/dist/astryx.css +8 -0
  42. package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
  43. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  44. package/dist/utils/interactionOverlay.stylex.js +9 -0
  45. package/package.json +3 -3
  46. package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
  47. package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
  48. package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
  49. package/src/Button/__tests__/Button.a11y.states.ts +9 -0
  50. package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
  51. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  52. package/src/CheckboxInput/CheckboxInput.tsx +21 -1
  53. package/src/ClickableCard/ClickableCard.test.tsx +102 -5
  54. package/src/Collapsible/Collapsible.doc.mjs +11 -0
  55. package/src/Collapsible/Collapsible.test.tsx +21 -0
  56. package/src/Collapsible/Collapsible.tsx +5 -0
  57. package/src/Kbd/Kbd.doc.mjs +3 -3
  58. package/src/Kbd/Kbd.test.tsx +57 -1
  59. package/src/Kbd/Kbd.tsx +57 -37
  60. package/src/Link/Link.doc.mjs +11 -0
  61. package/src/Link/Link.test.tsx +24 -0
  62. package/src/Link/Link.tsx +5 -0
  63. package/src/Markdown/Markdown.spec.md +159 -43
  64. package/src/Markdown/Markdown.test.tsx +11 -0
  65. package/src/Markdown/Markdown.tsx +106 -96
  66. package/src/Markdown/ast.test.ts +169 -0
  67. package/src/Markdown/ast.ts +231 -0
  68. package/src/Markdown/incremental.test.ts +12 -0
  69. package/src/Markdown/parser.ts +427 -118
  70. package/src/Outline/Outline.spec.md +1 -1
  71. package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
  72. package/src/Outline/parseOutlineFromMarkdown.ts +8 -13
  73. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
  74. package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
  75. package/src/RadioList/RadioList.doc.mjs +11 -0
  76. package/src/RadioList/RadioList.test.tsx +32 -0
  77. package/src/RadioList/RadioListItem.tsx +25 -1
  78. package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
  79. package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
  80. package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
  81. package/src/SideNav/SideNav.doc.mjs +1 -1
  82. package/src/SideNav/SideNav.test.tsx +10 -0
  83. package/src/SideNav/SideNav.tsx +14 -2
  84. package/src/Slider/Slider.doc.mjs +11 -0
  85. package/src/Slider/Slider.test.tsx +128 -0
  86. package/src/Slider/Slider.tsx +25 -7
  87. package/src/Switch/Switch.doc.mjs +11 -0
  88. package/src/Switch/Switch.test.tsx +16 -0
  89. package/src/Switch/Switch.tsx +28 -0
  90. package/src/TabList/Tab.tsx +35 -7
  91. package/src/TabList/TabList.doc.mjs +11 -0
  92. package/src/TabList/TabList.test.tsx +66 -0
  93. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
  94. package/src/ToggleButton/ToggleButton.test.tsx +133 -0
  95. package/src/ToggleButton/ToggleButton.tsx +9 -2
  96. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
  97. package/src/Tokenizer/Tokenizer.spec.md +142 -75
  98. package/src/Typeahead/BaseTypeahead.spec.md +4 -3
  99. package/src/Typeahead/BaseTypeahead.tsx +15 -6
  100. package/src/Typeahead/Typeahead.test.tsx +53 -0
  101. package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
  102. package/src/__tests__/pressState.ts +93 -0
  103. package/src/utils/interactionOverlay.stylex.ts +20 -0
package/dist/astryx.css CHANGED
@@ -1331,6 +1331,7 @@
1331
1331
  .x7v6acp.x7v6acp:where(.x84s7jz:focus-within *):not(#\#):not(#\#):not(#\#){visibility:visible}
1332
1332
  .x5um2zc.x5um2zc:where(.x1twag4g:first-child *):not(#\#):not(#\#):not(#\#){visibility:hidden}
1333
1333
  .x1se5z8o.x1se5z8o:where(.x1twag4g:last-child *):not(#\#):not(#\#):not(#\#){visibility:hidden}
1334
+ .x6ipy2d.x6ipy2d:where(.x1u3b27q:active *):not(#\#):not(#\#):not(#\#){background-color:var(--color-overlay-pressed)}
1334
1335
  @supports (animation-timeline: view()){.x1iu2jvn.x1iu2jvn:not(#\#):not(#\#):not(#\#){animation-name:x1tut0r7-B}}
1335
1336
  @supports (container-type: scroll-state){.xafyuhj.xafyuhj:not(#\#):not(#\#):not(#\#){container-type:scroll-state}}
1336
1337
  .xfd3fxj:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){animation-name:x1c2q33o-B}
@@ -1364,6 +1365,7 @@
1364
1365
  .xx47ajj:focus-visible:not(#\#):not(#\#):not(#\#){outline-color:var(--focus-outline-color)}
1365
1366
  .x7s97pk:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:2px}
1366
1367
  .x1nw7mhf:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:calc(-1 * var(--focus-outline-width))}
1368
+ .x1l5jwag:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:calc(0px - var(--focus-outline-width))}
1367
1369
  .x13aywxo:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--button-focus-offset)}
1368
1370
  .x1vwwbsn:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)}
1369
1371
  .xwz5zln.xwz5zln:where(.x1iwu4tg:has(:focus-visible) *):not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)}
@@ -1479,6 +1481,8 @@
1479
1481
  @media (hover: hover){.x8ydxbz.x8ydxbz.x8ydxbz:where(.x84s7jz:hover *):not(#\#):not(#\#):not(#\#){pointer-events:auto}}
1480
1482
  @media (hover: hover){.x17hxqr7.x17hxqr7.x17hxqr7:where(.x84s7jz:hover *):not(#\#):not(#\#):not(#\#){transform:translateY(0)}}
1481
1483
  @media (hover: hover){.xneequu.xneequu.xneequu:where(.x84s7jz:hover *):not(#\#):not(#\#):not(#\#){visibility:visible}}
1484
+ @media (hover: hover){.x74x3iv.x74x3iv.x74x3iv:where(.x1u3b27q:active *):not(#\#):not(#\#):not(#\#){background-color:var(--color-overlay-pressed)}}
1485
+ @media (forced-colors: none){.x1xm682w.x1xm682w.x1xm682w:where(.x1iwu4tg:active *):not(#\#):not(#\#):not(#\#){background-image:linear-gradient(var(--color-overlay-pressed),var(--color-overlay-pressed))}}
1482
1486
  @supports (animation-timeline: view()){@media (prefers-reduced-motion: reduce){.xk3h8ob.xk3h8ob.xk3h8ob:not(#\#):not(#\#):not(#\#){animation-name:xuvekqa-B}}}
1483
1487
  @media (hover: hover){.xvxo0qp.xvxo0qp.xvxo0qp:hover:where(:not(:disabled,[aria-disabled="true"])):not(#\#):not(#\#):not(#\#){background-color:color-mix(in srgb,var(--color-accent),var(--color-tint-hover) 15%)}}
1484
1488
  @media (hover: hover){.x1pfkqlk.x1pfkqlk.x1pfkqlk:hover:where(:not(:disabled,[aria-disabled="true"])):not(#\#):not(#\#):not(#\#){background-color:transparent}}
@@ -1885,6 +1889,8 @@
1885
1889
  @starting-style{.x14gflnl.x14gflnl:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transform:translateX(100%)}}
1886
1890
  .x1k48kgn:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{border-radius:inherit}
1887
1891
  .xxyica1:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{border-radius:inherit}
1892
+ .xq94oqs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{border-radius:var(--radius-full)}
1893
+ .xtb6wbx:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{border-radius:var(--radius-inner)}
1888
1894
  .x17gn2l9:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{grid-column:1}
1889
1895
  .xd54j2p:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{inset-block:0}
1890
1896
  .xpsyfx0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-date-and-time-value{margin-block:0}
@@ -1964,6 +1970,8 @@
1964
1970
  .xa5v58t:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{transition-timing-function:var(--ease-standard)}
1965
1971
  .x1rbqspl:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{user-select:none}
1966
1972
  .xy5mcqj:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{z-index:-1}
1973
+ .xzywxsk.xzywxsk:where(.x1odsvnm:active *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-pressed)}
1974
+ .x2kv6q6.x2kv6q6:where(.x1odsvnm:active:not(:has(a:active,button:active,[role="button"]:active,[role="link"]:active)) *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-pressed)}
1967
1975
  .xk7vujl:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-image:linear-gradient(to left,var(--color-shadow),transparent)}
1968
1976
  .xlptwl5:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-image:linear-gradient(to right,var(--color-shadow),transparent)}
1969
1977
  .xdq8fwl:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{transform-origin:right center}
@@ -4,6 +4,11 @@
4
4
  * @output Exports reusable background-color and background-image state styles
5
5
  * @position Internal styling utility for interactive core surfaces
6
6
  *
7
+ * Every surface that paints a press composes one of these (or carries its own
8
+ * `:active` rule), so a change to how the system answers a press is made here
9
+ * once. `pressedBackgroundColor` is the press without the hover, for controls
10
+ * whose hover is a colour or nothing.
11
+ *
7
12
  * Keep the enabled guard outside the individual states. StyleX assigns an
8
13
  * extra priority bucket (and generated selector specificity) to media-nested
9
14
  * rules. Repeating `:active` inside the hover-capable branch gives hover and
@@ -22,5 +27,8 @@ export declare const interactionOverlayStyles: Readonly<{
22
27
  readonly backgroundImageOnNeutral: Readonly<{
23
28
  readonly backgroundImage: stylex.StyleXClassNameFor<"backgroundImage", string | null>;
24
29
  }>;
30
+ readonly pressedBackgroundColor: Readonly<{
31
+ readonly backgroundColor: stylex.StyleXClassNameFor<"backgroundColor", "light-dark(#05365919, #FFFFFF19)" | null>;
32
+ }>;
25
33
  }>;
26
34
  //# sourceMappingURL=interactionOverlay.stylex.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"interactionOverlay.stylex.d.ts","sourceRoot":"","sources":["../../src/utils/interactionOverlay.stylex.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAU3C,eAAO,MAAM,wBAAwB;;;;;;;;;;EA2CnC,CAAC"}
1
+ {"version":3,"file":"interactionOverlay.stylex.d.ts","sourceRoot":"","sources":["../../src/utils/interactionOverlay.stylex.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAU3C,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;EA0DnC,CAAC"}
@@ -10,6 +10,11 @@
10
10
  * @output Exports reusable background-color and background-image state styles
11
11
  * @position Internal styling utility for interactive core surfaces
12
12
  *
13
+ * Every surface that paints a press composes one of these (or carries its own
14
+ * `:active` rule), so a change to how the system answers a press is made here
15
+ * once. `pressedBackgroundColor` is the press without the hover, for controls
16
+ * whose hover is a colour or nothing.
17
+ *
13
18
  * Keep the enabled guard outside the individual states. StyleX assigns an
14
19
  * extra priority bucket (and generated selector specificity) to media-nested
15
20
  * rules. Repeating `:active` inside the hover-capable branch gives hover and
@@ -38,5 +43,9 @@ export const interactionOverlayStyles = {
38
43
  backgroundImageOnNeutral: {
39
44
  kKwaWg: "x14bno8m xzmimnh x1otsd3y xo3fi6e",
40
45
  $$css: true
46
+ },
47
+ pressedBackgroundColor: {
48
+ kWkggS: "x1anq1lc",
49
+ $$css: true
41
50
  }
42
51
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.6.2-canary.bfee8a4",
3
+ "version": "0.6.2-canary.d4cc232",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -672,8 +672,8 @@
672
672
  "react-dom": ">=19.0.0"
673
673
  },
674
674
  "devDependencies": {
675
- "@astryxdesign/a11y-spec": "0.6.2-canary.bfee8a4",
676
- "@astryxdesign/cli": "0.6.2-canary.bfee8a4",
675
+ "@astryxdesign/a11y-spec": "0.6.2-canary.d4cc232",
676
+ "@astryxdesign/cli": "0.6.2-canary.d4cc232",
677
677
  "@babel/cli": "^7.29.7",
678
678
  "@babel/core": "^7.29.7",
679
679
  "@babel/preset-react": "^7.29.7",
@@ -21,7 +21,13 @@
21
21
  * ./Button.a11y.known-failures.ts, both shared with the jsdom lane.
22
22
  */
23
23
 
24
- import {expect, test, type CDPSession, type Page} from '@playwright/test';
24
+ import {
25
+ expect,
26
+ test,
27
+ type CDPSession,
28
+ type Locator,
29
+ type Page,
30
+ } from '@playwright/test';
25
31
  import {
26
32
  BUTTON_PATTERN,
27
33
  blockingResults,
@@ -93,6 +99,14 @@ async function activationsOn(page: Page): Promise<number> {
93
99
  return Number(raw ?? '0');
94
100
  }
95
101
 
102
+ function pointerTargetFor(
103
+ page: Page,
104
+ state: ButtonBindingRow,
105
+ ): Locator | undefined {
106
+ const selector = (state as ButtonBindingState).pointerTargetSelector;
107
+ return selector == null ? undefined : page.locator(selector).first();
108
+ }
109
+
96
110
  async function runState(
97
111
  page: Page,
98
112
  cdp: CDPSession,
@@ -111,6 +125,7 @@ async function runState(
111
125
  return createChromiumHarness({
112
126
  page,
113
127
  subject: page.locator('#storybook-root').getByRole('button').first(),
128
+ pointerTarget: pointerTargetFor(page, state),
114
129
  cdp,
115
130
  });
116
131
  },
@@ -306,6 +321,7 @@ test('every expectation is exercised by at least one bound state', async ({
306
321
  .locator('#storybook-root')
307
322
  .getByRole('button')
308
323
  .first(),
324
+ pointerTarget: pointerTargetFor(page, state),
309
325
  cdp,
310
326
  });
311
327
  },
@@ -83,33 +83,4 @@ export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
83
83
  userImpact: 'The same, from an icon button.',
84
84
  reason: 'Inherited from Button, as above.',
85
85
  },
86
-
87
- // ---- ClickableCard's role-bearing element cannot be clicked -------------
88
- {
89
- expectation: 'button.action.survives-an-aborted-press',
90
- binding: 'ClickableCard',
91
- state: 'clickable-card',
92
- evidenceLayer: 'real-browser',
93
- failureEquals:
94
- 'a pointer press cannot land on this control: something else is on top of it at its own centre, so there is no press here to abort',
95
- standardsReference: 'WCAG 2.2 2.5.2 Pointer Cancellation (Level A)',
96
- userImpact:
97
- 'The same defect seen from the other side: pointer cancellation cannot be demonstrated on a control no pointer press can land on. Recorded rather than reported green — a serene pass here would claim an outcome nobody observed.',
98
- reason:
99
- 'Same cause as the record above. It is a second record rather than a wider one because a known failure names exactly one outcome, and these two would be fixed and verified separately.',
100
- },
101
- {
102
- expectation: 'button.action.runs-on-pointer',
103
- binding: 'ClickableCard',
104
- state: 'clickable-card',
105
- evidenceLayer: 'real-browser',
106
- failureEquals:
107
- 'a pointer could not reach this control within 2000ms: the browser never found it visible, stable, and able to receive a pointer event. Something is covering it, or it is clipped to nothing.',
108
- standardsReference:
109
- 'Current Astryx buttons family FR2 and WAI-ARIA APG Button operability requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
110
- userImpact:
111
- "Anyone whose tooling acts on the accessible object rather than on pixels — a speech-input user saying the card's name, or assistive technology dispatching a click at the element it is told is the button — aims at a 1×1 control the card's own content paints over. A sighted mouse user is unaffected: they click the card surface, which handles it.",
112
- reason:
113
- 'ClickableCard deliberately splits the surface that takes the click from the hidden button that carries the role and name. Reconciling the two is a design-system decision, not a change this contract should make.',
114
- },
115
86
  ];
@@ -88,12 +88,19 @@ export const BUTTON_STATE_RENDERS: Record<ButtonStateId, StateRender> = {
88
88
  ),
89
89
 
90
90
  'clickable-card': activate => (
91
- <ClickableCard label="Open billing settings" onClick={activate}>
91
+ <ClickableCard
92
+ data-a11y-pointer-target
93
+ label="Open billing settings"
94
+ onClick={activate}>
92
95
  <div>Billing</div>
93
96
  </ClickableCard>
94
97
  ),
95
98
  'clickable-card-disabled': activate => (
96
- <ClickableCard label="Open billing settings" isDisabled onClick={activate}>
99
+ <ClickableCard
100
+ data-a11y-pointer-target
101
+ label="Open billing settings"
102
+ isDisabled
103
+ onClick={activate}>
97
104
  <div>Billing</div>
98
105
  </ClickableCard>
99
106
  ),
@@ -92,6 +92,13 @@ export interface ButtonBindingState {
92
92
  * typo here is reported as a stale inventory, not as a standards failure.
93
93
  */
94
94
  readonly visibleLabel: string | null;
95
+ /**
96
+ * The surface that receives pointer input when it is not the role-bearing
97
+ * element. ClickableCard keeps its button/link hidden for role, name, and
98
+ * keyboard focus, and takes the pointer on the card surface; a binding that
99
+ * aimed the pointer at the hidden control would be testing the wrong surface.
100
+ */
101
+ readonly pointerTargetSelector?: string;
95
102
  /** The checked-in Storybook story the Chromium lane drives. */
96
103
  readonly storyId: string;
97
104
  /**
@@ -220,6 +227,7 @@ export const BUTTON_BINDING_STATES = [
220
227
  "a card whose whole surface performs an action, through the hidden button that carries the card's role and name",
221
228
  facts: facts(),
222
229
  visibleLabel: null,
230
+ pointerTargetSelector: '[data-a11y-pointer-target]',
223
231
  storyId: 'a11y-button-pattern--clickable-card-default',
224
232
  },
225
233
  {
@@ -228,6 +236,7 @@ export const BUTTON_BINDING_STATES = [
228
236
  summary: 'a disabled action card',
229
237
  facts: facts({operable: false, focusable: false, unavailable: true}),
230
238
  visibleLabel: null,
239
+ pointerTargetSelector: '[data-a11y-pointer-target]',
231
240
  storyId: 'a11y-button-pattern--clickable-card-disabled',
232
241
  },
233
242
 
@@ -137,6 +137,17 @@ export const docs = {
137
137
  ],
138
138
  },
139
139
  usage: {
140
+ accessibility: [
141
+ {
142
+ name: 'Checkbox box',
143
+ category: 'Color contrast',
144
+ criterion: '1.4.11 Non-text Contrast',
145
+ requirement: '3:1',
146
+ states: ['Rest', 'Hover', 'Pointer down', 'Checked'],
147
+ description:
148
+ 'The box edge (unchecked) and fill (checked) 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.',
149
+ },
150
+ ],
140
151
  description: 'CheckboxInput toggles a single on/off value. Use it for settings like "Enable notifications", terms acceptance, or opt-in choices. For multiple checkboxes in a group, use CheckboxList instead.',
141
152
  bestPractices: [
142
153
  { guidance: true, description: 'Always provide a visible label so the user knows what they are toggling. Use isLabelHidden only when surrounding context makes it obvious.' },
@@ -15,6 +15,7 @@
15
15
  import {describe, it, expect, vi, beforeEach, afterEach} 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 {CheckboxInput} from './CheckboxInput';
19
20
  import {Theme} from '../theme/Theme';
20
21
  import {defineTheme} from '../theme/defineTheme';
@@ -763,3 +764,36 @@ describe('label theme target', () => {
763
764
  expect(label).toHaveClass('astryx-checkbox-label');
764
765
  });
765
766
  });
767
+
768
+ describe('pressed state', () => {
769
+ it('paints the pressed overlay over the indicator while the row is pressed', () => {
770
+ const {container} = render(
771
+ <CheckboxInput label="Accept terms" value={false} onChange={() => {}} />,
772
+ );
773
+ const box = container.querySelector('.astryx-checkbox-indicator');
774
+ const wrapper = box?.parentElement?.parentElement;
775
+ if (wrapper == null) {
776
+ throw new Error('the checkbox has no indicator wrapper to press');
777
+ }
778
+ // The owner paints over the resolved indicator, so the treatment survives a
779
+ // theme replacement that does not forward style props.
780
+ expect(hasPressedArm(wrapper)).toBe(true);
781
+ });
782
+
783
+ it('does not expose a pressed arm on a disabled checkbox', () => {
784
+ const {container} = render(
785
+ <CheckboxInput
786
+ label="Unavailable"
787
+ value={false}
788
+ onChange={() => {}}
789
+ isDisabled
790
+ />,
791
+ );
792
+ const box = container.querySelector('.astryx-checkbox-indicator');
793
+ const wrapper = box?.parentElement?.parentElement;
794
+ if (wrapper == null) {
795
+ throw new Error('the checkbox has no indicator wrapper');
796
+ }
797
+ expect(hasPressedArm(wrapper)).toBe(false);
798
+ });
799
+ });
@@ -70,6 +70,22 @@ const styles = stylex.create({
70
70
  flexShrink: 0,
71
71
  isolation: 'isolate',
72
72
  },
73
+ // The owner paints this layer over the resolved indicator, so a theme
74
+ // replacement cannot accidentally drop the component's pressed contract.
75
+ indicatorPressOverlay: {
76
+ '::after': {
77
+ content: '""',
78
+ position: 'absolute',
79
+ inset: 0,
80
+ borderRadius: radiusVars['--radius-inner'],
81
+ pointerEvents: 'none',
82
+ backgroundColor: {
83
+ default: 'transparent',
84
+ [stylex.when.ancestor(':active', indicatorScope)]:
85
+ colorVars['--color-overlay-pressed'],
86
+ },
87
+ },
88
+ },
73
89
  // Holds only the indicator, so the focus ring has one unambiguous target.
74
90
  // `display: contents` adds no box of its own — the indicator keeps whatever
75
91
  // layout relationship it already had with the wrapper.
@@ -399,7 +415,11 @@ export function CheckboxInput({
399
415
  !isDisabled && indicatorScope,
400
416
  )}>
401
417
  <div
402
- {...stylex.props(styles.checkboxWrapper, wrapperSizeStyles[size])}
418
+ {...stylex.props(
419
+ styles.checkboxWrapper,
420
+ wrapperSizeStyles[size],
421
+ !isDisabled && styles.indicatorPressOverlay,
422
+ )}
403
423
  {...focusProps}>
404
424
  <input
405
425
  {...rest}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
4
  import {render, screen, fireEvent} from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
5
6
  import {ClickableCard} from './ClickableCard';
6
7
 
7
8
  describe('ClickableCard', () => {
@@ -47,6 +48,47 @@ describe('ClickableCard', () => {
47
48
  expect(handleClick).toHaveBeenCalledTimes(1);
48
49
  });
49
50
 
51
+ it('calls onClick once when the accessible control itself is clicked', () => {
52
+ const handleClick = vi.fn();
53
+ render(
54
+ <ClickableCard label="Test card" onClick={handleClick}>
55
+ <span>Content</span>
56
+ </ClickableCard>,
57
+ );
58
+ // Pointer activation aimed at the element carrying the role — what
59
+ // speech input, assistive technology, and automation dispatch.
60
+ fireEvent.click(screen.getByRole('button', {name: 'Test card'}));
61
+ expect(handleClick).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it('passes the card surface as currentTarget for surface clicks', () => {
65
+ const seen: EventTarget[] = [];
66
+ render(
67
+ <ClickableCard
68
+ label="Test card"
69
+ onClick={e => seen.push(e.currentTarget)}>
70
+ <span>Content</span>
71
+ </ClickableCard>,
72
+ );
73
+ fireEvent.click(screen.getByText('Content'));
74
+ expect(seen).toHaveLength(1);
75
+ expect(seen[0]).toBe(screen.getByText('Content').parentElement);
76
+ });
77
+
78
+ it('calls onClick exactly once when the surface of an href card is clicked without preventDefault', () => {
79
+ const handleClick = vi.fn();
80
+ render(
81
+ <ClickableCard label="Nav card" href="/settings" onClick={handleClick}>
82
+ <span>Content</span>
83
+ </ClickableCard>,
84
+ );
85
+ // The container hook proxies the surface click to the link with
86
+ // `link.click()`; that synthetic click bubbles back through the card and
87
+ // must not run the consumer callback a second time.
88
+ fireEvent.click(screen.getByText('Content'));
89
+ expect(handleClick).toHaveBeenCalledTimes(1);
90
+ });
91
+
50
92
  it('does NOT call onClick when a nested button is clicked', () => {
51
93
  const handleCardClick = vi.fn();
52
94
  const handleButtonClick = vi.fn();
@@ -86,25 +128,80 @@ describe('ClickableCard', () => {
86
128
  expect(link).toHaveAttribute('target', '_blank');
87
129
  });
88
130
 
89
- it('disabled button is disabled', () => {
131
+ it('disabled button is disabled and the surface runs nothing', () => {
90
132
  const handleClick = vi.fn();
91
133
  render(
92
134
  <ClickableCard label="Disabled" onClick={handleClick} isDisabled>
93
- Content
135
+ <span>Content</span>
94
136
  </ClickableCard>,
95
137
  );
96
138
  const button = screen.getByRole('button', {name: 'Disabled'});
97
139
  expect(button).toBeDisabled();
140
+ fireEvent.click(screen.getByText('Content'));
141
+ fireEvent.click(button);
142
+ expect(handleClick).not.toHaveBeenCalled();
98
143
  });
99
144
 
100
- it('disabled link has aria-disabled', () => {
145
+ it('disabled link has aria-disabled, leaves the tab order, and the surface runs nothing', () => {
146
+ const handleClick = vi.fn();
101
147
  render(
102
- <ClickableCard label="Disabled link" href="/settings" isDisabled>
103
- Content
148
+ <ClickableCard
149
+ label="Disabled link"
150
+ href="/settings"
151
+ onClick={handleClick}
152
+ isDisabled>
153
+ <span>Content</span>
104
154
  </ClickableCard>,
105
155
  );
106
156
  const link = screen.getByRole('link', {name: 'Disabled link'});
107
157
  expect(link).toHaveAttribute('aria-disabled', 'true');
158
+ expect(link).toHaveAttribute('tabindex', '-1');
159
+ fireEvent.click(screen.getByText('Content'));
160
+ expect(handleClick).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('runs onClick once from the keyboard via the hidden button', async () => {
164
+ const user = userEvent.setup();
165
+ const handleClick = vi.fn();
166
+ render(
167
+ <ClickableCard label="Test card" onClick={handleClick}>
168
+ <span>Content</span>
169
+ </ClickableCard>,
170
+ );
171
+ await user.tab();
172
+ expect(screen.getByRole('button', {name: 'Test card'})).toHaveFocus();
173
+ await user.keyboard('{Enter}');
174
+ await user.keyboard(' ');
175
+ expect(handleClick).toHaveBeenCalledTimes(2);
176
+ });
177
+
178
+ it('does NOT call onClick when a nested input is clicked', () => {
179
+ const handleCardClick = vi.fn();
180
+ render(
181
+ <ClickableCard label="Test card" onClick={handleCardClick}>
182
+ <input aria-label="Quantity" readOnly value="1" />
183
+ </ClickableCard>,
184
+ );
185
+ fireEvent.click(screen.getByRole('textbox', {name: 'Quantity'}));
186
+ expect(handleCardClick).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it('does NOT call onClick when text inside the card is selected', () => {
190
+ const handleClick = vi.fn();
191
+ render(
192
+ <ClickableCard label="Test card" onClick={handleClick}>
193
+ <span>Selectable body text</span>
194
+ </ClickableCard>,
195
+ );
196
+ const text = screen.getByText('Selectable body text');
197
+ const range = document.createRange();
198
+ range.selectNodeContents(text);
199
+ const selection = document.getSelection()!;
200
+ selection.removeAllRanges();
201
+ selection.addRange(range);
202
+ fireEvent.click(text);
203
+ expect(handleClick).not.toHaveBeenCalled();
204
+ selection.removeAllRanges();
108
205
  });
109
206
 
110
207
  describe('elevation', () => {
@@ -82,6 +82,17 @@ export const docs = {
82
82
  {name: 'CollapsibleGroup'},
83
83
  ],
84
84
  usage: {
85
+ accessibility: [
86
+ {
87
+ name: 'Trigger label',
88
+ category: 'Color contrast',
89
+ criterion: '1.4.3 Contrast (Minimum)',
90
+ requirement: '4.5:1',
91
+ states: ['Rest', 'Pointer down'],
92
+ description:
93
+ 'The trigger text must have at least 4.5:1 contrast with the surface behind it. For Pointer down, measure against the pressed overlay the trigger row paints while it is pressed.',
94
+ },
95
+ ],
85
96
  description: 'Collapsible hides and reveals content behind a trigger button. Use it in settings panels, FAQ pages, or detail views to keep the page scannable while letting users drill into sections they care about. Wrap multiple collapsibles in CollapsibleGroup for accordion behavior. For custom collapsible components, use the `useCollapsible` hook directly (`astryx hook useCollapsible`).',
86
97
  bestPractices: [
87
98
  { guidance: true, description: 'Use hasDividers on CollapsibleGroup for FAQ-style lists: built-in row hairlines with themed border tokens, no hand-rolled borders.' },
@@ -12,6 +12,7 @@
12
12
  import {describe, it, expect, vi} from 'vitest';
13
13
  import {render, screen, within} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import {hasPressedArm} from '../__tests__/pressState';
15
16
  import {Collapsible} from './Collapsible';
16
17
  import {CollapsibleGroup} from './CollapsibleGroup';
17
18
 
@@ -615,3 +616,23 @@ describe('Collapsible', () => {
615
616
  });
616
617
  });
617
618
  });
619
+
620
+ describe('pressed state', () => {
621
+ it('paints the pressed overlay on the trigger row while it is pressed', () => {
622
+ render(
623
+ <Collapsible trigger="Details">
624
+ <p>Body</p>
625
+ </Collapsible>,
626
+ );
627
+ expect(hasPressedArm(screen.getByRole('button'))).toBe(true);
628
+ });
629
+
630
+ it('does not press a disabled trigger', () => {
631
+ render(
632
+ <Collapsible trigger="Details" isDisabled>
633
+ <p>Body</p>
634
+ </Collapsible>,
635
+ );
636
+ expect(hasPressedArm(screen.getByRole('button'))).toBe(false);
637
+ });
638
+ });
@@ -48,6 +48,7 @@ import {mergeProps} from '../utils';
48
48
  import type {BaseProps} from '../BaseProps';
49
49
  import {themeProps} from '../utils/themeProps';
50
50
  import {focusOutlineProps} from '../utils/focusOutline.stylex';
51
+ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
51
52
 
52
53
  const styles = stylex.create({
53
54
  root: {
@@ -411,6 +412,10 @@ export function Collapsible({
411
412
  focusOutlineProps.focusVisible(
412
413
  styles.trigger,
413
414
  density != null && triggerDensity[density],
415
+ // The system's pressed overlay on the disclosure row. The trigger
416
+ // has no hover surface of its own, so this is the one background
417
+ // it paints, and only while it is pressed.
418
+ !isDisabled && interactionOverlayStyles.pressedBackgroundColor,
414
419
  isDisabled && styles.triggerDisabled,
415
420
  ),
416
421
  )}>
@@ -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.',