@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/README.md +1 -1
- package/package.json +2 -2
- package/src/AppShell.test.tsx +9 -4
- package/src/AppShell.tsx +17 -3
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +30 -18
- package/src/locale.tsx +9 -0
- package/src/markers.test.ts +2 -0
- package/src/router.tsx +11 -1
- package/src/styles.test.ts +52 -34
- package/src/styles.ts +175 -27
- package/src/tokens.guard.test.ts +49 -2
- package/src/ui/Combobox.test.tsx +90 -0
- package/src/ui/Combobox.tsx +247 -41
- package/src/ui/DatePicker.tsx +16 -7
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +14 -0
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +27 -0
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
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
the
|
|
1954
|
-
|
|
1955
|
-
|
|
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)
|
|
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)
|
|
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:
|
|
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
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
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);
|
package/src/tokens.guard.test.ts
CHANGED
|
@@ -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":
|
|
137
|
-
|
|
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
|
+
});
|
package/src/ui/Combobox.test.tsx
CHANGED
|
@@ -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
|
+
});
|