@terpjs/react-core 0.10.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/src/styles.ts CHANGED
@@ -1939,20 +1939,32 @@ textarea[data-terp="input"] {
1939
1939
  [data-terp="dataview"] {
1940
1940
  display: grid;
1941
1941
  }
1942
- /* The full variant's surface. (0,2,0) against the bare marker's (0,1,0), so it wins
1943
- on specificity no tie, no :not(), no source order. BOTH values of data-variant
1944
- are stamped and only this one has a rule, which is the theme-toggle idiom where
1945
- inline is stamped and takes the shared base while only stacked declares anything.
1946
- Rejected: putting the surface on the bare marker and un-declaring it under
1947
- [data-variant="embedded"], which needs background: transparent, border: 0 and
1948
- border-radius: 0 the shape ADR 0094 exists to avoid. Also rejected: stamping
1949
- nothing for the default on the density precedent, which holds only because
1950
- comfortable IS the :root value and so has nothing to declare.
1951
- Byte-identical to [data-terp="card"]'s trio; written flat anyway, the Badge /
1952
- Alert / row-tone precedent. No overflow: hidden, and none should be added here
1953
- the last row's border crossing the rounded bottom corners is pre-existing and
1954
- belongs to its own commit. */
1955
- [data-terp="dataview"][data-variant="full"] {
1942
+ /* The full variant's surface belongs to the TABLE, not to the whole view.
1943
+ It used to wrap everything: one card holding the toolbar, the table and the
1944
+ pagination, divided internally by a border under the toolbar and over the
1945
+ pagination. That reads as three bands of one object, and it costs twice — the
1946
+ table's cells are then flush against the outer frame (nothing between the first
1947
+ column's text and the card edge but cell padding), and the toolbar's controls sit
1948
+ inside a surface they do not belong to.
1949
+
1950
+ So the surface moves down one level, onto whatever occupies the table's slot, and
1951
+ the toolbar and the pagination float on the page background instead. The table
1952
+ becomes the object; the controls above and below it become controls. That also
1953
+ dissolves the flush-to-the-edge problem rather than padding around it: the table's
1954
+ own frame is the edge now, and --density-cell-pad-x is already the inset from it.
1955
+
1956
+ The alternative considered was keeping the outer card and adding inline padding to
1957
+ the table inside it. Rejected: it fixes the symptom by making the card thicker,
1958
+ leaves the toolbar inside a surface, and leaves two nested frames whenever the view
1959
+ is empty (the empty state's dashed frame inside the card's solid one).
1960
+
1961
+ Keyed on [data-variant="full"] rather than the bare marker for the reason the
1962
+ previous rule gave and which still holds: [data-variant="embedded"] must declare
1963
+ nothing, and un-declaring a surface with background: transparent / border: 0 is the
1964
+ shape ADR 0094 exists to avoid. */
1965
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"],
1966
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-error"],
1967
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-skeleton"] {
1956
1968
  background: var(--color-neutral-0);
1957
1969
  border: 1px solid var(--color-neutral-200);
1958
1970
  border-radius: var(--radius-lg);
@@ -2031,22 +2043,38 @@ textarea[data-terp="input"] {
2031
2043
  No colour declaration here on purpose. Two of this element's direct children
2032
2044
  are arbitrary caller slots, and inheriting a muted ink onto app-authored filter
2033
2045
  controls would be a silent restyle of app DOM. */
2046
+ /* Floating: no background, no divider, and no inline padding. The divider was the
2047
+ seam between two bands of one card and there is no card now — a border under a strip
2048
+ that sits on the page background is a line drawn across nothing. Dropping the inline
2049
+ padding aligns the controls with the table's OUTER edge (its frame) rather than with
2050
+ its cell text, which is the alignment a floating control row wants: the eye follows
2051
+ the frame, and a control indented to meet the first column's text reads as belonging
2052
+ inside the table.
2053
+
2054
+ The background's previous justification is worth answering rather than deleting: it
2055
+ was there for the EMBEDDED variant, whose root declares nothing but a display, so that
2056
+ the band would not show the page canvas through it. That is now the intent in both
2057
+ variants. A floating strip shows whatever is behind it — the page in the full variant,
2058
+ the app's own card in the embedded one — and in neither case is a neutral-0 rectangle
2059
+ under the controls something the design asks for. dataview-toolbar-bare still renders
2060
+ on a neutral-50 host, so the difference is visible in a baseline either way. */
2034
2061
  [data-terp="dataview-toolbar"] {
2035
2062
  display: flex;
2036
2063
  align-items: center;
2037
2064
  gap: var(--space-2);
2038
2065
  flex-wrap: wrap;
2039
- padding: var(--space-2) var(--density-cell-pad-x);
2040
- border-block-end: 1px solid var(--color-neutral-200);
2041
- background: var(--color-neutral-0);
2042
- border-top-left-radius: var(--radius-lg);
2043
- border-top-right-radius: var(--radius-lg);
2066
+ padding-block: var(--space-2);
2044
2067
  min-height: 3rem;
2045
2068
  }
2046
2069
  /* Selection mode. A resting surface rather than an interaction state, so
2047
2070
  terp.base — and (0,2,0) against the base's (0,1,0) means it wins on
2048
2071
  specificity alone, needing no :not() and no source-order dependency. */
2049
2072
  [data-terp="dataview-toolbar"][data-variant="selection"] {
2073
+ /* Still a filled surface, because it marks a MODE and losing that would make
2074
+ selection invisible — but now it is a surface of its own rather than a band of the
2075
+ card, so it takes the padding and radius that make it read as one. */
2076
+ padding-inline: var(--density-cell-pad-x);
2077
+ border-radius: var(--radius-md);
2050
2078
  background: var(--color-neutral-50);
2051
2079
  }
2052
2080
  [data-terp="dataview-toolbar-count"] {
@@ -2263,6 +2291,17 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2263
2291
  data-tone on an element no selector could reach, and the row-tones baseline
2264
2292
  would have lost its tints the moment the tone moved out of a style object.
2265
2293
  It is unconditional now, and clickability is an attribute of its own. */
2294
+ /* The last row draws no bottom border, which fixes two things the full variant's own
2295
+ comment had already named and deferred. The container carries a 1px border and a
2296
+ radius, so the last row's border sat a pixel inside it as a DOUBLE line, and with no
2297
+ overflow: hidden it also ran straight across the rounded bottom corners. Dropping the
2298
+ border is the fix rather than clipping the container: overflow: hidden here would trap
2299
+ the horizontal scroll container and any overlay a cell renders, which is why that
2300
+ comment refused it. tbody's last row, not the table's — a footer row would want its
2301
+ own rule. */
2302
+ [data-terp="dataview-row"]:last-child > td {
2303
+ border-bottom: none;
2304
+ }
2266
2305
  [data-terp="dataview-row"][data-clickable="true"] {
2267
2306
  cursor: pointer;
2268
2307
  }
@@ -2296,15 +2335,31 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2296
2335
  sorted, so the unsorted glyph's dimming keys off its ABSENCE rather than off
2297
2336
  an attribute minted for it — this component owns aria-sort, unlike the
2298
2337
  breadcrumb's aria-current, which a router stamps on every ancestor link. */
2338
+ /* font: inherit is not enough, and the gap it leaves split the header row in two.
2339
+ The font shorthand carries no text-transform and no letter-spacing, and the UA
2340
+ stylesheet resets both on form controls — so in any table mixing sortable and
2341
+ non-sortable columns the plain th rendered uppercase with 0.04em tracking while
2342
+ the sortable one rendered sentence case with none, side by side in one row. Both
2343
+ are named explicitly because inheritance is what the UA overrode.
2344
+
2345
+ The block padding mirrors the th's so the button fills the cell it sits in,
2346
+ pulled back out by the negative margin. The control was a 17px-tall target inside
2347
+ a 34px cell — half the cell unused, and the most-used control in a data app
2348
+ clearing WCAG 2.5.8 only through the spacing exception. Filling the cell costs
2349
+ nothing and changes no layout: the button's box grows into padding the th
2350
+ already reserved. */
2299
2351
  [data-terp="dataview-column-sort"] {
2300
2352
  display: inline-flex;
2301
2353
  align-items: center;
2302
2354
  gap: var(--space-1);
2303
2355
  font: inherit;
2356
+ text-transform: inherit;
2357
+ letter-spacing: inherit;
2304
2358
  color: inherit;
2305
2359
  background: transparent;
2306
2360
  border: none;
2307
- padding: 0;
2361
+ padding: var(--space-2) 0;
2362
+ margin-block: calc(-1 * var(--space-2));
2308
2363
  cursor: pointer;
2309
2364
  }
2310
2365
  [data-terp="dataview-table"] > thead > tr > th:not([aria-sort]) > [data-terp="dataview-column-sort"] > svg {
@@ -2518,8 +2573,7 @@ th[data-terp="dataview-actions-cell"] > span {
2518
2573
  justify-content: space-between;
2519
2574
  gap: var(--space-3);
2520
2575
  flex-wrap: wrap;
2521
- padding: var(--space-2) var(--density-cell-pad-x);
2522
- border-block-start: 1px solid var(--color-neutral-200);
2576
+ padding-block: var(--space-2);
2523
2577
  font-size: var(--font-size-sm);
2524
2578
  color: var(--color-fg-subtle);
2525
2579
  }
@@ -2707,6 +2761,24 @@ th[data-terp="dataview-actions-cell"] > span {
2707
2761
  border-radius: var(--radius-lg);
2708
2762
  background: var(--color-neutral-0);
2709
2763
  }
2764
+ /* Compact: a section's emptiness rather than the page's. Same frame and same words,
2765
+ laid out as a row — the glyph beside the text instead of above it — so two of
2766
+ these stacked read as two quiet sections rather than 480px of repeated poster. */
2767
+ [data-terp="empty-state"][data-size="compact"] {
2768
+ grid-template-columns: auto 1fr;
2769
+ justify-items: start;
2770
+ align-items: center;
2771
+ gap: var(--space-2) var(--space-3);
2772
+ padding: var(--space-3) var(--space-4);
2773
+ text-align: start;
2774
+ }
2775
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-title"] {
2776
+ font-size: var(--font-size-sm);
2777
+ }
2778
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-description"],
2779
+ [data-terp="empty-state"][data-size="compact"] > :not([data-terp="empty-state-icon"]):not([data-terp="empty-state-title"]) {
2780
+ grid-column: 2;
2781
+ }
2710
2782
  [data-terp="empty-state-icon"] {
2711
2783
  color: var(--color-neutral-400);
2712
2784
  display: inline-flex;
@@ -2822,6 +2894,71 @@ input[data-terp="input"][role="combobox"] {
2822
2894
  position: relative;
2823
2895
  display: grid;
2824
2896
  }
2897
+ /* Multiple: the tokens share the field's box with the input, wrapping onto as many rows
2898
+ as the selection needs. A fixed-height field would either clip the third token or
2899
+ reserve room for tokens nobody has chosen — and a set-valued field whose height never
2900
+ changes is lying about how much is in it.
2901
+
2902
+ The input keeps a minimum inline size so a filter is still typeable when the tokens have
2903
+ taken most of a row, and flex-basis 0 so it yields to them rather than pushing the last
2904
+ token out of the box. */
2905
+ [data-terp="combobox-field"][data-multiple="true"] {
2906
+ display: flex;
2907
+ flex-wrap: wrap;
2908
+ align-items: center;
2909
+ gap: var(--space-1);
2910
+ padding: var(--space-1);
2911
+ border: 1px solid var(--color-neutral-300);
2912
+ border-radius: var(--radius-md);
2913
+ background: var(--color-neutral-0);
2914
+ }
2915
+ [data-terp="combobox-field"][data-multiple="true"] > [data-terp="input"] {
2916
+ flex: 1 1 0;
2917
+ min-inline-size: 6rem;
2918
+ border: none;
2919
+ background: transparent;
2920
+ padding-inline: var(--space-1);
2921
+ }
2922
+ [data-terp="combobox-token"] {
2923
+ display: inline-flex;
2924
+ align-items: center;
2925
+ gap: var(--space-1);
2926
+ padding-block: 0;
2927
+ padding-inline: var(--space-2);
2928
+ font-size: var(--font-size-sm);
2929
+ color: var(--color-neutral-900);
2930
+ background: var(--color-neutral-100);
2931
+ border: 1px solid var(--color-neutral-200);
2932
+ border-radius: var(--radius-sm);
2933
+ /* Matches the control height a token sits beside, so a row of tokens and the input
2934
+ share one baseline instead of the tokens riding high. */
2935
+ min-block-size: calc(var(--density-control-min-height) - var(--space-2));
2936
+ }
2937
+ /* The remove control is a real button and a real tab stop, which is the accessible half of
2938
+ the Backspace shortcut rather than a duplicate of it: the shortcut is discoverable only if
2939
+ you already know it, and a token nobody can reach by keyboard cannot be removed by one. */
2940
+ [data-terp="combobox-token-remove"] {
2941
+ display: inline-flex;
2942
+ align-items: center;
2943
+ justify-content: center;
2944
+ min-inline-size: var(--space-4);
2945
+ min-block-size: var(--space-4);
2946
+ padding: 0;
2947
+ color: var(--color-neutral-600);
2948
+ background: transparent;
2949
+ border: none;
2950
+ border-radius: var(--radius-sm);
2951
+ cursor: pointer;
2952
+ line-height: 1;
2953
+ }
2954
+ [data-terp="combobox-token-remove"]:hover:not(:disabled) {
2955
+ color: var(--color-neutral-900);
2956
+ background: var(--color-neutral-200);
2957
+ }
2958
+ [data-terp="combobox-token-remove"]:disabled {
2959
+ cursor: not-allowed;
2960
+ opacity: 0.5;
2961
+ }
2825
2962
  /* Addressed structurally rather than by a marker of its own: it is an
2826
2963
  iconbutton, and the only thing distinguishing it is where it sits. */
2827
2964
  [data-terp="combobox-field"] > [data-terp="iconbutton"] {
@@ -3006,7 +3143,7 @@ button[data-terp="input"][data-placeholder="true"] {
3006
3143
  }
3007
3144
  [data-terp="tooltip"] {
3008
3145
  position: absolute;
3009
- z-index: 1;
3146
+ z-index: var(--z-index-tooltip);
3010
3147
  inset-block-end: calc(100% + var(--space-1));
3011
3148
  inset-inline-start: 0;
3012
3149
  max-inline-size: min(18rem, calc(100vw - 2 * var(--space-4)));
@@ -3393,10 +3530,21 @@ button[data-terp="input"][data-placeholder="true"] {
3393
3530
  viewport reads --z-index-toast. (This paragraph said "AppShell still writes
3394
3531
  50/40/30 ... and comes right with its own migration" for a release after that
3395
3532
  migration landed, which is the shape of stale comment worth naming: it read as
3396
- a known gap rather than as a finished one.) Tooltip's z-index:
3397
- 1 above is deliberately NOT a token the tooltip is absolutely positioned
3398
- inside its own anchor, so 1 is a local lift within a stacking context rather
3399
- than a place in the app-wide order. */
3533
+ a known gap rather than as a finished one.) Tooltip's z-index WAS 1 on the
3534
+ reasoning that the tooltip is absolutely positioned inside its own anchor, so 1
3535
+ is a local lift within a stacking context rather than a place in the app-wide
3536
+ order. That is sound about the anchor and wrong about the page:
3537
+ [data-terp="tooltip-anchor"] is only position: relative, which does NOT create a
3538
+ stacking context, so the 1 competed in the ROOT context — against a sticky header
3539
+ at 30 and an open popover at 60 — and lost. Reported as a tooltip that renders
3540
+ below content "sometimes, not always", which is exactly what a level that depends
3541
+ on whatever ancestor happens to establish a context looks like. It reads
3542
+ --z-index-tooltip (70) now, the level published for it.
3543
+
3544
+ Still true, and NOT fixed by this: an ancestor with overflow: hidden clips an
3545
+ absolutely positioned tooltip whatever its level. The fix for that is the one
3546
+ [data-terp="popover-panel"] already uses — position: fixed with measured
3547
+ coordinates — and it is a change to the component rather than to this sheet. */
3400
3548
  [data-terp="popover-panel"] {
3401
3549
  position: fixed;
3402
3550
  z-index: var(--z-index-popover);
@@ -133,8 +133,13 @@ const UNREAD_TOKENS: Record<string, string[]> = {
133
133
  * prose scale.
134
134
  */
135
135
  const BARE_TYPE_LITERALS: Record<string, Record<string, number>> = {
136
- "line-height": { "0": 2, "1": 4, "1.2": 4, "1.25": 7, "1.3": 2, "1.4": 3, "1.5": 4 },
137
- "letter-spacing": { "0": 4, "0.04em": 1, "0.06em": 1 },
136
+ "line-height": { "0": 2, "1": 5, "1.2": 4, "1.25": 7, "1.3": 2, "1.4": 3, "1.5": 4 },
137
+ // `inherit` is recorded rather than tokenised, and it is the one value here that is not
138
+ // debt: the sort button in a table header must render the tracking its own `th` sets, and
139
+ // naming a token would pin it to whatever that header uses TODAY. The UA stylesheet resets
140
+ // letter-spacing on form controls, so inheritance has to be asked for explicitly — the
141
+ // header and its button were rendering two different treatments side by side until it was.
142
+ "letter-spacing": { "0": 4, "0.04em": 1, "0.06em": 1, inherit: 1 },
138
143
  };
139
144
 
140
145
  describe("design tokens", () => {
@@ -242,3 +247,45 @@ describe("design tokens", () => {
242
247
  }
243
248
  });
244
249
  });
250
+
251
+ /**
252
+ * Bare `z-index` values the sheet is allowed to write, and why each one is not the app-wide
253
+ * order. This is a whitelist rather than a count, because unlike the type scale there is no
254
+ * migration debt here: a full `--z-index-*` family is published and read by seven rules, so
255
+ * any NEW bare value is a component opting out of the order rather than waiting for a pass.
256
+ *
257
+ * It exists because the absence of it cost a real defect. Tooltip wrote `z-index: 1` with a
258
+ * comment arguing it was "a local lift within a stacking context rather than a place in the
259
+ * app-wide order" — sound about its anchor and wrong about the page, since
260
+ * `[data-terp="tooltip-anchor"]` is only `position: relative` and so establishes no stacking
261
+ * context. The 1 competed in the root context against a sticky header at 30 and an open
262
+ * popover at 60, and lost: a tooltip that rendered below content "sometimes, not always".
263
+ * Nothing caught it, because a published scale with no guard against skipping it is a
264
+ * convention, not a contract.
265
+ */
266
+ const LOCAL_STACKING_LIFTS: Record<string, string> = {
267
+ 'dataview-column-resizer': "a lift inside the cell's own stacking context, above the cell's content and nothing else",
268
+ };
269
+
270
+ describe("stacking order", () => {
271
+ it("every z-index reads the published scale, or is a recorded local lift", () => {
272
+ const bare = [...sheet.matchAll(/([^{}]+)\{([^}]*)\}/g)].flatMap(([, selector, body]) => {
273
+ const match = /\bz-index:\s*([^;]+);/.exec(body ?? "");
274
+ if (match === null || match[1]!.includes("var(")) {
275
+ return [];
276
+ }
277
+ const marker = /data-terp="([^"]+)"/.exec(selector ?? "");
278
+ return [{ marker: marker?.[1] ?? (selector ?? "").trim(), value: match[1]!.trim() }];
279
+ });
280
+
281
+ const unrecorded = bare.filter(({ marker }) => !(marker in LOCAL_STACKING_LIFTS));
282
+ expect(
283
+ unrecorded,
284
+ "a bare z-index is a component opting out of the app-wide --z-index-* order. Read the " +
285
+ "token published for its level, or record it in LOCAL_STACKING_LIFTS with the reason " +
286
+ "its stacking context is genuinely local — and be sure it IS local: an anchor that is " +
287
+ "only position: relative establishes no stacking context, which is how the tooltip's " +
288
+ "own lift ended up competing with the page and losing.",
289
+ ).toEqual([]);
290
+ });
291
+ });
@@ -127,3 +127,93 @@ describe("Combobox", () => {
127
127
  expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
128
128
  });
129
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
+ });