@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Accordion.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Default } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("a trigger says what it controls and whether it is open", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
const disc = expectAgentDrivable(canvas, {
|
|
18
|
+
name: "Disc",
|
|
19
|
+
role: "button",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await expect(disc).toHaveAttribute(
|
|
23
|
+
"aria-expanded",
|
|
24
|
+
"true",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// The panel is queried by id rather than by role, because it is
|
|
28
|
+
// deliberately **not** a landmark — the APG's own caveat about
|
|
29
|
+
// "landmark region proliferation", which a board of four
|
|
30
|
+
// accordions turned into a failing `landmark-unique` immediately.
|
|
31
|
+
const panel = document.getElementById(
|
|
32
|
+
disc.getAttribute("aria-controls") ?? "",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
await expect(panel).not.toBeNull()
|
|
36
|
+
|
|
37
|
+
// The link still reaches in both directions — the button's
|
|
38
|
+
// `aria-controls` at the panel, the panel's `aria-labelledby` back
|
|
39
|
+
// at the button. Both ids come from one `useUniqueId`, and this is
|
|
40
|
+
// the relationship a screen reader actually follows; the landmark
|
|
41
|
+
// only ever added a second route to the same node.
|
|
42
|
+
await expect(panel).toHaveAttribute(
|
|
43
|
+
"aria-labelledby",
|
|
44
|
+
disc.id,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
await expect(panel).toHaveTextContent("Blade Runner")
|
|
48
|
+
|
|
49
|
+
await expectNoAxeViolations(canvasElement)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The first-paint hole `Tabs` found, in the other kind. Members
|
|
54
|
+
* register from an effect, so on the very first paint the intent
|
|
55
|
+
* still lives in `pendingKey` — an accordion reading only
|
|
56
|
+
* `visibleKey` renders every section collapsed and corrects itself a
|
|
57
|
+
* frame later.
|
|
58
|
+
*/
|
|
59
|
+
test("the initially expanded section is expanded on the first paint", async () => {
|
|
60
|
+
const { canvas } = await mountStory(Default)
|
|
61
|
+
|
|
62
|
+
const expanded = canvas
|
|
63
|
+
.queryAllByRole("button")
|
|
64
|
+
.filter(
|
|
65
|
+
(one: HTMLElement) =>
|
|
66
|
+
one.getAttribute("aria-expanded") === "true",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await expect(expanded).toHaveLength(1)
|
|
70
|
+
|
|
71
|
+
await expect(expanded[0]).toHaveTextContent("Disc")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("exclusive means opening one closes the other", async () => {
|
|
75
|
+
const { canvas } = await mountStory(Default)
|
|
76
|
+
|
|
77
|
+
const log = expectAgentDrivable(canvas, {
|
|
78
|
+
name: "Log",
|
|
79
|
+
role: "button",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
await userEvent.click(log)
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(log).toHaveAttribute("aria-expanded", "true")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
expectAgentDrivable(canvas, {
|
|
90
|
+
name: "Disc",
|
|
91
|
+
role: "button",
|
|
92
|
+
}),
|
|
93
|
+
).toHaveAttribute("aria-expanded", "false")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("multiple keeps both open", async () => {
|
|
97
|
+
const { canvas, canvasElement } =
|
|
98
|
+
await mountStory(AllVariants)
|
|
99
|
+
|
|
100
|
+
// Two sections start expanded in the `multiple` cell, which the
|
|
101
|
+
// exclusive kind cannot represent at all.
|
|
102
|
+
const expanded = canvas
|
|
103
|
+
.queryAllByRole("button")
|
|
104
|
+
.filter(
|
|
105
|
+
(one: HTMLElement) =>
|
|
106
|
+
one.getAttribute("aria-expanded") === "true",
|
|
107
|
+
)
|
|
108
|
+
.map((one: HTMLElement) => one.textContent)
|
|
109
|
+
|
|
110
|
+
await expect(expanded).toEqual([
|
|
111
|
+
"Disc",
|
|
112
|
+
"Disc",
|
|
113
|
+
"Flags",
|
|
114
|
+
"Steps",
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
await expectNoAxeViolations(canvasElement)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("the heading level is the caller's, not the component's", async () => {
|
|
121
|
+
const { canvas } = await mountStory(AllVariants)
|
|
122
|
+
|
|
123
|
+
const level2 = expectAgentDrivable(canvas, {
|
|
124
|
+
name: "Steps",
|
|
125
|
+
role: "heading",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// An accordion inside a `Card` that already has an `<h2>` needs
|
|
129
|
+
// `3`, and getting it wrong is a document-outline break no gate
|
|
130
|
+
// here can see — it depends entirely on the page.
|
|
131
|
+
await expect(level2.tagName).toBe("H2")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Registration is membership. A disabled section never registers, so
|
|
136
|
+
* nothing can expand it — including this story, which passes its key
|
|
137
|
+
* in `expandedKeys` on purpose.
|
|
138
|
+
*/
|
|
139
|
+
test("a disabled section cannot be expanded, even by the caller", async () => {
|
|
140
|
+
const { canvas, canvasElement } =
|
|
141
|
+
await mountStory(AllStates)
|
|
142
|
+
|
|
143
|
+
const verdict = expectAgentDrivable(canvas, {
|
|
144
|
+
name: "Verdict",
|
|
145
|
+
role: "button",
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
await expect(verdict).toBeDisabled()
|
|
149
|
+
|
|
150
|
+
await expect(verdict).toHaveAttribute(
|
|
151
|
+
"aria-expanded",
|
|
152
|
+
"false",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
await userEvent.click(verdict, { pointerEventsCheck: 0 })
|
|
156
|
+
|
|
157
|
+
await expect(verdict).toHaveAttribute(
|
|
158
|
+
"aria-expanded",
|
|
159
|
+
"false",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
await expectNoAxeViolations(canvasElement)
|
|
163
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMultiplePicker,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
useVisibilityGroup,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { ReactNode } from "react"
|
|
7
|
+
|
|
8
|
+
import { toClassName } from "../toClassName.ts"
|
|
9
|
+
import { AccordionSection } from "./AccordionSection.tsx"
|
|
10
|
+
|
|
11
|
+
export type AccordionHeadingLevel = 2 | 3 | 4 | 5 | 6
|
|
12
|
+
|
|
13
|
+
export type AccordionItem = {
|
|
14
|
+
content: ReactNode
|
|
15
|
+
isDisabled?: boolean
|
|
16
|
+
key: string
|
|
17
|
+
label: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type AccordionProps = {
|
|
21
|
+
className?: string
|
|
22
|
+
/** **Initial** only. Charcuterie owns it from then on. */
|
|
23
|
+
expandedKeys?: string[]
|
|
24
|
+
/**
|
|
25
|
+
* The heading level the triggers sit at. An accordion inside a
|
|
26
|
+
* `Card` that already has an `<h2>` needs `3`, and getting this
|
|
27
|
+
* wrong is a document-outline break that no gate here can see —
|
|
28
|
+
* it depends entirely on the page.
|
|
29
|
+
*/
|
|
30
|
+
headingLevel?: AccordionHeadingLevel
|
|
31
|
+
/**
|
|
32
|
+
* Several sections open at once. The default is **exclusive**,
|
|
33
|
+
* because that is what every one of the fleet's disclosure groups
|
|
34
|
+
* does today.
|
|
35
|
+
*/
|
|
36
|
+
isMultiple?: boolean
|
|
37
|
+
items: AccordionItem[]
|
|
38
|
+
onChange?: (expandedKeys: string[]) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Eleven sites in the fleet, and the plan called it "nearly free
|
|
43
|
+
* once `Tabs` exists". It is — the exclusive mode is
|
|
44
|
+
* `VisibilityGroup` and nothing else — but the reason it is worth
|
|
45
|
+
* shipping is not the saving.
|
|
46
|
+
*
|
|
47
|
+
* ### The two-owners bug, live, in the app this milestone migrates
|
|
48
|
+
*
|
|
49
|
+
* mux-magic's `JobStepsDisclosure` is built on `<details>`, and
|
|
50
|
+
* `<details>` **owns `open`**. It also wants the state in a Jotai
|
|
51
|
+
* atom so a job can be expanded from elsewhere. Reconciling the two
|
|
52
|
+
* takes three separate mechanisms:
|
|
53
|
+
*
|
|
54
|
+
* ```tsx
|
|
55
|
+
* const detailsRef = useRef<HTMLDetailsElement>(null)
|
|
56
|
+
* const skipNextToggleRef = useRef(isOpen)
|
|
57
|
+
*
|
|
58
|
+
* useEffect(() => {
|
|
59
|
+
* if (detailsRef.current) detailsRef.current.open = isOpen
|
|
60
|
+
* }, [isOpen])
|
|
61
|
+
*
|
|
62
|
+
* const handleToggle = (event) => {
|
|
63
|
+
* if (skipNextToggleRef.current) {
|
|
64
|
+
* skipNextToggleRef.current = false
|
|
65
|
+
* return // ← swallow the toggle our own write just fired
|
|
66
|
+
* }
|
|
67
|
+
* setStepsOpen(…)
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* That is the Radix argument from `Popover` pointed at the platform:
|
|
72
|
+
* a ref to reach past React, an effect to push state into the DOM,
|
|
73
|
+
* and a guard to stop the DOM's echo coming back. Every one of the
|
|
74
|
+
* three exists only because two things hold one fact.
|
|
75
|
+
*
|
|
76
|
+
* So this is a `<button aria-expanded>` and a `role="region"` — the
|
|
77
|
+
* ARIA Authoring Practices accordion — with `VisibilityGroup`
|
|
78
|
+
* holding the state alone. `<details name="…">` is the platform's
|
|
79
|
+
* own exclusive accordion and was rejected for the same reason, plus
|
|
80
|
+
* one it cannot fix: **a `<summary>` cannot be disabled**, and four
|
|
81
|
+
* of the fleet's eleven sites have a section that is unreachable
|
|
82
|
+
* until a job produces it.
|
|
83
|
+
*
|
|
84
|
+
* ### Two kinds, one component, and both hooks always run
|
|
85
|
+
*
|
|
86
|
+
* `isMultiple` picks between `VisibilityGroup` and `MultiplePicker`,
|
|
87
|
+
* and hooks cannot be conditional — so both are created and one is
|
|
88
|
+
* consulted. That is two empty stores and no DOM, which is cheaper
|
|
89
|
+
* than the alternative: reimplementing exclusivity on top of
|
|
90
|
+
* `MultiplePicker` by deselecting the others, which is precisely the
|
|
91
|
+
* logic `VisibilityGroup` exists to own once.
|
|
92
|
+
*/
|
|
93
|
+
export const Accordion = ({
|
|
94
|
+
className,
|
|
95
|
+
expandedKeys,
|
|
96
|
+
headingLevel = 3,
|
|
97
|
+
isMultiple = false,
|
|
98
|
+
items,
|
|
99
|
+
onChange,
|
|
100
|
+
}: AccordionProps): ReactNode => {
|
|
101
|
+
const baseId = useUniqueId()
|
|
102
|
+
|
|
103
|
+
const [initialKey] = expandedKeys ?? []
|
|
104
|
+
|
|
105
|
+
const group = useVisibilityGroup<string>({
|
|
106
|
+
onChange: (visibleKey) => {
|
|
107
|
+
if (!isMultiple) {
|
|
108
|
+
onChange?.(visibleKey === null ? [] : [visibleKey])
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
visibleKey: initialKey ?? null,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const picker = useMultiplePicker<string>({
|
|
115
|
+
onChange: (selectedValues) => {
|
|
116
|
+
if (isMultiple) {
|
|
117
|
+
onChange?.([...selectedValues])
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
selectedValues: expandedKeys,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Visible **or** pending, the same load-bearing fallback `Tabs`
|
|
125
|
+
* needs and for the same reason: members register from an effect,
|
|
126
|
+
* so on the first paint the intent still lives in the pending
|
|
127
|
+
* field. Reading only the settled one renders every section
|
|
128
|
+
* collapsed and corrects itself a frame later — a flash on a
|
|
129
|
+
* desktop and a visible jump on the kiosk Pi.
|
|
130
|
+
*
|
|
131
|
+
* **And only until something registers**, which `Tabs` never had
|
|
132
|
+
* to say because it seeds itself from the first *enabled* tab. A
|
|
133
|
+
* key that can never register — a disabled section's — stays
|
|
134
|
+
* pending forever, so an unqualified fallback renders it expanded
|
|
135
|
+
* permanently and `isDisabled` quietly stops meaning anything.
|
|
136
|
+
* Found by the story that passes a disabled key on purpose.
|
|
137
|
+
*
|
|
138
|
+
* Every member registers in one commit, so this really is "before
|
|
139
|
+
* the first effects ran" rather than a race between them.
|
|
140
|
+
*/
|
|
141
|
+
const hasRegistered = isMultiple
|
|
142
|
+
? picker.registeredValues.length > 0
|
|
143
|
+
: group.registeredKeys.length > 0
|
|
144
|
+
|
|
145
|
+
const expanded = isMultiple
|
|
146
|
+
? new Set([
|
|
147
|
+
...picker.selectedValues,
|
|
148
|
+
...(hasRegistered ? [] : picker.pendingValues),
|
|
149
|
+
])
|
|
150
|
+
: new Set(
|
|
151
|
+
[
|
|
152
|
+
group.visibleKey ??
|
|
153
|
+
(hasRegistered ? null : group.pendingKey),
|
|
154
|
+
].filter((key) => key !== null),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
className={toClassName(
|
|
160
|
+
"flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-subtle",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{items.map((item, index) => (
|
|
165
|
+
<AccordionSection
|
|
166
|
+
headingLevel={headingLevel}
|
|
167
|
+
id={`${baseId}-trigger-${index}`}
|
|
168
|
+
isExpanded={expanded.has(item.key)}
|
|
169
|
+
item={item}
|
|
170
|
+
key={item.key}
|
|
171
|
+
onToggle={
|
|
172
|
+
isMultiple ? picker.toggle : group.toggle
|
|
173
|
+
}
|
|
174
|
+
panelId={`${baseId}-panel-${index}`}
|
|
175
|
+
register={
|
|
176
|
+
isMultiple ? picker.register : group.register
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useEffect } from "react"
|
|
3
|
+
|
|
4
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import type {
|
|
7
|
+
AccordionHeadingLevel,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
} from "./Accordion.tsx"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Written out rather than interpolated into a tag name. React
|
|
13
|
+
* accepts `` `h${level}` `` perfectly well; a reader and a grep do
|
|
14
|
+
* not, and "which heading levels does this component emit" should be
|
|
15
|
+
* answerable by looking.
|
|
16
|
+
*/
|
|
17
|
+
const HEADING_TAG: Record<
|
|
18
|
+
AccordionHeadingLevel,
|
|
19
|
+
"h2" | "h3" | "h4" | "h5" | "h6"
|
|
20
|
+
> = {
|
|
21
|
+
2: "h2",
|
|
22
|
+
3: "h3",
|
|
23
|
+
4: "h4",
|
|
24
|
+
5: "h5",
|
|
25
|
+
6: "h6",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AccordionSectionProps = {
|
|
29
|
+
headingLevel: AccordionHeadingLevel
|
|
30
|
+
id: string
|
|
31
|
+
isExpanded: boolean
|
|
32
|
+
item: AccordionItem
|
|
33
|
+
onToggle: (key: string) => void
|
|
34
|
+
panelId: string
|
|
35
|
+
/**
|
|
36
|
+
* `VisibilityGroup.register` or `MultiplePicker.register`,
|
|
37
|
+
* whichever kind the accordion is running. Both have this shape,
|
|
38
|
+
* which is the point of the kinds being separate rather than one
|
|
39
|
+
* configurable one.
|
|
40
|
+
*/
|
|
41
|
+
register: (key: string) => () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Its own file for the same reason `TabTrigger` is: **registration
|
|
46
|
+
* is an effect**, and an effect cannot run in a loop.
|
|
47
|
+
*
|
|
48
|
+
* A disabled section stays out of the group entirely — it is not a
|
|
49
|
+
* member, so nothing can expand it, including a consumer passing its
|
|
50
|
+
* key in `expandedKeys`. That is registration-as-membership again,
|
|
51
|
+
* and it is the behaviour a `<summary>` cannot have at all.
|
|
52
|
+
*/
|
|
53
|
+
export const AccordionSection = ({
|
|
54
|
+
headingLevel,
|
|
55
|
+
id,
|
|
56
|
+
isExpanded,
|
|
57
|
+
item,
|
|
58
|
+
onToggle,
|
|
59
|
+
panelId,
|
|
60
|
+
register,
|
|
61
|
+
}: AccordionSectionProps): ReactNode => {
|
|
62
|
+
const { isDisabled = false, key, label } = item
|
|
63
|
+
|
|
64
|
+
const Heading = HEADING_TAG[headingLevel]
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (isDisabled) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return register(key)
|
|
72
|
+
}, [isDisabled, key, register])
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="bg-surface-raised">
|
|
76
|
+
<Heading className="m-0 text-md">
|
|
77
|
+
<button
|
|
78
|
+
aria-controls={panelId}
|
|
79
|
+
aria-expanded={isExpanded}
|
|
80
|
+
className={toClassName(
|
|
81
|
+
"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",
|
|
82
|
+
"hover:bg-intent-neutral-surface",
|
|
83
|
+
isDisabled &&
|
|
84
|
+
"cursor-not-allowed text-content-disabled hover:bg-transparent",
|
|
85
|
+
FOCUS_RING_CLASS,
|
|
86
|
+
)}
|
|
87
|
+
disabled={isDisabled}
|
|
88
|
+
id={id}
|
|
89
|
+
onClick={() => {
|
|
90
|
+
onToggle(key)
|
|
91
|
+
}}
|
|
92
|
+
type="button"
|
|
93
|
+
>
|
|
94
|
+
{label}
|
|
95
|
+
|
|
96
|
+
<svg
|
|
97
|
+
// Decoration. The button already announces
|
|
98
|
+
// `aria-expanded`, which is the same fact said properly.
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
className={toClassName(
|
|
101
|
+
"size-4 shrink-0 text-content-secondary transition-transform duration-(--duration-fast) ease-standard",
|
|
102
|
+
isExpanded && "rotate-180",
|
|
103
|
+
)}
|
|
104
|
+
fill="none"
|
|
105
|
+
focusable={false}
|
|
106
|
+
stroke="currentColor"
|
|
107
|
+
strokeLinecap="round"
|
|
108
|
+
strokeLinejoin="round"
|
|
109
|
+
strokeWidth={1.75}
|
|
110
|
+
viewBox="0 0 24 24"
|
|
111
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
112
|
+
>
|
|
113
|
+
<path d="m6 9 6 6 6-6" />
|
|
114
|
+
</svg>
|
|
115
|
+
</button>
|
|
116
|
+
</Heading>
|
|
117
|
+
|
|
118
|
+
{/*
|
|
119
|
+
`group`, not `region`, and the difference is a landmark.
|
|
120
|
+
|
|
121
|
+
The APG's accordion pattern says a panel *may* take
|
|
122
|
+
`role="region"` and adds a caveat in the same breath: avoid
|
|
123
|
+
it "in circumstances that create landmark region
|
|
124
|
+
proliferation". A component library has to assume the worst
|
|
125
|
+
case, and the worst case arrived on the first board — four
|
|
126
|
+
accordions produced four landmarks named "Disc", and axe's
|
|
127
|
+
`landmark-unique` failed the story. A real page with a job
|
|
128
|
+
list does exactly that.
|
|
129
|
+
|
|
130
|
+
Dropping the role entirely was the first fix and it was
|
|
131
|
+
wrong: `aria-labelledby` on a roleless `<div>` is **inert**,
|
|
132
|
+
so the panel would have lost its name rather than just its
|
|
133
|
+
landmark — caught by `useAriaPropsSupportedByRole`, which is
|
|
134
|
+
the one linter rule in this file that was right.
|
|
135
|
+
|
|
136
|
+
`group` is the role the caveat is reaching for: "a set of UI
|
|
137
|
+
objects **not** intended to be included in a page summary".
|
|
138
|
+
It keeps `aria-labelledby` meaningful and stays out of the
|
|
139
|
+
landmark list.
|
|
140
|
+
*/}
|
|
141
|
+
{/* biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics and form-reset behaviour onto an accordion panel that contains prose. */}
|
|
142
|
+
<div
|
|
143
|
+
aria-labelledby={id}
|
|
144
|
+
className="px-4 pt-1 pb-4 text-content-secondary text-sm"
|
|
145
|
+
// `hidden` rather than unmounting, matching `Tabs`. A panel
|
|
146
|
+
// that unmounts loses a scroll position, a partially typed
|
|
147
|
+
// form, and any subscription its content opened — and the
|
|
148
|
+
// fleet's log panes are exactly that.
|
|
149
|
+
hidden={!isExpanded}
|
|
150
|
+
id={panelId}
|
|
151
|
+
// `group`, not `region`. A `group` is explicitly "a set of
|
|
152
|
+
// UI objects **not** intended to be included in a page
|
|
153
|
+
// summary", so it carries `aria-labelledby` and the
|
|
154
|
+
// trigger↔panel relationship without joining the landmark
|
|
155
|
+
// list — which is exactly the distinction the APG's caveat
|
|
156
|
+
// is reaching for and does not have a role for.
|
|
157
|
+
role="group"
|
|
158
|
+
>
|
|
159
|
+
{item.content}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as FieldStories from "./Field.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={FieldStories} />
|
|
6
|
+
|
|
7
|
+
# Field
|
|
8
|
+
|
|
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
|
+
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.
|
|
27
|
+
|
|
28
|
+
<Canvas of={FieldStories.Default} />
|
|
29
|
+
|
|
30
|
+
## Props
|
|
31
|
+
|
|
32
|
+
<Controls />
|
|
33
|
+
|
|
34
|
+
## The control is a slot
|
|
35
|
+
|
|
36
|
+
Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`, a
|
|
37
|
+
`<textarea>`, a `Select`, or an app's own control all go in the same hole, and the `id`,
|
|
38
|
+
`aria-describedby`, `aria-invalid` and `required` are injected onto whatever arrives.
|
|
39
|
+
|
|
40
|
+
<Canvas of={FieldStories.AllVariants} />
|
|
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
|
+
|
|
98
|
+
## `createLinkedIds` is *not* here, and that is the finding
|
|
99
|
+
|
|
100
|
+
The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
|
|
101
|
+
did not fit `Tabs`: that kind exists for a **dynamic** pairing, where the registration
|
|
102
|
+
multiset is what stops an `aria-describedby` naming a node React has already removed.
|
|
103
|
+
|
|
104
|
+
A field renders its own description and its own error, so it knows *at render* which exist.
|
|
105
|
+
The ids come from one `useUniqueId` and a conditional join, and they cannot get out of step
|
|
106
|
+
at all.
|
|
107
|
+
|
|
108
|
+
That leaves `createLinkedIds` with **no consumer in this package across twenty-five
|
|
109
|
+
components**. It is the one state kind the component layer has never needed, and saying so
|
|
110
|
+
is more useful than manufacturing a use for it.
|
|
111
|
+
|
|
112
|
+
## One source of invalidity
|
|
113
|
+
|
|
114
|
+
There is no `isInvalid` prop. `error` being present *is* the invalidity:
|
|
115
|
+
|
|
116
|
+
| `error` | `aria-invalid` | `aria-describedby` |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| absent | not set | description only, if any |
|
|
119
|
+
| present | `"true"` | description then error |
|
|
120
|
+
|
|
121
|
+
Two props for one fact is how a control ends up `aria-invalid="true"` with nothing saying
|
|
122
|
+
why — which is what `RegexWithFlagsField` does in mux-magic today.
|
|
123
|
+
|
|
124
|
+
<Canvas of={FieldStories.AllStates} />
|
|
125
|
+
|
|
126
|
+
## The error is *described*, not announced
|
|
127
|
+
|
|
128
|
+
No `role="alert"` and no live region, deliberately. An error bound through
|
|
129
|
+
`aria-describedby` is read when the control takes focus — which is when the user can act on
|
|
130
|
+
it. A live region on the same text announces it a second time, from nowhere, while the caret
|
|
131
|
+
is somewhere else.
|
|
132
|
+
|
|
133
|
+
A form that fails on submit should say so **once, at the top**, with the `Alert` this
|
|
134
|
+
library already ships. Then the user tabs into the field and hears the specific reason.
|
|
135
|
+
`ToastRegion` draws the same line for the same reason.
|
|
136
|
+
|
|
137
|
+
## Order is behaviour
|
|
138
|
+
|
|
139
|
+
`aria-describedby` is read in sequence, so description comes before error:
|
|
140
|
+
|
|
141
|
+
> "Absolute paths only. That path is not writable."
|
|
142
|
+
|
|
143
|
+
reads correctly; its reverse does not. Nothing enforces this but the order of one array in
|
|
144
|
+
the component, which is why that array is not built from an object.
|