@terpjs/react-core 0.10.0 → 0.12.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 +7 -6
- package/package.json +2 -2
- package/src/AppShell.test.tsx +52 -4
- package/src/AppShell.tsx +35 -17
- package/src/ConfirmDialog.tsx +4 -4
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +31 -8
- package/src/ErrorState.tsx +8 -4
- package/src/Field.test.tsx +20 -0
- package/src/Field.tsx +2 -2
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +33 -20
- package/src/UserMenu.test.tsx +2 -2
- package/src/bootstrap.tsx +5 -2
- package/src/format.test.tsx +3 -3
- package/src/index.ts +24 -3
- package/src/locale.test.tsx +195 -3
- package/src/locale.tsx +196 -11
- package/src/markers.test.ts +2 -0
- package/src/nav.ts +2 -2
- package/src/router.tsx +13 -3
- package/src/sso.ts +2 -2
- package/src/styles.test.ts +84 -34
- package/src/styles.ts +233 -31
- 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.test.tsx +15 -1
- package/src/uiText.tsx +71 -2
package/src/styles.test.ts
CHANGED
|
@@ -63,6 +63,38 @@ afterEach(() => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("injectTerpStyles", () => {
|
|
66
|
+
it("adopts a constructable stylesheet when the document supports one", () => {
|
|
67
|
+
// jsdom exposes the CSSStyleSheet constructor but not document.adoptedStyleSheets,
|
|
68
|
+
// so the property is defined here to reach the branch a real browser takes. That
|
|
69
|
+
// branch is the whole point of the injector: an adopted sheet is not governed by
|
|
70
|
+
// CSP's style-src, so a generated app needs no 'unsafe-inline' — measured in
|
|
71
|
+
// Chromium, where a <style> element is reported as style-src-elem and dropped.
|
|
72
|
+
const sheets: CSSStyleSheet[] = [];
|
|
73
|
+
Object.defineProperty(document, "adoptedStyleSheets", {
|
|
74
|
+
configurable: true,
|
|
75
|
+
get: () => sheets,
|
|
76
|
+
set: (next: CSSStyleSheet[]) => {
|
|
77
|
+
sheets.length = 0;
|
|
78
|
+
sheets.push(...next);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
injectTerpStyles();
|
|
83
|
+
expect(sheets.length).toBe(1);
|
|
84
|
+
// No element: taking the adopted route must not also append a <style>, or the
|
|
85
|
+
// page would carry the rules twice and still need the CSP keyword.
|
|
86
|
+
expect(document.getElementById(TERP_STYLES_ID)).toBeNull();
|
|
87
|
+
expect(sheets[0]?.cssRules.length ?? 0).toBeGreaterThan(0);
|
|
88
|
+
|
|
89
|
+
// Idempotent by the sheet's own mark rather than by an element id.
|
|
90
|
+
injectTerpStyles();
|
|
91
|
+
injectTerpStyles();
|
|
92
|
+
expect(sheets.length).toBe(1);
|
|
93
|
+
} finally {
|
|
94
|
+
delete (document as unknown as { adoptedStyleSheets?: unknown }).adoptedStyleSheets;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
66
98
|
it("appends the stylesheet once and is idempotent on re-invocation", () => {
|
|
67
99
|
injectTerpStyles();
|
|
68
100
|
injectTerpStyles();
|
|
@@ -366,20 +398,30 @@ describe("cascade structure", () => {
|
|
|
366
398
|
).toContain("height: 100%");
|
|
367
399
|
});
|
|
368
400
|
|
|
369
|
-
it("puts the DataView's surface on the full variant, not on the
|
|
401
|
+
it("puts the DataView's surface on the full variant's TABLE, not on the root or the marker", () => {
|
|
370
402
|
// Both values of data-variant are stamped and only one has a rule, which is what keeps
|
|
371
403
|
// the embedded variant a bare grid. The alternative — surface on the marker, un-declared
|
|
372
404
|
// under [data-variant="embedded"] — needs background: transparent, border: 0 and
|
|
373
405
|
// border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
|
|
374
406
|
//
|
|
375
407
|
// dataview-embedded is the negative evidence this rests on: deleting the full-variant
|
|
376
|
-
// rule must move
|
|
408
|
+
// rule must move the full-variant baselines and leave that one untouched.
|
|
409
|
+
//
|
|
410
|
+
// The OWNER changed and the guarantee did not. The surface used to sit on the root, which
|
|
411
|
+
// made one card of the toolbar, the table and the pagination, divided by internal borders
|
|
412
|
+
// — and left the table's cells flush against the outer frame. It now sits on whatever
|
|
413
|
+
// occupies the table's slot, so the table is the object and the controls above and below
|
|
414
|
+
// it float on the page. Still keyed on the variant, for the reason above.
|
|
377
415
|
const base = layerBody("terp.base");
|
|
378
416
|
expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
|
|
379
417
|
expect(
|
|
380
|
-
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
381
|
-
"the surface belongs to the full variant",
|
|
418
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"]'),
|
|
419
|
+
"the surface belongs to the full variant's table slot",
|
|
382
420
|
).toBe(true);
|
|
421
|
+
expect(
|
|
422
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
423
|
+
"the root must NOT carry a surface — that is the card this change removed",
|
|
424
|
+
).toBe(false);
|
|
383
425
|
expect(
|
|
384
426
|
base,
|
|
385
427
|
"un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
|
|
@@ -402,38 +444,30 @@ describe("cascade structure", () => {
|
|
|
402
444
|
);
|
|
403
445
|
});
|
|
404
446
|
|
|
405
|
-
it("keeps the toolbar
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// neutral-0 rather than leaving it to the host. Against every composed DataView specimen
|
|
412
|
-
// dropping it moves nothing, because the full variant's root and the workbench's specimen
|
|
413
|
-
// card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
|
|
414
|
-
// declares nothing but a display, and the band would show the page canvas through it.
|
|
415
|
-
// `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
|
|
447
|
+
it("keeps the toolbar FLOATING — no surface, no divider, and still no ink", () => {
|
|
448
|
+
// This assertion reversed, and the reversal is the change: the band used to declare
|
|
449
|
+
// neutral-0 and two top radii because it was the top third of a card, and nothing else
|
|
450
|
+
// kept the selection colour inside the root's rounded frame. There is no card now. A
|
|
451
|
+
// background on a strip that sits on the page canvas paints a rectangle the design does
|
|
452
|
+
// not have, and a border under it draws a line across nothing.
|
|
416
453
|
//
|
|
417
|
-
// The
|
|
418
|
-
//
|
|
419
|
-
//
|
|
454
|
+
// The radii went with the card for the same reason: there is no rounded frame above the
|
|
455
|
+
// toolbar to stay inside. Selection mode keeps a fill — it marks a MODE and losing that
|
|
456
|
+
// makes selection invisible — but as a surface of its own, with its own padding and
|
|
457
|
+
// radius, asserted below.
|
|
420
458
|
//
|
|
421
|
-
//
|
|
422
|
-
// (`children` and `trailing`),
|
|
423
|
-
//
|
|
424
|
-
// stop the framework doing.
|
|
459
|
+
// NO colour survives unchanged, and it is the invariant with the sharpest edge: two of
|
|
460
|
+
// this element's direct children are arbitrary caller slots (`children` and `trailing`),
|
|
461
|
+
// so a muted ink here would inherit into app-authored filter controls — a silent restyle
|
|
462
|
+
// of app DOM, which is what the styling migration exists to stop the framework doing.
|
|
425
463
|
const base = layerBody("terp.base");
|
|
426
464
|
const at = base.indexOf('[data-terp="dataview-toolbar"]');
|
|
427
465
|
expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
|
|
428
466
|
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
429
|
-
expect(block, "
|
|
430
|
-
|
|
467
|
+
expect(block, "a floating strip paints no surface of its own").not.toContain("background:");
|
|
468
|
+
expect(block, "a divider under a floating strip is a line across nothing").not.toContain(
|
|
469
|
+
"border-block-end:",
|
|
431
470
|
);
|
|
432
|
-
for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
|
|
433
|
-
expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
|
|
434
|
-
`${radius}: var(--radius-lg)`,
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
471
|
expect(
|
|
438
472
|
/(^|;)\s*color:/.test(block),
|
|
439
473
|
"a colour here inherits into the caller's filter slot and trailing slot",
|
|
@@ -449,11 +483,27 @@ describe("cascade structure", () => {
|
|
|
449
483
|
layerBody("terp.state"),
|
|
450
484
|
"selection mode is a resting surface, not an interaction state",
|
|
451
485
|
).not.toContain('[data-variant="selection"]');
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
486
|
+
// The band used to read --density-cell-pad-x for its INLINE padding, so a compact view's
|
|
487
|
+
// band lined up with its first cell. That alignment target is gone with the card: the
|
|
488
|
+
// toolbar now aligns with the table's outer frame, whose position does not move with
|
|
489
|
+
// density, so inline padding here would push the controls off it. Block padding stays on
|
|
490
|
+
// the spacing scale — vertical rhythm was never the density question.
|
|
491
|
+
expect(block, "the band's block padding stays on the spacing scale").toContain(
|
|
492
|
+
"padding-block: var(--space-2)",
|
|
493
|
+
);
|
|
494
|
+
expect(
|
|
495
|
+
/padding-inline|padding:/.test(block),
|
|
496
|
+
"inline padding would push the controls off the table frame they now align to",
|
|
497
|
+
).toBe(false);
|
|
498
|
+
// Selection mode is the exception and declares its own inline padding, because there it
|
|
499
|
+
// IS a surface and its fill needs to clear its text.
|
|
500
|
+
const selectionAt = base.indexOf('[data-terp="dataview-toolbar"][data-variant="selection"]');
|
|
501
|
+
const selectionBlock = base.slice(
|
|
502
|
+
base.indexOf("{", selectionAt) + 1,
|
|
503
|
+
base.indexOf("}", selectionAt),
|
|
504
|
+
);
|
|
505
|
+
expect(selectionBlock, "the selection surface reads density for its own inset").toContain(
|
|
506
|
+
"padding-inline: var(--density-cell-pad-x)",
|
|
457
507
|
);
|
|
458
508
|
});
|
|
459
509
|
|
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);
|
|
@@ -4080,13 +4228,50 @@ button[data-terp="input"][data-placeholder="true"] {
|
|
|
4080
4228
|
}
|
|
4081
4229
|
`;
|
|
4082
4230
|
|
|
4231
|
+
/**
|
|
4232
|
+
* The property stamped on the constructed sheet so a second call recognises it.
|
|
4233
|
+
*
|
|
4234
|
+
* On the sheet object rather than on an element or a `data-` attribute: the sheet
|
|
4235
|
+
* lives on the `document`, so the mark is document-scoped exactly like the old
|
|
4236
|
+
* element-id check was, and it cannot collide with the `data-terp*` selectors
|
|
4237
|
+
* this very stylesheet declares.
|
|
4238
|
+
*/
|
|
4239
|
+
const ADOPTED_MARKER = "__terpStylesId";
|
|
4240
|
+
|
|
4241
|
+
/** A constructed sheet plus the mark identifying it as ours. */
|
|
4242
|
+
type MarkedSheet = CSSStyleSheet & { [ADOPTED_MARKER]?: string };
|
|
4243
|
+
|
|
4244
|
+
/**
|
|
4245
|
+
* `document.adoptedStyleSheets` is absent in jsdom, so the property is read
|
|
4246
|
+
* through a type that admits that. Cast via `unknown` rather than intersected
|
|
4247
|
+
* with `Document`, because the DOM lib declares the property as always present
|
|
4248
|
+
* and an intersection would keep that stricter declaration.
|
|
4249
|
+
*/
|
|
4250
|
+
type AdoptableDocument = { adoptedStyleSheets?: MarkedSheet[] };
|
|
4251
|
+
|
|
4083
4252
|
/**
|
|
4084
4253
|
* Inject the react-core interaction-state stylesheet once per document.
|
|
4085
4254
|
*
|
|
4086
|
-
*
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
4089
|
-
*
|
|
4255
|
+
* Prefers a **constructable stylesheet** (`new CSSStyleSheet()` +
|
|
4256
|
+
* `document.adoptedStyleSheets`), because that is the only injection route a
|
|
4257
|
+
* Content-Security-Policy does not have to widen for. A `<style>` element's
|
|
4258
|
+
* rules are inline styles as far as CSP is concerned, so shipping them obliged
|
|
4259
|
+
* every generated app to serve `style-src 'unsafe-inline'` — a keyword that,
|
|
4260
|
+
* once present, also permits every *other* inline stylesheet on the page,
|
|
4261
|
+
* including one an injection managed to introduce. Measured in Chromium: an
|
|
4262
|
+
* adopted sheet applies cleanly under `style-src 'self'` while a `<style>`
|
|
4263
|
+
* element is reported as a `style-src-elem` violation and its rules dropped.
|
|
4264
|
+
*
|
|
4265
|
+
* The `<style>` element remains the fallback, because a browser without
|
|
4266
|
+
* constructable stylesheets would otherwise render the chrome unstyled. Under a
|
|
4267
|
+
* strict policy those browsers get no styling either way, so the fallback only
|
|
4268
|
+
* ever helps.
|
|
4269
|
+
*
|
|
4270
|
+
* SSR-safe: no-op when `document` is undefined. Idempotent by either route — the
|
|
4271
|
+
* adopted sheet carries {@link ADOPTED_MARKER}, the element is keyed by
|
|
4272
|
+
* {@link TERP_STYLES_ID} — so repeated calls from any component's module scope
|
|
4273
|
+
* attach the rules exactly once. Neither route touches an HTML sink: the element
|
|
4274
|
+
* path sets `textContent`, never `innerHTML`, and `replaceSync` parses CSS only.
|
|
4090
4275
|
*/
|
|
4091
4276
|
export function injectTerpStyles(): void {
|
|
4092
4277
|
if (typeof document === "undefined") {
|
|
@@ -4095,6 +4280,23 @@ export function injectTerpStyles(): void {
|
|
|
4095
4280
|
if (document.getElementById(TERP_STYLES_ID) !== null) {
|
|
4096
4281
|
return;
|
|
4097
4282
|
}
|
|
4283
|
+
|
|
4284
|
+
const adopted = (document as unknown as AdoptableDocument).adoptedStyleSheets;
|
|
4285
|
+
if (adopted !== undefined && typeof CSSStyleSheet === "function") {
|
|
4286
|
+
if (adopted.some((sheet) => sheet[ADOPTED_MARKER] === TERP_STYLES_ID)) {
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
try {
|
|
4290
|
+
const sheet: MarkedSheet = new CSSStyleSheet();
|
|
4291
|
+
sheet.replaceSync(TERP_STYLES_CSS);
|
|
4292
|
+
sheet[ADOPTED_MARKER] = TERP_STYLES_ID;
|
|
4293
|
+
(document as unknown as AdoptableDocument).adoptedStyleSheets = [...adopted, sheet];
|
|
4294
|
+
return;
|
|
4295
|
+
} catch {
|
|
4296
|
+
// A browser that exposes the API but refuses this sheet still gets styling.
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4098
4300
|
const el = document.createElement("style");
|
|
4099
4301
|
el.id = TERP_STYLES_ID;
|
|
4100
4302
|
el.textContent = TERP_STYLES_CSS;
|
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
|
+
});
|