@terpjs/react-core 0.6.1 → 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/README.md +12 -2
- 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/ModuleNav.tsx +1 -1
- 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 +33 -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 +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- 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.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- 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 +13 -25
- 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/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
package/src/ui/Button.test.tsx
CHANGED
|
@@ -7,20 +7,25 @@ import { Button } from "./Button";
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
9
|
describe("Button", () => {
|
|
10
|
-
|
|
10
|
+
// These assert the attributes rather than `style.background` (ADR 0094). The variant is
|
|
11
|
+
// the semantic claim — that the sheet paints it is the sheet's business, and the visual
|
|
12
|
+
// baselines are what prove the paint. Asserting the absence of an inline style is the
|
|
13
|
+
// other half: it is what makes the rules in the sheet reachable at all, so a component
|
|
14
|
+
// that quietly regrew a base `style={}` would silently take back its own restyleability.
|
|
15
|
+
it("renders an accessible button with a default type and no inline styling", () => {
|
|
11
16
|
render(<Button>Save</Button>);
|
|
12
17
|
const button = screen.getByRole("button", { name: "Save" });
|
|
13
18
|
expect(button).toHaveAttribute("type", "button");
|
|
14
19
|
expect(button).toHaveAttribute("data-terp", "button");
|
|
15
20
|
expect(button).toHaveAttribute("data-variant", "primary");
|
|
16
|
-
expect(button.style
|
|
21
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
17
22
|
});
|
|
18
23
|
|
|
19
|
-
it("
|
|
24
|
+
it("names the ghost variant on the element", () => {
|
|
20
25
|
render(<Button variant="ghost">Cancel</Button>);
|
|
21
26
|
const button = screen.getByRole("button", { name: "Cancel" });
|
|
22
27
|
expect(button).toHaveAttribute("data-variant", "ghost");
|
|
23
|
-
expect(button.style
|
|
28
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
it("renders a leading icon before the children", () => {
|
|
@@ -32,5 +37,13 @@ describe("Button", () => {
|
|
|
32
37
|
expect(button.contains(icon)).toBe(true);
|
|
33
38
|
expect(button.textContent).toBe("iDo it");
|
|
34
39
|
});
|
|
40
|
+
|
|
41
|
+
it("still forwards an explicit style, so framework callers keep their escape", () => {
|
|
42
|
+
// The sheet owns the base; a one-off geometry override (LoginView's full-width submit)
|
|
43
|
+
// is inline and therefore still wins, which is the boundary ADR 0094 draws between
|
|
44
|
+
// styling policy and a measured value.
|
|
45
|
+
render(<Button style={{ width: "100%" }}>Wide</Button>);
|
|
46
|
+
expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
|
|
47
|
+
});
|
|
35
48
|
});
|
|
36
49
|
|
package/src/ui/Button.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { ButtonHTMLAttributes,
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "../styles";
|
|
4
|
-
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
5
4
|
|
|
6
5
|
injectTerpStyles();
|
|
7
6
|
|
|
@@ -13,78 +12,26 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
13
12
|
icon?: ReactNode;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const baseStyle: CSSProperties = {
|
|
17
|
-
...CONTROL_TEXT_STYLE,
|
|
18
|
-
display: "inline-flex",
|
|
19
|
-
alignItems: "center",
|
|
20
|
-
justifyContent: "center",
|
|
21
|
-
gap: "var(--space-2)",
|
|
22
|
-
fontWeight: "var(--font-weight-medium)" as never,
|
|
23
|
-
lineHeight: 1.2,
|
|
24
|
-
width: "fit-content",
|
|
25
|
-
maxWidth: "100%",
|
|
26
|
-
minHeight: "2.25rem",
|
|
27
|
-
padding: "0 var(--space-4)",
|
|
28
|
-
border: "1px solid transparent",
|
|
29
|
-
borderRadius: "var(--radius-md)",
|
|
30
|
-
boxSizing: "border-box",
|
|
31
|
-
cursor: "pointer",
|
|
32
|
-
whiteSpace: "normal",
|
|
33
|
-
textAlign: "center",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const variantStyle: Record<ButtonVariant, CSSProperties> = {
|
|
37
|
-
primary: {
|
|
38
|
-
background: "var(--color-brand-primary)",
|
|
39
|
-
color: "var(--color-brand-primary-contrast)",
|
|
40
|
-
boxShadow: "var(--shadow-sm)",
|
|
41
|
-
},
|
|
42
|
-
secondary: {
|
|
43
|
-
background: "var(--color-neutral-0)",
|
|
44
|
-
color: "var(--color-neutral-900)",
|
|
45
|
-
borderColor: "var(--color-neutral-300)",
|
|
46
|
-
},
|
|
47
|
-
danger: {
|
|
48
|
-
background: "var(--color-status-danger)",
|
|
49
|
-
color: "var(--color-neutral-0)",
|
|
50
|
-
},
|
|
51
|
-
ghost: {
|
|
52
|
-
background: "transparent",
|
|
53
|
-
color: "var(--color-neutral-700)",
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const iconWrapStyle: CSSProperties = {
|
|
58
|
-
display: "inline-flex",
|
|
59
|
-
alignItems: "center",
|
|
60
|
-
justifyContent: "center",
|
|
61
|
-
flexShrink: 0,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
15
|
/**
|
|
65
|
-
* Token-styled button — use instead of a raw `<button>` (the module-boundary rule).
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
16
|
+
* Token-styled button — use instead of a raw `<button>` (the module-boundary rule).
|
|
17
|
+
*
|
|
18
|
+
* It renders no inline styles: the geometry, the per-variant colours and the hover /
|
|
19
|
+
* active / disabled / `:focus-visible` states all live in the injected react-core sheet,
|
|
20
|
+
* matched on the `data-terp` / `data-variant` attributes set below (ADR 0094). So the
|
|
21
|
+
* variant is a fact about the element rather than a style object chosen in here — which
|
|
22
|
+
* is what a test should assert, and what an app's `theme.css` can restyle.
|
|
69
23
|
*/
|
|
70
24
|
export function Button({
|
|
71
25
|
variant = "primary",
|
|
72
26
|
icon,
|
|
73
|
-
style,
|
|
74
27
|
type = "button",
|
|
75
28
|
children,
|
|
76
29
|
...rest
|
|
77
30
|
}: ButtonProps) {
|
|
78
31
|
return (
|
|
79
|
-
<button
|
|
80
|
-
type={type}
|
|
81
|
-
data-terp="button"
|
|
82
|
-
data-variant={variant}
|
|
83
|
-
{...rest}
|
|
84
|
-
style={{ ...baseStyle, ...variantStyle[variant], ...style }}
|
|
85
|
-
>
|
|
32
|
+
<button type={type} data-terp="button" data-variant={variant} {...rest}>
|
|
86
33
|
{icon !== undefined && (
|
|
87
|
-
<span aria-hidden="true"
|
|
34
|
+
<span aria-hidden="true" data-terp="button-icon">
|
|
88
35
|
{icon}
|
|
89
36
|
</span>
|
|
90
37
|
)}
|
package/src/ui/Card.test.tsx
CHANGED
|
@@ -17,8 +17,12 @@ describe("Card", () => {
|
|
|
17
17
|
expect(heading).toBeInTheDocument();
|
|
18
18
|
const card = heading.closest('[data-terp="card"]') as HTMLElement;
|
|
19
19
|
expect(card.tagName).toBe("SECTION");
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// The surface is a sheet rule (ADR 0094); the card's own claims are its element, its
|
|
21
|
+
// gap step and that the title is a real h3 inside the header row.
|
|
22
|
+
expect(card.getAttribute("style")).toBeNull();
|
|
23
|
+
expect(card).toHaveAttribute("data-gap", "3");
|
|
24
|
+
expect(heading).toHaveAttribute("data-terp", "card-title");
|
|
25
|
+
expect(heading.closest('[data-terp="card-header"]')).not.toBeNull();
|
|
22
26
|
expect(screen.getByText("Uren per maand.")).toBeInTheDocument();
|
|
23
27
|
expect(screen.getByText("body")).toBeInTheDocument();
|
|
24
28
|
});
|
package/src/ui/Card.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import type { SpaceToken } from "../layout";
|
|
4
|
+
import { injectTerpStyles } from "../styles";
|
|
4
5
|
import { useUiText } from "../uiText";
|
|
5
6
|
import type { UiText } from "../uiText";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export interface CardProps
|
|
8
11
|
extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
|
|
9
12
|
/** Optional section heading, rendered as an `<h3>` in the card's header row. */
|
|
@@ -19,37 +22,6 @@ export interface CardProps
|
|
|
19
22
|
children?: ReactNode;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
const cardStyle: CSSProperties = {
|
|
23
|
-
display: "flex",
|
|
24
|
-
flexDirection: "column",
|
|
25
|
-
background: "var(--color-neutral-0)",
|
|
26
|
-
border: "1px solid var(--color-neutral-200)",
|
|
27
|
-
borderRadius: "var(--radius-lg)",
|
|
28
|
-
padding: "var(--space-4)",
|
|
29
|
-
minWidth: 0,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const headerStyle: CSSProperties = {
|
|
33
|
-
display: "flex",
|
|
34
|
-
alignItems: "center",
|
|
35
|
-
justifyContent: "space-between",
|
|
36
|
-
flexWrap: "wrap",
|
|
37
|
-
gap: "var(--space-3)",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const titleStyle: CSSProperties = {
|
|
41
|
-
margin: 0,
|
|
42
|
-
fontSize: "var(--font-size-base)",
|
|
43
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
44
|
-
lineHeight: 1.3,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const descriptionStyle: CSSProperties = {
|
|
48
|
-
margin: 0,
|
|
49
|
-
color: "var(--color-neutral-600)",
|
|
50
|
-
fontSize: "var(--font-size-sm)",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
25
|
/**
|
|
54
26
|
* A token-styled surface that groups one block of a page — the sanctioned way to give
|
|
55
27
|
* sections visual separation (border + background + padding) without module CSS, and
|
|
@@ -70,23 +42,23 @@ export function Card({
|
|
|
70
42
|
const resolve = useUiText();
|
|
71
43
|
const hasHeader = title !== undefined || actions !== undefined;
|
|
72
44
|
return (
|
|
73
|
-
<Component {...rest} data-terp="card"
|
|
45
|
+
<Component {...rest} data-terp="card" data-gap={String(gap)}>
|
|
74
46
|
{hasHeader ? (
|
|
75
|
-
<div data-terp="card-header"
|
|
76
|
-
<div
|
|
77
|
-
{title !== undefined ? <h3
|
|
47
|
+
<div data-terp="card-header">
|
|
48
|
+
<div data-terp="card-heading">
|
|
49
|
+
{title !== undefined ? <h3 data-terp="card-title">{resolve(title)}</h3> : null}
|
|
78
50
|
{description !== undefined ? (
|
|
79
|
-
<p
|
|
51
|
+
<p data-terp="card-description">{resolve(description)}</p>
|
|
80
52
|
) : null}
|
|
81
53
|
</div>
|
|
82
54
|
{actions !== undefined ? (
|
|
83
|
-
<div data-terp="card-actions"
|
|
55
|
+
<div data-terp="card-actions">
|
|
84
56
|
{actions}
|
|
85
57
|
</div>
|
|
86
58
|
) : null}
|
|
87
59
|
</div>
|
|
88
60
|
) : description !== undefined ? (
|
|
89
|
-
<p
|
|
61
|
+
<p data-terp="card-description">{resolve(description)}</p>
|
|
90
62
|
) : null}
|
|
91
63
|
{children}
|
|
92
64
|
</Component>
|
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-brand-primary)",
|
|
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-brand-primary)"
|
|
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
|
});
|