diff --git a/.changeset/components-sp3-inputs.md b/.changeset/components-sp3-inputs.md new file mode 100644 index 000000000..4f04f9b0c --- /dev/null +++ b/.changeset/components-sp3-inputs.md @@ -0,0 +1,66 @@ +--- +"@pretable/react": minor +"@pretable/ui": patch +--- + +The kit's field and its checkbox, and the fourteen controls in the grid rebuilt +on them. + +`PretableTextInput` is an `` and `PretableCheckbox` a +`button[role="checkbox"]`, with `PretableTextInputProps`, +`PretableCheckboxProps`, `PretableTextInputComponent` and +`PretableCheckboxComponent` alongside them and `TextInput` and `Checkbox` slots +on `PretableComponents` — `components={{ TextInput, Checkbox }}` on +`` and `` replaces the grid's seven chrome text +fields and all seven of its checkboxes, the same way `Button`, `IconButton` and +`Select` already do. The fields are the filter dialog's value and its two range +bounds, the tool panel's three filter-row values, and the Columns search box; +the checkboxes are row select, header select-all, the boolean cell, the column +visibility toggle, "hide grouped columns", and the two enum checklists. + +**`PretableButtonSite` is now `PretableSite`, and `PretableBuiltInButtonSite` is +`PretableBuiltInSite`.** The type names where any kit control sits, not where a +button does, and a field and a checkbox have sites too. The union gains ten +names: `filter-value`, `filter-row-value`, `tool-search`, `row-select`, +`row-select-all`, `bool-cell`, `tool-column-toggle`, `hide-grouped`, +`filter-choice` and `filter-row-choice`. + +**Three checkboxes are no longer ``.** The filter +dialog's enum checklist, the tool panel's filter-builder enum checklist and the +"hide grouped columns" switch are now `button[role="checkbox"]` carrying +`aria-checked`. A test keyed on `.checked`, on Playwright's `.check()` or on an +`input[type=checkbox]` selector must read `aria-checked` and click the button +instead; `toBeChecked()` still works, because it reads the ARIA state. + +**A listbox option is marked `data-pretable-option-value`, not `data-value`.** +The select's open list is the one place where an attribute a consumer could +already have written against `0.17.0` has been renamed: a selector like +`[data-pretable-option][data-value="open"]` now matches nothing, and must read +`data-pretable-option-value` instead. (This branch's own end-to-end suite broke +on exactly that selector.) The new name says which option an element **is**, as +against the select trigger's `data-pretable-value`, which is what that picker +has committed. + +**Attributes.** Every kit field carries `data-pretable-text-input` and every kit +checkbox `data-pretable-checkbox`, and each site keeps the `data-pretable-*` +attribute it already had. The two enum checklists gain +`data-pretable-filter-choice` and `data-pretable-filter-row-choice`, and each +choice carries `data-pretable-option-value` too. The filter builder's checklist +_wrapper_ is now `data-pretable-filter-row-set` — the field alongside it keeps +`data-pretable-filter-row-value`, which the wrapper used to borrow. + +**Look.** The three ex-native checkboxes become the kit's 16px square drawn from +the `--pretable-checkbox-*` tokens, in place of the browser's ~13px default. The +Columns search box and every kit checkbox now take the product focus ring +instead of the user agent's — visibly on the header select-all and the column +visibility toggle, and on row select and the boolean cell too, though both are +`tabIndex={-1}` and so out of a `Tab`'s reach. Nothing else moves: the seven +fields and the four checkboxes that were already buttons keep their boxes, their +element type and their spacing exactly. + +**Behaviour.** A kit checkbox's keyboard is the native button's — Space and +Enter both activate. A consumer `onClick` runs before the toggle and may +`preventDefault()` to veto it, which is how a shift-click range select keeps its +click without a second write. `checked` accepts `"mixed"` for the header +select-all's partial selection; it renders the minus glyph and toggles to +`true`. diff --git a/apps/website/app/fixtures/components/page.tsx b/apps/website/app/fixtures/components/page.tsx index 370107882..17debe998 100644 --- a/apps/website/app/fixtures/components/page.tsx +++ b/apps/website/app/fixtures/components/page.tsx @@ -3,9 +3,11 @@ import { PretableSurface, type PretableButtonComponent, + type PretableCheckboxComponent, type PretableColumn, type PretableIconButtonComponent, type PretableSelectComponent, + type PretableTextInputComponent, } from "@pretable/react"; import { forwardRef } from "react"; @@ -29,17 +31,32 @@ interface Row { id: string; name: string; qty: number; + status: string; } const ROWS: Row[] = [ - { id: "a", name: "Alpha", qty: 1 }, - { id: "b", name: "Bravo", qty: 2 }, - { id: "c", name: "Charlie", qty: 3 }, + { id: "a", name: "Alpha", qty: 1, status: "open" }, + { id: "b", name: "Bravo", qty: 2, status: "closed" }, + { id: "c", name: "Charlie", qty: 3, status: "open" }, ]; const COLUMNS: PretableColumn[] = [ { id: "name", header: "Name", widthPx: 160, type: "text" }, { id: "qty", header: "Qty", widthPx: 100, type: "number" }, + // `enum` is what makes the Name funnel's sibling a CHECKLIST: an enum + // column's first operator is `isAnyOf`, whose value shape is `set`, which + // is the only shape that renders `site="filter-choice"` checkboxes. Without + // it the fixture could not reach that site at all. + { + id: "status", + header: "Status", + widthPx: 120, + type: "enum", + options: [ + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + ], + }, ]; const FixtureButton: PretableButtonComponent = forwardRef( @@ -103,6 +120,48 @@ const FixtureSelect: PretableSelectComponent = forwardRef( }, ); +/** + * A field replacement that is the native input and nothing else — the point + * being that the grid's own value semantics (the debounce on the filter + * operand, the search's live narrowing) live above the slot, so a bare input + * is enough to drive them. It records its `site` and nothing else. + */ +const FixtureTextInput: PretableTextInputComponent = forwardRef( + function FixtureTextInput({ site, ...props }, ref) { + return ; + }, +); + +/** + * A checkbox replacement that keeps the kit's CONTRACT and none of its + * markup: no `role="checkbox"`, no `aria-checked`, just a button that records + * the state it was handed and re-implements the one behaviour the kit + * promises — consumer `onClick` first, `preventDefault()` vetoes the toggle. + * A test that finds the grid still selecting rows through this proves the + * grid drives the slot's props, not the kit's internals. + */ +const FixtureCheckbox: PretableCheckboxComponent = forwardRef( + function FixtureCheckbox( + { site, checked, onCheckedChange, onClick, ...props }, + ref, + ) { + return ( + + ); + }, +); +``` +`MinusIcon` exists in `icons.tsx` (the header uses it) — confirm the export name. + +- [ ] **Step 5: run → pass; mutation-check** the veto (remove `if (e.defaultPrevented) return;` → veto test fails) and the mixed→true rule (change to `!checked` → mixed test fails); restore. eslint 0, typecheck 0. Commit `feat(react): PretableCheckbox`. + +--- + +### Task 4: `PretableTextInput` + +**Files:** create `components/text-input.tsx`, `__tests__/components-text-input.test.tsx`. + +- [ ] **Step 1: failing tests** + +```tsx +// packages/react/src/__tests__/components-text-input.test.tsx +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { createElement, createRef } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { PretableTextInput } from "../components/text-input"; +import { resetDevWarnings } from "../dev-warn"; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); +beforeEach(() => { resetDevWarnings(); }); + +describe("PretableTextInput", () => { + test("is the native input carrying the kit attributes; type/inputMode/value/onChange pass through", () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + const input = getByRole("textbox", { name: "Filter value" }) as HTMLInputElement; + expect(input.tagName).toBe("INPUT"); + expect(input).toHaveAttribute("data-pretable-text-input", ""); + expect(input).toHaveAttribute("data-pretable-site", "filter-value"); + expect(input).toHaveAttribute("data-pretable-filter-value", ""); + expect(input).toHaveAttribute("inputmode", "decimal"); + expect(input.value).toBe("4"); + fireEvent.change(input, { target: { value: "42" } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + test("a date field stays a native date input", () => { + const { container } = render( {}} />); + expect(container.querySelector('input[type="date"]')).not.toBeNull(); + }); + + test("forwards its ref; className, style and disabled pass through", () => { + const ref = createRef(); + const { getByRole } = render(); + expect(ref.current).toBe(getByRole("textbox")); + expect(ref.current).toHaveClass("mine"); + expect(ref.current?.style.width).toBe("80px"); + expect(ref.current).toBeDisabled(); + }); + + test("warns in development when nothing can name it; silent for aria-label, aria-labelledby, or a label-for", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatch(/PretableTextInput/); + warn.mockClear(); resetDevWarnings(); + render(); + render(<>B); + render(<>); + expect(warn).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: run → fails. Step 3: implement** + +```tsx +// packages/react/src/components/text-input.tsx +/** + * The kit's text field: the native ``, no more. Deliberately — the + * native element is the accessible one, the date type keeps its native + * picker, and what a theme needs is only the box, which grid.css draws + * through `data-pretable-text-input`. A site's own attribute arrives through + * the spread; `site` lands as `data-pretable-site`. + */ +import { createElement, forwardRef, useEffect, useRef, type InputHTMLAttributes, type ReactElement } from "react"; + +import { warnOnce } from "../dev-warn"; +import { hasAccessibleName } from "./accessible-name"; +import type { PretableSite } from "./button"; + +/** + * Props for {@link PretableTextInput}: the native input's, plus `site`. + * + * @public + */ +export interface PretableTextInputProps extends Omit, "children"> { + /** Where in the grid this field is; lands as `data-pretable-site`. */ + site?: PretableSite; +} + +/** + * A text field in the grid's own chrome. + * + * ```tsx + * setText(e.target.value)} /> + * ``` + * + * @public + */ +export const PretableTextInput = forwardRef( + function PretableTextInput({ site, ...inputProps }, ref): ReactElement { + const ownRef = useRef(null); + const setRef = (node: HTMLInputElement | null) => { + ownRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + // A