@astryxdesign/core 0.7.1 → 0.7.2-canary.01eab6a
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/AspectRatio/AspectRatio.d.ts +6 -8
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +44 -3
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +3 -1
- package/dist/CheckboxList/CheckboxListItem.d.ts +9 -1
- package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListItem.js +4 -2
- package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +37 -5
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +5 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +20 -8
- package/dist/Drawer/Drawer.d.ts +191 -0
- package/dist/Drawer/Drawer.d.ts.map +1 -0
- package/dist/Drawer/Drawer.js +382 -0
- package/dist/Drawer/DrawerHeader.d.ts +85 -0
- package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
- package/dist/Drawer/DrawerHeader.js +134 -0
- package/dist/Drawer/index.d.ts +11 -0
- package/dist/Drawer/index.d.ts.map +1 -0
- package/dist/Drawer/index.js +11 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
- package/dist/Drawer/useDrawerDialogPresence.js +236 -0
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +47 -4
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
- package/dist/DropdownMenu/DropdownMenuDivider.d.ts +5 -3
- package/dist/DropdownMenu/DropdownMenuDivider.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuDivider.js +7 -3
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -8
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +4 -10
- package/dist/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +8 -4
- package/dist/Grid/GridSpan.d.ts.map +1 -1
- package/dist/Grid/GridSpan.js +1 -0
- package/dist/Heading/Heading.d.ts.map +1 -1
- package/dist/Heading/Heading.js +4 -2
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +10 -2
- package/dist/Item/Item.d.ts +6 -2
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +19 -4
- package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
- package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
- package/dist/Layer/layerViewportInset.stylex.js +6 -2
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +13 -7
- package/dist/Layer/useLayerDismissal.d.ts +1 -1
- package/dist/Layer/useLayerDismissal.js +1 -1
- package/dist/Layout/container.stylex.d.ts +5 -5
- package/dist/Layout/container.stylex.js +4 -4
- package/dist/List/List.d.ts +46 -19
- package/dist/List/List.d.ts.map +1 -1
- package/dist/List/List.js +28 -10
- package/dist/List/ListContext.d.ts +8 -3
- package/dist/List/ListContext.d.ts.map +1 -1
- package/dist/List/ListContext.js +7 -2
- package/dist/List/ListItem.d.ts +13 -5
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +21 -8
- package/dist/List/index.d.ts +1 -1
- package/dist/List/index.d.ts.map +1 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +8 -1
- package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
- package/dist/Markdown/plugins/frontmatter.js +7 -3
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +5 -1
- package/dist/MetadataList/MetadataListContext.d.ts +1 -1
- package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +22 -8
- package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Popover/Popover.js +2 -2
- package/dist/PowerSearch/PowerSearch.js +1 -1
- package/dist/RadioList/RadioList.d.ts +1 -1
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +6 -2
- package/dist/RadioList/RadioListItem.d.ts +9 -1
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +2 -0
- package/dist/Selector/hooks.d.ts.map +1 -1
- package/dist/Selector/hooks.js +6 -2
- package/dist/SideNav/SideNavItem.d.ts +8 -1
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +71 -9
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -1
- package/dist/TabList/TabMenu.d.ts +2 -2
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +6 -3
- package/dist/Text/Text.d.ts.map +1 -1
- package/dist/Text/Text.js +4 -2
- package/dist/Text/useTruncation.d.ts +7 -5
- package/dist/Text/useTruncation.d.ts.map +1 -1
- package/dist/Text/useTruncation.js +16 -5
- package/dist/ToggleButton/ToggleButton.d.ts +3 -2
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +12 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -0
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +3 -3
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +3 -3
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +1 -0
- package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
- package/dist/TreeList/TreeListBranches.js +2 -2
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +57 -9
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +26 -0
- package/dist/astryx.css +47 -13
- package/dist/hooks/useEntryAnimation.d.ts +2 -1
- package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
- package/dist/hooks/useEntryAnimation.js +14 -12
- package/dist/hooks/useIsHydrating.d.ts +6 -0
- package/dist/hooks/useIsHydrating.d.ts.map +1 -0
- package/dist/hooks/useIsHydrating.js +30 -0
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +18 -13
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +112 -1
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +0 -3
- package/dist/utils/focusOutline.stylex.d.ts +4 -0
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +4 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +15 -9
- package/dist/utils/sharedMutationObserver.d.ts +21 -0
- package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
- package/dist/utils/sharedMutationObserver.js +69 -0
- package/locales/en.json +444 -0
- package/locales/pseudo.json +333 -0
- package/package.json +9 -4
- package/src/AspectRatio/AspectRatio.test.tsx +44 -43
- package/src/AspectRatio/AspectRatio.tsx +38 -18
- package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
- package/src/Avatar/Avatar.doc.mjs +5 -3
- package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
- package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +30 -1
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
- package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
- package/src/CheckboxInput/CheckboxInput.tsx +3 -1
- package/src/CheckboxList/CheckboxList.test.tsx +22 -0
- package/src/CheckboxList/CheckboxListItem.doc.mjs +16 -0
- package/src/CheckboxList/CheckboxListItem.tsx +10 -0
- package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
- package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
- package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
- package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +292 -0
- package/src/ContextMenu/ContextMenu.doc.mjs +2 -3
- package/src/ContextMenu/ContextMenu.tsx +4 -1
- package/src/ContextMenu/__tests__/ContextMenuAudit.a11y.chromium.spec.ts +438 -0
- package/src/Dialog/Dialog.test.tsx +46 -0
- package/src/Dialog/Dialog.tsx +20 -7
- package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
- package/src/Drawer/Drawer.doc.mjs +472 -0
- package/src/Drawer/Drawer.spec.md +357 -0
- package/src/Drawer/Drawer.test.tsx +1300 -0
- package/src/Drawer/Drawer.tsx +613 -0
- package/src/Drawer/DrawerDirection.test.tsx +127 -0
- package/src/Drawer/DrawerHeader.doc.mjs +293 -0
- package/src/Drawer/DrawerHeader.test.tsx +102 -0
- package/src/Drawer/DrawerHeader.tsx +235 -0
- package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
- package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
- package/src/Drawer/index.ts +13 -0
- package/src/Drawer/useDrawerDialogPresence.ts +284 -0
- package/src/DropdownMenu/DropdownMenu.test.tsx +62 -13
- package/src/DropdownMenu/DropdownMenu.tsx +47 -4
- package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
- package/src/DropdownMenu/DropdownMenuDivider.doc.mjs +2 -2
- package/src/DropdownMenu/DropdownMenuDivider.spec.md +192 -0
- package/src/DropdownMenu/DropdownMenuDivider.tsx +9 -5
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +19 -5
- package/src/DropdownMenu/__tests__/DropdownMenuDividerAudit.a11y.chromium.spec.ts +280 -0
- package/src/DropdownMenu/__tests__/Menu.a11y.chromium.spec.ts +145 -0
- package/src/DropdownMenu/__tests__/Menu.a11y.known-failures.ts +26 -0
- package/src/DropdownMenu/__tests__/Menu.a11y.renders.tsx +65 -0
- package/src/DropdownMenu/__tests__/Menu.a11y.states.ts +138 -0
- package/src/DropdownMenu/__tests__/Menu.a11y.test.tsx +136 -0
- package/src/EmptyState/EmptyState.doc.mjs +3 -2
- package/src/EmptyState/EmptyState.spec.md +189 -0
- package/src/EmptyState/EmptyState.test.tsx +74 -3
- package/src/EmptyState/EmptyState.tsx +4 -8
- package/src/Field/Field.doc.mjs +14 -8
- package/src/Field/Field.spec.md +217 -0
- package/src/Field/Field.test.tsx +60 -2
- package/src/Field/Field.tsx +17 -8
- package/src/Grid/GridSpan.tsx +3 -0
- package/src/Heading/Heading.test.tsx +57 -0
- package/src/Heading/Heading.tsx +6 -2
- package/src/HoverCard/HoverCard.test.tsx +58 -0
- package/src/HoverCard/useHoverCard.tsx +10 -2
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/Item/Item.doc.mjs +12 -8
- package/src/Item/Item.spec.md +226 -0
- package/src/Item/Item.test.tsx +61 -0
- package/src/Item/Item.tsx +40 -4
- package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
- package/src/Layer/layerViewportInset.stylex.ts +6 -2
- package/src/Layer/layerViewportInset.test.ts +1 -0
- package/src/Layer/useLayer.tsx +24 -13
- package/src/Layer/useLayerDismissal.ts +1 -1
- package/src/Layout/container.stylex.ts +6 -6
- package/src/List/List.doc.mjs +14 -1
- package/src/List/List.spec.md +129 -77
- package/src/List/List.test.tsx +163 -94
- package/src/List/List.tsx +74 -18
- package/src/List/ListContext.tsx +9 -3
- package/src/List/ListItem.doc.mjs +24 -5
- package/src/List/ListItem.tsx +56 -27
- package/src/List/index.ts +1 -1
- package/src/Markdown/Markdown.tsx +14 -1
- package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
- package/src/Markdown/plugins/frontmatter.ts +7 -3
- package/src/MetadataList/MetadataList.doc.mjs +2 -2
- package/src/MetadataList/MetadataList.test.tsx +38 -0
- package/src/MetadataList/MetadataList.tsx +8 -1
- package/src/MetadataList/MetadataListContext.tsx +1 -1
- package/src/MultiSelector/MultiSelector.test.tsx +159 -0
- package/src/MultiSelector/MultiSelector.tsx +40 -7
- package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Popover/Popover.test.tsx +2 -2
- package/src/Popover/Popover.tsx +4 -4
- package/src/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/RadioList/RadioList.fr10.test.tsx +45 -2
- package/src/RadioList/RadioList.test.tsx +22 -0
- package/src/RadioList/RadioList.tsx +4 -1
- package/src/RadioList/RadioListItem.doc.mjs +16 -0
- package/src/RadioList/RadioListItem.tsx +10 -1
- package/src/Selector/hooks.ts +6 -2
- package/src/Selector/useCombobox.doc.mjs +237 -0
- package/src/SideNav/SideNav.test.tsx +179 -0
- package/src/SideNav/SideNavItem.doc.mjs +2 -2
- package/src/SideNav/SideNavItem.tsx +96 -17
- package/src/Slider/Slider.spec.md +39 -18
- package/src/Switch/Switch.test.tsx +49 -0
- package/src/Switch/Switch.tsx +11 -0
- package/src/TabList/TabList.spec.md +57 -44
- package/src/TabList/TabList.test.tsx +67 -0
- package/src/TabList/TabMenu.doc.mjs +4 -3
- package/src/TabList/TabMenu.tsx +7 -4
- package/src/Text/Text.test.tsx +57 -0
- package/src/Text/Text.tsx +6 -2
- package/src/Text/useTruncation.test.tsx +69 -0
- package/src/Text/useTruncation.ts +16 -5
- package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
- package/src/ToggleButton/ToggleButton.test.tsx +95 -0
- package/src/ToggleButton/ToggleButton.tsx +12 -1
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- package/src/Tooltip/Tooltip.test.tsx +31 -0
- package/src/Tooltip/useTooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +11 -0
- package/src/TopNav/TopNavMegaMenu.test.tsx +22 -0
- package/src/TopNav/TopNavMegaMenu.tsx +7 -3
- package/src/TopNav/TopNavMenu.test.tsx +19 -0
- package/src/TopNav/TopNavMenu.tsx +11 -3
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/TreeList/TreeList.spec.md +29 -17
- package/src/TreeList/TreeList.test.tsx +327 -6
- package/src/TreeList/TreeList.tsx +5 -1
- package/src/TreeList/TreeListBranches.tsx +6 -0
- package/src/TreeList/TreeListItem.tsx +88 -13
- package/src/Typeahead/BaseTypeahead.tsx +26 -0
- package/src/Typeahead/Typeahead.test.tsx +260 -2
- package/src/docPropLiterals.test.ts +36 -10
- package/src/hooks/useEntryAnimation.doc.mjs +4 -4
- package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
- package/src/hooks/useEntryAnimation.ts +15 -16
- package/src/hooks/useIsHydrating.test.tsx +70 -0
- package/src/hooks/useIsHydrating.ts +31 -0
- package/src/hooks/useMenuHover.ts +18 -13
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +121 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
- package/src/index.ts +1 -0
- package/src/reset.css +0 -29
- package/src/theme/derivedVarRegistry.test.ts +7 -3
- package/src/theme/derivedVarRegistry.ts +0 -3
- package/src/utils/focusOutline.stylex.ts +4 -0
- package/src/utils/plainDate.test.ts +37 -1
- package/src/utils/plainDate.ts +15 -5
- package/src/utils/sharedMutationObserver.test.ts +53 -0
- package/src/utils/sharedMutationObserver.ts +78 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file AspectRatioFitLayers.a11y.browser.spec.ts
|
|
5
|
+
* @input The built FitModes story (its rendered markup and compiled CSS) and
|
|
6
|
+
* Tailwind's preflight.css
|
|
7
|
+
* @output Browser evidence that `fit` fills the box on the documented setups
|
|
8
|
+
* @position AspectRatio integration proof. Which rule sizes the child is a
|
|
9
|
+
* cascade-layer question, and only a shipping engine resolves the cascade.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import {fileURLToPath} from 'node:url';
|
|
14
|
+
import {expect, test, type Page} from '@playwright/test';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_STORYBOOK_DIR,
|
|
17
|
+
serveStorybook,
|
|
18
|
+
type StaticServer,
|
|
19
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
20
|
+
|
|
21
|
+
const PREFLIGHT_CSS = fs.readFileSync(
|
|
22
|
+
fileURLToPath(import.meta.resolve('tailwindcss/preflight.css')),
|
|
23
|
+
'utf8',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// The layer order each setup guide declares, and the layer preflight goes in.
|
|
27
|
+
const TAILWIND_SETUPS = [
|
|
28
|
+
{
|
|
29
|
+
name: 'Tailwind v4',
|
|
30
|
+
order:
|
|
31
|
+
'@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;',
|
|
32
|
+
preflightLayer: 'base',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'Tailwind v3',
|
|
36
|
+
order: '@layer reset, tw-preflight, astryx-base, astryx-theme;',
|
|
37
|
+
preflightLayer: 'tw-preflight',
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// A 100×100 image in a 320px-wide 16:9 box: filling the box makes it 320×180.
|
|
42
|
+
const IMAGE =
|
|
43
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'/%3E";
|
|
44
|
+
|
|
45
|
+
type Fit = 'cover' | 'contain';
|
|
46
|
+
|
|
47
|
+
let storybook: StaticServer;
|
|
48
|
+
test.beforeAll(async () => {
|
|
49
|
+
storybook = await serveStorybook(
|
|
50
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
test.afterAll(async () => {
|
|
54
|
+
await storybook?.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The markup AspectRatio renders for `fit`, and every CSS rule the story
|
|
59
|
+
* page loaded, taken from the built story so the classes and rules are the
|
|
60
|
+
* component's real compiled output. The child image gets a fixed id, a
|
|
61
|
+
* local source and any extra class.
|
|
62
|
+
*/
|
|
63
|
+
async function captureStory(page: Page, fit: Fit, mediaClass: string) {
|
|
64
|
+
await page.goto(
|
|
65
|
+
`${storybook.origin}/iframe.html?id=core-aspectratio--fit-modes&viewMode=story`,
|
|
66
|
+
);
|
|
67
|
+
const wrapper = page.locator(`[data-astryx-aspect-ratio-override="${fit}"]`);
|
|
68
|
+
await expect(wrapper).toHaveCount(1);
|
|
69
|
+
return wrapper.evaluate(
|
|
70
|
+
(element, {image, extraClass}) => {
|
|
71
|
+
const root = element.parentElement?.cloneNode(true);
|
|
72
|
+
const media =
|
|
73
|
+
root instanceof HTMLElement ? root.querySelector('img') : null;
|
|
74
|
+
if (!(root instanceof HTMLElement) || media == null) {
|
|
75
|
+
throw new Error('FitModes no longer renders an img inside AspectRatio');
|
|
76
|
+
}
|
|
77
|
+
media.id = 'media';
|
|
78
|
+
media.src = image;
|
|
79
|
+
if (extraClass) {
|
|
80
|
+
media.classList.add(extraClass);
|
|
81
|
+
}
|
|
82
|
+
const css = [...document.styleSheets]
|
|
83
|
+
.flatMap(sheet => [...sheet.cssRules].map(rule => rule.cssText))
|
|
84
|
+
.join('\n');
|
|
85
|
+
return {html: root.outerHTML, css};
|
|
86
|
+
},
|
|
87
|
+
{image: IMAGE, extraClass: mediaClass},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Re-host the captured story with `before` CSS ahead of it and `after` CSS behind it. */
|
|
92
|
+
async function measureMedia(
|
|
93
|
+
page: Page,
|
|
94
|
+
fit: Fit,
|
|
95
|
+
{
|
|
96
|
+
before = [],
|
|
97
|
+
after = [],
|
|
98
|
+
mediaClass = '',
|
|
99
|
+
}: {
|
|
100
|
+
before?: string[];
|
|
101
|
+
after?: string[];
|
|
102
|
+
mediaClass?: string;
|
|
103
|
+
},
|
|
104
|
+
) {
|
|
105
|
+
const {html, css} = await captureStory(page, fit, mediaClass);
|
|
106
|
+
await page.setContent(
|
|
107
|
+
`<!doctype html><div style="width: 320px">${html}</div>`,
|
|
108
|
+
);
|
|
109
|
+
for (const content of [...before, css, ...after]) {
|
|
110
|
+
await page.addStyleTag({content});
|
|
111
|
+
}
|
|
112
|
+
await page.waitForFunction(
|
|
113
|
+
() => (document.getElementById('media') as HTMLImageElement).complete,
|
|
114
|
+
);
|
|
115
|
+
return page.evaluate(() => {
|
|
116
|
+
const media = document.getElementById('media');
|
|
117
|
+
if (!media) {
|
|
118
|
+
throw new Error('missing media');
|
|
119
|
+
}
|
|
120
|
+
const box = media.getBoundingClientRect();
|
|
121
|
+
return {
|
|
122
|
+
width: box.width,
|
|
123
|
+
height: box.height,
|
|
124
|
+
objectFit: getComputedStyle(media).objectFit,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const setup of TAILWIND_SETUPS) {
|
|
130
|
+
for (const fit of ['cover', 'contain'] as const) {
|
|
131
|
+
test(`fit="${fit}" fills the box on the ${setup.name} setup`, async ({
|
|
132
|
+
page,
|
|
133
|
+
}) => {
|
|
134
|
+
const media = await measureMedia(page, fit, {
|
|
135
|
+
before: [
|
|
136
|
+
setup.order,
|
|
137
|
+
`@layer ${setup.preflightLayer} { ${PREFLIGHT_CSS} }`,
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
expect(media).toEqual({width: 320, height: 180, objectFit: fit});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test('fit fills the box with only Astryx CSS loaded', async ({page}) => {
|
|
146
|
+
const media = await measureMedia(page, 'cover', {});
|
|
147
|
+
expect(media).toEqual({width: 320, height: 180, objectFit: 'cover'});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a utility class on the child still wins over fit', async ({page}) => {
|
|
151
|
+
const media = await measureMedia(page, 'cover', {
|
|
152
|
+
before: [TAILWIND_SETUPS[0].order, `@layer base { ${PREFLIGHT_CSS} }`],
|
|
153
|
+
after: ['@layer utilities { .h-10 { height: 40px; } }'],
|
|
154
|
+
mediaClass: 'h-10',
|
|
155
|
+
});
|
|
156
|
+
expect(media).toEqual({width: 320, height: 40, objectFit: 'cover'});
|
|
157
|
+
});
|
|
@@ -67,8 +67,10 @@ export const docs = {
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
name: 'size',
|
|
70
|
-
type:
|
|
71
|
-
|
|
70
|
+
type:
|
|
71
|
+
"'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
|
|
72
|
+
description:
|
|
73
|
+
"Avatar size: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. Inside an AvatarGroup, the group's size always overrides this prop, including when the group uses its default size.",
|
|
72
74
|
default: "'md'",
|
|
73
75
|
},
|
|
74
76
|
{
|
|
@@ -169,7 +171,7 @@ export const docsDense = {
|
|
|
169
171
|
fallbackSrc: 'fallback image when primary fails',
|
|
170
172
|
name: 'user name for initials and alt text',
|
|
171
173
|
alt: 'alt text; falls back to name',
|
|
172
|
-
size: "
|
|
174
|
+
size: "named sizes: 'xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px; numeric px restricted to values listed in the type. AvatarGroup's size always wins, default included.",
|
|
173
175
|
status:
|
|
174
176
|
'corner content for status indicators; AvatarStatusDot reports its `label`, composed into the avatar accessible name ("Jane Doe, Online"), at any nesting depth',
|
|
175
177
|
tooltip:
|
|
@@ -48,9 +48,10 @@ export const docs = {
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
name: 'size',
|
|
51
|
-
type:
|
|
51
|
+
type:
|
|
52
|
+
"'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
|
|
52
53
|
description:
|
|
53
|
-
"Size applied to all avatars
|
|
54
|
+
"Size applied to all avatars: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. The group's size always overrides each child Avatar's size, including when the group uses its default size.",
|
|
54
55
|
default: "'md'",
|
|
55
56
|
},
|
|
56
57
|
{
|
|
@@ -56,6 +56,7 @@ import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
|
56
56
|
import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
|
|
57
57
|
import {
|
|
58
58
|
DropdownMenuContext,
|
|
59
|
+
DropdownMenuRootOpenContext,
|
|
59
60
|
type DropdownMenuContextValue,
|
|
60
61
|
type DropdownMenuSize,
|
|
61
62
|
} from '../DropdownMenu/DropdownMenuContext';
|
|
@@ -673,7 +674,9 @@ function BreadcrumbMenuTrigger({
|
|
|
673
674
|
stylex.props(menuStyles.menu),
|
|
674
675
|
)}>
|
|
675
676
|
<DropdownMenuContext value={contextValue}>
|
|
676
|
-
{
|
|
677
|
+
<DropdownMenuRootOpenContext value={popover.isOpen}>
|
|
678
|
+
{menuContent}
|
|
679
|
+
</DropdownMenuRootOpenContext>
|
|
677
680
|
</DropdownMenuContext>
|
|
678
681
|
</div>,
|
|
679
682
|
{
|
|
@@ -7,12 +7,13 @@ import {Breadcrumbs} from './Breadcrumbs';
|
|
|
7
7
|
import {BreadcrumbItem} from './BreadcrumbItem';
|
|
8
8
|
import {
|
|
9
9
|
BreadcrumbMenuItem,
|
|
10
|
+
BreadcrumbMenuDivider,
|
|
10
11
|
BreadcrumbMenuGroup,
|
|
11
12
|
BreadcrumbMenuCheckboxItem,
|
|
12
13
|
BreadcrumbMenuRadioGroup,
|
|
13
14
|
BreadcrumbMenuRadioItem,
|
|
14
15
|
} from './index';
|
|
15
|
-
import type {BreadcrumbMenuOption} from './index';
|
|
16
|
+
import type {BreadcrumbMenuDividerProps, BreadcrumbMenuOption} from './index';
|
|
16
17
|
import {LinkProvider} from '../Link/LinkProvider';
|
|
17
18
|
|
|
18
19
|
function CustomLink({
|
|
@@ -771,6 +772,34 @@ describe('BreadcrumbItem menu', () => {
|
|
|
771
772
|
expect(separators).toHaveLength(3);
|
|
772
773
|
});
|
|
773
774
|
|
|
775
|
+
it('renders the separator and props types through the BreadcrumbMenuDivider aliases', async () => {
|
|
776
|
+
const user = userEvent.setup();
|
|
777
|
+
const dividerProps: BreadcrumbMenuDividerProps = {
|
|
778
|
+
'data-testid': 'breadcrumb-menu-divider',
|
|
779
|
+
};
|
|
780
|
+
render(
|
|
781
|
+
<Breadcrumbs>
|
|
782
|
+
<BreadcrumbItem
|
|
783
|
+
menu={
|
|
784
|
+
<>
|
|
785
|
+
<BreadcrumbMenuItem label="Overview" />
|
|
786
|
+
<BreadcrumbMenuDivider {...dividerProps} />
|
|
787
|
+
<BreadcrumbMenuItem label="Settings" />
|
|
788
|
+
</>
|
|
789
|
+
}>
|
|
790
|
+
Project
|
|
791
|
+
</BreadcrumbItem>
|
|
792
|
+
<BreadcrumbItem isCurrent>Current</BreadcrumbItem>
|
|
793
|
+
</Breadcrumbs>,
|
|
794
|
+
);
|
|
795
|
+
await user.click(screen.getByRole('button', {name: 'Project'}));
|
|
796
|
+
|
|
797
|
+
expect(screen.getByTestId('breadcrumb-menu-divider')).toHaveAttribute(
|
|
798
|
+
'role',
|
|
799
|
+
'separator',
|
|
800
|
+
);
|
|
801
|
+
});
|
|
802
|
+
|
|
774
803
|
it('carries the group semantics through the BreadcrumbMenuGroup alias', async () => {
|
|
775
804
|
// The alias must bring the semantics, not just the component: a group
|
|
776
805
|
// that renders but is not named leaves a screen reader with a run of
|
|
@@ -138,7 +138,7 @@ export const docs = {
|
|
|
138
138
|
{
|
|
139
139
|
name: 'isLoading',
|
|
140
140
|
type: 'boolean',
|
|
141
|
-
description: 'Shows a loading spinner and
|
|
141
|
+
description: 'Shows a loading spinner and blocks re-activation while staying focusable (aria-busy + aria-disabled, not native disabled, so keyboard focus is never dropped mid-action). Announces "Loading" via a live region.',
|
|
142
142
|
default: 'false',
|
|
143
143
|
},
|
|
144
144
|
{
|
|
@@ -419,7 +419,7 @@ export const docsDense = {
|
|
|
419
419
|
displayName: 'HTML name for form submission',
|
|
420
420
|
value: 'HTML value for form submission',
|
|
421
421
|
form: 'associates button with form element by ID',
|
|
422
|
-
isLoading: 'shows spinner
|
|
422
|
+
isLoading: 'shows spinner, blocks re-activation but stays focusable (aria-disabled, not native disabled); announces via live region',
|
|
423
423
|
icon: 'icon element rendered before label text; unsized Astryx Icon defaults to sm for sm/md buttons and md for lg',
|
|
424
424
|
isIconOnly: 'when true, renders square icon-only button; label becomes aria-label',
|
|
425
425
|
width: "Width of button. Numbers=pixels, strings=as-is (e.g. '100%' for full-width). Default: content width, capped at the container; label truncates w/ ellipsis in a narrow row; icon-only stays square.",
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import {readFileSync} from 'node:fs';
|
|
13
13
|
import {describe, it, expect, vi} from 'vitest';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
render,
|
|
16
|
+
screen,
|
|
17
|
+
fireEvent,
|
|
18
|
+
createEvent,
|
|
19
|
+
act,
|
|
20
|
+
} from '@testing-library/react';
|
|
15
21
|
import userEvent from '@testing-library/user-event';
|
|
16
22
|
import {Button} from './Button';
|
|
17
23
|
import * as stylex from '@stylexjs/stylex';
|
|
@@ -127,8 +133,9 @@ describe('Button', () => {
|
|
|
127
133
|
it('shows isLoading state with spinner', () => {
|
|
128
134
|
render(<Button label="Submit" isLoading />);
|
|
129
135
|
const button = screen.getByRole('button');
|
|
130
|
-
//
|
|
131
|
-
expect(button).toBeDisabled();
|
|
136
|
+
// Busy is aria-disabled (focusable), not native disabled — see #4871.
|
|
137
|
+
expect(button).not.toBeDisabled();
|
|
138
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
132
139
|
expect(button.className).toContain('styles.inactive');
|
|
133
140
|
expect(button.className).not.toContain('styles.disabled');
|
|
134
141
|
});
|
|
@@ -143,7 +150,7 @@ describe('Button', () => {
|
|
|
143
150
|
|
|
144
151
|
it('sets aria-busy synchronously while clickAction is pending', async () => {
|
|
145
152
|
// The spinner reveal is visually delayed (CSS animation-delay), but the
|
|
146
|
-
// loading DOM state — aria-busy and disabled — must not be delayed.
|
|
153
|
+
// loading DOM state — aria-busy and aria-disabled — must not be delayed.
|
|
147
154
|
const user = userEvent.setup();
|
|
148
155
|
let resolveAction: (() => void) | undefined;
|
|
149
156
|
const clickAction = vi.fn(
|
|
@@ -157,7 +164,9 @@ describe('Button', () => {
|
|
|
157
164
|
|
|
158
165
|
await user.click(button);
|
|
159
166
|
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
160
|
-
|
|
167
|
+
// Busy is aria-disabled (focusable), not native disabled — see #4871.
|
|
168
|
+
expect(button).not.toBeDisabled();
|
|
169
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
161
170
|
|
|
162
171
|
await act(async () => {
|
|
163
172
|
resolveAction?.();
|
|
@@ -165,6 +174,7 @@ describe('Button', () => {
|
|
|
165
174
|
});
|
|
166
175
|
expect(button).not.toHaveAttribute('aria-busy', 'true');
|
|
167
176
|
expect(button).not.toBeDisabled();
|
|
177
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
168
178
|
});
|
|
169
179
|
|
|
170
180
|
it('renders the loading spinner with the inherit shade for every variant (#2717)', () => {
|
|
@@ -310,9 +320,10 @@ describe('Button', () => {
|
|
|
310
320
|
);
|
|
311
321
|
// endContent should still be in the DOM
|
|
312
322
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
313
|
-
//
|
|
323
|
+
// Busy is aria-disabled (focusable), not native disabled — see #4871.
|
|
314
324
|
const button = screen.getByRole('button');
|
|
315
|
-
expect(button).toBeDisabled();
|
|
325
|
+
expect(button).not.toBeDisabled();
|
|
326
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
316
327
|
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
317
328
|
});
|
|
318
329
|
|
|
@@ -516,6 +527,111 @@ describe('Button', () => {
|
|
|
516
527
|
expect(handleKeyDown).toHaveBeenCalledTimes(1);
|
|
517
528
|
});
|
|
518
529
|
|
|
530
|
+
it('uses aria-disabled instead of disabled while isLoading, so the button stays focusable (#4871)', () => {
|
|
531
|
+
render(<Button label="Save" isLoading />);
|
|
532
|
+
const button = screen.getByRole('button');
|
|
533
|
+
// Native disabled would move focus to <body> the moment the action
|
|
534
|
+
// starts, losing the keyboard user's place — busy must stay
|
|
535
|
+
// aria-disabled + focusable per the "Disabled vs Busy" convention.
|
|
536
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
537
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
538
|
+
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
539
|
+
button.focus();
|
|
540
|
+
expect(button).toHaveFocus();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('does not fire onClick while aria-disabled via isLoading', async () => {
|
|
544
|
+
const user = userEvent.setup();
|
|
545
|
+
const handleClick = vi.fn();
|
|
546
|
+
render(<Button label="Save" isLoading onClick={handleClick} />);
|
|
547
|
+
await user.click(screen.getByRole('button'));
|
|
548
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('does not let a click on a busy button reach a wrapping ancestor handler', async () => {
|
|
552
|
+
// A native `disabled` element never dispatches a click, so it never
|
|
553
|
+
// reaches an ancestor either. aria-disabled (busy-only) is a real,
|
|
554
|
+
// interactive element that still dispatches one — preventDefault alone
|
|
555
|
+
// only cancels the button's own default action, not propagation, so
|
|
556
|
+
// an unrelated wrapper (a clickable row/card/backdrop) would otherwise
|
|
557
|
+
// still fire.
|
|
558
|
+
const user = userEvent.setup();
|
|
559
|
+
const ancestorClick = vi.fn();
|
|
560
|
+
render(
|
|
561
|
+
<div onClick={ancestorClick}>
|
|
562
|
+
<Button label="Save" isLoading />
|
|
563
|
+
</div>,
|
|
564
|
+
);
|
|
565
|
+
await user.click(screen.getByRole('button'));
|
|
566
|
+
expect(ancestorClick).not.toHaveBeenCalled();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('does not let a click on a busy-only anchor Button reach a wrapping ancestor handler', async () => {
|
|
570
|
+
const user = userEvent.setup();
|
|
571
|
+
const ancestorClick = vi.fn();
|
|
572
|
+
render(
|
|
573
|
+
<div onClick={ancestorClick}>
|
|
574
|
+
<Button label="Docs" href="https://example.com" isLoading />
|
|
575
|
+
</div>,
|
|
576
|
+
);
|
|
577
|
+
await user.click(screen.getByRole('link'));
|
|
578
|
+
expect(ancestorClick).not.toHaveBeenCalled();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('suppresses activation keys but passes other keys while aria-disabled via isLoading', async () => {
|
|
582
|
+
const user = userEvent.setup();
|
|
583
|
+
const handleKeyDown = vi.fn();
|
|
584
|
+
render(<Button label="Save" isLoading onKeyDown={handleKeyDown} />);
|
|
585
|
+
const button = screen.getByRole('button');
|
|
586
|
+
button.focus();
|
|
587
|
+
await user.keyboard('{Enter}');
|
|
588
|
+
expect(handleKeyDown).not.toHaveBeenCalled();
|
|
589
|
+
|
|
590
|
+
await user.keyboard('{Escape}');
|
|
591
|
+
expect(handleKeyDown).toHaveBeenCalledTimes(1);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('suppresses activation keys but passes other keys on a busy-only anchor Button', async () => {
|
|
595
|
+
// The button-rendered path suppresses Enter/Space while aria-disabled;
|
|
596
|
+
// the anchor-rendered path (href set, busy-only) shares the same
|
|
597
|
+
// handleKeyDown but only wires onClick, so a consumer onKeyDown still
|
|
598
|
+
// received Enter/Space on a busy link.
|
|
599
|
+
const user = userEvent.setup();
|
|
600
|
+
const handleKeyDown = vi.fn();
|
|
601
|
+
render(
|
|
602
|
+
<Button
|
|
603
|
+
label="Docs"
|
|
604
|
+
href="https://example.com"
|
|
605
|
+
isLoading
|
|
606
|
+
onKeyDown={handleKeyDown}
|
|
607
|
+
/>,
|
|
608
|
+
);
|
|
609
|
+
const link = screen.getByRole('link');
|
|
610
|
+
link.focus();
|
|
611
|
+
await user.keyboard('{Enter}');
|
|
612
|
+
expect(handleKeyDown).not.toHaveBeenCalled();
|
|
613
|
+
|
|
614
|
+
await user.keyboard('{Escape}');
|
|
615
|
+
expect(handleKeyDown).toHaveBeenCalledTimes(1);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('keeps native disabled (not aria-disabled) when isDisabled and not busy, even without a tooltip', () => {
|
|
619
|
+
render(<Button label="Save" isDisabled />);
|
|
620
|
+
const button = screen.getByRole('button');
|
|
621
|
+
expect(button).toHaveAttribute('disabled');
|
|
622
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('keeps native disabled when isDisabled and isLoading are both true (isDisabled wins)', () => {
|
|
626
|
+
render(<Button label="Save" isDisabled isLoading />);
|
|
627
|
+
const button = screen.getByRole('button');
|
|
628
|
+
// "isDisabled behavior unchanged: that stays native disabled" — busy-only
|
|
629
|
+
// aria-disabled is only for the case where busy is the SOLE reason the
|
|
630
|
+
// button can't be clicked, not when it's also genuinely isDisabled.
|
|
631
|
+
expect(button).toHaveAttribute('disabled');
|
|
632
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
633
|
+
});
|
|
634
|
+
|
|
519
635
|
it('has a live region that announces loading state', () => {
|
|
520
636
|
const {rerender} = render(<Button label="Submit" />);
|
|
521
637
|
const button = screen.getByRole('button');
|
|
@@ -663,8 +779,6 @@ describe('Button', () => {
|
|
|
663
779
|
});
|
|
664
780
|
|
|
665
781
|
it('exposes aria-busy on the link-rendered button while loading', () => {
|
|
666
|
-
// Non-interruptible loading disables the button, which falls back to
|
|
667
|
-
// <button> rendering — so an anchor only shows loading when interruptible.
|
|
668
782
|
render(
|
|
669
783
|
<Button
|
|
670
784
|
label="Docs"
|
|
@@ -682,6 +796,38 @@ describe('Button', () => {
|
|
|
682
796
|
const link = screen.getByRole('link');
|
|
683
797
|
expect(link).not.toHaveAttribute('aria-busy');
|
|
684
798
|
});
|
|
799
|
+
|
|
800
|
+
it('stays an anchor while busy-only, not a disabled <button> fallback (#4871)', () => {
|
|
801
|
+
// Non-interruptible busy is exactly the isBusyOnlyDisabled case: not
|
|
802
|
+
// isDisabled, blocked only because a fire-once action is in flight.
|
|
803
|
+
// Swapping the element to <button disabled> here would drop focus the
|
|
804
|
+
// same way the native disabled attribute did (credit @AKnassa, #4879).
|
|
805
|
+
render(<Button label="Docs" href="https://example.com" isLoading />);
|
|
806
|
+
const link = screen.getByRole('link', {name: 'Docs'});
|
|
807
|
+
expect(link.tagName).toBe('A');
|
|
808
|
+
expect(link).toHaveAttribute('href', 'https://example.com');
|
|
809
|
+
expect(link).toHaveAttribute('aria-busy', 'true');
|
|
810
|
+
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
811
|
+
expect(link).not.toHaveAttribute('disabled');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('keeps a busy-only anchor focusable and guards its navigation', () => {
|
|
815
|
+
render(<Button label="Docs" href="https://example.com" isLoading />);
|
|
816
|
+
const link = screen.getByRole('link', {name: 'Docs'});
|
|
817
|
+
link.focus();
|
|
818
|
+
expect(link).toHaveFocus();
|
|
819
|
+
|
|
820
|
+
const event = createEvent.click(link);
|
|
821
|
+
fireEvent(link, event);
|
|
822
|
+
expect(event.defaultPrevented).toBe(true);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('falls back to <button disabled> for a truly disabled href Button', () => {
|
|
826
|
+
render(<Button label="Docs" href="https://example.com" isDisabled />);
|
|
827
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
828
|
+
const button = screen.getByRole('button', {name: 'Docs'});
|
|
829
|
+
expect(button).toBeDisabled();
|
|
830
|
+
});
|
|
685
831
|
});
|
|
686
832
|
|
|
687
833
|
describe('Button pressed state (touch)', () => {
|
package/src/Button/Button.tsx
CHANGED
|
@@ -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
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
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
|
|
611
|
-
// Disabled links are an accessibility anti-pattern
|
|
612
|
-
|
|
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
|
|
615
|
-
//
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
821
|
+
aria-disabled={useAriaDisabled || undefined}
|
|
822
|
+
onClick={handleClick}
|
|
823
|
+
{...(handleKeyDown ? {onKeyDown: handleKeyDown} : null)}>
|
|
801
824
|
{buttonContent}
|
|
802
825
|
</LinkComponent>
|
|
803
826
|
);
|