@astryxdesign/core 0.7.1 → 0.7.2-canary.198a7e3

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 (163) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/Drawer/Drawer.d.ts +191 -0
  4. package/dist/Drawer/Drawer.d.ts.map +1 -0
  5. package/dist/Drawer/Drawer.js +382 -0
  6. package/dist/Drawer/DrawerHeader.d.ts +85 -0
  7. package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
  8. package/dist/Drawer/DrawerHeader.js +134 -0
  9. package/dist/Drawer/index.d.ts +11 -0
  10. package/dist/Drawer/index.d.ts.map +1 -0
  11. package/dist/Drawer/index.js +11 -0
  12. package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
  13. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
  14. package/dist/Drawer/useDrawerDialogPresence.js +236 -0
  15. package/dist/DropdownMenu/DropdownMenu.js +1 -1
  16. package/dist/DropdownMenu/DropdownMenuSubMenu.js +4 -4
  17. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  18. package/dist/HoverCard/useHoverCard.js +10 -2
  19. package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
  20. package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
  21. package/dist/Layer/layerViewportInset.stylex.js +6 -2
  22. package/dist/Layer/useLayer.d.ts.map +1 -1
  23. package/dist/Layer/useLayer.js +13 -7
  24. package/dist/Layer/useLayerDismissal.d.ts +1 -1
  25. package/dist/Layer/useLayerDismissal.js +1 -1
  26. package/dist/Layout/container.stylex.d.ts +5 -5
  27. package/dist/Layout/container.stylex.js +4 -4
  28. package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
  29. package/dist/Markdown/plugins/frontmatter.js +7 -3
  30. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  31. package/dist/MultiSelector/MultiSelector.js +22 -8
  32. package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
  33. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  34. package/dist/MultiSelector/hooks.js +3 -1
  35. package/dist/Pagination/Pagination.d.ts +8 -5
  36. package/dist/Pagination/Pagination.d.ts.map +1 -1
  37. package/dist/Pagination/Pagination.js +21 -8
  38. package/dist/Popover/Popover.js +2 -2
  39. package/dist/PowerSearch/PowerSearch.js +1 -1
  40. package/dist/Selector/hooks.d.ts.map +1 -1
  41. package/dist/Selector/hooks.js +6 -2
  42. package/dist/Switch/Switch.d.ts +1 -1
  43. package/dist/Switch/Switch.d.ts.map +1 -1
  44. package/dist/Switch/Switch.js +13 -1
  45. package/dist/TabList/TabMenu.d.ts +2 -2
  46. package/dist/TabList/TabMenu.d.ts.map +1 -1
  47. package/dist/TabList/TabMenu.js +6 -3
  48. package/dist/Text/useTruncation.d.ts +7 -5
  49. package/dist/Text/useTruncation.d.ts.map +1 -1
  50. package/dist/Text/useTruncation.js +16 -5
  51. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  52. package/dist/Tooltip/useTooltip.js +11 -0
  53. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  54. package/dist/Typeahead/BaseTypeahead.js +26 -0
  55. package/dist/astryx.css +21 -10
  56. package/dist/hooks/useEntryAnimation.d.ts +2 -1
  57. package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
  58. package/dist/hooks/useEntryAnimation.js +14 -12
  59. package/dist/hooks/useIsHydrating.d.ts +6 -0
  60. package/dist/hooks/useIsHydrating.d.ts.map +1 -0
  61. package/dist/hooks/useIsHydrating.js +30 -0
  62. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  63. package/dist/i18n/generated-locales/en.generated.js +112 -1
  64. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  65. package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  70. package/dist/theme/derivedVarRegistry.js +0 -3
  71. package/dist/utils/focusOutline.stylex.d.ts +4 -0
  72. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  73. package/dist/utils/focusOutline.stylex.js +4 -0
  74. package/dist/utils/sharedMutationObserver.d.ts +21 -0
  75. package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
  76. package/dist/utils/sharedMutationObserver.js +69 -0
  77. package/locales/en.json +444 -0
  78. package/locales/pseudo.json +333 -0
  79. package/package.json +9 -4
  80. package/src/Button/Button.doc.mjs +2 -2
  81. package/src/Button/Button.test.tsx +155 -9
  82. package/src/Button/Button.tsx +39 -16
  83. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  84. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  85. package/src/Drawer/Drawer.doc.mjs +472 -0
  86. package/src/Drawer/Drawer.spec.md +357 -0
  87. package/src/Drawer/Drawer.test.tsx +1300 -0
  88. package/src/Drawer/Drawer.tsx +613 -0
  89. package/src/Drawer/DrawerDirection.test.tsx +127 -0
  90. package/src/Drawer/DrawerHeader.doc.mjs +293 -0
  91. package/src/Drawer/DrawerHeader.test.tsx +102 -0
  92. package/src/Drawer/DrawerHeader.tsx +235 -0
  93. package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
  94. package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
  95. package/src/Drawer/index.ts +13 -0
  96. package/src/Drawer/useDrawerDialogPresence.ts +284 -0
  97. package/src/DropdownMenu/DropdownMenu.test.tsx +0 -12
  98. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  99. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +2 -2
  100. package/src/DropdownMenu/__tests__/Menu.a11y.chromium.spec.ts +145 -0
  101. package/src/DropdownMenu/__tests__/Menu.a11y.known-failures.ts +26 -0
  102. package/src/DropdownMenu/__tests__/Menu.a11y.renders.tsx +65 -0
  103. package/src/DropdownMenu/__tests__/Menu.a11y.states.ts +138 -0
  104. package/src/DropdownMenu/__tests__/Menu.a11y.test.tsx +136 -0
  105. package/src/HoverCard/HoverCard.test.tsx +58 -0
  106. package/src/HoverCard/useHoverCard.tsx +10 -2
  107. package/src/IconButton/IconButton.doc.mjs +2 -2
  108. package/src/IconButton/IconButton.test.tsx +3 -1
  109. package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
  110. package/src/Layer/layerViewportInset.stylex.ts +6 -2
  111. package/src/Layer/layerViewportInset.test.ts +1 -0
  112. package/src/Layer/useLayer.tsx +24 -13
  113. package/src/Layer/useLayerDismissal.ts +1 -1
  114. package/src/Layout/container.stylex.ts +6 -6
  115. package/src/List/ListItem.doc.mjs +8 -5
  116. package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
  117. package/src/Markdown/plugins/frontmatter.ts +7 -3
  118. package/src/MultiSelector/MultiSelector.test.tsx +159 -0
  119. package/src/MultiSelector/MultiSelector.tsx +40 -7
  120. package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
  121. package/src/MultiSelector/hooks.ts +3 -1
  122. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  123. package/src/Pagination/Pagination.doc.mjs +7 -7
  124. package/src/Pagination/Pagination.spec.md +9 -9
  125. package/src/Pagination/Pagination.test.tsx +242 -0
  126. package/src/Pagination/Pagination.tsx +27 -11
  127. package/src/Popover/Popover.test.tsx +2 -2
  128. package/src/Popover/Popover.tsx +4 -4
  129. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  130. package/src/PowerSearch/PowerSearch.tsx +2 -2
  131. package/src/Selector/hooks.ts +6 -2
  132. package/src/Selector/useCombobox.doc.mjs +237 -0
  133. package/src/Switch/Switch.test.tsx +49 -0
  134. package/src/Switch/Switch.tsx +11 -0
  135. package/src/TabList/TabList.test.tsx +67 -0
  136. package/src/TabList/TabMenu.doc.mjs +4 -3
  137. package/src/TabList/TabMenu.tsx +7 -4
  138. package/src/Text/useTruncation.test.tsx +69 -0
  139. package/src/Text/useTruncation.ts +16 -5
  140. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  141. package/src/Tooltip/Tooltip.test.tsx +31 -0
  142. package/src/Tooltip/useTooltip.test.tsx +13 -0
  143. package/src/Tooltip/useTooltip.tsx +11 -0
  144. package/src/TreeList/TreeList.spec.md +29 -17
  145. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  146. package/src/Typeahead/Typeahead.test.tsx +260 -2
  147. package/src/hooks/useEntryAnimation.doc.mjs +4 -4
  148. package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
  149. package/src/hooks/useEntryAnimation.ts +15 -16
  150. package/src/hooks/useIsHydrating.test.tsx +70 -0
  151. package/src/hooks/useIsHydrating.ts +31 -0
  152. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  153. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  154. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  155. package/src/i18n/generated-locales/en.generated.ts +121 -0
  156. package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
  157. package/src/i18n/useTranslator.test.tsx +37 -0
  158. package/src/index.ts +1 -0
  159. package/src/theme/derivedVarRegistry.test.ts +0 -3
  160. package/src/theme/derivedVarRegistry.ts +0 -3
  161. package/src/utils/focusOutline.stylex.ts +4 -0
  162. package/src/utils/sharedMutationObserver.test.ts +53 -0
  163. package/src/utils/sharedMutationObserver.ts +78 -0
@@ -594,26 +594,37 @@ export function Button({
594
594
  // the consumer is deliberately showing it.
595
595
  const delaySpinner = isPending || isInterruptible;
596
596
  const groupDisabled = buttonGroup?.isDisabled ?? false;
597
- // When interruptible, the loading state drives the spinner and aria-busy but
598
- // not disabled, so clicks keep landing and can interrupt the in-flight action.
599
- const buttonDisabled =
600
- isDisabled || groupDisabled || (isLoadingState && !isInterruptible);
601
- // A loading button remains non-interactive, but its spinner communicates an
602
- // active state and must retain contrast. Only explicitly disabled controls
603
- // receive the visually dimmed treatment.
604
- const visuallyDisabled = isDisabled || groupDisabled;
597
+ const isTrueDisabled = isDisabled || groupDisabled;
598
+ // Busy-only: the button can't fire again purely because an action is in
599
+ // flight, not because it's genuinely isDisabled/group-disabled. Per the API
600
+ // Conventions "Disabled vs Busy" rule, busy is visual-only (aria-busy +
601
+ // spinner) and must never take focusability away — the click-handler ref
602
+ // guard (actionInFlightRef) is what actually makes a fire-once action fire
603
+ // once, so the native disabled attribute adds nothing but a focus loss
604
+ // here. When interruptible, the loading state drives the spinner and
605
+ // aria-busy but not disabled at all, so clicks keep landing and can
606
+ // interrupt the in-flight action.
607
+ const isBusyOnlyDisabled =
608
+ !isTrueDisabled && isLoadingState && !isInterruptible;
609
+ const buttonDisabled = isTrueDisabled || isBusyOnlyDisabled;
605
610
  // isIconOnly prop is the source of truth for icon-only rendering.
606
611
  // When false (default), label is always rendered as visible text.
607
612
 
608
613
  const LinkComponent = useLinkComponent(as);
609
614
 
610
- // Render as link when href is provided and button is not disabled.
611
- // Disabled links are an accessibility anti-pattern — fall back to <button>.
612
- const renderAsLink = href != null && !buttonDisabled;
615
+ // Render as link when href is provided and the button is not TRULY
616
+ // disabled. Disabled links are an accessibility anti-pattern, so a
617
+ // genuinely isDisabled/group-disabled button falls back to <button>.
618
+ // Busy-only does NOT fall back: swapping <a> for a disabled <button>
619
+ // mid-action would drop focus the same way the native attribute does
620
+ // (credit @AKnassa, #4879), which is exactly the bug this PR fixes.
621
+ const renderAsLink = href != null && !isTrueDisabled;
613
622
 
614
- // Use aria-disabled when tooltip is present so the button remains focusable
615
- // for keyboard users to reach the tooltip. Otherwise use native disabled.
616
- const useAriaDisabled = tooltip != null && buttonDisabled;
623
+ // Use aria-disabled (button stays focusable) when busy-only, or when a
624
+ // tooltip is present so keyboard users can still reach it. A genuinely
625
+ // isDisabled/group-disabled button (not busy) keeps native disabled.
626
+ const useAriaDisabled =
627
+ buttonDisabled && (isBusyOnlyDisabled || tooltip != null);
617
628
 
618
629
  // Attach tooltip behavior via the hook rather than wrapping the button in a
619
630
  // <Tooltip> element. The hook adds hover/focus triggers to the button itself,
@@ -630,6 +641,16 @@ export function Button({
630
641
  // a re-click while pending starts a fresh action that interrupts the prior.
631
642
  if (buttonDisabled || (actionInFlightRef.current && !isInterruptible)) {
632
643
  e.preventDefault();
644
+ // A native `disabled` element never dispatches a click at all, so it
645
+ // never reaches a wrapping element's own handler either. An
646
+ // aria-disabled button (busy-only, or with a tooltip) is still a real,
647
+ // interactive element the DOM keeps dispatching clicks on —
648
+ // preventDefault alone only cancels the button's OWN default action
649
+ // (form submit, link navigation), not propagation. Without this, an
650
+ // unavailable Button inside a clickable row/card/backdrop still
651
+ // triggers that ancestor's action, which the previously native-disabled
652
+ // control never did.
653
+ e.stopPropagation();
633
654
  return;
634
655
  }
635
656
  props.onClick?.(e);
@@ -669,7 +690,7 @@ export function Button({
669
690
  isIconOnly && styles.iconOnly,
670
691
  interactionOverlayStyles.backgroundImage,
671
692
  buttonDisabled && styles.inactive,
672
- visuallyDisabled && styles.disabled,
693
+ isTrueDisabled && styles.disabled,
673
694
  useAriaDisabled && styles.ariaDisabled,
674
695
  renderAsLink && styles.link,
675
696
  !buttonGroup && styles.pressable,
@@ -797,7 +818,9 @@ export function Button({
797
818
  {...describedByProp}
798
819
  {...edgeCompAttr}
799
820
  aria-busy={isLoadingState || undefined}
800
- onClick={handleClick}>
821
+ aria-disabled={useAriaDisabled || undefined}
822
+ onClick={handleClick}
823
+ {...(handleKeyDown ? {onKeyDown: handleKeyDown} : null)}>
801
824
  {buttonContent}
802
825
  </LinkComponent>
803
826
  );
@@ -22,65 +22,4 @@
22
22
 
23
23
  import type {KnownFailure} from '@astryxdesign/a11y-spec';
24
24
 
25
- export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
26
- // ---- Button and IconButton go natively disabled while busy --------------
27
- // One defect, one record per component.
28
- // They are written out rather than generated, because a record has to name
29
- // exactly one thing that is broken — a loop would make it easy to widen this
30
- // later without anyone noticing.
31
- {
32
- expectation: 'button.focus.reachable-and-escapable',
33
- binding: 'Button',
34
- state: 'button-loading',
35
- evidenceLayer: 'real-browser',
36
- failureEquals:
37
- '10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
38
- standardsReference:
39
- 'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
40
- userImpact:
41
- 'A keyboard user who activates Save and waits is dropped to the top of the document the moment the action starts, and the button leaves the tab sequence entirely — so they cannot get back to it, to see that it is busy or to interrupt it. The next Tab restarts from the beginning of the page.',
42
- reason:
43
- 'Button sets the native disabled attribute while a clickAction is pending. A natively disabled element is neither focusable nor a tab stop. Separate implementation work will turn this record into an unexpected pass.',
44
- },
45
- {
46
- expectation: 'button.focus.reachable-and-escapable',
47
- binding: 'IconButton',
48
- state: 'icon-button-loading',
49
- evidenceLayer: 'real-browser',
50
- failureEquals:
51
- '10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
52
- standardsReference:
53
- 'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
54
- userImpact:
55
- 'The same drop from an icon button, where it is worse: an icon button is usually one of several in a row, so the user loses their place among them.',
56
- reason:
57
- 'IconButton is a thin wrapper over Button and inherits the behaviour exactly.',
58
- },
59
-
60
- {
61
- expectation: 'button.unavailable.inert',
62
- binding: 'Button',
63
- state: 'button-loading',
64
- evidenceLayer: 'real-browser',
65
- failureEquals:
66
- 'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
67
- standardsReference:
68
- 'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
69
- userImpact:
70
- 'The third face of the same defect: because the busy button cannot take focus, there is no way to confirm from the keyboard that it declines a second press — the user can neither reach it nor see why it is unavailable.',
71
- reason:
72
- 'Same native disabled attribute. Recorded separately because a record names exactly one outcome, and a fix that restored focus without keeping the button inert would satisfy one of these and not the other.',
73
- },
74
- {
75
- expectation: 'button.unavailable.inert',
76
- binding: 'IconButton',
77
- state: 'icon-button-loading',
78
- evidenceLayer: 'real-browser',
79
- failureEquals:
80
- 'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
81
- standardsReference:
82
- 'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
83
- userImpact: 'The same, from an icon button.',
84
- reason: 'Inherited from Button, as above.',
85
- },
86
- ];
25
+ export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
@@ -177,16 +177,9 @@ export const BUTTON_BINDING_STATES = [
177
177
  binding: 'Button',
178
178
  summary:
179
179
  'a button waiting on the action it started: the action is unavailable, and it is meant to stay focusable so the user can see that and interrupt it',
180
- // `focusable: true` is the declaration, and the component does not deliver
181
- // it — see the known-failure records. Declaring what the component does
182
- // instead would make the expectations that catch it not-applicable, which
183
- // is how a contract quietly stops noticing a defect.
184
180
  facts: facts({operable: false, unavailable: true}),
185
181
  visibleLabel: null,
186
182
  storyId: 'a11y-button-pattern--button-loading',
187
- declaredNotDelivered: [
188
- {fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
189
- ],
190
183
  },
191
184
 
192
185
  // ---- IconButton ---------------------------------------------------------
@@ -214,9 +207,6 @@ export const BUTTON_BINDING_STATES = [
214
207
  facts: facts({operable: false, unavailable: true}),
215
208
  visibleLabel: null,
216
209
  storyId: 'a11y-button-pattern--icon-button-loading',
217
- declaredNotDelivered: [
218
- {fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
219
- ],
220
210
  },
221
211
 
222
212
  // ---- ClickableCard ------------------------------------------------------
@@ -0,0 +1,472 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Drawer.doc.mjs
5
+ * @input Drawer props, panel anatomy, DrawerHeader composition, overlay playground config, and story-aligned examples
6
+ * @output Consumer documentation and examples for Drawer
7
+ * @position CLI and docsite metadata; runnable docsite demos live in packages/cli/assets/templates/blocks/components/Drawer
8
+ */
9
+
10
+ /** @type {import('@astryxdesign/cli/authoring').ComponentAnatomyElement[]} */
11
+ const anatomy = [
12
+ {
13
+ name: 'Panel',
14
+ required: true,
15
+ description:
16
+ 'The root <dialog> element: a full-height surface anchored to the inline start or end edge, flush with three viewport edges (square corners).',
17
+ },
18
+ {
19
+ name: 'Content area',
20
+ required: true,
21
+ description:
22
+ 'Scrollable container that receives children; compose the header, body, and footer inside it with a Layout, as in Dialog. Inset by --spacing-4 (16px) by default; the padding prop or a theme sets a different container inset.',
23
+ },
24
+ {
25
+ name: 'Header',
26
+ required: false,
27
+ description:
28
+ 'DrawerHeader, composed in a Layout header slot as DialogHeader is in Dialog: a title row with optional subtitle and start/end content. Pass it onOpenChange to show a close button, as with DialogHeader.',
29
+ },
30
+ {
31
+ name: 'Scrim',
32
+ required: false,
33
+ description:
34
+ 'Backdrop that dims and blocks the page behind a modal drawer (hasScrim, the default); a non-modal drawer renders none.',
35
+ },
36
+ ];
37
+
38
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
39
+
40
+ export const docs = {
41
+ name: 'Drawer',
42
+ displayName: 'Drawer',
43
+ // Keeps the registry route Drawer had as a canary-only Lab component.
44
+ registry: {aliases: ['lab/drawer']},
45
+ group: 'Drawer',
46
+ category: 'Overlay',
47
+ keywords: [
48
+ 'drawer',
49
+ 'side panel',
50
+ 'panel',
51
+ 'inspector',
52
+ 'detail view',
53
+ 'overlay',
54
+ 'slide',
55
+ 'sidebar',
56
+ 'dialog',
57
+ 'side drawer',
58
+ ],
59
+ // No isInline docs-preview prop (the drawer has no inline containment), so
60
+ // this is the overlay preview: closed on load with an open trigger, knobs
61
+ // live, and opening renders the real top-layer drawer — see
62
+ // ComponentPlaygroundConfig.overlay (#3657).
63
+ playground: {
64
+ overlay: true,
65
+ defaults: {
66
+ isOpen: false,
67
+ label: 'Details',
68
+ children: {
69
+ __element: 'Section',
70
+ props: {padding: 4},
71
+ children: {
72
+ __element: 'VStack',
73
+ props: {gap: 2},
74
+ children: [
75
+ {__element: 'Heading', props: {level: 3}, children: 'Details'},
76
+ {
77
+ __element: 'Text',
78
+ props: {type: 'body'},
79
+ children: 'Adjust the properties below, then open the preview.',
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ },
85
+ },
86
+ theming: {
87
+ container: true,
88
+ targets: [
89
+ {className: 'astryx-drawer', visualProps: ['side']},
90
+ {className: 'astryx-drawer-header'},
91
+ {className: 'astryx-drawer-header-start-content'},
92
+ {className: 'astryx-drawer-header-title-block'},
93
+ {className: 'astryx-drawer-header-end-content'},
94
+ {className: 'astryx-drawer-header-close-icon'},
95
+ ],
96
+ derived: [{property: 'padding', expand: 'container'}],
97
+ },
98
+ description:
99
+ "Side panel that floats above page content, using the native <dialog> element. Slides in from the inline start or end edge; full height, never reflows the layout underneath. Like Dialog, it is a container: set padding (or a theme's drawer padding) and a lone Section child or bleed children such as Table and Divider align against that inset. The default inset is --spacing-4 (16px), as in Dialog.",
100
+ props: [
101
+ {
102
+ name: 'isOpen',
103
+ type: 'boolean',
104
+ description:
105
+ 'Whether the drawer is open. Fully controlled; pair with onOpenChange.',
106
+ required: true,
107
+ },
108
+ {
109
+ name: 'onOpenChange',
110
+ type: '(isOpen: boolean) => void',
111
+ description:
112
+ 'Called when the drawer requests an open-state change. Escape and a scrim click call it with false as purpose allows; a DrawerHeader given the same callback calls it from its close button. The caller owns the open state. With sibling drawers open, Escape only closes the last-opened one.',
113
+ required: true,
114
+ },
115
+ {
116
+ name: 'label',
117
+ type: 'string',
118
+ description:
119
+ 'Accessible label for the drawer. Required; the drawer has no built-in heading to derive a name from.',
120
+ required: true,
121
+ },
122
+ {
123
+ name: 'children',
124
+ type: 'ReactNode',
125
+ description:
126
+ 'Drawer content, rendered inside a full-height scrollable area. Compose your own header/body/footer; an element with data-autofocus is focused on open. Children stay mounted during the exit animation; keep the last-selected item rendered instead of nulling content on close.',
127
+ required: true,
128
+ },
129
+ {
130
+ name: 'side',
131
+ type: "'start' | 'end'",
132
+ description:
133
+ "Edge the drawer slides from: 'end' is right in LTR (the inspector convention), 'start' is left. Inline axis only; for a bottom sheet use BottomSheet.",
134
+ default: "'end'",
135
+ },
136
+ {
137
+ name: 'width',
138
+ type: 'number | string',
139
+ description:
140
+ "Desktop width budget. A number is pixels; a string is any CSS length ('50%', '32rem'). Below the 640px mobile breakpoint this remains the maximum while the drawer preserves a 56px reveal of the page behind.",
141
+ default: '400',
142
+ },
143
+ {
144
+ name: 'experimental',
145
+ type: '{isFullWidthOnMobile?: boolean}',
146
+ stability: 'experimental',
147
+ description:
148
+ 'Experimental options: anything in this object may change or be removed in a patch release, and omitting it keeps the stable behavior. isFullWidthOnMobile (default false) covers the full viewport width below the 640px mobile breakpoint instead of preserving the default 56px reveal of the page behind, which makes the drawer read as an overlay, not a navigation.',
149
+ },
150
+ {
151
+ name: 'hasScrim',
152
+ type: 'boolean',
153
+ description:
154
+ 'Modal scrim behind the drawer. true uses showModal() (top layer, focus trap, scroll lock; clicking the scrim closes unless purpose prevents it; modal only); false uses the manual Popover API for a non-modal top-layer overlay that does NOT trap focus and keeps the page behind interactive.',
155
+ default: 'true',
156
+ },
157
+ {
158
+ name: 'purpose',
159
+ type: "'required' | 'form' | 'info'",
160
+ description:
161
+ 'Configures implicit dismissal, matching Dialog. info: Escape and a scrim click close. form: Escape closes, a scrim click does not. required: neither closes (Escape is consumed) and a modal drawer is exposed as an alertdialog, so give its content a way out. A non-modal drawer has no scrim, so form and info behave the same.',
162
+ default: "'info'",
163
+ },
164
+ {
165
+ name: 'padding',
166
+ type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10',
167
+ description:
168
+ "Internal padding of the drawer content using the spacing scale, matching Dialog. When omitted, uses the theme default for drawers: --spacing-4 (16px) unless the theme sets padding on drawer. Pass 0 for a full-bleed content area. A lone Section child escapes the padding, bleed children such as Table and Divider compensate against it, and a Layout picks it up for its regions. The content area's block-end edge adds the home-indicator safe area on top in every mode.",
169
+ },
170
+ ],
171
+ usage: {
172
+ anatomy,
173
+ description:
174
+ 'A side panel that floats above page content for inspectors and detail views: the "click a table row, see its details" pattern. Unlike a docked panel it overlays the layout instead of reflowing it. Works on desktop and touch: the width budget applies on desktop and the panel preserves a 56px page reveal below 640px without exceeding the width budget. Escape closes the drawer (unless purpose is required) and focus returns to the element that opened it. Entry/exit slide animation respects prefers-reduced-motion. Stacking contract: sibling drawers stack last-opened on top, Escape closes only the topmost, and closing peels innermost-first; render them as siblings, never nested.\n\nLike Dialog, the drawer is a container: its content area is inset by --spacing-4 (16px) by default, by the padding prop, or by a theme\'s padding on drawer. Compose the header, body, and footer the way Dialog does, with a Layout: DrawerHeader in the header slot (it shows a close button when you pass it onOpenChange, like DialogHeader), the body in LayoutContent, and actions in LayoutFooter. The Layout redistributes the inset to those regions so they line up on one content line, and the header is not inset twice. A lone Section child also escapes the inset. Drawer renders no close button of its own, so give every drawer a visible way to close, especially a non-modal one, which has no scrim to click. Put data-autofocus on the control that should receive focus on open.\n\nChoosing a surface: use Dialog for a centered decision or short form, Drawer for full-height side detail that keeps the page in sight, BottomSheet for block-axis sheets on touch, and a docked panel (a layout column) when content should reflow the page instead of floating over it.\n\nTheming: the panel is the single stable target (astryx-drawer, with data-side reflecting side), and the scrim is the panel\'s native ::backdrop. DrawerHeader exposes astryx-drawer-header targets for its row, slots, and close icon.',
175
+ bestPractices: [
176
+ {
177
+ guidance: true,
178
+ description:
179
+ 'Use for contextual detail views (row inspectors, entity details) where the user should keep the underlying list in sight.',
180
+ },
181
+ {
182
+ guidance: true,
183
+ description:
184
+ 'Keep the caller as the source of truth: derive isOpen from selection state and clear the selection in onOpenChange.',
185
+ },
186
+ {
187
+ guidance: true,
188
+ description:
189
+ 'Use hasScrim={false} for master-detail flows; non-modal drawers do not trap focus and the page behind stays interactive.',
190
+ },
191
+ {
192
+ guidance: true,
193
+ description:
194
+ 'Keep the last-selected item rendered on close: children stay mounted during the exit animation, so nulling content mid-close blanks the panel while it slides out.',
195
+ },
196
+ {
197
+ guidance: true,
198
+ description:
199
+ 'Give every drawer a visible close action, usually DrawerHeader with onOpenChange; a non-modal drawer has no scrim to click.',
200
+ },
201
+ {
202
+ guidance: true,
203
+ description:
204
+ 'Use purpose="form" when a stray scrim click would discard unsaved input.',
205
+ },
206
+ {
207
+ guidance: false,
208
+ description:
209
+ 'Use a Drawer for short confirmations or small forms; use Dialog or AlertDialog instead.',
210
+ },
211
+ {
212
+ guidance: false,
213
+ description:
214
+ 'Reach for a Drawer when the content should push the page aside; a Drawer floats over content, so use a docked panel or layout column instead.',
215
+ },
216
+ {
217
+ guidance: false,
218
+ description:
219
+ 'Use a Drawer as a bottom or top sheet; it is inline-axis only, so use BottomSheet for block-axis sheets.',
220
+ },
221
+ {
222
+ guidance: false,
223
+ description:
224
+ 'Nest a Drawer inside another Drawer; render drawers as siblings; the last-opened stacks on top and Escape closes it first.',
225
+ },
226
+ ],
227
+ },
228
+ // CLI snippets. The docsite renders the paired Drawer blocks in ../../blocks.
229
+ examples: [
230
+ {
231
+ label: 'Basic',
232
+ code: `const [isOpen, setIsOpen] = useState(false);
233
+ <>
234
+ <Button label="Open drawer" onClick={() => setIsOpen(true)} />
235
+ <Drawer
236
+ isOpen={isOpen}
237
+ onOpenChange={setIsOpen}
238
+ label="Details"
239
+ width={360}>
240
+ <Layout
241
+ header={<DrawerHeader title="Details" onOpenChange={setIsOpen} />}
242
+ content={
243
+ <LayoutContent>
244
+ <Text type="body">Close with Escape, the scrim, or the close button.</Text>
245
+ </LayoutContent>
246
+ }
247
+ />
248
+ </Drawer>
249
+ </>
250
+ // Modal by default: the scrim dims the page and focus is trapped. Escape, a
251
+ // scrim click, or the header's close button closes it, and focus returns to
252
+ // the trigger.`,
253
+ },
254
+ {
255
+ label: 'Modal or non-modal (hasScrim)',
256
+ code: `const [openModal, setOpenModal] = useState(false);
257
+ const [openPanel, setOpenPanel] = useState(false);
258
+ <>
259
+ {/* Modal (default): the scrim dims the page, focus is trapped, and a
260
+ scrim click closes. */}
261
+ <Drawer isOpen={openModal} onOpenChange={setOpenModal} label="Edit details">
262
+ <EditForm />
263
+ </Drawer>
264
+ {/* Non-modal: no scrim, no focus trap; the page behind stays interactive.
265
+ With no scrim to click, the header's close button is the visible exit. */}
266
+ <Drawer
267
+ isOpen={openPanel}
268
+ onOpenChange={setOpenPanel}
269
+ label="Details"
270
+ hasScrim={false}>
271
+ <Layout
272
+ header={<DrawerHeader title="Details" onOpenChange={setOpenPanel} />}
273
+ content={<LayoutContent><DetailsPanel /></LayoutContent>}
274
+ />
275
+ </Drawer>
276
+ </>`,
277
+ },
278
+ {
279
+ label: 'Edit form with footer actions',
280
+ code: `const [isOpen, setIsOpen] = useState(false);
281
+ <Drawer
282
+ isOpen={isOpen}
283
+ onOpenChange={setIsOpen}
284
+ label="Edit host"
285
+ purpose="form">
286
+ <Layout
287
+ header={<DrawerHeader title="Edit host" onOpenChange={setIsOpen} />}
288
+ content={<LayoutContent><HostFields /></LayoutContent>}
289
+ footer={
290
+ <LayoutFooter>
291
+ <HStack gap={2} hAlign="end">
292
+ <Button label="Cancel" variant="secondary" onClick={() => setIsOpen(false)} />
293
+ <Button label="Save" variant="primary" onClick={save} />
294
+ </HStack>
295
+ </LayoutFooter>
296
+ }
297
+ />
298
+ </Drawer>
299
+ // purpose="form" keeps Escape but ignores scrim clicks, so a stray click
300
+ // outside does not discard edits. The footer stays pinned while the fields
301
+ // scroll.`,
302
+ },
303
+ {
304
+ label: 'Slide in from the start edge',
305
+ code: `const [isOpen, setIsOpen] = useState(false);
306
+ <Drawer
307
+ isOpen={isOpen}
308
+ onOpenChange={setIsOpen}
309
+ label="Navigation"
310
+ side="start">
311
+ <NavPanel />
312
+ </Drawer>
313
+ // 'start' is left in LTR and mirrors under RTL; 'end' (the default) is the
314
+ // inspector convention.`,
315
+ },
316
+ {
317
+ label: 'Width budget: pixels or any CSS length',
318
+ code: `const [isOpen, setIsOpen] = useState(false);
319
+ <Drawer
320
+ isOpen={isOpen}
321
+ onOpenChange={setIsOpen}
322
+ label="Details"
323
+ width="50%">
324
+ <DetailsPanel />
325
+ </Drawer>
326
+ // A number is pixels (width={320}); a string is any CSS length ('32rem',
327
+ // '50%'). The budget also caps the drawer on mobile.`,
328
+ },
329
+ ],
330
+ };
331
+
332
+ /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
333
+ export const docsZh = {
334
+ usage: {
335
+ anatomy: [
336
+ {
337
+ name: 'Panel',
338
+ required: true,
339
+ description:
340
+ '根 <dialog> 元素:锚定于行内起始或结束边缘的全高表面,与三个视口边缘齐平(直角)。',
341
+ },
342
+ {
343
+ name: 'Content area',
344
+ required: true,
345
+ description:
346
+ '承载 children 的可滚动容器;像 Dialog 一样在其中用 Layout 组合页眉、正文和页脚。默认带有 --spacing-4(16px)的内边距;padding 属性或主题的 drawer padding 可设置其他容器内边距。',
347
+ },
348
+ {
349
+ name: 'Header',
350
+ required: false,
351
+ description:
352
+ 'DrawerHeader,像 Dialog 中的 DialogHeader 一样放在 Layout 的 header 插槽:包含标题,以及可选的副标题和首尾内容。传入 onOpenChange 即显示关闭按钮,与 DialogHeader 相同。',
353
+ },
354
+ {
355
+ name: 'Scrim',
356
+ required: false,
357
+ description:
358
+ '模态抽屉(hasScrim,默认开启)背后调暗并阻止页面交互的背景层;非模态抽屉没有。',
359
+ },
360
+ ],
361
+ description:
362
+ '浮在页面内容之上的侧边面板,用于检查器和详情视图——"点击表格行查看详情"的模式。与停靠面板不同,它覆盖在布局之上,不会挤压页面。桌面端按 width 设定宽度,宽度小于 640px 时保留 56px 的底层页面,并且不超过 width 上限。按 Escape 关闭抽屉(purpose 为 required 时除外),焦点返回到打开它的元素。滑入/滑出动画遵循 prefers-reduced-motion。堆叠约定:同级抽屉后开的在上层,Escape 只关闭最上层的,关闭顺序由内向外——请以同级方式渲染,切勿嵌套。\n\n与 Dialog 一样,Drawer 是一个容器:内容区域默认带有 --spacing-4(16px)的内边距,也可以由 padding 属性或主题中 drawer 的 padding 设置。像 Dialog 一样用 Layout 组合页眉、正文和页脚:DrawerHeader 放在 header 插槽(传入 onOpenChange 时显示关闭按钮,与 DialogHeader 相同),正文放在 LayoutContent,操作放在 LayoutFooter。Layout 会把内边距分配给这些区域,使它们对齐在同一条内容线上,页眉也不会被重复缩进。作为唯一子元素的 Section 同样会越出该内边距。Drawer 本身不渲染关闭按钮,所以每个抽屉都要提供可见的关闭方式,非模态抽屉尤其如此,因为它没有可点击的遮罩。在打开时应获得焦点的控件上加 data-autofocus。\n\n如何选择:居中的决定或短表单用 Dialog;保持页面可见的全高侧边详情用 Drawer;触屏上的块轴面板用 BottomSheet;内容应当把页面挤开重排时用停靠面板(布局分栏),而不是浮层。\n\n主题化:面板是唯一的稳定目标(astryx-drawer,data-side 反映 side 值),遮罩是面板的原生 ::backdrop。DrawerHeader 为其标题行、插槽和关闭图标提供 astryx-drawer-header 系列目标。',
363
+ bestPractices: [
364
+ {
365
+ guidance: true,
366
+ description:
367
+ '用于上下文详情视图(行检查器、实体详情),让用户保持对底层列表的可见性。',
368
+ },
369
+ {
370
+ guidance: true,
371
+ description:
372
+ '让调用方作为唯一数据源:从选中状态派生 isOpen,并在 onOpenChange 中清除选中。',
373
+ },
374
+ {
375
+ guidance: true,
376
+ description:
377
+ '在主从流程中使用 hasScrim={false}——非模态抽屉不捕获焦点,抽屉后面的页面保持可交互。',
378
+ },
379
+ {
380
+ guidance: true,
381
+ description:
382
+ '关闭时保留最后选中的内容:退出动画期间子内容仍然挂载,中途置空会让面板在滑出时变为空白。',
383
+ },
384
+ {
385
+ guidance: true,
386
+ description:
387
+ '为每个抽屉提供可见的关闭操作,通常是带 onOpenChange 的 DrawerHeader;非模态抽屉没有可点击的遮罩。',
388
+ },
389
+ {
390
+ guidance: true,
391
+ description: '误点遮罩会丢失未保存的输入时,使用 purpose="form"。',
392
+ },
393
+ {
394
+ guidance: false,
395
+ description:
396
+ '用 Drawer 做简短确认或小表单——请改用 Dialog 或 AlertDialog。',
397
+ },
398
+ {
399
+ guidance: false,
400
+ description:
401
+ '需要把页面内容挤开时不要用 Drawer——它浮在内容之上,请改用停靠面板或布局分栏。',
402
+ },
403
+ {
404
+ guidance: false,
405
+ description:
406
+ '不要把 Drawer 当作底部/顶部面板使用——它只支持行内轴,块轴面板请用 BottomSheet。',
407
+ },
408
+ {
409
+ guidance: false,
410
+ description:
411
+ '在 Drawer 中嵌套另一个 Drawer;应以同级方式渲染——后开的堆叠在上层,Escape 先关闭它。',
412
+ },
413
+ ],
414
+ },
415
+ };
416
+
417
+ /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
418
+ export const docsDense = {
419
+ description:
420
+ 'side panel floating over content (native <dialog>): start/end edge, full height, Dialog-like container padding (--spacing-4 by default; padding prop or theme drawer padding)',
421
+ usage: {
422
+ anatomy,
423
+ description:
424
+ "Overlay side panel for inspectors and detail views; floats over content, never reflows it. width = desktop budget; 56px page reveal below 640px, capped by width. Escape closes topmost (not when purpose=required); focus restores to the opener. Siblings stack last-opened on top; never nest. Slide animation respects prefers-reduced-motion. Dialog-like container: content area inset = --spacing-4 (16px) by default, or the padding prop / a theme's drawer padding (padding={0} = full bleed). Compose like Dialog: Layout with DrawerHeader in the header slot (close button only when given onOpenChange), LayoutContent, LayoutFooter; Layout redistributes the inset so regions line up and the header is not inset twice; a lone Section escapes the inset. No built-in close button, so every drawer needs a visible exit; data-autofocus picks the focus target. purpose matches Dialog: info = Escape + scrim click, form = Escape only, required = neither (modal → alertdialog). Choose: Dialog for centered decisions/short forms, Drawer for full-height side detail, BottomSheet for block-axis sheets, docked layout column to reflow instead of float. Theming: single target astryx-drawer (data-side); scrim is its ::backdrop; DrawerHeader has astryx-drawer-header targets.",
425
+ bestPractices: [
426
+ {
427
+ guidance: true,
428
+ description: 'Use for row inspectors and entity detail views.',
429
+ },
430
+ {
431
+ guidance: true,
432
+ description:
433
+ 'Derive isOpen from selection state; clear it in onOpenChange.',
434
+ },
435
+ {
436
+ guidance: true,
437
+ description:
438
+ 'Use hasScrim={false} for non-modal master-detail flows (no focus trap, page stays interactive).',
439
+ },
440
+ {
441
+ guidance: true,
442
+ description:
443
+ 'Keep last-selected content rendered on close (children stay mounted during exit).',
444
+ },
445
+ {
446
+ guidance: true,
447
+ description:
448
+ 'Give every drawer a visible close action (DrawerHeader + onOpenChange); non-modal has no scrim.',
449
+ },
450
+ {
451
+ guidance: true,
452
+ description:
453
+ 'Use purpose="form" when a stray scrim click would lose input.',
454
+ },
455
+ {
456
+ guidance: false,
457
+ description:
458
+ 'Use for confirmations or small forms; use Dialog instead.',
459
+ },
460
+ {
461
+ guidance: false,
462
+ description:
463
+ 'Use when content should push the page aside (it floats over) or as a bottom sheet (use BottomSheet).',
464
+ },
465
+ {
466
+ guidance: false,
467
+ description:
468
+ 'Nest Drawers: render as siblings; Escape closes the last-opened first.',
469
+ },
470
+ ],
471
+ },
472
+ };