@charcuterie/ui 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useClonedChild,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { ReactElement, ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
import type { SlotProps } from "../slotProps.ts"
|
|
8
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
9
|
+
import { toClassName } from "../toClassName.ts"
|
|
10
|
+
|
|
11
|
+
export type FieldProps = SlotProps & {
|
|
12
|
+
/**
|
|
13
|
+
* The control. **Cloned, not wrapped** — the same slot contract
|
|
14
|
+
* as `Popover`'s trigger, so a `Field` can go around an `<input>`,
|
|
15
|
+
* a `<textarea>`, a `Select`, or an app's own control without
|
|
16
|
+
* changing that control's layout.
|
|
17
|
+
*/
|
|
18
|
+
children: ReactElement
|
|
19
|
+
className?: string
|
|
20
|
+
/**
|
|
21
|
+
* Standing help — units, a format, a warning about what the value
|
|
22
|
+
* does. Announced with the control, before any error.
|
|
23
|
+
*/
|
|
24
|
+
description?: ReactNode
|
|
25
|
+
/**
|
|
26
|
+
* Present means invalid. There is no separate `isInvalid`, because
|
|
27
|
+
* two sources for one fact is how a control ends up
|
|
28
|
+
* `aria-invalid="true"` with nothing saying why — which is what
|
|
29
|
+
* `RegexWithFlagsField` does in mux-magic today.
|
|
30
|
+
*/
|
|
31
|
+
error?: ReactNode
|
|
32
|
+
isRequired?: boolean
|
|
33
|
+
label: ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sixteen files in the fleet spell a label, a control, and a hint,
|
|
38
|
+
* and no two spell the wiring the same way.
|
|
39
|
+
*
|
|
40
|
+
* ### What mux-magic's `FieldLabel` actually gets wrong
|
|
41
|
+
*
|
|
42
|
+
* An earlier version of this docstring said it "renders a `<label>`
|
|
43
|
+
* with no `htmlFor` at all". **That is false** — it renders
|
|
44
|
+
* ``htmlFor={`${stepId}-${field.name}`}``, and has since it was
|
|
45
|
+
* written. The defect is at the other end of the pair: **8 of its 16
|
|
46
|
+
* call sites never render that id on anything**. Measured on
|
|
47
|
+
* `mux-magic@master`, the eight that do are `BooleanField`,
|
|
48
|
+
* `ChapterSplitsField`, `EnumField`, `NumberField`,
|
|
49
|
+
* `NumberWithLookupField`, `PathField`, `StringArrayField` and
|
|
50
|
+
* `StringField`; the other eight either render no id at all or render
|
|
51
|
+
* a different one — `RegexWithFlagsField` mints its own `patternId` /
|
|
52
|
+
* `flagsId` / `sampleId`, and `SubtitleRulesField` spells
|
|
53
|
+
* ``${step.command}-hasDefaultRules`` where the label says
|
|
54
|
+
* ``${step.id}-…``.
|
|
55
|
+
*
|
|
56
|
+
* The outcome is the same unnamed textbox, by a worse route: a
|
|
57
|
+
* `<label>` that points at an id nowhere in the document reads as
|
|
58
|
+
* correct in the label's own file, and is only wrong in the *other*
|
|
59
|
+
* file. Half of them are broken and nothing says which half.
|
|
60
|
+
*
|
|
61
|
+
* That is the argument for a component rather than a convention: here
|
|
62
|
+
* the id is minted and consumed in one place and cannot be spelled
|
|
63
|
+
* twice.
|
|
64
|
+
*
|
|
65
|
+
* ### The ids are static, and that is why `createLinkedIds` is not here
|
|
66
|
+
*
|
|
67
|
+
* The plan expected `useLinkedIds` to do this wiring. It does not
|
|
68
|
+
* fit, for the same reason it did not fit `Tabs`: that kind exists
|
|
69
|
+
* for a **dynamic** pairing, where the registration multiset is what
|
|
70
|
+
* stops an `aria-describedby` naming a node React has already
|
|
71
|
+
* removed. A field renders its own description and its own error, so
|
|
72
|
+
* it knows at render which of them exist — the ids come from one
|
|
73
|
+
* `useUniqueId` and a conditional join, and they cannot get out of
|
|
74
|
+
* step at all.
|
|
75
|
+
*
|
|
76
|
+
* That leaves `createLinkedIds` with **no consumer in this package
|
|
77
|
+
* across twenty-five components**, which is worth saying plainly
|
|
78
|
+
* rather than manufacturing a use for. It is the one state kind the
|
|
79
|
+
* component layer has never needed.
|
|
80
|
+
*
|
|
81
|
+
* ### Order matters inside `aria-describedby`
|
|
82
|
+
*
|
|
83
|
+
* Description first, error second. A screen reader reads the list in
|
|
84
|
+
* order, and "millisecondsonly — required" is a different sentence
|
|
85
|
+
* from "required — milliseconds only". Nothing enforces this but the
|
|
86
|
+
* order of the array below, which is why it is not built from an
|
|
87
|
+
* object.
|
|
88
|
+
*
|
|
89
|
+
* ### The error is described, not announced
|
|
90
|
+
*
|
|
91
|
+
* No `role="alert"` and no live region here, deliberately. An error
|
|
92
|
+
* bound through `aria-describedby` is read when the control takes
|
|
93
|
+
* focus, which is when the user can act on it; a live region on the
|
|
94
|
+
* same text announces it a second time, from nowhere, while the
|
|
95
|
+
* caret is elsewhere. A form that fails on submit should say so
|
|
96
|
+
* once, at the top, with the `Alert` this library already ships —
|
|
97
|
+
* and then the user tabs into the field and hears the specific
|
|
98
|
+
* reason.
|
|
99
|
+
*
|
|
100
|
+
* ### A slot is a pass-through
|
|
101
|
+
*
|
|
102
|
+
* A `Tooltip` between the field and its control is the obvious thing
|
|
103
|
+
* to write, and it is what mux-magic's `FieldLabel` does. Both
|
|
104
|
+
* components clone onto their one child, so everything below used to
|
|
105
|
+
* land on the `Tooltip` **component** and be dropped in silence —
|
|
106
|
+
* leaving the `<label htmlFor>` above pointing at nothing, which is
|
|
107
|
+
* precisely the defect this component was built to make impossible.
|
|
108
|
+
* `SlotProps` arriving from an outer slot is forwarded down, and
|
|
109
|
+
* `aria-describedby` is merged rather than replaced.
|
|
110
|
+
* `slotProps.ts` has the reasoning.
|
|
111
|
+
*/
|
|
112
|
+
export const Field = ({
|
|
113
|
+
children,
|
|
114
|
+
className,
|
|
115
|
+
description,
|
|
116
|
+
error,
|
|
117
|
+
isRequired = false,
|
|
118
|
+
label,
|
|
119
|
+
...receivedSlotProps
|
|
120
|
+
}: FieldProps): ReactNode => {
|
|
121
|
+
const baseId = useUniqueId()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A caller's own `id` wins, which is what makes a `Field` usable
|
|
125
|
+
* where the id has to be predictable — an autofill hint, a deep
|
|
126
|
+
* link, a server-rendered error summary linking to the control.
|
|
127
|
+
* `useUniqueId` is still called: hooks are unconditional, and it
|
|
128
|
+
* costs one counter read.
|
|
129
|
+
*/
|
|
130
|
+
const controlId =
|
|
131
|
+
receivedSlotProps.id ?? `${baseId}-control`
|
|
132
|
+
|
|
133
|
+
const descriptionId = `${baseId}-description`
|
|
134
|
+
|
|
135
|
+
const errorId = `${baseId}-error`
|
|
136
|
+
|
|
137
|
+
const describedBy = [
|
|
138
|
+
description === undefined ? null : descriptionId,
|
|
139
|
+
error === undefined ? null : errorId,
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(" ")
|
|
143
|
+
|
|
144
|
+
const clonedControl = useClonedChild(
|
|
145
|
+
children,
|
|
146
|
+
mergeSlotProps(receivedSlotProps, {
|
|
147
|
+
"aria-describedby":
|
|
148
|
+
describedBy === "" ? undefined : describedBy,
|
|
149
|
+
// `aria-invalid` is derived from the error's presence, never
|
|
150
|
+
// passed. A control marked invalid with no described reason is
|
|
151
|
+
// a screen reader saying "invalid" and stopping.
|
|
152
|
+
"aria-invalid":
|
|
153
|
+
error === undefined ? undefined : true,
|
|
154
|
+
// Both, and they are not redundant: `required` is the
|
|
155
|
+
// constraint the browser validates and `aria-required` is the
|
|
156
|
+
// one it announces on a control the browser does not validate
|
|
157
|
+
// — a `role="combobox"`, or anything composite.
|
|
158
|
+
"aria-required": isRequired || undefined,
|
|
159
|
+
id: controlId,
|
|
160
|
+
required: isRequired || undefined,
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
className={toClassName(
|
|
167
|
+
"flex flex-col gap-1.5",
|
|
168
|
+
className,
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<label
|
|
172
|
+
className="font-medium text-content-primary text-sm"
|
|
173
|
+
htmlFor={controlId}
|
|
174
|
+
>
|
|
175
|
+
{label}
|
|
176
|
+
|
|
177
|
+
{isRequired ? (
|
|
178
|
+
<span
|
|
179
|
+
// Decoration beside a real `aria-required`. An asterisk
|
|
180
|
+
// announced as "asterisk" is noise; announced as
|
|
181
|
+
// nothing, with the control already saying "required",
|
|
182
|
+
// is correct.
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
className="ms-1 text-intent-danger-content"
|
|
185
|
+
>
|
|
186
|
+
*
|
|
187
|
+
</span>
|
|
188
|
+
) : null}
|
|
189
|
+
</label>
|
|
190
|
+
|
|
191
|
+
{clonedControl}
|
|
192
|
+
|
|
193
|
+
{description === undefined ? null : (
|
|
194
|
+
<p
|
|
195
|
+
className="text-content-secondary text-xs"
|
|
196
|
+
id={descriptionId}
|
|
197
|
+
>
|
|
198
|
+
{description}
|
|
199
|
+
</p>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{error === undefined ? null : (
|
|
203
|
+
<p
|
|
204
|
+
className="text-intent-danger-content text-xs"
|
|
205
|
+
id={errorId}
|
|
206
|
+
>
|
|
207
|
+
{error}
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
|
|
6
|
+
export type FieldGroupProps = {
|
|
7
|
+
/**
|
|
8
|
+
* The controls. **Rendered as-is, not cloned** — that is the whole
|
|
9
|
+
* difference from `Field`, and it is why this is a separate
|
|
10
|
+
* component rather than a boolean on that one.
|
|
11
|
+
*/
|
|
12
|
+
children: ReactNode
|
|
13
|
+
className?: string
|
|
14
|
+
/** Standing help for the group as a whole. */
|
|
15
|
+
description?: ReactNode
|
|
16
|
+
/** Present means the group is invalid. */
|
|
17
|
+
error?: ReactNode
|
|
18
|
+
isRequired?: boolean
|
|
19
|
+
label: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A label over **several** controls.
|
|
24
|
+
*
|
|
25
|
+
* `Field` mints one id and clones it onto one child, which is the
|
|
26
|
+
* right shape for a text input and the wrong shape the moment there
|
|
27
|
+
* are two of them: an `id` can only name one element, and a
|
|
28
|
+
* `<label htmlFor>` can only point at one. Six of mux-magic's sixteen
|
|
29
|
+
* field components are in that position — `RegexWithFlagsField`
|
|
30
|
+
* (pattern, flags, sample, and a checkbox), `NumberWithLookupField`
|
|
31
|
+
* (three inputs), `LanguageCodeField`, `LanguageCodesField`,
|
|
32
|
+
* `SubtitleTypesField` (a tag input beside a dropdown) and
|
|
33
|
+
* `RenameRegexField` (a row per rule) — and every one of them renders
|
|
34
|
+
* a `FieldLabel` whose `htmlFor` names, at best, one of them.
|
|
35
|
+
*
|
|
36
|
+
* ### `<fieldset>` and `<legend>`, not a `<label>`
|
|
37
|
+
*
|
|
38
|
+
* This is the one place in the library where `<fieldset>` is right.
|
|
39
|
+
* `AccordionSection` rejected it for its panel and said why — it is a
|
|
40
|
+
* *form-control grouping*, and dragging `<legend>` semantics onto
|
|
41
|
+
* prose is wrong. Here the content **is** a form-control grouping,
|
|
42
|
+
* so the element means what it says, `<legend>` names the group
|
|
43
|
+
* natively, and no `role="group"` and no `aria-labelledby` are
|
|
44
|
+
* needed.
|
|
45
|
+
*
|
|
46
|
+
* A `<label>` here would be the mux-magic defect deliberately: a
|
|
47
|
+
* label pointing at one control, or at none.
|
|
48
|
+
*
|
|
49
|
+
* ### What a group cannot carry
|
|
50
|
+
*
|
|
51
|
+
* `aria-invalid` has no group form — it belongs on the control that
|
|
52
|
+
* is actually invalid, and this component does not know which one
|
|
53
|
+
* that is. So `error` here is *described*, not *asserted*: it joins
|
|
54
|
+
* `aria-describedby` on the `<fieldset>`, which screen readers read
|
|
55
|
+
* as focus enters the group. A control inside that is itself invalid
|
|
56
|
+
* should say so itself, in its own `Field`.
|
|
57
|
+
*
|
|
58
|
+
* That is a real limitation and it is stated rather than papered
|
|
59
|
+
* over — the alternative was to clone `aria-invalid` onto every
|
|
60
|
+
* child, which marks the valid ones invalid.
|
|
61
|
+
*/
|
|
62
|
+
export const FieldGroup = ({
|
|
63
|
+
children,
|
|
64
|
+
className,
|
|
65
|
+
description,
|
|
66
|
+
error,
|
|
67
|
+
isRequired = false,
|
|
68
|
+
label,
|
|
69
|
+
}: FieldGroupProps): ReactNode => {
|
|
70
|
+
const baseId = useUniqueId()
|
|
71
|
+
|
|
72
|
+
const descriptionId = `${baseId}-description`
|
|
73
|
+
|
|
74
|
+
const errorId = `${baseId}-error`
|
|
75
|
+
|
|
76
|
+
// Description first, error second, for the reason `Field` gives:
|
|
77
|
+
// a screen reader reads the list in order and the two sentences
|
|
78
|
+
// are not interchangeable.
|
|
79
|
+
const describedBy = [
|
|
80
|
+
description === undefined ? null : descriptionId,
|
|
81
|
+
error === undefined ? null : errorId,
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join(" ")
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<fieldset
|
|
88
|
+
aria-describedby={
|
|
89
|
+
describedBy === "" ? undefined : describedBy
|
|
90
|
+
}
|
|
91
|
+
className={toClassName(
|
|
92
|
+
// A `<fieldset>` arrives with a border, an inline margin, a
|
|
93
|
+
// block padding, and `min-inline-size: min-content` — the
|
|
94
|
+
// last of which makes it refuse to shrink inside a flex or
|
|
95
|
+
// grid parent, which is the reason design systems avoid the
|
|
96
|
+
// element. All four are cleared here, once.
|
|
97
|
+
"m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<legend className="mb-1.5 p-0 font-medium text-content-primary text-sm">
|
|
102
|
+
{label}
|
|
103
|
+
|
|
104
|
+
{isRequired ? (
|
|
105
|
+
<span
|
|
106
|
+
// Decoration, as in `Field`. An asterisk announced as
|
|
107
|
+
// "asterisk" is noise; the controls inside carry their
|
|
108
|
+
// own `required`.
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
className="ms-1 text-intent-danger-content"
|
|
111
|
+
>
|
|
112
|
+
*
|
|
113
|
+
</span>
|
|
114
|
+
) : null}
|
|
115
|
+
</legend>
|
|
116
|
+
|
|
117
|
+
{children}
|
|
118
|
+
|
|
119
|
+
{description === undefined ? null : (
|
|
120
|
+
<p
|
|
121
|
+
className="text-content-secondary text-xs"
|
|
122
|
+
id={descriptionId}
|
|
123
|
+
>
|
|
124
|
+
{description}
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{error === undefined ? null : (
|
|
129
|
+
<p
|
|
130
|
+
className="text-intent-danger-content text-xs"
|
|
131
|
+
id={errorId}
|
|
132
|
+
>
|
|
133
|
+
{error}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</fieldset>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -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} />
|
|
@@ -0,0 +1,136 @@
|
|
|
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 { FileDropZone } from "./FileDropZone.tsx"
|
|
9
|
+
|
|
10
|
+
const noop = () => undefined
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Components/FileDropZone",
|
|
14
|
+
component: FileDropZone,
|
|
15
|
+
parameters: { layout: "padded" },
|
|
16
|
+
args: {
|
|
17
|
+
isDisabled: false,
|
|
18
|
+
isMultiple: false,
|
|
19
|
+
label: "Drop a disc image here",
|
|
20
|
+
onDropFiles: noop,
|
|
21
|
+
},
|
|
22
|
+
} satisfies Meta<typeof FileDropZone>
|
|
23
|
+
|
|
24
|
+
export default meta
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof meta>
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
description: "ISO or MKV, up to 50 GB.",
|
|
31
|
+
label: "Drop a disc image here",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const AllVariants: Story = {
|
|
36
|
+
args: { label: "Drop a disc image here" },
|
|
37
|
+
render: () => (
|
|
38
|
+
<StoryGrid columns={2}>
|
|
39
|
+
<StoryCell align="stretch" label="single file">
|
|
40
|
+
<FileDropZone
|
|
41
|
+
description="ISO or MKV, up to 50 GB."
|
|
42
|
+
label="Drop a disc image"
|
|
43
|
+
onDropFiles={noop}
|
|
44
|
+
/>
|
|
45
|
+
</StoryCell>
|
|
46
|
+
|
|
47
|
+
<StoryCell align="stretch" label="multiple">
|
|
48
|
+
<FileDropZone
|
|
49
|
+
description="Several at once."
|
|
50
|
+
isMultiple
|
|
51
|
+
label="Drop subtitle files"
|
|
52
|
+
onDropFiles={noop}
|
|
53
|
+
/>
|
|
54
|
+
</StoryCell>
|
|
55
|
+
|
|
56
|
+
<StoryCell align="stretch" label="accepting a type">
|
|
57
|
+
<FileDropZone
|
|
58
|
+
accept=".srt,.ass"
|
|
59
|
+
description="Subtitles only."
|
|
60
|
+
label="Drop a subtitle track"
|
|
61
|
+
onDropFiles={noop}
|
|
62
|
+
/>
|
|
63
|
+
</StoryCell>
|
|
64
|
+
|
|
65
|
+
<StoryCell align="stretch" label="no description">
|
|
66
|
+
<FileDropZone
|
|
67
|
+
label="Drop anything"
|
|
68
|
+
onDropFiles={noop}
|
|
69
|
+
/>
|
|
70
|
+
</StoryCell>
|
|
71
|
+
</StoryGrid>
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const AllStates: Story = {
|
|
76
|
+
args: { label: "Drop a disc image here" },
|
|
77
|
+
render: () => (
|
|
78
|
+
<StoryGrid columns={2}>
|
|
79
|
+
<StoryCell align="stretch" label="idle">
|
|
80
|
+
<FileDropZone
|
|
81
|
+
label="Drop an idle image"
|
|
82
|
+
onDropFiles={noop}
|
|
83
|
+
/>
|
|
84
|
+
</StoryCell>
|
|
85
|
+
|
|
86
|
+
<StoryCell align="stretch" label="disabled">
|
|
87
|
+
<FileDropZone
|
|
88
|
+
description="A rip is already running."
|
|
89
|
+
isDisabled
|
|
90
|
+
label="Drop a disabled image"
|
|
91
|
+
onDropFiles={noop}
|
|
92
|
+
/>
|
|
93
|
+
</StoryCell>
|
|
94
|
+
</StoryGrid>
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Tab to it and press Enter — the file picker opens, which is the
|
|
100
|
+
* whole point: **there is no keyboard gesture for drag-and-drop**,
|
|
101
|
+
* so the zone is a `<label>` around a real `<input type="file">` and
|
|
102
|
+
* the drop handlers are an enhancement on top of a control that
|
|
103
|
+
* already works without them.
|
|
104
|
+
*
|
|
105
|
+
* Dragging a *link* reports through `onDropText`, because a browser
|
|
106
|
+
* hands over a dragged URL as `text/plain` with no files at all —
|
|
107
|
+
* which is what gallery-downloader's page is built entirely around.
|
|
108
|
+
*/
|
|
109
|
+
export const Interactive: Story = {
|
|
110
|
+
args: { label: "Drop a disc image here" },
|
|
111
|
+
render: function DropTarget() {
|
|
112
|
+
const [dropped, setDropped] = useState<string[]>([])
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex flex-col gap-3">
|
|
116
|
+
<FileDropZone
|
|
117
|
+
description="Files, or a dragged link."
|
|
118
|
+
isMultiple
|
|
119
|
+
label="Drop files or a link"
|
|
120
|
+
onDropFiles={(files) => {
|
|
121
|
+
setDropped(files.map((file) => file.name))
|
|
122
|
+
}}
|
|
123
|
+
onDropText={(text) => {
|
|
124
|
+
setDropped([text])
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
<ul className="list-none p-0 text-content-secondary text-sm">
|
|
129
|
+
{dropped.map((one) => (
|
|
130
|
+
<li key={one}>{one}</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
}
|