@charcuterie/ui 0.1.0 → 0.2.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 +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.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 +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -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 +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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/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 +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -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 +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -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 +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
- package/CHANGELOG.md +0 -13
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
} from "../board.storyHelpers.tsx"
|
|
8
|
+
import type { AccordionItem } from "./Accordion.tsx"
|
|
9
|
+
import { Accordion } from "./Accordion.tsx"
|
|
10
|
+
|
|
11
|
+
const JOB_SECTIONS: AccordionItem[] = [
|
|
12
|
+
{
|
|
13
|
+
content: (
|
|
14
|
+
<p>Blade Runner (1982) · 9 titles · MakeMKV 1.17.8</p>
|
|
15
|
+
),
|
|
16
|
+
key: "disc",
|
|
17
|
+
label: "Disc",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
content: (
|
|
21
|
+
<p className="font-mono text-xs">
|
|
22
|
+
14:02:11 AACS handshake complete, reading…
|
|
23
|
+
</p>
|
|
24
|
+
),
|
|
25
|
+
key: "log",
|
|
26
|
+
label: "Log",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
content: (
|
|
30
|
+
<div className="flex gap-2">
|
|
31
|
+
<Badge intent="warning" size="sm">
|
|
32
|
+
1 retry
|
|
33
|
+
</Badge>
|
|
34
|
+
|
|
35
|
+
<Badge intent="info" size="sm">
|
|
36
|
+
AACS
|
|
37
|
+
</Badge>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
key: "flags",
|
|
41
|
+
label: "Flags",
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const STATE_SECTIONS: AccordionItem[] = [
|
|
46
|
+
...JOB_SECTIONS,
|
|
47
|
+
{
|
|
48
|
+
content: <p>Unreachable — no verdict yet.</p>,
|
|
49
|
+
isDisabled: true,
|
|
50
|
+
key: "verdict",
|
|
51
|
+
label: "Verdict",
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const meta = {
|
|
56
|
+
title: "Components/Accordion",
|
|
57
|
+
component: Accordion,
|
|
58
|
+
parameters: { layout: "padded" },
|
|
59
|
+
args: {
|
|
60
|
+
headingLevel: 3,
|
|
61
|
+
isMultiple: false,
|
|
62
|
+
items: JOB_SECTIONS,
|
|
63
|
+
},
|
|
64
|
+
} satisfies Meta<typeof Accordion>
|
|
65
|
+
|
|
66
|
+
export default meta
|
|
67
|
+
|
|
68
|
+
type Story = StoryObj<typeof meta>
|
|
69
|
+
|
|
70
|
+
export const Default: Story = {
|
|
71
|
+
args: { expandedKeys: ["disc"], items: JOB_SECTIONS },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Exclusive is `VisibilityGroup`; `isMultiple` is
|
|
76
|
+
* `MultiplePicker`. Both hooks are created and one is consulted,
|
|
77
|
+
* because hooks cannot be conditional — two empty stores and no DOM,
|
|
78
|
+
* which is cheaper than reimplementing exclusivity on top of the
|
|
79
|
+
* multiple kind.
|
|
80
|
+
*/
|
|
81
|
+
export const AllVariants: Story = {
|
|
82
|
+
args: { items: JOB_SECTIONS },
|
|
83
|
+
render: () => (
|
|
84
|
+
<StoryGrid columns={2}>
|
|
85
|
+
<StoryCell label="exclusive (default)">
|
|
86
|
+
<Accordion
|
|
87
|
+
expandedKeys={["disc"]}
|
|
88
|
+
items={JOB_SECTIONS}
|
|
89
|
+
/>
|
|
90
|
+
</StoryCell>
|
|
91
|
+
|
|
92
|
+
<StoryCell label="multiple">
|
|
93
|
+
<Accordion
|
|
94
|
+
expandedKeys={["disc", "flags"]}
|
|
95
|
+
isMultiple
|
|
96
|
+
items={JOB_SECTIONS}
|
|
97
|
+
/>
|
|
98
|
+
</StoryCell>
|
|
99
|
+
|
|
100
|
+
<StoryCell label="all collapsed">
|
|
101
|
+
<Accordion items={JOB_SECTIONS} />
|
|
102
|
+
</StoryCell>
|
|
103
|
+
|
|
104
|
+
{/*
|
|
105
|
+
Its own item set, with names that appear nowhere else on the
|
|
106
|
+
board. Four accordions sharing three labels means
|
|
107
|
+
`getByRole("heading", { name: "Log" })` matches four
|
|
108
|
+
elements, and `expectAgentDrivable` treats ambiguity as a
|
|
109
|
+
failure — correctly, since an agent would be picking one at
|
|
110
|
+
random.
|
|
111
|
+
*/}
|
|
112
|
+
<StoryCell label="heading level 2">
|
|
113
|
+
<Accordion
|
|
114
|
+
expandedKeys={["steps"]}
|
|
115
|
+
headingLevel={2}
|
|
116
|
+
items={[
|
|
117
|
+
{
|
|
118
|
+
content: <p>4 of 9 titles complete.</p>,
|
|
119
|
+
key: "steps",
|
|
120
|
+
label: "Steps",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
content: <p>No errors.</p>,
|
|
124
|
+
key: "errors",
|
|
125
|
+
label: "Errors",
|
|
126
|
+
},
|
|
127
|
+
]}
|
|
128
|
+
/>
|
|
129
|
+
</StoryCell>
|
|
130
|
+
</StoryGrid>
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A disabled section is **not a member of the group**. Registration
|
|
136
|
+
* is membership, so nothing can expand it — including a consumer
|
|
137
|
+
* passing its key in `expandedKeys`. That is the behaviour a
|
|
138
|
+
* `<summary>` cannot have at all, which is one of the two reasons
|
|
139
|
+
* this is not built on `<details>`.
|
|
140
|
+
*/
|
|
141
|
+
export const AllStates: Story = {
|
|
142
|
+
args: {
|
|
143
|
+
expandedKeys: ["verdict"],
|
|
144
|
+
items: STATE_SECTIONS,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
@@ -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
|
+
}
|