@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
|
@@ -168,14 +168,18 @@ function defaultCompare<T extends Record<string, unknown>>(
|
|
|
168
168
|
const aVal = a[sortKey];
|
|
169
169
|
const bVal = b[sortKey];
|
|
170
170
|
|
|
171
|
-
// null/undefined sort to the end
|
|
172
|
-
|
|
171
|
+
// null/undefined/NaN sort to the end. NaN must not reach the numeric fast
|
|
172
|
+
// path: a NaN comparator result reads as "equal" to Array.sort, which makes
|
|
173
|
+
// the comparator inconsistent and corrupts the order of the other rows.
|
|
174
|
+
const aMissing = aVal == null || (typeof aVal === 'number' && Number.isNaN(aVal));
|
|
175
|
+
const bMissing = bVal == null || (typeof bVal === 'number' && Number.isNaN(bVal));
|
|
176
|
+
if (aMissing && bMissing) {
|
|
173
177
|
return 0;
|
|
174
178
|
}
|
|
175
|
-
if (
|
|
179
|
+
if (aMissing) {
|
|
176
180
|
return 1;
|
|
177
181
|
}
|
|
178
|
-
if (
|
|
182
|
+
if (bMissing) {
|
|
179
183
|
return -1;
|
|
180
184
|
}
|
|
181
185
|
|
|
@@ -13,7 +13,7 @@ import {useState} from 'react';
|
|
|
13
13
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
14
14
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
16
|
-
import {
|
|
16
|
+
import {TestIcon} from '../__tests__/TestIcon';
|
|
17
17
|
import {TextArea} from './TextArea';
|
|
18
18
|
|
|
19
19
|
// Mock showPopover/hidePopover since jsdom does not implement them. Used by the
|
|
@@ -193,7 +193,7 @@ describe('TextArea', () => {
|
|
|
193
193
|
label="Description"
|
|
194
194
|
value=""
|
|
195
195
|
onChange={() => {}}
|
|
196
|
-
startIcon={
|
|
196
|
+
startIcon={TestIcon}
|
|
197
197
|
/>,
|
|
198
198
|
);
|
|
199
199
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
@@ -577,7 +577,7 @@ describe('TextArea', () => {
|
|
|
577
577
|
label="Notes"
|
|
578
578
|
value=""
|
|
579
579
|
onChange={() => {}}
|
|
580
|
-
startIcon={<
|
|
580
|
+
startIcon={<TestIcon />}
|
|
581
581
|
/>,
|
|
582
582
|
);
|
|
583
583
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
13
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
|
-
import {
|
|
15
|
+
import {TestIcon} from '../__tests__/TestIcon';
|
|
16
16
|
import {TextInput} from './TextInput';
|
|
17
17
|
|
|
18
18
|
// Mock showPopover/hidePopover since jsdom does not implement them. Used by the
|
|
@@ -163,7 +163,7 @@ describe('TextInput', () => {
|
|
|
163
163
|
label="Search"
|
|
164
164
|
value=""
|
|
165
165
|
onChange={() => {}}
|
|
166
|
-
startIcon={
|
|
166
|
+
startIcon={TestIcon}
|
|
167
167
|
/>,
|
|
168
168
|
);
|
|
169
169
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
@@ -482,7 +482,7 @@ describe('TextInput', () => {
|
|
|
482
482
|
label="Search"
|
|
483
483
|
value=""
|
|
484
484
|
onChange={() => {}}
|
|
485
|
-
startIcon={<
|
|
485
|
+
startIcon={<TestIcon />}
|
|
486
486
|
/>,
|
|
487
487
|
);
|
|
488
488
|
|
|
@@ -37,11 +37,45 @@ describe('Thumbnail', () => {
|
|
|
37
37
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it('
|
|
40
|
+
it('exposes the label as an accessible name via a valid group role', () => {
|
|
41
41
|
render(<Thumbnail label="photo.png" data-testid="thumb" />);
|
|
42
|
-
|
|
42
|
+
// The accessible name must be carried by a valid named role (group),
|
|
43
|
+
// not by a bare aria-label on a generic div (aria-prohibited-attr).
|
|
44
|
+
const group = screen.getByRole('group', {name: 'photo.png'});
|
|
45
|
+
expect(group).toBe(screen.getByTestId('thumb'));
|
|
43
46
|
});
|
|
44
47
|
|
|
48
|
+
it('does not put aria-label on a generic (roleless) element', () => {
|
|
49
|
+
render(<Thumbnail label="photo.png" data-testid="thumb" />);
|
|
50
|
+
const thumb = screen.getByTestId('thumb');
|
|
51
|
+
// The labeled element must declare a role so the name is legitimate.
|
|
52
|
+
expect(thumb).toHaveAttribute('role', 'group');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps interactive children accessible while exposing the group name', () => {
|
|
56
|
+
const onRemove = vi.fn();
|
|
57
|
+
render(
|
|
58
|
+
<Thumbnail
|
|
59
|
+
src="/img.jpg"
|
|
60
|
+
alt="Clickable"
|
|
61
|
+
label="file.png"
|
|
62
|
+
onClick={vi.fn()}
|
|
63
|
+
onRemove={onRemove}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
// A group role (unlike img) must not hide descendant controls.
|
|
67
|
+
expect(
|
|
68
|
+
screen.getByRole('group', {name: 'file.png — Clickable'}),
|
|
69
|
+
).toBeInTheDocument();
|
|
70
|
+
expect(
|
|
71
|
+
screen.getByRole('button', {name: 'Open file.png — Clickable'}),
|
|
72
|
+
).toBeInTheDocument();
|
|
73
|
+
expect(
|
|
74
|
+
screen.getByRole('button', {name: 'Remove file.png — Clickable'}),
|
|
75
|
+
).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
45
79
|
it('label is shown via tooltip, not as inline text', () => {
|
|
46
80
|
render(<Thumbnail label="photo.png" data-testid="thumb" />);
|
|
47
81
|
// Label should exist in DOM (tooltip) but not as a direct child text node
|
|
@@ -58,8 +58,9 @@ export interface ThumbnailProps extends BaseProps<HTMLDivElement> {
|
|
|
58
58
|
alt?: string;
|
|
59
59
|
/**
|
|
60
60
|
* Accessible label for the thumbnail (e.g. file name).
|
|
61
|
-
* Not rendered visually — shown in a tooltip on hover
|
|
62
|
-
*
|
|
61
|
+
* Not rendered visually — shown in a tooltip on hover, exposed as the
|
|
62
|
+
* accessible name of the thumbnail group, and used as the accessible
|
|
63
|
+
* name for the remove button.
|
|
63
64
|
*/
|
|
64
65
|
label?: string;
|
|
65
66
|
/**
|
|
@@ -296,6 +297,7 @@ export function Thumbnail({
|
|
|
296
297
|
<div
|
|
297
298
|
ref={ref}
|
|
298
299
|
data-testid={testId}
|
|
300
|
+
role="group"
|
|
299
301
|
aria-label={accessibleName}
|
|
300
302
|
{...mergeProps(
|
|
301
303
|
themeProps('thumbnail'),
|
|
@@ -160,6 +160,43 @@ describe('TimeInput', () => {
|
|
|
160
160
|
expect(onChange).not.toHaveBeenCalled();
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
it('sets aria-invalid="true" when typed input is out of range', () => {
|
|
164
|
+
render(<TimeInput label="Time" onChange={() => {}} />);
|
|
165
|
+
|
|
166
|
+
const input = screen.getByRole('textbox');
|
|
167
|
+
fireEvent.change(input, {target: {value: '25:99'}});
|
|
168
|
+
|
|
169
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not set aria-invalid when typed input is a valid time', () => {
|
|
173
|
+
render(<TimeInput label="Time" onChange={() => {}} />);
|
|
174
|
+
|
|
175
|
+
const input = screen.getByRole('textbox');
|
|
176
|
+
fireEvent.change(input, {target: {value: '3:45 pm'}});
|
|
177
|
+
|
|
178
|
+
expect(input).not.toHaveAttribute('aria-invalid');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('announces an alert message when typed input is invalid', () => {
|
|
182
|
+
render(<TimeInput label="Time" onChange={() => {}} />);
|
|
183
|
+
|
|
184
|
+
const input = screen.getByRole('textbox');
|
|
185
|
+
fireEvent.change(input, {target: {value: '25:99'}});
|
|
186
|
+
|
|
187
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Invalid time');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not announce an alert message when input is valid', () => {
|
|
191
|
+
render(<TimeInput label="Time" onChange={() => {}} />);
|
|
192
|
+
|
|
193
|
+
const input = screen.getByRole('textbox');
|
|
194
|
+
fireEvent.change(input, {target: {value: '3:45 pm'}});
|
|
195
|
+
|
|
196
|
+
expect(screen.getByRole('alert')).toHaveTextContent('');
|
|
197
|
+
expect(screen.queryByText('Invalid time')).not.toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
|
|
163
200
|
it('calls onChange on blur when input is valid', async () => {
|
|
164
201
|
const user = userEvent.setup();
|
|
165
202
|
const onChange = vi.fn();
|
|
@@ -635,7 +635,9 @@ export function TimeInput({
|
|
|
635
635
|
data-autofocus={hasAutoFocus || undefined}
|
|
636
636
|
aria-describedby={ariaDescribedBy}
|
|
637
637
|
aria-required={isRequired === true ? 'true' : undefined}
|
|
638
|
-
aria-invalid={
|
|
638
|
+
aria-invalid={
|
|
639
|
+
status?.type === 'error' || !isInputValid ? 'true' : undefined
|
|
640
|
+
}
|
|
639
641
|
aria-busy={isBusy || undefined}
|
|
640
642
|
aria-labelledby={ariaLabelledBy}
|
|
641
643
|
{...stylex.props(
|
|
@@ -644,6 +646,14 @@ export function TimeInput({
|
|
|
644
646
|
!isInputValid && styles.inputInvalid,
|
|
645
647
|
)}
|
|
646
648
|
/>
|
|
649
|
+
{/*
|
|
650
|
+
Live region announcing invalid typed input to assistive technology.
|
|
651
|
+
The value silently reverts on blur, so without this a screen-reader
|
|
652
|
+
user would get no feedback that their entry was rejected (WCAG 3.3.1).
|
|
653
|
+
*/}
|
|
654
|
+
<VisuallyHidden as="div" role="alert" aria-live="assertive">
|
|
655
|
+
{!isInputValid ? 'Invalid time' : ''}
|
|
656
|
+
</VisuallyHidden>
|
|
647
657
|
{isBusy && <Spinner size="sm" />}
|
|
648
658
|
{hasClear && value && !isDisabled && (
|
|
649
659
|
<button
|
package/src/Toast/Toast.tsx
CHANGED
|
@@ -121,8 +121,14 @@ export function Toast({
|
|
|
121
121
|
// Will be initialized by startTimer when actually used
|
|
122
122
|
const startTimeRef = useRef<number | null>(null);
|
|
123
123
|
|
|
124
|
+
// Read onDismiss through a ref: the viewport re-creates it on every render
|
|
125
|
+
// (another toast arriving/exiting), and a startTimer that depends on it
|
|
126
|
+
// would restart — and un-pause — this toast's timer on unrelated renders.
|
|
127
|
+
const onDismissRef = useRef(onDismiss);
|
|
128
|
+
onDismissRef.current = onDismiss;
|
|
129
|
+
|
|
124
130
|
const startTimer = useCallback(() => {
|
|
125
|
-
if (!isAutoHide) {
|
|
131
|
+
if (!isAutoHide || isPausedRef.current) {
|
|
126
132
|
return;
|
|
127
133
|
}
|
|
128
134
|
if (timerRef.current) {
|
|
@@ -130,9 +136,9 @@ export function Toast({
|
|
|
130
136
|
}
|
|
131
137
|
startTimeRef.current = Date.now();
|
|
132
138
|
timerRef.current = setTimeout(() => {
|
|
133
|
-
|
|
139
|
+
onDismissRef.current('auto');
|
|
134
140
|
}, remainingRef.current);
|
|
135
|
-
}, [isAutoHide
|
|
141
|
+
}, [isAutoHide]);
|
|
136
142
|
|
|
137
143
|
const pauseTimer = useCallback(() => {
|
|
138
144
|
if (!isAutoHide || isPausedRef.current) {
|
|
@@ -165,6 +171,8 @@ export function Toast({
|
|
|
165
171
|
clearTimeout(timerRef.current);
|
|
166
172
|
}
|
|
167
173
|
};
|
|
174
|
+
// startTimer's identity is stable per isAutoHide, so this runs on mount
|
|
175
|
+
// and on a genuine duration change — not on unrelated viewport renders.
|
|
168
176
|
}, [autoHideDuration, startTimer]);
|
|
169
177
|
|
|
170
178
|
// Pause the auto-hide timer while the window is not focused, so a toast
|
|
@@ -212,3 +212,67 @@ describe('ToastViewport region ARIA', () => {
|
|
|
212
212
|
expect(region).not.toHaveAttribute('aria-modal');
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
describe('toast timer lifecycle (#3589)', () => {
|
|
217
|
+
it('fires onHide exactly once when dismissed twice during the exit window', () => {
|
|
218
|
+
const onHide = vi.fn();
|
|
219
|
+
renderViewport(
|
|
220
|
+
<ShowToastButton options={{body: 'Once', onHide}} triggerLabel="Show" />,
|
|
221
|
+
);
|
|
222
|
+
act(() => {
|
|
223
|
+
fireEvent.click(screen.getByText('Show'));
|
|
224
|
+
});
|
|
225
|
+
const dismiss = screen.getByRole('button', {name: 'Dismiss notification'});
|
|
226
|
+
act(() => {
|
|
227
|
+
fireEvent.click(dismiss);
|
|
228
|
+
});
|
|
229
|
+
// The toast stays mounted during its exit transition; a second click
|
|
230
|
+
// lands on the same still-mounted button.
|
|
231
|
+
act(() => {
|
|
232
|
+
fireEvent.click(dismiss);
|
|
233
|
+
});
|
|
234
|
+
expect(onHide).toHaveBeenCalledTimes(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('keeps a window-blur pause alive when another toast arrives', () => {
|
|
238
|
+
vi.useFakeTimers();
|
|
239
|
+
try {
|
|
240
|
+
const onHide = vi.fn();
|
|
241
|
+
renderViewport(
|
|
242
|
+
<>
|
|
243
|
+
<ShowToastButton
|
|
244
|
+
options={{body: 'Paused', autoHideDuration: 3000, onHide}}
|
|
245
|
+
triggerLabel="Show A"
|
|
246
|
+
/>
|
|
247
|
+
<ShowToastButton options={INFO_B} triggerLabel="Show B" />
|
|
248
|
+
</>,
|
|
249
|
+
);
|
|
250
|
+
act(() => {
|
|
251
|
+
fireEvent.click(screen.getByText('Show A'));
|
|
252
|
+
});
|
|
253
|
+
act(() => {
|
|
254
|
+
fireEvent.blur(window);
|
|
255
|
+
});
|
|
256
|
+
// A second toast arriving re-renders the viewport; the paused timer
|
|
257
|
+
// must not silently restart.
|
|
258
|
+
act(() => {
|
|
259
|
+
fireEvent.click(screen.getByText('Show B'));
|
|
260
|
+
});
|
|
261
|
+
act(() => {
|
|
262
|
+
vi.advanceTimersByTime(60_000);
|
|
263
|
+
});
|
|
264
|
+
expect(onHide).not.toHaveBeenCalled();
|
|
265
|
+
// Focus returns: the remaining time resumes and completes normally.
|
|
266
|
+
act(() => {
|
|
267
|
+
fireEvent.focus(window);
|
|
268
|
+
});
|
|
269
|
+
act(() => {
|
|
270
|
+
vi.advanceTimersByTime(60_000);
|
|
271
|
+
});
|
|
272
|
+
expect(onHide).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(onHide).toHaveBeenCalledWith('auto');
|
|
274
|
+
} finally {
|
|
275
|
+
vi.useRealTimers();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -114,6 +114,9 @@ export function ToastViewport({
|
|
|
114
114
|
|
|
115
115
|
// Show the popover on mount so it enters the top layer.
|
|
116
116
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
// Toast ids whose exit has begun — guards onHide from double-firing (see
|
|
118
|
+
// removeToast). Mirrors exitingIds state, readable synchronously.
|
|
119
|
+
const exitingIdsRef = useRef<Set<string>>(new Set());
|
|
117
120
|
// The element that was focused before the user jumped into the viewport
|
|
118
121
|
// (via F6). Used to restore focus once all toasts are dismissed so focus
|
|
119
122
|
// never falls to <body>.
|
|
@@ -171,6 +174,14 @@ export function ToastViewport({
|
|
|
171
174
|
}, []);
|
|
172
175
|
|
|
173
176
|
const removeToast = useCallback((id: string, reason: ToastDismissReason) => {
|
|
177
|
+
// An exiting toast stays in toastsRef until its exit transition ends, so
|
|
178
|
+
// a second dismissal inside that window (double-click, auto-timer plus
|
|
179
|
+
// manual dismiss()) would re-fire onHide. Track exiting ids in a ref —
|
|
180
|
+
// the exitingIds state dedupe below runs too late to protect onHide.
|
|
181
|
+
if (exitingIdsRef.current.has(id)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
exitingIdsRef.current.add(id);
|
|
174
185
|
const entry = toastsRef.current.find(t => t.id === id);
|
|
175
186
|
if (entry) {
|
|
176
187
|
entry.options.onHide?.(reason);
|
|
@@ -210,6 +221,7 @@ export function ToastViewport({
|
|
|
210
221
|
}, []);
|
|
211
222
|
|
|
212
223
|
const handleExited = useCallback((id: string) => {
|
|
224
|
+
exitingIdsRef.current.delete(id);
|
|
213
225
|
setExitingIds(prev => {
|
|
214
226
|
if (!prev.has(id)) {
|
|
215
227
|
return prev;
|
|
@@ -17,6 +17,7 @@ export const docs = {
|
|
|
17
17
|
theming: {
|
|
18
18
|
targets: [
|
|
19
19
|
{className: 'astryx-toggle-button-group'},
|
|
20
|
+
{className: 'astryx-toggle-button', states: ['isPressed']},
|
|
20
21
|
],
|
|
21
22
|
},
|
|
22
23
|
description: 'A button that toggles between pressed and unpressed states. Thin wrapper over Button with controlled toggle pattern, icon swap, and font weight emphasis.',
|
|
@@ -189,7 +189,7 @@ export const docs = {
|
|
|
189
189
|
],
|
|
190
190
|
theming: {
|
|
191
191
|
targets: [
|
|
192
|
-
{className: 'astryx-tokenizer', visualProps: ['size']},
|
|
192
|
+
{className: 'astryx-tokenizer', visualProps: ['size', 'status']},
|
|
193
193
|
],
|
|
194
194
|
},
|
|
195
195
|
usage: {
|
|
@@ -390,7 +390,7 @@ export const docsZh = {
|
|
|
390
390
|
],
|
|
391
391
|
theming: {
|
|
392
392
|
targets: [
|
|
393
|
-
{className: 'astryx-tokenizer', visualProps: ['size']},
|
|
393
|
+
{className: 'astryx-tokenizer', visualProps: ['size', 'status']},
|
|
394
394
|
],
|
|
395
395
|
},
|
|
396
396
|
usage: {
|
|
@@ -17,7 +17,7 @@ export const docs = {
|
|
|
17
17
|
theming: {
|
|
18
18
|
targets: [
|
|
19
19
|
{className: 'astryx-top-nav', states: ['mode']},
|
|
20
|
-
{className: 'astryx-top-nav-item', states: ['mode']},
|
|
20
|
+
{className: 'astryx-top-nav-item', states: ['mode', 'selected']},
|
|
21
21
|
{className: 'astryx-top-nav-heading'},
|
|
22
22
|
{className: 'astryx-top-nav-mega-menu', states: ['mode']},
|
|
23
23
|
{className: 'astryx-top-nav-mega-menu-item', states: ['mode']},
|
|
@@ -25,7 +25,7 @@ export const docs = {
|
|
|
25
25
|
theming: {
|
|
26
26
|
targets: [
|
|
27
27
|
{className: 'astryx-tree-list', visualProps: ['density']},
|
|
28
|
-
{className: 'astryx-tree-list-item', states: ['selected', 'disabled']},
|
|
28
|
+
{className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
|
|
29
29
|
],
|
|
30
30
|
},
|
|
31
31
|
components: [
|
|
@@ -89,7 +89,7 @@ export const docsZh = {
|
|
|
89
89
|
theming: {
|
|
90
90
|
targets: [
|
|
91
91
|
{className: 'astryx-tree-list', visualProps: ['density']},
|
|
92
|
-
{className: 'astryx-tree-list-item', states: ['selected', 'disabled']},
|
|
92
|
+
{className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
|
|
93
93
|
],
|
|
94
94
|
},
|
|
95
95
|
components: [
|
|
@@ -8,6 +8,16 @@ export const docs = {
|
|
|
8
8
|
displayName: 'Base Typeahead',
|
|
9
9
|
isHiddenFromOverview: true,
|
|
10
10
|
description: 'Unstyled combobox engine providing input, search, keyboard navigation, and dropdown. No wrapper div, no border styling, no token rendering. Used by Typeahead and Tokenizer for custom compositions.',
|
|
11
|
+
usage: {
|
|
12
|
+
description: 'Unstyled combobox engine providing input, search, keyboard navigation, and dropdown. No wrapper div, no border styling, no token rendering. Used by Typeahead and Tokenizer for custom compositions.',
|
|
13
|
+
bestPractices: [
|
|
14
|
+
{ guidance: true, description: 'Use Typeahead or Tokenizer for standard fields; they wrap BaseTypeahead with the wrapper div, border styling, and token rendering it intentionally omits.' },
|
|
15
|
+
{ guidance: true, description: 'Provide your own wrapper div with border and layout when composing directly, since BaseTypeahead renders no visual chrome of its own.' },
|
|
16
|
+
{ guidance: true, description: 'Pass anchorRef pointing to your wrapper so the dropdown positions against your custom input chrome, not just the bare input element.' },
|
|
17
|
+
{ guidance: false, description: 'Expect a wrapper div, border, or token rendering. BaseTypeahead is an engine only; all visual chrome is the caller\'s responsibility.' },
|
|
18
|
+
{ guidance: false, description: 'Use BaseTypeahead when Typeahead or Tokenizer would suffice; the extra wrapper and styling work is only justified for truly custom compositions.' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
11
21
|
props: [
|
|
12
22
|
{
|
|
13
23
|
name: 'searchSource',
|
|
@@ -226,6 +236,15 @@ export const docsDense = {
|
|
|
226
236
|
isHiddenFromOverview: true,
|
|
227
237
|
displayName: 'Base Typeahead',
|
|
228
238
|
description: 'Unstyled combobox engine; input+search+keyboard nav+dropdown. No wrapper/border/token. Used by Typeahead+Tokenizer.',
|
|
239
|
+
usage: {
|
|
240
|
+
bestPractices: [
|
|
241
|
+
{ guidance: true, description: 'Use Typeahead or Tokenizer for standard fields; they add the wrapper, border, and token rendering BaseTypeahead omits.' },
|
|
242
|
+
{ guidance: true, description: 'Provide your own wrapper div with border and layout when composing directly; BaseTypeahead renders no chrome.' },
|
|
243
|
+
{ guidance: true, description: 'Pass anchorRef to your wrapper so the dropdown positions against your input chrome, not the bare input.' },
|
|
244
|
+
{ guidance: false, description: 'Expect wrapper, border, or token rendering. Engine only; chrome is the caller\'s job.' },
|
|
245
|
+
{ guidance: false, description: 'Use BaseTypeahead when Typeahead or Tokenizer suffice; extra work only pays off for custom compositions.' },
|
|
246
|
+
],
|
|
247
|
+
},
|
|
229
248
|
propDescriptions: {
|
|
230
249
|
searchSource: 'Data source w/ search+bootstrap methods.',
|
|
231
250
|
value: 'Currently selected item.',
|
|
@@ -410,7 +410,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
410
410
|
const performSearch = useCallback(
|
|
411
411
|
async (searchQuery: string) => {
|
|
412
412
|
searchSource.cancel?.();
|
|
413
|
-
|
|
413
|
+
// Claim a new generation so overlapping searches can't race: an
|
|
414
|
+
// in-flight response for an older query fails the gen check below
|
|
415
|
+
// instead of overwriting the newer results.
|
|
416
|
+
const gen = ++searchGenRef.current;
|
|
414
417
|
setIsLoading(true);
|
|
415
418
|
setHasSearched(true);
|
|
416
419
|
try {
|
|
@@ -451,7 +454,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
451
454
|
|
|
452
455
|
// Perform bootstrap
|
|
453
456
|
const performBootstrap = useCallback(async () => {
|
|
454
|
-
const gen = searchGenRef.current;
|
|
457
|
+
const gen = ++searchGenRef.current;
|
|
455
458
|
setIsLoading(true);
|
|
456
459
|
try {
|
|
457
460
|
const bootstrapResults = await searchSource.bootstrap();
|
|
@@ -168,7 +168,7 @@ export const docs = {
|
|
|
168
168
|
components: [{name: 'BaseTypeahead'}, {name: 'TypeaheadItem'}],
|
|
169
169
|
theming: {
|
|
170
170
|
targets: [
|
|
171
|
-
{className: 'astryx-typeahead', visualProps: ['status']},
|
|
171
|
+
{className: 'astryx-typeahead', visualProps: ['status', 'size']},
|
|
172
172
|
{className: 'astryx-typeahead-dropdown'},
|
|
173
173
|
{className: 'astryx-typeahead-item'},
|
|
174
174
|
],
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
afterAll,
|
|
19
19
|
beforeEach,
|
|
20
20
|
} from 'vitest';
|
|
21
|
-
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
21
|
+
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
22
22
|
import userEvent from '@testing-library/user-event';
|
|
23
23
|
import {Typeahead} from './Typeahead';
|
|
24
24
|
import {BaseTypeahead} from './BaseTypeahead';
|
|
@@ -314,6 +314,50 @@ describe('BaseTypeahead focus-out', () => {
|
|
|
314
314
|
});
|
|
315
315
|
|
|
316
316
|
describe('Typeahead', () => {
|
|
317
|
+
describe('out-of-order async results', () => {
|
|
318
|
+
it('discards a stale response that resolves after a newer query', async () => {
|
|
319
|
+
const resolvers = new Map<string, (items: SearchableItem[]) => void>();
|
|
320
|
+
const rawSource: SearchSource = {
|
|
321
|
+
search: async (query: string) =>
|
|
322
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
323
|
+
resolvers.set(query, resolve);
|
|
324
|
+
}),
|
|
325
|
+
bootstrap: () => [],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
render(
|
|
329
|
+
<Typeahead
|
|
330
|
+
label="Fruit"
|
|
331
|
+
searchSource={rawSource}
|
|
332
|
+
value={null}
|
|
333
|
+
onChange={() => {}}
|
|
334
|
+
debounceMs={0}
|
|
335
|
+
/>,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const input = screen.getByRole('combobox');
|
|
339
|
+
fireEvent.change(input, {target: {value: 'a'}});
|
|
340
|
+
fireEvent.change(input, {target: {value: 'ap'}});
|
|
341
|
+
|
|
342
|
+
// The newer query resolves first…
|
|
343
|
+
await act(async () => {
|
|
344
|
+
resolvers.get('ap')!([{id: 'apple', label: 'Apple'}]);
|
|
345
|
+
});
|
|
346
|
+
expect(screen.getByText('Apple')).toBeInTheDocument();
|
|
347
|
+
|
|
348
|
+
// …then the abandoned query's slow response arrives and must be
|
|
349
|
+
// discarded rather than overwriting the current results.
|
|
350
|
+
await act(async () => {
|
|
351
|
+
resolvers.get('a')!([
|
|
352
|
+
{id: 'avocado', label: 'Avocado'},
|
|
353
|
+
{id: 'apricot', label: 'Apricot'},
|
|
354
|
+
]);
|
|
355
|
+
});
|
|
356
|
+
expect(screen.getByText('Apple')).toBeInTheDocument();
|
|
357
|
+
expect(screen.queryByText('Avocado')).not.toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
317
361
|
it('renders with label', () => {
|
|
318
362
|
render(
|
|
319
363
|
<Typeahead
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file TestIcon.tsx
|
|
5
|
+
* @input Standard SVG props
|
|
6
|
+
* @output A minimal SVG React component for tests
|
|
7
|
+
* @position Test fixture; a stand-in external SVG icon for Icon component-mode tests
|
|
8
|
+
*
|
|
9
|
+
* A trivial SVG component used by tests that need to pass a custom SVG icon to
|
|
10
|
+
* `Icon` (component mode) or component `*Icon` props. It forwards all standard
|
|
11
|
+
* SVG props and its ref so tests exercising prop/ref forwarding behave the same
|
|
12
|
+
* as a real third-party icon component.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, {type SVGProps} from 'react';
|
|
16
|
+
|
|
17
|
+
export function TestIcon({
|
|
18
|
+
ref,
|
|
19
|
+
...props
|
|
20
|
+
}: SVGProps<SVGSVGElement> & {ref?: React.Ref<SVGSVGElement>}) {
|
|
21
|
+
return (
|
|
22
|
+
<svg ref={ref} viewBox="0 0 24 24" {...props}>
|
|
23
|
+
<path d="M4 4h16v16H4z" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|