@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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.
Files changed (52) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -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, 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,28 +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
- // 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
 
31
96
  import * as React from "@uniflowed/react";
32
97
  import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
33
98
 
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({});
140
+
34
141
  type FieldState = {|
35
142
  readonly controlId: string,
36
143
  readonly labelId: string,
37
144
  readonly descriptionId: string,
145
+ readonly statusId: string,
38
146
  readonly errorId: string,
39
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,
40
153
  readonly describedBy: string | void,
41
154
  readonly registerDescription: (present: boolean) => void,
155
+ readonly registerStatus: (present: boolean) => void,
42
156
  readonly registerError: (present: boolean) => void,
43
157
  |};
44
158
 
@@ -61,78 +175,151 @@ hook useField(part: string): FieldState {
61
175
  /**
62
176
  * The field's container, and the only place ids are made.
63
177
  *
64
- * `invalid` is the root's business rather than the control's because three
65
- * parts have to agree about it: the control says `aria-invalid`, the error
66
- * message is rendered or not, and the control's `aria-describedby` includes the
67
- * 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.
68
189
  */
69
190
  export component FieldRoot(
70
191
  children: React.Node,
71
192
  invalid?: boolean = false,
72
- ...rest: { readonly [string]: mixed }
193
+ required?: boolean = false,
194
+ busy?: boolean = false,
195
+ field?: FieldSource,
196
+ group?: boolean = false,
197
+ render?: RenderProp,
198
+ ...rest: Rest
73
199
  ) {
74
200
  const base = useId();
75
201
  const [hasDescription, setHasDescription] = useState(false);
202
+ const [hasStatus, setHasStatus] = useState(false);
76
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;
77
209
 
78
210
  const state = useMemo(() => {
79
211
  const descriptionId = `${base}-description`;
212
+ const statusId = `${base}-status`;
80
213
  const errorId = `${base}-error`;
214
+ const wrong = invalid || sourceInvalid;
81
215
  // Only ids that are in the document. `aria-describedby` naming a missing
82
216
  // element makes a screen reader announce nothing rather than skipping it.
83
217
  const described = [
84
218
  hasDescription ? descriptionId : null,
85
- invalid && hasError ? errorId : null,
219
+ hasStatus ? statusId : null,
220
+ wrong && hasError ? errorId : null,
86
221
  ].filter(Boolean);
87
222
 
88
223
  return {
89
224
  controlId: `${base}-control`,
90
225
  labelId: `${base}-label`,
91
226
  descriptionId,
227
+ statusId,
92
228
  errorId,
93
- invalid,
229
+ invalid: wrong,
230
+ required: required || sourceRequired,
231
+ busy: busy || sourceBusy,
232
+ group,
233
+ message,
234
+ control,
94
235
  describedBy: described.length === 0 ? undefined : described.join(" "),
95
236
  registerDescription: setHasDescription,
237
+ registerStatus: setHasStatus,
96
238
  registerError: setHasError,
97
239
  };
98
- }, [base, invalid, hasDescription, hasError]);
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
+ });
99
267
 
100
268
  return (
101
269
  <FieldContext.Provider value={state}>
102
- <div {...rest}>{children}</div>
270
+ {render != null ? render(props) : <div {...props} />}
103
271
  </FieldContext.Provider>
104
272
  );
105
273
  }
106
274
 
107
- /** The label, pointing at the control by id rather than by nesting. */
108
- export component FieldLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
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) {
109
283
  const field = useField("Field.Label");
110
284
  // `rest` first: a caller `id` here would break the relationship the control
111
285
  // points at, and it would break it silently.
112
- return (
113
- <label {...rest} htmlFor={field.controlId} id={field.labelId}>
114
- {children}
115
- </label>
116
- );
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} />;
117
292
  }
118
293
 
119
294
  /**
120
295
  * The control, given every attribute the rest of the field implies.
121
296
  *
122
- * 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.
123
302
  */
124
- export component FieldControl(render: (props: { readonly [string]: mixed }) => React.Node) {
303
+ export component FieldControl(render: RenderProp) {
125
304
  const field = useField("Field.Control");
126
- return render({
127
- id: field.controlId,
128
- "aria-labelledby": field.labelId,
129
- "aria-describedby": field.describedBy,
130
- "aria-invalid": field.invalid ? "true" : undefined,
131
- });
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));
132
319
  }
133
320
 
134
321
  /** Help text, which the control points at while it is rendered. */
135
- export component FieldDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
322
+ export component FieldDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
136
323
  const field = useField("Field.Description");
137
324
  const register = field.registerDescription;
138
325
  useEffect(() => {
@@ -140,20 +327,47 @@ export component FieldDescription(children: React.Node, ...rest: { readonly [str
140
327
  return () => register(false);
141
328
  }, [register]);
142
329
 
143
- return (
144
- <p {...rest} id={field.descriptionId}>
145
- {children}
146
- </p>
147
- );
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} />;
148
356
  }
149
357
 
150
358
  /**
151
359
  * The error message, rendered only when the field is invalid.
152
360
  *
153
361
  * `role="alert"` so it is announced when it appears, which is the point of an
154
- * error that arrives after a blur or a submit.
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.
155
369
  */
156
- export component FieldError(children: React.Node, ...rest: { readonly [string]: mixed }) {
370
+ export component FieldError(children?: React.Node, render?: RenderProp, ...rest: Rest) {
157
371
  const field = useField("Field.Error");
158
372
  const register = field.registerError;
159
373
  useEffect(() => {
@@ -164,9 +378,10 @@ export component FieldError(children: React.Node, ...rest: { readonly [string]:
164
378
  if (!field.invalid) {
165
379
  return null;
166
380
  }
167
- return (
168
- <p {...rest} id={field.errorId} role="alert">
169
- {children}
170
- </p>
171
- );
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} />;
172
387
  }