@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,95 @@
|
|
|
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 { MenuItem } from "./Menu.tsx"
|
|
7
|
+
|
|
8
|
+
export type MenuActionProps = {
|
|
9
|
+
item: MenuItem
|
|
10
|
+
onDismiss: () => void
|
|
11
|
+
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
12
|
+
register: (value: string) => () => void
|
|
13
|
+
tabIndex: number
|
|
14
|
+
trackElement: (
|
|
15
|
+
key: string,
|
|
16
|
+
element: HTMLButtonElement | null,
|
|
17
|
+
) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Its own file because **registration is an effect**, and an effect
|
|
22
|
+
* cannot run in a loop — the same reason `TabTrigger` and
|
|
23
|
+
* `AccordionSection` are separate. Any member of a registering group
|
|
24
|
+
* ends up shaped like this.
|
|
25
|
+
*
|
|
26
|
+
* A disabled item is simply not registered, so the arrow keys skip
|
|
27
|
+
* it without any command in `RovingFocus` knowing what "disabled"
|
|
28
|
+
* means. It stays in the DOM and stays announced, which is the
|
|
29
|
+
* difference between "you cannot do this right now" and "this does
|
|
30
|
+
* not exist".
|
|
31
|
+
*/
|
|
32
|
+
export const MenuAction = ({
|
|
33
|
+
item,
|
|
34
|
+
onDismiss,
|
|
35
|
+
register,
|
|
36
|
+
tabIndex,
|
|
37
|
+
trackElement,
|
|
38
|
+
}: MenuActionProps): ReactNode => {
|
|
39
|
+
const {
|
|
40
|
+
icon,
|
|
41
|
+
isDisabled = false,
|
|
42
|
+
key,
|
|
43
|
+
label,
|
|
44
|
+
onSelect,
|
|
45
|
+
} = item
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (isDisabled) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return register(key)
|
|
53
|
+
}, [isDisabled, key, register])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
className={toClassName(
|
|
58
|
+
"flex w-full cursor-pointer items-center gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
|
|
59
|
+
"hover:bg-intent-neutral-surface",
|
|
60
|
+
isDisabled &&
|
|
61
|
+
"cursor-not-allowed text-content-disabled hover:bg-transparent",
|
|
62
|
+
FOCUS_RING_CLASS,
|
|
63
|
+
)}
|
|
64
|
+
disabled={isDisabled}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
onSelect()
|
|
67
|
+
|
|
68
|
+
// Choosing dismisses. A menu that stays open after an action
|
|
69
|
+
// has fired is the one interaction users read as "it didn't
|
|
70
|
+
// work", and every one of the fleet's menus closes here
|
|
71
|
+
// today by unmounting its whole parent.
|
|
72
|
+
onDismiss()
|
|
73
|
+
}}
|
|
74
|
+
ref={(element) => {
|
|
75
|
+
trackElement(key, element)
|
|
76
|
+
}}
|
|
77
|
+
role="menuitem"
|
|
78
|
+
tabIndex={tabIndex}
|
|
79
|
+
type="button"
|
|
80
|
+
>
|
|
81
|
+
{icon === undefined ? null : (
|
|
82
|
+
<span
|
|
83
|
+
// Decoration beside the item's own text. An icon that
|
|
84
|
+
// announces itself turns "Delete" into "trash Delete".
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
className="shrink-0 text-content-secondary"
|
|
87
|
+
>
|
|
88
|
+
{icon}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{label}
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SelectStories from "./Select.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SelectStories} />
|
|
6
|
+
|
|
7
|
+
# Select
|
|
8
|
+
|
|
9
|
+
Eighteen sites in the fleet, and they split cleanly in two: **twelve native `<select>`s**
|
|
10
|
+
that differ only in their Tailwind classes, and **six hand-rolled `role="listbox"`
|
|
11
|
+
controls**. This component is the first twelve. The other six are a different pattern —
|
|
12
|
+
see below.
|
|
13
|
+
|
|
14
|
+
<Canvas of={SelectStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## There is no state kind here, and that is the finding
|
|
21
|
+
|
|
22
|
+
Every other interactive component in this package owns its state through
|
|
23
|
+
`@charcuterie/logic`. This one owns **none**, because the platform already does — a
|
|
24
|
+
`<select>` holds the chosen option in the DOM, and putting a `useSinglePicker` beside it
|
|
25
|
+
would create the exact conflict the state layer exists to prevent:
|
|
26
|
+
|
|
27
|
+
> Two things believing they hold `value`.
|
|
28
|
+
|
|
29
|
+
That is the Radix argument from `Popover`, pointed at ourselves. So `value` seeds
|
|
30
|
+
`defaultValue` and changes are reported outward.
|
|
31
|
+
|
|
32
|
+
The consequence worth knowing: **`Select` is uncontrolled and `Tabs` is not**, and a reader
|
|
33
|
+
should be able to tell why without asking. A tab bar's selection has no DOM representation,
|
|
34
|
+
so something has to hold it. A select's does.
|
|
35
|
+
|
|
36
|
+
<Canvas of={SelectStories.AllVariants} />
|
|
37
|
+
|
|
38
|
+
## Native, and what that rules out
|
|
39
|
+
|
|
40
|
+
| Native gives you | A hand-rolled listbox rebuilds |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| Type-ahead | a keystroke buffer and a timer |
|
|
43
|
+
| Home / End / PageUp / PageDown | four more key handlers |
|
|
44
|
+
| The mobile wheel picker | nothing that feels native |
|
|
45
|
+
| Form submission and `:invalid` | a hidden input |
|
|
46
|
+
| Autofill | — |
|
|
47
|
+
|
|
48
|
+
What it cannot do is render a rich option — an icon, two lines, a badge — or filter.
|
|
49
|
+
mux-magic has six controls that need one of those (`CommandPicker`, `PathPicker`,
|
|
50
|
+
`LinkPicker`, `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and **every one of them
|
|
51
|
+
is a `Combobox`**: a text input that filters a listbox. Different pattern, different ARIA
|
|
52
|
+
contract, P2 in the plan.
|
|
53
|
+
|
|
54
|
+
Building a bare `Listbox` here that none of the six could adopt would have shipped a
|
|
55
|
+
component with no caller — the mistake this milestone was explicitly checking for.
|
|
56
|
+
|
|
57
|
+
## The placeholder is a disabled option
|
|
58
|
+
|
|
59
|
+
Not an empty-valued one. An enabled `<option value="">` looks identical, is selectable, and
|
|
60
|
+
silently posts `""`. Disabled, it reads as a prompt and can never be submitted.
|
|
61
|
+
|
|
62
|
+
<Canvas of={SelectStories.AllStates} />
|
|
63
|
+
|
|
64
|
+
## `appearance-none` takes the arrow away
|
|
65
|
+
|
|
66
|
+
Removing the UA's own chevron and forgetting to put one back leaves a control that looks
|
|
67
|
+
exactly like a text input. The replacement is `aria-hidden` decoration in the same grid
|
|
68
|
+
cell, with `pe-9` reserving room for it.
|
|
69
|
+
|
|
70
|
+
## Naming, once
|
|
71
|
+
|
|
72
|
+
Either `label` (which becomes `aria-label`) **or** a surrounding `Field` — never both. Two
|
|
73
|
+
naming mechanisms firing at once means the accessible name is whichever won, which is not a
|
|
74
|
+
thing a reader can predict.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
} from "../board.storyHelpers.tsx"
|
|
8
|
+
import { Field } from "../Field/Field.tsx"
|
|
9
|
+
import type { SelectItem } from "./Select.tsx"
|
|
10
|
+
import { Select } from "./Select.tsx"
|
|
11
|
+
|
|
12
|
+
const PROFILES: SelectItem[] = [
|
|
13
|
+
{ label: "Lossless remux", value: "lossless" },
|
|
14
|
+
{ label: "Compressed 1080p", value: "compressed" },
|
|
15
|
+
{ isDisabled: true, label: "Dolby Vision", value: "dv" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const GROUPED: SelectItem[] = [
|
|
19
|
+
{
|
|
20
|
+
label: "Video",
|
|
21
|
+
options: [
|
|
22
|
+
{ label: "Remux", value: "remux" },
|
|
23
|
+
{ label: "Transcode", value: "transcode" },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "Audio",
|
|
28
|
+
options: [
|
|
29
|
+
{ label: "Passthrough", value: "passthrough" },
|
|
30
|
+
{ label: "Downmix", value: "downmix" },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const meta = {
|
|
36
|
+
title: "Components/Select",
|
|
37
|
+
component: Select,
|
|
38
|
+
parameters: { layout: "padded" },
|
|
39
|
+
argTypes: { size: controlSizeArgType },
|
|
40
|
+
args: {
|
|
41
|
+
isDisabled: false,
|
|
42
|
+
options: PROFILES,
|
|
43
|
+
size: "md",
|
|
44
|
+
},
|
|
45
|
+
} satisfies Meta<typeof Select>
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
|
|
49
|
+
type Story = StoryObj<typeof meta>
|
|
50
|
+
|
|
51
|
+
export const Default: Story = {
|
|
52
|
+
args: { label: "Rip profile", options: PROFILES },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const AllVariants: Story = {
|
|
56
|
+
args: { label: "Rip profile", options: PROFILES },
|
|
57
|
+
render: () => (
|
|
58
|
+
<StoryGrid columns={2}>
|
|
59
|
+
<StoryCell label="sm">
|
|
60
|
+
<Select
|
|
61
|
+
label="Profile sm"
|
|
62
|
+
options={PROFILES}
|
|
63
|
+
size="sm"
|
|
64
|
+
/>
|
|
65
|
+
</StoryCell>
|
|
66
|
+
|
|
67
|
+
<StoryCell label="lg">
|
|
68
|
+
<Select
|
|
69
|
+
label="Profile lg"
|
|
70
|
+
options={PROFILES}
|
|
71
|
+
size="lg"
|
|
72
|
+
/>
|
|
73
|
+
</StoryCell>
|
|
74
|
+
|
|
75
|
+
<StoryCell label="grouped">
|
|
76
|
+
<Select label="Stream kind" options={GROUPED} />
|
|
77
|
+
</StoryCell>
|
|
78
|
+
|
|
79
|
+
<StoryCell label="placeholder">
|
|
80
|
+
<Select
|
|
81
|
+
label="Profile with prompt"
|
|
82
|
+
options={PROFILES}
|
|
83
|
+
placeholder="Choose a profile…"
|
|
84
|
+
/>
|
|
85
|
+
</StoryCell>
|
|
86
|
+
</StoryGrid>
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The disabled option is still announced and still unselectable —
|
|
92
|
+
* "you cannot do this right now", rather than the option not
|
|
93
|
+
* existing. A `placeholder` is the same trick at the top of the
|
|
94
|
+
* list: disabled, so it reads as a prompt and can never be
|
|
95
|
+
* submitted.
|
|
96
|
+
*/
|
|
97
|
+
export const AllStates: Story = {
|
|
98
|
+
args: { label: "Rip profile", options: PROFILES },
|
|
99
|
+
render: () => (
|
|
100
|
+
<StoryGrid columns={3}>
|
|
101
|
+
<StoryCell label="default">
|
|
102
|
+
<Select
|
|
103
|
+
label="Profile default"
|
|
104
|
+
options={PROFILES}
|
|
105
|
+
/>
|
|
106
|
+
</StoryCell>
|
|
107
|
+
|
|
108
|
+
<StoryCell label="disabled">
|
|
109
|
+
<Select
|
|
110
|
+
isDisabled
|
|
111
|
+
label="Profile disabled"
|
|
112
|
+
options={PROFILES}
|
|
113
|
+
/>
|
|
114
|
+
</StoryCell>
|
|
115
|
+
|
|
116
|
+
<StoryCell label="in a Field">
|
|
117
|
+
<Field
|
|
118
|
+
description="Applied to every title on the disc."
|
|
119
|
+
label="Profile in a field"
|
|
120
|
+
>
|
|
121
|
+
<Select options={PROFILES} />
|
|
122
|
+
</Field>
|
|
123
|
+
</StoryCell>
|
|
124
|
+
</StoryGrid>
|
|
125
|
+
),
|
|
126
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent } 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 "./Select.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Default } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("a select is named, and therefore findable", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
// Twelve of the fleet's fourteen native selects have neither a
|
|
18
|
+
// `<label for>` nor an `aria-label`, so this query returns nothing
|
|
19
|
+
// for any of them. A `<select>` reports `role="combobox"`.
|
|
20
|
+
expectAgentDrivable(canvas, {
|
|
21
|
+
name: "Rip profile",
|
|
22
|
+
role: "combobox",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
await expectNoAxeViolations(canvasElement)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("choosing reports the value outward", async () => {
|
|
29
|
+
const { canvas } = await mountStory(Default)
|
|
30
|
+
|
|
31
|
+
const select = expectAgentDrivable(canvas, {
|
|
32
|
+
name: "Rip profile",
|
|
33
|
+
role: "combobox",
|
|
34
|
+
}) as HTMLSelectElement
|
|
35
|
+
|
|
36
|
+
await userEvent.selectOptions(select, "compressed")
|
|
37
|
+
|
|
38
|
+
await expect(select.value).toBe("compressed")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The platform owns the value, and this component owns none. Every
|
|
43
|
+
* other interactive component here holds its state in
|
|
44
|
+
* `@charcuterie/logic`; putting a `useSinglePicker` beside a
|
|
45
|
+
* `<select>` would be two things believing they hold one fact —
|
|
46
|
+
* the Radix argument from `Popover`, pointed at ourselves.
|
|
47
|
+
*/
|
|
48
|
+
test("the initial value seeds the DOM and nothing more", async () => {
|
|
49
|
+
const { canvas } = await mountStory(AllVariants)
|
|
50
|
+
|
|
51
|
+
const select = expectAgentDrivable(canvas, {
|
|
52
|
+
name: "Profile with prompt",
|
|
53
|
+
role: "combobox",
|
|
54
|
+
}) as HTMLSelectElement
|
|
55
|
+
|
|
56
|
+
// A placeholder is a *disabled* option, so it shows and cannot be
|
|
57
|
+
// submitted. An empty-valued enabled option would look identical
|
|
58
|
+
// and silently post "".
|
|
59
|
+
await expect(select.value).toBe("")
|
|
60
|
+
|
|
61
|
+
const [placeholder] = Array.from(select.options)
|
|
62
|
+
|
|
63
|
+
await expect(placeholder).toBeDisabled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("a disabled option stays announced", async () => {
|
|
67
|
+
const { canvas } = await mountStory(Default)
|
|
68
|
+
|
|
69
|
+
const select = expectAgentDrivable(canvas, {
|
|
70
|
+
name: "Rip profile",
|
|
71
|
+
role: "combobox",
|
|
72
|
+
}) as HTMLSelectElement
|
|
73
|
+
|
|
74
|
+
const dolby = Array.from(select.options).find(
|
|
75
|
+
(option) => option.value === "dv",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// In the tree, and unselectable. Removing it instead would tell a
|
|
79
|
+
// screen-reader user the profile does not exist rather than that
|
|
80
|
+
// it is unavailable.
|
|
81
|
+
await expect(dolby).toBeDisabled()
|
|
82
|
+
|
|
83
|
+
await expect(dolby).toHaveTextContent("Dolby Vision")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("option groups keep their group names", async () => {
|
|
87
|
+
const { canvas } = await mountStory(AllVariants)
|
|
88
|
+
|
|
89
|
+
const select = expectAgentDrivable(canvas, {
|
|
90
|
+
name: "Stream kind",
|
|
91
|
+
role: "combobox",
|
|
92
|
+
}) as HTMLSelectElement
|
|
93
|
+
|
|
94
|
+
const groups = Array.from(
|
|
95
|
+
select.querySelectorAll("optgroup"),
|
|
96
|
+
).map((group) => group.label)
|
|
97
|
+
|
|
98
|
+
await expect(groups).toEqual(["Video", "Audio"])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("a disabled select is disabled, not merely dimmed", async () => {
|
|
102
|
+
const { canvas, canvasElement } =
|
|
103
|
+
await mountStory(AllStates)
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
expectAgentDrivable(canvas, {
|
|
107
|
+
name: "Profile disabled",
|
|
108
|
+
role: "combobox",
|
|
109
|
+
}),
|
|
110
|
+
).toBeDisabled()
|
|
111
|
+
|
|
112
|
+
await expectNoAxeViolations(canvasElement)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("inside a Field, the label does the naming", async () => {
|
|
116
|
+
const { canvas } = await mountStory(AllStates)
|
|
117
|
+
|
|
118
|
+
// No `label` prop on this one — `Field` clones an `id` in and its
|
|
119
|
+
// `<label for>` picks it up. Two naming mechanisms that must not
|
|
120
|
+
// both fire, or the accessible name is the wrong one.
|
|
121
|
+
const select = expectAgentDrivable(canvas, {
|
|
122
|
+
name: "Profile in a field",
|
|
123
|
+
role: "combobox",
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await expect(select).not.toHaveAttribute("aria-label")
|
|
127
|
+
|
|
128
|
+
await expect(select).toHaveAttribute("aria-describedby")
|
|
129
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type {
|
|
3
|
+
ChangeEvent,
|
|
4
|
+
ComponentPropsWithRef,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CONTROL_SIZE_CLASS,
|
|
10
|
+
MIN_TOUCH_TARGET_CLASS,
|
|
11
|
+
} from "../controlStyles.ts"
|
|
12
|
+
import {
|
|
13
|
+
DISABLED_CLASS,
|
|
14
|
+
FOCUS_RING_CLASS,
|
|
15
|
+
} from "../intentStyles.ts"
|
|
16
|
+
import { toClassName } from "../toClassName.ts"
|
|
17
|
+
|
|
18
|
+
export type SelectOption = {
|
|
19
|
+
isDisabled?: boolean
|
|
20
|
+
label: string
|
|
21
|
+
value: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SelectOptionGroup = {
|
|
25
|
+
label: string
|
|
26
|
+
options: SelectOption[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SelectItem = SelectOption | SelectOptionGroup
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The HTML attributes are passed through, and that is not
|
|
33
|
+
* convenience — it is what makes the `Field` slot work at all.
|
|
34
|
+
*
|
|
35
|
+
* `Field` clones `id`, `aria-describedby`, `aria-invalid` and
|
|
36
|
+
* `required` onto whatever control it is given. A component with a
|
|
37
|
+
* closed prop list **silently drops all four**: the field renders,
|
|
38
|
+
* the label still points at nothing, and every assertion about the
|
|
39
|
+
* control's own props passes because they never arrived. Found by
|
|
40
|
+
* the one test that asked the control what it had been handed.
|
|
41
|
+
*
|
|
42
|
+
* Same arrangement as `Button`, for the same reason.
|
|
43
|
+
*/
|
|
44
|
+
export type SelectProps = Omit<
|
|
45
|
+
ComponentPropsWithRef<"select">,
|
|
46
|
+
"disabled" | "onChange" | "size" | "value"
|
|
47
|
+
> & {
|
|
48
|
+
className?: string
|
|
49
|
+
isDisabled?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* The accessible name, when this is **not** inside a `Field`. A
|
|
52
|
+
* `<select>` with neither a `<label for>` nor an `aria-label` is
|
|
53
|
+
* announced as "combobox" and is unfindable by
|
|
54
|
+
* `getByRole("combobox", { name })` — twelve of the fleet's
|
|
55
|
+
* fourteen native selects are in exactly that state.
|
|
56
|
+
*/
|
|
57
|
+
label?: string
|
|
58
|
+
onChange?: (value: string) => void
|
|
59
|
+
options: SelectItem[]
|
|
60
|
+
/**
|
|
61
|
+
* A disabled first entry, not a real option. Reads as a prompt to
|
|
62
|
+
* a sighted user and cannot be submitted, which is what a
|
|
63
|
+
* placeholder should mean — unlike an empty-valued option, which
|
|
64
|
+
* is selectable and silently posts "".
|
|
65
|
+
*/
|
|
66
|
+
placeholder?: string
|
|
67
|
+
size?: ControlSize
|
|
68
|
+
/** **Initial** only, like every other value prop in this library. */
|
|
69
|
+
value?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const getIsGroup = (
|
|
73
|
+
item: SelectItem,
|
|
74
|
+
): item is SelectOptionGroup => "options" in item
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A styled native `<select>`, and the fleet's fourteen hand-rolled
|
|
78
|
+
* ones collapse onto it.
|
|
79
|
+
*
|
|
80
|
+
* ### There is no state kind here, and that is the finding
|
|
81
|
+
*
|
|
82
|
+
* Every other interactive component in this package owns its state
|
|
83
|
+
* through `@charcuterie/logic`. This one owns none, because the
|
|
84
|
+
* platform already does: a `<select>` holds the chosen option in the
|
|
85
|
+
* DOM, and putting a `useSinglePicker` beside it would create the
|
|
86
|
+
* exact conflict the state layer exists to prevent — two things
|
|
87
|
+
* believing they hold `value`, which is the Radix argument from
|
|
88
|
+
* `Popover` pointed at ourselves. So `value` seeds `defaultValue`
|
|
89
|
+
* and changes are reported outward.
|
|
90
|
+
*
|
|
91
|
+
* The consequence worth knowing: **`Select` is uncontrolled and
|
|
92
|
+
* `Tabs` is not**, and a reader should be able to tell why without
|
|
93
|
+
* asking. A tab bar's selection has no DOM representation, so
|
|
94
|
+
* something has to hold it; a select's does.
|
|
95
|
+
*
|
|
96
|
+
* ### Native, and what that rules out
|
|
97
|
+
*
|
|
98
|
+
* A `<select>` cannot render a rich option — an icon, two lines, a
|
|
99
|
+
* badge — and cannot filter. mux-magic has six controls that need
|
|
100
|
+
* one of those (`CommandPicker`, `PathPicker`, `LinkPicker`,
|
|
101
|
+
* `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and every
|
|
102
|
+
* one of them is a **`Combobox`**: a text input that filters a
|
|
103
|
+
* listbox. That is a different pattern with a different ARIA
|
|
104
|
+
* contract, it is P2 in the plan, and building a bare `Listbox`
|
|
105
|
+
* here that none of the six could adopt would have shipped a
|
|
106
|
+
* component with no caller.
|
|
107
|
+
*
|
|
108
|
+
* What native buys, and what a hand-rolled listbox has to rebuild
|
|
109
|
+
* from nothing: type-ahead, Home/End, PageUp/PageDown, the mobile
|
|
110
|
+
* wheel picker, form submission, `:invalid`, and autofill.
|
|
111
|
+
*/
|
|
112
|
+
export const Select = ({
|
|
113
|
+
className,
|
|
114
|
+
isDisabled = false,
|
|
115
|
+
label,
|
|
116
|
+
onChange,
|
|
117
|
+
options,
|
|
118
|
+
placeholder,
|
|
119
|
+
size = "md",
|
|
120
|
+
value,
|
|
121
|
+
...selectProps
|
|
122
|
+
}: SelectProps): ReactNode => (
|
|
123
|
+
<div className="relative inline-grid w-full items-center">
|
|
124
|
+
<select
|
|
125
|
+
{...selectProps}
|
|
126
|
+
aria-label={label}
|
|
127
|
+
className={toClassName(
|
|
128
|
+
"w-full cursor-pointer appearance-none rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard",
|
|
129
|
+
// Room for the chevron below, which sits in the same grid
|
|
130
|
+
// cell. A `<select>`'s own arrow is what `appearance-none`
|
|
131
|
+
// removes, and forgetting to put one back leaves a control
|
|
132
|
+
// that looks exactly like a text input.
|
|
133
|
+
"pe-9",
|
|
134
|
+
CONTROL_SIZE_CLASS[size],
|
|
135
|
+
MIN_TOUCH_TARGET_CLASS,
|
|
136
|
+
"hover:border-border-strong",
|
|
137
|
+
FOCUS_RING_CLASS,
|
|
138
|
+
DISABLED_CLASS,
|
|
139
|
+
className,
|
|
140
|
+
)}
|
|
141
|
+
defaultValue={
|
|
142
|
+
value ??
|
|
143
|
+
(placeholder === undefined ? undefined : "")
|
|
144
|
+
}
|
|
145
|
+
disabled={isDisabled}
|
|
146
|
+
onChange={(
|
|
147
|
+
changeEvent: ChangeEvent<HTMLSelectElement>,
|
|
148
|
+
) => {
|
|
149
|
+
onChange?.(changeEvent.currentTarget.value)
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{placeholder === undefined ? null : (
|
|
153
|
+
<option disabled value="">
|
|
154
|
+
{placeholder}
|
|
155
|
+
</option>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{options.map((item) =>
|
|
159
|
+
getIsGroup(item) ? (
|
|
160
|
+
<optgroup key={item.label} label={item.label}>
|
|
161
|
+
{item.options.map((option) => (
|
|
162
|
+
<option
|
|
163
|
+
disabled={option.isDisabled}
|
|
164
|
+
key={option.value}
|
|
165
|
+
value={option.value}
|
|
166
|
+
>
|
|
167
|
+
{option.label}
|
|
168
|
+
</option>
|
|
169
|
+
))}
|
|
170
|
+
</optgroup>
|
|
171
|
+
) : (
|
|
172
|
+
<option
|
|
173
|
+
disabled={item.isDisabled}
|
|
174
|
+
key={item.value}
|
|
175
|
+
value={item.value}
|
|
176
|
+
>
|
|
177
|
+
{item.label}
|
|
178
|
+
</option>
|
|
179
|
+
),
|
|
180
|
+
)}
|
|
181
|
+
</select>
|
|
182
|
+
|
|
183
|
+
<svg
|
|
184
|
+
// Decoration. The control beside it already has a name, and an
|
|
185
|
+
// announced "chevron" is noise on every select in the fleet.
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
className="pointer-events-none absolute end-3 size-4 text-content-secondary"
|
|
188
|
+
fill="none"
|
|
189
|
+
focusable={false}
|
|
190
|
+
stroke="currentColor"
|
|
191
|
+
strokeLinecap="round"
|
|
192
|
+
strokeLinejoin="round"
|
|
193
|
+
strokeWidth={1.75}
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
196
|
+
>
|
|
197
|
+
<path d="m6 9 6 6 6-6" />
|
|
198
|
+
</svg>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SortableTableHeaderStories from "./SortableTableHeader.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SortableTableHeaderStories} />
|
|
6
|
+
|
|
7
|
+
# SortableTableHeader
|
|
8
|
+
|
|
9
|
+
**The header cell, and nothing else.** No `DataTable`, no rows, no comparator — the plan is
|
|
10
|
+
explicit about that, and the reason is that sorting *data* is app logic that differs per
|
|
11
|
+
column type, while announcing *that* a column is sorted is markup every table gets wrong the
|
|
12
|
+
same way.
|
|
13
|
+
|
|
14
|
+
<Canvas of={SortableTableHeaderStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## `aria-sort` does not exist anywhere in the fleet
|
|
21
|
+
|
|
22
|
+
Not in mux-magic, not in rip-deck, not in castkit. mux-magic's `FileExplorerModal` — the one
|
|
23
|
+
sortable table there is — renders the state as a character:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
{sortDirection === "asc" ? "▲" : "▼"}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A screen reader announces that as "black up-pointing triangle", if the font has it at all —
|
|
30
|
+
and this sandbox's headless Chromium does not, so the same glyph measures **blank** in a
|
|
31
|
+
screenshot. Sighted users see a direction; everyone else gets nothing.
|
|
32
|
+
|
|
33
|
+
No automated check anywhere reports it. **axe has no rule for a missing `aria-sort`**,
|
|
34
|
+
because a table with none is a table that is simply not sorted as far as the accessibility
|
|
35
|
+
tree knows.
|
|
36
|
+
|
|
37
|
+
This is therefore the one component in M6 whose payoff is not duplication — there is a
|
|
38
|
+
single site — but a class of failure invisible to every gate the fleet has.
|
|
39
|
+
|
|
40
|
+
## `none` is not the same as absent
|
|
41
|
+
|
|
42
|
+
| `aria-sort` | means |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| absent | this column **cannot** be sorted |
|
|
45
|
+
| `"none"` | sortable, not currently sorted |
|
|
46
|
+
| `"ascending"` / `"descending"` | this is the sorted column |
|
|
47
|
+
|
|
48
|
+
A table that omits the attribute on its unsorted columns tells a screen-reader user the other
|
|
49
|
+
four cannot be sorted at all. Exactly one column ever holds a direction.
|
|
50
|
+
|
|
51
|
+
<Canvas of={SortableTableHeaderStories.AllVariants} />
|
|
52
|
+
|
|
53
|
+
## The cell announces, the button acts
|
|
54
|
+
|
|
55
|
+
`aria-sort` belongs on the `<th>`; the click target is a `<button>` inside it. Putting the
|
|
56
|
+
role on the button instead is the common mistake and it breaks the grid semantics — a `<th>`
|
|
57
|
+
that *is* a button is no longer a column header to anything reading the table structure.
|
|
58
|
+
|
|
59
|
+
## The name stays the column's name
|
|
60
|
+
|
|
61
|
+
It is tempting to append the state — "Title, sorted ascending" — and that duplicates what
|
|
62
|
+
`aria-sort` already says, so the column is announced twice **and**
|
|
63
|
+
`getByRole("columnheader", { name: "Title" })` stops matching the moment somebody sorts it.
|
|
64
|
+
|
|
65
|
+
<Canvas of={SortableTableHeaderStories.AllStates} />
|
|
66
|
+
|
|
67
|
+
<Canvas of={SortableTableHeaderStories.Interactive} />
|