@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14
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 +21 -3
- package/calendar.js +550 -0
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +176 -5
- package/context-menu.js +198 -0
- package/date-picker.js +346 -0
- package/field.js +192 -25
- package/hover-card.js +3 -3
- package/index.js +196 -10
- package/internal/anchor.js +71 -6
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +309 -163
- package/menubar.js +285 -0
- package/package.json +8 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/switch.js +5 -3
- package/toggle.js +3 -2
- package/tooltip.js +3 -3
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,14 +17,79 @@
|
|
|
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
|
|
|
@@ -32,6 +97,29 @@ import * as React from "@uniflowed/react";
|
|
|
32
97
|
import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
|
|
33
98
|
|
|
34
99
|
import type { 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
|
+
export type FieldSource = {|
|
|
113
|
+
readonly invalid: boolean,
|
|
114
|
+
readonly required: boolean,
|
|
115
|
+
readonly disabled: boolean,
|
|
116
|
+
/** What is wrong, or null while the field is valid. */
|
|
117
|
+
readonly message: string | null,
|
|
118
|
+
readonly control: Rest,
|
|
119
|
+
|};
|
|
120
|
+
|
|
121
|
+
/** No form: the shape the field falls back to, made once. */
|
|
122
|
+
const NO_CONTROL: Rest = Object.freeze({});
|
|
35
123
|
|
|
36
124
|
type FieldState = {|
|
|
37
125
|
readonly controlId: string,
|
|
@@ -39,6 +127,10 @@ type FieldState = {|
|
|
|
39
127
|
readonly descriptionId: string,
|
|
40
128
|
readonly errorId: string,
|
|
41
129
|
readonly invalid: boolean,
|
|
130
|
+
readonly required: boolean,
|
|
131
|
+
readonly group: boolean,
|
|
132
|
+
readonly message: string | null,
|
|
133
|
+
readonly control: Rest,
|
|
42
134
|
readonly describedBy: string | void,
|
|
43
135
|
readonly registerDescription: (present: boolean) => void,
|
|
44
136
|
readonly registerError: (present: boolean) => void,
|
|
@@ -63,24 +155,43 @@ hook useField(part: string): FieldState {
|
|
|
63
155
|
/**
|
|
64
156
|
* The field's container, and the only place ids are made.
|
|
65
157
|
*
|
|
66
|
-
* `invalid`
|
|
67
|
-
* parts have to agree about
|
|
68
|
-
* message is rendered or not, and the control's
|
|
69
|
-
* error's id or not.
|
|
158
|
+
* `invalid` and `required` are the root's business rather than the control's
|
|
159
|
+
* because three parts have to agree about each: the control says `aria-invalid`
|
|
160
|
+
* and `aria-required`, the error message is rendered or not, and the control's
|
|
161
|
+
* `aria-describedby` includes the error's id or not.
|
|
162
|
+
*
|
|
163
|
+
* `field` is the same two facts arriving from a form store instead of from the
|
|
164
|
+
* caller's hand, and it wins over the props when it is there — a field inside a
|
|
165
|
+
* form has one source of truth about its own validity, and the props are what a
|
|
166
|
+
* field with no form around it uses. Passing both is not an error; it is a
|
|
167
|
+
* caller saying "and also mark it invalid", which is what `invalid || source`
|
|
168
|
+
* means and what a server-side error arriving beside a client-side one needs.
|
|
70
169
|
*/
|
|
71
|
-
export component FieldRoot(
|
|
170
|
+
export component FieldRoot(
|
|
171
|
+
children: React.Node,
|
|
172
|
+
invalid?: boolean = false,
|
|
173
|
+
required?: boolean = false,
|
|
174
|
+
field?: FieldSource,
|
|
175
|
+
group?: boolean = false,
|
|
176
|
+
...rest: Rest
|
|
177
|
+
) {
|
|
72
178
|
const base = useId();
|
|
73
179
|
const [hasDescription, setHasDescription] = useState(false);
|
|
74
180
|
const [hasError, setHasError] = useState(false);
|
|
181
|
+
const sourceInvalid = field?.invalid ?? false;
|
|
182
|
+
const sourceRequired = field?.required ?? false;
|
|
183
|
+
const message = field?.message ?? null;
|
|
184
|
+
const control = field?.control ?? NO_CONTROL;
|
|
75
185
|
|
|
76
186
|
const state = useMemo(() => {
|
|
77
187
|
const descriptionId = `${base}-description`;
|
|
78
188
|
const errorId = `${base}-error`;
|
|
189
|
+
const wrong = invalid || sourceInvalid;
|
|
79
190
|
// Only ids that are in the document. `aria-describedby` naming a missing
|
|
80
191
|
// element makes a screen reader announce nothing rather than skipping it.
|
|
81
192
|
const described = [
|
|
82
193
|
hasDescription ? descriptionId : null,
|
|
83
|
-
|
|
194
|
+
wrong && hasError ? errorId : null,
|
|
84
195
|
].filter(Boolean);
|
|
85
196
|
|
|
86
197
|
return {
|
|
@@ -88,25 +199,64 @@ export component FieldRoot(children: React.Node, invalid?: boolean = false, ...r
|
|
|
88
199
|
labelId: `${base}-label`,
|
|
89
200
|
descriptionId,
|
|
90
201
|
errorId,
|
|
91
|
-
invalid,
|
|
202
|
+
invalid: wrong,
|
|
203
|
+
required: required || sourceRequired,
|
|
204
|
+
group,
|
|
205
|
+
message,
|
|
206
|
+
control,
|
|
92
207
|
describedBy: described.length === 0 ? undefined : described.join(" "),
|
|
93
208
|
registerDescription: setHasDescription,
|
|
94
209
|
registerError: setHasError,
|
|
95
210
|
};
|
|
96
|
-
}, [
|
|
211
|
+
}, [
|
|
212
|
+
base,
|
|
213
|
+
invalid,
|
|
214
|
+
sourceInvalid,
|
|
215
|
+
required,
|
|
216
|
+
sourceRequired,
|
|
217
|
+
group,
|
|
218
|
+
message,
|
|
219
|
+
control,
|
|
220
|
+
hasDescription,
|
|
221
|
+
hasError,
|
|
222
|
+
]);
|
|
97
223
|
|
|
98
224
|
return (
|
|
99
225
|
<FieldContext.Provider value={state}>
|
|
100
|
-
<div
|
|
226
|
+
<div
|
|
227
|
+
{...rest}
|
|
228
|
+
// A group names itself, describes itself and reports its own validity,
|
|
229
|
+
// because there is no one control inside it to carry any of the three.
|
|
230
|
+
// See the module header.
|
|
231
|
+
aria-describedby={group ? state.describedBy : undefined}
|
|
232
|
+
aria-invalid={group && state.invalid ? "true" : undefined}
|
|
233
|
+
aria-labelledby={group ? state.labelId : undefined}
|
|
234
|
+
role={group ? "group" : undefined}
|
|
235
|
+
>
|
|
236
|
+
{children}
|
|
237
|
+
</div>
|
|
101
238
|
</FieldContext.Provider>
|
|
102
239
|
);
|
|
103
240
|
}
|
|
104
241
|
|
|
105
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* The label, pointing at the control by id rather than by nesting.
|
|
244
|
+
*
|
|
245
|
+
* In a group it is a `<span>` instead, and the group points at *it*: a
|
|
246
|
+
* `<label for>` naming something that is not a form control is ignored by every
|
|
247
|
+
* browser, and ignored silently. The module header says more.
|
|
248
|
+
*/
|
|
106
249
|
export component FieldLabel(children: React.Node, ...rest: Rest) {
|
|
107
250
|
const field = useField("Field.Label");
|
|
108
251
|
// `rest` first: a caller `id` here would break the relationship the control
|
|
109
252
|
// points at, and it would break it silently.
|
|
253
|
+
if (field.group) {
|
|
254
|
+
return (
|
|
255
|
+
<span {...rest} id={field.labelId}>
|
|
256
|
+
{children}
|
|
257
|
+
</span>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
110
260
|
return (
|
|
111
261
|
<label {...rest} htmlFor={field.controlId} id={field.labelId}>
|
|
112
262
|
{children}
|
|
@@ -117,16 +267,27 @@ export component FieldLabel(children: React.Node, ...rest: Rest) {
|
|
|
117
267
|
/**
|
|
118
268
|
* The control, given every attribute the rest of the field implies.
|
|
119
269
|
*
|
|
120
|
-
* See the module header for why this takes a render function
|
|
270
|
+
* See the module header for why this takes a render function, and for the
|
|
271
|
+
* division of labour that decides what is in here. The form's own binding is
|
|
272
|
+
* underneath, so a caller who spreads these onto an element gets the store's
|
|
273
|
+
* `ref` and handlers *and* the field's attributes from one spread instead of
|
|
274
|
+
* two that overwrite each other.
|
|
121
275
|
*/
|
|
122
276
|
export component FieldControl(render: (props: Rest) => React.Node) {
|
|
123
277
|
const field = useField("Field.Control");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"aria-
|
|
129
|
-
|
|
278
|
+
// A group already carries the name, the description and the validity, and a
|
|
279
|
+
// control repeating them makes a reader hear the error once for the set and
|
|
280
|
+
// again for the member. What is left is what only the control can say.
|
|
281
|
+
const own: Rest = field.group
|
|
282
|
+
? { "aria-required": field.required ? "true" : undefined }
|
|
283
|
+
: {
|
|
284
|
+
id: field.controlId,
|
|
285
|
+
"aria-labelledby": field.labelId,
|
|
286
|
+
"aria-describedby": field.describedBy,
|
|
287
|
+
"aria-invalid": field.invalid ? "true" : undefined,
|
|
288
|
+
"aria-required": field.required ? "true" : undefined,
|
|
289
|
+
};
|
|
290
|
+
return render(withProps(field.control, own));
|
|
130
291
|
}
|
|
131
292
|
|
|
132
293
|
/** Help text, which the control points at while it is rendered. */
|
|
@@ -149,9 +310,15 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
|
|
|
149
310
|
* The error message, rendered only when the field is invalid.
|
|
150
311
|
*
|
|
151
312
|
* `role="alert"` so it is announced when it appears, which is the point of an
|
|
152
|
-
* error that arrives after a blur or a
|
|
313
|
+
* error that arrives after a blur, a submit, or a Server Action — see the
|
|
314
|
+
* module header for why an assertive region may be inserted with its text where
|
|
315
|
+
* a polite one may not.
|
|
316
|
+
*
|
|
317
|
+
* With no children it shows the message the form gave, so a field bound to a
|
|
318
|
+
* store does not need the caller to reach back into `formState.errors` for a
|
|
319
|
+
* string the source is already carrying.
|
|
153
320
|
*/
|
|
154
|
-
export component FieldError(children
|
|
321
|
+
export component FieldError(children?: React.Node, ...rest: Rest) {
|
|
155
322
|
const field = useField("Field.Error");
|
|
156
323
|
const register = field.registerError;
|
|
157
324
|
useEffect(() => {
|
|
@@ -164,7 +331,7 @@ export component FieldError(children: React.Node, ...rest: Rest) {
|
|
|
164
331
|
}
|
|
165
332
|
return (
|
|
166
333
|
<p {...rest} id={field.errorId} role="alert">
|
|
167
|
-
{children}
|
|
334
|
+
{children ?? field.message}
|
|
168
335
|
</p>
|
|
169
336
|
);
|
|
170
337
|
}
|
package/hover-card.js
CHANGED
|
@@ -43,7 +43,7 @@ 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
48
|
import type { Rest } from "./internal/merge-props.js";
|
|
49
49
|
import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
@@ -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,
|
|
@@ -213,7 +213,7 @@ export component HoverCardBody(
|
|
|
213
213
|
alignOffset?: number = 0,
|
|
214
214
|
avoidCollisions?: boolean = true,
|
|
215
215
|
collisionPadding?: number = 0,
|
|
216
|
-
side?:
|
|
216
|
+
side?: LogicalSide = "bottom",
|
|
217
217
|
sideOffset?: number = 0,
|
|
218
218
|
...rest: Rest
|
|
219
219
|
) {
|
package/index.js
CHANGED
|
@@ -98,9 +98,16 @@
|
|
|
98
98
|
// than what they replaced. Each module's header says what it gives that the
|
|
99
99
|
// plain element does not; if it ever stops being true, the component should
|
|
100
100
|
// be deleted rather than fixed.
|
|
101
|
-
// - `menu.js` — the arrow keys, typeahead,
|
|
102
|
-
//
|
|
103
|
-
//
|
|
101
|
+
// - `menu.js`, `context-menu.js` and `menubar.js` — the arrow keys, typeahead,
|
|
102
|
+
// submenus and `Escape` stacking, and the two components that are that
|
|
103
|
+
// behaviour opened differently. A context menu is opened by the right button,
|
|
104
|
+
// by `Shift+F10` and by a long press, and opens at a *point*; a menubar is a
|
|
105
|
+
// row of them with one tab stop and arrows that walk between the open menus.
|
|
106
|
+
// shadcn's fourth menu, Dropdown Menu, is `menu.js` under another name and is
|
|
107
|
+
// deliberately not a second export.
|
|
108
|
+
// - `combobox.js` — `aria-activedescendant` over a filtered list, the count a
|
|
109
|
+
// screen reader is told, and the option groups that make a command palette a
|
|
110
|
+
// composition rather than a seventh module.
|
|
104
111
|
// - `select.js` — the other half of the combobox pattern: the select-only one,
|
|
105
112
|
// with typeahead, option groups and a value a form can submit.
|
|
106
113
|
// - `tabs.js` — the roving `tabindex`, and automatic versus manual activation.
|
|
@@ -137,7 +144,7 @@
|
|
|
137
144
|
// is by primitive because that is the unit a reader looks for, the unit a
|
|
138
145
|
// bundler drops, and the unit the WAI-ARIA practices are written in.
|
|
139
146
|
//
|
|
140
|
-
// `internal/` holds
|
|
147
|
+
// `internal/` holds ten modules and nothing else, each a rule the primitives
|
|
141
148
|
// must apply identically and a consumer must not be able to apply differently:
|
|
142
149
|
// `merge-props.js` (the caller's props go on first, the component's semantics
|
|
143
150
|
// last), `controlled-state.js` (what "controlled" means here),
|
|
@@ -150,8 +157,10 @@
|
|
|
150
157
|
// fit where it was asked to go), `focus.js` (which elements a reader can reach,
|
|
151
158
|
// which a focus trap and a popover want opposite things from), and
|
|
152
159
|
// `hover-intent.js` (what WCAG requires of content shown on hover or focus,
|
|
153
|
-
// which is three clauses and one mechanism)
|
|
154
|
-
//
|
|
160
|
+
// which is three clauses and one mechanism), and `menu-tree.js` (the chain of
|
|
161
|
+
// open menus the three menu components share, and what `Escape` and choosing an
|
|
162
|
+
// item are defined in terms of). Each says in its own header why it is
|
|
163
|
+
// unreachable rather than exported. There is no `internal/props.js`-shaped
|
|
155
164
|
// bag of helpers: a module that cannot say what it is about does not belong in
|
|
156
165
|
// this package.
|
|
157
166
|
|
|
@@ -182,10 +191,20 @@ import {
|
|
|
182
191
|
CarouselPrevious,
|
|
183
192
|
CarouselRoot,
|
|
184
193
|
} from "./carousel.js";
|
|
194
|
+
import {
|
|
195
|
+
CalendarDay,
|
|
196
|
+
CalendarMonth,
|
|
197
|
+
CalendarNext,
|
|
198
|
+
CalendarPrevious,
|
|
199
|
+
CalendarRoot,
|
|
200
|
+
} from "./calendar.js";
|
|
185
201
|
import { Checkbox } from "./checkbox.js";
|
|
202
|
+
import { ContextMenuRoot, ContextMenuTrigger } from "./context-menu.js";
|
|
186
203
|
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "./collapsible.js";
|
|
187
204
|
import {
|
|
188
205
|
ComboboxEmpty,
|
|
206
|
+
ComboboxGroup,
|
|
207
|
+
ComboboxGroupLabel,
|
|
189
208
|
ComboboxInput,
|
|
190
209
|
ComboboxLabel,
|
|
191
210
|
ComboboxList,
|
|
@@ -193,6 +212,12 @@ import {
|
|
|
193
212
|
ComboboxRoot,
|
|
194
213
|
ComboboxStatus,
|
|
195
214
|
} from "./combobox.js";
|
|
215
|
+
import {
|
|
216
|
+
DatePickerCalendar,
|
|
217
|
+
DatePickerInput,
|
|
218
|
+
DatePickerRoot,
|
|
219
|
+
DatePickerTrigger,
|
|
220
|
+
} from "./date-picker.js";
|
|
196
221
|
import {
|
|
197
222
|
DialogBody,
|
|
198
223
|
DialogClose,
|
|
@@ -221,15 +246,19 @@ import { HoverCardBody, HoverCardRoot, HoverCardTrigger } from "./hover-card.js"
|
|
|
221
246
|
import { InputOtpGroup, InputOtpRoot, InputOtpSeparator, InputOtpSlot } from "./input-otp.js";
|
|
222
247
|
import {
|
|
223
248
|
MenuBody,
|
|
249
|
+
MenuCheckboxItem,
|
|
224
250
|
MenuGroup,
|
|
225
251
|
MenuItem,
|
|
226
252
|
MenuLabel,
|
|
253
|
+
MenuRadioGroup,
|
|
254
|
+
MenuRadioItem,
|
|
227
255
|
MenuRoot,
|
|
228
256
|
MenuSeparator,
|
|
229
257
|
MenuSub,
|
|
230
258
|
MenuSubTrigger,
|
|
231
259
|
MenuTrigger,
|
|
232
260
|
} from "./menu.js";
|
|
261
|
+
import { MenubarMenu, MenubarRoot, MenubarTrigger } from "./menubar.js";
|
|
233
262
|
import {
|
|
234
263
|
NavigationMenuBody,
|
|
235
264
|
NavigationMenuItem,
|
|
@@ -312,6 +341,9 @@ import { ToggleGroupItem, ToggleGroupRoot } from "./toggle-group.js";
|
|
|
312
341
|
import { TooltipBody, TooltipProvider, TooltipRoot, TooltipTrigger } from "./tooltip.js";
|
|
313
342
|
|
|
314
343
|
export type { AccordionType } from "./accordion.js";
|
|
344
|
+
// A date, however a caller had one to hand: a `PlainDate` from
|
|
345
|
+
// `@uniflowed/temporal`, or the ISO 8601 string a form field or a URL carries.
|
|
346
|
+
export type { DateValue } from "./calendar.js";
|
|
315
347
|
export type { ActivationMode } from "./tabs.js";
|
|
316
348
|
// What a modal announces itself as, for a caller who holds one in a variable.
|
|
317
349
|
// Two members, not a string: see `dialog.js`.
|
|
@@ -324,7 +356,10 @@ export type { SidebarSide } from "./sidebar.js";
|
|
|
324
356
|
// Where an anchored overlay opens, for a caller who holds one in a variable or
|
|
325
357
|
// a prop of their own. Unions rather than strings, so `side="botom"` is a type
|
|
326
358
|
// error at the call rather than an overlay that quietly opens somewhere else.
|
|
327
|
-
|
|
359
|
+
// `LogicalSide` is the same four plus `inline-start` and `inline-end`, which
|
|
360
|
+
// are the ones that mean "the way the reader reads" - what a submenu opens
|
|
361
|
+
// onto, and the left of the page in Arabic.
|
|
362
|
+
export type { Align, LogicalSide, Side } from "./popover.js";
|
|
328
363
|
export type { Sort } from "./table.js";
|
|
329
364
|
export type { Notification, ToastChanges, ToastOptions, Urgency } from "./toast.js";
|
|
330
365
|
export type { ToggleGroupType } from "./toggle-group.js";
|
|
@@ -353,6 +388,20 @@ export { dismissAllToasts, dismissToast, toast, updateToast };
|
|
|
353
388
|
* <Field.Description>We will not share it.</Field.Description>
|
|
354
389
|
* <Field.Error>{error}</Field.Error>
|
|
355
390
|
* </Field.Root>
|
|
391
|
+
*
|
|
392
|
+
* Inside a form, `field` replaces the hand-written `invalid`: the form says
|
|
393
|
+
* whether the field is wrong and what the message is, and the field composes
|
|
394
|
+
* every `aria-*` from that in one place. `@uniflowed/form`'s `useFieldSource`
|
|
395
|
+
* is what produces one, and `field.js`'s header says why the hook lives there
|
|
396
|
+
* rather than here.
|
|
397
|
+
*
|
|
398
|
+
* const email = useFieldSource(form, "email", { required: "We need one" });
|
|
399
|
+
* <Field.Root field={email}>…<Field.Error /></Field.Root>
|
|
400
|
+
*
|
|
401
|
+
* `group` is for a set with no single control to point a `<label for>` at — a
|
|
402
|
+
* radio group, a checkbox group, three selects making a date. The root becomes
|
|
403
|
+
* `role="group"` named by the label, and the description and the error describe
|
|
404
|
+
* the set.
|
|
356
405
|
*/
|
|
357
406
|
export const Field = {
|
|
358
407
|
Root: FieldRoot,
|
|
@@ -739,6 +788,77 @@ export const Menu = {
|
|
|
739
788
|
Trigger: MenuTrigger,
|
|
740
789
|
Body: MenuBody,
|
|
741
790
|
Item: MenuItem,
|
|
791
|
+
CheckboxItem: MenuCheckboxItem,
|
|
792
|
+
RadioGroup: MenuRadioGroup,
|
|
793
|
+
RadioItem: MenuRadioItem,
|
|
794
|
+
Separator: MenuSeparator,
|
|
795
|
+
Group: MenuGroup,
|
|
796
|
+
Label: MenuLabel,
|
|
797
|
+
Sub: MenuSub,
|
|
798
|
+
SubTrigger: MenuSubTrigger,
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* The same menu, opened by the right button — and by the keyboard.
|
|
803
|
+
*
|
|
804
|
+
* `Shift+F10`, the `ContextMenu` key and a long press all open it, because a
|
|
805
|
+
* command reachable only by right-click is reachable only by a pointer, which
|
|
806
|
+
* is a WCAG 2.1.1 failure. `context-menu.js` says why the trigger is in the tab
|
|
807
|
+
* order and when to take it out again.
|
|
808
|
+
*
|
|
809
|
+
* The body needs an `aria-label`: its trigger is a table row or a canvas rather
|
|
810
|
+
* than a short name, so unlike `Menu.Body` it cannot name itself after one.
|
|
811
|
+
*
|
|
812
|
+
* <ContextMenu.Root>
|
|
813
|
+
* <ContextMenu.Trigger>{row}</ContextMenu.Trigger>
|
|
814
|
+
* <ContextMenu.Body aria-label="Row actions">
|
|
815
|
+
* <ContextMenu.Item onSelect={rename}>Rename…</ContextMenu.Item>
|
|
816
|
+
* <ContextMenu.CheckboxItem defaultChecked>Show hidden</ContextMenu.CheckboxItem>
|
|
817
|
+
* </ContextMenu.Body>
|
|
818
|
+
* </ContextMenu.Root>
|
|
819
|
+
*/
|
|
820
|
+
export const ContextMenu = {
|
|
821
|
+
Root: ContextMenuRoot,
|
|
822
|
+
Trigger: ContextMenuTrigger,
|
|
823
|
+
Body: MenuBody,
|
|
824
|
+
Item: MenuItem,
|
|
825
|
+
CheckboxItem: MenuCheckboxItem,
|
|
826
|
+
RadioGroup: MenuRadioGroup,
|
|
827
|
+
RadioItem: MenuRadioItem,
|
|
828
|
+
Separator: MenuSeparator,
|
|
829
|
+
Group: MenuGroup,
|
|
830
|
+
Label: MenuLabel,
|
|
831
|
+
Sub: MenuSub,
|
|
832
|
+
SubTrigger: MenuSubTrigger,
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* A row of menus that behaves as one control: File, Edit, View.
|
|
837
|
+
*
|
|
838
|
+
* One tab stop for the whole bar, arrows between the menus, and — the part that
|
|
839
|
+
* is always missing — arrows *while a menu is open* that close it and open the
|
|
840
|
+
* next one, so a reader walks File → Edit → View without pressing Escape.
|
|
841
|
+
*
|
|
842
|
+
* <Menubar.Root aria-label="Main">
|
|
843
|
+
* <Menubar.Menu value="file">
|
|
844
|
+
* <Menubar.Trigger>File</Menubar.Trigger>
|
|
845
|
+
* <Menubar.Body>
|
|
846
|
+
* <Menubar.Item onSelect={open}>Open…</Menubar.Item>
|
|
847
|
+
* </Menubar.Body>
|
|
848
|
+
* </Menubar.Menu>
|
|
849
|
+
* </Menubar.Root>
|
|
850
|
+
*/
|
|
851
|
+
export const Menubar = {
|
|
852
|
+
Root: MenubarRoot,
|
|
853
|
+
Menu: MenubarMenu,
|
|
854
|
+
Trigger: MenubarTrigger,
|
|
855
|
+
// `Menu.Body` itself: a bar's menu is a root menu, and `menubar.js`'s header
|
|
856
|
+
// says why a wrapper with the same defaults would be a second place to drift.
|
|
857
|
+
Body: MenuBody,
|
|
858
|
+
Item: MenuItem,
|
|
859
|
+
CheckboxItem: MenuCheckboxItem,
|
|
860
|
+
RadioGroup: MenuRadioGroup,
|
|
861
|
+
RadioItem: MenuRadioItem,
|
|
742
862
|
Separator: MenuSeparator,
|
|
743
863
|
Group: MenuGroup,
|
|
744
864
|
Label: MenuLabel,
|
|
@@ -755,13 +875,19 @@ export const Menu = {
|
|
|
755
875
|
* <Combobox.Label>Country</Combobox.Label>
|
|
756
876
|
* <Combobox.Input />
|
|
757
877
|
* <Combobox.List>
|
|
758
|
-
*
|
|
759
|
-
* <Combobox.
|
|
760
|
-
*
|
|
878
|
+
* <Combobox.Group>
|
|
879
|
+
* <Combobox.GroupLabel>Europe</Combobox.GroupLabel>
|
|
880
|
+
* {european.map((each) => (
|
|
881
|
+
* <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
|
|
882
|
+
* ))}
|
|
883
|
+
* </Combobox.Group>
|
|
761
884
|
* </Combobox.List>
|
|
762
885
|
* <Combobox.Empty>No matches.</Combobox.Empty>
|
|
763
886
|
* <Combobox.Status />
|
|
764
887
|
* </Combobox.Root>
|
|
888
|
+
*
|
|
889
|
+
* `Combobox.Label` names the field and `Combobox.GroupLabel` names a group of
|
|
890
|
+
* options, which is why there are two of them.
|
|
765
891
|
*/
|
|
766
892
|
export const Combobox = {
|
|
767
893
|
Root: ComboboxRoot,
|
|
@@ -769,6 +895,8 @@ export const Combobox = {
|
|
|
769
895
|
Input: ComboboxInput,
|
|
770
896
|
List: ComboboxList,
|
|
771
897
|
Option: ComboboxOption,
|
|
898
|
+
Group: ComboboxGroup,
|
|
899
|
+
GroupLabel: ComboboxGroupLabel,
|
|
772
900
|
Empty: ComboboxEmpty,
|
|
773
901
|
Status: ComboboxStatus,
|
|
774
902
|
};
|
|
@@ -837,6 +965,64 @@ export const Popover = {
|
|
|
837
965
|
Body: PopoverBody,
|
|
838
966
|
};
|
|
839
967
|
|
|
968
|
+
/**
|
|
969
|
+
* A month of dates, as one stop in the page's tab order.
|
|
970
|
+
*
|
|
971
|
+
* The grid is `role="grid"`, the arrow keys move by a day and by a week, and
|
|
972
|
+
* `PageUp` and `PageDown` change the month - with `Shift`, the year. Running off
|
|
973
|
+
* the end of a month shows the next one and lands on its first day, and the
|
|
974
|
+
* month is announced in a live region when it changes.
|
|
975
|
+
*
|
|
976
|
+
* <Calendar.Root defaultValue="2026-10-14" onValueChange={setWhen}>
|
|
977
|
+
* <Calendar.Previous>Previous month</Calendar.Previous>
|
|
978
|
+
* <Calendar.Next>Next month</Calendar.Next>
|
|
979
|
+
* <Calendar.Month />
|
|
980
|
+
* </Calendar.Root>
|
|
981
|
+
*
|
|
982
|
+
* `Calendar.Month` takes a function child when a day needs more than its number
|
|
983
|
+
* in it - a dot for an appointment, a price for a night - and it is handed the
|
|
984
|
+
* date and returns a `Calendar.Day`.
|
|
985
|
+
*
|
|
986
|
+
* Dates are `@uniflowed/temporal`'s `PlainDate`, or the ISO strings it reads.
|
|
987
|
+
* `isDateDisabled` marks a day unavailable *without* making it unreachable: it
|
|
988
|
+
* is `aria-disabled` and the arrow keys still land on it, which is the opposite
|
|
989
|
+
* of what a disabled menu item does and the only way a reader can find out which
|
|
990
|
+
* days are unavailable.
|
|
991
|
+
*/
|
|
992
|
+
export const Calendar = {
|
|
993
|
+
Root: CalendarRoot,
|
|
994
|
+
Previous: CalendarPrevious,
|
|
995
|
+
Next: CalendarNext,
|
|
996
|
+
Month: CalendarMonth,
|
|
997
|
+
Day: CalendarDay,
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* A field somebody types a date into, and a calendar for the times they would
|
|
1002
|
+
* rather point at one.
|
|
1003
|
+
*
|
|
1004
|
+
* <DatePicker.Root onValueChange={setWhen} value={when}>
|
|
1005
|
+
* <DatePicker.Input aria-label="Arrive on" />
|
|
1006
|
+
* <DatePicker.Trigger>Choose a date</DatePicker.Trigger>
|
|
1007
|
+
* <DatePicker.Calendar>
|
|
1008
|
+
* <Calendar.Previous>Previous month</Calendar.Previous>
|
|
1009
|
+
* <Calendar.Next>Next month</Calendar.Next>
|
|
1010
|
+
* <Calendar.Month />
|
|
1011
|
+
* </DatePicker.Calendar>
|
|
1012
|
+
* </DatePicker.Root>
|
|
1013
|
+
*
|
|
1014
|
+
* The field is the control and the grid is the second way in: `Escape` and a
|
|
1015
|
+
* chosen date both put focus back on the field. `format` and `parse` are ISO
|
|
1016
|
+
* 8601 both ways unless a caller passes their own - `date-picker.js` says why a
|
|
1017
|
+
* locale format is not this package's to guess.
|
|
1018
|
+
*/
|
|
1019
|
+
export const DatePicker = {
|
|
1020
|
+
Root: DatePickerRoot,
|
|
1021
|
+
Input: DatePickerInput,
|
|
1022
|
+
Trigger: DatePickerTrigger,
|
|
1023
|
+
Calendar: DatePickerCalendar,
|
|
1024
|
+
};
|
|
1025
|
+
|
|
840
1026
|
/**
|
|
841
1027
|
* A phrase about a control, on hover and on focus, that WCAG would accept.
|
|
842
1028
|
*
|