@terpjs/react-core 0.9.0 → 0.11.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 +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/ui/Button.test.tsx
CHANGED
|
@@ -38,12 +38,78 @@ describe("Button", () => {
|
|
|
38
38
|
expect(button.textContent).toBe("iDo it");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("names a non-default size and leaves the standard control unmarked", () => {
|
|
42
|
+
// md is the absence of an attribute, because the standard control's geometry IS the base
|
|
43
|
+
// rule — the shape density already uses, where "comfortable" matches no rule either. So
|
|
44
|
+
// this asserts the absence as deliberately as it asserts the presence: stamping
|
|
45
|
+
// data-size="md" would be harmless but would make the sheet's two-rule split read as an
|
|
46
|
+
// omission.
|
|
47
|
+
const { rerender } = render(<Button size="sm">Small</Button>);
|
|
48
|
+
expect(screen.getByRole("button", { name: "Small" })).toHaveAttribute("data-size", "sm");
|
|
49
|
+
rerender(<Button size="lg">Large</Button>);
|
|
50
|
+
expect(screen.getByRole("button", { name: "Large" })).toHaveAttribute("data-size", "lg");
|
|
51
|
+
rerender(<Button>Standard</Button>);
|
|
52
|
+
const standard = screen.getByRole("button", { name: "Standard" });
|
|
53
|
+
expect(standard.hasAttribute("data-size")).toBe(false);
|
|
54
|
+
expect(standard.getAttribute("style")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fills its container from an attribute rather than an inline width", () => {
|
|
58
|
+
// The point of the prop: `style={{ width: "100%" }}` is the only other way there, and app
|
|
59
|
+
// modules may not write it (ADR 0059) — so full width was a shape the framework could
|
|
60
|
+
// produce and its consumers could not ask for. The absence of a style attribute is the
|
|
61
|
+
// half that matters, because an inline width would outrank the app theme.css that ADR 0094
|
|
62
|
+
// exists to empower.
|
|
63
|
+
render(<Button fullWidth>Wide</Button>);
|
|
64
|
+
const button = screen.getByRole("button", { name: "Wide" });
|
|
65
|
+
expect(button).toHaveAttribute("data-full-width", "true");
|
|
66
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("marks a loading button busy, disables it, and shows the spinner in the icon slot", () => {
|
|
70
|
+
render(
|
|
71
|
+
<Button loading icon={<span data-testid="ico">i</span>}>
|
|
72
|
+
Saving
|
|
73
|
+
</Button>,
|
|
74
|
+
);
|
|
75
|
+
const button = screen.getByRole("button", { name: "Saving" });
|
|
76
|
+
expect(button).toHaveAttribute("data-loading", "true");
|
|
77
|
+
expect(button).toHaveAttribute("aria-busy", "true");
|
|
78
|
+
// Disabled, so a second click cannot start the same request twice — the reason the state
|
|
79
|
+
// exists at all rather than being decoration on a still-live control.
|
|
80
|
+
expect(button).toBeDisabled();
|
|
81
|
+
// The spinner REPLACES the icon rather than joining it, so the button's width does not
|
|
82
|
+
// jump as it enters and leaves the state.
|
|
83
|
+
expect(screen.queryByTestId("ico")).toBeNull();
|
|
84
|
+
expect(button.querySelector('[data-terp="spinner-ring"]')).not.toBeNull();
|
|
85
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps a loading button disabled even when the caller says otherwise", () => {
|
|
89
|
+
// `disabled={false}` and `loading` together is a real combination — a form that computes
|
|
90
|
+
// one from validity and the other from the request in flight — and the request has to
|
|
91
|
+
// win, or the state is advisory.
|
|
92
|
+
render(
|
|
93
|
+
<Button loading disabled={false}>
|
|
94
|
+
Saving
|
|
95
|
+
</Button>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByRole("button", { name: "Saving" })).toBeDisabled();
|
|
98
|
+
});
|
|
99
|
+
|
|
41
100
|
it("still forwards an explicit style, so framework callers keep their escape", () => {
|
|
42
101
|
// The sheet owns the base, and the escape still has to work: a caller may pass a measured
|
|
43
102
|
// value the sheet has no business owning, and it wins because a style attribute outranks
|
|
44
|
-
// any author rule (ADR 0094 §3).
|
|
45
|
-
//
|
|
46
|
-
//
|
|
103
|
+
// any author rule (ADR 0094 §3).
|
|
104
|
+
//
|
|
105
|
+
// This comment has now cited three different homes for the same 100%, which is the useful
|
|
106
|
+
// part of it. First it was LoginView's inline style. Then a rule on the login form's group,
|
|
107
|
+
// because a fixed 100% is layout policy rather than a measured value and the sheet could
|
|
108
|
+
// reach it — with a prop declined as API the package did not need for one internal caller.
|
|
109
|
+
// Now it is `fullWidth`, because the constraint that mattered was never the internal caller:
|
|
110
|
+
// app modules may not write `style` at all, so full width was a shape the framework could
|
|
111
|
+
// produce and its consumers could not ask for. The group rule is gone and LoginView passes
|
|
112
|
+
// the prop. The escape below is unaffected either way, which is why it is still here.
|
|
47
113
|
render(<Button style={{ width: "100%" }}>Wide</Button>);
|
|
48
114
|
expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
|
|
49
115
|
});
|
package/src/ui/Button.tsx
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
|
+
import { InlineSpinner } from "../LoadingState";
|
|
3
4
|
import { injectTerpStyles } from "../styles";
|
|
4
5
|
|
|
5
6
|
injectTerpStyles();
|
|
6
7
|
|
|
7
8
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
8
9
|
|
|
10
|
+
/** Control size: `"md"` is the standard control and needs no attribute (see below). */
|
|
11
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
12
|
+
|
|
9
13
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
10
14
|
variant?: ButtonVariant;
|
|
15
|
+
/**
|
|
16
|
+
* Control size (default `"md"`). Changes the height, the horizontal padding and the label
|
|
17
|
+
* size together; composes with `data-density`, so a `"sm"` button inside a compact subtree
|
|
18
|
+
* is shorter still.
|
|
19
|
+
*/
|
|
20
|
+
size?: ButtonSize;
|
|
21
|
+
/**
|
|
22
|
+
* The action is in flight: shows a spinner in the icon slot, marks the control
|
|
23
|
+
* `aria-busy` and disables it, so a second click cannot start the request twice.
|
|
24
|
+
*
|
|
25
|
+
* The spinner replaces `icon` rather than joining it, which keeps the button's width from
|
|
26
|
+
* jumping as it enters and leaves the state.
|
|
27
|
+
*/
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Fill the container's inline size instead of the label's.
|
|
31
|
+
*
|
|
32
|
+
* It exists because the alternative was `style={{ width: "100%" }}`, which app modules may
|
|
33
|
+
* not write (ADR 0059) — so full width was a shape the framework could produce and its
|
|
34
|
+
* consumers could not ask for.
|
|
35
|
+
*/
|
|
36
|
+
fullWidth?: boolean;
|
|
11
37
|
/** Optional leading icon, rendered before `children` (e.g. `<Icon name="plus" />`). */
|
|
12
38
|
icon?: ReactNode;
|
|
13
39
|
}
|
|
@@ -20,23 +46,50 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
20
46
|
* matched on the `data-terp` / `data-variant` attributes set below (ADR 0094). So the
|
|
21
47
|
* variant is a fact about the element rather than a style object chosen in here — which
|
|
22
48
|
* is what a test should assert, and what an app's `theme.css` can restyle.
|
|
49
|
+
*
|
|
50
|
+
* `size`, `loading` and `fullWidth` are all closed sets, so all three are attributes with a
|
|
51
|
+
* rule each (ADR 0094 §3). Two details about how they are stamped are deliberate:
|
|
52
|
+
*
|
|
53
|
+
* `data-size` appears only for `"sm"` and `"lg"`. The standard control's geometry IS the base
|
|
54
|
+
* rule, so `md` is the absence of an attribute — the same shape density takes, where
|
|
55
|
+
* "comfortable" is the token sheet's `:root` value and the attribute for it matches no rule.
|
|
56
|
+
* (`data-variant` is stamped even for its default because `primary` has a rule of its own;
|
|
57
|
+
* the two idioms differ for that reason rather than by accident.)
|
|
58
|
+
*
|
|
59
|
+
* `loading` sets `disabled` as well, and the sizes are expressed as a `calc()` off the
|
|
60
|
+
* density token rather than as heights of their own, so density keeps composing with all
|
|
61
|
+
* three sizes without a second family of tokens to keep in step.
|
|
23
62
|
*/
|
|
24
63
|
export function Button({
|
|
25
64
|
variant = "primary",
|
|
65
|
+
size = "md",
|
|
66
|
+
loading = false,
|
|
67
|
+
fullWidth = false,
|
|
26
68
|
icon,
|
|
27
69
|
type = "button",
|
|
70
|
+
disabled,
|
|
28
71
|
children,
|
|
29
72
|
...rest
|
|
30
73
|
}: ButtonProps) {
|
|
74
|
+
const leading = loading ? <InlineSpinner size={14} /> : icon;
|
|
31
75
|
return (
|
|
32
|
-
<button
|
|
33
|
-
{
|
|
76
|
+
<button
|
|
77
|
+
type={type}
|
|
78
|
+
data-terp="button"
|
|
79
|
+
data-variant={variant}
|
|
80
|
+
data-size={size === "md" ? undefined : size}
|
|
81
|
+
data-loading={loading ? "true" : undefined}
|
|
82
|
+
data-full-width={fullWidth ? "true" : undefined}
|
|
83
|
+
disabled={disabled === true || loading}
|
|
84
|
+
aria-busy={loading ? true : undefined}
|
|
85
|
+
{...rest}
|
|
86
|
+
>
|
|
87
|
+
{leading !== undefined && leading !== null && (
|
|
34
88
|
<span aria-hidden="true" data-terp="button-icon">
|
|
35
|
-
{
|
|
89
|
+
{leading}
|
|
36
90
|
</span>
|
|
37
91
|
)}
|
|
38
92
|
{children}
|
|
39
93
|
</button>
|
|
40
94
|
);
|
|
41
95
|
}
|
|
42
|
-
|
package/src/ui/Card.test.tsx
CHANGED
|
@@ -41,4 +41,17 @@ describe("Card", () => {
|
|
|
41
41
|
const card = screen.getByText("alleen inhoud").closest('[data-terp="card"]');
|
|
42
42
|
expect(card?.querySelector('[data-terp="card-header"]')).toBeNull();
|
|
43
43
|
});
|
|
44
|
+
it("names the plain variant and leaves the boxed default unmarked", () => {
|
|
45
|
+
// The chrome-less titled region. A variant rather than a `Section` component of its own,
|
|
46
|
+
// because a chrome-less region is this element with three declarations removed — a second
|
|
47
|
+
// component would have meant six more markers describing the same DOM, and a `Surface` is
|
|
48
|
+
// a Card with no title, which this already is.
|
|
49
|
+
const { rerender } = render(<Card variant="plain" title="Plain">body</Card>);
|
|
50
|
+
const plain = screen.getByRole("heading", { name: "Plain" }).closest("section");
|
|
51
|
+
expect(plain).toHaveAttribute("data-variant", "plain");
|
|
52
|
+
rerender(<Card title="Boxed">body</Card>);
|
|
53
|
+
const boxed = screen.getByRole("heading", { name: "Boxed" }).closest("section");
|
|
54
|
+
expect(boxed!.hasAttribute("data-variant")).toBe(false);
|
|
55
|
+
expect(boxed!.getAttribute("style")).toBeNull();
|
|
56
|
+
});
|
|
44
57
|
});
|
package/src/ui/Card.tsx
CHANGED
|
@@ -7,8 +7,27 @@ import type { UiText } from "../uiText";
|
|
|
7
7
|
|
|
8
8
|
injectTerpStyles();
|
|
9
9
|
|
|
10
|
+
/** Chrome, or none: `"plain"` keeps the heading and drops the box. */
|
|
11
|
+
export type CardVariant = "boxed" | "plain";
|
|
12
|
+
|
|
10
13
|
export interface CardProps
|
|
11
14
|
extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
|
|
15
|
+
/**
|
|
16
|
+
* Whether the block carries a box (default `"boxed"`) or just its heading (`"plain"`).
|
|
17
|
+
*
|
|
18
|
+
* `"plain"` is the labelled-region case: a titled group inside something that is already a
|
|
19
|
+
* surface, where a second border reads as a frame around a frame. The commonest instance is
|
|
20
|
+
* a section whose body is a `DataView` — boxed, the table gets a border inside a border and
|
|
21
|
+
* loses its full width.
|
|
22
|
+
*
|
|
23
|
+
* It is a variant rather than a `Section` component of its own, and that is a decision worth
|
|
24
|
+
* knowing. A chrome-less titled region is exactly this element with three declarations
|
|
25
|
+
* removed: `Card` already renders a `<section>` with an `<h3>` and stacks its children on
|
|
26
|
+
* the token scale. A second component would have meant six more markers describing the same
|
|
27
|
+
* DOM, and a `Surface` — the third name the diagnosis suggested — is a `Card` with no title,
|
|
28
|
+
* which this already is. Two names for one box is the `LoadingButton` mistake.
|
|
29
|
+
*/
|
|
30
|
+
variant?: CardVariant;
|
|
12
31
|
/** Optional section heading, rendered as an `<h3>` in the card's header row. */
|
|
13
32
|
title?: UiText;
|
|
14
33
|
/** Optional muted one-liner under the title (what this block is about). */
|
|
@@ -31,6 +50,7 @@ export interface CardProps
|
|
|
31
50
|
* spacing scale.
|
|
32
51
|
*/
|
|
33
52
|
export function Card({
|
|
53
|
+
variant = "boxed",
|
|
34
54
|
title,
|
|
35
55
|
description,
|
|
36
56
|
actions,
|
|
@@ -42,7 +62,14 @@ export function Card({
|
|
|
42
62
|
const resolve = useUiText();
|
|
43
63
|
const hasHeader = title !== undefined || actions !== undefined;
|
|
44
64
|
return (
|
|
45
|
-
<Component
|
|
65
|
+
<Component
|
|
66
|
+
{...rest}
|
|
67
|
+
data-terp="card"
|
|
68
|
+
// `boxed` is the base rule, so its attribute would describe the default twice — the
|
|
69
|
+
// idiom density, Button's `md` and Grid's `auto` all use.
|
|
70
|
+
data-variant={variant === "boxed" ? undefined : variant}
|
|
71
|
+
data-gap={String(gap)}
|
|
72
|
+
>
|
|
46
73
|
{hasHeader ? (
|
|
47
74
|
<div data-terp="card-header">
|
|
48
75
|
<div data-terp="card-heading">
|
package/src/ui/Checkbox.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InputHTMLAttributes } from "react";
|
|
1
|
+
import type { ChangeEvent, InputHTMLAttributes } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "../styles";
|
|
4
4
|
import { useUiText } from "../uiText";
|
|
@@ -25,7 +25,15 @@ export function Checkbox({ label, checked, defaultChecked, onChange, style, ...r
|
|
|
25
25
|
data-terp="checkbox"
|
|
26
26
|
checked={checked}
|
|
27
27
|
defaultChecked={defaultChecked}
|
|
28
|
-
|
|
28
|
+
// Attached only when there is something to call, exactly as Select does and for the
|
|
29
|
+
// reason stated there: an unconditional handler silences React's own "you provided a
|
|
30
|
+
// `checked` prop to a form field without an `onChange` handler" guard, so a caller who
|
|
31
|
+
// pinned `checked` and forgot the handler gets a control that looks operable, never
|
|
32
|
+
// changes, and says nothing about it. The spread form is what keeps the prop absent
|
|
33
|
+
// rather than present-and-undefined, which React treats as the same mistake.
|
|
34
|
+
{...(onChange !== undefined
|
|
35
|
+
? { onChange: (event: ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }
|
|
36
|
+
: {})}
|
|
29
37
|
/>
|
|
30
38
|
<span>{resolve(label)}</span>
|
|
31
39
|
</label>
|
package/src/ui/Combobox.test.tsx
CHANGED
|
@@ -31,6 +31,55 @@ describe("Combobox", () => {
|
|
|
31
31
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("matches a needle that the host's own case fold would have hidden", () => {
|
|
35
|
+
// The filter used to fold with `toLocaleLowerCase`, which asks the host what lowercase means.
|
|
36
|
+
// Turkish has two i's, and folded there `Italy` becomes `ıtaly` — dotless — which does not
|
|
37
|
+
// contain the `i` the user typed. Every option with a capital I disappeared for a Turkish
|
|
38
|
+
// visitor and for nobody else, and folding BOTH sides the same way does not help: the needle
|
|
39
|
+
// comes from a keyboard and the haystack from a server.
|
|
40
|
+
//
|
|
41
|
+
// The host is simulated rather than assumed, because the suite runs on nl-NL and a test that
|
|
42
|
+
// merely typed `i` would pass under the bug here and fail only in Istanbul. The prototype
|
|
43
|
+
// patch is scoped to this test and restored in `finally`.
|
|
44
|
+
const original = String.prototype.toLocaleLowerCase;
|
|
45
|
+
// Confirm the hazard is real on this ICU build before relying on it as the mechanism.
|
|
46
|
+
expect(original.call("Italy", "tr")).not.toContain("i");
|
|
47
|
+
String.prototype.toLocaleLowerCase = function turkish(this: string) {
|
|
48
|
+
return original.call(this, "tr");
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
render(
|
|
52
|
+
<Combobox
|
|
53
|
+
aria-label="Country"
|
|
54
|
+
options={[...options, { value: "it", label: "Italy" }]}
|
|
55
|
+
onChange={vi.fn()}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
const input = screen.getByRole("combobox", { name: /Country/ });
|
|
59
|
+
fireEvent.focus(input);
|
|
60
|
+
// BOTH cases, because each catches a different half and neither catches the other.
|
|
61
|
+
//
|
|
62
|
+
// Lowercase is the real defect: the user types the dotted `i` their keyboard produces,
|
|
63
|
+
// Turkish folds the label to the dotless `ıtaly`, and the option vanishes. The ORIGINAL
|
|
64
|
+
// code folded both sides with the host locale, which is self-consistent — "ıtal" does
|
|
65
|
+
// occur in "ıtaly" — so a capital-I needle passes under the very bug this test exists
|
|
66
|
+
// for. The first version of this test asserted only the capital, and was green against
|
|
67
|
+
// it.
|
|
68
|
+
//
|
|
69
|
+
// Capital is still needed: it is the only case that fails when the NEEDLE alone reverts
|
|
70
|
+
// to the host fold, which lowercase cannot see because folding "ital" changes nothing.
|
|
71
|
+
for (const needle of ["ital", "Ital"]) {
|
|
72
|
+
fireEvent.change(input, { target: { value: needle } });
|
|
73
|
+
expect(
|
|
74
|
+
screen.getByRole("option", { name: "Italy" }),
|
|
75
|
+
`typing ${needle} must still match Italy on a Turkish host`,
|
|
76
|
+
).toBeInTheDocument();
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
String.prototype.toLocaleLowerCase = original;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
34
83
|
it("supports controlled value, Field labels, ARIA active option and keyboard navigation", () => {
|
|
35
84
|
const onChange = vi.fn();
|
|
36
85
|
render(
|
|
@@ -78,3 +127,93 @@ describe("Combobox", () => {
|
|
|
78
127
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
79
128
|
});
|
|
80
129
|
});
|
|
130
|
+
|
|
131
|
+
describe("Combobox multiple", () => {
|
|
132
|
+
it("accumulates a set, keeps the list open, and clears the filter between picks", () => {
|
|
133
|
+
// The reason this mode exists: a set-valued field had no control, and the absence
|
|
134
|
+
// produced comma-separated text boxes with the legal values in a grey hint beside them
|
|
135
|
+
// — a closed enum typed as free text, so validation the value set could have enforced
|
|
136
|
+
// was lost. Picking one member of a set is almost never the last thing a user wants, so
|
|
137
|
+
// the list staying open is the behaviour, not a detail.
|
|
138
|
+
const onChange = vi.fn();
|
|
139
|
+
render(<Combobox multiple aria-label="Fields" options={options} onChange={onChange} />);
|
|
140
|
+
const input = screen.getByRole("combobox", { name: "Fields" });
|
|
141
|
+
|
|
142
|
+
fireEvent.focus(input);
|
|
143
|
+
fireEvent.click(screen.getByRole("option", { name: "Netherlands" }));
|
|
144
|
+
expect(onChange).toHaveBeenLastCalledWith(["nl"], [options[0]]);
|
|
145
|
+
// Still open, and the filter is empty so the next pick starts from the whole list.
|
|
146
|
+
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
147
|
+
expect(input).toHaveValue("");
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByRole("option", { name: "France" }));
|
|
150
|
+
expect(onChange).toHaveBeenLastCalledWith(["nl", "fr"], [options[0], options[3]]);
|
|
151
|
+
|
|
152
|
+
// Selecting an already-chosen option removes it — one control, both directions.
|
|
153
|
+
fireEvent.click(screen.getByRole("option", { name: "Netherlands" }));
|
|
154
|
+
expect(onChange).toHaveBeenLastCalledWith(["fr"], [options[3]]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("says it is multi-selectable and marks every chosen option", () => {
|
|
158
|
+
render(<Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} defaultOpen />);
|
|
159
|
+
expect(screen.getByRole("listbox")).toHaveAttribute("aria-multiselectable", "true");
|
|
160
|
+
expect(screen.getByRole("option", { name: "Netherlands" })).toHaveAttribute("aria-selected", "true");
|
|
161
|
+
expect(screen.getByRole("option", { name: "France" })).toHaveAttribute("aria-selected", "true");
|
|
162
|
+
expect(screen.getByRole("option", { name: "Belgium" })).toHaveAttribute("aria-selected", "false");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("gives every token a remove control whose name says which token it removes", () => {
|
|
166
|
+
// N identical "Remove" buttons is not a keyboard-accessible token field: the name has to
|
|
167
|
+
// carry the option, or a screen-reader user cannot tell which one they are about to
|
|
168
|
+
// remove. These are real buttons and real tab stops — the accessible half of the
|
|
169
|
+
// Backspace shortcut rather than a duplicate of it, because a shortcut is only
|
|
170
|
+
// discoverable if you already know about it.
|
|
171
|
+
const onChange = vi.fn();
|
|
172
|
+
render(
|
|
173
|
+
<Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
|
|
174
|
+
);
|
|
175
|
+
fireEvent.click(screen.getByRole("button", { name: "Remove Netherlands" }));
|
|
176
|
+
expect(onChange).toHaveBeenLastCalledWith(["fr"], [options[3]]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("removes the last token on Backspace only when the filter is empty", () => {
|
|
180
|
+
const onChange = vi.fn();
|
|
181
|
+
render(
|
|
182
|
+
<Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
|
|
183
|
+
);
|
|
184
|
+
const input = screen.getByRole("combobox", { name: "Fields" });
|
|
185
|
+
|
|
186
|
+
// With text in the box, Backspace belongs to the text: eating a token here would
|
|
187
|
+
// delete a selection while the user thinks they are correcting a typo.
|
|
188
|
+
fireEvent.change(input, { target: { value: "Bel" } });
|
|
189
|
+
fireEvent.keyDown(input, { key: "Backspace" });
|
|
190
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
191
|
+
|
|
192
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
193
|
+
fireEvent.keyDown(input, { key: "Backspace" });
|
|
194
|
+
expect(onChange).toHaveBeenLastCalledWith(["nl"], [options[0]]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("keeps a controlled set when the parent declines the change", () => {
|
|
198
|
+
// The same invariant single mode has: the control shows what the prop says, not what
|
|
199
|
+
// was clicked. A token that appears because it was clicked and not because the parent
|
|
200
|
+
// accepted it is a field that disagrees with the state it is bound to.
|
|
201
|
+
const onChange = vi.fn();
|
|
202
|
+
render(
|
|
203
|
+
<Combobox multiple aria-label="Fields" options={options} value={["nl"]} onChange={onChange} defaultOpen />,
|
|
204
|
+
);
|
|
205
|
+
fireEvent.click(screen.getByRole("option", { name: "France" }));
|
|
206
|
+
expect(onChange).toHaveBeenLastCalledWith(["nl", "fr"], [options[0], options[3]]);
|
|
207
|
+
expect(screen.getByRole("button", { name: "Remove Netherlands" })).toBeInTheDocument();
|
|
208
|
+
expect(screen.queryByRole("button", { name: "Remove France" })).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("clears the whole set through the clear control", () => {
|
|
212
|
+
const onChange = vi.fn();
|
|
213
|
+
render(
|
|
214
|
+
<Combobox multiple clearable aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
|
|
215
|
+
);
|
|
216
|
+
fireEvent.click(screen.getByRole("button", { name: "Clear all selections" }));
|
|
217
|
+
expect(onChange).toHaveBeenLastCalledWith([], []);
|
|
218
|
+
});
|
|
219
|
+
});
|