@astryxdesign/core 0.2.0-canary.f306f13 → 0.2.0-canary.fc53ff7

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 (162) hide show
  1. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  2. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.js +6 -2
  4. package/dist/Button/Button.d.ts +1 -1
  5. package/dist/Button/Button.d.ts.map +1 -1
  6. package/dist/Button/Button.js +4 -2
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +2 -2
  9. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  10. package/dist/CheckboxList/CheckboxListItem.js +19 -4
  11. package/dist/CodeBlock/CodeBlock.js +2 -2
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +10 -1
  14. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +8 -11
  15. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +3 -1
  17. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  18. package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
  19. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
  21. package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
  22. package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
  23. package/dist/DropdownMenu/menuItemHover.js +1 -0
  24. package/dist/Field/FieldLabel.d.ts +2 -1
  25. package/dist/Field/FieldLabel.d.ts.map +1 -1
  26. package/dist/Field/FieldLabel.js +7 -3
  27. package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
  28. package/dist/FieldStatus/FieldStatus.js +12 -2
  29. package/dist/Item/Item.d.ts +16 -3
  30. package/dist/Item/Item.d.ts.map +1 -1
  31. package/dist/Item/Item.js +31 -6
  32. package/dist/List/ListItem.d.ts +11 -1
  33. package/dist/List/ListItem.d.ts.map +1 -1
  34. package/dist/List/ListItem.js +2 -0
  35. package/dist/MetadataList/MetadataList.d.ts +1 -1
  36. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  37. package/dist/MetadataList/MetadataList.js +4 -2
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.js +14 -3
  40. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  41. package/dist/RadioList/RadioListItem.js +1 -1
  42. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControlItem.js +3 -2
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +5 -0
  46. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  47. package/dist/Skeleton/Skeleton.js +2 -2
  48. package/dist/Switch/Switch.d.ts.map +1 -1
  49. package/dist/Switch/Switch.js +15 -4
  50. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  51. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
  52. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  53. package/dist/Thumbnail/Thumbnail.js +42 -43
  54. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  55. package/dist/Toast/ToastViewport.js +61 -9
  56. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  57. package/dist/ToggleButton/ToggleButton.js +3 -1
  58. package/dist/Token/Token.d.ts +4 -1
  59. package/dist/Token/Token.d.ts.map +1 -1
  60. package/dist/Token/Token.js +68 -43
  61. package/dist/Token/TokenLink.d.ts +62 -0
  62. package/dist/Token/TokenLink.d.ts.map +1 -0
  63. package/dist/Token/TokenLink.js +76 -0
  64. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavMegaMenu.js +9 -3
  66. package/dist/Typeahead/BaseTypeahead.d.ts +9 -0
  67. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  68. package/dist/Typeahead/BaseTypeahead.js +2 -0
  69. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/Typeahead.js +8 -1
  71. package/dist/astryx.css +51 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
  75. package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
  76. package/dist/hooks/containerReveal.pool.stylex.js +281 -0
  77. package/dist/hooks/index.d.ts +2 -0
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +2 -1
  80. package/dist/hooks/useContainerReveal.d.ts +83 -0
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -0
  82. package/dist/hooks/useContainerReveal.js +123 -0
  83. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  84. package/dist/hooks/useInputStatusIcon.js +11 -0
  85. package/dist/hooks/useKeyboardHint.d.ts +1 -1
  86. package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
  87. package/dist/hooks/useKeyboardHint.js +4 -2
  88. package/locales/en.json +32 -0
  89. package/locales/pseudo.json +24 -0
  90. package/package.json +1 -1
  91. package/src/AvatarGroup/AvatarGroup.test.tsx +21 -1
  92. package/src/AvatarGroup/AvatarGroupOverflow.tsx +4 -2
  93. package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
  94. package/src/Button/Button.test.tsx +16 -0
  95. package/src/Button/Button.tsx +4 -2
  96. package/src/CheckboxInput/CheckboxInput.test.tsx +23 -0
  97. package/src/CheckboxInput/CheckboxInput.tsx +17 -2
  98. package/src/CheckboxList/CheckboxList.test.tsx +94 -3
  99. package/src/CheckboxList/CheckboxListItem.tsx +17 -10
  100. package/src/CodeBlock/CodeBlock.test.tsx +17 -0
  101. package/src/CodeBlock/CodeBlock.tsx +2 -2
  102. package/src/ContextMenu/ContextMenu.test.tsx +17 -2
  103. package/src/ContextMenu/ContextMenu.tsx +10 -1
  104. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +4 -12
  105. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +9 -10
  106. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  107. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +20 -2
  108. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
  109. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
  110. package/src/DropdownMenu/menuItemHover.ts +1 -0
  111. package/src/Field/Field.doc.mjs +4 -0
  112. package/src/Field/FieldLabel.test.tsx +25 -0
  113. package/src/Field/FieldLabel.tsx +11 -2
  114. package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
  115. package/src/FieldStatus/FieldStatus.test.tsx +61 -0
  116. package/src/FieldStatus/FieldStatus.tsx +14 -3
  117. package/src/Item/Item.doc.mjs +5 -0
  118. package/src/Item/Item.test.tsx +88 -0
  119. package/src/Item/Item.tsx +57 -11
  120. package/src/List/ListItem.doc.mjs +12 -0
  121. package/src/List/ListItem.tsx +13 -0
  122. package/src/MetadataList/MetadataList.test.tsx +24 -0
  123. package/src/MetadataList/MetadataList.tsx +6 -2
  124. package/src/MultiSelector/MultiSelector.test.tsx +76 -0
  125. package/src/MultiSelector/MultiSelector.tsx +28 -7
  126. package/src/RadioList/RadioList.test.tsx +26 -4
  127. package/src/RadioList/RadioListItem.tsx +8 -1
  128. package/src/SegmentedControl/SegmentedControl.test.tsx +28 -0
  129. package/src/SegmentedControl/SegmentedControlItem.tsx +20 -2
  130. package/src/Selector/Selector.test.tsx +22 -0
  131. package/src/Selector/Selector.tsx +8 -1
  132. package/src/Skeleton/Skeleton.test.tsx +16 -0
  133. package/src/Skeleton/Skeleton.tsx +13 -1
  134. package/src/Switch/Switch.test.tsx +37 -0
  135. package/src/Switch/Switch.tsx +52 -3
  136. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +19 -0
  137. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +3 -1
  138. package/src/Thumbnail/Thumbnail.tsx +18 -32
  139. package/src/Toast/ToastViewport.test.tsx +218 -8
  140. package/src/Toast/ToastViewport.tsx +71 -12
  141. package/src/ToggleButton/ToggleButton.test.tsx +24 -0
  142. package/src/ToggleButton/ToggleButton.tsx +19 -1
  143. package/src/Token/Token.test.tsx +145 -0
  144. package/src/Token/Token.tsx +73 -33
  145. package/src/Token/TokenLink.tsx +107 -0
  146. package/src/TopNav/TopNavMegaMenu.tsx +26 -2
  147. package/src/Typeahead/BaseTypeahead.tsx +12 -0
  148. package/src/Typeahead/Typeahead.test.tsx +62 -0
  149. package/src/Typeahead/Typeahead.tsx +6 -0
  150. package/src/__tests__/forcedColors.ts +60 -0
  151. package/src/hooks/containerReveal.pool.stylex.ts +464 -0
  152. package/src/hooks/index.ts +7 -0
  153. package/src/hooks/useContainerReveal.test.tsx +116 -0
  154. package/src/hooks/useContainerReveal.ts +180 -0
  155. package/src/hooks/useInputStatusIcon.test.tsx +59 -0
  156. package/src/hooks/useInputStatusIcon.tsx +12 -1
  157. package/src/hooks/useKeyboardHint.test.tsx +22 -0
  158. package/src/hooks/useKeyboardHint.tsx +6 -2
  159. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
  160. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
  161. package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
  162. package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
@@ -14,6 +14,7 @@ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {RadioList} from './RadioList';
16
16
  import {RadioListItem} from './RadioListItem';
17
+ import {getForcedColorsRules} from '../__tests__/forcedColors';
17
18
 
18
19
  // Mock showPopover/hidePopover (not implemented in jsdom) so the tooltip layer
19
20
  // reflects its open state via a `popover-open` attribute the tests can assert.
@@ -580,7 +581,11 @@ describe('RadioList', () => {
580
581
  it('submits the selected value under htmlName', () => {
581
582
  const {container} = render(
582
583
  <form>
583
- <RadioList label="Preference" htmlName="pref" value="b" onChange={() => {}}>
584
+ <RadioList
585
+ label="Preference"
586
+ htmlName="pref"
587
+ value="b"
588
+ onChange={() => {}}>
584
589
  <RadioListItem label="Option A" value="a" />
585
590
  <RadioListItem label="Option B" value="b" />
586
591
  </RadioList>
@@ -599,13 +604,14 @@ describe('RadioList', () => {
599
604
  value="a"
600
605
  onChange={() => {}}
601
606
  isDisabled
602
- disabledMessage="Locked"
603
- >
607
+ disabledMessage="Locked">
604
608
  <RadioListItem label="Option A" value="a" />
605
609
  </RadioList>
606
610
  </form>,
607
611
  );
608
- expect([...new FormData(container.querySelector('form')!).keys()]).toEqual([]);
612
+ expect([
613
+ ...new FormData(container.querySelector('form')!).keys(),
614
+ ]).toEqual([]);
609
615
  });
610
616
 
611
617
  it('keeps working as an isolated group when htmlName is omitted', () => {
@@ -642,3 +648,19 @@ describe('RadioList', () => {
642
648
  });
643
649
  });
644
650
  });
651
+
652
+ // jsdom cannot emulate forced-colors rendering, so this asserts that the
653
+ // compiled output includes the forced-colors rule; visual behavior needs
654
+ // manual verification under Windows High Contrast.
655
+ describe('forced colors (WCAG 1.4.11)', () => {
656
+ it('compiles a forced-colors fill so the selected dot survives Windows High Contrast', () => {
657
+ render(
658
+ <RadioList label="Preference" value="a" onChange={() => {}}>
659
+ <RadioListItem label="Option A" value="a" />
660
+ </RadioList>,
661
+ );
662
+ // The painted inner dot would be stripped to Canvas (invisible), making
663
+ // checked and unchecked radios identical; CanvasText keeps it perceivable.
664
+ expect(getForcedColorsRules()).toContain('background-color: canvastext;');
665
+ });
666
+ });
@@ -119,7 +119,14 @@ const styles = stylex.create({
119
119
  },
120
120
  innerDot: {
121
121
  borderRadius: '50%',
122
- backgroundColor: colorVars['--color-on-accent'],
122
+ backgroundColor: {
123
+ default: colorVars['--color-on-accent'],
124
+ // Forced colors (Windows High Contrast) strips painted backgrounds,
125
+ // which would make the selected dot invisible — checked and unchecked
126
+ // radios would look identical. CanvasText keeps the dot perceivable on
127
+ // the Canvas circle fill (WCAG 1.4.11).
128
+ '@media (forced-colors: active)': 'CanvasText',
129
+ },
123
130
  },
124
131
  labelDisabled: {
125
132
  color: colorVars['--color-text-disabled'],
@@ -14,6 +14,10 @@ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {SegmentedControl} from './SegmentedControl';
16
16
  import {SegmentedControlItem} from './SegmentedControlItem';
17
+ import {
18
+ getAllInjectedCss,
19
+ getForcedColorsRules,
20
+ } from '../__tests__/forcedColors';
17
21
 
18
22
  // Mock showPopover/hidePopover (not implemented in jsdom) so the tooltip layer
19
23
  // reflects its open state via a `popover-open` attribute the tests can assert.
@@ -780,3 +784,27 @@ describe('SegmentedControl container handler forwarding', () => {
780
784
  expect(onChange).not.toHaveBeenCalled();
781
785
  });
782
786
  });
787
+
788
+ // jsdom cannot emulate forced-colors rendering, so these assert that the
789
+ // compiled output includes the forced-colors rules; visual behavior needs
790
+ // manual verification under Windows High Contrast.
791
+ describe('forced colors (WCAG 1.4.11)', () => {
792
+ it('compiles forced-colors overrides so the selected segment survives Windows High Contrast', () => {
793
+ render(
794
+ <SegmentedControl value="grid" onChange={() => {}} label="View mode">
795
+ <SegmentedControlItem value="grid" label="Grid" />
796
+ <SegmentedControlItem value="list" label="List" />
797
+ </SegmentedControl>,
798
+ );
799
+ const css = getForcedColorsRules();
800
+ // The painted surface fill and shadow are stripped; Highlight/
801
+ // HighlightText marks the selected segment.
802
+ expect(css).toContain('background-color: highlight;');
803
+ expect(css).toContain('color: highlighttext;');
804
+ // The segment is a <button>; without opting out of UA remapping it keeps
805
+ // the native ButtonFace surface and ignores the Highlight fill, leaving
806
+ // HighlightText text on a white surface. forced-color-adjust: none makes
807
+ // both render as authored.
808
+ expect(getAllInjectedCss()).toContain('forced-color-adjust: none;');
809
+ });
810
+ });
@@ -103,9 +103,27 @@ const styles = stylex.create({
103
103
  },
104
104
  },
105
105
  selected: {
106
- color: colorVars['--color-text-primary'],
106
+ // Forced colors (Windows High Contrast) strips the painted surface fill
107
+ // and box shadow, which would leave the selected segment with no state
108
+ // indication beyond font weight. Highlight/HighlightText is the platform
109
+ // convention for a selected item (WCAG 1.4.11).
110
+ //
111
+ // forced-color-adjust must be `none` here: the segment is a <button>, and
112
+ // the UA keeps native form-control colors (ButtonFace surface) for it under
113
+ // forced colors, ignoring the authored Highlight fill — the label kept its
114
+ // HighlightText color, giving white text on a white surface. Opting the
115
+ // selected segment out of UA remapping makes both the Highlight surface and
116
+ // the HighlightText label render as authored, restoring figure-ground.
117
+ forcedColorAdjust: 'none',
118
+ color: {
119
+ default: colorVars['--color-text-primary'],
120
+ '@media (forced-colors: active)': 'HighlightText',
121
+ },
107
122
  fontWeight: fontWeightVars['--font-weight-semibold'],
108
- backgroundColor: colorVars['--color-background-surface'],
123
+ backgroundColor: {
124
+ default: colorVars['--color-background-surface'],
125
+ '@media (forced-colors: active)': 'Highlight',
126
+ },
109
127
  boxShadow: shadowVars['--shadow-low'],
110
128
  },
111
129
  disabled: {
@@ -613,6 +613,28 @@ describe('Selector', () => {
613
613
  expect(within(listbox).getByText('No results found')).toBeInTheDocument();
614
614
  });
615
615
 
616
+ it('empty-state message is not exposed as a listbox child', async () => {
617
+ const user = userEvent.setup();
618
+ render(
619
+ <Selector
620
+ label="Fruit"
621
+ options={OPTIONS}
622
+ value="Apple"
623
+ onChange={() => {}}
624
+ hasSearch
625
+ />,
626
+ );
627
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
628
+ await user.type(screen.getByRole('combobox', h), 'xyz');
629
+
630
+ // role="listbox" only permits option/group children — the visual
631
+ // empty-state message must be presentational (it is announced through
632
+ // the result-count live region instead).
633
+ const listbox = screen.getByRole('listbox', h);
634
+ const empty = within(listbox).getByText('No results found');
635
+ expect(empty).toHaveAttribute('role', 'presentation');
636
+ });
637
+
616
638
  it('calls onChange when selecting a filtered option', async () => {
617
639
  const user = userEvent.setup();
618
640
  const onChange = vi.fn();
@@ -963,8 +963,15 @@ export function Selector<T extends SelectorOptionType>(
963
963
 
964
964
  // Nothing matched across every group/option: show the empty state.
965
965
  if (isSearching && filteredItems.length === 0) {
966
+ // role="presentation" keeps the message out of the listbox's
967
+ // accessibility tree (role="listbox" only permits option/group
968
+ // children); the no-results outcome is announced via the
969
+ // result-count live region instead.
966
970
  return [
967
- <div key="empty" {...stylex.props(styles.emptyState)}>
971
+ <div
972
+ key="empty"
973
+ role="presentation"
974
+ {...stylex.props(styles.emptyState)}>
968
975
  No results found
969
976
  </div>,
970
977
  ];
@@ -3,6 +3,7 @@
3
3
  import {describe, it, expect} from 'vitest';
4
4
  import {render, screen} from '@testing-library/react';
5
5
  import {Skeleton} from './Skeleton';
6
+ import {getForcedColorsRules} from '../__tests__/forcedColors';
6
7
 
7
8
  describe('Skeleton', () => {
8
9
  it('renders a placeholder element', () => {
@@ -23,3 +24,18 @@ describe('Skeleton', () => {
23
24
  expect(screen.getByTestId('sk')).toHaveAttribute('aria-hidden', 'false');
24
25
  });
25
26
  });
27
+
28
+ // jsdom cannot emulate forced-colors rendering, so these assert that the
29
+ // compiled output includes the forced-colors rules; visual behavior needs
30
+ // manual verification under Windows High Contrast.
31
+ describe('forced colors (WCAG 1.4.11)', () => {
32
+ it('compiles forced-colors overrides so the placeholder stays visible under Windows High Contrast', () => {
33
+ render(<Skeleton width={200} height={20} data-testid="sk" />);
34
+ const css = getForcedColorsRules();
35
+ // The painted fill is stripped to Canvas (invisible); GrayText is a
36
+ // system color, so it survives forcing.
37
+ expect(css).toContain('background-color: graytext;');
38
+ // The resting 0.25 opacity is lifted so the static placeholder reads.
39
+ expect(css).toContain('opacity: 1;');
40
+ });
41
+ });
@@ -55,8 +55,20 @@ const styles = stylex.create({
55
55
  backgroundColor: {
56
56
  default: colorVars['--color-skeleton'],
57
57
  '@media (prefers-contrast: more)': `color-mix(in srgb, ${colorVars['--color-skeleton']}, ${colorVars['--color-text-primary']} 30%)`,
58
+ // Forced colors (Windows High Contrast) strips painted backgrounds,
59
+ // which would make the placeholder invisible. GrayText is a system
60
+ // color, so it survives forcing and keeps the placeholder visible
61
+ // (WCAG 1.4.11). Listed after prefers-contrast so it wins when both
62
+ // media features are active.
63
+ '@media (forced-colors: active)': 'GrayText',
64
+ },
65
+ opacity: {
66
+ default: 0.25,
67
+ // The resting 0.25 opacity would render GrayText nearly invisible on
68
+ // Canvas; full opacity keeps the static placeholder perceivable (the
69
+ // fade animation still pulses when motion is allowed).
70
+ '@media (forced-colors: active)': 1,
58
71
  },
59
- opacity: 0.25,
60
72
  },
61
73
  animate: {
62
74
  animationDirection: 'alternate',
@@ -13,6 +13,10 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {Switch} from './Switch';
16
+ import {
17
+ getAllInjectedCss,
18
+ getForcedColorsRules,
19
+ } from '../__tests__/forcedColors';
16
20
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17
21
 
18
22
  afterEach(() => {
@@ -729,3 +733,36 @@ describe('Switch', () => {
729
733
  });
730
734
  });
731
735
  });
736
+
737
+ // jsdom cannot emulate forced-colors rendering, so these assert that the
738
+ // compiled output includes the forced-colors rules; visual behavior needs
739
+ // manual verification under Windows High Contrast.
740
+ describe('forced colors (WCAG 1.4.11)', () => {
741
+ it('compiles forced-colors overrides so on/off state survives Windows High Contrast', () => {
742
+ render(<Switch label="Notifications" value={true} onChange={() => {}} />);
743
+ const css = getForcedColorsRules();
744
+ // Track outline (backgrounds are stripped; the border keeps the bounds).
745
+ expect(css).toContain('border-color: canvastext;');
746
+ // Off track stays empty; on track uses the selection color.
747
+ expect(css).toContain('background-color: canvas;');
748
+ expect(css).toContain('background-color: highlight;');
749
+ // Thumb fill per state.
750
+ expect(css).toContain('background-color: canvastext;');
751
+ expect(css).toContain('background-color: highlighttext;');
752
+ // Disabled affordance (opacity dimming does not survive forcing).
753
+ expect(css).toContain('border-color: graytext;');
754
+ });
755
+
756
+ it('gates the hover tint out of forced colors so the thumb stays visible on hover', () => {
757
+ render(<Switch label="Notifications" value={true} onChange={() => {}} />);
758
+ // The ancestor-hover tint is a non-system color-mix whose rule outranks the
759
+ // plain forced-colors track rule. It is gated behind `forced-colors: none`
760
+ // so it cannot reassert on hover and flatten the Highlight track to white
761
+ // under the HighlightText thumb (white-on-white).
762
+ expect(getAllInjectedCss()).toContain(
763
+ '(hover: hover) and (forced-colors: none)',
764
+ );
765
+ // And the tint never leaks into the forced-colors output.
766
+ expect(getForcedColorsRules()).not.toContain('color-mix');
767
+ });
768
+ });
@@ -173,6 +173,21 @@ const styles = stylex.create({
173
173
  },
174
174
  transitionTimingFunction: easeVars['--ease-standard'],
175
175
  boxSizing: 'border-box',
176
+ // Forced colors (Windows High Contrast) strips painted backgrounds, which
177
+ // would leave the track invisible. A system-color border keeps the
178
+ // control's bounds perceivable (WCAG 1.4.11).
179
+ borderWidth: {
180
+ default: 0,
181
+ '@media (forced-colors: active)': '1px',
182
+ },
183
+ borderStyle: {
184
+ default: 'none',
185
+ '@media (forced-colors: active)': 'solid',
186
+ },
187
+ borderColor: {
188
+ default: null,
189
+ '@media (forced-colors: active)': 'CanvasText',
190
+ },
176
191
  },
177
192
  trackFocus: {
178
193
  outline: {
@@ -189,21 +204,39 @@ const styles = stylex.create({
189
204
  trackOff: {
190
205
  backgroundColor: {
191
206
  default: colorVars['--color-background-gray'],
207
+ // Off = empty (Canvas) track; on = Highlight track, so the two states
208
+ // stay distinguishable under forced colors.
209
+ '@media (forced-colors: active)': 'Canvas',
210
+ // The ancestor-hover tint is a non-system color-mix, and its rule
211
+ // outranks the plain forced-colors rule above. Left ungated it would
212
+ // reassert on hover under forced colors, where the UA flattens the
213
+ // color-mix back to Canvas — so the HighlightText thumb would sit on a
214
+ // white track (white-on-white). Gating on `forced-colors: none` keeps
215
+ // the tint out of forced colors and lets the system-color track stand.
192
216
  [stylex.when.ancestor(':hover', switchScope)]: {
193
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-background-gray']}, ${colorVars['--color-tint-hover']} 5%)`,
217
+ '@media (hover: hover) and (forced-colors: none)': `color-mix(in srgb, ${colorVars['--color-background-gray']}, ${colorVars['--color-tint-hover']} 5%)`,
194
218
  },
195
219
  },
196
220
  },
197
221
  trackOn: {
198
222
  backgroundColor: {
199
223
  default: colorVars['--color-accent'],
224
+ '@media (forced-colors: active)': 'Highlight',
225
+ // See trackOff: gate the hover tint out of forced colors so it cannot
226
+ // flatten the Highlight track to white under the HighlightText thumb.
200
227
  [stylex.when.ancestor(':hover', switchScope)]: {
201
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
228
+ '@media (hover: hover) and (forced-colors: none)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
202
229
  },
203
230
  },
204
231
  },
205
232
  trackDisabled: {
206
233
  opacity: 0.5,
234
+ // Opacity dimming does not survive forced colors; GrayText is the
235
+ // platform's disabled affordance there.
236
+ borderColor: {
237
+ default: null,
238
+ '@media (forced-colors: active)': 'GrayText',
239
+ },
207
240
  },
208
241
  trackDisabledOff: {
209
242
  backgroundColor: colorVars['--color-background-gray'],
@@ -213,7 +246,6 @@ const styles = stylex.create({
213
246
  alignItems: 'center',
214
247
  justifyContent: 'center',
215
248
  borderRadius: radiusVars['--radius-full'],
216
- backgroundColor: colorVars['--color-background-surface'],
217
249
  transitionProperty: 'transform, width, height',
218
250
  transitionDuration: {
219
251
  default: durationVars['--duration-fast'],
@@ -221,6 +253,22 @@ const styles = stylex.create({
221
253
  },
222
254
  transitionTimingFunction: easeVars['--ease-standard'],
223
255
  },
256
+ // The thumb fill lives on the on/off styles (not the shared thumb style)
257
+ // because forced colors needs a per-state system color: CanvasText on the
258
+ // empty off track, HighlightText on the Highlight on track. Sizing stays in
259
+ // thumbOffSizeStyles/thumbOnSizeStyles; only the fill is state-dependent.
260
+ thumbOff: {
261
+ backgroundColor: {
262
+ default: colorVars['--color-background-surface'],
263
+ '@media (forced-colors: active)': 'CanvasText',
264
+ },
265
+ },
266
+ thumbOn: {
267
+ backgroundColor: {
268
+ default: colorVars['--color-background-surface'],
269
+ '@media (forced-colors: active)': 'HighlightText',
270
+ },
271
+ },
224
272
  labelWrapper: {
225
273
  display: 'flex',
226
274
  flexDirection: 'column',
@@ -537,6 +585,7 @@ export function Switch({
537
585
  stylex.props(
538
586
  styles.thumb,
539
587
  isOn ? thumbOnSizeStyles[size] : thumbOffSizeStyles[size],
588
+ isOn ? styles.thumbOn : styles.thumbOff,
540
589
  ),
541
590
  )}>
542
591
  {isBusy && <Spinner size="sm" />}
@@ -9,6 +9,7 @@ import {
9
9
  useTableRowExpansion,
10
10
  useTableRowExpansionState,
11
11
  } from './useTableRowExpansion';
12
+ import {InternationalizationProvider} from '../../../i18n';
12
13
 
13
14
  // popover mock for context-menu tests
14
15
  beforeEach(() => {
@@ -118,6 +119,24 @@ describe('useTableRowExpansion', () => {
118
119
  expect(items.length).toBeGreaterThan(0);
119
120
  });
120
121
 
122
+ it('localizes the context-menu action label through the i18n catalog', () => {
123
+ render(
124
+ <InternationalizationProvider
125
+ locale="fr"
126
+ overrides={{
127
+ fr: {'@astryx.tableRowExpansion.expandRow': 'Développer la ligne'},
128
+ }}>
129
+ <Harness />
130
+ </InternationalizationProvider>,
131
+ );
132
+ fireEvent.contextMenu(screen.getByText('Folder A'));
133
+ const items = screen.getAllByRole('menuitem', {
134
+ name: /Développer la ligne/,
135
+ hidden: true,
136
+ });
137
+ expect(items.length).toBeGreaterThan(0);
138
+ });
139
+
121
140
  it('does not show chevron for leaf nodes', () => {
122
141
  render(<Harness />);
123
142
  // Leaf C has no children
@@ -576,7 +576,9 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
576
576
  {
577
577
  id: 'row-expansion-toggle',
578
578
  group: 'row-expansion',
579
- label: isExpanded ? 'Collapse row' : 'Expand row',
579
+ label: isExpanded
580
+ ? t('@astryx.tableRowExpansion.collapseRow')
581
+ : t('@astryx.tableRowExpansion.expandRow'),
580
582
  icon: (
581
583
  <span {...stylex.props(rtlStyles.mirror)}>
582
584
  <Icon
@@ -39,11 +39,11 @@ import {Skeleton} from '../Skeleton';
39
39
  import {Spinner} from '../Spinner';
40
40
  import {Tooltip} from '../Tooltip/Tooltip';
41
41
  import {useDevWarning} from '../hooks/useDevWarning';
42
+ import {useContainerReveal} from '../hooks/useContainerReveal';
42
43
  import type {BaseProps} from '../BaseProps';
43
44
  import {mergeProps} from '../utils';
44
45
  import {themeProps} from '../utils/themeProps';
45
46
  import {useTranslator} from '../i18n';
46
- import {thumbnailScope} from './thumbnail.markers.stylex';
47
47
 
48
48
  export interface ThumbnailProps extends BaseProps<HTMLDivElement> {
49
49
  /** Ref forwarded to the root element */
@@ -236,28 +236,6 @@ const styles = stylex.create({
236
236
  zIndex: 1,
237
237
  lineHeight: 0,
238
238
  },
239
- // showRemoveOn="hover": the remove button is hidden at rest and revealed
240
- // when the thumbnail is hovered or when focus enters it (keyboard). Only
241
- // opacity is animated — the button stays mounted and focusable so tabbing
242
- // to it triggers the :focus-within reveal. Any touch-capable device (incl.
243
- // hybrid laptops that also report hover) keeps the button visible so the
244
- // remove path is never gated behind an unavailable hover.
245
- removeOnHover: {
246
- opacity: {
247
- default: 0,
248
- [stylex.when.ancestor(':hover', thumbnailScope)]: {
249
- '@media (hover: hover)': 1,
250
- },
251
- [stylex.when.ancestor(':focus-within', thumbnailScope)]: 1,
252
- '@media (any-pointer: coarse)': 1,
253
- },
254
- transitionProperty: 'opacity',
255
- transitionDuration: {
256
- default: durationVars['--duration-fast'],
257
- '@media (prefers-reduced-motion: reduce)': '0s',
258
- },
259
- transitionTimingFunction: easeVars['--ease-standard'],
260
- },
261
239
  });
262
240
 
263
241
  // =============================================================================
@@ -326,6 +304,9 @@ export function Thumbnail({
326
304
  const isInteractive = onClick != null && !isDisabled && !isLoading;
327
305
  const hasRemove = onRemove != null && !isDisabled;
328
306
  const isHoverReveal = hasRemove && showRemoveOn === 'hover';
307
+ const {getContainerProps, getContentRevealProps} = useContainerReveal({
308
+ isEnabled: isHoverReveal,
309
+ });
329
310
  const accessibleName =
330
311
  label && alt
331
312
  ? `${label} — ${alt}`
@@ -377,9 +358,12 @@ export function Thumbnail({
377
358
 
378
359
  const removeButtonEl = hasRemove ? (
379
360
  <div
380
- {...stylex.props(
381
- styles.removeSlot,
382
- isHoverReveal && styles.removeOnHover,
361
+ {...mergeProps(
362
+ stylex.props(styles.removeSlot),
363
+ // The remove button is absolutely positioned in the corner, so it is
364
+ // already out of flow — an opacity-only reveal (layout preserved)
365
+ // matches its overlay placement instead of the clip recipe.
366
+ isHoverReveal ? getContentRevealProps({isLayoutPreserved: true}) : {},
383
367
  )}>
384
368
  <Button
385
369
  icon={<Icon icon="close" size="xsm" />}
@@ -410,12 +394,14 @@ export function Thumbnail({
410
394
  )}
411
395
  {...props}>
412
396
  <div
413
- {...stylex.props(
414
- styles.imageContainer,
415
- isHoverReveal && thumbnailScope,
416
- isInteractive && styles.interactive,
417
- isInteractive && styles.overlay,
418
- isInteractive && styles.hoverOnPointer,
397
+ {...mergeProps(
398
+ stylex.props(
399
+ styles.imageContainer,
400
+ isInteractive && styles.interactive,
401
+ isInteractive && styles.overlay,
402
+ isInteractive && styles.hoverOnPointer,
403
+ ),
404
+ isHoverReveal ? getContainerProps() : {},
419
405
  )}>
420
406
  {isInteractive ? (
421
407
  <button