@charcuterie/ui 0.1.1 → 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/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- 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/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.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/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
package/README.md
CHANGED
|
@@ -18,6 +18,15 @@ M3 ships the **P0 pure-presentation** set: `Spinner`, `Skeleton`, `Button`, `Ico
|
|
|
18
18
|
is a composition that could not be expressed until
|
|
19
19
|
[`Tabs` moved onto `SinglePicker`](../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
|
|
20
20
|
|
|
21
|
+
**M6a ships the nine P1 components**: `Select`, `Field`, `Accordion`, `LogViewer`, `Menu`,
|
|
22
|
+
`Tooltip`, `SortableTableHeader`, `Toast` (with `ToastRegion`), and `FileDropZone`. Twenty
|
|
23
|
+
five components in all. Four of them changed rules this package had already written down —
|
|
24
|
+
a menu is [named by its trigger](../../docs/decisions/2026-07-31-a-menu-is-named-by-its-trigger.md),
|
|
25
|
+
an accordion panel is [a `group` rather than a landmark](../../docs/decisions/2026-07-31-an-accordion-panel-is-a-group-not-a-landmark.md),
|
|
26
|
+
[`Select` owns no state at all](../../docs/decisions/2026-07-31-select-is-uncontrolled-because-the-platform-owns-the-value.md),
|
|
27
|
+
and [not every boolean is a state kind](../../docs/decisions/2026-07-31-not-every-boolean-is-a-state-kind.md).
|
|
28
|
+
Full write-up: [M6a handoff](../../docs/2026-07-31-m6a-the-p1-components.md).
|
|
29
|
+
|
|
21
30
|
## Install and wire
|
|
22
31
|
|
|
23
32
|
```ts
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type AccordionHeadingLevel = 2 | 3 | 4 | 5 | 6;
|
|
3
|
+
export type AccordionItem = {
|
|
4
|
+
content: ReactNode;
|
|
5
|
+
isDisabled?: boolean;
|
|
6
|
+
key: string;
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
export type AccordionProps = {
|
|
10
|
+
className?: string;
|
|
11
|
+
/** **Initial** only. Charcuterie owns it from then on. */
|
|
12
|
+
expandedKeys?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* The heading level the triggers sit at. An accordion inside a
|
|
15
|
+
* `Card` that already has an `<h2>` needs `3`, and getting this
|
|
16
|
+
* wrong is a document-outline break that no gate here can see —
|
|
17
|
+
* it depends entirely on the page.
|
|
18
|
+
*/
|
|
19
|
+
headingLevel?: AccordionHeadingLevel;
|
|
20
|
+
/**
|
|
21
|
+
* Several sections open at once. The default is **exclusive**,
|
|
22
|
+
* because that is what every one of the fleet's disclosure groups
|
|
23
|
+
* does today.
|
|
24
|
+
*/
|
|
25
|
+
isMultiple?: boolean;
|
|
26
|
+
items: AccordionItem[];
|
|
27
|
+
onChange?: (expandedKeys: string[]) => void;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Eleven sites in the fleet, and the plan called it "nearly free
|
|
31
|
+
* once `Tabs` exists". It is — the exclusive mode is
|
|
32
|
+
* `VisibilityGroup` and nothing else — but the reason it is worth
|
|
33
|
+
* shipping is not the saving.
|
|
34
|
+
*
|
|
35
|
+
* ### The two-owners bug, live, in the app this milestone migrates
|
|
36
|
+
*
|
|
37
|
+
* mux-magic's `JobStepsDisclosure` is built on `<details>`, and
|
|
38
|
+
* `<details>` **owns `open`**. It also wants the state in a Jotai
|
|
39
|
+
* atom so a job can be expanded from elsewhere. Reconciling the two
|
|
40
|
+
* takes three separate mechanisms:
|
|
41
|
+
*
|
|
42
|
+
* ```tsx
|
|
43
|
+
* const detailsRef = useRef<HTMLDetailsElement>(null)
|
|
44
|
+
* const skipNextToggleRef = useRef(isOpen)
|
|
45
|
+
*
|
|
46
|
+
* useEffect(() => {
|
|
47
|
+
* if (detailsRef.current) detailsRef.current.open = isOpen
|
|
48
|
+
* }, [isOpen])
|
|
49
|
+
*
|
|
50
|
+
* const handleToggle = (event) => {
|
|
51
|
+
* if (skipNextToggleRef.current) {
|
|
52
|
+
* skipNextToggleRef.current = false
|
|
53
|
+
* return // ← swallow the toggle our own write just fired
|
|
54
|
+
* }
|
|
55
|
+
* setStepsOpen(…)
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* That is the Radix argument from `Popover` pointed at the platform:
|
|
60
|
+
* a ref to reach past React, an effect to push state into the DOM,
|
|
61
|
+
* and a guard to stop the DOM's echo coming back. Every one of the
|
|
62
|
+
* three exists only because two things hold one fact.
|
|
63
|
+
*
|
|
64
|
+
* So this is a `<button aria-expanded>` and a `role="region"` — the
|
|
65
|
+
* ARIA Authoring Practices accordion — with `VisibilityGroup`
|
|
66
|
+
* holding the state alone. `<details name="…">` is the platform's
|
|
67
|
+
* own exclusive accordion and was rejected for the same reason, plus
|
|
68
|
+
* one it cannot fix: **a `<summary>` cannot be disabled**, and four
|
|
69
|
+
* of the fleet's eleven sites have a section that is unreachable
|
|
70
|
+
* until a job produces it.
|
|
71
|
+
*
|
|
72
|
+
* ### Two kinds, one component, and both hooks always run
|
|
73
|
+
*
|
|
74
|
+
* `isMultiple` picks between `VisibilityGroup` and `MultiplePicker`,
|
|
75
|
+
* and hooks cannot be conditional — so both are created and one is
|
|
76
|
+
* consulted. That is two empty stores and no DOM, which is cheaper
|
|
77
|
+
* than the alternative: reimplementing exclusivity on top of
|
|
78
|
+
* `MultiplePicker` by deselecting the others, which is precisely the
|
|
79
|
+
* logic `VisibilityGroup` exists to own once.
|
|
80
|
+
*/
|
|
81
|
+
export declare const Accordion: ({ className, expandedKeys, headingLevel, isMultiple, items, onChange, }: AccordionProps) => ReactNode;
|
|
82
|
+
//# sourceMappingURL=Accordion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Accordion.d.ts","sourceRoot":"","sources":["../../src/Accordion/Accordion.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,MAAM,MAAM,qBAAqB,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,SAAS,CAAA;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,SAAS,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAA;IACpC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,QAAQ,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;CAC5C,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,SAAS,GAAI,yEAOvB,cAAc,KAAG,SAkFnB,CAAA"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMultiplePicker, useUniqueId, useVisibilityGroup, } from "@charcuterie/logic";
|
|
3
|
+
import { toClassName } from "../toClassName.js";
|
|
4
|
+
import { AccordionSection } from "./AccordionSection.js";
|
|
5
|
+
/**
|
|
6
|
+
* Eleven sites in the fleet, and the plan called it "nearly free
|
|
7
|
+
* once `Tabs` exists". It is — the exclusive mode is
|
|
8
|
+
* `VisibilityGroup` and nothing else — but the reason it is worth
|
|
9
|
+
* shipping is not the saving.
|
|
10
|
+
*
|
|
11
|
+
* ### The two-owners bug, live, in the app this milestone migrates
|
|
12
|
+
*
|
|
13
|
+
* mux-magic's `JobStepsDisclosure` is built on `<details>`, and
|
|
14
|
+
* `<details>` **owns `open`**. It also wants the state in a Jotai
|
|
15
|
+
* atom so a job can be expanded from elsewhere. Reconciling the two
|
|
16
|
+
* takes three separate mechanisms:
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const detailsRef = useRef<HTMLDetailsElement>(null)
|
|
20
|
+
* const skipNextToggleRef = useRef(isOpen)
|
|
21
|
+
*
|
|
22
|
+
* useEffect(() => {
|
|
23
|
+
* if (detailsRef.current) detailsRef.current.open = isOpen
|
|
24
|
+
* }, [isOpen])
|
|
25
|
+
*
|
|
26
|
+
* const handleToggle = (event) => {
|
|
27
|
+
* if (skipNextToggleRef.current) {
|
|
28
|
+
* skipNextToggleRef.current = false
|
|
29
|
+
* return // ← swallow the toggle our own write just fired
|
|
30
|
+
* }
|
|
31
|
+
* setStepsOpen(…)
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* That is the Radix argument from `Popover` pointed at the platform:
|
|
36
|
+
* a ref to reach past React, an effect to push state into the DOM,
|
|
37
|
+
* and a guard to stop the DOM's echo coming back. Every one of the
|
|
38
|
+
* three exists only because two things hold one fact.
|
|
39
|
+
*
|
|
40
|
+
* So this is a `<button aria-expanded>` and a `role="region"` — the
|
|
41
|
+
* ARIA Authoring Practices accordion — with `VisibilityGroup`
|
|
42
|
+
* holding the state alone. `<details name="…">` is the platform's
|
|
43
|
+
* own exclusive accordion and was rejected for the same reason, plus
|
|
44
|
+
* one it cannot fix: **a `<summary>` cannot be disabled**, and four
|
|
45
|
+
* of the fleet's eleven sites have a section that is unreachable
|
|
46
|
+
* until a job produces it.
|
|
47
|
+
*
|
|
48
|
+
* ### Two kinds, one component, and both hooks always run
|
|
49
|
+
*
|
|
50
|
+
* `isMultiple` picks between `VisibilityGroup` and `MultiplePicker`,
|
|
51
|
+
* and hooks cannot be conditional — so both are created and one is
|
|
52
|
+
* consulted. That is two empty stores and no DOM, which is cheaper
|
|
53
|
+
* than the alternative: reimplementing exclusivity on top of
|
|
54
|
+
* `MultiplePicker` by deselecting the others, which is precisely the
|
|
55
|
+
* logic `VisibilityGroup` exists to own once.
|
|
56
|
+
*/
|
|
57
|
+
export const Accordion = ({ className, expandedKeys, headingLevel = 3, isMultiple = false, items, onChange, }) => {
|
|
58
|
+
const baseId = useUniqueId();
|
|
59
|
+
const [initialKey] = expandedKeys ?? [];
|
|
60
|
+
const group = useVisibilityGroup({
|
|
61
|
+
onChange: (visibleKey) => {
|
|
62
|
+
if (!isMultiple) {
|
|
63
|
+
onChange?.(visibleKey === null ? [] : [visibleKey]);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
visibleKey: initialKey ?? null,
|
|
67
|
+
});
|
|
68
|
+
const picker = useMultiplePicker({
|
|
69
|
+
onChange: (selectedValues) => {
|
|
70
|
+
if (isMultiple) {
|
|
71
|
+
onChange?.([...selectedValues]);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
selectedValues: expandedKeys,
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* Visible **or** pending, the same load-bearing fallback `Tabs`
|
|
78
|
+
* needs and for the same reason: members register from an effect,
|
|
79
|
+
* so on the first paint the intent still lives in the pending
|
|
80
|
+
* field. Reading only the settled one renders every section
|
|
81
|
+
* collapsed and corrects itself a frame later — a flash on a
|
|
82
|
+
* desktop and a visible jump on the kiosk Pi.
|
|
83
|
+
*
|
|
84
|
+
* **And only until something registers**, which `Tabs` never had
|
|
85
|
+
* to say because it seeds itself from the first *enabled* tab. A
|
|
86
|
+
* key that can never register — a disabled section's — stays
|
|
87
|
+
* pending forever, so an unqualified fallback renders it expanded
|
|
88
|
+
* permanently and `isDisabled` quietly stops meaning anything.
|
|
89
|
+
* Found by the story that passes a disabled key on purpose.
|
|
90
|
+
*
|
|
91
|
+
* Every member registers in one commit, so this really is "before
|
|
92
|
+
* the first effects ran" rather than a race between them.
|
|
93
|
+
*/
|
|
94
|
+
const hasRegistered = isMultiple
|
|
95
|
+
? picker.registeredValues.length > 0
|
|
96
|
+
: group.registeredKeys.length > 0;
|
|
97
|
+
const expanded = isMultiple
|
|
98
|
+
? new Set([
|
|
99
|
+
...picker.selectedValues,
|
|
100
|
+
...(hasRegistered ? [] : picker.pendingValues),
|
|
101
|
+
])
|
|
102
|
+
: new Set([
|
|
103
|
+
group.visibleKey ??
|
|
104
|
+
(hasRegistered ? null : group.pendingKey),
|
|
105
|
+
].filter((key) => key !== null));
|
|
106
|
+
return (_jsx("div", { className: toClassName("flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-subtle", className), children: items.map((item, index) => (_jsx(AccordionSection, { headingLevel: headingLevel, id: `${baseId}-trigger-${index}`, isExpanded: expanded.has(item.key), item: item, onToggle: isMultiple ? picker.toggle : group.toggle, panelId: `${baseId}-panel-${index}`, register: isMultiple ? picker.register : group.register }, item.key))) }));
|
|
107
|
+
};
|
|
108
|
+
//# sourceMappingURL=Accordion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Accordion.js","sourceRoot":"","sources":["../../src/Accordion/Accordion.tsx"],"names":[],"mappings":";AAAA,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,GACnB,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAwB,CAAA;AAgCzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,EACxB,SAAS,EACT,YAAY,EACZ,YAAY,GAAG,CAAC,EAChB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,GACO,EAAa,EAAE;IAC9B,MAAM,MAAM,GAAG,WAAW,EAAE,CAAA;IAE5B,MAAM,CAAC,UAAU,CAAC,GAAG,YAAY,IAAI,EAAE,CAAA;IAEvC,MAAM,KAAK,GAAG,kBAAkB,CAAS;QACvC,QAAQ,EAAE,CAAC,UAAU,EAAE,EAAE;YACvB,IAAI,CAAC,UAAU,EAAE,CAAC;gBAChB,QAAQ,EAAE,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;YACrD,CAAC;QACH,CAAC;QACD,UAAU,EAAE,UAAU,IAAI,IAAI;KAC/B,CAAC,CAAA;IAEF,MAAM,MAAM,GAAG,iBAAiB,CAAS;QACvC,QAAQ,EAAE,CAAC,cAAc,EAAE,EAAE;YAC3B,IAAI,UAAU,EAAE,CAAC;gBACf,QAAQ,EAAE,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAA;YACjC,CAAC;QACH,CAAC;QACD,cAAc,EAAE,YAAY;KAC7B,CAAC,CAAA;IAEF;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,aAAa,GAAG,UAAU;QAC9B,CAAC,CAAC,MAAM,CAAC,gBAAgB,CAAC,MAAM,GAAG,CAAC;QACpC,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC,CAAA;IAEnC,MAAM,QAAQ,GAAG,UAAU;QACzB,CAAC,CAAC,IAAI,GAAG,CAAC;YACN,GAAG,MAAM,CAAC,cAAc;YACxB,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC;SAC/C,CAAC;QACJ,CAAC,CAAC,IAAI,GAAG,CACL;YACE,KAAK,CAAC,UAAU;gBACd,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC;SAC5C,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,CAChC,CAAA;IAEL,OAAO,CACL,cACE,SAAS,EAAE,WAAW,CACpB,oGAAoG,EACpG,SAAS,CACV,YAEA,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAC1B,KAAC,gBAAgB,IACf,YAAY,EAAE,YAAY,EAC1B,EAAE,EAAE,GAAG,MAAM,YAAY,KAAK,EAAE,EAChC,UAAU,EAAE,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,EAClC,IAAI,EAAE,IAAI,EAEV,QAAQ,EACN,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,EAE3C,OAAO,EAAE,GAAG,MAAM,UAAU,KAAK,EAAE,EACnC,QAAQ,EACN,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAN1C,IAAI,CAAC,GAAG,CAQb,CACH,CAAC,GACE,CACP,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { AccordionHeadingLevel, AccordionItem } from "./Accordion.tsx";
|
|
3
|
+
export type AccordionSectionProps = {
|
|
4
|
+
headingLevel: AccordionHeadingLevel;
|
|
5
|
+
id: string;
|
|
6
|
+
isExpanded: boolean;
|
|
7
|
+
item: AccordionItem;
|
|
8
|
+
onToggle: (key: string) => void;
|
|
9
|
+
panelId: string;
|
|
10
|
+
/**
|
|
11
|
+
* `VisibilityGroup.register` or `MultiplePicker.register`,
|
|
12
|
+
* whichever kind the accordion is running. Both have this shape,
|
|
13
|
+
* which is the point of the kinds being separate rather than one
|
|
14
|
+
* configurable one.
|
|
15
|
+
*/
|
|
16
|
+
register: (key: string) => () => void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Its own file for the same reason `TabTrigger` is: **registration
|
|
20
|
+
* is an effect**, and an effect cannot run in a loop.
|
|
21
|
+
*
|
|
22
|
+
* A disabled section stays out of the group entirely — it is not a
|
|
23
|
+
* member, so nothing can expand it, including a consumer passing its
|
|
24
|
+
* key in `expandedKeys`. That is registration-as-membership again,
|
|
25
|
+
* and it is the behaviour a `<summary>` cannot have at all.
|
|
26
|
+
*/
|
|
27
|
+
export declare const AccordionSection: ({ headingLevel, id, isExpanded, item, onToggle, panelId, register, }: AccordionSectionProps) => ReactNode;
|
|
28
|
+
//# sourceMappingURL=AccordionSection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccordionSection.d.ts","sourceRoot":"","sources":["../../src/Accordion/AccordionSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC,OAAO,KAAK,EACV,qBAAqB,EACrB,aAAa,EACd,MAAM,iBAAiB,CAAA;AAmBxB,MAAM,MAAM,qBAAqB,GAAG;IAClC,YAAY,EAAE,qBAAqB,CAAA;IACnC,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,EAAE,OAAO,CAAA;IACnB,IAAI,EAAE,aAAa,CAAA;IACnB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,IAAI,CAAA;CACtC,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,sEAQ9B,qBAAqB,KAAG,SAsG1B,CAAA"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.js";
|
|
4
|
+
import { toClassName } from "../toClassName.js";
|
|
5
|
+
/**
|
|
6
|
+
* Written out rather than interpolated into a tag name. React
|
|
7
|
+
* accepts `` `h${level}` `` perfectly well; a reader and a grep do
|
|
8
|
+
* not, and "which heading levels does this component emit" should be
|
|
9
|
+
* answerable by looking.
|
|
10
|
+
*/
|
|
11
|
+
const HEADING_TAG = {
|
|
12
|
+
2: "h2",
|
|
13
|
+
3: "h3",
|
|
14
|
+
4: "h4",
|
|
15
|
+
5: "h5",
|
|
16
|
+
6: "h6",
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Its own file for the same reason `TabTrigger` is: **registration
|
|
20
|
+
* is an effect**, and an effect cannot run in a loop.
|
|
21
|
+
*
|
|
22
|
+
* A disabled section stays out of the group entirely — it is not a
|
|
23
|
+
* member, so nothing can expand it, including a consumer passing its
|
|
24
|
+
* key in `expandedKeys`. That is registration-as-membership again,
|
|
25
|
+
* and it is the behaviour a `<summary>` cannot have at all.
|
|
26
|
+
*/
|
|
27
|
+
export const AccordionSection = ({ headingLevel, id, isExpanded, item, onToggle, panelId, register, }) => {
|
|
28
|
+
const { isDisabled = false, key, label } = item;
|
|
29
|
+
const Heading = HEADING_TAG[headingLevel];
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isDisabled) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
return register(key);
|
|
35
|
+
}, [isDisabled, key, register]);
|
|
36
|
+
return (_jsxs("div", { className: "bg-surface-raised", children: [_jsx(Heading, { className: "m-0 text-md", children: _jsxs("button", { "aria-controls": panelId, "aria-expanded": isExpanded, className: toClassName("flex w-full cursor-pointer items-center justify-between gap-3 bg-transparent px-4 py-3 text-start font-medium text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard", "hover:bg-intent-neutral-surface", isDisabled &&
|
|
37
|
+
"cursor-not-allowed text-content-disabled hover:bg-transparent", FOCUS_RING_CLASS), disabled: isDisabled, id: id, onClick: () => {
|
|
38
|
+
onToggle(key);
|
|
39
|
+
}, type: "button", children: [label, _jsx("svg", { "aria-hidden": "true", className: toClassName("size-4 shrink-0 text-content-secondary transition-transform duration-(--duration-fast) ease-standard", isExpanded && "rotate-180"), fill: "none", focusable: false, stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.75, viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }) }), _jsx("div", { "aria-labelledby": id, className: "px-4 pt-1 pb-4 text-content-secondary text-sm",
|
|
40
|
+
// `hidden` rather than unmounting, matching `Tabs`. A panel
|
|
41
|
+
// that unmounts loses a scroll position, a partially typed
|
|
42
|
+
// form, and any subscription its content opened — and the
|
|
43
|
+
// fleet's log panes are exactly that.
|
|
44
|
+
hidden: !isExpanded, id: panelId,
|
|
45
|
+
// `group`, not `region`. A `group` is explicitly "a set of
|
|
46
|
+
// UI objects **not** intended to be included in a page
|
|
47
|
+
// summary", so it carries `aria-labelledby` and the
|
|
48
|
+
// trigger↔panel relationship without joining the landmark
|
|
49
|
+
// list — which is exactly the distinction the APG's caveat
|
|
50
|
+
// is reaching for and does not have a role for.
|
|
51
|
+
role: "group", children: item.content })] }));
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=AccordionSection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccordionSection.js","sourceRoot":"","sources":["../../src/Accordion/AccordionSection.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEjC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAM/C;;;;;GAKG;AACH,MAAM,WAAW,GAGb;IACF,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;CACR,CAAA;AAkBD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,EAC/B,YAAY,EACZ,EAAE,EACF,UAAU,EACV,IAAI,EACJ,QAAQ,EACR,OAAO,EACP,QAAQ,GACc,EAAa,EAAE;IACrC,MAAM,EAAE,UAAU,GAAG,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;IAE/C,MAAM,OAAO,GAAG,WAAW,CAAC,YAAY,CAAC,CAAA;IAEzC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,UAAU,EAAE,CAAC;YACf,OAAM;QACR,CAAC;QAED,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAA;IACtB,CAAC,EAAE,CAAC,UAAU,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAA;IAE/B,OAAO,CACL,eAAK,SAAS,EAAC,mBAAmB,aAChC,KAAC,OAAO,IAAC,SAAS,EAAC,aAAa,YAC9B,mCACiB,OAAO,mBACP,UAAU,EACzB,SAAS,EAAE,WAAW,CACpB,uMAAuM,EACvM,iCAAiC,EACjC,UAAU;wBACR,+DAA+D,EACjE,gBAAgB,CACjB,EACD,QAAQ,EAAE,UAAU,EACpB,EAAE,EAAE,EAAE,EACN,OAAO,EAAE,GAAG,EAAE;wBACZ,QAAQ,CAAC,GAAG,CAAC,CAAA;oBACf,CAAC,EACD,IAAI,EAAC,QAAQ,aAEZ,KAAK,EAEN,6BAGc,MAAM,EAClB,SAAS,EAAE,WAAW,CACpB,sGAAsG,EACtG,UAAU,IAAI,YAAY,CAC3B,EACD,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,IACC,GACD,EA0BV,iCACmB,EAAE,EACnB,SAAS,EAAC,+CAA+C;gBACzD,4DAA4D;gBAC5D,2DAA2D;gBAC3D,0DAA0D;gBAC1D,sCAAsC;gBACtC,MAAM,EAAE,CAAC,UAAU,EACnB,EAAE,EAAE,OAAO;gBACX,2DAA2D;gBAC3D,uDAAuD;gBACvD,oDAAoD;gBACpD,0DAA0D;gBAC1D,2DAA2D;gBAC3D,gDAAgD;gBAChD,IAAI,EAAC,OAAO,YAEX,IAAI,CAAC,OAAO,GACT,IACF,CACP,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { SlotProps } from "../slotProps.ts";
|
|
3
|
+
export type FieldProps = SlotProps & {
|
|
4
|
+
/**
|
|
5
|
+
* The control. **Cloned, not wrapped** — the same slot contract
|
|
6
|
+
* as `Popover`'s trigger, so a `Field` can go around an `<input>`,
|
|
7
|
+
* a `<textarea>`, a `Select`, or an app's own control without
|
|
8
|
+
* changing that control's layout.
|
|
9
|
+
*/
|
|
10
|
+
children: ReactElement;
|
|
11
|
+
className?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Standing help — units, a format, a warning about what the value
|
|
14
|
+
* does. Announced with the control, before any error.
|
|
15
|
+
*/
|
|
16
|
+
description?: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Present means invalid. There is no separate `isInvalid`, because
|
|
19
|
+
* two sources for one fact is how a control ends up
|
|
20
|
+
* `aria-invalid="true"` with nothing saying why — which is what
|
|
21
|
+
* `RegexWithFlagsField` does in mux-magic today.
|
|
22
|
+
*/
|
|
23
|
+
error?: ReactNode;
|
|
24
|
+
isRequired?: boolean;
|
|
25
|
+
label: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
29
|
+
* and no two spell the wiring the same way.
|
|
30
|
+
*
|
|
31
|
+
* ### What mux-magic's `FieldLabel` actually gets wrong
|
|
32
|
+
*
|
|
33
|
+
* An earlier version of this docstring said it "renders a `<label>`
|
|
34
|
+
* with no `htmlFor` at all". **That is false** — it renders
|
|
35
|
+
* ``htmlFor={`${stepId}-${field.name}`}``, and has since it was
|
|
36
|
+
* written. The defect is at the other end of the pair: **8 of its 16
|
|
37
|
+
* call sites never render that id on anything**. Measured on
|
|
38
|
+
* `mux-magic@master`, the eight that do are `BooleanField`,
|
|
39
|
+
* `ChapterSplitsField`, `EnumField`, `NumberField`,
|
|
40
|
+
* `NumberWithLookupField`, `PathField`, `StringArrayField` and
|
|
41
|
+
* `StringField`; the other eight either render no id at all or render
|
|
42
|
+
* a different one — `RegexWithFlagsField` mints its own `patternId` /
|
|
43
|
+
* `flagsId` / `sampleId`, and `SubtitleRulesField` spells
|
|
44
|
+
* ``${step.command}-hasDefaultRules`` where the label says
|
|
45
|
+
* ``${step.id}-…``.
|
|
46
|
+
*
|
|
47
|
+
* The outcome is the same unnamed textbox, by a worse route: a
|
|
48
|
+
* `<label>` that points at an id nowhere in the document reads as
|
|
49
|
+
* correct in the label's own file, and is only wrong in the *other*
|
|
50
|
+
* file. Half of them are broken and nothing says which half.
|
|
51
|
+
*
|
|
52
|
+
* That is the argument for a component rather than a convention: here
|
|
53
|
+
* the id is minted and consumed in one place and cannot be spelled
|
|
54
|
+
* twice.
|
|
55
|
+
*
|
|
56
|
+
* ### The ids are static, and that is why `createLinkedIds` is not here
|
|
57
|
+
*
|
|
58
|
+
* The plan expected `useLinkedIds` to do this wiring. It does not
|
|
59
|
+
* fit, for the same reason it did not fit `Tabs`: that kind exists
|
|
60
|
+
* for a **dynamic** pairing, where the registration multiset is what
|
|
61
|
+
* stops an `aria-describedby` naming a node React has already
|
|
62
|
+
* removed. A field renders its own description and its own error, so
|
|
63
|
+
* it knows at render which of them exist — the ids come from one
|
|
64
|
+
* `useUniqueId` and a conditional join, and they cannot get out of
|
|
65
|
+
* step at all.
|
|
66
|
+
*
|
|
67
|
+
* That leaves `createLinkedIds` with **no consumer in this package
|
|
68
|
+
* across twenty-five components**, which is worth saying plainly
|
|
69
|
+
* rather than manufacturing a use for. It is the one state kind the
|
|
70
|
+
* component layer has never needed.
|
|
71
|
+
*
|
|
72
|
+
* ### Order matters inside `aria-describedby`
|
|
73
|
+
*
|
|
74
|
+
* Description first, error second. A screen reader reads the list in
|
|
75
|
+
* order, and "millisecondsonly — required" is a different sentence
|
|
76
|
+
* from "required — milliseconds only". Nothing enforces this but the
|
|
77
|
+
* order of the array below, which is why it is not built from an
|
|
78
|
+
* object.
|
|
79
|
+
*
|
|
80
|
+
* ### The error is described, not announced
|
|
81
|
+
*
|
|
82
|
+
* No `role="alert"` and no live region here, deliberately. An error
|
|
83
|
+
* bound through `aria-describedby` is read when the control takes
|
|
84
|
+
* focus, which is when the user can act on it; a live region on the
|
|
85
|
+
* same text announces it a second time, from nowhere, while the
|
|
86
|
+
* caret is elsewhere. A form that fails on submit should say so
|
|
87
|
+
* once, at the top, with the `Alert` this library already ships —
|
|
88
|
+
* and then the user tabs into the field and hears the specific
|
|
89
|
+
* reason.
|
|
90
|
+
*
|
|
91
|
+
* ### A slot is a pass-through
|
|
92
|
+
*
|
|
93
|
+
* A `Tooltip` between the field and its control is the obvious thing
|
|
94
|
+
* to write, and it is what mux-magic's `FieldLabel` does. Both
|
|
95
|
+
* components clone onto their one child, so everything below used to
|
|
96
|
+
* land on the `Tooltip` **component** and be dropped in silence —
|
|
97
|
+
* leaving the `<label htmlFor>` above pointing at nothing, which is
|
|
98
|
+
* precisely the defect this component was built to make impossible.
|
|
99
|
+
* `SlotProps` arriving from an outer slot is forwarded down, and
|
|
100
|
+
* `aria-describedby` is merged rather than replaced.
|
|
101
|
+
* `slotProps.ts` has the reasoning.
|
|
102
|
+
*/
|
|
103
|
+
export declare const Field: ({ children, className, description, error, isRequired, label, ...receivedSlotProps }: FieldProps) => ReactNode;
|
|
104
|
+
//# sourceMappingURL=Field.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Field.d.ts","sourceRoot":"","sources":["../../src/Field/Field.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAIhD,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG;IACnC;;;;;OAKG;IACH,QAAQ,EAAE,YAAY,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,SAAS,CAAA;CACjB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AACH,eAAO,MAAM,KAAK,GAAI,sFAQnB,UAAU,KAAG,SA4Ff,CAAA"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useClonedChild, useUniqueId, } from "@charcuterie/logic";
|
|
3
|
+
import { mergeSlotProps } from "../slotProps.js";
|
|
4
|
+
import { toClassName } from "../toClassName.js";
|
|
5
|
+
/**
|
|
6
|
+
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
7
|
+
* and no two spell the wiring the same way.
|
|
8
|
+
*
|
|
9
|
+
* ### What mux-magic's `FieldLabel` actually gets wrong
|
|
10
|
+
*
|
|
11
|
+
* An earlier version of this docstring said it "renders a `<label>`
|
|
12
|
+
* with no `htmlFor` at all". **That is false** — it renders
|
|
13
|
+
* ``htmlFor={`${stepId}-${field.name}`}``, and has since it was
|
|
14
|
+
* written. The defect is at the other end of the pair: **8 of its 16
|
|
15
|
+
* call sites never render that id on anything**. Measured on
|
|
16
|
+
* `mux-magic@master`, the eight that do are `BooleanField`,
|
|
17
|
+
* `ChapterSplitsField`, `EnumField`, `NumberField`,
|
|
18
|
+
* `NumberWithLookupField`, `PathField`, `StringArrayField` and
|
|
19
|
+
* `StringField`; the other eight either render no id at all or render
|
|
20
|
+
* a different one — `RegexWithFlagsField` mints its own `patternId` /
|
|
21
|
+
* `flagsId` / `sampleId`, and `SubtitleRulesField` spells
|
|
22
|
+
* ``${step.command}-hasDefaultRules`` where the label says
|
|
23
|
+
* ``${step.id}-…``.
|
|
24
|
+
*
|
|
25
|
+
* The outcome is the same unnamed textbox, by a worse route: a
|
|
26
|
+
* `<label>` that points at an id nowhere in the document reads as
|
|
27
|
+
* correct in the label's own file, and is only wrong in the *other*
|
|
28
|
+
* file. Half of them are broken and nothing says which half.
|
|
29
|
+
*
|
|
30
|
+
* That is the argument for a component rather than a convention: here
|
|
31
|
+
* the id is minted and consumed in one place and cannot be spelled
|
|
32
|
+
* twice.
|
|
33
|
+
*
|
|
34
|
+
* ### The ids are static, and that is why `createLinkedIds` is not here
|
|
35
|
+
*
|
|
36
|
+
* The plan expected `useLinkedIds` to do this wiring. It does not
|
|
37
|
+
* fit, for the same reason it did not fit `Tabs`: that kind exists
|
|
38
|
+
* for a **dynamic** pairing, where the registration multiset is what
|
|
39
|
+
* stops an `aria-describedby` naming a node React has already
|
|
40
|
+
* removed. A field renders its own description and its own error, so
|
|
41
|
+
* it knows at render which of them exist — the ids come from one
|
|
42
|
+
* `useUniqueId` and a conditional join, and they cannot get out of
|
|
43
|
+
* step at all.
|
|
44
|
+
*
|
|
45
|
+
* That leaves `createLinkedIds` with **no consumer in this package
|
|
46
|
+
* across twenty-five components**, which is worth saying plainly
|
|
47
|
+
* rather than manufacturing a use for. It is the one state kind the
|
|
48
|
+
* component layer has never needed.
|
|
49
|
+
*
|
|
50
|
+
* ### Order matters inside `aria-describedby`
|
|
51
|
+
*
|
|
52
|
+
* Description first, error second. A screen reader reads the list in
|
|
53
|
+
* order, and "millisecondsonly — required" is a different sentence
|
|
54
|
+
* from "required — milliseconds only". Nothing enforces this but the
|
|
55
|
+
* order of the array below, which is why it is not built from an
|
|
56
|
+
* object.
|
|
57
|
+
*
|
|
58
|
+
* ### The error is described, not announced
|
|
59
|
+
*
|
|
60
|
+
* No `role="alert"` and no live region here, deliberately. An error
|
|
61
|
+
* bound through `aria-describedby` is read when the control takes
|
|
62
|
+
* focus, which is when the user can act on it; a live region on the
|
|
63
|
+
* same text announces it a second time, from nowhere, while the
|
|
64
|
+
* caret is elsewhere. A form that fails on submit should say so
|
|
65
|
+
* once, at the top, with the `Alert` this library already ships —
|
|
66
|
+
* and then the user tabs into the field and hears the specific
|
|
67
|
+
* reason.
|
|
68
|
+
*
|
|
69
|
+
* ### A slot is a pass-through
|
|
70
|
+
*
|
|
71
|
+
* A `Tooltip` between the field and its control is the obvious thing
|
|
72
|
+
* to write, and it is what mux-magic's `FieldLabel` does. Both
|
|
73
|
+
* components clone onto their one child, so everything below used to
|
|
74
|
+
* land on the `Tooltip` **component** and be dropped in silence —
|
|
75
|
+
* leaving the `<label htmlFor>` above pointing at nothing, which is
|
|
76
|
+
* precisely the defect this component was built to make impossible.
|
|
77
|
+
* `SlotProps` arriving from an outer slot is forwarded down, and
|
|
78
|
+
* `aria-describedby` is merged rather than replaced.
|
|
79
|
+
* `slotProps.ts` has the reasoning.
|
|
80
|
+
*/
|
|
81
|
+
export const Field = ({ children, className, description, error, isRequired = false, label, ...receivedSlotProps }) => {
|
|
82
|
+
const baseId = useUniqueId();
|
|
83
|
+
/**
|
|
84
|
+
* A caller's own `id` wins, which is what makes a `Field` usable
|
|
85
|
+
* where the id has to be predictable — an autofill hint, a deep
|
|
86
|
+
* link, a server-rendered error summary linking to the control.
|
|
87
|
+
* `useUniqueId` is still called: hooks are unconditional, and it
|
|
88
|
+
* costs one counter read.
|
|
89
|
+
*/
|
|
90
|
+
const controlId = receivedSlotProps.id ?? `${baseId}-control`;
|
|
91
|
+
const descriptionId = `${baseId}-description`;
|
|
92
|
+
const errorId = `${baseId}-error`;
|
|
93
|
+
const describedBy = [
|
|
94
|
+
description === undefined ? null : descriptionId,
|
|
95
|
+
error === undefined ? null : errorId,
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join(" ");
|
|
99
|
+
const clonedControl = useClonedChild(children, mergeSlotProps(receivedSlotProps, {
|
|
100
|
+
"aria-describedby": describedBy === "" ? undefined : describedBy,
|
|
101
|
+
// `aria-invalid` is derived from the error's presence, never
|
|
102
|
+
// passed. A control marked invalid with no described reason is
|
|
103
|
+
// a screen reader saying "invalid" and stopping.
|
|
104
|
+
"aria-invalid": error === undefined ? undefined : true,
|
|
105
|
+
// Both, and they are not redundant: `required` is the
|
|
106
|
+
// constraint the browser validates and `aria-required` is the
|
|
107
|
+
// one it announces on a control the browser does not validate
|
|
108
|
+
// — a `role="combobox"`, or anything composite.
|
|
109
|
+
"aria-required": isRequired || undefined,
|
|
110
|
+
id: controlId,
|
|
111
|
+
required: isRequired || undefined,
|
|
112
|
+
}));
|
|
113
|
+
return (_jsxs("div", { className: toClassName("flex flex-col gap-1.5", className), children: [_jsxs("label", { className: "font-medium text-content-primary text-sm", htmlFor: controlId, children: [label, isRequired ? (_jsx("span", { "aria-hidden": "true", className: "ms-1 text-intent-danger-content", children: "*" })) : null] }), clonedControl, description === undefined ? null : (_jsx("p", { className: "text-content-secondary text-xs", id: descriptionId, children: description })), error === undefined ? null : (_jsx("p", { className: "text-intent-danger-content text-xs", id: errorId, children: error }))] }));
|
|
114
|
+
};
|
|
115
|
+
//# sourceMappingURL=Field.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Field.js","sourceRoot":"","sources":["../../src/Field/Field.tsx"],"names":[],"mappings":";AAAA,OAAO,EACL,cAAc,EACd,WAAW,GACZ,MAAM,oBAAoB,CAAA;AAI3B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AA2B/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,EACL,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,GAAG,iBAAiB,EACT,EAAa,EAAE;IAC1B,MAAM,MAAM,GAAG,WAAW,EAAE,CAAA;IAE5B;;;;;;OAMG;IACH,MAAM,SAAS,GACb,iBAAiB,CAAC,EAAE,IAAI,GAAG,MAAM,UAAU,CAAA;IAE7C,MAAM,aAAa,GAAG,GAAG,MAAM,cAAc,CAAA;IAE7C,MAAM,OAAO,GAAG,GAAG,MAAM,QAAQ,CAAA;IAEjC,MAAM,WAAW,GAAG;QAClB,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa;QAChD,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO;KACrC;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,MAAM,aAAa,GAAG,cAAc,CAClC,QAAQ,EACR,cAAc,CAAC,iBAAiB,EAAE;QAChC,kBAAkB,EAChB,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;QAC9C,6DAA6D;QAC7D,+DAA+D;QAC/D,iDAAiD;QACjD,cAAc,EACZ,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;QACxC,sDAAsD;QACtD,8DAA8D;QAC9D,8DAA8D;QAC9D,gDAAgD;QAChD,eAAe,EAAE,UAAU,IAAI,SAAS;QACxC,EAAE,EAAE,SAAS;QACb,QAAQ,EAAE,UAAU,IAAI,SAAS;KAClC,CAAC,CACH,CAAA;IAED,OAAO,CACL,eACE,SAAS,EAAE,WAAW,CACpB,uBAAuB,EACvB,SAAS,CACV,aAED,iBACE,SAAS,EAAC,0CAA0C,EACpD,OAAO,EAAE,SAAS,aAEjB,KAAK,EAEL,UAAU,CAAC,CAAC,CAAC,CACZ,8BAKc,MAAM,EAClB,SAAS,EAAC,iCAAiC,kBAGtC,CACR,CAAC,CAAC,CAAC,IAAI,IACF,EAEP,aAAa,EAEb,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,YACE,SAAS,EAAC,gCAAgC,EAC1C,EAAE,EAAE,aAAa,YAEhB,WAAW,GACV,CACL,EAEA,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC5B,YACE,SAAS,EAAC,oCAAoC,EAC9C,EAAE,EAAE,OAAO,YAEV,KAAK,GACJ,CACL,IACG,CACP,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type FieldGroupProps = {
|
|
3
|
+
/**
|
|
4
|
+
* The controls. **Rendered as-is, not cloned** — that is the whole
|
|
5
|
+
* difference from `Field`, and it is why this is a separate
|
|
6
|
+
* component rather than a boolean on that one.
|
|
7
|
+
*/
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Standing help for the group as a whole. */
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
/** Present means the group is invalid. */
|
|
13
|
+
error?: ReactNode;
|
|
14
|
+
isRequired?: boolean;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A label over **several** controls.
|
|
19
|
+
*
|
|
20
|
+
* `Field` mints one id and clones it onto one child, which is the
|
|
21
|
+
* right shape for a text input and the wrong shape the moment there
|
|
22
|
+
* are two of them: an `id` can only name one element, and a
|
|
23
|
+
* `<label htmlFor>` can only point at one. Six of mux-magic's sixteen
|
|
24
|
+
* field components are in that position — `RegexWithFlagsField`
|
|
25
|
+
* (pattern, flags, sample, and a checkbox), `NumberWithLookupField`
|
|
26
|
+
* (three inputs), `LanguageCodeField`, `LanguageCodesField`,
|
|
27
|
+
* `SubtitleTypesField` (a tag input beside a dropdown) and
|
|
28
|
+
* `RenameRegexField` (a row per rule) — and every one of them renders
|
|
29
|
+
* a `FieldLabel` whose `htmlFor` names, at best, one of them.
|
|
30
|
+
*
|
|
31
|
+
* ### `<fieldset>` and `<legend>`, not a `<label>`
|
|
32
|
+
*
|
|
33
|
+
* This is the one place in the library where `<fieldset>` is right.
|
|
34
|
+
* `AccordionSection` rejected it for its panel and said why — it is a
|
|
35
|
+
* *form-control grouping*, and dragging `<legend>` semantics onto
|
|
36
|
+
* prose is wrong. Here the content **is** a form-control grouping,
|
|
37
|
+
* so the element means what it says, `<legend>` names the group
|
|
38
|
+
* natively, and no `role="group"` and no `aria-labelledby` are
|
|
39
|
+
* needed.
|
|
40
|
+
*
|
|
41
|
+
* A `<label>` here would be the mux-magic defect deliberately: a
|
|
42
|
+
* label pointing at one control, or at none.
|
|
43
|
+
*
|
|
44
|
+
* ### What a group cannot carry
|
|
45
|
+
*
|
|
46
|
+
* `aria-invalid` has no group form — it belongs on the control that
|
|
47
|
+
* is actually invalid, and this component does not know which one
|
|
48
|
+
* that is. So `error` here is *described*, not *asserted*: it joins
|
|
49
|
+
* `aria-describedby` on the `<fieldset>`, which screen readers read
|
|
50
|
+
* as focus enters the group. A control inside that is itself invalid
|
|
51
|
+
* should say so itself, in its own `Field`.
|
|
52
|
+
*
|
|
53
|
+
* That is a real limitation and it is stated rather than papered
|
|
54
|
+
* over — the alternative was to clone `aria-invalid` onto every
|
|
55
|
+
* child, which marks the valid ones invalid.
|
|
56
|
+
*/
|
|
57
|
+
export declare const FieldGroup: ({ children, className, description, error, isRequired, label, }: FieldGroupProps) => ReactNode;
|
|
58
|
+
//# sourceMappingURL=FieldGroup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldGroup.d.ts","sourceRoot":"","sources":["../../src/Field/FieldGroup.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;OAIG;IACH,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8CAA8C;IAC9C,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,SAAS,CAAA;CACjB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,UAAU,GAAI,iEAOxB,eAAe,KAAG,SAqEpB,CAAA"}
|