@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.
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +6 -2
- package/dist/Button/Button.d.ts +1 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +4 -2
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +2 -2
- package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListItem.js +19 -4
- package/dist/CodeBlock/CodeBlock.js +2 -2
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +10 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +8 -11
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +3 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
- package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
- package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
- package/dist/DropdownMenu/menuItemHover.js +1 -0
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +7 -3
- package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
- package/dist/FieldStatus/FieldStatus.js +12 -2
- package/dist/Item/Item.d.ts +16 -3
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +31 -6
- package/dist/List/ListItem.d.ts +11 -1
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +2 -0
- package/dist/MetadataList/MetadataList.d.ts +1 -1
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +4 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +14 -3
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +3 -2
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +5 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +2 -2
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +15 -4
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +42 -43
- package/dist/Toast/ToastViewport.d.ts.map +1 -1
- package/dist/Toast/ToastViewport.js +61 -9
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +3 -1
- package/dist/Token/Token.d.ts +4 -1
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +68 -43
- package/dist/Token/TokenLink.d.ts +62 -0
- package/dist/Token/TokenLink.d.ts.map +1 -0
- package/dist/Token/TokenLink.js +76 -0
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +9 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +9 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +2 -0
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +8 -1
- package/dist/astryx.css +51 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
- package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
- package/dist/hooks/containerReveal.pool.stylex.js +281 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +2 -1
- package/dist/hooks/useContainerReveal.d.ts +83 -0
- package/dist/hooks/useContainerReveal.d.ts.map +1 -0
- package/dist/hooks/useContainerReveal.js +123 -0
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +11 -0
- package/dist/hooks/useKeyboardHint.d.ts +1 -1
- package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
- package/dist/hooks/useKeyboardHint.js +4 -2
- package/locales/en.json +32 -0
- package/locales/pseudo.json +24 -0
- package/package.json +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +21 -1
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +4 -2
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
- package/src/Button/Button.test.tsx +16 -0
- package/src/Button/Button.tsx +4 -2
- package/src/CheckboxInput/CheckboxInput.test.tsx +23 -0
- package/src/CheckboxInput/CheckboxInput.tsx +17 -2
- package/src/CheckboxList/CheckboxList.test.tsx +94 -3
- package/src/CheckboxList/CheckboxListItem.tsx +17 -10
- package/src/CodeBlock/CodeBlock.test.tsx +17 -0
- package/src/CodeBlock/CodeBlock.tsx +2 -2
- package/src/ContextMenu/ContextMenu.test.tsx +17 -2
- package/src/ContextMenu/ContextMenu.tsx +10 -1
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +4 -12
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +9 -10
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +20 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
- package/src/DropdownMenu/menuItemHover.ts +1 -0
- package/src/Field/Field.doc.mjs +4 -0
- package/src/Field/FieldLabel.test.tsx +25 -0
- package/src/Field/FieldLabel.tsx +11 -2
- package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
- package/src/FieldStatus/FieldStatus.test.tsx +61 -0
- package/src/FieldStatus/FieldStatus.tsx +14 -3
- package/src/Item/Item.doc.mjs +5 -0
- package/src/Item/Item.test.tsx +88 -0
- package/src/Item/Item.tsx +57 -11
- package/src/List/ListItem.doc.mjs +12 -0
- package/src/List/ListItem.tsx +13 -0
- package/src/MetadataList/MetadataList.test.tsx +24 -0
- package/src/MetadataList/MetadataList.tsx +6 -2
- package/src/MultiSelector/MultiSelector.test.tsx +76 -0
- package/src/MultiSelector/MultiSelector.tsx +28 -7
- package/src/RadioList/RadioList.test.tsx +26 -4
- package/src/RadioList/RadioListItem.tsx +8 -1
- package/src/SegmentedControl/SegmentedControl.test.tsx +28 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +20 -2
- package/src/Selector/Selector.test.tsx +22 -0
- package/src/Selector/Selector.tsx +8 -1
- package/src/Skeleton/Skeleton.test.tsx +16 -0
- package/src/Skeleton/Skeleton.tsx +13 -1
- package/src/Switch/Switch.test.tsx +37 -0
- package/src/Switch/Switch.tsx +52 -3
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +19 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +3 -1
- package/src/Thumbnail/Thumbnail.tsx +18 -32
- package/src/Toast/ToastViewport.test.tsx +218 -8
- package/src/Toast/ToastViewport.tsx +71 -12
- package/src/ToggleButton/ToggleButton.test.tsx +24 -0
- package/src/ToggleButton/ToggleButton.tsx +19 -1
- package/src/Token/Token.test.tsx +145 -0
- package/src/Token/Token.tsx +73 -33
- package/src/Token/TokenLink.tsx +107 -0
- package/src/TopNav/TopNavMegaMenu.tsx +26 -2
- package/src/Typeahead/BaseTypeahead.tsx +12 -0
- package/src/Typeahead/Typeahead.test.tsx +62 -0
- package/src/Typeahead/Typeahead.tsx +6 -0
- package/src/__tests__/forcedColors.ts +60 -0
- package/src/hooks/containerReveal.pool.stylex.ts +464 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useContainerReveal.test.tsx +116 -0
- package/src/hooks/useContainerReveal.ts +180 -0
- package/src/hooks/useInputStatusIcon.test.tsx +59 -0
- package/src/hooks/useInputStatusIcon.tsx +12 -1
- package/src/hooks/useKeyboardHint.test.tsx +22 -0
- package/src/hooks/useKeyboardHint.tsx +6 -2
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
- package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
- package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
package/src/Token/Token.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
<
|
|
386
|
-
ref={ref
|
|
423
|
+
<TokenLink
|
|
424
|
+
ref={ref}
|
|
387
425
|
href={href as string}
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|