@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,74 @@
|
|
|
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. mux-magic's `FieldLabel` renders a `<label>` with **no `htmlFor`** —
|
|
11
|
+
which looks correct, reads correctly to a sighted user, and gives a screen reader an
|
|
12
|
+
unnamed textbox.
|
|
13
|
+
|
|
14
|
+
<Canvas of={FieldStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## The control is a slot
|
|
21
|
+
|
|
22
|
+
Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`, a
|
|
23
|
+
`<textarea>`, a `Select`, or an app's own control all go in the same hole, and the `id`,
|
|
24
|
+
`aria-describedby`, `aria-invalid` and `required` are injected onto whatever arrives.
|
|
25
|
+
|
|
26
|
+
<Canvas of={FieldStories.AllVariants} />
|
|
27
|
+
|
|
28
|
+
## `createLinkedIds` is *not* here, and that is the finding
|
|
29
|
+
|
|
30
|
+
The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
|
|
31
|
+
did not fit `Tabs`: that kind exists for a **dynamic** pairing, where the registration
|
|
32
|
+
multiset is what stops an `aria-describedby` naming a node React has already removed.
|
|
33
|
+
|
|
34
|
+
A field renders its own description and its own error, so it knows *at render* which exist.
|
|
35
|
+
The ids come from one `useUniqueId` and a conditional join, and they cannot get out of step
|
|
36
|
+
at all.
|
|
37
|
+
|
|
38
|
+
That leaves `createLinkedIds` with **no consumer in this package across twenty-five
|
|
39
|
+
components**. It is the one state kind the component layer has never needed, and saying so
|
|
40
|
+
is more useful than manufacturing a use for it.
|
|
41
|
+
|
|
42
|
+
## One source of invalidity
|
|
43
|
+
|
|
44
|
+
There is no `isInvalid` prop. `error` being present *is* the invalidity:
|
|
45
|
+
|
|
46
|
+
| `error` | `aria-invalid` | `aria-describedby` |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| absent | not set | description only, if any |
|
|
49
|
+
| present | `"true"` | description then error |
|
|
50
|
+
|
|
51
|
+
Two props for one fact is how a control ends up `aria-invalid="true"` with nothing saying
|
|
52
|
+
why — which is what `RegexWithFlagsField` does in mux-magic today.
|
|
53
|
+
|
|
54
|
+
<Canvas of={FieldStories.AllStates} />
|
|
55
|
+
|
|
56
|
+
## The error is *described*, not announced
|
|
57
|
+
|
|
58
|
+
No `role="alert"` and no live region, deliberately. An error bound through
|
|
59
|
+
`aria-describedby` is read when the control takes focus — which is when the user can act on
|
|
60
|
+
it. A live region on the same text announces it a second time, from nowhere, while the caret
|
|
61
|
+
is somewhere else.
|
|
62
|
+
|
|
63
|
+
A form that fails on submit should say so **once, at the top**, with the `Alert` this
|
|
64
|
+
library already ships. Then the user tabs into the field and hears the specific reason.
|
|
65
|
+
`ToastRegion` draws the same line for the same reason.
|
|
66
|
+
|
|
67
|
+
## Order is behaviour
|
|
68
|
+
|
|
69
|
+
`aria-describedby` is read in sequence, so description comes before error:
|
|
70
|
+
|
|
71
|
+
> "Absolute paths only. That path is not writable."
|
|
72
|
+
|
|
73
|
+
reads correctly; its reverse does not. Nothing enforces this but the order of one array in
|
|
74
|
+
the component, which is why that array is not built from an object.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
StoryCell,
|
|
5
|
+
StoryGrid,
|
|
6
|
+
} from "../board.storyHelpers.tsx"
|
|
7
|
+
import { Select } from "../Select/Select.tsx"
|
|
8
|
+
import { Field } from "./Field.tsx"
|
|
9
|
+
|
|
10
|
+
const TEXT_INPUT_CLASS =
|
|
11
|
+
"w-full rounded-md border border-border-default bg-surface-raised px-3 py-2 text-content-primary text-sm focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-focus-ring"
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: "Components/Field",
|
|
15
|
+
component: Field,
|
|
16
|
+
parameters: { layout: "padded" },
|
|
17
|
+
args: {
|
|
18
|
+
isRequired: false,
|
|
19
|
+
label: "Output directory",
|
|
20
|
+
},
|
|
21
|
+
} satisfies Meta<typeof Field>
|
|
22
|
+
|
|
23
|
+
export default meta
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof meta>
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
children: (
|
|
30
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
31
|
+
),
|
|
32
|
+
description: "Where finished rips are moved.",
|
|
33
|
+
label: "Output directory",
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The control is a slot, not a hardcoded `<input>` — `Select` goes
|
|
39
|
+
* in the same hole, and so does an app's own control. The `id`,
|
|
40
|
+
* `aria-describedby`, `aria-invalid`, and `required` are cloned onto
|
|
41
|
+
* whatever is passed.
|
|
42
|
+
*/
|
|
43
|
+
export const AllVariants: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
children: (
|
|
46
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
47
|
+
),
|
|
48
|
+
label: "Output directory",
|
|
49
|
+
},
|
|
50
|
+
render: () => (
|
|
51
|
+
<StoryGrid columns={2}>
|
|
52
|
+
<StoryCell label="text input">
|
|
53
|
+
<Field label="Output directory">
|
|
54
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
55
|
+
</Field>
|
|
56
|
+
</StoryCell>
|
|
57
|
+
|
|
58
|
+
<StoryCell label="select">
|
|
59
|
+
<Field label="Rip profile">
|
|
60
|
+
<Select
|
|
61
|
+
options={[
|
|
62
|
+
{ label: "Lossless", value: "lossless" },
|
|
63
|
+
{ label: "Compressed", value: "compressed" },
|
|
64
|
+
]}
|
|
65
|
+
/>
|
|
66
|
+
</Field>
|
|
67
|
+
</StoryCell>
|
|
68
|
+
|
|
69
|
+
<StoryCell label="with description">
|
|
70
|
+
<Field
|
|
71
|
+
description="Where finished rips are moved."
|
|
72
|
+
label="Archive path"
|
|
73
|
+
>
|
|
74
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
75
|
+
</Field>
|
|
76
|
+
</StoryCell>
|
|
77
|
+
|
|
78
|
+
<StoryCell label="required">
|
|
79
|
+
<Field isRequired label="Disc label">
|
|
80
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
81
|
+
</Field>
|
|
82
|
+
</StoryCell>
|
|
83
|
+
</StoryGrid>
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `error` is the only source of invalidity — there is no separate
|
|
89
|
+
* `isInvalid`, because two sources for one fact is how a control
|
|
90
|
+
* ends up `aria-invalid="true"` with nothing saying why.
|
|
91
|
+
*
|
|
92
|
+
* With both a description and an error, `aria-describedby` names
|
|
93
|
+
* them in that order. A screen reader reads the list in sequence,
|
|
94
|
+
* and the two sentences are not interchangeable.
|
|
95
|
+
*/
|
|
96
|
+
export const AllStates: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
children: (
|
|
99
|
+
<input className={TEXT_INPUT_CLASS} type="text" />
|
|
100
|
+
),
|
|
101
|
+
label: "Output directory",
|
|
102
|
+
},
|
|
103
|
+
render: () => (
|
|
104
|
+
<StoryGrid columns={3}>
|
|
105
|
+
<StoryCell label="valid">
|
|
106
|
+
<Field label="Bay name">
|
|
107
|
+
<input
|
|
108
|
+
className={TEXT_INPUT_CLASS}
|
|
109
|
+
defaultValue="Bay 3"
|
|
110
|
+
type="text"
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
</StoryCell>
|
|
114
|
+
|
|
115
|
+
<StoryCell label="invalid">
|
|
116
|
+
<Field
|
|
117
|
+
error="That path is not writable."
|
|
118
|
+
label="Scratch path"
|
|
119
|
+
>
|
|
120
|
+
<input
|
|
121
|
+
className={TEXT_INPUT_CLASS}
|
|
122
|
+
defaultValue="/mnt/readonly"
|
|
123
|
+
type="text"
|
|
124
|
+
/>
|
|
125
|
+
</Field>
|
|
126
|
+
</StoryCell>
|
|
127
|
+
|
|
128
|
+
<StoryCell label="described and invalid">
|
|
129
|
+
<Field
|
|
130
|
+
description="Absolute paths only."
|
|
131
|
+
error="That path is not writable."
|
|
132
|
+
isRequired
|
|
133
|
+
label="Archive path"
|
|
134
|
+
>
|
|
135
|
+
<input
|
|
136
|
+
className={TEXT_INPUT_CLASS}
|
|
137
|
+
defaultValue="mnt/archive"
|
|
138
|
+
type="text"
|
|
139
|
+
/>
|
|
140
|
+
</Field>
|
|
141
|
+
</StoryCell>
|
|
142
|
+
</StoryGrid>
|
|
143
|
+
),
|
|
144
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect } 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 "./Field.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Default } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("the label names the control it points at", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
// The whole point of the component in one query: the control is
|
|
18
|
+
// findable by the *label's* text, which is only true if `htmlFor`
|
|
19
|
+
// and the control's `id` agree. mux-magic's `FieldLabel` renders
|
|
20
|
+
// a `<label>` with no `htmlFor` at all — it looks right, reads
|
|
21
|
+
// right to a sighted user, and gives this query nothing.
|
|
22
|
+
const control = expectAgentDrivable(canvas, {
|
|
23
|
+
name: "Output directory",
|
|
24
|
+
role: "textbox",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await expect(control).toHaveAttribute("id")
|
|
28
|
+
|
|
29
|
+
await expectNoAxeViolations(canvasElement)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("a description is announced with the control", async () => {
|
|
33
|
+
const { canvas } = await mountStory(Default)
|
|
34
|
+
|
|
35
|
+
const control = expectAgentDrivable(canvas, {
|
|
36
|
+
name: "Output directory",
|
|
37
|
+
role: "textbox",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const describedBy = control.getAttribute(
|
|
41
|
+
"aria-describedby",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
await expect(describedBy).not.toBeNull()
|
|
45
|
+
|
|
46
|
+
await expect(
|
|
47
|
+
document.getElementById(describedBy ?? ""),
|
|
48
|
+
).toHaveTextContent("Where finished rips are moved.")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("the slot takes a Select as readily as an input", async () => {
|
|
52
|
+
const { canvas, canvasElement } =
|
|
53
|
+
await mountStory(AllVariants)
|
|
54
|
+
|
|
55
|
+
// A native `<select>` reports `role="combobox"`, and it is named
|
|
56
|
+
// by the same cloned `id`. The slot does not know what it is
|
|
57
|
+
// holding, which is the contract.
|
|
58
|
+
expectAgentDrivable(canvas, {
|
|
59
|
+
name: "Rip profile",
|
|
60
|
+
role: "combobox",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await expectNoAxeViolations(canvasElement)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("`required` is spelled twice, on purpose", async () => {
|
|
67
|
+
const { canvas } = await mountStory(AllVariants)
|
|
68
|
+
|
|
69
|
+
const control = expectAgentDrivable(canvas, {
|
|
70
|
+
name: "Disc label",
|
|
71
|
+
role: "textbox",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// `required` is the constraint the browser validates;
|
|
75
|
+
// `aria-required` is the one it announces on a control the browser
|
|
76
|
+
// does not validate. They are not redundant, and a component that
|
|
77
|
+
// sets only the first is silent on a composite widget.
|
|
78
|
+
await expect(control).toBeRequired()
|
|
79
|
+
|
|
80
|
+
await expect(control).toHaveAttribute(
|
|
81
|
+
"aria-required",
|
|
82
|
+
"true",
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("an error is the only source of invalidity", async () => {
|
|
87
|
+
const { canvas, canvasElement } =
|
|
88
|
+
await mountStory(AllStates)
|
|
89
|
+
|
|
90
|
+
// Valid: nothing claims otherwise.
|
|
91
|
+
await expect(
|
|
92
|
+
expectAgentDrivable(canvas, {
|
|
93
|
+
name: "Bay name",
|
|
94
|
+
role: "textbox",
|
|
95
|
+
}),
|
|
96
|
+
).not.toHaveAttribute("aria-invalid")
|
|
97
|
+
|
|
98
|
+
const invalid = expectAgentDrivable(canvas, {
|
|
99
|
+
name: "Scratch path",
|
|
100
|
+
role: "textbox",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await expect(invalid).toHaveAttribute(
|
|
104
|
+
"aria-invalid",
|
|
105
|
+
"true",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
document.getElementById(
|
|
110
|
+
invalid.getAttribute("aria-describedby") ?? "",
|
|
111
|
+
),
|
|
112
|
+
).toHaveTextContent("That path is not writable.")
|
|
113
|
+
|
|
114
|
+
await expectNoAxeViolations(canvasElement)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Order inside `aria-describedby` is behaviour, not formatting. A
|
|
119
|
+
* screen reader reads the list in sequence, and "absolute paths only
|
|
120
|
+
* — that path is not writable" is a different sentence from its
|
|
121
|
+
* reverse. Nothing but the order of one array enforces it.
|
|
122
|
+
*/
|
|
123
|
+
test("description is described before error", async () => {
|
|
124
|
+
const { canvas } = await mountStory(AllStates)
|
|
125
|
+
|
|
126
|
+
const control = expectAgentDrivable(canvas, {
|
|
127
|
+
name: "Archive path",
|
|
128
|
+
role: "textbox",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const [descriptionId, errorId] = (
|
|
132
|
+
control.getAttribute("aria-describedby") ?? ""
|
|
133
|
+
).split(" ")
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
document.getElementById(descriptionId ?? ""),
|
|
137
|
+
).toHaveTextContent("Absolute paths only.")
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
document.getElementById(errorId ?? ""),
|
|
141
|
+
).toHaveTextContent("That path is not writable.")
|
|
142
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useClonedChild,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { ReactElement, ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
import { toClassName } from "../toClassName.ts"
|
|
8
|
+
|
|
9
|
+
export type FieldProps = {
|
|
10
|
+
/**
|
|
11
|
+
* The control. **Cloned, not wrapped** — the same slot contract
|
|
12
|
+
* as `Popover`'s trigger, so a `Field` can go around an `<input>`,
|
|
13
|
+
* a `<textarea>`, a `Select`, or an app's own control without
|
|
14
|
+
* changing that control's layout.
|
|
15
|
+
*/
|
|
16
|
+
children: ReactElement
|
|
17
|
+
className?: string
|
|
18
|
+
/**
|
|
19
|
+
* Standing help — units, a format, a warning about what the value
|
|
20
|
+
* does. Announced with the control, before any error.
|
|
21
|
+
*/
|
|
22
|
+
description?: ReactNode
|
|
23
|
+
/**
|
|
24
|
+
* Present means invalid. There is no separate `isInvalid`, because
|
|
25
|
+
* two sources for one fact is how a control ends up
|
|
26
|
+
* `aria-invalid="true"` with nothing saying why — which is what
|
|
27
|
+
* `RegexWithFlagsField` does in mux-magic today.
|
|
28
|
+
*/
|
|
29
|
+
error?: ReactNode
|
|
30
|
+
isRequired?: boolean
|
|
31
|
+
label: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
36
|
+
* and no two spell the wiring the same way. `FieldLabel` in
|
|
37
|
+
* mux-magic renders a `<label>` with no `htmlFor` at all — which
|
|
38
|
+
* looks correct, reads correctly to a sighted user, and gives a
|
|
39
|
+
* screen reader an unnamed textbox.
|
|
40
|
+
*
|
|
41
|
+
* ### The ids are static, and that is why `createLinkedIds` is not here
|
|
42
|
+
*
|
|
43
|
+
* The plan expected `useLinkedIds` to do this wiring. It does not
|
|
44
|
+
* fit, for the same reason it did not fit `Tabs`: that kind exists
|
|
45
|
+
* for a **dynamic** pairing, where the registration multiset is what
|
|
46
|
+
* stops an `aria-describedby` naming a node React has already
|
|
47
|
+
* removed. A field renders its own description and its own error, so
|
|
48
|
+
* it knows at render which of them exist — the ids come from one
|
|
49
|
+
* `useUniqueId` and a conditional join, and they cannot get out of
|
|
50
|
+
* step at all.
|
|
51
|
+
*
|
|
52
|
+
* That leaves `createLinkedIds` with **no consumer in this package
|
|
53
|
+
* across twenty-five components**, which is worth saying plainly
|
|
54
|
+
* rather than manufacturing a use for. It is the one state kind the
|
|
55
|
+
* component layer has never needed.
|
|
56
|
+
*
|
|
57
|
+
* ### Order matters inside `aria-describedby`
|
|
58
|
+
*
|
|
59
|
+
* Description first, error second. A screen reader reads the list in
|
|
60
|
+
* order, and "millisecondsonly — required" is a different sentence
|
|
61
|
+
* from "required — milliseconds only". Nothing enforces this but the
|
|
62
|
+
* order of the array below, which is why it is not built from an
|
|
63
|
+
* object.
|
|
64
|
+
*
|
|
65
|
+
* ### The error is described, not announced
|
|
66
|
+
*
|
|
67
|
+
* No `role="alert"` and no live region here, deliberately. An error
|
|
68
|
+
* bound through `aria-describedby` is read when the control takes
|
|
69
|
+
* focus, which is when the user can act on it; a live region on the
|
|
70
|
+
* same text announces it a second time, from nowhere, while the
|
|
71
|
+
* caret is elsewhere. A form that fails on submit should say so
|
|
72
|
+
* once, at the top, with the `Alert` this library already ships —
|
|
73
|
+
* and then the user tabs into the field and hears the specific
|
|
74
|
+
* reason.
|
|
75
|
+
*/
|
|
76
|
+
export const Field = ({
|
|
77
|
+
children,
|
|
78
|
+
className,
|
|
79
|
+
description,
|
|
80
|
+
error,
|
|
81
|
+
isRequired = false,
|
|
82
|
+
label,
|
|
83
|
+
}: FieldProps): ReactNode => {
|
|
84
|
+
const baseId = useUniqueId()
|
|
85
|
+
|
|
86
|
+
const controlId = `${baseId}-control`
|
|
87
|
+
|
|
88
|
+
const descriptionId = `${baseId}-description`
|
|
89
|
+
|
|
90
|
+
const errorId = `${baseId}-error`
|
|
91
|
+
|
|
92
|
+
const describedBy = [
|
|
93
|
+
description === undefined ? null : descriptionId,
|
|
94
|
+
error === undefined ? null : errorId,
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" ")
|
|
98
|
+
|
|
99
|
+
const clonedControl = useClonedChild(children, {
|
|
100
|
+
"aria-describedby":
|
|
101
|
+
describedBy === "" ? undefined : describedBy,
|
|
102
|
+
// `aria-invalid` is derived from the error's presence, never
|
|
103
|
+
// passed. A control marked invalid with no described reason is
|
|
104
|
+
// a screen reader saying "invalid" and stopping.
|
|
105
|
+
"aria-invalid": error === undefined ? undefined : true,
|
|
106
|
+
// Both, and they are not redundant: `required` is the
|
|
107
|
+
// constraint the browser validates and `aria-required` is the
|
|
108
|
+
// one it announces on a control the browser does not validate
|
|
109
|
+
// — a `role="combobox"`, or anything composite.
|
|
110
|
+
"aria-required": isRequired || undefined,
|
|
111
|
+
id: controlId,
|
|
112
|
+
required: isRequired || undefined,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={toClassName(
|
|
118
|
+
"flex flex-col gap-1.5",
|
|
119
|
+
className,
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<label
|
|
123
|
+
className="font-medium text-content-primary text-sm"
|
|
124
|
+
htmlFor={controlId}
|
|
125
|
+
>
|
|
126
|
+
{label}
|
|
127
|
+
|
|
128
|
+
{isRequired ? (
|
|
129
|
+
<span
|
|
130
|
+
// Decoration beside a real `aria-required`. An asterisk
|
|
131
|
+
// announced as "asterisk" is noise; announced as
|
|
132
|
+
// nothing, with the control already saying "required",
|
|
133
|
+
// is correct.
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
className="ms-1 text-intent-danger-content"
|
|
136
|
+
>
|
|
137
|
+
*
|
|
138
|
+
</span>
|
|
139
|
+
) : null}
|
|
140
|
+
</label>
|
|
141
|
+
|
|
142
|
+
{clonedControl}
|
|
143
|
+
|
|
144
|
+
{description === undefined ? null : (
|
|
145
|
+
<p
|
|
146
|
+
className="text-content-secondary text-xs"
|
|
147
|
+
id={descriptionId}
|
|
148
|
+
>
|
|
149
|
+
{description}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{error === undefined ? null : (
|
|
154
|
+
<p
|
|
155
|
+
className="text-intent-danger-content text-xs"
|
|
156
|
+
id={errorId}
|
|
157
|
+
>
|
|
158
|
+
{error}
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as FileDropZoneStories from "./FileDropZone.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={FileDropZoneStories} />
|
|
6
|
+
|
|
7
|
+
# FileDropZone
|
|
8
|
+
|
|
9
|
+
The one drag target in the fleet is gallery-downloader's, and it **gets the hard part
|
|
10
|
+
right** — credit where it is due, and the fix is reproduced here.
|
|
11
|
+
|
|
12
|
+
<Canvas of={FileDropZoneStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## Dragging is pointer-only, so the zone is a label around an input
|
|
19
|
+
|
|
20
|
+
There is **no keyboard gesture for drag-and-drop.** WCAG 2.5.7 (AA since 2.2) requires a
|
|
21
|
+
single-pointer alternative for any dragging movement, and the honest one has existed since
|
|
22
|
+
1995: a real `<input type="file">`.
|
|
23
|
+
|
|
24
|
+
So this component is a `<label>` wrapping a visually-hidden input:
|
|
25
|
+
|
|
26
|
+
- the whole zone is the label's click target
|
|
27
|
+
- Tab reaches the input; Space and Enter open the picker
|
|
28
|
+
- the drop handlers are an **enhancement** on a control that already works without them
|
|
29
|
+
|
|
30
|
+
That ordering is the entire design. Building the drop zone first and bolting on an "or
|
|
31
|
+
browse" link is how the fleet's version ended up with an `alert()` as its error channel and
|
|
32
|
+
no keyboard path at all.
|
|
33
|
+
|
|
34
|
+
`sr-only` rather than `display: none` — the clip-rect technique keeps the element focusable,
|
|
35
|
+
which is the whole point of it being there.
|
|
36
|
+
|
|
37
|
+
<Canvas of={FileDropZoneStories.AllVariants} />
|
|
38
|
+
|
|
39
|
+
## The `dragDepth` counter, kept
|
|
40
|
+
|
|
41
|
+
`dragenter` and `dragleave` fire once per **element** the pointer crosses, not once per
|
|
42
|
+
zone. A plain boolean turns the highlight off halfway across the zone and it strobes:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
let dragDepth = 0
|
|
46
|
+
|
|
47
|
+
function handlePageDragEnter() { dragDepth++; if (dragDepth === 1) …on() }
|
|
48
|
+
function handlePageDragLeave() { dragDepth--; if (dragDepth === 0) …off() }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That is gallery-downloader's, and it is correct. It lives in a ref here rather than in state,
|
|
52
|
+
because nothing renders from the depth itself — only from whether it is above zero — and a
|
|
53
|
+
re-render per crossed child during a drag is exactly the jank this avoids.
|
|
54
|
+
|
|
55
|
+
## `dragover` must be prevented, and the reason is a footgun
|
|
56
|
+
|
|
57
|
+
A browser's default for `dragover` is **reject the drop**. Not preventing it means `drop`
|
|
58
|
+
never fires — nothing errors, the cursor shows a "no entry" sign, and the zone looks like it
|
|
59
|
+
has a broken handler when it in fact has no handler running at all.
|
|
60
|
+
|
|
61
|
+
## It reports text as well as files
|
|
62
|
+
|
|
63
|
+
Dragging a **link** gives you `text/plain` and an empty `files` list. A zone that only reads
|
|
64
|
+
`.files` silently ignores the single most common thing a user drags into a downloader — and
|
|
65
|
+
gallery-downloader's page is built entirely around `dataTransfer.getData("text")`, never
|
|
66
|
+
looking at `.files` at all.
|
|
67
|
+
|
|
68
|
+
`onDropText` is here rather than being someone's follow-up for exactly that reason: the one
|
|
69
|
+
consumer this component has wants the text path, not the file path.
|
|
70
|
+
|
|
71
|
+
<Canvas of={FileDropZoneStories.AllStates} />
|
|
72
|
+
|
|
73
|
+
## The input is cleared after every pick
|
|
74
|
+
|
|
75
|
+
A file input keeps its value, so choosing the *same* file twice is not a change and the
|
|
76
|
+
handler never runs. It reads as "it ignored me", and it is the oldest bug in this control.
|
|
77
|
+
|
|
78
|
+
<Canvas of={FileDropZoneStories.Interactive} />
|