@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29

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 (214) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +392 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +942 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  53. package/dist/Lightbox/Lightbox.js +9 -3
  54. package/dist/Markdown/parser.d.ts.map +1 -1
  55. package/dist/Markdown/parser.js +55 -12
  56. package/dist/MobileNav/MobileNav.d.ts +12 -0
  57. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  58. package/dist/MobileNav/MobileNav.js +102 -30
  59. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  60. package/dist/MultiSelector/MultiSelector.js +21 -7
  61. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  62. package/dist/NumberInput/NumberInput.js +9 -1
  63. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  64. package/dist/PowerSearch/PowerSearch.js +4 -1
  65. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  66. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  67. package/dist/PowerSearch/formatFilterValue.js +2 -4
  68. package/dist/Selector/Selector.d.ts.map +1 -1
  69. package/dist/Selector/Selector.js +14 -3
  70. package/dist/Switch/Switch.d.ts.map +1 -1
  71. package/dist/Switch/Switch.js +5 -1
  72. package/dist/Table/columnUtils.d.ts.map +1 -1
  73. package/dist/Table/columnUtils.js +4 -1
  74. package/dist/TextArea/TextArea.d.ts +6 -3
  75. package/dist/TextArea/TextArea.d.ts.map +1 -1
  76. package/dist/TextArea/TextArea.js +17 -6
  77. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  78. package/dist/TimeInput/TimeInput.js +8 -1
  79. package/dist/Tokenizer/Tokenizer.js +11 -5
  80. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  81. package/dist/Tooltip/useTooltip.js +4 -1
  82. package/dist/TreeList/TreeList.js +2 -1
  83. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  84. package/dist/Typeahead/BaseTypeahead.js +5 -3
  85. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  86. package/dist/Typeahead/Typeahead.js +10 -1
  87. package/dist/astryx.css +25 -2
  88. package/dist/hooks/index.d.ts +8 -1
  89. package/dist/hooks/index.d.ts.map +1 -1
  90. package/dist/hooks/index.js +8 -1
  91. package/dist/hooks/useFocusTrap.d.ts +0 -11
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +1 -11
  94. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  95. package/dist/hooks/useInputStatusIcon.js +1 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/theme/defineTheme.d.ts +30 -7
  100. package/dist/theme/defineTheme.d.ts.map +1 -1
  101. package/dist/theme/defineTheme.js +36 -47
  102. package/dist/theme/expandColorScale.d.ts +17 -2
  103. package/dist/theme/expandColorScale.d.ts.map +1 -1
  104. package/dist/theme/expandColorScale.js +63 -33
  105. package/dist/theme/mergeComponents.d.ts +20 -0
  106. package/dist/theme/mergeComponents.d.ts.map +1 -0
  107. package/dist/theme/mergeComponents.js +56 -0
  108. package/dist/theme/onMediaTokens.d.ts +6 -1
  109. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  110. package/dist/theme/onMediaTokens.js +11 -3
  111. package/dist/utils/characters.d.ts +27 -0
  112. package/dist/utils/characters.d.ts.map +1 -0
  113. package/dist/utils/characters.js +83 -0
  114. package/dist/utils/ime.d.ts +5 -0
  115. package/dist/utils/ime.d.ts.map +1 -0
  116. package/dist/utils/ime.js +63 -0
  117. package/dist/utils/index.d.ts +2 -0
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/locales/en.json +48 -0
  121. package/locales/pseudo.json +36 -0
  122. package/package.json +9 -8
  123. package/src/Avatar/Avatar.test.tsx +6 -1
  124. package/src/Avatar/Avatar.tsx +3 -21
  125. package/src/Banner/Banner.doc.mjs +12 -10
  126. package/src/Banner/Banner.test.tsx +108 -0
  127. package/src/Banner/Banner.tsx +124 -38
  128. package/src/Banner/index.ts +15 -13
  129. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  130. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  131. package/src/BottomSheet/BottomSheet.tsx +545 -0
  132. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  133. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  134. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  135. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  137. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  138. package/src/BottomSheet/index.ts +15 -0
  139. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  140. package/src/BottomSheet/snapOffsets.ts +159 -0
  141. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  142. package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
  143. package/src/BottomSheet/useSheetGestures.ts +1407 -0
  144. package/src/Chat/ChatComposerInput.tsx +3 -4
  145. package/src/Chat/ChatTokenizedText.tsx +1 -1
  146. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  147. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  148. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  149. package/src/ComplexSelector/index.ts +3 -1
  150. package/src/ContextMenu/ContextMenu.tsx +4 -2
  151. package/src/DateInput/DateInput.test.tsx +21 -0
  152. package/src/DateInput/DateInput.tsx +9 -1
  153. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  154. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  155. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  156. package/src/Dialog/Dialog.tsx +2 -1
  157. package/src/FileInput/FileInput.test.tsx +53 -9
  158. package/src/Lightbox/Lightbox.test.tsx +49 -28
  159. package/src/Lightbox/Lightbox.tsx +7 -3
  160. package/src/Markdown/parser.test.ts +53 -0
  161. package/src/Markdown/parser.ts +53 -12
  162. package/src/MobileNav/MobileNav.tsx +128 -37
  163. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  164. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  165. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  166. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  167. package/src/MultiSelector/MultiSelector.tsx +22 -8
  168. package/src/NumberInput/NumberInput.test.tsx +18 -0
  169. package/src/NumberInput/NumberInput.tsx +9 -1
  170. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  171. package/src/PowerSearch/PowerSearch.tsx +4 -1
  172. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  173. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  174. package/src/PowerSearch/formatFilterValue.ts +2 -4
  175. package/src/Selector/Selector.test.tsx +85 -26
  176. package/src/Selector/Selector.tsx +13 -4
  177. package/src/Switch/Switch.test.tsx +16 -0
  178. package/src/Switch/Switch.tsx +7 -0
  179. package/src/Table/Table.test.tsx +6 -0
  180. package/src/Table/columnUtils.ts +3 -1
  181. package/src/TextArea/TextArea.doc.mjs +1 -1
  182. package/src/TextArea/TextArea.test.tsx +72 -0
  183. package/src/TextArea/TextArea.tsx +26 -8
  184. package/src/TimeInput/TimeInput.test.tsx +23 -0
  185. package/src/TimeInput/TimeInput.tsx +8 -0
  186. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  187. package/src/Tokenizer/Tokenizer.tsx +5 -5
  188. package/src/Tooltip/useTooltip.tsx +4 -1
  189. package/src/TreeList/TreeList.doc.mjs +2 -2
  190. package/src/TreeList/TreeList.tsx +1 -1
  191. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  192. package/src/Typeahead/Typeahead.test.tsx +116 -7
  193. package/src/Typeahead/Typeahead.tsx +10 -1
  194. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  195. package/src/__tests__/stubMatchMedia.ts +95 -0
  196. package/src/hooks/index.ts +9 -5
  197. package/src/hooks/useFocusTrap.ts +1 -14
  198. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  199. package/src/hooks/useInputStatusIcon.tsx +7 -0
  200. package/src/index.ts +1 -0
  201. package/src/theme/defineTheme.test.ts +127 -0
  202. package/src/theme/defineTheme.ts +70 -55
  203. package/src/theme/expandColorScale.test.ts +138 -0
  204. package/src/theme/expandColorScale.ts +89 -44
  205. package/src/theme/extensibleAxes.test.ts +365 -0
  206. package/src/theme/mergeComponents.ts +59 -0
  207. package/src/theme/onMediaTokens.ts +9 -2
  208. package/src/utils/characters.test.ts +141 -0
  209. package/src/utils/characters.ts +88 -0
  210. package/src/utils/ime.test.ts +32 -0
  211. package/src/utils/ime.ts +69 -0
  212. package/src/utils/index.ts +4 -0
  213. package/dist/astryx.umd.js +0 -147
  214. package/dist/astryx.umd.js.map +0 -7
@@ -455,4 +455,112 @@ describe('Banner', () => {
455
455
  );
456
456
  });
457
457
  });
458
+
459
+ describe('dismiss focus handoff', () => {
460
+ it('returns focus to where it came from instead of dropping it to body', async () => {
461
+ const user = userEvent.setup();
462
+ render(
463
+ <>
464
+ <button type="button">Before</button>
465
+ <Banner status="info" title="Heads up" isDismissable />
466
+ </>,
467
+ );
468
+ const before = screen.getByRole('button', {name: 'Before'});
469
+ before.focus();
470
+
471
+ await user.tab();
472
+ expect(screen.getByRole('button', {name: 'Dismiss'})).toHaveFocus();
473
+
474
+ await user.keyboard('{Enter}');
475
+
476
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
477
+ expect(before).toHaveFocus();
478
+ expect(document.activeElement).not.toBe(document.body);
479
+ });
480
+
481
+ it('leaves focus alone when it never entered the banner', async () => {
482
+ const user = userEvent.setup();
483
+ render(
484
+ <>
485
+ <button type="button">Elsewhere</button>
486
+ <Banner status="info" title="Heads up" isDismissable />
487
+ </>,
488
+ );
489
+ await user.click(screen.getByRole('button', {name: 'Dismiss'}));
490
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
491
+ });
492
+ });
493
+
494
+ describe('empty slots', () => {
495
+ it('does not show the expand affordance for children that render nothing', () => {
496
+ render(
497
+ <Banner status="info" title="Heads up">
498
+ {false}
499
+ </Banner>,
500
+ );
501
+ expect(
502
+ screen.queryByRole('button', {name: 'Expand'}),
503
+ ).not.toBeInTheDocument();
504
+ });
505
+
506
+ it('still shows the expand affordance for real children', () => {
507
+ render(
508
+ <Banner status="info" title="Heads up">
509
+ <p>Detail</p>
510
+ </Banner>,
511
+ );
512
+ expect(
513
+ screen.getByRole('button', {name: 'Expand'}),
514
+ ).toBeInTheDocument();
515
+ });
516
+
517
+ it('renders no description node for a description that renders nothing', () => {
518
+ const {container} = render(
519
+ <Banner status="info" title="Heads up" description="" />,
520
+ );
521
+ const header = container.firstElementChild!.firstElementChild!;
522
+ // icon wrapper + text column, and the text column holds the title alone
523
+ expect(header.children[1].children).toHaveLength(1);
524
+ });
525
+ });
526
+
527
+ // jsdom does no flex layout, so these read the declarations that produce the
528
+ // wrap. The rendered result is verified in Chromium at 320/375/480/768.
529
+ describe('narrow-viewport wrapping', () => {
530
+ const renderBanner = (endContent?: React.ReactNode) => {
531
+ const {container} = render(
532
+ <Banner
533
+ status="warning"
534
+ title="A compute node is required"
535
+ endContent={endContent}
536
+ />,
537
+ );
538
+ const header = container.firstElementChild!.firstElementChild!;
539
+ return {
540
+ header,
541
+ textColumn: screen.getByText('A compute node is required')
542
+ .parentElement!,
543
+ };
544
+ };
545
+
546
+ it('lets the header wrap so the end area can take its own row', () => {
547
+ const {header} = renderBanner(<button type="button">Retry</button>);
548
+ expect(getComputedStyle(header).flexWrap).toBe('wrap');
549
+ });
550
+
551
+ it('gives the text column a wrap threshold when endContent is present', () => {
552
+ const {textColumn} = renderBanner(<button type="button">Retry</button>);
553
+ expect(getComputedStyle(textColumn).flexBasis).toBe('8rem');
554
+ });
555
+
556
+ it('leaves the text column free to shrink when there is no endContent', () => {
557
+ const {textColumn} = renderBanner();
558
+ expect(getComputedStyle(textColumn).flexBasis).not.toBe('8rem');
559
+ });
560
+
561
+ it('leaves it free for an endContent that renders nothing', () => {
562
+ const {textColumn} = renderBanner(false);
563
+ expect(getComputedStyle(textColumn).flexBasis).not.toBe('8rem');
564
+ });
565
+ });
458
566
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Banner.tsx
7
- * @input Uses React useState, Button, Icon (with registry string names), StyleX
7
+ * @input Uses React useState/useRef/useId, Button, Icon (with registry string names), StyleX
8
8
  * @output Exports Banner component, BannerProps, BannerStatus, BannerContainer types
9
9
  * @position Core implementation; consumed by index.ts, tested by Banner.test.tsx
10
10
  *
@@ -19,6 +19,10 @@
19
19
  * - Each visual area owns its own border-radius (no overflow:clip on the container)
20
20
  * - When children are provided, a collapse/expand toggle button appears in the end area
21
21
  *
22
+ * A status added through `BannerStatusMap` augmentation has no entry in the
23
+ * status lookups, so it renders with no status fill, no default glyph and the
24
+ * polite `role="status"` rather than losing its ARIA role entirely.
25
+ *
22
26
  * Title and description render as <div> (not <p>): they accept arbitrary
23
27
  * ReactNode content, and <p> cannot legally contain block-level children
24
28
  * (the HTML parser reparents them, desyncing SSR markup from the hydrated
@@ -34,12 +38,12 @@
34
38
  * - /packages/cli/assets/templates/blocks/components/Banner/ (showcase blocks)
35
39
  */
36
40
 
37
- import {useId, useState, type ReactNode} from 'react';
41
+ import {useId, useRef, useState, type ReactNode} from 'react';
38
42
  import * as stylex from '@stylexjs/stylex';
39
43
  import type {BaseProps} from '../BaseProps';
40
44
  import {Button} from '../Button';
41
45
  import {Icon} from '../Icon';
42
- import type {IconName} from '../Icon';
46
+ import type {IconName, IconColor} from '../Icon';
43
47
  import {
44
48
  colorVars,
45
49
  spacingVars,
@@ -51,7 +55,7 @@ import {
51
55
  easeVars,
52
56
  shadowVars,
53
57
  } from '../theme/tokens.stylex';
54
- import {mergeProps} from '../utils';
58
+ import {composeEventHandlers, isRenderable, mergeProps} from '../utils';
55
59
  import type {Elevation} from '../utils/types';
56
60
  import {edgeCompSlot} from '../Layout/edgeCompensation.stylex';
57
61
  import {themeProps} from '../utils/themeProps';
@@ -112,6 +116,9 @@ export interface BannerProps extends BaseProps<HTMLDivElement> {
112
116
  * Action button rendered in the header area (end-aligned).
113
117
  * Typically an Button with a secondary or ghost variant.
114
118
  *
119
+ * When the header is too narrow to hold the actions and a readable text
120
+ * column, the whole end area wraps to its own row below the text.
121
+ *
115
122
  * @example
116
123
  * ```
117
124
  * endContent={<Button label="Retry" variant="ghost" onClick={handleRetry} />}
@@ -147,37 +154,38 @@ export interface BannerProps extends BaseProps<HTMLDivElement> {
147
154
  }
148
155
 
149
156
  // =============================================================================
150
- // Status → Icon mapping
157
+ // Status lookups
151
158
  // =============================================================================
152
159
 
153
- const defaultIconNames: Record<BannerStatus, IconName> = {
160
+ // `BannerStatus` is `keyof BannerStatusMap`, and index.ts documents augmenting
161
+ // that interface to add a status. Every lookup below is therefore partial: an
162
+ // augmented status the library has never heard of falls through to the base
163
+ // treatment (no status fill, no glyph, the polite role) instead of resolving to
164
+ // `undefined` and dropping the ARIA role along with it.
165
+
166
+ const defaultIconNames: Partial<Record<BannerStatus, IconName>> = {
154
167
  info: 'info',
155
168
  warning: 'warning',
156
169
  error: 'error',
157
170
  success: 'success',
158
171
  };
159
172
 
160
- // =============================================================================
161
- // Status → ARIA role mapping
162
- // =============================================================================
163
-
164
- const statusRole: Record<BannerStatus, 'alert' | 'status'> = {
173
+ const statusRole: Partial<Record<BannerStatus, 'alert' | 'status'>> = {
165
174
  info: 'status',
166
175
  warning: 'alert',
167
176
  error: 'alert',
168
177
  success: 'status',
169
178
  };
170
179
 
171
- // =============================================================================
172
- // Status → Icon color mapping
173
- // =============================================================================
180
+ /** An unknown status is not urgent by definition, so it announces politely. */
181
+ const FALLBACK_ROLE = 'status';
174
182
 
175
- const statusIconColor = {
183
+ const statusIconColor: Partial<Record<BannerStatus, IconColor>> = {
176
184
  info: 'accent',
177
185
  warning: 'warning',
178
186
  error: 'error',
179
187
  success: 'success',
180
- } as const;
188
+ };
181
189
 
182
190
  // =============================================================================
183
191
  // Styles
@@ -193,24 +201,28 @@ const styles = stylex.create({
193
201
  // When elevated, a card-container banner rounds its root so the shadow
194
202
  // follows the same silhouette as the header/content border-radius.
195
203
  rootElevatedCard: {
196
- borderRadius: radiusVars['--radius-container'],
204
+ borderRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
197
205
  },
198
206
  // Header area — colored status background with icon, title, description, actions
199
207
  // This is the primary theme target ('banner')
200
208
  header: {
201
209
  display: 'flex',
202
210
  alignItems: 'flex-start',
203
- gap: spacingVars['--spacing-2'],
211
+ flexWrap: 'wrap',
212
+ columnGap: spacingVars['--spacing-2'],
213
+ // The end area carries a -4px block margin (see endArea), so the wrapped
214
+ // row reads as one step of spacing rather than two.
215
+ rowGap: spacingVars['--spacing-3'],
204
216
  paddingBlock: spacingVars['--spacing-3'],
205
217
  paddingInline: spacingVars['--spacing-4'],
206
218
  },
207
219
  // Border-radius for card container: all corners when standalone, top-only when content is visible
208
220
  headerCardStandalone: {
209
- borderRadius: radiusVars['--radius-container'],
221
+ borderRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
210
222
  },
211
223
  headerCardWithContent: {
212
- borderStartStartRadius: radiusVars['--radius-container'],
213
- borderStartEndRadius: radiusVars['--radius-container'],
224
+ borderStartStartRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
225
+ borderStartEndRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
214
226
  borderEndStartRadius: 0,
215
227
  borderEndEndRadius: 0,
216
228
  },
@@ -226,6 +238,15 @@ const styles = stylex.create({
226
238
  flex: 1,
227
239
  minWidth: 0,
228
240
  },
241
+ // The wrap threshold, applied only when there is `endContent` to wrap. Flex
242
+ // breaks a line when the items no longer fit at their base size, so this
243
+ // basis is what moves the end area to its own row instead of letting it hold
244
+ // the header and squeeze the title down to one word per line. In rem so it
245
+ // tracks the user's font size. The dismiss and expand controls alone are
246
+ // narrow enough never to need it.
247
+ headerContentWithEndContent: {
248
+ flexBasis: '8rem',
249
+ },
229
250
  title: {
230
251
  margin: 0,
231
252
  fontFamily: 'inherit',
@@ -233,6 +254,10 @@ const styles = stylex.create({
233
254
  fontWeight: fontWeightVars['--font-weight-semibold'],
234
255
  lineHeight: typeScaleVars['--text-label-leading'],
235
256
  color: colorVars['--color-text-primary'],
257
+ // A single unbroken token (a URL, an ID, a German compound) otherwise sets
258
+ // the flex item's min-content width and pushes the page into horizontal
259
+ // scrolling at 320px, which is a WCAG 1.4.10 reflow failure.
260
+ overflowWrap: 'anywhere',
236
261
  },
237
262
  description: {
238
263
  margin: 0,
@@ -241,6 +266,7 @@ const styles = stylex.create({
241
266
  fontWeight: fontWeightVars['--font-weight-normal'],
242
267
  lineHeight: typeScaleVars['--text-supporting-leading'],
243
268
  color: colorVars['--color-text-secondary'],
269
+ overflowWrap: 'anywhere',
244
270
  },
245
271
  iconWrapper: {
246
272
  display: 'flex',
@@ -250,8 +276,13 @@ const styles = stylex.create({
250
276
  endArea: {
251
277
  display: 'flex',
252
278
  alignItems: 'center',
279
+ flexWrap: 'wrap',
280
+ justifyContent: 'flex-end',
253
281
  gap: spacingVars['--spacing-2'],
254
282
  flexShrink: 0,
283
+ // Bounded so a group of actions wider than the row wraps within itself
284
+ // rather than pushing the banner past the viewport (WCAG 1.4.10).
285
+ maxWidth: '100%',
255
286
  marginInlineStart: 'auto',
256
287
  marginBlock: `calc(-1 * (${spacingVars['--spacing-3']} - ${spacingVars['--spacing-2']}))`,
257
288
  },
@@ -262,17 +293,17 @@ const styles = stylex.create({
262
293
  paddingInline: spacingVars['--spacing-4'],
263
294
  borderInlineStartWidth: borderVars['--border-width'],
264
295
  borderInlineEndWidth: borderVars['--border-width'],
265
- borderBottomWidth: borderVars['--border-width'],
296
+ borderBlockEndWidth: borderVars['--border-width'],
266
297
  borderInlineStartStyle: 'solid',
267
298
  borderInlineEndStyle: 'solid',
268
- borderBottomStyle: 'solid',
299
+ borderBlockEndStyle: 'solid',
269
300
  borderInlineStartColor: colorVars['--color-border'],
270
301
  borderInlineEndColor: colorVars['--color-border'],
271
- borderBottomColor: colorVars['--color-border'],
302
+ borderBlockEndColor: colorVars['--color-border'],
272
303
  },
273
304
  contentAreaCard: {
274
- borderEndStartRadius: radiusVars['--radius-container'],
275
- borderEndEndRadius: radiusVars['--radius-container'],
305
+ borderEndStartRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
306
+ borderEndEndRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
276
307
  },
277
308
  // Applied to the chevron <Icon> itself (via `xstyle`) rather than a wrapper,
278
309
  // so the element that rotates is the element a theme targets.
@@ -304,6 +335,17 @@ const statusStyles = stylex.create({
304
335
  },
305
336
  });
306
337
 
338
+ /**
339
+ * Narrows an augmented `BannerStatus` to one the style map actually carries,
340
+ * so an unknown status renders with no status fill rather than crashing the
341
+ * lookup. StyleX style maps cannot be declared `Partial`, hence the guard.
342
+ */
343
+ function hasStatusStyle(
344
+ status: BannerStatus,
345
+ ): status is BannerStatus & keyof typeof statusStyles {
346
+ return Object.prototype.hasOwnProperty.call(statusStyles, status);
347
+ }
348
+
307
349
  // Resting elevation for the banner. Applied to the root so the shadow wraps
308
350
  // the whole banner (header + optional content). 'none' is the default and
309
351
  // leaves the layout-only root untouched. For the `card` container the root is
@@ -381,28 +423,63 @@ export function Banner({
381
423
  className,
382
424
  style,
383
425
  ref,
426
+ onFocusCapture,
427
+ onPointerDownCapture,
384
428
  ...rest
385
429
  }: BannerProps) {
386
430
  const t = useTranslator();
387
431
  const [isDismissed, setIsDismissed] = useState(false);
388
432
  const [isExpanded, setIsExpanded] = useState(defaultIsExpanded);
433
+ // The element focus came from before it entered the banner. Dismissing
434
+ // unmounts the whole banner, dismiss button included, so without a handoff
435
+ // the browser drops focus to <body> and a keyboard user loses their place.
436
+ // ToastViewport makes the same handoff when a focused toast is dismissed.
437
+ const focusOriginRef = useRef<HTMLElement | null>(null);
389
438
  // Links the expand/collapse toggle to the content region it shows/hides so
390
439
  // assistive tech can move from the button to its controlled content
391
440
  // (disclosure pattern). The region is conditionally rendered, so aria-controls
392
441
  // below is set only while it's mounted to avoid a dangling reference.
393
442
  const contentId = useId();
394
443
  const defaultIconName = defaultIconNames[status];
395
- const role = statusRole[status];
444
+ const role = statusRole[status] ?? FALLBACK_ROLE;
396
445
  const iconColor = statusIconColor[status];
397
- const hasChildren = children != null;
446
+ const hasChildren = isRenderable(children);
398
447
 
399
448
  if (isDismissed) {
400
449
  return null;
401
450
  }
402
451
 
452
+ // `focusin` reports the element focus came *from* as relatedTarget; on
453
+ // pointerdown focus has not moved yet, so document.activeElement is it.
454
+ const rememberFocusOrigin = (candidate: EventTarget | null, root: Node) => {
455
+ if (
456
+ candidate instanceof HTMLElement &&
457
+ candidate !== document.body &&
458
+ !root.contains(candidate)
459
+ ) {
460
+ focusOriginRef.current = candidate;
461
+ }
462
+ };
463
+
464
+ const handleFocusCapture = (event: React.FocusEvent<HTMLDivElement>) => {
465
+ rememberFocusOrigin(event.relatedTarget, event.currentTarget);
466
+ };
467
+
468
+ const handlePointerDownCapture = (
469
+ event: React.PointerEvent<HTMLDivElement>,
470
+ ) => {
471
+ rememberFocusOrigin(document.activeElement, event.currentTarget);
472
+ };
473
+
403
474
  const handleDismiss = () => {
475
+ const origin = focusOriginRef.current;
404
476
  setIsDismissed(true);
405
477
  onDismiss?.();
478
+ // Move focus before React removes the subtree, so the browser never has a
479
+ // frame where the focused node is gone.
480
+ if (origin?.isConnected) {
481
+ origin.focus();
482
+ }
406
483
  };
407
484
 
408
485
  const handleToggleExpand = () => {
@@ -410,11 +487,11 @@ export function Banner({
410
487
  };
411
488
 
412
489
  // Show the end area if there are actions, dismiss, or a collapsible toggle
413
- const showEndArea = endContent != null || isDismissable || hasChildren;
490
+ const showEndArea = isRenderable(endContent) || isDismissable || hasChildren;
414
491
  // Center items vertically when there's only a title (no description)
415
492
  // and the banner has action buttons
416
- const hasActions = endContent != null || isDismissable;
417
- const isSingleLine = description == null && hasActions;
493
+ const hasActions = isRenderable(endContent) || isDismissable;
494
+ const isSingleLine = !isRenderable(description) && hasActions;
418
495
 
419
496
  const showContent = hasChildren && isExpanded;
420
497
  const isCard = container === 'card';
@@ -423,6 +500,11 @@ export function Banner({
423
500
  <div
424
501
  ref={ref}
425
502
  role={role}
503
+ onFocusCapture={composeEventHandlers(onFocusCapture, handleFocusCapture)}
504
+ onPointerDownCapture={composeEventHandlers(
505
+ onPointerDownCapture,
506
+ handlePointerDownCapture,
507
+ )}
426
508
  {...mergeProps(
427
509
  stylex.props(
428
510
  styles.root,
@@ -441,7 +523,7 @@ export function Banner({
441
523
  stylex.props(
442
524
  styles.header,
443
525
  isSingleLine && styles.headerCentered,
444
- statusStyles[status],
526
+ hasStatusStyle(status) && statusStyles[status],
445
527
  isCard &&
446
528
  (showContent
447
529
  ? styles.headerCardWithContent
@@ -454,16 +536,16 @@ export function Banner({
454
536
  overrides reach it via inheritance). The wrapper itself stays
455
537
  layout-only. */}
456
538
  <div
457
- {...(icon != null
539
+ {...(isRenderable(icon)
458
540
  ? mergeProps(
459
541
  themeProps('banner-icon', {status}),
460
542
  stylex.props(styles.iconWrapper),
461
543
  )
462
544
  : stylex.props(styles.iconWrapper))}
463
545
  aria-hidden="true">
464
- {icon != null ? (
546
+ {isRenderable(icon) ? (
465
547
  icon
466
- ) : (
548
+ ) : defaultIconName != null ? (
467
549
  // Applied to the status <Icon> itself rather than the wrapper, so
468
550
  // the element that paints the glyph is the element a theme
469
551
  // targets — a 'banner-icon' 'status:X' color override beats the
@@ -474,11 +556,15 @@ export function Banner({
474
556
  color={iconColor}
475
557
  {...themeProps('banner-icon', {status})}
476
558
  />
477
- )}
559
+ ) : null}
478
560
  </div>
479
- <div {...stylex.props(styles.headerContent)}>
561
+ <div
562
+ {...stylex.props(
563
+ styles.headerContent,
564
+ isRenderable(endContent) && styles.headerContentWithEndContent,
565
+ )}>
480
566
  <div {...stylex.props(styles.title)}>{title}</div>
481
- {description != null && (
567
+ {isRenderable(description) && (
482
568
  <div {...stylex.props(styles.description)}>{description}</div>
483
569
  )}
484
570
  </div>
@@ -11,19 +11,6 @@
11
11
  * SYNC: When modified, update this header and /packages/core/src/Banner/Banner.doc.mjs
12
12
  */
13
13
 
14
- /**
15
- * Extensible status map for Banner.
16
- *
17
- * Theme packages can add custom statuses via TypeScript module augmentation:
18
- * @example
19
- * ```
20
- * declare module '@astryxdesign/core/Banner' {
21
- * interface BannerStatusMap {
22
- * 'neutral': true;
23
- * }
24
- * }
25
- * ```
26
- */
27
14
  /**
28
15
  * Extensible container map for Banner.
29
16
  *
@@ -42,6 +29,21 @@ export interface BannerContainerMap {
42
29
  section: true;
43
30
  }
44
31
 
32
+ /**
33
+ * Extensible status map for Banner.
34
+ *
35
+ * Theme packages can add custom statuses via TypeScript module augmentation.
36
+ * A status the library does not know falls through to the base treatment: no
37
+ * status fill, no default glyph, and `role="status"`.
38
+ * @example
39
+ * ```
40
+ * declare module '@astryxdesign/core/Banner' {
41
+ * interface BannerStatusMap {
42
+ * 'neutral': true;
43
+ * }
44
+ * }
45
+ * ```
46
+ */
45
47
  export interface BannerStatusMap {
46
48
  info: true;
47
49
  warning: true;