@astryxdesign/core 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2
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/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +8 -1
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +31 -11
- package/dist/Calendar/styles.d.ts +2 -2
- package/dist/Calendar/styles.d.ts.map +1 -1
- package/dist/Calendar/styles.js +2 -2
- package/dist/Card/Card.d.ts +4 -2
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +15 -7
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +1 -0
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +8 -1
- 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/ClickableCard/ClickableCard.d.ts.map +1 -1
- package/dist/ClickableCard/ClickableCard.js +26 -3
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +23 -5
- package/dist/Collapsible/Collapsible.d.ts +9 -3
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +101 -13
- package/dist/Collapsible/CollapsibleGroup.d.ts +39 -7
- package/dist/Collapsible/CollapsibleGroup.d.ts.map +1 -1
- package/dist/Collapsible/CollapsibleGroup.js +70 -9
- package/dist/Collapsible/CollapsibleGroupContext.d.ts +31 -0
- package/dist/Collapsible/CollapsibleGroupContext.d.ts.map +1 -1
- package/dist/Collapsible/CollapsibleGroupContext.js +33 -3
- package/dist/Collapsible/index.d.ts +1 -0
- package/dist/Collapsible/index.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +2 -0
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -0
- package/dist/Grid/Grid.d.ts +4 -3
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +26 -9
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +5 -1
- package/dist/InputGroup/InputGroup.js +2 -2
- package/dist/Item/Item.js +1 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +26 -2
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +32 -16
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +87 -36
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +39 -21
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -1
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +10 -2
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +6 -2
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +34 -7
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +14 -3
- package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
- package/dist/Table/plugins/columnResize/useTableColumnResize.js +5 -2
- package/dist/Table/plugins/pagination/paginateData.d.ts.map +1 -1
- package/dist/Table/plugins/pagination/paginateData.js +5 -1
- package/dist/Table/plugins/selection/useTableSelectionState.d.ts.map +1 -1
- package/dist/Table/plugins/selection/useTableSelectionState.js +11 -1
- package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortableState.js +8 -4
- package/dist/Thumbnail/Thumbnail.d.ts +3 -2
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +1 -0
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/Toast/Toast.d.ts.map +1 -1
- package/dist/Toast/Toast.js +11 -3
- package/dist/Toast/ToastViewport.d.ts.map +1 -1
- package/dist/Toast/ToastViewport.js +12 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -2
- package/dist/astryx.css +7 -0
- package/dist/astryx.umd.js +48 -48
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/focusableSelector.d.ts +1 -1
- package/dist/hooks/focusableSelector.d.ts.map +1 -1
- package/dist/hooks/focusableSelector.js +1 -1
- package/dist/hooks/useFocusTrap.d.ts +3 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +38 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +10 -3
- package/dist/theme/hct.d.ts +0 -15
- package/dist/theme/hct.d.ts.map +1 -1
- package/dist/theme/hct.js +9 -9
- package/dist/theme/tokens.d.ts +10 -2
- package/dist/theme/tokens.d.ts.map +1 -1
- package/dist/theme/tokens.js +220 -4
- package/dist/utils/color.d.ts +42 -0
- package/dist/utils/color.d.ts.map +1 -0
- package/dist/utils/color.js +150 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +8 -5
- package/package.json +1 -1
- package/src/Avatar/Avatar.doc.mjs +11 -0
- package/src/Banner/Banner.doc.mjs +8 -0
- package/src/Banner/Banner.test.tsx +38 -0
- package/src/Banner/Banner.tsx +8 -1
- package/src/Calendar/Calendar.doc.mjs +1 -1
- package/src/Calendar/Calendar.test.tsx +112 -4
- package/src/Calendar/Calendar.tsx +26 -5
- package/src/Calendar/styles.ts +12 -2
- package/src/Card/Card.tsx +16 -6
- package/src/Carousel/Carousel.doc.mjs +12 -12
- package/src/Carousel/Carousel.test.tsx +13 -0
- package/src/Carousel/Carousel.tsx +1 -0
- package/src/Chat/Chat.doc.mjs +1 -1
- package/src/Chat/ChatToolCalls.test.tsx +55 -0
- package/src/Chat/ChatToolCalls.tsx +10 -12
- package/src/CheckboxInput/CheckboxInput.doc.mjs +3 -3
- package/src/CheckboxInput/CheckboxInput.test.tsx +71 -4
- package/src/CheckboxInput/CheckboxInput.tsx +2 -0
- package/src/Citation/Citation.doc.mjs +5 -0
- package/src/ClickableCard/ClickableCard.tsx +45 -2
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -2
- package/src/CodeBlock/CodeBlock.test.tsx +87 -3
- package/src/CodeBlock/CodeBlock.tsx +43 -3
- package/src/Collapsible/Collapsible.doc.mjs +8 -4
- package/src/Collapsible/Collapsible.tsx +86 -10
- package/src/Collapsible/CollapsibleGroup.doc.mjs +27 -3
- package/src/Collapsible/CollapsibleGroup.test.tsx +288 -0
- package/src/Collapsible/CollapsibleGroup.tsx +107 -9
- package/src/Collapsible/CollapsibleGroupContext.tsx +42 -2
- package/src/Collapsible/index.ts +4 -0
- package/src/CommandPalette/CommandPaletteEmpty.doc.mjs +5 -0
- package/src/CommandPalette/CommandPaletteGroup.doc.mjs +10 -0
- package/src/CommandPalette/CommandPaletteInput.doc.mjs +6 -0
- package/src/CommandPalette/CommandPaletteItem.doc.mjs +6 -0
- package/src/CommandPalette/CommandPaletteList.doc.mjs +9 -0
- package/src/ContextMenu/ContextMenu.doc.mjs +15 -0
- package/src/ContextMenu/ContextMenu.test.tsx +15 -0
- package/src/ContextMenu/ContextMenu.tsx +2 -0
- package/src/ContextMenu/ContextMenuItem.doc.mjs +6 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +70 -0
- package/src/DateTimeInput/DateTimeInput.tsx +2 -0
- package/src/Dialog/DialogHeader.doc.mjs +45 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -1
- package/src/Field/FieldLabel.test.tsx +2 -2
- package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
- package/src/Grid/Grid.test.tsx +43 -18
- package/src/Grid/Grid.tsx +26 -9
- package/src/Icon/Icon.test.tsx +41 -29
- package/src/Icon/Icon.tsx +6 -2
- package/src/InputGroup/InputGroup.tsx +2 -2
- package/src/Item/Item.tsx +1 -1
- package/src/Layout/LayoutContent.doc.mjs +12 -0
- package/src/Layout/LayoutPanel.doc.mjs +40 -0
- package/src/Lightbox/Lightbox.doc.mjs +5 -0
- package/src/Lightbox/Lightbox.test.tsx +123 -2
- package/src/Lightbox/Lightbox.tsx +29 -1
- package/src/NavMenu/NavMenu.doc.mjs +15 -0
- package/src/NumberInput/NumberInput.test.tsx +48 -3
- package/src/NumberInput/NumberInput.tsx +32 -15
- package/src/Overlay/Overlay.doc.mjs +6 -0
- package/src/Pagination/Pagination.test.tsx +143 -0
- package/src/Pagination/Pagination.tsx +75 -23
- package/src/Popover/Popover.test.tsx +65 -0
- package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
- package/src/PowerSearch/PowerSearch.test.tsx +109 -1
- package/src/PowerSearch/PowerSearch.tsx +37 -16
- package/src/ProgressBar/ProgressBar.test.tsx +10 -0
- package/src/ProgressBar/ProgressBar.tsx +2 -1
- package/src/Resizable/ResizeHandle.test.tsx +180 -0
- package/src/Resizable/ResizeHandle.tsx +10 -2
- package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
- package/src/SelectableCard/SelectableCard.doc.mjs +1 -1
- package/src/SideNav/SideNav.doc.mjs +7 -1
- package/src/SideNav/SideNav.test.tsx +20 -0
- package/src/SideNav/SideNav.tsx +6 -2
- package/src/TabList/TabList.test.tsx +185 -1
- package/src/TabList/TabMenu.tsx +45 -9
- package/src/Table/BaseTable.tsx +13 -2
- package/src/Table/Table.test.tsx +51 -0
- package/src/Table/plugins/columnResize/useTableColumnResize.test.tsx +13 -0
- package/src/Table/plugins/columnResize/useTableColumnResize.tsx +3 -1
- package/src/Table/plugins/pagination/paginateData.ts +5 -1
- package/src/Table/plugins/pagination/useTablePagination.test.tsx +13 -0
- package/src/Table/plugins/selection/useTableSelectionState.test.tsx +23 -0
- package/src/Table/plugins/selection/useTableSelectionState.tsx +11 -6
- package/src/Table/plugins/sortable/useTableSortableState.test.tsx +35 -1
- package/src/Table/plugins/sortable/useTableSortableState.tsx +8 -4
- package/src/TextArea/TextArea.test.tsx +3 -3
- package/src/TextInput/TextInput.test.tsx +3 -3
- package/src/Thumbnail/Thumbnail.test.tsx +36 -2
- package/src/Thumbnail/Thumbnail.tsx +4 -2
- package/src/TimeInput/TimeInput.test.tsx +37 -0
- package/src/TimeInput/TimeInput.tsx +11 -1
- package/src/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Toast/Toast.tsx +11 -3
- package/src/Toast/ToastViewport.test.tsx +64 -0
- package/src/Toast/ToastViewport.tsx +12 -0
- package/src/ToggleButton/ToggleButton.doc.mjs +1 -0
- package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
- package/src/TopNav/TopNav.doc.mjs +1 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/Typeahead/BaseTypeahead.doc.mjs +19 -0
- package/src/Typeahead/BaseTypeahead.tsx +5 -2
- package/src/Typeahead/Typeahead.doc.mjs +1 -1
- package/src/Typeahead/Typeahead.test.tsx +45 -1
- package/src/__tests__/TestIcon.tsx +26 -0
- package/src/docPropReferences.test.ts +178 -0
- package/src/hooks/focusableSelector.ts +1 -1
- package/src/hooks/useFocusTrap.doc.mjs +4 -2
- package/src/hooks/useFocusTrap.test.tsx +149 -1
- package/src/hooks/useFocusTrap.ts +50 -1
- package/src/theme/SyntaxTheme.doc.mjs +4 -4
- package/src/theme/defineTheme.ts +4 -13
- package/src/theme/derivedVarRegistry.test.ts +2 -2
- package/src/theme/expandColorScale.test.ts +29 -0
- package/src/theme/expandColorScale.ts +11 -6
- package/src/theme/hct.ts +9 -17
- package/src/theme/tokens.test.ts +182 -9
- package/src/theme/tokens.ts +239 -4
- package/src/theme/useTheme.test.tsx +20 -0
- package/src/utils/color.test.ts +133 -0
- package/src/utils/color.ts +148 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/timeParser.test.ts +13 -0
- package/src/utils/timeParser.ts +8 -5
|
@@ -34,7 +34,7 @@ export const docs = {
|
|
|
34
34
|
],
|
|
35
35
|
theming: {
|
|
36
36
|
container: true,
|
|
37
|
-
targets: [{className: 'astryx-selectable-card', visualProps: ['selected']}],
|
|
37
|
+
targets: [{className: 'astryx-selectable-card', visualProps: ['selected', 'variant']}],
|
|
38
38
|
},
|
|
39
39
|
playground: {
|
|
40
40
|
defaults: {
|
|
@@ -21,7 +21,7 @@ export const docs = {
|
|
|
21
21
|
targets: [
|
|
22
22
|
{className: 'astryx-side-nav', visualProps: ['mode']},
|
|
23
23
|
{className: 'astryx-side-nav-heading'},
|
|
24
|
-
{className: 'astryx-side-nav-item'},
|
|
24
|
+
{className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected']},
|
|
25
25
|
{className: 'astryx-side-nav-section'},
|
|
26
26
|
],
|
|
27
27
|
},
|
|
@@ -102,6 +102,12 @@ export const docs = {
|
|
|
102
102
|
description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel).',
|
|
103
103
|
default: 'false',
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
name: 'resizable',
|
|
107
|
+
type: 'boolean | { defaultWidth?: number; minWidth?: number; maxWidth?: number; autoSaveId?: string; onWidthChange?: (width: number) => void }',
|
|
108
|
+
description: 'Enables a resize handle at the inline-end edge. true for defaults (260px initial, 180-480px range), or a ResizableConfig object (defaultWidth, minWidth, maxWidth, autoSaveId for localStorage persistence, onWidthChange). The handle is hidden while collapsed.',
|
|
109
|
+
default: 'false',
|
|
110
|
+
},
|
|
105
111
|
{
|
|
106
112
|
name: 'handleRef',
|
|
107
113
|
type: 'Ref<SideNavImperativeCollapseHandle>',
|
|
@@ -162,6 +162,26 @@ describe('SideNav', () => {
|
|
|
162
162
|
screen.getByRole('button', {name: 'Expand sidebar'}),
|
|
163
163
|
).toBeInTheDocument();
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
it('does not render an empty footer container when collapsible.hasButton is false', () => {
|
|
167
|
+
render(
|
|
168
|
+
<SideNav data-testid="nav" collapsible={{hasButton: false}}>
|
|
169
|
+
Content
|
|
170
|
+
</SideNav>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// The built-in collapse button is opted out (consumers render their own
|
|
174
|
+
// SideNavCollapseButton in the header), so it must not appear...
|
|
175
|
+
expect(
|
|
176
|
+
screen.queryByRole('button', {name: 'Collapse sidebar'}),
|
|
177
|
+
).not.toBeInTheDocument();
|
|
178
|
+
|
|
179
|
+
// ...and no empty sticky-bottom container should be left behind. With no
|
|
180
|
+
// footer/footerIcons and no built-in button, the scrollable content region
|
|
181
|
+
// is the nav's only child.
|
|
182
|
+
const nav = screen.getByTestId('nav');
|
|
183
|
+
expect(nav.children).toHaveLength(1);
|
|
184
|
+
});
|
|
165
185
|
});
|
|
166
186
|
|
|
167
187
|
// =============================================================================
|
package/src/SideNav/SideNav.tsx
CHANGED
|
@@ -490,6 +490,10 @@ export function SideNav({
|
|
|
490
490
|
// =========================================================================
|
|
491
491
|
const hasStickyTop = !!(header || topContent);
|
|
492
492
|
const hasStickyBottom = !!(footer || footerIcons);
|
|
493
|
+
// The built-in collapse button only renders when collapse is enabled and it
|
|
494
|
+
// hasn't been opted out via `collapsible.hasButton: false` (e.g. when the
|
|
495
|
+
// consumer places a SideNavCollapseButton in the header instead).
|
|
496
|
+
const showCollapseButton = isCollapsible && hasCollapseButton;
|
|
493
497
|
|
|
494
498
|
// When resizable, override the nav width via inline style
|
|
495
499
|
const resizableNavStyle: React.CSSProperties | undefined = isResizable
|
|
@@ -532,7 +536,7 @@ export function SideNav({
|
|
|
532
536
|
)}>
|
|
533
537
|
{children}
|
|
534
538
|
</div>
|
|
535
|
-
{(hasStickyBottom ||
|
|
539
|
+
{(hasStickyBottom || showCollapseButton) && (
|
|
536
540
|
<div
|
|
537
541
|
{...stylex.props(
|
|
538
542
|
styles.stickyBottom,
|
|
@@ -544,7 +548,7 @@ export function SideNav({
|
|
|
544
548
|
styles.footerRow,
|
|
545
549
|
collapsed && styles.footerRowCollapsed,
|
|
546
550
|
)}>
|
|
547
|
-
{
|
|
551
|
+
{showCollapseButton && <SideNavCollapseButton />}
|
|
548
552
|
{footerIcons}
|
|
549
553
|
</div>
|
|
550
554
|
</div>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
|
|
13
|
-
import {render, screen} from '@testing-library/react';
|
|
13
|
+
import {render, screen, fireEvent} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
15
|
import {TabList} from './TabList';
|
|
16
16
|
import {Tab} from './Tab';
|
|
@@ -702,3 +702,187 @@ describe('TabMenu', () => {
|
|
|
702
702
|
expect(reportsItem).not.toHaveAttribute('aria-current');
|
|
703
703
|
});
|
|
704
704
|
});
|
|
705
|
+
|
|
706
|
+
describe('TabMenu keyboard navigation (roving tabindex)', () => {
|
|
707
|
+
const menuOptions = [
|
|
708
|
+
{value: 'analytics', label: 'Analytics'},
|
|
709
|
+
{value: 'reports', label: 'Reports'},
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
it('exposes the overflow menu as a single Tab stop (one item tabbable, rest -1)', async () => {
|
|
713
|
+
const user = userEvent.setup();
|
|
714
|
+
render(
|
|
715
|
+
<TabList value="home" onChange={() => {}}>
|
|
716
|
+
<Tab value="home" label="Home" />
|
|
717
|
+
<TabMenu label="More" options={menuOptions} />
|
|
718
|
+
</TabList>,
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
722
|
+
|
|
723
|
+
const analytics = screen.getByRole('menuitem', {
|
|
724
|
+
name: 'Analytics',
|
|
725
|
+
hidden: true,
|
|
726
|
+
});
|
|
727
|
+
const reports = screen.getByRole('menuitem', {
|
|
728
|
+
name: 'Reports',
|
|
729
|
+
hidden: true,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Exactly one menu item is in the Tab sequence; arrow keys reach the rest.
|
|
733
|
+
expect(analytics).toHaveAttribute('tabindex', '0');
|
|
734
|
+
expect(reports).toHaveAttribute('tabindex', '-1');
|
|
735
|
+
|
|
736
|
+
const tabbable = [analytics, reports].filter(
|
|
737
|
+
el => el.getAttribute('tabindex') === '0',
|
|
738
|
+
);
|
|
739
|
+
expect(tabbable).toHaveLength(1);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('moves focus between items with ArrowDown and ArrowUp', async () => {
|
|
743
|
+
const user = userEvent.setup();
|
|
744
|
+
render(
|
|
745
|
+
<TabList value="home" onChange={() => {}}>
|
|
746
|
+
<Tab value="home" label="Home" />
|
|
747
|
+
<TabMenu label="More" options={menuOptions} />
|
|
748
|
+
</TabList>,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
752
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
753
|
+
const analytics = screen.getByRole('menuitem', {
|
|
754
|
+
name: 'Analytics',
|
|
755
|
+
hidden: true,
|
|
756
|
+
});
|
|
757
|
+
const reports = screen.getByRole('menuitem', {
|
|
758
|
+
name: 'Reports',
|
|
759
|
+
hidden: true,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
fireEvent.keyDown(menu, {key: 'ArrowDown'});
|
|
763
|
+
expect(analytics).toHaveFocus();
|
|
764
|
+
|
|
765
|
+
fireEvent.keyDown(menu, {key: 'ArrowDown'});
|
|
766
|
+
expect(reports).toHaveFocus();
|
|
767
|
+
|
|
768
|
+
fireEvent.keyDown(menu, {key: 'ArrowUp'});
|
|
769
|
+
expect(analytics).toHaveFocus();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('moves the roving tab stop with arrow navigation', async () => {
|
|
773
|
+
const user = userEvent.setup();
|
|
774
|
+
render(
|
|
775
|
+
<TabList value="home" onChange={() => {}}>
|
|
776
|
+
<Tab value="home" label="Home" />
|
|
777
|
+
<TabMenu label="More" options={menuOptions} />
|
|
778
|
+
</TabList>,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
782
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
783
|
+
const analytics = screen.getByRole('menuitem', {
|
|
784
|
+
name: 'Analytics',
|
|
785
|
+
hidden: true,
|
|
786
|
+
});
|
|
787
|
+
const reports = screen.getByRole('menuitem', {
|
|
788
|
+
name: 'Reports',
|
|
789
|
+
hidden: true,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
fireEvent.keyDown(menu, {key: 'ArrowDown'}); // focus analytics
|
|
793
|
+
fireEvent.keyDown(menu, {key: 'ArrowDown'}); // focus reports
|
|
794
|
+
|
|
795
|
+
// The tab stop follows focus, so it is still a single stop after moving.
|
|
796
|
+
expect(reports).toHaveAttribute('tabindex', '0');
|
|
797
|
+
expect(analytics).toHaveAttribute('tabindex', '-1');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('jumps to first and last item with Home and End', async () => {
|
|
801
|
+
const user = userEvent.setup();
|
|
802
|
+
const options = [
|
|
803
|
+
{value: 'analytics', label: 'Analytics'},
|
|
804
|
+
{value: 'reports', label: 'Reports'},
|
|
805
|
+
{value: 'exports', label: 'Exports'},
|
|
806
|
+
];
|
|
807
|
+
render(
|
|
808
|
+
<TabList value="home" onChange={() => {}}>
|
|
809
|
+
<Tab value="home" label="Home" />
|
|
810
|
+
<TabMenu label="More" options={options} />
|
|
811
|
+
</TabList>,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
815
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
816
|
+
const analytics = screen.getByRole('menuitem', {
|
|
817
|
+
name: 'Analytics',
|
|
818
|
+
hidden: true,
|
|
819
|
+
});
|
|
820
|
+
const exports = screen.getByRole('menuitem', {
|
|
821
|
+
name: 'Exports',
|
|
822
|
+
hidden: true,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
fireEvent.keyDown(menu, {key: 'End'});
|
|
826
|
+
expect(exports).toHaveFocus();
|
|
827
|
+
|
|
828
|
+
fireEvent.keyDown(menu, {key: 'Home'});
|
|
829
|
+
expect(analytics).toHaveFocus();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('selects an item with Enter and calls onChange', async () => {
|
|
833
|
+
const user = userEvent.setup();
|
|
834
|
+
const handleChange = vi.fn();
|
|
835
|
+
render(
|
|
836
|
+
<TabList value="home" onChange={handleChange}>
|
|
837
|
+
<Tab value="home" label="Home" />
|
|
838
|
+
<TabMenu label="More" options={menuOptions} />
|
|
839
|
+
</TabList>,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
843
|
+
const analytics = screen.getByRole('menuitem', {
|
|
844
|
+
name: 'Analytics',
|
|
845
|
+
hidden: true,
|
|
846
|
+
});
|
|
847
|
+
analytics.focus();
|
|
848
|
+
fireEvent.keyDown(analytics, {key: 'Enter'});
|
|
849
|
+
|
|
850
|
+
expect(handleChange).toHaveBeenCalledWith('analytics');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('closes the menu when Tab is pressed inside it (APG menu-button)', async () => {
|
|
854
|
+
const user = userEvent.setup();
|
|
855
|
+
render(
|
|
856
|
+
<TabList value="home" onChange={() => {}}>
|
|
857
|
+
<Tab value="home" label="Home" />
|
|
858
|
+
<TabMenu label="More" options={menuOptions} />
|
|
859
|
+
</TabList>,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
863
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
864
|
+
|
|
865
|
+
const hidePopover = vi.mocked(HTMLElement.prototype.hidePopover);
|
|
866
|
+
hidePopover.mockClear();
|
|
867
|
+
fireEvent.keyDown(menu, {key: 'Tab'});
|
|
868
|
+
expect(hidePopover).toHaveBeenCalled();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('closes the menu when Escape is pressed', async () => {
|
|
872
|
+
const user = userEvent.setup();
|
|
873
|
+
render(
|
|
874
|
+
<TabList value="home" onChange={() => {}}>
|
|
875
|
+
<Tab value="home" label="Home" />
|
|
876
|
+
<TabMenu label="More" options={menuOptions} />
|
|
877
|
+
</TabList>,
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
await user.click(screen.getByRole('button', {name: /More/}));
|
|
881
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
882
|
+
|
|
883
|
+
const hidePopover = vi.mocked(HTMLElement.prototype.hidePopover);
|
|
884
|
+
hidePopover.mockClear();
|
|
885
|
+
fireEvent.keyDown(menu, {key: 'Escape'});
|
|
886
|
+
expect(hidePopover).toHaveBeenCalled();
|
|
887
|
+
});
|
|
888
|
+
});
|
package/src/TabList/TabMenu.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - /packages/cli/templates/blocks/components/TabList/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import React, {useCallback, useId, type ReactNode} from 'react';
|
|
19
|
+
import React, {useCallback, useId, useRef, type ReactNode} from 'react';
|
|
20
20
|
import * as stylex from '@stylexjs/stylex';
|
|
21
21
|
import {Icon} from '../Icon';
|
|
22
22
|
import {renderIconSlot, type IconType} from '../Icon';
|
|
@@ -262,6 +262,7 @@ export function TabMenu({
|
|
|
262
262
|
}: TabMenuProps) {
|
|
263
263
|
const tabListCtx = useTabListContext();
|
|
264
264
|
const menuId = useId();
|
|
265
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
265
266
|
|
|
266
267
|
const popover = usePopover({
|
|
267
268
|
hasLightDismiss: true,
|
|
@@ -270,20 +271,50 @@ export function TabMenu({
|
|
|
270
271
|
// The popup's own role="menu" is the exposed semantics; a modal dialog
|
|
271
272
|
// wrapper would announce an unnamed dialog around the menu.
|
|
272
273
|
role: 'none',
|
|
274
|
+
// Return focus to the trigger when the menu closes (Escape, Tab, select,
|
|
275
|
+
// or light dismiss) so keyboard focus is never dropped to <body>.
|
|
276
|
+
onHide: useCallback(() => {
|
|
277
|
+
buttonRef.current?.focus();
|
|
278
|
+
}, []),
|
|
273
279
|
});
|
|
274
280
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
281
|
+
// The overflow menu is a composite widget: a single roving tab stop with
|
|
282
|
+
// arrow-key navigation between items, per the APG menu pattern. The hook owns
|
|
283
|
+
// item tabindex — items render tabIndex={-1} and exactly one is promoted to 0.
|
|
284
|
+
const {
|
|
285
|
+
listRef,
|
|
286
|
+
handleKeyDown: handleListKeyDown,
|
|
287
|
+
handleFocus,
|
|
288
|
+
focusFirst,
|
|
289
|
+
} = useListFocus<HTMLDivElement>({
|
|
290
|
+
hasRovingTabIndex: true,
|
|
291
|
+
onEscape: () => popover.hide(),
|
|
292
|
+
});
|
|
279
293
|
|
|
280
294
|
const handleToggle = useCallback(() => {
|
|
281
295
|
if (popover.isOpen) {
|
|
282
296
|
popover.hide();
|
|
283
297
|
} else {
|
|
284
298
|
popover.show();
|
|
299
|
+
// Move focus into the menu on open so arrow navigation works and the
|
|
300
|
+
// roving tab stop lands on a real item (APG menu-button focus-on-open).
|
|
301
|
+
requestAnimationFrame(() => focusFirst());
|
|
285
302
|
}
|
|
286
|
-
}, [popover]);
|
|
303
|
+
}, [popover, focusFirst]);
|
|
304
|
+
|
|
305
|
+
const handleMenuKeyDown = useCallback(
|
|
306
|
+
(e: React.KeyboardEvent) => {
|
|
307
|
+
// APG menu-button: Tab leaves the menu rather than walking its items.
|
|
308
|
+
// Close and let onHide return focus to the trigger, from which the
|
|
309
|
+
// browser's default Tab continues to the next element.
|
|
310
|
+
if (e.key === 'Tab') {
|
|
311
|
+
popover.hide();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
handleListKeyDown(e);
|
|
315
|
+
},
|
|
316
|
+
[handleListKeyDown, popover],
|
|
317
|
+
);
|
|
287
318
|
|
|
288
319
|
const selectedOption = options.find(o => o.value === tabListCtx.value);
|
|
289
320
|
const triggerLabel = selectedOption?.label ?? label;
|
|
@@ -299,7 +330,11 @@ export function TabMenu({
|
|
|
299
330
|
[tabListCtx, popover],
|
|
300
331
|
);
|
|
301
332
|
|
|
302
|
-
const setButtonRef = mergeRefs<HTMLButtonElement>(
|
|
333
|
+
const setButtonRef = mergeRefs<HTMLButtonElement>(
|
|
334
|
+
popover.triggerRef,
|
|
335
|
+
buttonRef,
|
|
336
|
+
ref,
|
|
337
|
+
);
|
|
303
338
|
|
|
304
339
|
return (
|
|
305
340
|
<>
|
|
@@ -357,7 +392,8 @@ export function TabMenu({
|
|
|
357
392
|
id={menuId}
|
|
358
393
|
role="menu"
|
|
359
394
|
aria-label={label}
|
|
360
|
-
onKeyDown={
|
|
395
|
+
onKeyDown={handleMenuKeyDown}
|
|
396
|
+
onFocus={handleFocus}
|
|
361
397
|
{...mergeProps(
|
|
362
398
|
themeProps('tab-menu-dropdown'),
|
|
363
399
|
stylex.props(styles.dropdown),
|
|
@@ -371,7 +407,7 @@ export function TabMenu({
|
|
|
371
407
|
<div
|
|
372
408
|
key={option.value}
|
|
373
409
|
role="menuitem"
|
|
374
|
-
tabIndex={
|
|
410
|
+
tabIndex={-1}
|
|
375
411
|
aria-current={isSelected ? 'true' : undefined}
|
|
376
412
|
onClick={() => handleSelect(option.value)}
|
|
377
413
|
onKeyDown={e => {
|
package/src/Table/BaseTable.tsx
CHANGED
|
@@ -279,11 +279,18 @@ function areRowPropsEqual<T extends Record<string, unknown>>(
|
|
|
279
279
|
return true;
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
// Different object reference - compare values
|
|
282
|
+
// Different object reference - compare values. This is intentionally a
|
|
283
|
+
// one-level shallow compare: nested fields rely on reference identity.
|
|
283
284
|
const prevItem = prevProps.item;
|
|
284
285
|
const nextItem = nextProps.item;
|
|
285
286
|
const keys = Object.keys(nextItem);
|
|
286
287
|
|
|
288
|
+
// A key deleted from nextItem would never be visited by the loop below,
|
|
289
|
+
// and the row would keep rendering the removed field's stale value.
|
|
290
|
+
if (Object.keys(prevItem).length !== keys.length) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
287
294
|
for (const key of keys) {
|
|
288
295
|
if (prevItem[key] !== nextItem[key]) {
|
|
289
296
|
return false;
|
|
@@ -365,9 +372,13 @@ function BaseTableInner<T extends Record<string, unknown>>({
|
|
|
365
372
|
const headerCells = resolvedColumns.map((col, columnIndex) => {
|
|
366
373
|
const headerContent = col.header ?? col.key;
|
|
367
374
|
|
|
368
|
-
// Build initial htmlProps with column alignment if specified
|
|
375
|
+
// Build initial htmlProps with column alignment if specified.
|
|
376
|
+
// `scope: 'col'` is the default so every column header associates its
|
|
377
|
+
// data cells with the correct column for screen readers; a plugin's
|
|
378
|
+
// transformHeaderCell can override it via htmlProps.scope.
|
|
369
379
|
const initialHeaderHtmlProps: Record<string, unknown> = {
|
|
370
380
|
'data-column-key': col.key,
|
|
381
|
+
scope: 'col',
|
|
371
382
|
};
|
|
372
383
|
if (col.align) {
|
|
373
384
|
initialHeaderHtmlProps.style = {textAlign: col.align};
|
package/src/Table/Table.test.tsx
CHANGED
|
@@ -229,6 +229,34 @@ describe('BaseTable', () => {
|
|
|
229
229
|
expect(headers[2]).toHaveTextContent('Email');
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
it('renders every column header with scope="col"', () => {
|
|
233
|
+
render(<BaseTable data={users} columns={columns} />);
|
|
234
|
+
const headers = screen.getAllByRole('columnheader');
|
|
235
|
+
expect(headers).toHaveLength(3);
|
|
236
|
+
for (const header of headers) {
|
|
237
|
+
expect(header).toHaveAttribute('scope', 'col');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('lets a consumer override scope via header html props', () => {
|
|
242
|
+
// A `<th scope="row">` maps to the `rowheader` role, so query the DOM
|
|
243
|
+
// directly to assert the attribute regardless of the resolved ARIA role.
|
|
244
|
+
const plugin: TablePlugin<User> = {
|
|
245
|
+
transformHeaderCell: (props, column) =>
|
|
246
|
+
column.key === 'name'
|
|
247
|
+
? {...props, htmlProps: {...props.htmlProps, scope: 'row'}}
|
|
248
|
+
: props,
|
|
249
|
+
};
|
|
250
|
+
const {container} = render(
|
|
251
|
+
<BaseTable data={users} columns={columns} plugins={[plugin]} />,
|
|
252
|
+
);
|
|
253
|
+
const headerCells = container.querySelectorAll('thead th');
|
|
254
|
+
// columns fixture order: name, age, email — plugin overrides name → 'row'
|
|
255
|
+
expect(headerCells[0]).toHaveAttribute('scope', 'row');
|
|
256
|
+
expect(headerCells[1]).toHaveAttribute('scope', 'col');
|
|
257
|
+
expect(headerCells[2]).toHaveAttribute('scope', 'col');
|
|
258
|
+
});
|
|
259
|
+
|
|
232
260
|
it('renders data cells', () => {
|
|
233
261
|
render(<BaseTable data={users} columns={columns} />);
|
|
234
262
|
const cells = screen.getAllByRole('cell');
|
|
@@ -617,6 +645,29 @@ describe('BaseTable', () => {
|
|
|
617
645
|
// =============================================================================
|
|
618
646
|
|
|
619
647
|
describe('Table', () => {
|
|
648
|
+
it('clears a cell when the field is removed from the row object (#3595)', () => {
|
|
649
|
+
const cols: TableColumn<Record<string, unknown>>[] = [
|
|
650
|
+
{key: 'name', header: 'Name'},
|
|
651
|
+
{key: 'status', header: 'Status'},
|
|
652
|
+
];
|
|
653
|
+
const {rerender} = render(
|
|
654
|
+
<Table
|
|
655
|
+
data={[{id: '1', name: 'Alice', status: 'active'}]}
|
|
656
|
+
columns={cols}
|
|
657
|
+
idKey="id"
|
|
658
|
+
/>,
|
|
659
|
+
);
|
|
660
|
+
expect(screen.getByText('active')).toBeInTheDocument();
|
|
661
|
+
|
|
662
|
+
// Clearing a field by omitting it (optimistic update / server response)
|
|
663
|
+
// must re-render the row — the memo previously only compared the keys of
|
|
664
|
+
// the NEW item, so the deleted field's stale value kept rendering.
|
|
665
|
+
rerender(
|
|
666
|
+
<Table data={[{id: '1', name: 'Alice'}]} columns={cols} idKey="id" />,
|
|
667
|
+
);
|
|
668
|
+
expect(screen.queryByText('active')).not.toBeInTheDocument();
|
|
669
|
+
});
|
|
670
|
+
|
|
620
671
|
it('renders a table with correct structure', () => {
|
|
621
672
|
render(<Table data={users} columns={columns} />);
|
|
622
673
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
@@ -551,6 +551,19 @@ describe('useTableColumnResize', () => {
|
|
|
551
551
|
expect(handle).toHaveAttribute('aria-valuenow', '200');
|
|
552
552
|
});
|
|
553
553
|
|
|
554
|
+
it('handle has a numeric aria-valuenow on initial render (before any width is measured)', () => {
|
|
555
|
+
// A focusable role="separator" requires aria-valuenow per the ARIA spec.
|
|
556
|
+
// Before the column width has been measured there is no override width, so
|
|
557
|
+
// the handle must fall back to a defined numeric value (the column
|
|
558
|
+
// minWidth) rather than omitting the attribute.
|
|
559
|
+
render(<ResizeTable minWidth={80} />);
|
|
560
|
+
const handle = getResizeHandles()[0];
|
|
561
|
+
const valueNow = handle.getAttribute('aria-valuenow');
|
|
562
|
+
expect(valueNow).not.toBeNull();
|
|
563
|
+
expect(Number.isNaN(Number(valueNow))).toBe(false);
|
|
564
|
+
expect(valueNow).toBe('80');
|
|
565
|
+
});
|
|
566
|
+
|
|
554
567
|
it('handle has aria-label with column header text', () => {
|
|
555
568
|
render(<ResizeTable />);
|
|
556
569
|
const handle = getResizeHandles()[0];
|
|
@@ -676,7 +676,9 @@ function ResizeHandle({
|
|
|
676
676
|
<div
|
|
677
677
|
role="separator"
|
|
678
678
|
aria-orientation="vertical"
|
|
679
|
-
aria-valuenow
|
|
679
|
+
// A focusable role="separator" requires a numeric aria-valuenow, so fall
|
|
680
|
+
// back to the column's minWidth before the width has been measured.
|
|
681
|
+
aria-valuenow={currentWidth ?? minWidth}
|
|
680
682
|
aria-valuemin={minWidth}
|
|
681
683
|
aria-valuemax={maxWidth === Infinity ? undefined : maxWidth}
|
|
682
684
|
aria-label={ariaLabel}
|
|
@@ -38,6 +38,10 @@ export function paginateData<T>(
|
|
|
38
38
|
const size = Number.isFinite(pageSize)
|
|
39
39
|
? Math.max(1, Math.floor(pageSize))
|
|
40
40
|
: 10;
|
|
41
|
-
|
|
41
|
+
// page needs the same coercion: a negative start index makes Array.slice
|
|
42
|
+
// count from the END of the data (page -1 returns the tail dressed up as a
|
|
43
|
+
// page), and a fractional page returns a slice straddling two pages.
|
|
44
|
+
const p = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
|
|
45
|
+
const start = (p - 1) * size;
|
|
42
46
|
return data.slice(start, start + size);
|
|
43
47
|
}
|
|
@@ -132,6 +132,19 @@ describe('paginateData', () => {
|
|
|
132
132
|
expect(paginateData([], 1, 10)).toEqual([]);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
it('clamps invalid page numbers to the first page (#3593)', () => {
|
|
136
|
+
const data = generateItems(30);
|
|
137
|
+
// Negative pages previously fed a negative index to Array.slice, which
|
|
138
|
+
// counts from the END of the data — the tail dressed up as a page.
|
|
139
|
+
expect(paginateData(data, -1, 10)[0].id).toBe('1');
|
|
140
|
+
expect(paginateData(data, 0, 10)[0].id).toBe('1');
|
|
141
|
+
expect(paginateData(data, NaN, 10)[0].id).toBe('1');
|
|
142
|
+
// Fractional pages floor to the containing page instead of straddling two.
|
|
143
|
+
expect(paginateData(data, 1.5, 10).map(i => i.id)).toEqual(
|
|
144
|
+
paginateData(data, 1, 10).map(i => i.id),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
135
148
|
it('returns empty array when page exceeds data', () => {
|
|
136
149
|
const data = generateItems(10);
|
|
137
150
|
expect(paginateData(data, 5, 10)).toEqual([]);
|
|
@@ -302,6 +302,29 @@ describe('useTableSelectionState with filtered data', () => {
|
|
|
302
302
|
expect(screen.getByTestId('selected-keys')).toHaveTextContent('1,3');
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
it('select-all is unchecked when the filter matches nothing (#3591)', async () => {
|
|
306
|
+
const user = userEvent.setup();
|
|
307
|
+
render(<FilteredSelectionTable />);
|
|
308
|
+
|
|
309
|
+
// Select Bob, then filter to an empty result set.
|
|
310
|
+
const checkboxes = screen.getAllByLabelText('Select row');
|
|
311
|
+
await user.click(checkboxes[1]);
|
|
312
|
+
expect(screen.getByTestId('selected-keys')).toHaveTextContent('2');
|
|
313
|
+
|
|
314
|
+
const input = screen.getByTestId('filter-input');
|
|
315
|
+
await user.clear(input);
|
|
316
|
+
await user.type(input, 'zzz');
|
|
317
|
+
|
|
318
|
+
// Zero visible rows must not read as "all selected" — previously the
|
|
319
|
+
// header checkbox rendered checked here and deselect-all was a no-op.
|
|
320
|
+
const selectAll = screen.getByLabelText('Select all rows');
|
|
321
|
+
expect(selectAll).not.toBeChecked();
|
|
322
|
+
|
|
323
|
+
// The hidden selection itself is preserved (frozen), as designed.
|
|
324
|
+
await user.clear(input);
|
|
325
|
+
expect(screen.getByTestId('selected-keys')).toHaveTextContent('2');
|
|
326
|
+
});
|
|
327
|
+
|
|
305
328
|
it('selections persist when filter changes', async () => {
|
|
306
329
|
const user = userEvent.setup();
|
|
307
330
|
render(<FilteredSelectionTable />);
|
|
@@ -125,12 +125,17 @@ export function useTableSelectionState<T extends Record<string, unknown>>(
|
|
|
125
125
|
[selectedKeys, selectableIDs],
|
|
126
126
|
);
|
|
127
127
|
|
|
128
|
-
const getAllSelected = useCallback(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
const getAllSelected = useCallback(() => {
|
|
129
|
+
// Require at least one actionable row: with zero visible selectable
|
|
130
|
+
// rows (e.g. a filter with no matches while a selection exists), the
|
|
131
|
+
// union-based size comparison would read any non-empty selection as
|
|
132
|
+
// "all selected" — a checked header checkbox over an empty table that
|
|
133
|
+
// deselect-all can't clear, since the invisible keys count as frozen.
|
|
134
|
+
if (selectableIDs.size === 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return allSelectableIDs.size === selectedKeys.size;
|
|
138
|
+
}, [allSelectableIDs, selectableIDs, selectedKeys]);
|
|
134
139
|
|
|
135
140
|
// Use refs to keep onSelectAll stable
|
|
136
141
|
const frozenSelectedIDsRef = useRef(frozenSelectedIDs);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {describe, it, expect, vi} from 'vitest';
|
|
11
|
-
import {render, screen, within} from '@testing-library/react';
|
|
11
|
+
import {render, renderHook, screen, within} from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
13
|
import {useState} from 'react';
|
|
14
14
|
import {Table} from '../../Table';
|
|
@@ -421,6 +421,40 @@ describe('useTableSortableState', () => {
|
|
|
421
421
|
});
|
|
422
422
|
});
|
|
423
423
|
|
|
424
|
+
describe('NaN handling', () => {
|
|
425
|
+
it('keeps valid numbers sorted when a NaN cell is present', () => {
|
|
426
|
+
const data: Employee[] = [
|
|
427
|
+
{id: '1', name: 'A', age: 5, department: 'X', salary: 1},
|
|
428
|
+
{id: '2', name: 'B', age: NaN, department: 'X', salary: 1},
|
|
429
|
+
{id: '3', name: 'C', age: 1, department: 'X', salary: 1},
|
|
430
|
+
{id: '4', name: 'D', age: 3, department: 'X', salary: 1},
|
|
431
|
+
];
|
|
432
|
+
const {result} = renderHook(() =>
|
|
433
|
+
useTableSortableState({
|
|
434
|
+
data,
|
|
435
|
+
defaultSort: [{sortKey: 'age', direction: 'ascending'}],
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
// NaN groups at the end like null; the valid numbers stay ordered.
|
|
439
|
+
expect(result.current.sortedData.map(e => e.age)).toEqual([1, 3, 5, NaN]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('sorts NaN to the start in descending order, like null', () => {
|
|
443
|
+
const data: Employee[] = [
|
|
444
|
+
{id: '1', name: 'A', age: 5, department: 'X', salary: 1},
|
|
445
|
+
{id: '2', name: 'B', age: NaN, department: 'X', salary: 1},
|
|
446
|
+
{id: '3', name: 'C', age: 1, department: 'X', salary: 1},
|
|
447
|
+
];
|
|
448
|
+
const {result} = renderHook(() =>
|
|
449
|
+
useTableSortableState({
|
|
450
|
+
data,
|
|
451
|
+
defaultSort: [{sortKey: 'age', direction: 'descending'}],
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
expect(result.current.sortedData.map(e => e.age)).toEqual([NaN, 5, 1]);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
424
458
|
describe('null/undefined handling', () => {
|
|
425
459
|
it('sorts null values to the end', () => {
|
|
426
460
|
const data: Employee[] = [
|