feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)

- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
This commit is contained in:
2026-09-24 23:48:22 +02:00
parent 81936e9f56
commit dac9d89f91
63 changed files with 923 additions and 416 deletions
+40 -2
View File
@@ -100,8 +100,9 @@ const BUTTON_PRIMITIVES = new Set([
"form.tsx", // Switch
"PriorityStepper.tsx",
"SortControl.tsx",
"FilterSelect.tsx", // the dropdown trigger (A5, S3c)
"Combobox.tsx", // the field's own chips and chevron (A4, S3c)
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's own chips and chevron (A4)
]);
const buttonPrimitive = {
meta: {
@@ -124,6 +125,40 @@ const buttonPrimitive = {
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
"NativeControls.tsx",
"PriorityStepper.tsx", // the number box laid over the stepper's own face
]);
const inputPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name !== "input" && name !== "textarea") return;
const type = node.attributes.find(
(a) => a.type === "JSXAttribute" && a.name.name === "type",
);
const v = type?.value?.type === "Literal" ? type.value.value : null;
if (name === "input" && (v === "file" || v === "hidden")) return;
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -200,6 +235,7 @@ const localRules = {
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"input-primitive": inputPrimitive,
"literal-a11y-text": literalA11yText,
},
};
@@ -255,6 +291,7 @@ export default tseslint.config(
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/input-primitive": "error",
"local/literal-a11y-text": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -418,6 +455,7 @@ export default tseslint.config(
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
},
},
prettier,