@astryxdesign/core 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +2 -2
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
@@ -25,9 +25,11 @@ import {Icon} from '../Icon';
25
25
  import {RadioIndicator} from '../Indicator';
26
26
  import {InputGroup, InputGroupText} from '../InputGroup';
27
27
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
28
+ import {__resetInteractionModalityForTest} from '../utils/interactionModality';
28
29
  import {defineTheme} from '../theme/defineTheme';
29
30
  import {Theme} from '../theme/Theme';
30
31
  import {generateThemeCSS} from '../theme/generateThemeRules';
32
+ import {spacingVars} from '../theme/tokens.stylex';
31
33
 
32
34
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
33
35
  const {prose, component} = generateThemeCSS(theme);
@@ -508,6 +510,89 @@ describe('Selector', () => {
508
510
  }
509
511
  });
510
512
 
513
+ describe('menu clearance', () => {
514
+ it('clears the trigger by the standard menu offset when placement is explicit', async () => {
515
+ const user = userEvent.setup();
516
+ render(
517
+ <Selector
518
+ label="Fruit"
519
+ options={OPTIONS}
520
+ value="Banana"
521
+ onChange={() => {}}
522
+ placement="above"
523
+ />,
524
+ );
525
+
526
+ await user.click(screen.getByRole('combobox'));
527
+ const popover = screen
528
+ .getByRole('listbox', {hidden: true})
529
+ .closest('[popover]') as HTMLElement;
530
+ // Both block edges, so the gap survives a position-try-fallbacks flip
531
+ // to the opposite side (#4803).
532
+ await waitFor(() => {
533
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe(
534
+ spacingVars['--spacing-1'],
535
+ );
536
+ });
537
+ expect(popover.style.getPropertyValue('--x-marginBlockEnd')).toBe(
538
+ spacingVars['--spacing-1'],
539
+ );
540
+ });
541
+
542
+ it('clears the trigger in search mode', async () => {
543
+ const user = userEvent.setup();
544
+ render(
545
+ <Selector
546
+ label="Fruit"
547
+ options={OPTIONS}
548
+ value="Banana"
549
+ onChange={() => {}}
550
+ hasSearch
551
+ />,
552
+ );
553
+
554
+ // In hasSearch mode the trigger is a plain button, not a combobox.
555
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
556
+ const popover = screen
557
+ .getByRole('listbox', {hidden: true})
558
+ .closest('[popover]') as HTMLElement;
559
+ await waitFor(() => {
560
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe(
561
+ spacingVars['--spacing-1'],
562
+ );
563
+ });
564
+ });
565
+
566
+ it('stays flush in the default selected-item overlay', async () => {
567
+ const restoreRects = mockSelectorRects();
568
+ const user = userEvent.setup();
569
+ try {
570
+ render(
571
+ <Selector
572
+ label="Fruit"
573
+ options={OPTIONS}
574
+ value="Banana"
575
+ onChange={() => {}}
576
+ />,
577
+ );
578
+
579
+ await user.click(screen.getByRole('combobox'));
580
+ const popover = screen
581
+ .getByRole('listbox', {hidden: true})
582
+ .closest('[popover]') as HTMLElement;
583
+ await waitFor(() => {
584
+ expect(popover.getAttribute('style')).toContain(
585
+ 'margin-block-start: -110px',
586
+ );
587
+ });
588
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe('');
589
+ expect(popover.style.getPropertyValue('--x-marginBlockEnd')).toBe('');
590
+ } finally {
591
+ restoreRects();
592
+ }
593
+ });
594
+ });
595
+
511
596
  describe('hasClear', () => {
512
597
  it('shows selected value label when hasClear is enabled', () => {
513
598
  render(
@@ -2302,7 +2387,165 @@ describe('Selector indicator (chevron) icon theme target', () => {
2302
2387
  });
2303
2388
  });
2304
2389
 
2390
+ describe('Selector section headings', () => {
2391
+ it('renders a section title as a plain heading inside the group, not a divider', async () => {
2392
+ const user = userEvent.setup();
2393
+ render(
2394
+ <Selector
2395
+ label="Fruit"
2396
+ options={[
2397
+ {
2398
+ type: 'section',
2399
+ title: 'Citrus',
2400
+ options: [
2401
+ {value: 'orange', label: 'Orange'},
2402
+ {value: 'lemon', label: 'Lemon'},
2403
+ ],
2404
+ },
2405
+ ]}
2406
+ value={undefined}
2407
+ onChange={() => {}}
2408
+ />,
2409
+ );
2410
+ await user.click(screen.getByRole('combobox'));
2411
+
2412
+ // A labeled Divider used to stand in for the heading; it rendered a
2413
+ // role="separator" as a direct child of the listbox and stacked a second
2414
+ // rule under the search row's own.
2415
+ expect(document.querySelectorAll('[role="separator"]')).toHaveLength(0);
2416
+
2417
+ const group = screen.getByRole('group', {name: 'Citrus', hidden: true});
2418
+ const heading = group.querySelector('.astryx-selector-section-heading');
2419
+ expect(heading).toBeTruthy();
2420
+ expect(heading).toHaveTextContent('Citrus');
2421
+ // The group already carries the title as its accessible name, so the
2422
+ // visible heading must not announce it a second time.
2423
+ expect(heading).toHaveAttribute('aria-hidden', 'true');
2424
+ });
2425
+ });
2426
+
2427
+ describe('Selector search focus ring', () => {
2428
+ // The ring is for keyboard focus only. `:focus-visible` cannot express that
2429
+ // on its own: per CSS Selectors 4 a pointer-focused text input matches it
2430
+ // too (verified in Chromium), which is why a modality gate sits alongside
2431
+ // it. jsdom does not implement `:focus-visible`, so these assert the gate;
2432
+ // the painted ring is verified in real Chromium.
2433
+ //
2434
+ // Focus moves into the search input on the frame after the panel opens, and
2435
+ // the gate is read at that moment: every case must wait for the focus to
2436
+ // land before asserting or typing, or a slow frame reads the modality of
2437
+ // whatever the test did next.
2438
+ beforeEach(() => {
2439
+ __resetInteractionModalityForTest();
2440
+ });
2441
+
2442
+ const field = () =>
2443
+ screen.getByRole('combobox', {hidden: true}).parentElement;
2444
+
2445
+ const waitForSearchFocus = async () =>
2446
+ waitFor(() =>
2447
+ expect(screen.getByRole('combobox', {hidden: true})).toHaveFocus(),
2448
+ );
2449
+
2450
+ it('does not ring when the panel is opened by mouse', async () => {
2451
+ const user = userEvent.setup();
2452
+ render(
2453
+ <Selector
2454
+ label="Fruit"
2455
+ options={OPTIONS}
2456
+ value={undefined}
2457
+ onChange={() => {}}
2458
+ hasSearch
2459
+ />,
2460
+ );
2461
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
2462
+ await waitForSearchFocus();
2463
+ expect(field()).not.toHaveAttribute('data-keyboard-focus');
2464
+ });
2465
+
2466
+ it('does not ring when the query is typed after a mouse open', async () => {
2467
+ const user = userEvent.setup();
2468
+ render(
2469
+ <Selector
2470
+ label="Fruit"
2471
+ options={OPTIONS}
2472
+ value={undefined}
2473
+ onChange={() => {}}
2474
+ hasSearch
2475
+ />,
2476
+ );
2477
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
2478
+ await waitForSearchFocus();
2479
+ await user.keyboard('an');
2480
+ // Typing does not retroactively make a pointer focus a keyboard one; the
2481
+ // caret already shows where the text is going.
2482
+ expect(field()).not.toHaveAttribute('data-keyboard-focus');
2483
+ });
2484
+
2485
+ it('rings when the panel is opened from the keyboard', async () => {
2486
+ const user = userEvent.setup();
2487
+ render(
2488
+ <Selector
2489
+ label="Fruit"
2490
+ options={OPTIONS}
2491
+ value={undefined}
2492
+ onChange={() => {}}
2493
+ hasSearch
2494
+ />,
2495
+ );
2496
+ await user.tab();
2497
+ await user.keyboard('{Enter}');
2498
+ await waitForSearchFocus();
2499
+ expect(field()).toHaveAttribute('data-keyboard-focus', 'true');
2500
+ });
2501
+ });
2502
+
2305
2503
  describe('Selector search affordances', () => {
2504
+ it('renders the search row seamlessly — no nested input box, a divider under it', async () => {
2505
+ const user = userEvent.setup();
2506
+ const {container} = render(
2507
+ <Selector
2508
+ label="Fruit"
2509
+ options={OPTIONS}
2510
+ value="Apple"
2511
+ onChange={() => {}}
2512
+ hasSearch
2513
+ />,
2514
+ );
2515
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
2516
+
2517
+ const search = screen.getByRole('combobox', {hidden: true});
2518
+ // The row is the outer gutter; the input sits inside the rounded field.
2519
+ const row = search.closest('.astryx-selector-search');
2520
+ const field = search.parentElement;
2521
+ if (!row || !field) {
2522
+ throw new Error('search row not found');
2523
+ }
2524
+ // The panel is already a bordered surface: the field inside it must not be
2525
+ // a second bordered box (this used to render a TextInput).
2526
+ expect(row).not.toHaveClass('astryx-text-input');
2527
+ expect(search.closest('.astryx-text-input')).toBeNull();
2528
+ // The field is a rounded box inset from the panel edge, shaped like the
2529
+ // option rows under it — not a full-bleed header strip.
2530
+ expect(field).not.toBe(row);
2531
+ expect(getComputedStyle(field).borderRadius).not.toBe('');
2532
+ // ...and a divider separates it from the options.
2533
+ const separator =
2534
+ container.ownerDocument.querySelector('[role="separator"]');
2535
+ if (!separator) {
2536
+ throw new Error('divider not found');
2537
+ }
2538
+ // Order: row, then divider, then the listbox.
2539
+ expect(
2540
+ row.compareDocumentPosition(separator) & Node.DOCUMENT_POSITION_FOLLOWING,
2541
+ ).toBeTruthy();
2542
+ const listbox = screen.getByRole('listbox', {hidden: true});
2543
+ expect(
2544
+ separator.compareDocumentPosition(listbox) &
2545
+ Node.DOCUMENT_POSITION_FOLLOWING,
2546
+ ).toBeTruthy();
2547
+ });
2548
+
2306
2549
  it('renders a decorative (aria-hidden) magnifier icon whenever hasSearch is on', async () => {
2307
2550
  const user = userEvent.setup();
2308
2551
  render(
@@ -2316,8 +2559,7 @@ describe('Selector search affordances', () => {
2316
2559
  );
2317
2560
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
2318
2561
  const search = screen.getByRole('combobox', {hidden: true});
2319
- // The search field is a TextInput; the magnifier is its startIcon, so it
2320
- // sits inside the input container as a sibling of the <input>.
2562
+ // The magnifier leads the search row, as a sibling of the <input>.
2321
2563
  const container = search.parentElement;
2322
2564
  const magnifier = container?.querySelector('.astryx-icon');
2323
2565
  expect(magnifier).toBeTruthy();
@@ -2550,3 +2792,145 @@ describe('Selector disabled state theme target', () => {
2550
2792
  expect(css).toContain('opacity: 0.4');
2551
2793
  });
2552
2794
  });
2795
+
2796
+ describe('Selector indicatorPosition', () => {
2797
+ const openRows = (): HTMLElement[] => screen.getAllByRole('option', h);
2798
+ const rowFor = (label: string): HTMLElement =>
2799
+ openRows().find(row => row.textContent?.includes(label))!;
2800
+
2801
+ it('draws the mark after the option content by default', () => {
2802
+ render(
2803
+ <Selector
2804
+ label="Fruit"
2805
+ options={OPTIONS}
2806
+ value="Banana"
2807
+ onChange={() => {}}
2808
+ isDefaultOpen
2809
+ />,
2810
+ );
2811
+ const row = rowFor('Banana');
2812
+ const mark = row.querySelector('.astryx-selector-check')!;
2813
+ const content = row.querySelector('.astryx-selector-option')!;
2814
+ expect(
2815
+ content.compareDocumentPosition(mark) & Node.DOCUMENT_POSITION_FOLLOWING,
2816
+ ).toBeTruthy();
2817
+ });
2818
+
2819
+ it('draws the mark before the option content when set to start', () => {
2820
+ render(
2821
+ <Selector
2822
+ label="Fruit"
2823
+ options={OPTIONS}
2824
+ value="Banana"
2825
+ onChange={() => {}}
2826
+ indicatorPosition="start"
2827
+ isDefaultOpen
2828
+ />,
2829
+ );
2830
+ const row = rowFor('Banana');
2831
+ const mark = row.querySelector('.astryx-selector-check')!;
2832
+ const content = row.querySelector('.astryx-selector-option')!;
2833
+ expect(
2834
+ content.compareDocumentPosition(mark) & Node.DOCUMENT_POSITION_PRECEDING,
2835
+ ).toBeTruthy();
2836
+ });
2837
+
2838
+ it('reserves the mark column on every row, at either position', () => {
2839
+ // The default check draws nothing when unchecked, so without a reserved
2840
+ // column the chosen row would be laid out differently from the rest —
2841
+ // indented at the start, truncating earlier at the end. Every row is two
2842
+ // children wide either way, so a row's geometry does not depend on whether
2843
+ // it happens to be the chosen one.
2844
+ const {unmount} = render(
2845
+ <Selector
2846
+ label="Fruit"
2847
+ options={OPTIONS}
2848
+ value="Banana"
2849
+ onChange={() => {}}
2850
+ indicatorPosition="start"
2851
+ isDefaultOpen
2852
+ />,
2853
+ );
2854
+ for (const row of openRows()) {
2855
+ expect(row.children).toHaveLength(2);
2856
+ }
2857
+ unmount();
2858
+
2859
+ render(
2860
+ <Selector
2861
+ label="Fruit"
2862
+ options={OPTIONS}
2863
+ value="Banana"
2864
+ onChange={() => {}}
2865
+ isDefaultOpen
2866
+ />,
2867
+ );
2868
+ for (const row of openRows()) {
2869
+ expect(row.children).toHaveLength(2);
2870
+ }
2871
+ });
2872
+
2873
+ it('positions a themed replacement indicator the same way', () => {
2874
+ const theme = defineTheme({
2875
+ name: 'selector-start-radio-mark-test',
2876
+ indicators: {check: RadioIndicator},
2877
+ });
2878
+ render(
2879
+ <Theme theme={theme}>
2880
+ <Selector
2881
+ label="Fruit"
2882
+ options={OPTIONS}
2883
+ value="Banana"
2884
+ onChange={() => {}}
2885
+ indicatorPosition="start"
2886
+ isDefaultOpen
2887
+ />
2888
+ </Theme>,
2889
+ );
2890
+ for (const row of openRows()) {
2891
+ const radio = row.querySelector('.astryx-radio')!;
2892
+ const content = row.querySelector('.astryx-selector-option')!;
2893
+ expect(
2894
+ content.compareDocumentPosition(radio) &
2895
+ Node.DOCUMENT_POSITION_PRECEDING,
2896
+ ).toBeTruthy();
2897
+ }
2898
+ });
2899
+ });
2900
+
2901
+ describe('Selector popup theme target', () => {
2902
+ // The surface is the same element whether or not the popup has a search
2903
+ // field — which is the reason the target lives there. Rendered on the
2904
+ // component's own content, it would land on the listbox in one branch and
2905
+ // on a wrapper in the other, so one theme rule would style two different
2906
+ // boxes.
2907
+ it.each([
2908
+ ['without search', false],
2909
+ ['with search', true],
2910
+ ])(
2911
+ 'puts astryx-selector-popup on the painting surface, %s',
2912
+ async (_label, hasSearch) => {
2913
+ const user = userEvent.setup();
2914
+ render(
2915
+ <Selector
2916
+ label="Fruit"
2917
+ options={['Apple', 'Banana']}
2918
+ value="Apple"
2919
+ onChange={() => {}}
2920
+ hasSearch={hasSearch}
2921
+ />,
2922
+ );
2923
+ // The trigger is a combobox in the plain variant and a listbox-popup
2924
+ // button in the search variant; the surface is the same either way.
2925
+ await user.click(
2926
+ screen.queryByRole('combobox') ??
2927
+ screen.getByRole('button', {name: /Fruit/}),
2928
+ );
2929
+
2930
+ const popup = document.querySelector('.astryx-selector-popup');
2931
+ expect(popup).not.toBeNull();
2932
+ expect(popup).toHaveClass('astryx-popover-surface');
2933
+ expect(popup?.querySelector('[role="listbox"]')).not.toBeNull();
2934
+ },
2935
+ );
2936
+ });