- grid.setHideGroupedColumns(!hideGroupedColumns)}
- type="checkbox"
+ onCheckedChange={(next) => grid.setHideGroupedColumns(next)}
/>
{messages.toolPanelHideGroupedColumnsLabel()}
diff --git a/packages/ui/grid.css b/packages/ui/grid.css
index 05b5089c0..7a55f991a 100644
--- a/packages/ui/grid.css
+++ b/packages/ui/grid.css
@@ -246,6 +246,53 @@
color: var(--pretable-text-selected);
}
+ /* ---- Kit components: text input --------------------------------------
+ The native input in the grid's box: the same rule border, grid surface
+ and cell ink the select trigger draws, so a field and the picker beside
+ it are one family. Sites keep their size, inset and flex participation
+ (the site rules below win by order); the element stays native, so the
+ date type keeps its picker. */
+ :where([data-pretable-text-input]) {
+ box-sizing: border-box;
+ padding-inline: 6px;
+ border: 1px solid var(--pretable-rule);
+ border-radius: var(--pretable-radius-control);
+ background: var(--pretable-bg-grid);
+ color: var(--pretable-text-cell);
+ font: inherit;
+ }
+
+ /* ---- Kit components: checkbox ----------------------------------------
+ The 16px square every checkbox in the grid is — the row-select cell, the
+ header select-all, the boolean cell, the column toggle, the checklists
+ and the hide-grouped switch — from one rule, so a theme edit reaches all
+ of them and two hand-kept copies cannot drift. aria-checked is the state
+ channel; the glyph is the kit icon, sized by the font-size here.
+ The tool panel's visibility toggle is enrolled here rather than restyled
+ in its own section: it IS this checkbox, and two hand-kept copies of the
+ same control are how a theme edit makes them drift apart. */
+ :where([data-pretable-checkbox]) {
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--pretable-checkbox-border);
+ background: var(--pretable-checkbox-bg);
+ border-radius: 3px;
+ cursor: pointer;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ line-height: 1;
+ color: var(--pretable-checkbox-checked-fg);
+ }
+ :where([data-pretable-checkbox][aria-checked="true"]),
+ :where([data-pretable-checkbox][aria-checked="mixed"]) {
+ background: var(--pretable-checkbox-checked-bg);
+ border-color: var(--pretable-checkbox-checked-bg);
+ color: var(--pretable-checkbox-checked-fg);
+ }
+
/* Multi-sort priority badge (rendered only when 2+ columns are sorted) */
:where([data-pretable-sort-priority]) {
margin-left: 2px;
@@ -862,63 +909,30 @@
padding-top: var(--pretable-cell-padding-y);
}
- /* The tool panel's visibility toggle is enrolled here rather than restyled
- in its own section: it IS this checkbox, and two hand-kept copies of the
- same control are how a theme edit makes them drift apart. */
- :where(
- button[data-pretable-row-select],
- button[data-pretable-row-select-all],
- button[data-pretable-bool-cell],
- button[data-pretable-tool-column-toggle]
- ) {
- width: 16px;
- height: 16px;
- border: 1px solid var(--pretable-checkbox-border);
- background: var(--pretable-checkbox-bg);
- border-radius: 3px;
- cursor: pointer;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- font-size: 11px;
- line-height: 1;
- color: var(--pretable-checkbox-checked-fg);
- }
- :where(
- button[data-pretable-row-select][aria-checked="true"],
- button[data-pretable-row-select-all][aria-checked="true"],
- button[data-pretable-bool-cell][aria-checked="true"],
- button[data-pretable-tool-column-toggle][aria-checked="true"]
- ) {
- background: var(--pretable-checkbox-checked-bg);
- border-color: var(--pretable-checkbox-checked-bg);
- color: var(--pretable-checkbox-checked-fg);
- }
- :where(
- button[data-pretable-row-select][aria-checked="mixed"],
- button[data-pretable-row-select-all][aria-checked="mixed"]
- ) {
- background: var(--pretable-checkbox-checked-bg);
- border-color: var(--pretable-checkbox-checked-bg);
- color: var(--pretable-checkbox-checked-fg);
- }
-
- /* Boolean toggle cell (typed editors) */
- :where(button[data-pretable-bool-cell]) {
+ /* Boolean toggle cell (typed editors). The box, the glyph colour and the
+ checked fill all come from the kit checkbox rule; what is left here is
+ the cell's own placement — centred in a data cell — and the two states
+ no other checkbox has, busy and invalid. */
+ :where([data-pretable-bool-cell]) {
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
- :where(button[data-pretable-bool-cell]:disabled) {
- cursor: default;
- }
- :where(button[data-pretable-bool-cell][aria-busy="true"]) {
+ :where([data-pretable-bool-cell][aria-busy="true"]) {
opacity: 0.5;
cursor: wait;
}
- :where(button[data-pretable-bool-cell][aria-invalid="true"]) {
+ /* This outline shares the kit's focus-ring property. The kit rule at the end
+ of the layer now matches `[data-pretable-checkbox]:focus-visible`, which
+ this cell is one of, and both selectors flatten to (0,0,0) — so if the two
+ ever matched at once the later kit rule would take the outline. Latent
+ today: the control is `tabIndex={-1}`, so a click never gives it
+ `:focus-visible`. And that precedence is the RIGHT one if it ever fires —
+ while a control is focused the focus ring must be the visible state, and
+ the invalid state is still carried to assistive tech by `aria-invalid` and
+ `aria-errormessage`, which no outline can say. Left alone deliberately. */
+ :where([data-pretable-bool-cell][aria-invalid="true"]) {
outline: 1px solid var(--pretable-text-error);
outline-offset: 1px;
}
@@ -1119,43 +1133,31 @@
line-height: 1.5;
color: var(--pretable-text-cell);
}
- :where([data-pretable-filter-menu]) input[type="text"],
- :where([data-pretable-filter-menu]) input[type="date"],
- :where([data-pretable-filter-menu]) input:not([type]) {
+ /* The dialog's three value fields. Kit text inputs: the kit rule at the top
+ of this file draws the box, the rule border and the ink. What is the
+ dialog's own is the size and the inset — full width, the 28px the
+ operator picker beside them stands at (an input left to its own padding
+ comes out several pixels taller, and a stacked pair that does not line
+ up is what this height buys), and this dialog's 7px rather than the
+ kit's 6px. */
+ :where(
+ [data-pretable-filter-value],
+ [data-pretable-filter-min],
+ [data-pretable-filter-max]
+ ) {
width: 100%;
- box-sizing: border-box;
- /* An explicit box, like the tool pane's identical controls: the operator
- picker beside these is a kit select with its own fixed block-size, and
- an input left to its own padding comes out several pixels taller — a
- stacked pair that does not line up. Both centre their own text
- vertically once the height is set, so the block padding goes. */
block-size: 28px;
padding-inline: 7px;
- border: 1px solid var(--pretable-rule);
- border-radius: var(--pretable-radius-control);
- background: var(--pretable-bg-grid);
- color: var(--pretable-text-cell);
- font: inherit;
}
- /* The dialog's operator picker. A kit select: the rule above draws the box
- for the INPUTS beside it, and the kit rule at the top of this file draws
- this one's. What is the dialog's own is the size — full width, the 28px
- the fields beside it stand at, and this dialog's 7px inset rather than
- the kit's 6px. */
+ /* The dialog's operator picker. A kit select: the kit rules at the top of
+ this file draw both this box and the fields' beside it. What is the
+ dialog's own is the size — full width, the 28px the fields beside it
+ stand at, and this dialog's 7px inset rather than the kit's 6px. */
:where([data-pretable-filter-operator]) {
width: 100%;
block-size: 28px;
padding-inline: 7px;
}
- /* The product's focus ring, not the host page's. Without this the dialog's
- fields keep the UA ring — which takes the CONSUMER's accent-color, so
- tabbing from the pane's copy of these same controls to this one changed
- the ring's colour, width and offset. The declaration the builder rows
- already share. */
- :where([data-pretable-filter-menu] input:focus-visible) {
- outline: 2px solid var(--pretable-focus-ring);
- outline-offset: -2px;
- }
:where([data-pretable-filter-set]) label {
display: flex;
gap: 6px;
@@ -1750,19 +1752,14 @@
overflow-y: auto;
}
- /* The filter menu's input treatment, restated rather than shared: the menus
- portal into and this input does not, and coupling their selectors
- would make the next popover edit silently restyle the panel. */
+ /* A kit text input: the kit rule at the top of this file draws the box, the
+ rule border and the ink. What is the pane's own is the size and the
+ spacing — full width, the 6px gap to the list below, and this pane's
+ 5px/7px inset rather than the kit's 6px inline. */
:where([data-pretable-tool-search]) {
- box-sizing: border-box;
inline-size: 100%;
margin-block-end: 6px;
padding: 5px 7px;
- border: 1px solid var(--pretable-rule);
- border-radius: var(--pretable-radius-control);
- background: var(--pretable-bg-grid);
- color: var(--pretable-text-cell);
- font: inherit;
}
/* Group labels split the list by pin state. Size and tracking do the
@@ -1983,11 +1980,11 @@
color: var(--pretable-text-dim);
}
- /* The single-value field. NOT a kit control — an
, which no kit rule
- draws — so it keeps the whole box here: the frame, the surface, the ink
- and the font, at the same 24px the pickers beside it stand at, because
- the difference between "which column", "which operator" and "what value"
- is the words inside the box, not the frame around it.
+ /* The single-value field. A kit text input: the kit rule at the top of this
+ file draws the frame, the surface, the ink and the font, so what is left
+ here is size and flex participation — the same 24px the pickers beside it
+ stand at, because the difference between "which column", "which operator"
+ and "what value" is the words inside the box, not the frame around it.
`flex: 1 1 auto` WITH an explicit `min-inline-size`, which is the pair
that makes the wrapping row above work: a field's automatic minimum size
is its content, so one long value would hold the row wider than the pane
@@ -1999,22 +1996,11 @@
one states nothing.
24px is the height this section bought for the join and the add actions,
so the whole row clears 2.5.8 on every pointer rather than only where a
- coarse-pointer block applies.
- `font: inherit` as the SHORTHAND — the add action's choice, not the
- join's longhands: UA stylesheets set their own font on form controls, and
- this rule sets no size of its own, so there is nothing for the shorthand
- to overwrite. */
- :where(input[data-pretable-filter-row-value]) {
- box-sizing: border-box;
+ coarse-pointer block applies. */
+ :where([data-pretable-filter-row-value]) {
flex: 1 1 auto;
min-inline-size: 24px;
block-size: 24px;
- padding-inline: 6px;
- border: 1px solid var(--pretable-rule);
- border-radius: var(--pretable-radius-control);
- background: var(--pretable-bg-grid);
- color: var(--pretable-text-cell);
- font: inherit;
}
/* The builder's pickers: size and flex participation only — the kit select
rule at the top of this file draws the box. `min-inline-size` matters for
@@ -2039,16 +2025,18 @@
/* The set shape's checklist, which is not a field: it takes its own line
(`flex-basis: 100%` in a wrapping row) because a column of checkboxes
beside three 24px controls would set the row's height to the number of
- choices. Declared AFTER the rule above and matching a different element,
- so nothing here is overriding anything — every selector in this file is
- :where()-flattened to (0,0,0) and source order is the only lever. */
- :where(div[data-pretable-filter-row-value]) {
+ choices. Its OWN attribute, not the field's: the wrapper wore
+ `data-pretable-filter-row-value` while the field was a bare
and
+ an element-type selector could tell the two apart. Both are kit controls
+ now — the field is the kit's
— so one attribute on both would
+ hand the wrapper the field's size rule. */
+ :where([data-pretable-filter-row-set]) {
display: flex;
flex-direction: column;
gap: 2px;
flex-basis: 100%;
}
- :where(div[data-pretable-filter-row-value]) label {
+ :where([data-pretable-filter-row-set]) label {
display: flex;
align-items: center;
gap: 6px;
@@ -2083,32 +2071,11 @@
);
color: var(--pretable-text-cell);
}
- /* The value field's ring, an OUTLINE like every other ring in this file: a
- box-shadow would spend the one shadow slot this control might later want
- for elevation, and whichever declaration lost would lose silently. The
- two pickers beside it take the KIT select's ring — same declaration, one
- source — so only the input is named here.
- `input[...-value]`, not the bare attribute: the set shape wears the same
- attribute on a
wrapper, which takes no focus. A rule that matched it
- would be inert today and would paint a ring around the whole checklist the
- day anything made that wrapper focusable — a state nobody would have
- designed. The FIELDS are what focus, so the field is what the selector
- names. */
- :where(input[data-pretable-filter-row-value]:focus-visible) {
- outline: 2px solid var(--pretable-focus-ring);
- outline-offset: -2px;
- }
- /* The checklist's checkboxes, which the rule above deliberately does not
- reach — they are inside the wrapper, not the wrapper. Without this they
- keep the UA's own focus ring, so tabbing through a set filter would change
- ring colour and shape halfway down a row that is otherwise entirely this
- section's. A POSITIVE offset, unlike every other ring here: a checkbox is
- ~13px and paints to its own edge, so an inset ring would sit on top of the
- box instead of around it. */
- :where(div[data-pretable-filter-row-value]) input:focus-visible {
- outline: 2px solid var(--pretable-focus-ring);
- outline-offset: 1px;
- }
+ /* The field's ring and the checklist's are both the KIT's now — the shared
+ ring rule in the state section, plus the kit-level positive offset every
+ checkbox takes. This section declared two of its own while these controls
+ were bare elements; keeping them would be two more copies of one
+ declaration to drift. */
/* The connective, which is the thing users misread — so it sits between
rows carrying the run's join rather than hiding in a group header. Two
@@ -2213,9 +2180,9 @@
flex-direction: row;
gap: 4px;
}
- /* The hide-grouped switch: a native checkbox and its clickable label on
+ /* The hide-grouped switch: a kit checkbox and its clickable label on
one line, sized to the add button's 24px row so the stack keeps the
- rail's rhythm. */
+ rail's rhythm. The ring is the kit's, here and in the checklists. */
:where([data-pretable-tool-grouping] label) {
display: inline-flex;
align-items: center;
@@ -2224,13 +2191,6 @@
block-size: 24px;
cursor: pointer;
}
- /* The checklist checkboxes' ring (its comment argues the POSITIVE offset:
- a ~13px box painting to its own edge would be covered by an inset
- ring), restated here because that rule is scoped to the filter rows. */
- :where(input[data-pretable-hide-grouped]:focus-visible) {
- outline: 2px solid var(--pretable-focus-ring);
- outline-offset: 1px;
- }
/* ---- Kit components: state -------------------------------------------
The ring and the disabled ink come LAST in the layer, after every site
@@ -2246,20 +2206,49 @@
:where(
[data-pretable-button]:focus-visible,
[data-pretable-icon-button]:focus-visible,
- [data-pretable-select]:focus-visible
+ [data-pretable-select]:focus-visible,
+ [data-pretable-text-input]:focus-visible,
+ [data-pretable-checkbox]:focus-visible
) {
outline: 2px solid var(--pretable-focus-ring);
outline-offset: -2px;
}
- /* The standard disabled treatment (#573): dim by token, drop the pointer. */
+ /* A checkbox rings OUTSIDE its box, alone among the kit controls. The
+ square is 16px and paints to its own edge — border, fill and glyph all
+ the way out — so the inset ring above would land ON the box rather than
+ around it, and the state would read as a recoloured border. The
+ checklists' own rules said exactly this while they had one each; every
+ checkbox gets it now, the four button sites included, which had the UA's
+ ring before this component existed.
+ AFTER the shared rule, not merged into it: both selectors are
+ :where()-flattened to (0,0,0), so this only wins by source order. */
+ :where([data-pretable-checkbox]:focus-visible) {
+ outline-offset: 1px;
+ }
+ /* The standard disabled treatment (#573): dim by token, drop the pointer.
+ These four controls all draw TEXT — a label, a committed value, typed
+ input — so dimming the ink is what "disabled" looks like on them. */
:where(
[data-pretable-button]:disabled,
[data-pretable-icon-button]:disabled,
- [data-pretable-select]:disabled
+ [data-pretable-select]:disabled,
+ [data-pretable-text-input]:disabled
) {
color: var(--pretable-text-dim);
cursor: default;
}
+ /* The checkbox is deliberately NOT in that list. Its `color` is not ink for
+ a label, it is the CHECK GLYPH, painted on the checked fill — so dimming
+ it puts --pretable-text-dim on --pretable-checkbox-checked-bg at about
+ 1.9:1 and the tick all but vanishes. A read-only boolean column renders
+ its cell control disabled on EVERY row, so that would be every tick in
+ the column. A checkbox's disabled look is its own tokens (border, fill,
+ and the shared opacity/forced-colors treatment), never text ink; all this
+ rule owes is the pointer. Declaring no `color` here also leaves the
+ checked rule above as the last word on the glyph. */
+ :where([data-pretable-checkbox]:disabled) {
+ cursor: default;
+ }
/* ---- Coarse pointers -------------------------------------------------- */
@@ -2466,7 +2455,9 @@
[data-pretable-menu-item]:disabled,
[data-pretable-button]:disabled,
[data-pretable-icon-button]:disabled,
- [data-pretable-select]:disabled
+ [data-pretable-select]:disabled,
+ [data-pretable-text-input]:disabled,
+ [data-pretable-checkbox]:disabled
) {
color: GrayText;
}
@@ -2480,6 +2471,18 @@
background-color: Highlight;
color: HighlightText;
}
+ /* A checked box is an author fill and an author glyph colour, both of
+ which the UA erases — leaving a checked checkbox identical to an
+ unchecked one, which is the whole of what the control says. Answered
+ exactly as the committed option above: the system's guaranteed pair,
+ and `forced-color-adjust: none` so the pair survives. The glyph is
+ drawn in currentColor, so HighlightText carries it too. */
+ :where([data-pretable-checkbox][aria-checked="true"]),
+ :where([data-pretable-checkbox][aria-checked="mixed"]) {
+ forced-color-adjust: none;
+ background-color: Highlight;
+ color: HighlightText;
+ }
}
/* Reduced motion. Every animated declaration in this file is named here:
diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts
index d8a667913..4e6d3a69b 100644
--- a/packages/ui/src/__tests__/css-cascade.test.ts
+++ b/packages/ui/src/__tests__/css-cascade.test.ts
@@ -58,6 +58,52 @@ const SELECT_SITES = [
"aggregate",
];
+/** The three text-field sites `PretableTextInput` collapses: the filter
+ * dialog's value field (which is really three — the plain value and the
+ * between-range min/max twins, each on its own attribute), the builder
+ * row's value field, and the tool pane's search box. */
+const TEXT_INPUT_SITES = ["filter-value", "filter-row-value", "tool-search"];
+
+/** The seven checkbox sites `PretableCheckbox` collapses: the selection
+ * column and its header select-all, the boolean cell, the tool panel's
+ * visibility toggle and its hide-grouped switch, and the two checklists
+ * (the filter dialog's set shape and the builder row's). */
+const CHECKBOX_SITES = [
+ "row-select",
+ "row-select-all",
+ "bool-cell",
+ "tool-column-toggle",
+ "hide-grouped",
+ "filter-choice",
+ "filter-row-choice",
+];
+
+/** A site is normally one attribute, `data-pretable-
`. The filter
+ * dialog's value field is the exception: the between-range twins are the
+ * SAME site wearing `-min`/`-max`, so a guard that looked only for
+ * `-value` would police one third of it. */
+const SITE_ATTRS: Record = {
+ "filter-value": [
+ "data-pretable-filter-value]",
+ "data-pretable-filter-min]",
+ "data-pretable-filter-max]",
+ ],
+};
+/** The `]` terminator is what keeps `data-pretable-row-select]` from also
+ * matching `data-pretable-row-select-all]` and
+ * `data-pretable-row-select-cell="true"]`. */
+const attrsFor = (site: string) =>
+ SITE_ATTRS[site] ?? [`data-pretable-${site}]`];
+
+/** The kit's checkbox-only ring offset, the one declaration that makes a
+ * checkbox ring OUTSIDE its box. Read by the builder checklist's guard,
+ * which cares that this is what rings its choices now — the section having
+ * dropped the identical rule it used to carry. */
+const kitCheckboxRingOffset = (css: string) =>
+ css.match(
+ /:where\(\[data-pretable-checkbox\]:focus-visible\)\s*\{([\s\S]*?)\}/,
+ )?.[1] ?? "";
+
/** The token names pretable.css declares — the contract a section may read
* from and must not add to. Same source the token contract test loads. */
const tokenContract = () => {
@@ -83,7 +129,7 @@ describe("grid.css cascade contract", () => {
expect(css).toMatch(/:where\(textarea\.pretable-cell-editor\)/);
expect(css).toMatch(/data-pretable-bool-cell/);
expect(css).toMatch(
- /:where\(button\[data-pretable-bool-cell\]\[aria-invalid="true"\]\)/,
+ /:where\(\[data-pretable-bool-cell\]\[aria-invalid="true"\]\)/,
);
expect(css).toMatch(/var\(--pretable-edit-bg\)/);
expect(css).toMatch(/var\(--pretable-text-error\)/);
@@ -520,6 +566,207 @@ describe("grid.css cascade contract", () => {
).toMatch(/background-color:\s*Highlight/);
});
+ test("the kit text input carries the field box", () => {
+ // The three fields each drew their own frame, surface, ink and font —
+ // three hand-kept copies of the select trigger's box, which is what made
+ // the dialog's field and the picker beside it drift by a pixel and a
+ // token at a time. One rule now; the sites keep only their size.
+ const css = strippedCss();
+ const base = rulesSelecting(
+ css,
+ (s) =>
+ s.includes("data-pretable-text-input]") &&
+ !s.includes("::") &&
+ !s.includes(":hover") &&
+ !s.includes(":focus") &&
+ !s.includes(":disabled"),
+ );
+ expect(base.length, "no kit text-input rule").toBeGreaterThan(0);
+ const body = base.map((m) => m[2]).join("");
+ for (const decl of [
+ /border:\s*1px solid var\(--pretable-rule\)/,
+ /border-radius:\s*var\(--pretable-radius-control\)/,
+ /background:\s*var\(--pretable-bg-grid\)/,
+ /color:\s*var\(--pretable-text-cell\)/,
+ /font:\s*inherit/,
+ /box-sizing:\s*border-box/,
+ // The inner inset is the kit's too — the same 6px the select trigger
+ // holds its label at, so a stacked field and picker line up inside.
+ /padding-inline:\s*6px/,
+ ])
+ expect(body).toMatch(decl);
+
+ // Ring and disabled ink in the state section, like every other kit.
+ const state = rulesSelecting(
+ css,
+ (s) =>
+ s.includes("data-pretable-text-input]:focus-visible") ||
+ s.includes("data-pretable-text-input]:disabled"),
+ )
+ .map((m) => m[2])
+ .join("");
+ expect(state).toMatch(/outline:\s*2px solid var\(--pretable-focus-ring\)/);
+ expect(state).toMatch(/color:\s*var\(--pretable-text-dim\)/);
+ expect(state).toMatch(/cursor:\s*default/);
+ });
+
+ test("the kit checkbox carries the square, checked and under forced colours", () => {
+ const css = strippedCss();
+ // The base rule alone: the checked rules declare a background and a
+ // colour of their own, and letting them into this bucket would let the
+ // fill vouch for the box.
+ const base = rulesSelecting(
+ css,
+ (s) =>
+ s.includes("data-pretable-checkbox]") &&
+ !s.includes("aria-checked") &&
+ !s.includes("::") &&
+ !s.includes(":hover") &&
+ !s.includes(":focus") &&
+ !s.includes(":disabled"),
+ );
+ expect(base.length, "no kit checkbox rule").toBeGreaterThan(0);
+ const body = base.map((m) => m[2]).join("");
+ for (const decl of [
+ /width:\s*16px/,
+ /height:\s*16px/,
+ /border:\s*1px solid var\(--pretable-checkbox-border\)/,
+ /background:\s*var\(--pretable-checkbox-bg\)/,
+ /border-radius:\s*3px/,
+ /cursor:\s*pointer/,
+ /display:\s*inline-flex/,
+ /padding:\s*0/,
+ // The glyph is a kit icon with no size of its own here; the font-size
+ // and the leading are what keep a check from pushing the 16px box out.
+ /font-size:\s*11px/,
+ /line-height:\s*1/,
+ /color:\s*var\(--pretable-checkbox-checked-fg\)/,
+ ])
+ expect(body).toMatch(decl);
+
+ // Both checked states fill, and both fill the BORDER too — a checked box
+ // that kept the unchecked border reads as a half-drawn control.
+ for (const state of ["true", "mixed"]) {
+ const rules = rulesSelecting(css, (s) =>
+ s.includes(`data-pretable-checkbox][aria-checked="${state}"]`),
+ );
+ expect(
+ rules.length,
+ `nothing fills a checkbox at aria-checked="${state}"`,
+ ).toBeGreaterThan(0);
+ const filled = rules.map((m) => m[2]).join("");
+ expect(filled).toMatch(
+ /background:\s*var\(--pretable-checkbox-checked-bg\)/,
+ );
+ expect(filled).toMatch(
+ /border-color:\s*var\(--pretable-checkbox-checked-bg\)/,
+ );
+ }
+
+ const state = rulesSelecting(
+ css,
+ (s) =>
+ s.includes("data-pretable-checkbox]:focus-visible") ||
+ s.includes("data-pretable-checkbox]:disabled"),
+ )
+ .map((m) => m[2])
+ .join("");
+ expect(state).toMatch(/outline:\s*2px solid var\(--pretable-focus-ring\)/);
+ expect(state).toMatch(/cursor:\s*default/);
+
+ // …and the disabled rule declares NO colour. `color` on a checkbox is the
+ // CHECK GLYPH, not label ink: the kit's --pretable-text-dim lands on the
+ // checked fill at about 1.9:1, and a read-only boolean column renders its
+ // control disabled on every row, so a dimmed tick is the whole column's
+ // ticks. The checkbox's disabled look is its own tokens; only the pointer
+ // belongs here. Scoped to the plain cascade — the forced-colours block
+ // below does set GrayText on it, and is answered there by the checked
+ // rules that follow it in source order.
+ const plain = css.replace(forcedColorsBlock(css), "");
+ const off = rulesSelecting(plain, (s) =>
+ s.includes("data-pretable-checkbox]:disabled"),
+ );
+ expect(off.length, "nothing disables a kit checkbox").toBeGreaterThan(0);
+ for (const [, selector, decls] of off)
+ expect(
+ decls,
+ `"${selector.trim()}" dims the check glyph on the checked fill`,
+ ).not.toMatch(/(^|[;{\s])color\s*:/);
+
+ // Forced colours erase the fill AND the glyph's colour, which between
+ // them are the whole of what a checkbox says — the committed option's
+ // failure exactly, answered the same way.
+ const forced = forcedColorsBlock(css);
+ for (const checked of ["true", "mixed"]) {
+ const rules = rulesSelecting(forced, (s) =>
+ s.includes(`data-pretable-checkbox][aria-checked="${checked}"]`),
+ );
+ expect(
+ rules.length,
+ `aria-checked="${checked}" is unanswered under forced colours`,
+ ).toBeGreaterThan(0);
+ const bodies = rules.map((m) => m[2]).join("");
+ expect(bodies).toMatch(/forced-color-adjust:\s*none/);
+ expect(bodies).toMatch(/background-color:\s*Highlight/);
+ expect(bodies).toMatch(/color:\s*HighlightText/);
+ expect(bodies).not.toMatch(/var\(--pretable-/);
+ }
+ const grayed = rulesSelecting(
+ forced,
+ (s) =>
+ s.includes(":disabled") &&
+ (s.includes("data-pretable-checkbox]") ||
+ s.includes("data-pretable-text-input]")),
+ );
+ for (const attr of ["data-pretable-checkbox]", "data-pretable-text-input]"])
+ expect(
+ grayed.map((m) => m[1]).join(""),
+ `[${attr} is not in the forced-colours disabled list`,
+ ).toContain(attr);
+ expect(grayed.map((m) => m[2]).join("")).toMatch(/color:\s*GrayText/);
+ });
+
+ test("no element-type selector targets a kit control", () => {
+ // The kit attribute is the whole contract: `input[data-pretable-…]` and
+ // `button[data-pretable-…]` say the control is an or a ,
+ // which is the component's business and not the stylesheet's — and a
+ // descendant `… input` reaches whatever a consumer renders inside the
+ // surface, kit control or not. Both shapes are what Task 8 removes when
+ // the sites start wearing [data-pretable-text-input] and
+ // [data-pretable-checkbox].
+ //
+ // The site list is the source, not a hand-kept alternation: every site
+ // that has a kit component behind it is policed, so a new one is covered
+ // the day it joins the list rather than the day someone remembers this
+ // regex. `data-pretable-filter-join` is deliberately NOT here — it is no
+ // kit control, and its element type is load-bearing: the SAME attribute
+ // rides a (the run's leading `Where`) that must not take the
+ // button's border, so `button[…]` there is the selector doing its job.
+ const kitAttrs = [
+ ...PUSH_BUTTON_SITES,
+ ...SELECT_SITES,
+ ...TEXT_INPUT_SITES,
+ ...CHECKBOX_SITES,
+ ]
+ .flatMap((site) => attrsFor(site))
+ // `attrsFor` carries the `]` terminator; the element-type shape needs
+ // the bare attribute name so `[` can follow it here.
+ .map((attr) => attr.slice(0, -1))
+ .concat(["data-pretable-text-input", "data-pretable-checkbox"]);
+ const typed = new RegExp(
+ `(^|[\\s,(])(input|button|select|textarea)\\[(${kitAttrs.join("|")})`,
+ );
+ const css = strippedCss();
+ const offenders = rulesSelecting(
+ css,
+ (s) => typed.test(s) || /[\])]\s+input(\[|:|\s|$)/.test(s),
+ ).map((m) => m[1].trim());
+ expect(
+ offenders,
+ `element-type selectors on kit controls: ${offenders.join(" | ")}`,
+ ).toEqual([]);
+ });
+
test("a push-button site rule declares only what is its own", () => {
// The kit rules own the box: the border, the background, the radius, the
// cursor, the font, the flex centring, the ring and the disabled ink. A
@@ -563,7 +810,36 @@ describe("grid.css cascade contract", () => {
/(?:^|[;{\s])font:\s*inherit/,
/(?:^|[;{\s])cursor:\s*pointer/,
];
-
+ // The kit text-input rule owns the field box the three fields used to
+ // each declare: the frame, the surface, the ink, the font and the
+ // border-box. Same shape as the select's list, because it is the same
+ // box — a field and the picker beside it are one family. A site keeps
+ // its SIZE, its inset and its flex participation.
+ const TEXT_INPUT_OWNED = [
+ /(?:^|[;{\s])border:\s*1px solid var\(--pretable-rule\)/,
+ /border-radius:\s*var\(--pretable-radius-control\)/,
+ /(?:^|[;{\s])background:\s*var\(--pretable-bg-grid\)/,
+ /(?:^|[;{\s])color:\s*var\(--pretable-text-cell\)/,
+ /(?:^|[;{\s])font:\s*inherit/,
+ /(?:^|[;{\s])box-sizing:\s*border-box/,
+ ];
+ // The kit checkbox rule owns the 16px square outright — there is no
+ // per-site size here, unlike every other kind: a checkbox is one size
+ // everywhere in the grid. So a site that names any of these is the
+ // second hand-kept copy the collapse exists to remove.
+ // `display: inline-flex` is the kit's; `display: flex` at the boolean
+ // cell is not the same declaration, and is that site's own placement
+ // decision (centre a 16px control inside a data cell), so the forbidden
+ // pattern below names the kit's value and nothing carves anything out.
+ const CHECKBOX_OWNED = [
+ /(?:^|[;{\s])width:\s*16px/,
+ /(?:^|[;{\s])height:\s*16px/,
+ /(?:^|[;{\s])border:\s*1px solid var\(--pretable-checkbox-border\)/,
+ /(?:^|[;{\s])background:\s*var\(--pretable-checkbox-bg\)/,
+ /border-radius:\s*3px/,
+ /(?:^|[;{\s])cursor:\s*pointer/,
+ /(?:^|[;{\s])display:\s*inline-flex/,
+ ];
// What each site genuinely OWNS, and must keep after the collapse: a
// size, a reveal, an alignment. Without this the guard passes for a site
// whose rule was deleted outright rather than collapsed into the kit.
@@ -593,30 +869,55 @@ describe("grid.css cascade contract", () => {
"filter-row-column": /block-size:\s*24px/,
"filter-row-operator": /block-size:\s*24px/,
aggregate: /block-size:\s*24px/,
+ // The dialog's field is 28px like its picker; the builder's is the
+ // section's 24px flex item; the search box is a full-width band.
+ "filter-value": /block-size:\s*28px/,
+ "filter-row-value": /block-size:\s*24px/,
+ "tool-search": /inline-size:\s*100%/,
+ // The one checkbox site with anything of its own: the boolean cell
+ // centres its square in the column. Every other checkbox is the kit's
+ // square and nothing else, so `null` here means what it means for the
+ // ghost buttons — anything left selecting it belongs to the kit.
+ "bool-cell": /margin:\s*0 auto/,
+ "row-select": null,
+ "row-select-all": null,
+ "tool-column-toggle": null,
+ "hide-grouped": null,
+ "filter-choice": null,
+ "filter-row-choice": null,
};
- for (const site of [...PUSH_BUTTON_SITES, ...SELECT_SITES]) {
- const attr = `data-pretable-${site}]`;
+ for (const site of [
+ ...PUSH_BUTTON_SITES,
+ ...SELECT_SITES,
+ ...TEXT_INPUT_SITES,
+ ...CHECKBOX_SITES,
+ ]) {
+ const attrs = attrsFor(site);
// Every rule that NAMES the site and is not a state or a pseudo —
// including the :has() ancestor rules, which is where two of these
// sites keep the whole of what they own.
const rules = rulesSelecting(
css,
(selector) =>
- selector.includes(attr) &&
+ attrs.some((attr) => selector.includes(attr)) &&
!selector.includes("::") &&
!selector.includes(":hover") &&
!selector.includes(":focus") &&
!selector.includes(":disabled"),
);
- const forbidden = SELECT_SITES.includes(site)
- ? SELECT_OWNED
- : [
- ...OWNED,
- ...(LABELLED_BUTTON_SITES.includes(site)
- ? LABELLED_OWNED
- : ICON_OWNED),
- ];
+ const forbidden = CHECKBOX_SITES.includes(site)
+ ? CHECKBOX_OWNED
+ : TEXT_INPUT_SITES.includes(site)
+ ? TEXT_INPUT_OWNED
+ : SELECT_SITES.includes(site)
+ ? SELECT_OWNED
+ : [
+ ...OWNED,
+ ...(LABELLED_BUTTON_SITES.includes(site)
+ ? LABELLED_OWNED
+ : ICON_OWNED),
+ ];
for (const [, selector, body] of rules) {
for (const owned of forbidden) {
expect(
@@ -648,48 +949,54 @@ describe("grid.css cascade contract", () => {
// hover-hidden control, the site's own business, and a guard demanding
// zero :focus-visible rules per site would have driven it out of the
// file — a keyboard-focused funnel that stays invisible.
- for (const [, selector, body] of rulesSelecting(css, (selector) =>
- selector.includes(`${attr}:focus-visible`),
- )) {
- expect(
- body,
- `"${selector.trim()}" draws ${site} its own focus ring; the kit owns the ring`,
- ).not.toMatch(/(?:^|[;{\s])outline(?:-[a-z]+)?:/);
- }
- for (const [, selector, body] of rulesSelecting(css, (selector) =>
- selector.includes(`${attr}:disabled`),
- )) {
- for (const decl of [/(?:^|[;{\s])color:/, /(?:^|[;{\s])cursor:/]) {
+ //
+ // Per ATTRIBUTE, not per site: the dialog's value field is three of
+ // them, and holding only the first to these would leave the min/max
+ // twins free to draw their own ring.
+ for (const attr of attrs) {
+ for (const [, selector, body] of rulesSelecting(css, (selector) =>
+ selector.includes(`${attr}:focus-visible`),
+ )) {
expect(
body,
- `"${selector.trim()}" gives ${site} its own disabled treatment; the kit owns it`,
- ).not.toMatch(decl);
+ `"${selector.trim()}" draws ${site} its own focus ring; the kit owns the ring`,
+ ).not.toMatch(/(?:^|[;{\s])outline(?:-[a-z]+)?:/);
+ }
+ for (const [, selector, body] of rulesSelecting(css, (selector) =>
+ selector.includes(`${attr}:disabled`),
+ )) {
+ for (const decl of [/(?:^|[;{\s])color:/, /(?:^|[;{\s])cursor:/]) {
+ expect(
+ body,
+ `"${selector.trim()}" gives ${site} its own disabled treatment; the kit owns it`,
+ ).not.toMatch(decl);
+ }
}
- }
- // No icon site is disabled today, so this is latent — but the rule is
- // the kit's (#573): a disabled button answers nothing to :hover, and a
- // site rule that skips :not(:disabled) would paint a hover background
- // on a disabled control the day one of these sites gains that state.
- for (const [, selector] of rulesSelecting(css, (selector) =>
- selector.includes(`${attr}:hover`),
- )) {
- // A comma-grouped selector can carry another site's hover alongside
- // this one — checking the whole string lets that OTHER part's
- // `:not(:disabled)` vouch for a part that has none of its own. Split
- // on the comma and hold only THIS site's part to that requirement.
- const parts = selector
- .split(",")
- .filter((part) => part.includes(`${attr}:hover`));
- expect(
- parts.length,
- `"${selector.trim()}" matched ${attr}:hover but no comma-part contains it`,
- ).toBeGreaterThan(0);
- for (const part of parts) {
+ // No icon site is disabled today, so this is latent — but the rule is
+ // the kit's (#573): a disabled button answers nothing to :hover, and a
+ // site rule that skips :not(:disabled) would paint a hover background
+ // on a disabled control the day one of these sites gains that state.
+ for (const [, selector] of rulesSelecting(css, (selector) =>
+ selector.includes(`${attr}:hover`),
+ )) {
+ // A comma-grouped selector can carry another site's hover alongside
+ // this one — checking the whole string lets that OTHER part's
+ // `:not(:disabled)` vouch for a part that has none of its own. Split
+ // on the comma and hold only THIS site's part to that requirement.
+ const parts = selector
+ .split(",")
+ .filter((part) => part.includes(`${attr}:hover`));
expect(
- part,
- `"${part.trim()}" hovers ${site} without :not(:disabled)`,
- ).toMatch(":not(:disabled)");
+ parts.length,
+ `"${selector.trim()}" matched ${attr}:hover but no comma-part contains it`,
+ ).toBeGreaterThan(0);
+ for (const part of parts) {
+ expect(
+ part,
+ `"${part.trim()}" hovers ${site} without :not(:disabled)`,
+ ).toMatch(":not(:disabled)");
+ }
}
}
}
@@ -717,6 +1024,19 @@ describe("grid.css cascade contract", () => {
// would let the select rule move below its sites unnoticed.
const kitSelectBase = css.search(/:where\(\[data-pretable-select\]\)\s*\{/);
expect(kitSelectBase, "no kit select base rule").toBeGreaterThan(-1);
+ // The field box and the 16px square are two more kit base rules, each
+ // with its own sites resting on the same ordering. Anchored with the
+ // `{` so a DESCENDANT rule (`:where([data-pretable-checkbox]) …`) cannot
+ // stand in for the base rule that moved — the trap SP2 hit with a bare
+ // indexOf on a selector prefix.
+ const kitTextInputBase = css.search(
+ /:where\(\[data-pretable-text-input\]\)\s*\{/,
+ );
+ expect(kitTextInputBase, "no kit text-input base rule").toBeGreaterThan(-1);
+ const kitCheckboxBase = css.search(
+ /:where\(\[data-pretable-checkbox\]\)\s*\{/,
+ );
+ expect(kitCheckboxBase, "no kit checkbox base rule").toBeGreaterThan(-1);
const positions = (attr: string) => {
const out: number[] = [];
@@ -724,9 +1044,12 @@ describe("grid.css cascade contract", () => {
out.push(at);
return out;
};
- const sitePositions = [...PUSH_BUTTON_SITES, ...SELECT_SITES].flatMap(
- (site) => positions(`data-pretable-${site}]`),
- );
+ const sitePositions = [
+ ...PUSH_BUTTON_SITES,
+ ...SELECT_SITES,
+ ...TEXT_INPUT_SITES,
+ ...CHECKBOX_SITES,
+ ].flatMap((site) => attrsFor(site).flatMap((attr) => positions(attr)));
expect(sitePositions.length, "no site rules at all").toBeGreaterThan(0);
expect(
kitBase,
@@ -740,6 +1063,25 @@ describe("grid.css cascade contract", () => {
...SELECT_SITES.flatMap((site) => positions(`data-pretable-${site}]`)),
),
);
+ // The same claim for the other two kits, each against its OWN sites: a
+ // kit rule that slid below one of the sites it draws loses to it at
+ // equal specificity, and nothing else in this file would notice.
+ for (const [what, base, sites] of [
+ ["text input", kitTextInputBase, TEXT_INPUT_SITES],
+ ["checkbox", kitCheckboxBase, CHECKBOX_SITES],
+ ] as const) {
+ const own = sites.flatMap((site) =>
+ attrsFor(site).flatMap((attr) => positions(attr)),
+ );
+ // A kit whose sites all happen to be unstyled has nothing to be ahead
+ // of, and `Math.min()` of an empty list is `Infinity` — which would
+ // pass this vacuously rather than skip it.
+ if (own.length === 0) continue;
+ expect(
+ base,
+ `the kit ${what}'s shared box comes after a site rule, which then cannot win at equal specificity`,
+ ).toBeLessThan(Math.min(...own));
+ }
// Measured against the site rules OUTSIDE the media blocks: the coarse
// and forced-colours blocks at the end of the layer are the file's last
@@ -777,6 +1119,22 @@ describe("grid.css cascade contract", () => {
selectDisabled,
"the kit select disabled treatment precedes a site rule, which can then overwrite it",
).toBeGreaterThan(lastSite);
+
+ // The checkbox's ring offset overrides the shared ring's `-2px`, and both
+ // selectors are :where()-flattened to (0,0,0), so ORDER is the whole of
+ // what makes it win. Above the shared rule it is silently undone and
+ // every checkbox rings inside its own box again — a state that looks
+ // like a recoloured border, with nothing else in this suite the wiser.
+ const checkboxOffset = css.search(
+ /:where\(\[data-pretable-checkbox\]:focus-visible\)\s*\{/,
+ );
+ expect(checkboxOffset, "no kit checkbox ring-offset rule").toBeGreaterThan(
+ -1,
+ );
+ expect(
+ checkboxOffset,
+ "the checkbox's ring offset precedes the shared ring, which then overwrites it at equal specificity",
+ ).toBeGreaterThan(ring);
});
test("a focused cell draws its ring with `outline`, never `box-shadow`", () => {
@@ -1121,18 +1479,42 @@ describe("grid.css cascade contract", () => {
test("the filter dialog's fields wear the product's focus ring", () => {
const css = strippedCss();
- // Without a rule of its own a field keeps the UA ring, which takes the
+ // Without a ring of its own a field keeps the UA's, which takes the
// CONSUMER's accent-color — a different colour and shape from the ring on
- // the identical controls in the tool pane.
+ // the identical controls in the tool pane. Since the collapse that ring
+ // is the KIT's, on `[data-pretable-text-input]`, rather than a rule of
+ // the dialog's own scoped to `[data-pretable-filter-menu] input`: the
+ // dialog's fields ARE kit text inputs, and the same declaration written
+ // twice is what drifts. So the claim is checked where it now lives, and
+ // it must still reach all three of the dialog's fields.
const rules = rulesSelecting(
css,
(selector) =>
- selector.includes("data-pretable-filter-menu") &&
- selector.includes(":focus-visible"),
+ selector.includes("data-pretable-text-input]:focus-visible") ||
+ (selector.includes("data-pretable-filter-menu") &&
+ selector.includes(":focus-visible")),
);
expect(rules.length).toBeGreaterThan(0);
const body = rules.map((m) => m[2]).join("");
expect(body).toMatch(/outline:\s*2px solid var\(--pretable-focus-ring\)/);
+ // The selector must be the KIT attribute, not a dialog-scoped copy: a
+ // rule saying `[data-pretable-filter-menu] input:focus-visible` would
+ // satisfy the assertion above while leaving the builder's identical
+ // field and the pane's search box on the UA ring.
+ expect(
+ rules.map((m) => m[1]).join(""),
+ "the dialog's field ring is a local copy again; the kit owns it",
+ ).toContain("data-pretable-text-input]:focus-visible");
+ // And the three fields still exist as sites, so the ring has something
+ // to reach; that they RENDER a kit text input is pinned in
+ // @pretable/react's attribute-contract suite, which can see the DOM.
+ for (const attr of [
+ "data-pretable-filter-value]",
+ "data-pretable-filter-min]",
+ "data-pretable-filter-max]",
+ ]) {
+ expect(css, `no rule for [${attr}`).toContain(attr);
+ }
});
test("body cells clip their own content instead of spilling into the next column", () => {
@@ -1677,13 +2059,15 @@ describe("grid.css cascade contract", () => {
new RegExp(`:where\\(\\s*\\[${attr}\\]`),
);
}
- // The visibility checkbox is the grid's own checkbox control, enrolled
- // in the row-select rules so the panel and the selection column cannot
- // drift apart.
+ // The visibility checkbox is the grid's own checkbox control — a kit
+ // `PretableCheckbox`, so the box comes from `[data-pretable-checkbox]`
+ // and the panel and the selection column cannot drift apart. What is
+ // asserted here is that the panel still IDENTIFIES it: an attribute a
+ // consumer can select on, and the site guard's own anchor.
expect(
css,
- "no rule for button[data-pretable-tool-column-toggle]",
- ).toMatch(/button\[data-pretable-tool-column-toggle\]/);
+ "no rule for [data-pretable-checkbox], which is what draws the visibility toggle",
+ ).toMatch(/:where\(\[data-pretable-checkbox\]\)/);
// The states the panel is unusable without: an open tab, a keyboard
// ring on tabs and rows, and drag feedback.
expect(css, "no selected-tab rule").toMatch(
@@ -1954,10 +2338,14 @@ describe("grid.css cascade contract", () => {
`[${attr}] is not in the leaf row's shared picker rule`,
).toContain(attr);
}
+ // The field's own attribute, and only its own: the checklist WRAPPER
+ // wore this same attribute until the collapse — `div[…]` told them
+ // apart — and now wears `data-pretable-filter-row-set` instead, so a
+ // bare-attribute predicate reaches exactly one rule again.
const fieldRules = rulesSelecting(
css,
(sel) =>
- sel.includes("input[data-pretable-filter-row-value]") &&
+ sel.includes("data-pretable-filter-row-value]") &&
!sel.includes(":focus"),
);
expect(fieldRules, "no leaf-row value field box rule").toHaveLength(1);
@@ -1981,8 +2369,24 @@ describe("grid.css cascade contract", () => {
/block-size:\s*24px/,
);
}
- // The field's own box-sizing; the pickers take the kit rule's.
- expect(field).toMatch(/box-sizing:\s*border-box/);
+ // Both take their box-sizing from a KIT rule now — the field from the
+ // text input's, the pickers from the select's. Same claim as before,
+ // one source instead of two: the 24px the row promises is an OUTER
+ // 24px, and a content-box field with a 1px border stands 26 beside a
+ // 24px picker.
+ expect(
+ rulesSelecting(
+ css,
+ (sel) =>
+ sel.includes("data-pretable-text-input]") &&
+ !sel.includes("::") &&
+ !sel.includes(":hover") &&
+ !sel.includes(":focus") &&
+ !sel.includes(":disabled"),
+ )
+ .map((m) => m[2])
+ .join(""),
+ ).toMatch(/box-sizing:\s*border-box/);
expect(
rulesSelecting(
css,
@@ -2005,9 +2409,14 @@ describe("grid.css cascade contract", () => {
// `flex-basis: 100%` it sits beside the three 24px fields and sets the
// row's height to the number of choices — an enum with twelve values
// would draw a twelve-line row inside a 264px pane.
+ // The wrapper wears its OWN attribute since the collapse
+ // (`data-pretable-filter-row-set`): while the field was a bare
+ // the element type told the two apart, and now that both are kit
+ // controls one shared attribute would hand the wrapper the field's
+ // 24px size rule.
const css = strippedCss();
const list = css.match(
- /:where\(div\[data-pretable-filter-row-value\]\)\s*\{([\s\S]*?)\}/,
+ /:where\(\[data-pretable-filter-row-set\]\)\s*\{([\s\S]*?)\}/,
)?.[1];
expect(list, "no set-shape checklist rule").toBeDefined();
expect(
@@ -2015,23 +2424,24 @@ describe("grid.css cascade contract", () => {
"the checklist must take its own line; beside the fields it sets the row's height to the number of choices",
).toMatch(/flex-basis:\s*100%/);
- // And its checkboxes ring in the section's token, not the UA's own: the
- // ring above deliberately does not reach inside the wrapper, so without
- // this rule the ring changes colour and shape halfway down the row.
- // A DESCENDANT of the wrapper — `…) input:focus-visible`, not
- // `input[…-value]:focus-visible`, which is the FIELDS' own ring and
- // contains every other substring this predicate could ask for. Written
- // loosely, this guard stayed green with the checkbox rule deleted.
- const ring = rulesSelecting(css, (sel) =>
- /\[data-pretable-filter-row-value\][^{]*\)\s+input[^{]*:focus-visible/.test(
- sel,
- ),
+ // And its checkboxes ring in the product's token, not the UA's own —
+ // from the KIT now, not from a rule of this section's. The claim is
+ // the same one this section used to make on its own: the offset is
+ // POSITIVE, so the ring surrounds a box that paints to its own edge
+ // instead of covering it.
+ expect(
+ kitCheckboxRingOffset(css),
+ "the kit must ring a checkbox outside its box",
+ ).toMatch(/outline-offset:\s*1px/);
+ // And this section no longer draws one of its own: two copies of one
+ // declaration is what the collapse removed.
+ const local = rulesSelecting(css, (sel) =>
+ /data-pretable-filter-row-choice[^{]*:focus-visible/.test(sel),
);
expect(
- ring.length,
- "no focus ring for the checklist's checkboxes; the fields' own ring does not reach inside the wrapper",
- ).toBe(1);
- expect(ring[0]![2]).toMatch(/outline:[^;]*var\(--pretable-focus-ring\)/);
+ local.map((m) => m[1].trim()),
+ "the checklist draws its own ring again; the kit owns it",
+ ).toEqual([]);
});
test("the leaf row wraps", () => {
diff --git a/type-tests/react/components-button.types.tsx b/type-tests/react/components-button.types.tsx
index da08c0e36..fc5e67607 100644
--- a/type-tests/react/components-button.types.tsx
+++ b/type-tests/react/components-button.types.tsx
@@ -1,11 +1,11 @@
import {
PretableButton,
PretableIconButton,
- type PretableBuiltInButtonSite,
+ type PretableBuiltInSite,
type PretableButtonProps,
- type PretableButtonSite,
type PretableComponents,
type PretableIconButtonProps,
+ type PretableSite,
} from "@pretable/react";
import type { Equal, Expect } from "../shared/assert";
@@ -28,7 +28,7 @@ import type { Equal, Expect } from "../shared/assert";
x ;
x ;
export type SiteIsOpenAndKeepsBuiltIns = Expect<
- Equal
+ Equal
>;
// The variant is closed.
@@ -45,7 +45,7 @@ const ref = { current: null as HTMLButtonElement | null };
// The props types are what a replacement is written against.
export type ButtonHasSite = Expect<
- Equal
+ Equal
>;
export type IconHasName = Expect<
Equal
diff --git a/type-tests/react/components-inputs.types.tsx b/type-tests/react/components-inputs.types.tsx
new file mode 100644
index 000000000..ea32cecc0
--- /dev/null
+++ b/type-tests/react/components-inputs.types.tsx
@@ -0,0 +1,158 @@
+import {
+ PretableCheckbox,
+ PretableTextInput,
+ type PretableBuiltInSite,
+ type PretableCheckboxComponent,
+ type PretableCheckboxProps,
+ type PretableComponents,
+ type PretableSite,
+ type PretableTextInputComponent,
+ type PretableTextInputProps,
+} from "@pretable/react";
+import type { Equal, Expect } from "../shared/assert";
+
+// --- PretableCheckbox ---------------------------------------------------
+
+// `checked` is required.
+// prettier-ignore
+// @ts-expect-error — checked is required
+ {}} />;
+
+// `onCheckedChange` is required.
+// prettier-ignore
+// @ts-expect-error — onCheckedChange is required
+ ;
+
+// `checked` is `boolean | "mixed"`, not an arbitrary string.
+// prettier-ignore
+// @ts-expect-error — checked is boolean | "mixed", not an arbitrary string
+ {}} />;
+
+// "mixed" is a valid checked state — the header select-all's partial state.
+ {}} />;
+ {}} />;
+
+// No `type`, `role`, native `onChange`, or children leak through — the
+// element is always type="button" role="checkbox" with its own aria-checked
+// wired to `checked`. (`aria-checked` itself is not tested here: TypeScript
+// special-cases `aria-*`/`data-*` attributes on every JSX element, so no
+// props type — Omit or not — can make one a type error.)
+// prettier-ignore
+// @ts-expect-error — the element is always type="button"
+ {}} type="submit" />;
+// prettier-ignore
+// @ts-expect-error — the element is always role="checkbox"
+ {}} role="switch" />;
+// prettier-ignore
+// @ts-expect-error — the native onChange is not a channel; use onCheckedChange
+ {}} onChange={() => {}} />;
+// prettier-ignore
+// @ts-expect-error — the glyph is drawn from checked; children are not a channel
+ {}}>x ;
+
+// The checkbox sites are in the kit's vocabulary, and it stays open.
+ {}}
+ site="row-select"
+/>;
+ {}} site="mine" />;
+
+// Refs are typed to the button, not the input.
+const checkboxRef = { current: null as HTMLButtonElement | null };
+ {}}
+/>;
+const wrongCheckboxRef = { current: null as HTMLInputElement | null };
+// prettier-ignore
+// @ts-expect-error — the ref is HTMLButtonElement, not HTMLInputElement
+ {}} />;
+
+// `onCheckedChange` hands back a plain boolean.
+ {
+ const b: boolean = v;
+ void b;
+ }}
+/>;
+
+export type CheckedIsBooleanOrMixed = Expect<
+ Equal
+>;
+
+// --- PretableTextInput ---------------------------------------------------
+
+// Native input attributes pass straight through: `type`, `inputMode`,
+// `value` and `onChange` keep their native shapes.
+ {}}
+/>;
+ ;
+ {
+ const s: string = e.target.value;
+ void s;
+ }}
+/>;
+
+// prettier-ignore
+// @ts-expect-error — the native element draws its own value; children are not a channel
+text ;
+
+// Refs are typed to the input.
+const textInputRef = { current: null as HTMLInputElement | null };
+ ;
+
+// The kit's own sites are in the vocabulary, and it stays open.
+ ;
+
+export type TextInputSiteIsOpen = Expect<
+ Equal
+>;
+
+// --- The ten SP3 sites are pinned in PretableBuiltInSite -----------------
+
+type TenSp3Sites =
+ | "filter-value"
+ | "filter-row-value"
+ | "tool-search"
+ | "row-select"
+ | "row-select-all"
+ | "bool-cell"
+ | "tool-column-toggle"
+ | "hide-grouped"
+ | "filter-choice"
+ | "filter-row-choice";
+
+// `Extract` over a union containing `(string & {})` is vacuous —
+// pin the built-in union directly, so removing a site from it fails this.
+export type Sp3SitesAreBuiltIn = Expect<
+ Equal, TenSp3Sites>
+>;
+
+// The negative twin: simulate a site dropping out of the union (as SP2's
+// reviewer did with `button.tsx`, without touching the file) and prove the
+// pin can actually fail.
+// prettier-ignore
+// @ts-expect-error — removing a site must break the pin
+export type RemovingASiteBreaksThePin = Expect, TenSp3Sites>, TenSp3Sites>>;
+
+// The built-ins satisfy their own slot types, and a replacement is written
+// against each component's props like every other kit component.
+const components: PretableComponents = {
+ TextInput: PretableTextInput,
+ Checkbox: PretableCheckbox,
+};
+void components;
+const t: PretableTextInputComponent = PretableTextInput;
+const c: PretableCheckboxComponent = PretableCheckbox;
+void t;
+void c;
diff --git a/type-tests/react/components-select.types.tsx b/type-tests/react/components-select.types.tsx
index 8b94bd150..f6cbc2ec6 100644
--- a/type-tests/react/components-select.types.tsx
+++ b/type-tests/react/components-select.types.tsx
@@ -1,11 +1,11 @@
import {
PretableSelect,
- type PretableBuiltInButtonSite,
- type PretableButtonSite,
+ type PretableBuiltInSite,
type PretableComponents,
type PretableSelectComponent,
type PretableSelectOption,
type PretableSelectProps,
+ type PretableSite,
} from "@pretable/react";
import type { Equal, Expect } from "../shared/assert";
@@ -63,19 +63,41 @@ const options: readonly PretableSelectOption[] = [
/>;
// `Extract` over a union containing `(string & {})` is vacuous —
// pin the built-in union directly, so removing a site from it fails this.
+// Pins all fourteen non-button sites: the four select sites plus the ten
+// input and checkbox sites (SP3).
export type SelectSitesAreBuiltIn = Expect<
Equal<
Extract<
- PretableBuiltInButtonSite,
+ PretableBuiltInSite,
| "aggregate"
| "filter-row-column"
| "filter-row-operator"
| "filter-operator"
+ | "filter-value"
+ | "filter-row-value"
+ | "tool-search"
+ | "row-select"
+ | "row-select-all"
+ | "bool-cell"
+ | "tool-column-toggle"
+ | "hide-grouped"
+ | "filter-choice"
+ | "filter-row-choice"
>,
| "aggregate"
| "filter-row-column"
| "filter-row-operator"
| "filter-operator"
+ | "filter-value"
+ | "filter-row-value"
+ | "tool-search"
+ | "row-select"
+ | "row-select-all"
+ | "bool-cell"
+ | "tool-column-toggle"
+ | "hide-grouped"
+ | "filter-choice"
+ | "filter-row-choice"
>
>;
@@ -97,7 +119,7 @@ export type NameIsRequired = Expect<
// `site` accepts the built-ins and any string.
export type SiteIsOpen = Expect<
- Equal
+ Equal
>;
// The built-in satisfies its own slot type, and a replacement is written