@uniflowed/ui 0.0.0-alpha.9 → 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/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -0
package/field.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// The hard part of a form field is not the markup, it is the wiring: the label
|
|
6
6
|
// has to point at the control, the description and the error message have to be
|
|
7
7
|
// named by `aria-describedby`, the control has to say `aria-invalid` when it is
|
|
8
|
-
// wrong
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// announces nothing.
|
|
8
|
+
// wrong and `aria-required` before the reader gets there, and every id has to be
|
|
9
|
+
// unique on the page and stable across renders. Doing that by hand is five
|
|
10
|
+
// attributes and two `useId` calls per field, and getting one wrong is silent —
|
|
11
|
+
// the field looks right and a screen reader announces nothing.
|
|
12
12
|
//
|
|
13
13
|
// So the parts read the ids off a context the root creates. `Field.Label` knows
|
|
14
14
|
// which control it labels because there is exactly one in its root, and
|
|
@@ -17,30 +17,142 @@
|
|
|
17
17
|
// document makes a screen reader announce nothing at all, which is worse than
|
|
18
18
|
// omitting the attribute.
|
|
19
19
|
//
|
|
20
|
+
// # One place computes the four attributes
|
|
21
|
+
//
|
|
22
|
+
// This is the whole of ubugeeei-prod/uf#297, and it was a bug that looked like
|
|
23
|
+
// working code. `@uniflowed/form`'s `register("email")` also produces
|
|
24
|
+
// `aria-invalid` and `aria-describedby`, from the form store's errors, and it is
|
|
25
|
+
// right to: a form library is what knows whether a field is wrong. Spread both
|
|
26
|
+
// onto one `<input>` and the *later spread wins*, so the field was described by
|
|
27
|
+
// the form's message or by `Field.Description`, depending on argument order,
|
|
28
|
+
// and never by both.
|
|
29
|
+
//
|
|
30
|
+
// The division that fixes it is the one each package can actually keep:
|
|
31
|
+
//
|
|
32
|
+
// * **`Field` owns the ids and the attributes**, because it owns the label and
|
|
33
|
+
// the description and is the only thing that can compose a token list out of
|
|
34
|
+
// them.
|
|
35
|
+
// * **The form owns the facts**: is this field invalid, is it required, what
|
|
36
|
+
// does the message say, and what does the control have to be bound with.
|
|
37
|
+
//
|
|
38
|
+
// `FieldSource` is that handover, and `@uniflowed/form`'s `useFieldSource` is
|
|
39
|
+
// what fills it. Notice what it does *not* contain: no `aria-*` at all. A source
|
|
40
|
+
// that handed over a finished `aria-describedby` would be the same collision
|
|
41
|
+
// with an extra step.
|
|
42
|
+
//
|
|
43
|
+
// # Why the form fills a prop rather than the field reading a context
|
|
44
|
+
//
|
|
45
|
+
// Because the dependency can only run one way. `@uniflowed/ui` is published to
|
|
46
|
+
// npm and `@uniflowed/form` is not — its name has never been bound
|
|
47
|
+
// (ubugeeei-prod/uf#210) — and `tools/ci/publishable.sh` refuses a published
|
|
48
|
+
// package that depends on an unpublished one, because `npm install` would answer
|
|
49
|
+
// `ETARGET` for a package that installs perfectly well from this workspace. So
|
|
50
|
+
// `Field.Root` cannot import a form context, cannot take a `name` and look it
|
|
51
|
+
// up, and cannot know that `@uniflowed/form` exists.
|
|
52
|
+
//
|
|
53
|
+
// What it can do is state the shape it accepts and let the package that *may*
|
|
54
|
+
// depend on it produce one. `field={useFieldSource(form, "email")}` is one hook
|
|
55
|
+
// call where the application used to write `invalid={errors.email != null}` by
|
|
56
|
+
// hand and then choose which of two `aria-describedby` values survived.
|
|
57
|
+
//
|
|
20
58
|
// # Why it takes a render function
|
|
21
59
|
//
|
|
22
60
|
// `Field.Control` hands the attributes to a callback rather than rendering an
|
|
23
61
|
// `<input>`, because a field wraps a select, a textarea, a `Combobox.Input` or
|
|
24
62
|
// somebody else's component just as often, and each of those needs the same
|
|
25
|
-
//
|
|
63
|
+
// attributes on whatever element it eventually renders. A component that
|
|
26
64
|
// rendered the input itself would have to grow a prop for every element anyone
|
|
27
65
|
// might want, and would still be wrong for the next one.
|
|
66
|
+
//
|
|
67
|
+
// # A group is not a label's `for`
|
|
68
|
+
//
|
|
69
|
+
// `<label for>` names *one* form control. A set of radios, a checkbox group and
|
|
70
|
+
// a date picker made of three selects have no single control to point at, and a
|
|
71
|
+
// `for` aimed at the wrapper around them points at something that is not a form
|
|
72
|
+
// control — which browsers ignore, silently. `Field.Root group` is the answer:
|
|
73
|
+
// the root becomes `role="group"` named by the label through `aria-labelledby`,
|
|
74
|
+
// the label stops being a `<label>`, and the description and the error describe
|
|
75
|
+
// the set rather than one member of it.
|
|
76
|
+
//
|
|
77
|
+
// # Why `Field.Error` is `role="alert"` and `combobox.js` says the opposite
|
|
78
|
+
//
|
|
79
|
+
// `combobox.js`'s header argues that a live region inserted *together with* its
|
|
80
|
+
// content is usually not announced, and that a status region has to be in the
|
|
81
|
+
// document before there is anything to say. Both are true, and this component
|
|
82
|
+
// does the opposite on purpose.
|
|
83
|
+
//
|
|
84
|
+
// The difference is politeness. `role="status"` is polite: a screen reader waits
|
|
85
|
+
// for a pause, by which time a region that appeared and filled in one commit has
|
|
86
|
+
// nothing left to distinguish it, and several engines never announce it at all.
|
|
87
|
+
// `role="alert"` is assertive, and assertive regions are announced on insertion
|
|
88
|
+
// by every engine that implements them — it is what `alert` is for. So the
|
|
89
|
+
// constraint is real and it is `status`'s, not `alert`'s. Written down here
|
|
90
|
+
// because the package states the general rule strongly in one file and does the
|
|
91
|
+
// opposite in another, and a reader who finds the second one first is entitled
|
|
92
|
+
// to know it was a decision.
|
|
28
93
|
|
|
29
94
|
"use client";
|
|
30
95
|
|
|
31
96
|
import * as React from "@uniflowed/react";
|
|
32
97
|
import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
|
|
33
98
|
|
|
34
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
99
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
100
|
+
import { withProps } from "./internal/merge-props.js";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a form library tells a field about one of its fields.
|
|
104
|
+
*
|
|
105
|
+
* Facts, and no attributes: see the module header for why an `aria-describedby`
|
|
106
|
+
* in here would be the collision this type exists to end. `control` is the
|
|
107
|
+
* binding — the `name`, the `ref` the store attaches through, `onChange`,
|
|
108
|
+
* `onBlur` and the constraint attributes a progressive form emits — spread onto
|
|
109
|
+
* whatever element `Field.Control` renders, underneath the attributes the field
|
|
110
|
+
* computes.
|
|
111
|
+
*
|
|
112
|
+
* # It is declared here and produced there
|
|
113
|
+
*
|
|
114
|
+
* `@uniflowed/form`'s `useFieldSource` builds one of these, so this type is the
|
|
115
|
+
* seam between the two packages — and it lives on this side only because the
|
|
116
|
+
* dependency can only run this way today: this package is on npm and that one
|
|
117
|
+
* is not, and `tools/release/publishable.sh` refuses a published package that
|
|
118
|
+
* depends on an unpublished one.
|
|
119
|
+
*
|
|
120
|
+
* That is backwards, and ubugeeei-prod/uf#614 says so. It stands because
|
|
121
|
+
* declaring the type twice trades a documented edge for silent drift — a
|
|
122
|
+
* `Field.Root` accepting a shape `useFieldSource` no longer produces would
|
|
123
|
+
* type-check on both sides and fail only where they meet — and because the
|
|
124
|
+
* import is type-only, so no project installing `@uniflowed/form` loads,
|
|
125
|
+
* bundles or runs any of this package. #210 is the trigger: once
|
|
126
|
+
* `@uniflowed/form` publishes, this moves there and `@uniflowed/ui` imports it.
|
|
127
|
+
*/
|
|
128
|
+
export type FieldSource = {|
|
|
129
|
+
readonly invalid: boolean,
|
|
130
|
+
readonly required: boolean,
|
|
131
|
+
readonly disabled: boolean,
|
|
132
|
+
readonly busy: boolean,
|
|
133
|
+
/** What is wrong, or null while the field is valid. */
|
|
134
|
+
readonly message: string | null,
|
|
135
|
+
readonly control: Rest,
|
|
136
|
+
|};
|
|
137
|
+
|
|
138
|
+
/** No form: the shape the field falls back to, made once. */
|
|
139
|
+
const NO_CONTROL: Rest = Object.freeze({});
|
|
35
140
|
|
|
36
141
|
type FieldState = {|
|
|
37
142
|
readonly controlId: string,
|
|
38
143
|
readonly labelId: string,
|
|
39
144
|
readonly descriptionId: string,
|
|
145
|
+
readonly statusId: string,
|
|
40
146
|
readonly errorId: string,
|
|
41
147
|
readonly invalid: boolean,
|
|
148
|
+
readonly required: boolean,
|
|
149
|
+
readonly busy: boolean,
|
|
150
|
+
readonly group: boolean,
|
|
151
|
+
readonly message: string | null,
|
|
152
|
+
readonly control: Rest,
|
|
42
153
|
readonly describedBy: string | void,
|
|
43
154
|
readonly registerDescription: (present: boolean) => void,
|
|
155
|
+
readonly registerStatus: (present: boolean) => void,
|
|
44
156
|
readonly registerError: (present: boolean) => void,
|
|
45
157
|
|};
|
|
46
158
|
|
|
@@ -63,74 +175,151 @@ hook useField(part: string): FieldState {
|
|
|
63
175
|
/**
|
|
64
176
|
* The field's container, and the only place ids are made.
|
|
65
177
|
*
|
|
66
|
-
* `invalid`
|
|
67
|
-
* parts have to agree about
|
|
68
|
-
* message is rendered or not, and the control's
|
|
69
|
-
* error's id or not.
|
|
178
|
+
* `invalid` and `required` are the root's business rather than the control's
|
|
179
|
+
* because three parts have to agree about each: the control says `aria-invalid`
|
|
180
|
+
* and `aria-required`, the error message is rendered or not, and the control's
|
|
181
|
+
* `aria-describedby` includes the error's id or not.
|
|
182
|
+
*
|
|
183
|
+
* `field` is the same two facts arriving from a form store instead of from the
|
|
184
|
+
* caller's hand, and it wins over the props when it is there — a field inside a
|
|
185
|
+
* form has one source of truth about its own validity, and the props are what a
|
|
186
|
+
* field with no form around it uses. Passing both is not an error; it is a
|
|
187
|
+
* caller saying "and also mark it invalid", which is what `invalid || source`
|
|
188
|
+
* means and what a server-side error arriving beside a client-side one needs.
|
|
70
189
|
*/
|
|
71
|
-
export component FieldRoot(
|
|
190
|
+
export component FieldRoot(
|
|
191
|
+
children: React.Node,
|
|
192
|
+
invalid?: boolean = false,
|
|
193
|
+
required?: boolean = false,
|
|
194
|
+
busy?: boolean = false,
|
|
195
|
+
field?: FieldSource,
|
|
196
|
+
group?: boolean = false,
|
|
197
|
+
render?: RenderProp,
|
|
198
|
+
...rest: Rest
|
|
199
|
+
) {
|
|
72
200
|
const base = useId();
|
|
73
201
|
const [hasDescription, setHasDescription] = useState(false);
|
|
202
|
+
const [hasStatus, setHasStatus] = useState(false);
|
|
74
203
|
const [hasError, setHasError] = useState(false);
|
|
204
|
+
const sourceInvalid = field?.invalid ?? false;
|
|
205
|
+
const sourceRequired = field?.required ?? false;
|
|
206
|
+
const sourceBusy = field?.busy ?? false;
|
|
207
|
+
const message = field?.message ?? null;
|
|
208
|
+
const control = field?.control ?? NO_CONTROL;
|
|
75
209
|
|
|
76
210
|
const state = useMemo(() => {
|
|
77
211
|
const descriptionId = `${base}-description`;
|
|
212
|
+
const statusId = `${base}-status`;
|
|
78
213
|
const errorId = `${base}-error`;
|
|
214
|
+
const wrong = invalid || sourceInvalid;
|
|
79
215
|
// Only ids that are in the document. `aria-describedby` naming a missing
|
|
80
216
|
// element makes a screen reader announce nothing rather than skipping it.
|
|
81
217
|
const described = [
|
|
82
218
|
hasDescription ? descriptionId : null,
|
|
83
|
-
|
|
219
|
+
hasStatus ? statusId : null,
|
|
220
|
+
wrong && hasError ? errorId : null,
|
|
84
221
|
].filter(Boolean);
|
|
85
222
|
|
|
86
223
|
return {
|
|
87
224
|
controlId: `${base}-control`,
|
|
88
225
|
labelId: `${base}-label`,
|
|
89
226
|
descriptionId,
|
|
227
|
+
statusId,
|
|
90
228
|
errorId,
|
|
91
|
-
invalid,
|
|
229
|
+
invalid: wrong,
|
|
230
|
+
required: required || sourceRequired,
|
|
231
|
+
busy: busy || sourceBusy,
|
|
232
|
+
group,
|
|
233
|
+
message,
|
|
234
|
+
control,
|
|
92
235
|
describedBy: described.length === 0 ? undefined : described.join(" "),
|
|
93
236
|
registerDescription: setHasDescription,
|
|
237
|
+
registerStatus: setHasStatus,
|
|
94
238
|
registerError: setHasError,
|
|
95
239
|
};
|
|
96
|
-
}, [
|
|
240
|
+
}, [
|
|
241
|
+
base,
|
|
242
|
+
invalid,
|
|
243
|
+
sourceInvalid,
|
|
244
|
+
required,
|
|
245
|
+
sourceRequired,
|
|
246
|
+
busy,
|
|
247
|
+
sourceBusy,
|
|
248
|
+
group,
|
|
249
|
+
message,
|
|
250
|
+
control,
|
|
251
|
+
hasDescription,
|
|
252
|
+
hasStatus,
|
|
253
|
+
hasError,
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const props = withProps(rest, {
|
|
257
|
+
// A group names itself, describes itself and reports its own validity,
|
|
258
|
+
// because there is no one control inside it to carry any of the three.
|
|
259
|
+
// See the module header.
|
|
260
|
+
"aria-busy": group && state.busy ? "true" : undefined,
|
|
261
|
+
"aria-describedby": group ? state.describedBy : undefined,
|
|
262
|
+
"aria-invalid": group && state.invalid ? "true" : undefined,
|
|
263
|
+
"aria-labelledby": group ? state.labelId : undefined,
|
|
264
|
+
children,
|
|
265
|
+
role: group ? "group" : undefined,
|
|
266
|
+
});
|
|
97
267
|
|
|
98
268
|
return (
|
|
99
269
|
<FieldContext.Provider value={state}>
|
|
100
|
-
<div {...
|
|
270
|
+
{render != null ? render(props) : <div {...props} />}
|
|
101
271
|
</FieldContext.Provider>
|
|
102
272
|
);
|
|
103
273
|
}
|
|
104
274
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
275
|
+
/**
|
|
276
|
+
* The label, pointing at the control by id rather than by nesting.
|
|
277
|
+
*
|
|
278
|
+
* In a group it is a `<span>` instead, and the group points at *it*: a
|
|
279
|
+
* `<label for>` naming something that is not a form control is ignored by every
|
|
280
|
+
* browser, and ignored silently. The module header says more.
|
|
281
|
+
*/
|
|
282
|
+
export component FieldLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
107
283
|
const field = useField("Field.Label");
|
|
108
284
|
// `rest` first: a caller `id` here would break the relationship the control
|
|
109
285
|
// points at, and it would break it silently.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
286
|
+
if (field.group) {
|
|
287
|
+
const props = withProps(rest, { children, id: field.labelId });
|
|
288
|
+
return render != null ? render(props) : <span {...props} />;
|
|
289
|
+
}
|
|
290
|
+
const props = withProps(rest, { children, htmlFor: field.controlId, id: field.labelId });
|
|
291
|
+
return render != null ? render(props) : <label {...props} />;
|
|
115
292
|
}
|
|
116
293
|
|
|
117
294
|
/**
|
|
118
295
|
* The control, given every attribute the rest of the field implies.
|
|
119
296
|
*
|
|
120
|
-
* See the module header for why this takes a render function
|
|
297
|
+
* See the module header for why this takes a render function, and for the
|
|
298
|
+
* division of labour that decides what is in here. The form's own binding is
|
|
299
|
+
* underneath, so a caller who spreads these onto an element gets the store's
|
|
300
|
+
* `ref` and handlers *and* the field's attributes from one spread instead of
|
|
301
|
+
* two that overwrite each other.
|
|
121
302
|
*/
|
|
122
|
-
export component FieldControl(render:
|
|
303
|
+
export component FieldControl(render: RenderProp) {
|
|
123
304
|
const field = useField("Field.Control");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"aria-
|
|
129
|
-
|
|
305
|
+
// A group already carries the name, the description and the validity, and a
|
|
306
|
+
// control repeating them makes a reader hear the error once for the set and
|
|
307
|
+
// again for the member. What is left is what only the control can say.
|
|
308
|
+
const own: Rest = field.group
|
|
309
|
+
? { "aria-required": field.required ? "true" : undefined }
|
|
310
|
+
: {
|
|
311
|
+
"aria-busy": field.busy ? "true" : undefined,
|
|
312
|
+
id: field.controlId,
|
|
313
|
+
"aria-labelledby": field.labelId,
|
|
314
|
+
"aria-describedby": field.describedBy,
|
|
315
|
+
"aria-invalid": field.invalid ? "true" : undefined,
|
|
316
|
+
"aria-required": field.required ? "true" : undefined,
|
|
317
|
+
};
|
|
318
|
+
return render(withProps(field.control, own));
|
|
130
319
|
}
|
|
131
320
|
|
|
132
321
|
/** Help text, which the control points at while it is rendered. */
|
|
133
|
-
export component FieldDescription(children: React.Node, ...rest: Rest) {
|
|
322
|
+
export component FieldDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
134
323
|
const field = useField("Field.Description");
|
|
135
324
|
const register = field.registerDescription;
|
|
136
325
|
useEffect(() => {
|
|
@@ -138,20 +327,47 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
|
|
|
138
327
|
return () => register(false);
|
|
139
328
|
}, [register]);
|
|
140
329
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
330
|
+
const props = withProps(rest, { children, id: field.descriptionId });
|
|
331
|
+
return render != null ? render(props) : <p {...props} />;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Non-error feedback for the field.
|
|
336
|
+
*
|
|
337
|
+
* A polite live region has to exist before the text changes. So unlike
|
|
338
|
+
* `Field.Error`, this part stays in the document even while it is empty; the
|
|
339
|
+
* control points at it only while the caller rendered the part.
|
|
340
|
+
*/
|
|
341
|
+
export component FieldStatus(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
342
|
+
const field = useField("Field.Status");
|
|
343
|
+
const register = field.registerStatus;
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
register(true);
|
|
346
|
+
return () => register(false);
|
|
347
|
+
}, [register]);
|
|
348
|
+
|
|
349
|
+
const props = withProps(rest, {
|
|
350
|
+
"aria-live": "polite",
|
|
351
|
+
children: children ?? "",
|
|
352
|
+
id: field.statusId,
|
|
353
|
+
role: "status",
|
|
354
|
+
});
|
|
355
|
+
return render != null ? render(props) : <p {...props} />;
|
|
146
356
|
}
|
|
147
357
|
|
|
148
358
|
/**
|
|
149
359
|
* The error message, rendered only when the field is invalid.
|
|
150
360
|
*
|
|
151
361
|
* `role="alert"` so it is announced when it appears, which is the point of an
|
|
152
|
-
* error that arrives after a blur or a
|
|
362
|
+
* error that arrives after a blur, a submit, or a Server Action — see the
|
|
363
|
+
* module header for why an assertive region may be inserted with its text where
|
|
364
|
+
* a polite one may not.
|
|
365
|
+
*
|
|
366
|
+
* With no children it shows the message the form gave, so a field bound to a
|
|
367
|
+
* store does not need the caller to reach back into `formState.errors` for a
|
|
368
|
+
* string the source is already carrying.
|
|
153
369
|
*/
|
|
154
|
-
export component FieldError(children
|
|
370
|
+
export component FieldError(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
155
371
|
const field = useField("Field.Error");
|
|
156
372
|
const register = field.registerError;
|
|
157
373
|
useEffect(() => {
|
|
@@ -162,9 +378,10 @@ export component FieldError(children: React.Node, ...rest: Rest) {
|
|
|
162
378
|
if (!field.invalid) {
|
|
163
379
|
return null;
|
|
164
380
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
);
|
|
381
|
+
const props = withProps(rest, {
|
|
382
|
+
children: children ?? field.message,
|
|
383
|
+
id: field.errorId,
|
|
384
|
+
role: "alert",
|
|
385
|
+
});
|
|
386
|
+
return render != null ? render(props) : <p {...props} />;
|
|
170
387
|
}
|
package/grid-list.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { CollectionRoot } from "./internal/collection.js";
|
|
5
|
+
import type { CollectionProps } from "./internal/collection.js";
|
|
6
|
+
export component GridList(...props: CollectionProps) {
|
|
7
|
+
return <CollectionRoot options={props} kind="grid" />;
|
|
8
|
+
}
|
package/hover-card.js
CHANGED
|
@@ -43,9 +43,9 @@ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@u
|
|
|
43
43
|
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
44
44
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
45
45
|
|
|
46
|
-
import type { Align,
|
|
46
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
47
47
|
import type { HoverIntent } from "./internal/hover-intent.js";
|
|
48
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
48
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
49
49
|
import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
50
50
|
import {
|
|
51
51
|
DEFAULT_CLOSE_DELAY,
|
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
import { useAnchor } from "./internal/anchor.js";
|
|
58
58
|
import { useControlled } from "./internal/controlled-state.js";
|
|
59
59
|
|
|
60
|
-
export type { Align, Side } from "./internal/anchor.js";
|
|
60
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
61
61
|
|
|
62
62
|
type HoverCardState = {|
|
|
63
63
|
readonly base: string,
|
|
@@ -68,7 +68,7 @@ type HoverCardState = {|
|
|
|
68
68
|
readonly openDelay: number,
|
|
69
69
|
readonly closeDelay: number,
|
|
70
70
|
/** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
|
|
71
|
-
readonly
|
|
71
|
+
readonly dismissedRef: { current: boolean },
|
|
72
72
|
|};
|
|
73
73
|
|
|
74
74
|
const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
|
|
@@ -99,14 +99,14 @@ export component HoverCardRoot(
|
|
|
99
99
|
const base = useId();
|
|
100
100
|
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
101
101
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
102
|
-
const
|
|
102
|
+
const dismissedRef = useRef(false);
|
|
103
103
|
const intent = useHoverIntent(setOpen);
|
|
104
104
|
|
|
105
105
|
const state = useMemo(
|
|
106
106
|
() => ({
|
|
107
107
|
base,
|
|
108
108
|
closeDelay,
|
|
109
|
-
|
|
109
|
+
dismissedRef,
|
|
110
110
|
intent,
|
|
111
111
|
open: isOpen,
|
|
112
112
|
openDelay,
|
|
@@ -127,42 +127,38 @@ export component HoverCardRoot(
|
|
|
127
127
|
* reachable by keyboard, and `useFocusableTrigger` refuses anything else —
|
|
128
128
|
* a hover card on a `<span>` is one a keyboard reader can never see.
|
|
129
129
|
*/
|
|
130
|
-
export component HoverCardTrigger(
|
|
131
|
-
children?: React.Node,
|
|
132
|
-
render?: (props: Rest) => React.Node,
|
|
133
|
-
...rest: Rest
|
|
134
|
-
) {
|
|
130
|
+
export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
135
131
|
const card = useHoverCard("HoverCard.Trigger");
|
|
136
|
-
const { closeDelay,
|
|
132
|
+
const { closeDelay, dismissedRef, intent, openDelay, triggerRef } = card;
|
|
137
133
|
useFocusableTrigger(triggerRef, "HoverCard.Trigger");
|
|
138
134
|
|
|
139
135
|
// A press focuses the trigger, and a card opening under the reader's own
|
|
140
136
|
// click would cover what they just went to. See `tooltip.js`.
|
|
141
|
-
const
|
|
137
|
+
const pressedRef = useRef(false);
|
|
142
138
|
|
|
143
139
|
useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
|
|
144
|
-
if (event.pointerType === "touch" ||
|
|
140
|
+
if (event.pointerType === "touch" || dismissedRef.current) {
|
|
145
141
|
return;
|
|
146
142
|
}
|
|
147
143
|
intent.openAfter(openDelay);
|
|
148
144
|
});
|
|
149
145
|
useEventListener(triggerRef, "pointerleave", () => {
|
|
150
|
-
|
|
146
|
+
dismissedRef.current = false;
|
|
151
147
|
intent.closeAfter(closeDelay);
|
|
152
148
|
});
|
|
153
149
|
useEventListener(triggerRef, "pointerdown", () => {
|
|
154
|
-
|
|
150
|
+
pressedRef.current = true;
|
|
155
151
|
intent.cancel();
|
|
156
152
|
});
|
|
157
153
|
useEventListener(triggerRef, "focusin", () => {
|
|
158
|
-
if (
|
|
159
|
-
|
|
154
|
+
if (pressedRef.current) {
|
|
155
|
+
pressedRef.current = false;
|
|
160
156
|
return;
|
|
161
157
|
}
|
|
162
158
|
// The focus a dismissed card hands *back* to this trigger must not reopen
|
|
163
159
|
// it, which is the whole reason the flag exists: without it `Escape` closes
|
|
164
160
|
// the card, focus returns here, and the card comes straight back.
|
|
165
|
-
if (
|
|
161
|
+
if (dismissedRef.current) {
|
|
166
162
|
return;
|
|
167
163
|
}
|
|
168
164
|
// A reader who tabbed here has said what they want; only the pointer is
|
|
@@ -170,8 +166,8 @@ export component HoverCardTrigger(
|
|
|
170
166
|
intent.openAfter(0);
|
|
171
167
|
});
|
|
172
168
|
useEventListener(triggerRef, "focusout", () => {
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
pressedRef.current = false;
|
|
170
|
+
dismissedRef.current = false;
|
|
175
171
|
// `closeAfter` rather than a close, and this is where the delay earns its
|
|
176
172
|
// keep a second time: `Tab` from the trigger *into* the card is a leave
|
|
177
173
|
// followed immediately by an arrival, and the card's own `focusin` calls
|
|
@@ -183,19 +179,18 @@ export component HoverCardTrigger(
|
|
|
183
179
|
|
|
184
180
|
// Annotated because this one is not written inside a `ref={...}`, and there
|
|
185
181
|
// is nothing else here for Flow to infer the element's type from.
|
|
182
|
+
// React calls callback refs during commit; pointer and focus handlers read it later.
|
|
183
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
186
184
|
const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
187
185
|
triggerRef.current = element;
|
|
188
186
|
});
|
|
189
187
|
|
|
188
|
+
const props = withProps(withoutComposed(rest, ["ref"]), { children, ref: attach });
|
|
189
|
+
|
|
190
190
|
if (render != null) {
|
|
191
|
-
return render(
|
|
191
|
+
return render(props);
|
|
192
192
|
}
|
|
193
|
-
|
|
194
|
-
return (
|
|
195
|
-
<button {...withoutComposed(rest, ["ref"])} ref={attach} type="button">
|
|
196
|
-
{children}
|
|
197
|
-
</button>
|
|
198
|
-
);
|
|
193
|
+
return <button {...props} type="button" />;
|
|
199
194
|
}
|
|
200
195
|
|
|
201
196
|
/**
|
|
@@ -213,21 +208,22 @@ export component HoverCardBody(
|
|
|
213
208
|
alignOffset?: number = 0,
|
|
214
209
|
avoidCollisions?: boolean = true,
|
|
215
210
|
collisionPadding?: number = 0,
|
|
216
|
-
|
|
211
|
+
render?: RenderProp,
|
|
212
|
+
side?: LogicalSide = "bottom",
|
|
217
213
|
sideOffset?: number = 0,
|
|
218
214
|
...rest: Rest
|
|
219
215
|
) {
|
|
220
216
|
const card = useHoverCard("HoverCard.Body");
|
|
221
|
-
const { closeDelay, intent, open, triggerRef } = card;
|
|
217
|
+
const { closeDelay, dismissedRef, intent, open, triggerRef } = card;
|
|
222
218
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
223
219
|
// Whether the reader is *in* the card, as opposed to over it. It decides one
|
|
224
220
|
// thing and it cannot be asked afterwards: a card closed while it held focus
|
|
225
221
|
// has to hand focus back, and by the time the effect below is cleaned up the
|
|
226
222
|
// element is gone from the document and `activeElement` has already fallen to
|
|
227
223
|
// `<body>` — so the answer is kept while it is still true.
|
|
228
|
-
const
|
|
224
|
+
const heldRef = useRef(false);
|
|
229
225
|
const close = useStableCallback(() => {
|
|
230
|
-
|
|
226
|
+
dismissedRef.current = true;
|
|
231
227
|
intent.cancel();
|
|
232
228
|
card.setOpen(false);
|
|
233
229
|
});
|
|
@@ -256,11 +252,11 @@ export component HoverCardBody(
|
|
|
256
252
|
const stay = () => intent.cancel();
|
|
257
253
|
const go = () => intent.closeAfter(closeDelay);
|
|
258
254
|
const arrived = () => {
|
|
259
|
-
|
|
255
|
+
heldRef.current = true;
|
|
260
256
|
stay();
|
|
261
257
|
};
|
|
262
258
|
const gone = () => {
|
|
263
|
-
|
|
259
|
+
heldRef.current = false;
|
|
264
260
|
go();
|
|
265
261
|
};
|
|
266
262
|
body.addEventListener("pointerenter", stay);
|
|
@@ -295,8 +291,8 @@ export component HoverCardBody(
|
|
|
295
291
|
if (open) {
|
|
296
292
|
return;
|
|
297
293
|
}
|
|
298
|
-
if (
|
|
299
|
-
|
|
294
|
+
if (heldRef.current) {
|
|
295
|
+
heldRef.current = false;
|
|
300
296
|
triggerRef.current?.focus?.();
|
|
301
297
|
}
|
|
302
298
|
}, [open, triggerRef]);
|
|
@@ -305,8 +301,8 @@ export component HoverCardBody(
|
|
|
305
301
|
// the reader is inside it leaves focus on a node that is gone.
|
|
306
302
|
useEffect(
|
|
307
303
|
() => () => {
|
|
308
|
-
if (
|
|
309
|
-
|
|
304
|
+
if (heldRef.current) {
|
|
305
|
+
heldRef.current = false;
|
|
310
306
|
triggerRef.current?.focus?.();
|
|
311
307
|
}
|
|
312
308
|
},
|
|
@@ -317,18 +313,22 @@ export component HoverCardBody(
|
|
|
317
313
|
return null;
|
|
318
314
|
}
|
|
319
315
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)
|
|
316
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
317
|
+
children,
|
|
318
|
+
"data-align": anchored.align,
|
|
319
|
+
"data-side": anchored.side,
|
|
320
|
+
"data-state": "open",
|
|
321
|
+
id: `${card.base}-body`,
|
|
322
|
+
// React calls callback refs during commit; placement effects read it later.
|
|
323
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
324
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
325
|
+
bodyRef.current = element;
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (render != null) {
|
|
330
|
+
return render(props);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return <div {...props} />;
|
|
334
334
|
}
|