@terpjs/react-core 0.7.0 → 0.8.0
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/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +242 -19
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/ui/Checkbox.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "../styles";
|
|
4
4
|
import { useUiText } from "../uiText";
|
|
@@ -6,22 +6,6 @@ import type { UiText } from "../uiText";
|
|
|
6
6
|
|
|
7
7
|
injectTerpStyles();
|
|
8
8
|
|
|
9
|
-
const labelStyle: CSSProperties = {
|
|
10
|
-
display: "inline-flex",
|
|
11
|
-
alignItems: "center",
|
|
12
|
-
gap: "var(--space-2)",
|
|
13
|
-
color: "var(--color-neutral-900)",
|
|
14
|
-
cursor: "pointer",
|
|
15
|
-
fontSize: "var(--font-size-sm)",
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const inputStyle: CSSProperties = {
|
|
19
|
-
inlineSize: "1rem",
|
|
20
|
-
blockSize: "1rem",
|
|
21
|
-
accentColor: "var(--color-fg-accent)",
|
|
22
|
-
cursor: "pointer",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
9
|
export interface CheckboxProps
|
|
26
10
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange"> {
|
|
27
11
|
label: UiText;
|
|
@@ -34,7 +18,7 @@ export interface CheckboxProps
|
|
|
34
18
|
export function Checkbox({ label, checked, defaultChecked, onChange, style, ...rest }: CheckboxProps) {
|
|
35
19
|
const resolve = useUiText();
|
|
36
20
|
return (
|
|
37
|
-
<label style={
|
|
21
|
+
<label data-terp="control-label" style={style}>
|
|
38
22
|
<input
|
|
39
23
|
{...rest}
|
|
40
24
|
type="checkbox"
|
|
@@ -42,7 +26,6 @@ export function Checkbox({ label, checked, defaultChecked, onChange, style, ...r
|
|
|
42
26
|
checked={checked}
|
|
43
27
|
defaultChecked={defaultChecked}
|
|
44
28
|
onChange={(event) => onChange?.(event.currentTarget.checked)}
|
|
45
|
-
style={inputStyle}
|
|
46
29
|
/>
|
|
47
30
|
<span>{resolve(label)}</span>
|
|
48
31
|
</label>
|
package/src/ui/Combobox.test.tsx
CHANGED
|
@@ -55,4 +55,26 @@ describe("Combobox", () => {
|
|
|
55
55
|
expect(screen.getByRole("combobox", { name: "Assignee" })).toBeDisabled();
|
|
56
56
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("opens on mount with the cursor on the selection when asked", () => {
|
|
60
|
+
// The prop exists so the listbox can be rendered deterministically — it had no way in
|
|
61
|
+
// at all before, which is why sixteen sheet rules for this subtree went unpainted by
|
|
62
|
+
// both visual lanes from the moment they were written.
|
|
63
|
+
render(<Combobox aria-label="Country" value="be" options={options} defaultOpen />);
|
|
64
|
+
const listbox = screen.getByRole("listbox");
|
|
65
|
+
expect(listbox).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true");
|
|
67
|
+
// The cursor lands on the selection rather than nowhere: an open list with no active
|
|
68
|
+
// option is a state focusing the box never produces.
|
|
69
|
+
const active = listbox.querySelector('[data-active="true"]');
|
|
70
|
+
expect(active?.textContent).toBe("Belgium");
|
|
71
|
+
expect(active).toHaveAttribute("aria-selected", "true");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the listbox shut when disabled, even with defaultOpen", () => {
|
|
75
|
+
render(
|
|
76
|
+
<Combobox aria-label="Country" value="be" options={options} defaultOpen disabled />,
|
|
77
|
+
);
|
|
78
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
58
80
|
});
|
package/src/ui/Combobox.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { InputHTMLAttributes, KeyboardEvent } from "react";
|
|
3
3
|
|
|
4
4
|
import { injectTerpStyles } from "../styles";
|
|
5
5
|
import { useUiText } from "../uiText";
|
|
6
6
|
import type { UiText } from "../uiText";
|
|
7
|
-
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
8
7
|
|
|
9
8
|
injectTerpStyles();
|
|
10
9
|
|
|
@@ -24,73 +23,16 @@ export interface ComboboxProps
|
|
|
24
23
|
loadingText?: UiText;
|
|
25
24
|
noOptionsText?: UiText;
|
|
26
25
|
clearable?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Open the listbox on mount (uncontrolled), the same shape `Popover` and `Menu` take.
|
|
28
|
+
*
|
|
29
|
+
* The cursor starts on the selection rather than nowhere, which is what focusing the box
|
|
30
|
+
* does — an already-open list with no active option would be a state the component cannot
|
|
31
|
+
* otherwise reach.
|
|
32
|
+
*/
|
|
33
|
+
defaultOpen?: boolean;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
const wrapperStyle: CSSProperties = { position: "relative", display: "grid" };
|
|
30
|
-
const inputWrapStyle: CSSProperties = { position: "relative", display: "grid" };
|
|
31
|
-
const inputStyle: CSSProperties = {
|
|
32
|
-
...CONTROL_TEXT_STYLE,
|
|
33
|
-
lineHeight: 1.2,
|
|
34
|
-
width: "100%",
|
|
35
|
-
minWidth: 0,
|
|
36
|
-
minHeight: "2.25rem",
|
|
37
|
-
padding: "0 calc(var(--space-3) + 1.5rem) 0 var(--space-3)",
|
|
38
|
-
border: "1px solid var(--color-neutral-300)",
|
|
39
|
-
borderRadius: "var(--radius-md)",
|
|
40
|
-
color: "var(--color-neutral-900)",
|
|
41
|
-
background: "var(--color-neutral-0)",
|
|
42
|
-
boxSizing: "border-box",
|
|
43
|
-
};
|
|
44
|
-
const clearStyle: CSSProperties = {
|
|
45
|
-
position: "absolute",
|
|
46
|
-
insetInlineEnd: "var(--space-1)",
|
|
47
|
-
insetBlockStart: "50%",
|
|
48
|
-
transform: "translateY(-50%)",
|
|
49
|
-
border: "none",
|
|
50
|
-
background: "transparent",
|
|
51
|
-
color: "var(--color-neutral-500)",
|
|
52
|
-
cursor: "pointer",
|
|
53
|
-
minWidth: "1.75rem",
|
|
54
|
-
minHeight: "1.75rem",
|
|
55
|
-
borderRadius: "var(--radius-sm)",
|
|
56
|
-
};
|
|
57
|
-
const listStyle: CSSProperties = {
|
|
58
|
-
position: "absolute",
|
|
59
|
-
insetInlineStart: 0,
|
|
60
|
-
insetInlineEnd: 0,
|
|
61
|
-
insetBlockStart: "calc(100% + var(--space-1))",
|
|
62
|
-
zIndex: 50,
|
|
63
|
-
display: "grid",
|
|
64
|
-
gap: "var(--space-1)",
|
|
65
|
-
maxHeight: "16rem",
|
|
66
|
-
overflowY: "auto",
|
|
67
|
-
padding: "var(--space-1)",
|
|
68
|
-
background: "var(--color-neutral-0)",
|
|
69
|
-
border: "1px solid var(--color-neutral-200)",
|
|
70
|
-
borderRadius: "var(--radius-lg)",
|
|
71
|
-
boxShadow: "var(--shadow-lg)",
|
|
72
|
-
};
|
|
73
|
-
const optionStyle = (active: boolean, selected: boolean, disabled: boolean): CSSProperties => ({
|
|
74
|
-
...CONTROL_TEXT_STYLE,
|
|
75
|
-
textAlign: "left",
|
|
76
|
-
padding: "var(--space-2) var(--space-3)",
|
|
77
|
-
border: "none",
|
|
78
|
-
borderRadius: "var(--radius-sm)",
|
|
79
|
-
background: active ? "var(--color-neutral-100)" : "transparent",
|
|
80
|
-
color: disabled
|
|
81
|
-
? "var(--color-neutral-400)"
|
|
82
|
-
: selected
|
|
83
|
-
? "var(--color-fg-accent)"
|
|
84
|
-
: "var(--color-neutral-900)",
|
|
85
|
-
cursor: disabled ? "not-allowed" : "pointer",
|
|
86
|
-
fontWeight: selected ? "var(--font-weight-semibold)" as never : "var(--font-weight-normal)" as never,
|
|
87
|
-
});
|
|
88
|
-
const emptyStyle: CSSProperties = {
|
|
89
|
-
padding: "var(--space-2) var(--space-3)",
|
|
90
|
-
color: "var(--color-neutral-500)",
|
|
91
|
-
fontSize: "var(--font-size-sm)",
|
|
92
|
-
};
|
|
93
|
-
|
|
94
36
|
/** Filterable ARIA combobox/typeahead with controlled or uncontrolled single selection. */
|
|
95
37
|
export function Combobox({
|
|
96
38
|
options,
|
|
@@ -101,6 +43,7 @@ export function Combobox({
|
|
|
101
43
|
loadingText = "Loading…",
|
|
102
44
|
noOptionsText = "No options",
|
|
103
45
|
clearable = false,
|
|
46
|
+
defaultOpen = false,
|
|
104
47
|
disabled,
|
|
105
48
|
onBlur,
|
|
106
49
|
onFocus,
|
|
@@ -117,8 +60,16 @@ export function Combobox({
|
|
|
117
60
|
const selectedValue = value ?? uncontrolledValue;
|
|
118
61
|
const selectedOption = options.find((option) => option.value === selectedValue) ?? null;
|
|
119
62
|
const [query, setQuery] = useState(() => (selectedOption ? resolve(selectedOption.label) : ""));
|
|
120
|
-
const [open, setOpen] = useState(
|
|
121
|
-
const [activeValue, setActiveValue] = useState<string | null>(
|
|
63
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
64
|
+
const [activeValue, setActiveValue] = useState<string | null>(
|
|
65
|
+
defaultOpen ? selectedOption?.value ?? null : null,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// What the DOM should say, as opposed to what the state happens to hold. The listbox render
|
|
69
|
+
// was already guarded on `disabled` while aria-expanded was not, so a disabled combobox seeded
|
|
70
|
+
// open advertised role="combobox" aria-expanded="true" with aria-controls pointing at an id
|
|
71
|
+
// that is not in the document. Derived once, so the three cannot drift apart again.
|
|
72
|
+
const isOpen = open && disabled !== true;
|
|
122
73
|
|
|
123
74
|
const renderedOptions = useMemo(() => {
|
|
124
75
|
const normalized = query.trim().toLocaleLowerCase();
|
|
@@ -227,17 +178,17 @@ export function Combobox({
|
|
|
227
178
|
}
|
|
228
179
|
|
|
229
180
|
return (
|
|
230
|
-
<div ref={rootRef}
|
|
231
|
-
<div
|
|
181
|
+
<div ref={rootRef} data-terp="combobox">
|
|
182
|
+
<div data-terp="combobox-field">
|
|
232
183
|
<input
|
|
233
184
|
{...rest}
|
|
234
185
|
ref={inputRef}
|
|
235
186
|
data-terp="input"
|
|
236
187
|
role="combobox"
|
|
237
188
|
aria-autocomplete="list"
|
|
238
|
-
aria-expanded={
|
|
189
|
+
aria-expanded={isOpen}
|
|
239
190
|
aria-controls={`${baseId}-listbox`}
|
|
240
|
-
aria-activedescendant={
|
|
191
|
+
aria-activedescendant={isOpen && activeOption !== null ? `${baseId}-option-${activeOption.value}` : undefined}
|
|
241
192
|
aria-invalid={rest["aria-invalid"]}
|
|
242
193
|
disabled={disabled}
|
|
243
194
|
placeholder={placeholder}
|
|
@@ -259,7 +210,7 @@ export function Combobox({
|
|
|
259
210
|
}
|
|
260
211
|
}}
|
|
261
212
|
onKeyDown={handleKeyDown}
|
|
262
|
-
style={
|
|
213
|
+
style={style}
|
|
263
214
|
/>
|
|
264
215
|
{clearable && !disabled && query.length > 0 && (
|
|
265
216
|
<button
|
|
@@ -270,18 +221,17 @@ export function Combobox({
|
|
|
270
221
|
commit(null);
|
|
271
222
|
inputRef.current?.focus();
|
|
272
223
|
}}
|
|
273
|
-
style={clearStyle}
|
|
274
224
|
>
|
|
275
225
|
×
|
|
276
226
|
</button>
|
|
277
227
|
)}
|
|
278
228
|
</div>
|
|
279
|
-
{
|
|
280
|
-
<div id={`${baseId}-listbox`} role="listbox"
|
|
229
|
+
{isOpen && (
|
|
230
|
+
<div id={`${baseId}-listbox`} role="listbox" data-terp="combobox-list">
|
|
281
231
|
{loading ? (
|
|
282
|
-
<div role="status"
|
|
232
|
+
<div role="status" data-terp="combobox-empty">{resolve(loadingText)}</div>
|
|
283
233
|
) : renderedOptions.length === 0 ? (
|
|
284
|
-
<div
|
|
234
|
+
<div data-terp="combobox-empty">{resolve(noOptionsText)}</div>
|
|
285
235
|
) : (
|
|
286
236
|
renderedOptions.map((option) => {
|
|
287
237
|
const label = resolve(option.label);
|
|
@@ -299,7 +249,8 @@ export function Combobox({
|
|
|
299
249
|
onMouseDown={(event) => event.preventDefault()}
|
|
300
250
|
onMouseEnter={() => setActiveValue(option.value)}
|
|
301
251
|
onClick={() => commit(option)}
|
|
302
|
-
|
|
252
|
+
data-terp="combobox-option"
|
|
253
|
+
data-active={active ? "true" : undefined}
|
|
303
254
|
>
|
|
304
255
|
{label}
|
|
305
256
|
</button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "../locale";
|
|
@@ -27,6 +27,110 @@ describe("DatePicker", () => {
|
|
|
27
27
|
expect(screen.queryByRole("grid")).not.toBeInTheDocument();
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// The ARIA shape and the roving cursor, both of which were wrong and neither of which
|
|
31
|
+
// any lane could see: the calendar is only reachable through an open Popover, nothing in
|
|
32
|
+
// the repo opened one, and the visual baselines capture resting state only.
|
|
33
|
+
it("renders a valid grid of week rows, not 42 cells hanging off the grid", () => {
|
|
34
|
+
render(
|
|
35
|
+
<LocaleProvider locales={{ en: LOCALE_EN }}>
|
|
36
|
+
<DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
|
|
37
|
+
</LocaleProvider>,
|
|
38
|
+
);
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "Due date" }));
|
|
40
|
+
const grid = screen.getByRole("grid");
|
|
41
|
+
const rows = screen.getAllByRole("row");
|
|
42
|
+
expect(rows).toHaveLength(6);
|
|
43
|
+
// Owned by the grid, and owning the cells: `grid` requires `row` children and a
|
|
44
|
+
// `gridcell` requires a `row` parent, so a flat grid fails both rules at once. Asserted
|
|
45
|
+
// as a DOM relationship rather than a count, because 42 cells in the tree somewhere is
|
|
46
|
+
// what the invalid version had too.
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
expect(row.parentElement).toBe(grid);
|
|
49
|
+
expect(row.querySelectorAll('[role="gridcell"]')).toHaveLength(7);
|
|
50
|
+
}
|
|
51
|
+
expect(grid.querySelectorAll(':scope > [role="gridcell"]')).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("moves DOM focus with the roving cursor, not just tabIndex", async () => {
|
|
55
|
+
render(
|
|
56
|
+
<LocaleProvider locales={{ en: LOCALE_EN }}>
|
|
57
|
+
<DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
|
|
58
|
+
</LocaleProvider>,
|
|
59
|
+
);
|
|
60
|
+
fireEvent.click(screen.getByRole("button", { name: "Due date" }));
|
|
61
|
+
const grid = screen.getByRole("grid");
|
|
62
|
+
const cursor = () => grid.querySelector<HTMLElement>('[tabindex="0"]');
|
|
63
|
+
|
|
64
|
+
// Opening focuses the cursor (deferred a tick — the panel is portalled).
|
|
65
|
+
await waitFor(() => expect(document.activeElement).toBe(cursor()));
|
|
66
|
+
expect(cursor()?.textContent).toBe("7");
|
|
67
|
+
|
|
68
|
+
fireEvent.keyDown(grid, { key: "ArrowRight" });
|
|
69
|
+
expect(cursor()?.textContent).toBe("8");
|
|
70
|
+
// The bug this pins: tabIndex moved and the browser's focus did not, so the focus ring
|
|
71
|
+
// and every screen reader stayed on the day the calendar opened on.
|
|
72
|
+
expect(document.activeElement).toBe(cursor());
|
|
73
|
+
|
|
74
|
+
fireEvent.keyDown(grid, { key: "ArrowDown" });
|
|
75
|
+
expect(cursor()?.textContent).toBe("15");
|
|
76
|
+
expect(document.activeElement).toBe(cursor());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not pull focus off the month buttons when they move the cursor", async () => {
|
|
80
|
+
render(
|
|
81
|
+
<LocaleProvider locales={{ en: LOCALE_EN }}>
|
|
82
|
+
<DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
|
|
83
|
+
</LocaleProvider>,
|
|
84
|
+
);
|
|
85
|
+
fireEvent.click(screen.getByRole("button", { name: "Due date" }));
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(document.activeElement).toBe(
|
|
88
|
+
screen.getByRole("grid").querySelector('[tabindex="0"]'),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
// Changing month also moves the cursor. The follow is scoped to the grid rather than
|
|
92
|
+
// the whole calendar precisely so this button keeps the focus the pointer gave it.
|
|
93
|
+
const next = screen.getByRole("button", { name: "Next month" });
|
|
94
|
+
next.focus();
|
|
95
|
+
fireEvent.click(next);
|
|
96
|
+
expect(screen.getByRole("grid", { name: /August 2026/ })).toBeInTheDocument();
|
|
97
|
+
expect(document.activeElement).toBe(next);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("opens the calendar on mount when asked", () => {
|
|
101
|
+
// Sixteen calendar rules have been in the sheet since stage 2c with no way to render the
|
|
102
|
+
// subtree, so neither visual lane had ever painted one. This is the way in.
|
|
103
|
+
render(
|
|
104
|
+
<LocaleProvider locales={{ en: LOCALE_EN }}>
|
|
105
|
+
<DatePicker aria-label="Due date" value={new Date(2026, 6, 7)} defaultOpen />
|
|
106
|
+
</LocaleProvider>,
|
|
107
|
+
);
|
|
108
|
+
expect(screen.getByRole("grid", { name: /July 2026/ })).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByRole("button", { name: "Due date" })).toHaveAttribute(
|
|
110
|
+
"aria-expanded",
|
|
111
|
+
"true",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("names the calendar dialog with its month, and each day with its whole date", () => {
|
|
116
|
+
render(
|
|
117
|
+
<LocaleProvider locales={{ en: LOCALE_EN }}>
|
|
118
|
+
<DatePicker aria-label="Due date" value={new Date(2026, 6, 7)} defaultOpen />
|
|
119
|
+
</LocaleProvider>,
|
|
120
|
+
);
|
|
121
|
+
// A role="dialog" with no accessible name announces itself as "dialog" and nothing else.
|
|
122
|
+
// The month was one level down on the grid, so it was reached only after the boundary had
|
|
123
|
+
// already been crossed unnamed. axe does not report this at the wcag2a/aa tags the visual
|
|
124
|
+
// suite runs, so opening the calendar did not surface it.
|
|
125
|
+
expect(screen.getByRole("dialog", { name: /July 2026/ })).toBeInTheDocument();
|
|
126
|
+
// And a day cell's visible text is a bare number, while the weekday row is aria-hidden AND
|
|
127
|
+
// a sibling of the grid rather than columnheaders inside it — so a cell had no weekday, no
|
|
128
|
+
// month and no year to announce.
|
|
129
|
+
expect(
|
|
130
|
+
screen.getByRole("gridcell", { name: "Tuesday, July 7, 2026" }),
|
|
131
|
+
).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
30
134
|
it("uses the active locale for month and weekday names", () => {
|
|
31
135
|
render(
|
|
32
136
|
<LocaleProvider locales={{ nl: LOCALE_NL }}>
|
|
@@ -40,11 +144,34 @@ describe("DatePicker", () => {
|
|
|
40
144
|
});
|
|
41
145
|
|
|
42
146
|
describe("DateRangePicker", () => {
|
|
147
|
+
it("opens the calendar on mount when asked", () => {
|
|
148
|
+
render(
|
|
149
|
+
<DateRangePicker
|
|
150
|
+
aria-label="Window"
|
|
151
|
+
value={{ start: new Date(2026, 6, 10), end: new Date(2026, 6, 14) }}
|
|
152
|
+
defaultOpen
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
156
|
+
// Both endpoints are selected and the days between them carry the range attribute — the
|
|
157
|
+
// only surface in the package that paints either, and now the only one with a baseline.
|
|
158
|
+
expect(screen.getByRole("grid").querySelectorAll('[aria-selected="true"]')).toHaveLength(2);
|
|
159
|
+
expect(
|
|
160
|
+
screen.getByRole("grid").querySelectorAll('[data-in-range="true"]').length,
|
|
161
|
+
).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Day cells are matched by the text a user sees rather than by accessible name: the name is
|
|
165
|
+
// now the whole date, and these two tests mount no LocaleProvider, so a name-based query
|
|
166
|
+
// would depend on the host machine's locale.
|
|
167
|
+
const dayCells = (text: string) =>
|
|
168
|
+
screen.getAllByRole("gridcell").filter((cell) => cell.textContent === text);
|
|
169
|
+
|
|
43
170
|
it("selects a start/end range and closes after the end", () => {
|
|
44
171
|
const onChange = vi.fn();
|
|
45
172
|
render(<DateRangePicker aria-label="Window" defaultValue={{ start: new Date(2026, 6, 10), end: null }} onChange={onChange} />);
|
|
46
173
|
fireEvent.click(screen.getByRole("button", { name: "Window" }));
|
|
47
|
-
fireEvent.click(
|
|
174
|
+
fireEvent.click(dayCells("12")[0]!);
|
|
48
175
|
expect(onChange).toHaveBeenCalledWith({ start: new Date(2026, 6, 10), end: new Date(2026, 6, 12) });
|
|
49
176
|
expect(screen.queryByRole("grid")).not.toBeInTheDocument();
|
|
50
177
|
});
|
|
@@ -61,8 +188,8 @@ describe("DateRangePicker", () => {
|
|
|
61
188
|
/>,
|
|
62
189
|
);
|
|
63
190
|
fireEvent.click(screen.getByRole("button", { name: "Window" }));
|
|
64
|
-
expect(
|
|
65
|
-
fireEvent.click(
|
|
191
|
+
expect(dayCells("4")[0]).toBeDisabled();
|
|
192
|
+
fireEvent.click(dayCells("8")[0]!);
|
|
66
193
|
expect(onChange).toHaveBeenCalledWith({ start: new Date(2026, 6, 8), end: null });
|
|
67
194
|
});
|
|
68
195
|
});
|