@charcuterie/ui 0.2.0 → 1.0.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/dist/Field/Field.d.ts +41 -6
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +50 -8
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +3 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +62 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts +16 -2
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +23 -3
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- package/src/Field/Field.mdx +73 -3
- package/src/Field/Field.stories.tsx +136 -0
- package/src/Field/Field.test.tsx +207 -5
- package/src/Field/Field.tsx +70 -21
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/LogViewer/LogViewer.mdx +36 -1
- package/src/LogViewer/LogViewer.stories.tsx +34 -0
- package/src/LogViewer/LogViewer.test.tsx +65 -2
- package/src/LogViewer/LogViewer.tsx +67 -1
- package/src/Tooltip/Tooltip.tsx +29 -5
- package/src/index.ts +4 -0
- package/src/slotProps.ts +143 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The props a **cloning ancestor** injects into the control below it.
|
|
4
|
+
*
|
|
5
|
+
* `Field` and `Tooltip` both take one child and `cloneElement` onto
|
|
6
|
+
* it — "cloned, not wrapped", which is what lets a `Field` go around
|
|
7
|
+
* an `<input>`, a `Select`, or an app's own control without changing
|
|
8
|
+
* that control's layout. The contract has a hole in it the moment two
|
|
9
|
+
* of them are nested:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <Field label="Pattern" isRequired>
|
|
13
|
+
* <Tooltip label="A JavaScript regular expression">
|
|
14
|
+
* <input />
|
|
15
|
+
* </Tooltip>
|
|
16
|
+
* </Field>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* `Field` clones onto the **`Tooltip` element**, so `id`,
|
|
20
|
+
* `aria-describedby`, `aria-invalid` and `required` are handed to a
|
|
21
|
+
* component that declares none of them — and `cloneElement` does not
|
|
22
|
+
* care. React drops them without a warning, TypeScript never sees
|
|
23
|
+
* them (`Children.only` returns a `ReactElement` with `any` props),
|
|
24
|
+
* every test still passes, and the render is pixel-identical. The
|
|
25
|
+
* `<label>` then points at an `id` that is nowhere in the document.
|
|
26
|
+
*
|
|
27
|
+
* **Silent prop-dropping is the worst version of this**, so the rule
|
|
28
|
+
* is: *a slot component is a pass-through*. Anything in `SlotProps`
|
|
29
|
+
* that arrives from above is forwarded to the child this component
|
|
30
|
+
* clones onto, so a chain of slots behaves like the one control at
|
|
31
|
+
* the bottom of it.
|
|
32
|
+
*
|
|
33
|
+
* ### The list is closed, the forwarding is not
|
|
34
|
+
*
|
|
35
|
+
* Worth being exact, because the two are easy to conflate. This type
|
|
36
|
+
* is a closed list of five keys rather than an index signature: it is
|
|
37
|
+
* exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
|
|
38
|
+
* it is greppable, and it is the set whose **merge semantics are
|
|
39
|
+
* defined** — which for four of them is last-write-wins and for
|
|
40
|
+
* `aria-describedby` is a join.
|
|
41
|
+
*
|
|
42
|
+
* The *runtime* forwards everything. Both components collect their
|
|
43
|
+
* received props with a rest spread and `mergeSlotProps` starts from
|
|
44
|
+
* `...receivedProps`, so an ancestor's `onPointerEnter`, its
|
|
45
|
+
* `onFocus`, its `ref` — none of which are named here — reach the
|
|
46
|
+
* control at the bottom too. That is not an oversight, it is load
|
|
47
|
+
* bearing: a `Tooltip` around a `Field` hands down `useHover`,
|
|
48
|
+
* `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
|
|
49
|
+
* that forwarded only the five keys below would leave the tip with no
|
|
50
|
+
* trigger and no anchor. A closed *runtime* filter would be
|
|
51
|
+
* mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
|
|
52
|
+
*
|
|
53
|
+
* The five keys, and who writes them:
|
|
54
|
+
*
|
|
55
|
+
* | Key | Written by |
|
|
56
|
+
* | --- | --- |
|
|
57
|
+
* | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
|
|
58
|
+
* | `aria-invalid` | `Field`, derived from `error` being present |
|
|
59
|
+
* | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
|
|
60
|
+
* | `id` | `Field`, so its `<label htmlFor>` has something to point at. A caller may also pass one directly, which is how a consumer pins a stable id for a deep link or an autofill hint |
|
|
61
|
+
* | `required` | `Field`, from `isRequired` — the constraint the browser validates |
|
|
62
|
+
*
|
|
63
|
+
* ### Why these are `Pick`ed and not written out
|
|
64
|
+
*
|
|
65
|
+
* Three of the five are booleans whose names cannot start with `is`
|
|
66
|
+
* or `has`, because they are not our names — they are the DOM's, and
|
|
67
|
+
* they are spread onto a DOM node verbatim. The house rule has
|
|
68
|
+
* [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
|
|
69
|
+
* for that: a *type property* is a shape we own, an object literal
|
|
70
|
+
* key is someone else's contract, and no `eslint-disable` is
|
|
71
|
+
* available because unused disable directives are themselves an
|
|
72
|
+
* error.
|
|
73
|
+
*
|
|
74
|
+
* So this type does not declare a shape. It **selects** one out of
|
|
75
|
+
* React's own `InputHTMLAttributes`, which is the honest description
|
|
76
|
+
* of what it is: five keys of the props React already types for an
|
|
77
|
+
* `<input>`, forwarded unchanged. The same reasoning as the
|
|
78
|
+
* `Record<string, boolean>` in `createReactAdapter` — we are not
|
|
79
|
+
* describing React's shape, we are naming the keys of it we touch —
|
|
80
|
+
* and it has the side benefit that `aria-invalid` gets React's real
|
|
81
|
+
* type (`"grammar"` and `"spelling"` are legal values) rather than
|
|
82
|
+
* the `boolean` a hand-written version would have narrowed it to.
|
|
83
|
+
*/
|
|
84
|
+
export type SlotProps = Pick<InputHTMLAttributes<HTMLElement>, "aria-describedby" | "aria-invalid" | "aria-required" | "id" | "required">;
|
|
85
|
+
/**
|
|
86
|
+
* Merge what arrived from above with what this component writes
|
|
87
|
+
* itself.
|
|
88
|
+
*
|
|
89
|
+
* Everything here is a single value and last-write-wins is correct
|
|
90
|
+
* for it — **except `aria-describedby`, which is a list**. Two slot
|
|
91
|
+
* components each writing their own onto one control is the entire
|
|
92
|
+
* nesting problem in one attribute: a `Field` naming its description
|
|
93
|
+
* and its error, and a `Tooltip` naming its tip. A plain spread keeps
|
|
94
|
+
* one of them and loses the other, which is the same silent drop by a
|
|
95
|
+
* shorter route.
|
|
96
|
+
*
|
|
97
|
+
* **Outer first.** A screen reader reads the list in order, and the
|
|
98
|
+
* field's own description and error are the control's primary
|
|
99
|
+
* explanation; a tip is supplementary by construction. The ordering
|
|
100
|
+
* inside a single `Field` — description, then error — is `Field`'s
|
|
101
|
+
* own concern and is unaffected.
|
|
102
|
+
*
|
|
103
|
+
* `undefined` in `ownProps` is *absence*, not an instruction to
|
|
104
|
+
* clear. Both callers spell their optional wiring as
|
|
105
|
+
* `isRequired || undefined`, so a plain spread would let an inner
|
|
106
|
+
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
107
|
+
* same silent drop, one level down.
|
|
108
|
+
*/
|
|
109
|
+
export declare const mergeSlotProps: <OwnProps extends Record<string, unknown>>(receivedProps: SlotProps, ownProps: OwnProps) => OwnProps & SlotProps;
|
|
110
|
+
//# sourceMappingURL=slotProps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slotProps.d.ts","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,MAAM,MAAM,SAAS,GAAG,IAAI,CAC1B,mBAAmB,CAAC,WAAW,CAAC,EAC9B,kBAAkB,GAClB,cAAc,GACd,eAAe,GACf,IAAI,GACJ,UAAU,CACb,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,cAAc,GACzB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAExC,eAAe,SAAS,EACxB,UAAU,QAAQ,KACjB,QAAQ,GAAG,SAoBb,CAAA"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge what arrived from above with what this component writes
|
|
3
|
+
* itself.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is a single value and last-write-wins is correct
|
|
6
|
+
* for it — **except `aria-describedby`, which is a list**. Two slot
|
|
7
|
+
* components each writing their own onto one control is the entire
|
|
8
|
+
* nesting problem in one attribute: a `Field` naming its description
|
|
9
|
+
* and its error, and a `Tooltip` naming its tip. A plain spread keeps
|
|
10
|
+
* one of them and loses the other, which is the same silent drop by a
|
|
11
|
+
* shorter route.
|
|
12
|
+
*
|
|
13
|
+
* **Outer first.** A screen reader reads the list in order, and the
|
|
14
|
+
* field's own description and error are the control's primary
|
|
15
|
+
* explanation; a tip is supplementary by construction. The ordering
|
|
16
|
+
* inside a single `Field` — description, then error — is `Field`'s
|
|
17
|
+
* own concern and is unaffected.
|
|
18
|
+
*
|
|
19
|
+
* `undefined` in `ownProps` is *absence*, not an instruction to
|
|
20
|
+
* clear. Both callers spell their optional wiring as
|
|
21
|
+
* `isRequired || undefined`, so a plain spread would let an inner
|
|
22
|
+
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
23
|
+
* same silent drop, one level down.
|
|
24
|
+
*/
|
|
25
|
+
export const mergeSlotProps = (receivedProps, ownProps) => {
|
|
26
|
+
const definedOwnProps = Object.fromEntries(Object.entries(ownProps).filter(([, value]) => value !== undefined));
|
|
27
|
+
const describedBy = [
|
|
28
|
+
receivedProps["aria-describedby"],
|
|
29
|
+
ownProps["aria-describedby"],
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(" ");
|
|
33
|
+
return {
|
|
34
|
+
...receivedProps,
|
|
35
|
+
...definedOwnProps,
|
|
36
|
+
"aria-describedby": describedBy === "" ? undefined : describedBy,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=slotProps.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AA6FA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAG5B,aAAwB,EACxB,QAAkB,EACI,EAAE;IACxB,MAAM,eAAe,GAAG,MAAM,CAAC,WAAW,CACxC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,MAAM,CAC7B,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,CACnC,CACU,CAAA;IAEb,MAAM,WAAW,GAAG;QAClB,aAAa,CAAC,kBAAkB,CAAC;QACjC,QAAQ,CAAC,kBAAkB,CAAC;KAC7B;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,OAAO;QACL,GAAG,aAAa;QAChB,GAAG,eAAe;QAClB,kBAAkB,EAChB,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;KAC/C,CAAA;AACH,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@charcuterie/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@charcuterie/logic": "
|
|
47
|
-
"@charcuterie/tokens": "0.
|
|
46
|
+
"@charcuterie/logic": "1.0.0",
|
|
47
|
+
"@charcuterie/tokens": "1.0.0",
|
|
48
48
|
"@floating-ui/react": "^0.27.20"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
package/src/Field/Field.mdx
CHANGED
|
@@ -7,9 +7,23 @@ import * as FieldStories from "./Field.stories";
|
|
|
7
7
|
# Field
|
|
8
8
|
|
|
9
9
|
Sixteen files in the fleet spell a label, a control, and a hint, and no two spell the
|
|
10
|
-
wiring the same way.
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
wiring the same way.
|
|
11
|
+
|
|
12
|
+
An earlier version of this page said mux-magic's `FieldLabel` renders a `<label>` with
|
|
13
|
+
**no `htmlFor`**. That is false, and worth correcting rather than quietly deleting: it
|
|
14
|
+
renders ``htmlFor={`${stepId}-${field.name}`}``, and has since it was written. The defect
|
|
15
|
+
is at the *other* end of the pair — **8 of its 16 call sites never render that id on
|
|
16
|
+
anything**. Measured on `mux-magic@master`, the eight that do are `BooleanField`,
|
|
17
|
+
`ChapterSplitsField`, `EnumField`, `NumberField`, `NumberWithLookupField`, `PathField`,
|
|
18
|
+
`StringArrayField` and `StringField`. Of the rest, `RegexWithFlagsField` mints its own
|
|
19
|
+
`patternId` / `flagsId` / `sampleId` and `SubtitleRulesField` spells
|
|
20
|
+
``${step.command}-hasDefaultRules`` where the label says ``${step.id}-…``.
|
|
21
|
+
|
|
22
|
+
The outcome is the same unnamed textbox by a worse route: a `<label>` pointing at an id
|
|
23
|
+
that is nowhere in the document reads as correct in the label's own file, and is only
|
|
24
|
+
wrong in the *other* file. Half of them are broken and nothing says which half. That is
|
|
25
|
+
the argument for a component rather than a convention — here the id is minted and consumed
|
|
26
|
+
in one place and cannot be spelled twice.
|
|
13
27
|
|
|
14
28
|
<Canvas of={FieldStories.Default} />
|
|
15
29
|
|
|
@@ -25,6 +39,62 @@ Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`,
|
|
|
25
39
|
|
|
26
40
|
<Canvas of={FieldStories.AllVariants} />
|
|
27
41
|
|
|
42
|
+
## A slot is a pass-through
|
|
43
|
+
|
|
44
|
+
Which is a rule this component needed and did not have. `Field` and `Tooltip` both clone
|
|
45
|
+
onto their single child, so the obvious thing to write —
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
<Field label="Rename pattern" isRequired>
|
|
49
|
+
<Tooltip label="A JavaScript regular expression.">
|
|
50
|
+
<input />
|
|
51
|
+
</Tooltip>
|
|
52
|
+
</Field>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
— handed `id`, `aria-describedby`, `aria-invalid` and `required` to the **`Tooltip`
|
|
56
|
+
component**, which declares none of them. `cloneElement` does not care. React drops all
|
|
57
|
+
four without a warning, TypeScript never sees it (`Children.only` returns a `ReactElement`
|
|
58
|
+
whose props are `any`), every test still passed, and the render was pixel-identical. The
|
|
59
|
+
only symptom was a `<label htmlFor>` pointing at an id nowhere in the document — the exact
|
|
60
|
+
defect above, reproduced by the component built to prevent it, and this time by the
|
|
61
|
+
library rather than by a consumer.
|
|
62
|
+
|
|
63
|
+
**Silent prop-dropping is the worst version of this**, so anything a slot receives from
|
|
64
|
+
above is forwarded to the child it clones onto, and a chain of slots behaves like the one
|
|
65
|
+
control at the bottom of it. `aria-describedby` — the one prop that is a *list* — is
|
|
66
|
+
merged rather than overwritten, outer first.
|
|
67
|
+
|
|
68
|
+
It works in both orders, and the second is not symmetrical: a `Tooltip` around a `Field`
|
|
69
|
+
hands down not one attribute but a working component — floating-ui's hover, focus and
|
|
70
|
+
dismiss handlers and `refs.setReference`. Those reach the control too, or the tip is a
|
|
71
|
+
floating node with no trigger and no anchor.
|
|
72
|
+
|
|
73
|
+
<Canvas of={FieldStories.Nested} />
|
|
74
|
+
|
|
75
|
+
## Several controls are a `FieldGroup`, not a `Field`
|
|
76
|
+
|
|
77
|
+
`Field` mints one id and clones it onto one child. An `id` names one element and a
|
|
78
|
+
`<label htmlFor>` points at one, so the moment there are two controls the label names one
|
|
79
|
+
of them and leaves the rest anonymous. Six of mux-magic's sixteen field components are in
|
|
80
|
+
that position — `RegexWithFlagsField` (pattern, flags, sample, and a checkbox),
|
|
81
|
+
`NumberWithLookupField`, `LanguageCodeField`, `LanguageCodesField`, `SubtitleTypesField`
|
|
82
|
+
and `RenameRegexField`.
|
|
83
|
+
|
|
84
|
+
`FieldGroup` renders `<fieldset>` + `<legend>` and does **not** clone. This is the one
|
|
85
|
+
place in the library where `<fieldset>` is right: `AccordionSection` rejected it for its
|
|
86
|
+
panel precisely because that element means *form-control grouping*, and here the content
|
|
87
|
+
really is one. `<legend>` names the group natively, so no `role="group"` and no
|
|
88
|
+
`aria-labelledby`.
|
|
89
|
+
|
|
90
|
+
What a group cannot carry is `aria-invalid`, which has no group form — it belongs on the
|
|
91
|
+
control that is actually invalid, and a `FieldGroup` does not know which one that is. So
|
|
92
|
+
`error` here is **described, not asserted**: it joins the `<fieldset>`'s
|
|
93
|
+
`aria-describedby`. Cloning `aria-invalid` onto every child would mark the valid ones
|
|
94
|
+
invalid. A control that is itself invalid says so in its own `Field`.
|
|
95
|
+
|
|
96
|
+
<Canvas of={FieldStories.Group} />
|
|
97
|
+
|
|
28
98
|
## `createLinkedIds` is *not* here, and that is the finding
|
|
29
99
|
|
|
30
100
|
The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
StoryGrid,
|
|
6
6
|
} from "../board.storyHelpers.tsx"
|
|
7
7
|
import { Select } from "../Select/Select.tsx"
|
|
8
|
+
import { Tooltip } from "../Tooltip/Tooltip.tsx"
|
|
8
9
|
import { Field } from "./Field.tsx"
|
|
10
|
+
import { FieldGroup } from "./FieldGroup.tsx"
|
|
9
11
|
|
|
10
12
|
const TEXT_INPUT_CLASS =
|
|
11
13
|
"w-full rounded-md border border-border-default bg-surface-raised px-3 py-2 text-content-primary text-sm focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-focus-ring"
|
|
@@ -142,3 +144,137 @@ export const AllStates: Story = {
|
|
|
142
144
|
</StoryGrid>
|
|
143
145
|
),
|
|
144
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Two slot components around one control, in both orders.
|
|
150
|
+
*
|
|
151
|
+
* `Field` and `Tooltip` each clone onto their single child, so
|
|
152
|
+
* nesting them used to hand `Field`'s `id`, `aria-describedby`,
|
|
153
|
+
* `aria-invalid` and `required` to the `Tooltip` **component** —
|
|
154
|
+
* which declares none of them and dropped all four, silently, with an
|
|
155
|
+
* identical render and a `<label>` pointing at an id that was nowhere
|
|
156
|
+
* in the document.
|
|
157
|
+
*
|
|
158
|
+
* A slot is a pass-through: whatever arrives from above is forwarded
|
|
159
|
+
* to the child at the bottom of the chain, and `aria-describedby` —
|
|
160
|
+
* the one prop that is a *list* — is merged rather than overwritten,
|
|
161
|
+
* outer first.
|
|
162
|
+
*/
|
|
163
|
+
export const Nested: Story = {
|
|
164
|
+
args: {
|
|
165
|
+
children: (
|
|
166
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
167
|
+
),
|
|
168
|
+
label: "Output directory",
|
|
169
|
+
},
|
|
170
|
+
render: () => (
|
|
171
|
+
<StoryGrid columns={2}>
|
|
172
|
+
<StoryCell label="tooltip inside field">
|
|
173
|
+
<Field
|
|
174
|
+
description="Applied to every title."
|
|
175
|
+
isRequired
|
|
176
|
+
label="Rename pattern"
|
|
177
|
+
>
|
|
178
|
+
<Tooltip label="A JavaScript regular expression.">
|
|
179
|
+
<input
|
|
180
|
+
className={TEXT_INPUT_CLASS}
|
|
181
|
+
defaultValue="^(.*)$"
|
|
182
|
+
type="text"
|
|
183
|
+
/>
|
|
184
|
+
</Tooltip>
|
|
185
|
+
</Field>
|
|
186
|
+
</StoryCell>
|
|
187
|
+
|
|
188
|
+
<StoryCell label="field inside tooltip">
|
|
189
|
+
<Tooltip label="Where finished rips are moved.">
|
|
190
|
+
<Field error="Not writable." label="Archive path">
|
|
191
|
+
<input
|
|
192
|
+
className={TEXT_INPUT_CLASS}
|
|
193
|
+
defaultValue="/mnt/readonly"
|
|
194
|
+
type="text"
|
|
195
|
+
/>
|
|
196
|
+
</Field>
|
|
197
|
+
</Tooltip>
|
|
198
|
+
</StoryCell>
|
|
199
|
+
</StoryGrid>
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `FieldGroup` — one label over **several** controls, which is where
|
|
205
|
+
* `Field` cannot go: an `id` names one element and a
|
|
206
|
+
* `<label htmlFor>` points at one.
|
|
207
|
+
*
|
|
208
|
+
* Six of mux-magic's sixteen field components are in this position,
|
|
209
|
+
* and every one of them renders a `FieldLabel` whose `htmlFor` names
|
|
210
|
+
* at best one of the controls under it. The element for the job is a
|
|
211
|
+
* `<fieldset>` with a `<legend>` — the one place in this library
|
|
212
|
+
* where `<fieldset>` is right, because here the content really *is* a
|
|
213
|
+
* form-control grouping.
|
|
214
|
+
*/
|
|
215
|
+
export const Group: Story = {
|
|
216
|
+
args: {
|
|
217
|
+
children: (
|
|
218
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
219
|
+
),
|
|
220
|
+
label: "Output directory",
|
|
221
|
+
},
|
|
222
|
+
render: () => (
|
|
223
|
+
<StoryGrid columns={2}>
|
|
224
|
+
<StoryCell label="several controls">
|
|
225
|
+
<FieldGroup
|
|
226
|
+
description="Applied to every title in the disc."
|
|
227
|
+
isRequired
|
|
228
|
+
label="Rename pattern"
|
|
229
|
+
>
|
|
230
|
+
<div className="flex gap-2">
|
|
231
|
+
<input
|
|
232
|
+
aria-label="Pattern"
|
|
233
|
+
className={TEXT_INPUT_CLASS}
|
|
234
|
+
defaultValue="^(.*)$"
|
|
235
|
+
type="text"
|
|
236
|
+
/>
|
|
237
|
+
|
|
238
|
+
<input
|
|
239
|
+
aria-label="Flags"
|
|
240
|
+
className={TEXT_INPUT_CLASS}
|
|
241
|
+
defaultValue="gi"
|
|
242
|
+
type="text"
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<input
|
|
247
|
+
aria-label="Sample"
|
|
248
|
+
className={TEXT_INPUT_CLASS}
|
|
249
|
+
defaultValue="Disc 1 — Title 04"
|
|
250
|
+
type="text"
|
|
251
|
+
/>
|
|
252
|
+
</FieldGroup>
|
|
253
|
+
</StoryCell>
|
|
254
|
+
|
|
255
|
+
<StoryCell label="invalid group">
|
|
256
|
+
<FieldGroup
|
|
257
|
+
error="A pattern needs at least one capture group."
|
|
258
|
+
label="Chapter split"
|
|
259
|
+
>
|
|
260
|
+
<div className="flex gap-2">
|
|
261
|
+
<Select
|
|
262
|
+
label="Unit"
|
|
263
|
+
options={[
|
|
264
|
+
{ label: "Chapters", value: "chapters" },
|
|
265
|
+
{ label: "Minutes", value: "minutes" },
|
|
266
|
+
]}
|
|
267
|
+
/>
|
|
268
|
+
|
|
269
|
+
<input
|
|
270
|
+
aria-label="Every"
|
|
271
|
+
className={TEXT_INPUT_CLASS}
|
|
272
|
+
defaultValue="5"
|
|
273
|
+
type="number"
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
</FieldGroup>
|
|
277
|
+
</StoryCell>
|
|
278
|
+
</StoryGrid>
|
|
279
|
+
),
|
|
280
|
+
}
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { composeStories } from "@storybook/react"
|
|
2
|
-
import { expect } from "storybook/test"
|
|
2
|
+
import { expect, waitFor } from "storybook/test"
|
|
3
3
|
import { test } from "vitest"
|
|
4
4
|
|
|
5
5
|
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Field.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, AllVariants, Default } =
|
|
10
|
+
const { AllStates, AllVariants, Default, Group, Nested } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("the label names the control it points at", async () => {
|
|
@@ -16,9 +16,10 @@ test("the label names the control it points at", async () => {
|
|
|
16
16
|
|
|
17
17
|
// The whole point of the component in one query: the control is
|
|
18
18
|
// findable by the *label's* text, which is only true if `htmlFor`
|
|
19
|
-
// and the control's `id` agree. mux-magic's `FieldLabel`
|
|
20
|
-
// a
|
|
21
|
-
//
|
|
19
|
+
// and the control's `id` agree. mux-magic's `FieldLabel` does
|
|
20
|
+
// render a `htmlFor` — 8 of its 16 call sites just never render
|
|
21
|
+
// that id on anything, so the pair agrees in one file and not in
|
|
22
|
+
// the other, and this query gets nothing.
|
|
22
23
|
const control = expectAgentDrivable(canvas, {
|
|
23
24
|
name: "Output directory",
|
|
24
25
|
role: "textbox",
|
|
@@ -114,6 +115,146 @@ test("an error is the only source of invalidity", async () => {
|
|
|
114
115
|
await expectNoAxeViolations(canvasElement)
|
|
115
116
|
})
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The nesting defect, and the reason `SlotProps` exists.
|
|
120
|
+
*
|
|
121
|
+
* `Field` clones onto its one child. With a `Tooltip` in between, the
|
|
122
|
+
* child is the **`Tooltip` element**, so `id`, `aria-describedby`,
|
|
123
|
+
* `aria-invalid` and `required` were handed to a component that
|
|
124
|
+
* declares none of them. `cloneElement` does not care, TypeScript
|
|
125
|
+
* never sees it, and the render is pixel-identical — the only
|
|
126
|
+
* symptom is a `<label htmlFor>` pointing at an id that is nowhere in
|
|
127
|
+
* the document, which is exactly the unnamed textbox this component
|
|
128
|
+
* exists to prevent.
|
|
129
|
+
*
|
|
130
|
+
* The query below is the proof: finding the control **by the label's
|
|
131
|
+
* text** is only possible if the `id` reached the `<input>` at the
|
|
132
|
+
* bottom of the chain.
|
|
133
|
+
*/
|
|
134
|
+
test("a slot forwards what an outer slot gave it", async () => {
|
|
135
|
+
const { canvas, canvasElement } = await mountStory(Nested)
|
|
136
|
+
|
|
137
|
+
const control = expectAgentDrivable(canvas, {
|
|
138
|
+
name: "Rename pattern",
|
|
139
|
+
role: "textbox",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await expect(control).toBeRequired()
|
|
143
|
+
|
|
144
|
+
await expect(control).toHaveAttribute(
|
|
145
|
+
"aria-required",
|
|
146
|
+
"true",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
document.getElementById(
|
|
151
|
+
control.getAttribute("aria-describedby") ?? "",
|
|
152
|
+
),
|
|
153
|
+
).toHaveTextContent("Applied to every title.")
|
|
154
|
+
|
|
155
|
+
await expectNoAxeViolations(canvasElement)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The same drop in reverse — and the half that nearly shipped
|
|
160
|
+
* untested.
|
|
161
|
+
*
|
|
162
|
+
* The first draft of the test above closed with an `aria-invalid`
|
|
163
|
+
* assertion on the inner control of the *other* cell and called that
|
|
164
|
+
* the reverse case. It is not: `Field` writes `aria-invalid` itself,
|
|
165
|
+
* from its own `error` prop, so that assertion holds whether or not a
|
|
166
|
+
* single thing arrived from the `Tooltip` above it. Reverting
|
|
167
|
+
* `Field`'s half of the fix left the whole file green.
|
|
168
|
+
*
|
|
169
|
+
* What a `Tooltip` hands down is not one attribute, it is a working
|
|
170
|
+
* component: `getReferenceProps()`'s hover, focus and dismiss
|
|
171
|
+
* handlers, `refs.setReference`, and an `aria-describedby` naming the
|
|
172
|
+
* tip. All of it lands on the `Field` **element**, and `Field` has to
|
|
173
|
+
* pass every bit of it through to the control at the bottom or the
|
|
174
|
+
* tip is a floating node with no trigger, no anchor and nobody
|
|
175
|
+
* pointed at it — mux-magic's `FieldTooltip` defect 1, reproduced by
|
|
176
|
+
* a library that shipped the fix for it.
|
|
177
|
+
*
|
|
178
|
+
* So the assertion is the tip *opening from the inner control's own
|
|
179
|
+
* focus* and then being named by it, which needs the handlers, the
|
|
180
|
+
* ref and the attribute to have all three arrived.
|
|
181
|
+
*/
|
|
182
|
+
test("a slot forwards what an outer slot gave it, in the other order", async () => {
|
|
183
|
+
const { canvas } = await mountStory(Nested)
|
|
184
|
+
|
|
185
|
+
const control = expectAgentDrivable(canvas, {
|
|
186
|
+
name: "Archive path",
|
|
187
|
+
role: "textbox",
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Focus on the control, not on the `Field` — `Field` renders a
|
|
191
|
+
// `<div>`, which cannot take focus, so a tip that opens here can
|
|
192
|
+
// only have been wired to the `<input>`.
|
|
193
|
+
control.focus()
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(canvas.getByRole("tooltip")).toHaveTextContent(
|
|
197
|
+
"Where finished rips are moved.",
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const [tipId, errorId] = (
|
|
202
|
+
control.getAttribute("aria-describedby") ?? ""
|
|
203
|
+
).split(" ")
|
|
204
|
+
|
|
205
|
+
// Outer first, which here is the tip: the merge order is
|
|
206
|
+
// structural — whoever wrapped whom — and the author of the nesting
|
|
207
|
+
// is the one who chose it.
|
|
208
|
+
await expect(
|
|
209
|
+
document.getElementById(tipId ?? ""),
|
|
210
|
+
).toHaveTextContent("Where finished rips are moved.")
|
|
211
|
+
|
|
212
|
+
await expect(
|
|
213
|
+
document.getElementById(errorId ?? ""),
|
|
214
|
+
).toHaveTextContent("Not writable.")
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `aria-describedby` is the one slot prop that is a **list**, so the
|
|
219
|
+
* merge is not last-write-wins. A `Field` names its description and
|
|
220
|
+
* its error; a `Tooltip` names its tip. A plain spread keeps one and
|
|
221
|
+
* loses the other — the same silent drop by a shorter route.
|
|
222
|
+
*
|
|
223
|
+
* Outer first: the field's own explanation before the supplementary
|
|
224
|
+
* tip.
|
|
225
|
+
*/
|
|
226
|
+
test("a tip is described alongside the field's own text, not instead of it", async () => {
|
|
227
|
+
const { canvas } = await mountStory(Nested)
|
|
228
|
+
|
|
229
|
+
const control = expectAgentDrivable(canvas, {
|
|
230
|
+
name: "Rename pattern",
|
|
231
|
+
role: "textbox",
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
control.focus()
|
|
235
|
+
|
|
236
|
+
// `useFocus` is what opens it — the line mux-magic's hand-rolled
|
|
237
|
+
// tip is missing, and the reason this is testable without a
|
|
238
|
+
// pointer at all.
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(canvas.getByRole("tooltip")).toHaveTextContent(
|
|
241
|
+
"A JavaScript regular expression.",
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const [descriptionId, tipId] = (
|
|
246
|
+
control.getAttribute("aria-describedby") ?? ""
|
|
247
|
+
).split(" ")
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
document.getElementById(descriptionId ?? ""),
|
|
251
|
+
).toHaveTextContent("Applied to every title.")
|
|
252
|
+
|
|
253
|
+
await expect(
|
|
254
|
+
document.getElementById(tipId ?? ""),
|
|
255
|
+
).toHaveTextContent("A JavaScript regular expression.")
|
|
256
|
+
})
|
|
257
|
+
|
|
117
258
|
/**
|
|
118
259
|
* Order inside `aria-describedby` is behaviour, not formatting. A
|
|
119
260
|
* screen reader reads the list in sequence, and "absolute paths only
|
|
@@ -140,3 +281,64 @@ test("description is described before error", async () => {
|
|
|
140
281
|
document.getElementById(errorId ?? ""),
|
|
141
282
|
).toHaveTextContent("That path is not writable.")
|
|
142
283
|
})
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `Field` cannot go here and the failure is quiet: an `id` names one
|
|
287
|
+
* element and a `<label htmlFor>` points at one, so a label over three
|
|
288
|
+
* inputs names one of them and leaves the other two anonymous. Six of
|
|
289
|
+
* mux-magic's sixteen field components are in exactly that state.
|
|
290
|
+
*
|
|
291
|
+
* `<fieldset>` + `<legend>` is the platform's answer, and it is the
|
|
292
|
+
* one place in this library where the element is right — the content
|
|
293
|
+
* really is a form-control grouping.
|
|
294
|
+
*/
|
|
295
|
+
test("a group is named by its legend, and its controls keep their own names", async () => {
|
|
296
|
+
const { canvas, canvasElement } = await mountStory(Group)
|
|
297
|
+
|
|
298
|
+
const group = expectAgentDrivable(canvas, {
|
|
299
|
+
// "Rename pattern", not "Rename pattern *" — the asterisk is
|
|
300
|
+
// `aria-hidden`, so it is decoration and stays out of the name.
|
|
301
|
+
name: "Rename pattern",
|
|
302
|
+
role: "group",
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Every control inside is still individually findable. A group
|
|
306
|
+
// that swallows its members' names is the same defect one level up.
|
|
307
|
+
for (const name of ["Pattern", "Flags", "Sample"]) {
|
|
308
|
+
expectAgentDrivable(canvas, { name, role: "textbox" })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
document.getElementById(
|
|
313
|
+
group.getAttribute("aria-describedby") ?? "",
|
|
314
|
+
),
|
|
315
|
+
).toHaveTextContent("Applied to every title in the disc.")
|
|
316
|
+
|
|
317
|
+
await expectNoAxeViolations(canvasElement)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `aria-invalid` has no group form — it belongs on the control that
|
|
322
|
+
* is actually invalid, and a `FieldGroup` does not know which one
|
|
323
|
+
* that is. So the error is *described*, not *asserted*, and the
|
|
324
|
+
* alternative — cloning `aria-invalid` onto every child — would mark
|
|
325
|
+
* the valid ones invalid.
|
|
326
|
+
*/
|
|
327
|
+
test("a group describes its error rather than asserting invalidity", async () => {
|
|
328
|
+
const { canvas } = await mountStory(Group)
|
|
329
|
+
|
|
330
|
+
const group = expectAgentDrivable(canvas, {
|
|
331
|
+
name: "Chapter split",
|
|
332
|
+
role: "group",
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
await expect(group).not.toHaveAttribute("aria-invalid")
|
|
336
|
+
|
|
337
|
+
await expect(
|
|
338
|
+
document.getElementById(
|
|
339
|
+
group.getAttribute("aria-describedby") ?? "",
|
|
340
|
+
),
|
|
341
|
+
).toHaveTextContent(
|
|
342
|
+
"A pattern needs at least one capture group.",
|
|
343
|
+
)
|
|
344
|
+
})
|