@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.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -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). It is no longer LoginView's full-width submit, which is
45
- // what this comment used to cite — a fixed 100% is layout policy rather than a measured
46
- // value, and the sheet could reach it, so it is a rule on the login form now.
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 type={type} data-terp="button" data-variant={variant} {...rest}>
33
- {icon !== undefined && (
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
- {icon}
89
+ {leading}
36
90
  </span>
37
91
  )}
38
92
  {children}
39
93
  </button>
40
94
  );
41
95
  }
42
-
@@ -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 {...rest} data-terp="card" data-gap={String(gap)}>
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">
@@ -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
- onChange={(event) => onChange?.(event.currentTarget.checked)}
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>
@@ -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
+ });