@charcuterie/ui 0.1.1 → 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 +1 -1
- 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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
} from "../board.storyHelpers.tsx"
|
|
8
|
+
import type { SortDirection } from "./SortableTableHeader.tsx"
|
|
9
|
+
import { SortableTableHeader } from "./SortableTableHeader.tsx"
|
|
10
|
+
|
|
11
|
+
const noop = () => undefined
|
|
12
|
+
|
|
13
|
+
const ROWS = [
|
|
14
|
+
{ size: "24.1 GB", title: "Blade Runner", year: 1982 },
|
|
15
|
+
{ size: "31.7 GB", title: "Alien", year: 1979 },
|
|
16
|
+
{ size: "18.2 GB", title: "Solaris", year: 1972 },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const TABLE_CLASS =
|
|
20
|
+
"w-full border-collapse text-content-primary text-sm"
|
|
21
|
+
|
|
22
|
+
const CELL_CLASS =
|
|
23
|
+
"border-border-subtle border-b px-3 py-2 text-start"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The component owns **only** the header cell, so every story has to
|
|
27
|
+
* bring its own table. That is the point rather than an
|
|
28
|
+
* inconvenience: sorting the data is app logic that differs per
|
|
29
|
+
* column type, and announcing *that* a column is sorted is markup
|
|
30
|
+
* every table gets wrong the same way.
|
|
31
|
+
*/
|
|
32
|
+
const meta = {
|
|
33
|
+
title: "Components/SortableTableHeader",
|
|
34
|
+
component: SortableTableHeader,
|
|
35
|
+
parameters: { layout: "padded" },
|
|
36
|
+
args: { onSort: noop },
|
|
37
|
+
} satisfies Meta<typeof SortableTableHeader>
|
|
38
|
+
|
|
39
|
+
export default meta
|
|
40
|
+
|
|
41
|
+
type Story = StoryObj<typeof meta>
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: { children: "Title", direction: "ascending" },
|
|
45
|
+
render: (args) => (
|
|
46
|
+
<table className={TABLE_CLASS}>
|
|
47
|
+
<thead>
|
|
48
|
+
<tr>
|
|
49
|
+
<SortableTableHeader {...args} />
|
|
50
|
+
|
|
51
|
+
<SortableTableHeader onSort={noop}>
|
|
52
|
+
Year
|
|
53
|
+
</SortableTableHeader>
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
|
|
57
|
+
<tbody>
|
|
58
|
+
{ROWS.map((row) => (
|
|
59
|
+
<tr key={row.title}>
|
|
60
|
+
<td className={CELL_CLASS}>{row.title}</td>
|
|
61
|
+
|
|
62
|
+
<td className={CELL_CLASS}>{row.year}</td>
|
|
63
|
+
</tr>
|
|
64
|
+
))}
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `aria-sort="none"` on the unsorted columns, not an omitted
|
|
72
|
+
* attribute. Absent means "this column is not sortable"; `none`
|
|
73
|
+
* means "sortable, not currently sorted" — and a table mixing the
|
|
74
|
+
* two tells a screen-reader user the other columns cannot be sorted
|
|
75
|
+
* at all.
|
|
76
|
+
*/
|
|
77
|
+
export const AllVariants: Story = {
|
|
78
|
+
args: { children: "Title" },
|
|
79
|
+
render: () => (
|
|
80
|
+
<StoryGrid columns={1}>
|
|
81
|
+
<StoryCell label="ascending · descending · none">
|
|
82
|
+
<table className={TABLE_CLASS}>
|
|
83
|
+
<thead>
|
|
84
|
+
<tr>
|
|
85
|
+
<SortableTableHeader
|
|
86
|
+
direction="ascending"
|
|
87
|
+
onSort={noop}
|
|
88
|
+
>
|
|
89
|
+
Title
|
|
90
|
+
</SortableTableHeader>
|
|
91
|
+
|
|
92
|
+
<SortableTableHeader
|
|
93
|
+
direction="descending"
|
|
94
|
+
onSort={noop}
|
|
95
|
+
>
|
|
96
|
+
Year
|
|
97
|
+
</SortableTableHeader>
|
|
98
|
+
|
|
99
|
+
<SortableTableHeader onSort={noop}>
|
|
100
|
+
Size
|
|
101
|
+
</SortableTableHeader>
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
|
|
105
|
+
<tbody>
|
|
106
|
+
{ROWS.map((row) => (
|
|
107
|
+
<tr key={row.title}>
|
|
108
|
+
<td className={CELL_CLASS}>{row.title}</td>
|
|
109
|
+
|
|
110
|
+
<td className={CELL_CLASS}>{row.year}</td>
|
|
111
|
+
|
|
112
|
+
<td className={CELL_CLASS}>{row.size}</td>
|
|
113
|
+
</tr>
|
|
114
|
+
))}
|
|
115
|
+
</tbody>
|
|
116
|
+
</table>
|
|
117
|
+
</StoryCell>
|
|
118
|
+
</StoryGrid>
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const AllStates: Story = {
|
|
123
|
+
args: { children: "Title", direction: "ascending" },
|
|
124
|
+
render: (args) => (
|
|
125
|
+
<table className={TABLE_CLASS}>
|
|
126
|
+
<thead>
|
|
127
|
+
<tr>
|
|
128
|
+
<SortableTableHeader {...args} />
|
|
129
|
+
</tr>
|
|
130
|
+
</thead>
|
|
131
|
+
|
|
132
|
+
<tbody>
|
|
133
|
+
<tr>
|
|
134
|
+
<td className={CELL_CLASS}>Blade Runner</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</tbody>
|
|
137
|
+
</table>
|
|
138
|
+
),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Click a header and watch `aria-sort` move — exactly one column
|
|
143
|
+
* ever holds a direction, which is the invariant a hand-rolled
|
|
144
|
+
* `▲`/`▼` cannot express at all.
|
|
145
|
+
*/
|
|
146
|
+
export const Interactive: Story = {
|
|
147
|
+
args: { children: "Title" },
|
|
148
|
+
render: function SortableTable() {
|
|
149
|
+
const [sort, setSort] = useState<{
|
|
150
|
+
column: "title" | "year"
|
|
151
|
+
direction: SortDirection
|
|
152
|
+
}>({ column: "title", direction: "ascending" })
|
|
153
|
+
|
|
154
|
+
const sorted = [...ROWS].sort((first, second) => {
|
|
155
|
+
const order =
|
|
156
|
+
sort.column === "title"
|
|
157
|
+
? first.title.localeCompare(second.title)
|
|
158
|
+
: first.year - second.year
|
|
159
|
+
|
|
160
|
+
return sort.direction === "ascending" ? order : -order
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<table className={TABLE_CLASS}>
|
|
165
|
+
<thead>
|
|
166
|
+
<tr>
|
|
167
|
+
<SortableTableHeader
|
|
168
|
+
direction={
|
|
169
|
+
sort.column === "title"
|
|
170
|
+
? sort.direction
|
|
171
|
+
: undefined
|
|
172
|
+
}
|
|
173
|
+
onSort={(direction) => {
|
|
174
|
+
setSort({ column: "title", direction })
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
Title
|
|
178
|
+
</SortableTableHeader>
|
|
179
|
+
|
|
180
|
+
<SortableTableHeader
|
|
181
|
+
direction={
|
|
182
|
+
sort.column === "year"
|
|
183
|
+
? sort.direction
|
|
184
|
+
: undefined
|
|
185
|
+
}
|
|
186
|
+
onSort={(direction) => {
|
|
187
|
+
setSort({ column: "year", direction })
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
Year
|
|
191
|
+
</SortableTableHeader>
|
|
192
|
+
</tr>
|
|
193
|
+
</thead>
|
|
194
|
+
|
|
195
|
+
<tbody>
|
|
196
|
+
{sorted.map((row) => (
|
|
197
|
+
<tr key={row.title}>
|
|
198
|
+
<td className={CELL_CLASS}>{row.title}</td>
|
|
199
|
+
|
|
200
|
+
<td className={CELL_CLASS}>{row.year}</td>
|
|
201
|
+
</tr>
|
|
202
|
+
))}
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
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 "./SortableTableHeader.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllVariants, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `aria-sort` exists **nowhere in the fleet** — not in mux-magic,
|
|
15
|
+
* not in rip-deck, not in castkit. The one sortable table renders
|
|
16
|
+
* the state as `{sortDirection === "asc" ? "▲" : "▼"}`, which a
|
|
17
|
+
* screen reader announces as "black up-pointing triangle" if the
|
|
18
|
+
* font has it at all, and this sandbox's headless Chromium does not.
|
|
19
|
+
*/
|
|
20
|
+
test("the cell announces the sort, not the glyph", async () => {
|
|
21
|
+
const { canvas, canvasElement } =
|
|
22
|
+
await mountStory(Default)
|
|
23
|
+
|
|
24
|
+
const title = expectAgentDrivable(canvas, {
|
|
25
|
+
name: "Title",
|
|
26
|
+
role: "columnheader",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
await expect(title).toHaveAttribute(
|
|
30
|
+
"aria-sort",
|
|
31
|
+
"ascending",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
await expectNoAxeViolations(canvasElement)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `none`, not an omitted attribute. Absent means "this column is not
|
|
39
|
+
* sortable"; `none` means "sortable, not currently sorted" — and a
|
|
40
|
+
* table mixing the two tells a screen-reader user the other columns
|
|
41
|
+
* cannot be sorted at all.
|
|
42
|
+
*/
|
|
43
|
+
test("an unsorted sortable column says `none`", async () => {
|
|
44
|
+
const { canvas, canvasElement } =
|
|
45
|
+
await mountStory(AllVariants)
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
expectAgentDrivable(canvas, {
|
|
49
|
+
name: "Size",
|
|
50
|
+
role: "columnheader",
|
|
51
|
+
}),
|
|
52
|
+
).toHaveAttribute("aria-sort", "none")
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
expectAgentDrivable(canvas, {
|
|
56
|
+
name: "Year",
|
|
57
|
+
role: "columnheader",
|
|
58
|
+
}),
|
|
59
|
+
).toHaveAttribute("aria-sort", "descending")
|
|
60
|
+
|
|
61
|
+
await expectNoAxeViolations(canvasElement)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The cell announces and the button acts. Putting the role on the
|
|
66
|
+
* button instead is the common mistake, and it breaks the grid
|
|
67
|
+
* semantics — a `<th>` that is a button is no longer a column header
|
|
68
|
+
* to anything reading the table's structure.
|
|
69
|
+
*/
|
|
70
|
+
test("the header is a columnheader containing a button", async () => {
|
|
71
|
+
const { canvas } = await mountStory(Default)
|
|
72
|
+
|
|
73
|
+
const header = expectAgentDrivable(canvas, {
|
|
74
|
+
name: "Title",
|
|
75
|
+
role: "columnheader",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
await expect(header.tagName).toBe("TH")
|
|
79
|
+
|
|
80
|
+
await expect(header).toHaveAttribute("scope", "col")
|
|
81
|
+
|
|
82
|
+
const button = expectAgentDrivable(canvas, {
|
|
83
|
+
name: "Title",
|
|
84
|
+
role: "button",
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await expect(header).toContainElement(button)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("clicking an unsorted column asks for ascending", async () => {
|
|
91
|
+
const { canvas } = await mountStory(Interactive)
|
|
92
|
+
|
|
93
|
+
await userEvent.click(
|
|
94
|
+
expectAgentDrivable(canvas, {
|
|
95
|
+
name: "Year",
|
|
96
|
+
role: "button",
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(
|
|
102
|
+
expectAgentDrivable(canvas, {
|
|
103
|
+
name: "Year",
|
|
104
|
+
role: "columnheader",
|
|
105
|
+
}),
|
|
106
|
+
).toHaveAttribute("aria-sort", "ascending")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Exactly one column ever holds a direction — the invariant a
|
|
110
|
+
// hand-rolled `▲`/`▼` cannot express at all.
|
|
111
|
+
await expect(
|
|
112
|
+
expectAgentDrivable(canvas, {
|
|
113
|
+
name: "Title",
|
|
114
|
+
role: "columnheader",
|
|
115
|
+
}),
|
|
116
|
+
).toHaveAttribute("aria-sort", "none")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("clicking the sorted column reverses it", async () => {
|
|
120
|
+
const { canvas } = await mountStory(Interactive)
|
|
121
|
+
|
|
122
|
+
const title = expectAgentDrivable(canvas, {
|
|
123
|
+
name: "Title",
|
|
124
|
+
role: "button",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await userEvent.click(title)
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(
|
|
131
|
+
expectAgentDrivable(canvas, {
|
|
132
|
+
name: "Title",
|
|
133
|
+
role: "columnheader",
|
|
134
|
+
}),
|
|
135
|
+
).toHaveAttribute("aria-sort", "descending")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await userEvent.click(title)
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(
|
|
142
|
+
expectAgentDrivable(canvas, {
|
|
143
|
+
name: "Title",
|
|
144
|
+
role: "columnheader",
|
|
145
|
+
}),
|
|
146
|
+
).toHaveAttribute("aria-sort", "ascending")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The name stays the column's name. Appending the state —
|
|
152
|
+
* "Title, sorted ascending" — duplicates what `aria-sort` already
|
|
153
|
+
* says *and* breaks `getByRole("columnheader", { name: "Title" })`
|
|
154
|
+
* the moment somebody sorts it.
|
|
155
|
+
*/
|
|
156
|
+
test("sorting does not rename the column", async () => {
|
|
157
|
+
const { canvas } = await mountStory(Interactive)
|
|
158
|
+
|
|
159
|
+
await userEvent.click(
|
|
160
|
+
expectAgentDrivable(canvas, {
|
|
161
|
+
name: "Title",
|
|
162
|
+
role: "button",
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(
|
|
168
|
+
expectAgentDrivable(canvas, {
|
|
169
|
+
name: "Title",
|
|
170
|
+
role: "columnheader",
|
|
171
|
+
}),
|
|
172
|
+
).toHaveAttribute("aria-sort", "descending")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Still exactly one match, and still under the same name.
|
|
176
|
+
expectAgentDrivable(canvas, {
|
|
177
|
+
name: "Title",
|
|
178
|
+
role: "columnheader",
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
|
|
6
|
+
export type SortDirection = "ascending" | "descending"
|
|
7
|
+
|
|
8
|
+
export type SortableTableHeaderProps = {
|
|
9
|
+
children: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
/**
|
|
12
|
+
* The direction **this column** is sorted in, or `undefined` when
|
|
13
|
+
* it is not the sorted one. Undefined is not "ascending by
|
|
14
|
+
* default": a table has exactly one sorted column and every other
|
|
15
|
+
* header has to say `aria-sort="none"`, which is what tells a
|
|
16
|
+
* screen reader which one is live.
|
|
17
|
+
*/
|
|
18
|
+
direction?: SortDirection
|
|
19
|
+
/** Called with the direction the header is asking to move to. */
|
|
20
|
+
onSort: (direction: SortDirection) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NEXT_DIRECTION: Record<SortDirection, SortDirection> =
|
|
24
|
+
{
|
|
25
|
+
ascending: "descending",
|
|
26
|
+
descending: "ascending",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* **The header cell, and nothing else.** No `DataTable`, no rows, no
|
|
31
|
+
* comparator — the plan is explicit about that, and the reason is
|
|
32
|
+
* that sorting *data* is app logic that differs per column type,
|
|
33
|
+
* while announcing *that* a column is sorted is markup every table
|
|
34
|
+
* gets wrong the same way.
|
|
35
|
+
*
|
|
36
|
+
* ### `aria-sort` does not exist anywhere in the fleet
|
|
37
|
+
*
|
|
38
|
+
* Not in mux-magic, not in rip-deck, not in castkit. mux-magic's
|
|
39
|
+
* `FileExplorerModal` — the one sortable table there is — renders
|
|
40
|
+
* the state as a character:
|
|
41
|
+
*
|
|
42
|
+
* ```tsx
|
|
43
|
+
* {sortDirection === "asc" ? "▲" : "▼"}
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* A screen reader announces that as "black up-pointing triangle", if
|
|
47
|
+
* the font has it at all, and the sandbox's headless Chromium does
|
|
48
|
+
* not have it — so the same glyph measures blank in a screenshot.
|
|
49
|
+
* Sighted users see a direction, everyone else gets nothing, and no
|
|
50
|
+
* automated check anywhere reports it: axe has no rule for a missing
|
|
51
|
+
* `aria-sort`, because a table with none is a table that is simply
|
|
52
|
+
* not sorted as far as the accessibility tree knows.
|
|
53
|
+
*
|
|
54
|
+
* This is therefore the one component in M6 whose payoff is not
|
|
55
|
+
* duplication — there is a single site — but a class of failure
|
|
56
|
+
* that is invisible to every gate the fleet has.
|
|
57
|
+
*
|
|
58
|
+
* ### The cell announces, the button acts
|
|
59
|
+
*
|
|
60
|
+
* `aria-sort` belongs on the `<th>`; the click target is a `<button>`
|
|
61
|
+
* inside it. Putting the role on the button instead is the common
|
|
62
|
+
* mistake and it breaks the grid semantics — a `<th>` that is a
|
|
63
|
+
* button is no longer a column header to anything reading the table
|
|
64
|
+
* structure.
|
|
65
|
+
*
|
|
66
|
+
* The name stays the column's name. It is tempting to append the
|
|
67
|
+
* state — "Name, sorted ascending" — and that duplicates what
|
|
68
|
+
* `aria-sort` already says, so the column is announced twice and
|
|
69
|
+
* `getByRole("columnheader", { name: "Name" })` stops matching the
|
|
70
|
+
* moment somebody sorts it.
|
|
71
|
+
*/
|
|
72
|
+
export const SortableTableHeader = ({
|
|
73
|
+
children,
|
|
74
|
+
className,
|
|
75
|
+
direction,
|
|
76
|
+
onSort,
|
|
77
|
+
}: SortableTableHeaderProps): ReactNode => (
|
|
78
|
+
<th
|
|
79
|
+
// `none`, not omitted, on the unsorted columns. An absent
|
|
80
|
+
// `aria-sort` means "this column is not sortable"; `none` means
|
|
81
|
+
// "sortable, not currently sorted", and a table of the first
|
|
82
|
+
// kind with one of the second is how a screen-reader user is
|
|
83
|
+
// told the other four columns cannot be sorted at all.
|
|
84
|
+
aria-sort={direction ?? "none"}
|
|
85
|
+
className={toClassName(
|
|
86
|
+
"border-border-subtle border-b p-0 text-start",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
scope="col"
|
|
90
|
+
>
|
|
91
|
+
<button
|
|
92
|
+
className={toClassName(
|
|
93
|
+
"flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-xs uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
|
|
94
|
+
"hover:text-content-primary",
|
|
95
|
+
FOCUS_RING_CLASS,
|
|
96
|
+
)}
|
|
97
|
+
onClick={() => {
|
|
98
|
+
onSort(
|
|
99
|
+
direction === undefined
|
|
100
|
+
? "ascending"
|
|
101
|
+
: NEXT_DIRECTION[direction],
|
|
102
|
+
)
|
|
103
|
+
}}
|
|
104
|
+
type="button"
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
|
|
108
|
+
<svg
|
|
109
|
+
// Decoration. `aria-sort` on the cell above is the same fact
|
|
110
|
+
// said in a way assistive technology can read, and the fleet
|
|
111
|
+
// ships only this half.
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
className={toClassName(
|
|
114
|
+
"size-3.5 shrink-0 transition-[transform,opacity] duration-(--duration-fast) ease-standard",
|
|
115
|
+
direction === undefined && "opacity-0",
|
|
116
|
+
direction === "descending" && "rotate-180",
|
|
117
|
+
)}
|
|
118
|
+
fill="none"
|
|
119
|
+
focusable={false}
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
strokeLinecap="round"
|
|
122
|
+
strokeLinejoin="round"
|
|
123
|
+
strokeWidth={2}
|
|
124
|
+
viewBox="0 0 24 24"
|
|
125
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
126
|
+
>
|
|
127
|
+
<path d="m6 15 6-6 6 6" />
|
|
128
|
+
</svg>
|
|
129
|
+
</button>
|
|
130
|
+
</th>
|
|
131
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ToastStories from "./Toast.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ToastStories} />
|
|
6
|
+
|
|
7
|
+
# Toast
|
|
8
|
+
|
|
9
|
+
The plan demoted this from P0 with the note *"its duplication evidence was almost entirely
|
|
10
|
+
those two"*, and the M6 survey confirmed it: **the React fleet has no toast at all.**
|
|
11
|
+
mux-magic's only hit is a comment about an undo-toast "rendered elsewhere"; the real ones
|
|
12
|
+
are in plex-channels' and darius-learns' vanilla JavaScript.
|
|
13
|
+
|
|
14
|
+
It ships anyway, on Kevin's call, and one thing here is genuinely new — the plan wrote a
|
|
15
|
+
machine down and M2 never built it:
|
|
16
|
+
|
|
17
|
+
> | Toast lifecycle | entering → visible → exiting → removed |
|
|
18
|
+
|
|
19
|
+
<Canvas of={ToastStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## Why a `Status`, when a boolean and a timeout would do
|
|
26
|
+
|
|
27
|
+
Because a boolean cannot express **why** the node is still mounted. A toast that is leaving
|
|
28
|
+
is still painted, still occupies its slot, and must not be announced again — and
|
|
29
|
+
`isVisible === false` says none of that.
|
|
30
|
+
|
|
31
|
+
| Transition | Legal | Why |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `entering → visible` | yes | after one frame, so the enter animation has something to animate *from* |
|
|
34
|
+
| `visible → exiting` | yes | the duration elapsed, or the user dismissed |
|
|
35
|
+
| `visible → removed` | **no** | skipping the exit is the flicker every hand-rolled stack has |
|
|
36
|
+
| `exiting → visible` | yes | hovering a toast that is sliding away has to bring it back |
|
|
37
|
+
| `removed → anything` | **no** | terminal, so `onDismiss` fires exactly once |
|
|
38
|
+
|
|
39
|
+
The last row is not hypothetical. Hover-to-pause plus a dismiss button means two independent
|
|
40
|
+
timers can be in flight, and the hand-rolled version reconciles them with a `clearTimeout`
|
|
41
|
+
and a ref. Here the second one **throws**.
|
|
42
|
+
|
|
43
|
+
<Canvas of={ToastStories.AllVariants} />
|
|
44
|
+
|
|
45
|
+
## One region, and it is polite
|
|
46
|
+
|
|
47
|
+
Not two. The usual arrangement — a polite region and an assertive one, so a danger toast
|
|
48
|
+
interrupts — was rejected because **the interrupting case does not belong in a toast at
|
|
49
|
+
all.** A message that must be heard *now* is one the user has to be able to go back and
|
|
50
|
+
read, and a toast is gone in five seconds. If it matters that much it is an `Alert` on the
|
|
51
|
+
page, which this library already ships and which stays put.
|
|
52
|
+
|
|
53
|
+
`Field` draws the same line for its error text. This library announces **once**, in the
|
|
54
|
+
place the user can act on it.
|
|
55
|
+
|
|
56
|
+
`role="status"` is `aria-live="polite"` *plus `aria-atomic="true"`* by default, and atomic is
|
|
57
|
+
wrong here — it re-reads the entire stack every time one arrives. `aria-atomic="false"` sits
|
|
58
|
+
beside it for exactly that reason.
|
|
59
|
+
|
|
60
|
+
<Canvas of={ToastStories.AllStates} />
|
|
61
|
+
|
|
62
|
+
## The stack does not own the toasts
|
|
63
|
+
|
|
64
|
+
`toasts` comes in and `onDismiss` goes out. There is no `toast()` function, no imperative
|
|
65
|
+
queue, and no context provider — an app already has a store (mux-magic has Jotai, rip-deck
|
|
66
|
+
has its own), and a component library that ships a second one is asking every consumer to
|
|
67
|
+
keep two lists of the same notifications in sync.
|
|
68
|
+
|
|
69
|
+
The lifecycle *inside* one toast is state a store cannot see, which is why `Toast` owns that
|
|
70
|
+
and nothing more.
|
|
71
|
+
|
|
72
|
+
## `title` is a string
|
|
73
|
+
|
|
74
|
+
Not a `ReactNode`. It is what the region announces and what names each toast's own dismiss
|
|
75
|
+
button — a stack of three all offering "Dismiss" is three matches for one query, which
|
|
76
|
+
`expectAgentDrivable` treats as a failure.
|
|
77
|
+
|
|
78
|
+
## `pointer-events-none` on the container
|
|
79
|
+
|
|
80
|
+
A fixed overlay across the bottom of the viewport swallows every click in that band,
|
|
81
|
+
including on the app behind it, and it does so invisibly. The container passes clicks through
|
|
82
|
+
and each toast takes them back.
|
|
83
|
+
|
|
84
|
+
<Canvas of={ToastStories.Interactive} />
|