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

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/checkbox.js CHANGED
@@ -31,6 +31,7 @@
31
31
 
32
32
  import * as React from "@uniflowed/react";
33
33
 
34
+ import type { Rest } from "./internal/merge-props.js";
34
35
  import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
35
36
  import { useControlled } from "./internal/controlled-state.js";
36
37
 
@@ -42,7 +43,7 @@ export component Checkbox(
42
43
  onCheckedChange?: (checked: boolean) => void,
43
44
  disabled?: boolean = false,
44
45
  children?: React.Node,
45
- ...rest: { readonly [string]: mixed }
46
+ ...rest: Rest
46
47
  ) {
47
48
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
48
49
  // A mixed checkbox moves to checked, not to "the opposite of the boolean
package/combobox.js CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  } from "@uniflowed/react";
65
65
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
66
66
 
67
+ import type { Rest } from "./internal/merge-props.js";
67
68
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
68
69
  import { itemsOf, moveTo } from "./internal/roving-focus.js";
69
70
  import { useControlled } from "./internal/controlled-state.js";
@@ -134,7 +135,7 @@ export component ComboboxRoot(
134
135
  open?: boolean,
135
136
  defaultOpen?: boolean = false,
136
137
  onOpenChange?: (open: boolean) => void,
137
- ...rest: { readonly [string]: mixed }
138
+ ...rest: Rest
138
139
  ) {
139
140
  const base = useId();
140
141
  const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
@@ -203,7 +204,7 @@ export component ComboboxRoot(
203
204
  * because the list names it, and naming a label that is not rendered is worse
204
205
  * than leaving the list unnamed.
205
206
  */
206
- export component ComboboxLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
207
+ export component ComboboxLabel(children: React.Node, ...rest: Rest) {
207
208
  const combobox = useCombobox("Combobox.Label");
208
209
  const register = combobox.registerLabel;
209
210
  useEffect(() => {
@@ -219,7 +220,7 @@ export component ComboboxLabel(children: React.Node, ...rest: { readonly [string
219
220
  }
220
221
 
221
222
  /** The text field, and every key the pattern defines. */
222
- export component ComboboxInput(...rest: { readonly [string]: mixed }) {
223
+ export component ComboboxInput(...rest: Rest) {
223
224
  const combobox = useCombobox("Combobox.Input");
224
225
  const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
225
226
 
@@ -337,10 +338,7 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
337
338
  * the count the live region announces, and the invariant that
338
339
  * `aria-activedescendant` never names an option that has left the list.
339
340
  */
340
- export component ComboboxList(
341
- children: renders* ComboboxOption,
342
- ...rest: { readonly [string]: mixed }
343
- ) {
341
+ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest) {
344
342
  const combobox = useCombobox("Combobox.List");
345
343
  const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
346
344
  const close = useStableCallback(() => {
@@ -444,7 +442,7 @@ export component ComboboxOption(
444
442
  children: React.Node,
445
443
  label?: string,
446
444
  disabled?: boolean = false,
447
- ...rest: { readonly [string]: mixed }
445
+ ...rest: Rest
448
446
  ) {
449
447
  const combobox = useCombobox("Combobox.Option");
450
448
  const id = useId();
@@ -495,7 +493,7 @@ export component ComboboxOption(
495
493
  * contain options: an "no matches" row inside one is announced as an option a
496
494
  * reader can choose, and choosing it does nothing.
497
495
  */
498
- export component ComboboxEmpty(children: React.Node, ...rest: { readonly [string]: mixed }) {
496
+ export component ComboboxEmpty(children: React.Node, ...rest: Rest) {
499
497
  const combobox = useCombobox("Combobox.Empty");
500
498
  if (!combobox.open || combobox.count > 0) {
501
499
  return null;
@@ -514,7 +512,7 @@ export component ComboboxEmpty(children: React.Node, ...rest: { readonly [string
514
512
  * `children` overrides the wording — the default is English and a real
515
513
  * application has a translation table.
516
514
  */
517
- export component ComboboxStatus(children?: React.Node, ...rest: { readonly [string]: mixed }) {
515
+ export component ComboboxStatus(children?: React.Node, ...rest: Rest) {
518
516
  const combobox = useCombobox("Combobox.Status");
519
517
  const message = children ?? defaultAnnouncement(combobox.open, combobox.count);
520
518
 
package/dialog.js CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  } from "@uniflowed/react";
52
52
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
53
53
 
54
+ import type { Rest } from "./internal/merge-props.js";
54
55
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
55
56
  import { useControlled } from "./internal/controlled-state.js";
56
57
 
@@ -114,7 +115,7 @@ export component DialogRoot(
114
115
  }
115
116
 
116
117
  /** What opens the dialog, and what focus comes back to when it closes. */
117
- export component DialogTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
118
+ export component DialogTrigger(children: React.Node, ...rest: Rest) {
118
119
  const dialog = useDialog("Dialog.Trigger");
119
120
  const passed = withoutComposed(rest, ["onClick", "ref"]);
120
121
 
@@ -147,7 +148,7 @@ export component DialogTrigger(children: React.Node, ...rest: { readonly [string
147
148
  * their own backdrop or omits one entirely must still get it. That lives on
148
149
  * `Dialog.Body`, which is the part that knows where "outside" is.
149
150
  */
150
- export component DialogOverlay(...rest: { readonly [string]: mixed }) {
151
+ export component DialogOverlay(...rest: Rest) {
151
152
  const dialog = useDialog("Dialog.Overlay");
152
153
  if (!dialog.open) {
153
154
  return null;
@@ -162,7 +163,7 @@ export component DialogOverlay(...rest: { readonly [string]: mixed }) {
162
163
  * which is the half of "modal" that CSS cannot express; `inert` on everything
163
164
  * outside is the half the browser enforces.
164
165
  */
165
- export component DialogBody(children: React.Node, ...rest: { readonly [string]: mixed }) {
166
+ export component DialogBody(children: React.Node, ...rest: Rest) {
166
167
  const dialog = useDialog("Dialog.Body");
167
168
  const bodyRef = useRef<HTMLElement | null>(null);
168
169
  // Stable, so the effect below depends on `open` and on nothing else. Keyed on
@@ -298,7 +299,7 @@ export component DialogBody(children: React.Node, ...rest: { readonly [string]:
298
299
  * rendered — a conditional title that is absent used to leave the dialog
299
300
  * pointing at an id nothing had.
300
301
  */
301
- export component DialogTitle(children: React.Node, ...rest: { readonly [string]: mixed }) {
302
+ export component DialogTitle(children: React.Node, ...rest: Rest) {
302
303
  const dialog = useDialog("Dialog.Title");
303
304
  const register = dialog.registerTitle;
304
305
  useEffect(() => {
@@ -320,7 +321,7 @@ export component DialogTitle(children: React.Node, ...rest: { readonly [string]:
320
321
  * the one moment the reader has to decide whether they care — so this is where
321
322
  * "this cannot be undone" belongs, not in body text further down.
322
323
  */
323
- export component DialogDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
324
+ export component DialogDescription(children: React.Node, ...rest: Rest) {
324
325
  const dialog = useDialog("Dialog.Description");
325
326
  const register = dialog.registerDescription;
326
327
  useEffect(() => {
@@ -344,17 +345,17 @@ export component DialogDescription(children: React.Node, ...rest: { readonly [st
344
345
  * styling layer has a name to attach to, and contributes no semantics because
345
346
  * it has none to contribute.
346
347
  */
347
- export component DialogHeader(children: React.Node, ...rest: { readonly [string]: mixed }) {
348
+ export component DialogHeader(children: React.Node, ...rest: Rest) {
348
349
  return <div {...rest}>{children}</div>;
349
350
  }
350
351
 
351
352
  /** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
352
- export component DialogFooter(children: React.Node, ...rest: { readonly [string]: mixed }) {
353
+ export component DialogFooter(children: React.Node, ...rest: Rest) {
353
354
  return <div {...rest}>{children}</div>;
354
355
  }
355
356
 
356
357
  /** A button that closes the dialog. */
357
- export component DialogClose(children: React.Node, ...rest: { readonly [string]: mixed }) {
358
+ export component DialogClose(children: React.Node, ...rest: Rest) {
358
359
  const dialog = useDialog("Dialog.Close");
359
360
  const passed = withoutComposed(rest, ["onClick"]);
360
361
 
package/field.js CHANGED
@@ -31,6 +31,8 @@
31
31
  import * as React from "@uniflowed/react";
32
32
  import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
33
33
 
34
+ import type { Rest } from "./internal/merge-props.js";
35
+
34
36
  type FieldState = {|
35
37
  readonly controlId: string,
36
38
  readonly labelId: string,
@@ -66,11 +68,7 @@ hook useField(part: string): FieldState {
66
68
  * message is rendered or not, and the control's `aria-describedby` includes the
67
69
  * error's id or not.
68
70
  */
69
- export component FieldRoot(
70
- children: React.Node,
71
- invalid?: boolean = false,
72
- ...rest: { readonly [string]: mixed }
73
- ) {
71
+ export component FieldRoot(children: React.Node, invalid?: boolean = false, ...rest: Rest) {
74
72
  const base = useId();
75
73
  const [hasDescription, setHasDescription] = useState(false);
76
74
  const [hasError, setHasError] = useState(false);
@@ -105,7 +103,7 @@ export component FieldRoot(
105
103
  }
106
104
 
107
105
  /** The label, pointing at the control by id rather than by nesting. */
108
- export component FieldLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
106
+ export component FieldLabel(children: React.Node, ...rest: Rest) {
109
107
  const field = useField("Field.Label");
110
108
  // `rest` first: a caller `id` here would break the relationship the control
111
109
  // points at, and it would break it silently.
@@ -121,7 +119,7 @@ export component FieldLabel(children: React.Node, ...rest: { readonly [string]:
121
119
  *
122
120
  * See the module header for why this takes a render function.
123
121
  */
124
- export component FieldControl(render: (props: { readonly [string]: mixed }) => React.Node) {
122
+ export component FieldControl(render: (props: Rest) => React.Node) {
125
123
  const field = useField("Field.Control");
126
124
  return render({
127
125
  id: field.controlId,
@@ -132,7 +130,7 @@ export component FieldControl(render: (props: { readonly [string]: mixed }) => R
132
130
  }
133
131
 
134
132
  /** Help text, which the control points at while it is rendered. */
135
- export component FieldDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
133
+ export component FieldDescription(children: React.Node, ...rest: Rest) {
136
134
  const field = useField("Field.Description");
137
135
  const register = field.registerDescription;
138
136
  useEffect(() => {
@@ -153,7 +151,7 @@ export component FieldDescription(children: React.Node, ...rest: { readonly [str
153
151
  * `role="alert"` so it is announced when it appears, which is the point of an
154
152
  * error that arrives after a blur or a submit.
155
153
  */
156
- export component FieldError(children: React.Node, ...rest: { readonly [string]: mixed }) {
154
+ export component FieldError(children: React.Node, ...rest: Rest) {
157
155
  const field = useField("Field.Error");
158
156
  const register = field.registerError;
159
157
  useEffect(() => {
@@ -28,8 +28,52 @@
28
28
  // consumer to build a part that spreads `rest` last, which is the failure this
29
29
  // exists to prevent — so it stays unreachable from outside the package.
30
30
 
31
- /** Anything a caller can spread onto an element. */
32
- export type Rest = { readonly [string]: mixed };
31
+ /**
32
+ * Props on their way onto an element: what a caller hands a part, and what
33
+ * `Field.Control` hands back for a caller to spread.
34
+ *
35
+ * `key` is named out of the indexer rather than left to it, and that one
36
+ * property is the whole subtlety of this type. React takes `key` off the
37
+ * attributes before a component is called, so a part's props never contain
38
+ * one — but an indexer does not know that, and `{ readonly [string]: mixed }`
39
+ * answers `mixed` for every name, `key` included. React's `key` is
40
+ * `string | number`, so every intrinsic this package rendered was rejected for
41
+ * a property that cannot be there:
42
+ *
43
+ * error[incompatible-type]: Cannot create button element because in
44
+ * property key: Either unknown is incompatible with string. Or unknown is
45
+ * incompatible with number.
46
+ *
47
+ * thirty-two times, one per element, which was 32 of `@uniflowed/ui`'s 73 type
48
+ * errors. `key?: empty` states what React already guarantees, and the errors
49
+ * are the checker agreeing.
50
+ *
51
+ * # Two answers that look better than they are
52
+ *
53
+ * **`readonly key?: string | number`** — React's own type for the property —
54
+ * also silences the error, and is a lie in the shape of a fix. It says a
55
+ * caller may pass a `key` here; a part would then spread it onto its element,
56
+ * which is the "spreading a key into JSX" mistake React 19 added a warning
57
+ * for. `empty` is the same repair and a true sentence. It reads oddly for
58
+ * about a second and then reads as exactly what it is: there is no value you
59
+ * can pass under this name.
60
+ *
61
+ * **`React.PropsOf<"button">`** — the props of the element actually being
62
+ * rendered, which is what this type would like to say — cannot be written
63
+ * here. uf does not merge Flow's `jsx.js` environment, deliberately and for
64
+ * reasons `crates/uf_check/src/upstream/environments.rs` gives, so
65
+ * `$JSXIntrinsics` is the bare-bones table in `lib/react.js`, every
66
+ * intrinsic's `props` is `any`, and `React.PropsOf` itself reads as an
67
+ * any-typed value. Nothing about an element is checked here except its `key`:
68
+ * `<button className={5} nonsenseAttr={{}} />` is not an error today. A named
69
+ * type per element would therefore not be React's contract but a hand-written
70
+ * copy of `jsx.js` living in a UI package, drifting from the DOM on its own
71
+ * schedule — and it would still need an indexer for `data-*` and `aria-*`,
72
+ * which is where this started. So it stays one `Rest`, and the day
73
+ * `$JSXIntrinsics` is real is the day this becomes `React.PropsOf` and the
74
+ * parts say which element they render.
75
+ */
76
+ export type Rest = { readonly key?: empty, readonly [string]: mixed };
33
77
 
34
78
  /**
35
79
  * Call the caller's handler and then the component's.
@@ -76,10 +120,16 @@ export function composeRefs<T>(
76
120
  * leaving them in would put the caller's copy back on top of the composed one.
77
121
  */
78
122
  export function withoutComposed(rest: Rest, names: $ReadOnlyArray<string>): Rest {
79
- const kept: { [string]: mixed } = {};
80
- for (const key of Object.keys(rest)) {
81
- if (!names.includes(key)) {
82
- kept[key] = rest[key];
123
+ const kept: { key?: empty, [string]: mixed } = {};
124
+ for (const name of Object.keys(rest)) {
125
+ // `key` is dropped whatever the caller asked to compose, because it is the
126
+ // one name the indexer does not speak for: writing `rest[name]` under it
127
+ // would put a `mixed` back where `Rest` promises nothing can be, and Flow
128
+ // says so. Nothing is lost — React removed the `key` long before this ran,
129
+ // so this is the type-level statement made at runtime rather than a filter
130
+ // that ever has work to do.
131
+ if (name !== "key" && !names.includes(name)) {
132
+ kept[name] = rest[name];
83
133
  }
84
134
  }
85
135
  return kept;
package/menu.js CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  } from "@uniflowed/react";
59
59
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
60
60
 
61
+ import type { Rest } from "./internal/merge-props.js";
61
62
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
62
63
  import {
63
64
  indexOfActive,
@@ -257,7 +258,7 @@ component MenuLevel(
257
258
  }
258
259
 
259
260
  /** The button that opens the menu. */
260
- export component MenuTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
261
+ export component MenuTrigger(children: React.Node, ...rest: Rest) {
261
262
  const menu = useMenu("Menu.Trigger");
262
263
  const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
263
264
  useTriggerRegistration(menu);
@@ -308,7 +309,7 @@ export component MenuTrigger(children: React.Node, ...rest: { readonly [string]:
308
309
  */
309
310
  export component MenuBody(
310
311
  children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
311
- ...rest: { readonly [string]: mixed }
312
+ ...rest: Rest
312
313
  ) {
313
314
  const menu = useMenu("Menu.Body");
314
315
  const bodyRef = useRef<HTMLElement | null>(null);
@@ -481,7 +482,7 @@ export component MenuItem(
481
482
  children: React.Node,
482
483
  disabled?: boolean = false,
483
484
  onSelect?: () => mixed,
484
- ...rest: { readonly [string]: mixed }
485
+ ...rest: Rest
485
486
  ) {
486
487
  const menu = useMenu("Menu.Item");
487
488
  const list = useContext(MenuListContext);
@@ -521,7 +522,7 @@ export component MenuItem(
521
522
  * is why it reads the list context of the menu around it and the menu context
522
523
  * of the one below it.
523
524
  */
524
- export component MenuSubTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
525
+ export component MenuSubTrigger(children: React.Node, ...rest: Rest) {
525
526
  const menu = useMenu("Menu.SubTrigger");
526
527
  const list = useContext(MenuListContext);
527
528
  const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
@@ -574,7 +575,7 @@ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [strin
574
575
  * moving through the menu is told the group changed. It is not focusable and
575
576
  * the arrow keys pass straight over it.
576
577
  */
577
- export component MenuSeparator(...rest: { readonly [string]: mixed }) {
578
+ export component MenuSeparator(...rest: Rest) {
578
579
  return <div {...rest} aria-orientation="horizontal" role="separator" />;
579
580
  }
580
581
 
@@ -586,7 +587,7 @@ export component MenuSeparator(...rest: { readonly [string]: mixed }) {
586
587
  * that is not in the document makes a screen reader announce *nothing*, which
587
588
  * is worse than an unnamed group.
588
589
  */
589
- export component MenuGroup(children: React.Node, ...rest: { readonly [string]: mixed }) {
590
+ export component MenuGroup(children: React.Node, ...rest: Rest) {
590
591
  const base = useId();
591
592
  const [labelled, setLabelled] = useState(false);
592
593
 
@@ -608,7 +609,7 @@ export component MenuGroup(children: React.Node, ...rest: { readonly [string]: m
608
609
  * as ordinary content would have a reader hear the heading once as the group's
609
610
  * name and again as a stray line of text between the items.
610
611
  */
611
- export component MenuLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
612
+ export component MenuLabel(children: React.Node, ...rest: Rest) {
612
613
  const group = useContext(MenuGroupContext);
613
614
  const register = group?.registerLabel;
614
615
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.0.0-alpha.4",
3
+ "version": "0.0.0-alpha.6",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,8 +25,8 @@
25
25
  "internal"
26
26
  ],
27
27
  "dependencies": {
28
- "@uniflowed/hooks": "0.0.0-alpha.4",
29
- "@uniflowed/react": "0.0.0-alpha.4"
28
+ "@uniflowed/hooks": "0.0.0-alpha.6",
29
+ "@uniflowed/react": "0.0.0-alpha.6"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "react": ">=19"
package/switch.js CHANGED
@@ -27,6 +27,7 @@
27
27
 
28
28
  import * as React from "@uniflowed/react";
29
29
 
30
+ import type { Rest } from "./internal/merge-props.js";
30
31
  import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
31
32
  import { useControlled } from "./internal/controlled-state.js";
32
33
 
@@ -37,7 +38,7 @@ export component Switch(
37
38
  onCheckedChange?: (checked: boolean) => void,
38
39
  disabled?: boolean = false,
39
40
  children?: React.Node,
40
- ...rest: { readonly [string]: mixed }
41
+ ...rest: Rest
41
42
  ) {
42
43
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
43
44
  const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
package/tabs.js CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  useState,
51
51
  } from "@uniflowed/react";
52
52
 
53
+ import type { Rest } from "./internal/merge-props.js";
53
54
  import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
54
55
  import { indexOfActive, itemsOf, movementFor, moveTo } from "./internal/roving-focus.js";
55
56
  import { useControlled } from "./internal/controlled-state.js";
@@ -93,7 +94,7 @@ export component TabsRoot(
93
94
  onValueChange?: (value: string) => void,
94
95
  activationMode?: ActivationMode = "automatic",
95
96
  orientation?: Orientation = "horizontal",
96
- ...rest: { readonly [string]: mixed }
97
+ ...rest: Rest
97
98
  ) {
98
99
  const base = useId();
99
100
  const [selected, select] = useControlled(value, defaultValue, onValueChange);
@@ -141,7 +142,7 @@ export component TabsRoot(
141
142
  * tabs push themselves into as they mount answers with mount order, which stops
142
143
  * being document order the first time a tab is conditional.
143
144
  */
144
- export component TabsList(children: renders* TabsTab, ...rest: { readonly [string]: mixed }) {
145
+ export component TabsList(children: renders* TabsTab, ...rest: Rest) {
145
146
  const tabs = useTabs("Tabs.List");
146
147
  const passed = withoutComposed(rest, ["onKeyDown"]);
147
148
 
@@ -190,7 +191,7 @@ export component TabsTab(
190
191
  value: string,
191
192
  children: React.Node,
192
193
  disabled?: boolean = false,
193
- ...rest: { readonly [string]: mixed }
194
+ ...rest: Rest
194
195
  ) {
195
196
  const tabs = useTabs("Tabs.Tab");
196
197
  const active = tabs.selected === value;
@@ -248,11 +249,7 @@ export component TabsTab(
248
249
  * subscription rather than "the selected value equals mine", because a caller
249
250
  * may render a subset of panels, or none at all until data arrives.
250
251
  */
251
- export component TabsPanel(
252
- value: string,
253
- children: React.Node,
254
- ...rest: { readonly [string]: mixed }
255
- ) {
252
+ export component TabsPanel(value: string, children: React.Node, ...rest: Rest) {
256
253
  const tabs = useTabs("Tabs.Panel");
257
254
  const register = tabs.registerPanel;
258
255
  const selected = tabs.selected === value;