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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  2. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.js +6 -2
  4. package/dist/Button/Button.d.ts +1 -1
  5. package/dist/Button/Button.d.ts.map +1 -1
  6. package/dist/Button/Button.js +4 -2
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +2 -2
  9. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  10. package/dist/CheckboxList/CheckboxListItem.js +19 -4
  11. package/dist/CodeBlock/CodeBlock.js +2 -2
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +10 -1
  14. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +8 -11
  15. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +3 -1
  17. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  18. package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
  19. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
  21. package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
  22. package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
  23. package/dist/DropdownMenu/menuItemHover.js +1 -0
  24. package/dist/Field/FieldLabel.d.ts +2 -1
  25. package/dist/Field/FieldLabel.d.ts.map +1 -1
  26. package/dist/Field/FieldLabel.js +7 -3
  27. package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
  28. package/dist/FieldStatus/FieldStatus.js +12 -2
  29. package/dist/Item/Item.d.ts +16 -3
  30. package/dist/Item/Item.d.ts.map +1 -1
  31. package/dist/Item/Item.js +31 -6
  32. package/dist/List/ListItem.d.ts +11 -1
  33. package/dist/List/ListItem.d.ts.map +1 -1
  34. package/dist/List/ListItem.js +2 -0
  35. package/dist/MetadataList/MetadataList.d.ts +1 -1
  36. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  37. package/dist/MetadataList/MetadataList.js +4 -2
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.js +14 -3
  40. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  41. package/dist/RadioList/RadioListItem.js +1 -1
  42. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControlItem.js +3 -2
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +5 -0
  46. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  47. package/dist/Skeleton/Skeleton.js +2 -2
  48. package/dist/Switch/Switch.d.ts.map +1 -1
  49. package/dist/Switch/Switch.js +15 -4
  50. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  51. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
  52. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  53. package/dist/Thumbnail/Thumbnail.js +42 -43
  54. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  55. package/dist/Toast/ToastViewport.js +61 -9
  56. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  57. package/dist/ToggleButton/ToggleButton.js +3 -1
  58. package/dist/Token/Token.d.ts +4 -1
  59. package/dist/Token/Token.d.ts.map +1 -1
  60. package/dist/Token/Token.js +68 -43
  61. package/dist/Token/TokenLink.d.ts +62 -0
  62. package/dist/Token/TokenLink.d.ts.map +1 -0
  63. package/dist/Token/TokenLink.js +76 -0
  64. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavMegaMenu.js +9 -3
  66. package/dist/Typeahead/BaseTypeahead.d.ts +9 -0
  67. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  68. package/dist/Typeahead/BaseTypeahead.js +2 -0
  69. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/Typeahead.js +8 -1
  71. package/dist/astryx.css +51 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
  75. package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
  76. package/dist/hooks/containerReveal.pool.stylex.js +281 -0
  77. package/dist/hooks/index.d.ts +2 -0
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +2 -1
  80. package/dist/hooks/useContainerReveal.d.ts +83 -0
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -0
  82. package/dist/hooks/useContainerReveal.js +123 -0
  83. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  84. package/dist/hooks/useInputStatusIcon.js +11 -0
  85. package/dist/hooks/useKeyboardHint.d.ts +1 -1
  86. package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
  87. package/dist/hooks/useKeyboardHint.js +4 -2
  88. package/locales/en.json +32 -0
  89. package/locales/pseudo.json +24 -0
  90. package/package.json +1 -1
  91. package/src/AvatarGroup/AvatarGroup.test.tsx +21 -1
  92. package/src/AvatarGroup/AvatarGroupOverflow.tsx +4 -2
  93. package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
  94. package/src/Button/Button.test.tsx +16 -0
  95. package/src/Button/Button.tsx +4 -2
  96. package/src/CheckboxInput/CheckboxInput.test.tsx +23 -0
  97. package/src/CheckboxInput/CheckboxInput.tsx +17 -2
  98. package/src/CheckboxList/CheckboxList.test.tsx +94 -3
  99. package/src/CheckboxList/CheckboxListItem.tsx +17 -10
  100. package/src/CodeBlock/CodeBlock.test.tsx +17 -0
  101. package/src/CodeBlock/CodeBlock.tsx +2 -2
  102. package/src/ContextMenu/ContextMenu.test.tsx +17 -2
  103. package/src/ContextMenu/ContextMenu.tsx +10 -1
  104. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +4 -12
  105. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +9 -10
  106. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  107. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +20 -2
  108. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
  109. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
  110. package/src/DropdownMenu/menuItemHover.ts +1 -0
  111. package/src/Field/Field.doc.mjs +4 -0
  112. package/src/Field/FieldLabel.test.tsx +25 -0
  113. package/src/Field/FieldLabel.tsx +11 -2
  114. package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
  115. package/src/FieldStatus/FieldStatus.test.tsx +61 -0
  116. package/src/FieldStatus/FieldStatus.tsx +14 -3
  117. package/src/Item/Item.doc.mjs +5 -0
  118. package/src/Item/Item.test.tsx +88 -0
  119. package/src/Item/Item.tsx +57 -11
  120. package/src/List/ListItem.doc.mjs +12 -0
  121. package/src/List/ListItem.tsx +13 -0
  122. package/src/MetadataList/MetadataList.test.tsx +24 -0
  123. package/src/MetadataList/MetadataList.tsx +6 -2
  124. package/src/MultiSelector/MultiSelector.test.tsx +76 -0
  125. package/src/MultiSelector/MultiSelector.tsx +28 -7
  126. package/src/RadioList/RadioList.test.tsx +26 -4
  127. package/src/RadioList/RadioListItem.tsx +8 -1
  128. package/src/SegmentedControl/SegmentedControl.test.tsx +28 -0
  129. package/src/SegmentedControl/SegmentedControlItem.tsx +20 -2
  130. package/src/Selector/Selector.test.tsx +22 -0
  131. package/src/Selector/Selector.tsx +8 -1
  132. package/src/Skeleton/Skeleton.test.tsx +16 -0
  133. package/src/Skeleton/Skeleton.tsx +13 -1
  134. package/src/Switch/Switch.test.tsx +37 -0
  135. package/src/Switch/Switch.tsx +52 -3
  136. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +19 -0
  137. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +3 -1
  138. package/src/Thumbnail/Thumbnail.tsx +18 -32
  139. package/src/Toast/ToastViewport.test.tsx +218 -8
  140. package/src/Toast/ToastViewport.tsx +71 -12
  141. package/src/ToggleButton/ToggleButton.test.tsx +24 -0
  142. package/src/ToggleButton/ToggleButton.tsx +19 -1
  143. package/src/Token/Token.test.tsx +145 -0
  144. package/src/Token/Token.tsx +73 -33
  145. package/src/Token/TokenLink.tsx +107 -0
  146. package/src/TopNav/TopNavMegaMenu.tsx +26 -2
  147. package/src/Typeahead/BaseTypeahead.tsx +12 -0
  148. package/src/Typeahead/Typeahead.test.tsx +62 -0
  149. package/src/Typeahead/Typeahead.tsx +6 -0
  150. package/src/__tests__/forcedColors.ts +60 -0
  151. package/src/hooks/containerReveal.pool.stylex.ts +464 -0
  152. package/src/hooks/index.ts +7 -0
  153. package/src/hooks/useContainerReveal.test.tsx +116 -0
  154. package/src/hooks/useContainerReveal.ts +180 -0
  155. package/src/hooks/useInputStatusIcon.test.tsx +59 -0
  156. package/src/hooks/useInputStatusIcon.tsx +12 -1
  157. package/src/hooks/useKeyboardHint.test.tsx +22 -0
  158. package/src/hooks/useKeyboardHint.tsx +6 -2
  159. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
  160. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
  161. package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
  162. package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
@@ -9,6 +9,7 @@
9
9
  * @position Core implementation; consumed by index.ts, tested by Token.test.tsx
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
+ * - /packages/core/src/Token/TokenLink.tsx (interactive wrapper for href+onRemove)
12
13
  * - /packages/core/src/Token/Token.doc.mjs (props table, features, implementation notes)
13
14
  * - /packages/core/src/Token/Token.test.tsx (tests for new/changed behavior)
14
15
  * - /packages/core/src/Token/index.ts (exports if types change)
@@ -32,6 +33,7 @@ import {Icon} from '../Icon';
32
33
  import {mergeProps} from '../utils';
33
34
  import {useLinkComponent} from '../Link/useLinkComponent';
34
35
  import {useInteractiveRole} from '../hooks/useInteractiveRole';
36
+ import {TokenLink} from './TokenLink';
35
37
  import type {BaseProps} from '../BaseProps';
36
38
  import {themeProps} from '../utils/themeProps';
37
39
  import {useTranslator} from '../i18n';
@@ -313,7 +315,9 @@ const colorStyles = stylex.create({
313
315
  * `<span>` container with an invisible `<button>` when `onClick` is provided.
314
316
  * The invisible button pattern provides real button semantics for accessibility
315
317
  * while the container uses `:focus-within` to show focus outlines around the
316
- * entire token.
318
+ * entire token. When both `href` and `onRemove` are provided, the same
319
+ * container pattern is used with an invisible `<a>` so the remove `<button>`
320
+ * renders as a sibling of the link rather than nested inside it.
317
321
  *
318
322
  * @example
319
323
  * ```
@@ -350,6 +354,20 @@ export function Token({
350
354
  // if onClick was provided — the popover attaches its own handler.
351
355
  const effectiveOnClick = onClick ?? (role === 'button' ? () => {} : null);
352
356
 
357
+ const removeButton = onRemove != null && (
358
+ <button
359
+ type="button"
360
+ aria-label={t('@astryx.token.remove', {label})}
361
+ onClick={e => {
362
+ e.stopPropagation();
363
+ onRemove(e);
364
+ }}
365
+ disabled={isDisabled}
366
+ {...stylex.props(styles.removeButton)}>
367
+ <Icon icon="close" size="xsm" color="inherit" />
368
+ </button>
369
+ );
370
+
353
371
  const content = (
354
372
  <>
355
373
  {icon}
@@ -358,19 +376,7 @@ export function Token({
358
376
  {label}
359
377
  </span>
360
378
  {endContent}
361
- {onRemove != null && (
362
- <button
363
- type="button"
364
- aria-label={t('@astryx.token.remove', {label})}
365
- onClick={e => {
366
- e.stopPropagation();
367
- onRemove(e);
368
- }}
369
- disabled={isDisabled}
370
- {...stylex.props(styles.removeButton)}>
371
- <Icon icon="close" size="xsm" color="inherit" />
372
- </button>
373
- )}
379
+ {removeButton}
374
380
  </>
375
381
  );
376
382
 
@@ -381,11 +387,57 @@ export function Token({
381
387
  };
382
388
 
383
389
  if (role === 'link') {
390
+ if (onRemove == null) {
391
+ return (
392
+ <LinkComponent
393
+ ref={ref as React.Ref<HTMLAnchorElement>}
394
+ href={href as string}
395
+ aria-disabled={isDisabled || undefined}
396
+ {...sharedProps}
397
+ {...mergeProps(
398
+ themeProps('token', {color, size}),
399
+ stylex.props(
400
+ styles.base,
401
+ sizeStyles[size],
402
+ colorStyles[color],
403
+ styles.interactive,
404
+ isDisabled && styles.disabled,
405
+ xstyle,
406
+ ),
407
+ className,
408
+ style,
409
+ )}>
410
+ {content}
411
+ </LinkComponent>
412
+ );
413
+ }
414
+
415
+ // With a remove button, the link cannot be the root — nesting a <button>
416
+ // inside an <a> is invalid HTML (WCAG 4.1.2). Delegate to TokenLink, a
417
+ // non-presentational wrapper that renders a <span> container with the link
418
+ // and the remove button as siblings and wires up useClickableContainer so
419
+ // the whole token surface activates the link (including middle-click and
420
+ // cmd/ctrl-click to open in a new tab). Token itself stays presentational
421
+ // (no refs/effects) per the @astryx/presentational-component rule.
384
422
  return (
385
- <LinkComponent
386
- ref={ref as React.Ref<HTMLAnchorElement>}
423
+ <TokenLink
424
+ ref={ref}
387
425
  href={href as string}
388
- aria-disabled={isDisabled || undefined}
426
+ isDisabled={isDisabled}
427
+ LinkComponent={LinkComponent}
428
+ icon={icon}
429
+ endContent={endContent}
430
+ removeButton={removeButton}
431
+ linkStyleProps={stylex.props(styles.invisibleButton)}
432
+ labelContent={
433
+ <span
434
+ {...stylex.props(
435
+ styles.label,
436
+ isLabelHidden && styles.labelHidden,
437
+ )}>
438
+ {label}
439
+ </span>
440
+ }
389
441
  {...sharedProps}
390
442
  {...mergeProps(
391
443
  themeProps('token', {color, size}),
@@ -394,14 +446,14 @@ export function Token({
394
446
  sizeStyles[size],
395
447
  colorStyles[color],
396
448
  styles.interactive,
449
+ styles.focusVisibleOutline,
397
450
  isDisabled && styles.disabled,
398
451
  xstyle,
399
452
  ),
400
453
  className,
401
454
  style,
402
- )}>
403
- {content}
404
- </LinkComponent>
455
+ )}
456
+ />
405
457
  );
406
458
  }
407
459
 
@@ -448,19 +500,7 @@ export function Token({
448
500
  </span>
449
501
  </button>
450
502
  {endContent}
451
- {onRemove != null && (
452
- <button
453
- type="button"
454
- aria-label={t('@astryx.token.remove', {label})}
455
- onClick={e => {
456
- e.stopPropagation();
457
- onRemove(e);
458
- }}
459
- disabled={isDisabled}
460
- {...stylex.props(styles.removeButton)}>
461
- <Icon icon="close" size="xsm" color="inherit" />
462
- </button>
463
- )}
503
+ {removeButton}
464
504
  </span>
465
505
  );
466
506
  }
@@ -0,0 +1,107 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file TokenLink.tsx
7
+ * @input Rendered token pieces (icon, label, endContent, remove button) plus
8
+ * container/link styling props and the resolved LinkComponent
9
+ * @output Exports TokenLink, the interactive container for the href+onRemove Token variant
10
+ * @position Internal to Token; keeps Token.tsx presentational
11
+ *
12
+ * Token is a presentational component (see
13
+ * /internal/eslint-plugin-astryx/presentational-component.js) and must not use
14
+ * refs or effects. When a Token has both `href` and `onRemove`, the remove
15
+ * <button> must be a sibling of the link (nesting a button inside an anchor is
16
+ * invalid HTML — WCAG 4.1.2), so the surface becomes a clickable container that
17
+ * delegates to an inner link. That delegation needs refs, so it lives here.
18
+ *
19
+ * useClickableContainer gives the container middle-click and cmd/ctrl-click
20
+ * open-in-new-tab behavior for the link, ignores clicks on nested interactive
21
+ * elements (the remove button), and skips activation during text selection.
22
+ *
23
+ * SYNC: When modified, update these files to stay in sync:
24
+ * - /packages/core/src/Token/Token.tsx (renders TokenLink for the href+onRemove case)
25
+ * - /packages/core/src/Token/Token.test.tsx (tests for delegated behavior)
26
+ */
27
+
28
+ import type {ReactNode, CSSProperties, Ref, HTMLAttributes} from 'react';
29
+ import {useRef} from 'react';
30
+ import {mergeRefs} from '../utils';
31
+ import {useClickableContainer} from '../hooks/useClickableContainer';
32
+ import type {LinkComponentType} from '../Link/types';
33
+
34
+ export interface TokenLinkProps extends HTMLAttributes<HTMLElement> {
35
+ /** Ref forwarded to the container element. */
36
+ ref?: Ref<HTMLElement>;
37
+ /** Link URL. */
38
+ href: string;
39
+ /** Whether the token is disabled. */
40
+ isDisabled: boolean;
41
+ /** Resolved link component (native `<a>` or a router link). */
42
+ LinkComponent: LinkComponentType;
43
+ /** `stylex.props()` output applied to the invisible inner link. */
44
+ linkStyleProps: {className?: string; style?: CSSProperties};
45
+ /** Visible (or visually hidden) label node rendered inside the link. */
46
+ labelContent: ReactNode;
47
+ /** Optional leading icon. */
48
+ icon?: ReactNode;
49
+ /** Content rendered after the label, before the remove button. */
50
+ endContent?: ReactNode;
51
+ /** The remove button node, rendered as a sibling of the link. */
52
+ removeButton: ReactNode;
53
+ /** Allow data-* attributes (e.g. data-testid, theme targets) on the container. */
54
+ [dataAttr: `data-${string}`]: unknown;
55
+ }
56
+
57
+ /**
58
+ * Interactive container for a Token that is both a link and removable.
59
+ *
60
+ * Renders a `<span>` wrapping an invisible inner link and the remove button as
61
+ * siblings. useClickableContainer delegates clicks on the surface to the link
62
+ * (including middle-click / cmd+click to open in a new tab) while the remove
63
+ * button keeps handling its own clicks.
64
+ */
65
+ export function TokenLink({
66
+ ref,
67
+ href,
68
+ isDisabled,
69
+ LinkComponent,
70
+ linkStyleProps,
71
+ labelContent,
72
+ icon,
73
+ endContent,
74
+ removeButton,
75
+ ...containerProps
76
+ }: TokenLinkProps) {
77
+ const containerRef = useRef<HTMLElement | null>(null);
78
+ const linkRef = useRef<HTMLElement | null>(null);
79
+
80
+ const {onClick, onMouseUp} = useClickableContainer({
81
+ containerRef,
82
+ interactiveRef: linkRef,
83
+ href,
84
+ disabled: isDisabled,
85
+ });
86
+
87
+ return (
88
+ <span
89
+ ref={mergeRefs(ref, containerRef)}
90
+ onClick={isDisabled ? undefined : onClick}
91
+ onMouseUp={isDisabled ? undefined : onMouseUp}
92
+ {...containerProps}>
93
+ {icon}
94
+ <LinkComponent
95
+ ref={linkRef as Ref<HTMLAnchorElement>}
96
+ href={href}
97
+ aria-disabled={isDisabled || undefined}
98
+ {...linkStyleProps}>
99
+ {labelContent}
100
+ </LinkComponent>
101
+ {endContent}
102
+ {removeButton}
103
+ </span>
104
+ );
105
+ }
106
+
107
+ TokenLink.displayName = 'TokenLink';
@@ -123,6 +123,18 @@ const styles = stylex.create({
123
123
  transform: 'translateY(-4px)',
124
124
  },
125
125
  },
126
+ // Clamp the anchored layer to the space available below the nav so a tall
127
+ // menu never runs off the bottom of the viewport. The layer is positioned
128
+ // with position-area: self-block-end, so its containing block spans from
129
+ // the nav's block-end to the viewport edge — 100% is exactly that space.
130
+ // The layer is a flex column so panelContainer can shrink and scroll its
131
+ // own content, keeping the surface radius/shadow static at the edges.
132
+ // Internal scroll is a stopgap until the mobile bottom-sheet lands.
133
+ panelViewportFit: {
134
+ display: 'flex',
135
+ flexDirection: 'column',
136
+ maxHeight: `calc(100% - ${spacingVars['--spacing-3']})`,
137
+ },
126
138
  // Visual styles for the panel content container.
127
139
  panelContainer: {
128
140
  backgroundColor: colorVars['--color-background-popover'],
@@ -132,6 +144,11 @@ const styles = stylex.create({
132
144
  borderRadius: radiusVars['--radius-container'],
133
145
  boxShadow: shadowVars['--shadow-low'],
134
146
  overflow: 'hidden',
147
+ // Allow the container to shrink inside the height-clamped layer so its
148
+ // content (panelContent) can scroll rather than overflow the viewport.
149
+ display: 'flex',
150
+ flexDirection: 'column',
151
+ minHeight: 0,
135
152
  },
136
153
  panelContent: {
137
154
  display: 'flex',
@@ -139,7 +156,14 @@ const styles = stylex.create({
139
156
  gap: spacingVars['--spacing-6'],
140
157
  paddingBlock: spacingVars['--spacing-3'],
141
158
  paddingInline: spacingVars['--spacing-3'],
142
- maxWidth: 960,
159
+ // Clamp to the viewport (minus a gutter) so the anchored panel never
160
+ // overflows the screen edge on narrow viewports; caps at 960px otherwise.
161
+ maxWidth: `min(960px, calc(100dvw - ${spacingVars['--spacing-4']}))`,
162
+ boxSizing: 'border-box',
163
+ // Scroll internally when the menu is taller than the available space
164
+ // below the nav (paired with panelViewportFit on the layer).
165
+ overflowY: 'auto',
166
+ overscrollBehavior: 'contain',
143
167
  },
144
168
  menuWrapper: {
145
169
  flexGrow: 2,
@@ -484,7 +508,7 @@ function DefaultMegaMenu({
484
508
  {
485
509
  placement: 'below',
486
510
  alignment: slot,
487
- xstyle: styles.panelAnimation,
511
+ xstyle: [styles.panelAnimation, styles.panelViewportFit],
488
512
  },
489
513
  )}
490
514
  </>
@@ -157,6 +157,16 @@ export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
157
157
  */
158
158
  inputXStyle?: StyleXStyles;
159
159
 
160
+ /**
161
+ * Tab-order override for the input element. Typeahead passes `-1` while
162
+ * its selected-value token is shown: the input is visually collapsed
163
+ * (width 0 / opacity 0) but must stay programmatically focusable for
164
+ * token edit/clear interactions, so removing it from the Tab order is
165
+ * what prevents an invisible tab stop (WCAG 2.4.3 / 2.4.7). The input
166
+ * remains focusable via `.focus()` regardless of this value.
167
+ */
168
+ inputTabIndex?: number;
169
+
160
170
  /**
161
171
  * Ref to the anchor element for dropdown positioning.
162
172
  * The dropdown will be positioned relative to this element.
@@ -316,6 +326,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
316
326
  ariaDescribedBy,
317
327
  ariaLabelledBy,
318
328
  inputXStyle,
329
+ inputTabIndex,
319
330
  anchorRef,
320
331
  onKeyDown: externalOnKeyDown,
321
332
  debounceMs = 150,
@@ -748,6 +759,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
748
759
  aria-describedby={ariaDescribedBy}
749
760
  aria-labelledby={ariaLabelledBy}
750
761
  aria-disabled={isFocusableDisabled ? 'true' : undefined}
762
+ tabIndex={inputTabIndex}
751
763
  value={query}
752
764
  onChange={handleInputChange}
753
765
  onPointerDown={() => {
@@ -737,6 +737,68 @@ describe('Typeahead edit mode', () => {
737
737
  });
738
738
  });
739
739
 
740
+ describe('Typeahead collapsed input tab order', () => {
741
+ it('removes the invisible input from the Tab order while a token is shown', () => {
742
+ render(
743
+ <Typeahead
744
+ label="Fruit"
745
+ searchSource={fruitSource}
746
+ value={fruits[0]}
747
+ onChange={() => {}}
748
+ />,
749
+ );
750
+ // While the token is shown the input is collapsed (width 0 / opacity 0);
751
+ // it must stay programmatically focusable for token interactions but must
752
+ // not be an invisible Tab stop (WCAG 2.4.3 / 2.4.7).
753
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
754
+ });
755
+
756
+ it('Tab from the token skips the invisible input', async () => {
757
+ const user = userEvent.setup();
758
+ render(
759
+ <Typeahead
760
+ label="Fruit"
761
+ searchSource={fruitSource}
762
+ value={fruits[0]}
763
+ onChange={() => {}}
764
+ />,
765
+ );
766
+ // Focus the token's internal button, then Tab away — focus must not land
767
+ // on the visually hidden combobox input.
768
+ const tokenButton = screen.getByRole('button', {name: fruits[0].label});
769
+ tokenButton.focus();
770
+ await user.tab();
771
+ expect(screen.getByRole('combobox')).not.toHaveFocus();
772
+ });
773
+
774
+ it('keeps the input in the Tab order when no token is shown', () => {
775
+ render(
776
+ <Typeahead
777
+ label="Fruit"
778
+ searchSource={fruitSource}
779
+ value={null}
780
+ onChange={() => {}}
781
+ />,
782
+ );
783
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex');
784
+ });
785
+
786
+ it('restores the input to the Tab order in edit mode', () => {
787
+ render(
788
+ <Typeahead
789
+ label="Fruit"
790
+ searchSource={fruitSource}
791
+ value={fruits[0]}
792
+ onChange={() => {}}
793
+ />,
794
+ );
795
+ // Entering edit mode removes the token and uncollapses the input
796
+ const tokenText = screen.getByText(fruits[0].label);
797
+ fireEvent.click(tokenText.closest('div')!);
798
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex');
799
+ });
800
+ });
801
+
740
802
  describe('BaseTypeahead paste behavior', () => {
741
803
  it('pasting text triggers search results like typing', async () => {
742
804
  const user = userEvent.setup();
@@ -467,6 +467,12 @@ export function Typeahead<T extends SearchableItem>({
467
467
  anchorRef={wrapperRef}
468
468
  onKeyDown={handleKeyDown}
469
469
  inputXStyle={showToken ? styles.inputHidden : undefined}
470
+ // While the token is shown the input is collapsed (width 0 /
471
+ // opacity 0) — take it out of the Tab order so keyboard users
472
+ // don't hit an invisible stop (WCAG 2.4.3 / 2.4.7). It stays
473
+ // programmatically focusable: entering edit mode and clearing
474
+ // both refocus it after it uncollapses.
475
+ inputTabIndex={showToken ? -1 : undefined}
470
476
  size={size}
471
477
  />
472
478
  {hasClear && value && !isDisabled && (
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file forcedColors.ts
5
+ * @input Uses the jsdom document (StyleX dev runtime injects rules into it)
6
+ * @output Exports getForcedColorsRules, getAllInjectedCss test helpers
7
+ * @position Shared test utility for asserting forced-colors style output
8
+ *
9
+ * jsdom cannot emulate `@media (forced-colors: active)` rendering, so
10
+ * component tests assert the next-best thing: that the StyleX dev runtime
11
+ * (runtimeInjection in vitest.config.ts) actually injected the forced-colors
12
+ * rules a component relies on for Windows High Contrast support (WCAG 1.4.11).
13
+ * Visual behavior needs manual verification in a forced-colors environment.
14
+ */
15
+
16
+ /**
17
+ * Collects the cssText of every injected CSS rule scoped to
18
+ * `@media (forced-colors: active)`, including rules nested in other
19
+ * conditions. Returns one string for substring assertions.
20
+ */
21
+ export function getForcedColorsRules(): string {
22
+ const chunks: string[] = [];
23
+ for (const sheet of Array.from(document.styleSheets)) {
24
+ let rules: CSSRule[];
25
+ try {
26
+ rules = Array.from(sheet.cssRules);
27
+ } catch {
28
+ continue;
29
+ }
30
+ for (const rule of rules) {
31
+ if (rule.cssText.includes('forced-colors: active')) {
32
+ chunks.push(rule.cssText);
33
+ }
34
+ }
35
+ }
36
+ return chunks.join('\n');
37
+ }
38
+
39
+ /**
40
+ * Collects the cssText of every injected CSS rule, regardless of condition.
41
+ * Use to assert declarations that live OUTSIDE `@media (forced-colors: active)`
42
+ * yet still exist for forced-colors support — e.g. `forced-color-adjust: none`
43
+ * (an unconditional declaration) or a hover tint gated behind
44
+ * `(forced-colors: none)` so it cannot override the forced-colors state.
45
+ */
46
+ export function getAllInjectedCss(): string {
47
+ const chunks: string[] = [];
48
+ for (const sheet of Array.from(document.styleSheets)) {
49
+ let rules: CSSRule[];
50
+ try {
51
+ rules = Array.from(sheet.cssRules);
52
+ } catch {
53
+ continue;
54
+ }
55
+ for (const rule of rules) {
56
+ chunks.push(rule.cssText);
57
+ }
58
+ }
59
+ return chunks.join('\n');
60
+ }