@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/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, and every id has to be unique on the page and stable across renders.
9
- // Doing that by hand is four attributes and two `useId` calls per field, and
10
- // getting one wrong is silent — the field looks right and a screen reader
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
- // four attributes on whatever element it eventually renders. A component that
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` is the root's business rather than the control's because three
67
- * parts have to agree about it: the control says `aria-invalid`, the error
68
- * message is rendered or not, and the control's `aria-describedby` includes the
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(children: React.Node, invalid?: boolean = false, ...rest: Rest) {
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
- invalid && hasError ? errorId : null,
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
- }, [base, invalid, hasDescription, hasError]);
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 {...rest}>{children}</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
- /** The label, pointing at the control by id rather than by nesting. */
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
- return render({
125
- id: field.controlId,
126
- "aria-labelledby": field.labelId,
127
- "aria-describedby": field.describedBy,
128
- "aria-invalid": field.invalid ? "true" : undefined,
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 submit.
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: React.Node, ...rest: Rest) {
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, Side } from "./internal/anchor.js";
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?: Side = "bottom",
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, submenus and `Escape` stacking.
102
- // - `combobox.js` — `aria-activedescendant` over a filtered list, and the
103
- // count a screen reader is told.
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 nine modules and nothing else, each a rule the primitives
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). Each says in its own header why it
154
- // is unreachable rather than exported. There is no `internal/props.js`-shaped
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
- export type { Align, Side } from "./popover.js";
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
- * {matches.map((each) => (
759
- * <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
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
  *