@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.5
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 +80 -0
- package/combobox.js +544 -0
- package/dialog.js +483 -0
- package/{internal/field.js → field.js} +31 -22
- package/index.js +182 -26
- package/internal/controlled-state.js +65 -0
- package/internal/merge-props.js +136 -0
- package/internal/roving-focus.js +236 -0
- package/menu.js +629 -0
- package/package.json +11 -7
- package/switch.js +73 -0
- package/tabs.js +283 -0
- package/internal/dialog.js +0 -236
- package/internal/props.js +0 -78
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
package/index.js
CHANGED
|
@@ -11,44 +11,128 @@
|
|
|
11
11
|
// That part is behaviour, and specifically keyboard and screen-reader
|
|
12
12
|
// behaviour: a roving `tabindex` so a twelve-tab list does not take twelve Tab
|
|
13
13
|
// presses to get past, a focus trap that actually cannot be escaped, focus
|
|
14
|
-
// restored to whatever opened a dialog,
|
|
15
|
-
//
|
|
16
|
-
// all of it is what separates a component
|
|
14
|
+
// restored to whatever opened a dialog, typeahead in a menu, an
|
|
15
|
+
// `aria-activedescendant` that names an option still in the document. None of
|
|
16
|
+
// it is visible in a screenshot and all of it is what separates a component
|
|
17
|
+
// from a `div` that looks like one.
|
|
18
|
+
//
|
|
19
|
+
// Each primitive implements the WAI-ARIA authoring practices pattern for it —
|
|
20
|
+
// the roles, the `aria-*` wiring, the focus management and the whole keyboard
|
|
21
|
+
// map — and each module's header says which interaction it exists to get right
|
|
22
|
+
// and what a naive version breaks.
|
|
17
23
|
//
|
|
18
24
|
// # Composition is type-checked
|
|
19
25
|
//
|
|
20
26
|
// This is where Flow says something no other type system can. `Tabs.List`
|
|
21
27
|
// declares `renders* Tabs.Tab`, so a `<button>` in a tab list is a *type
|
|
22
28
|
// error* — not a review comment, not a runtime warning, not a screen reader
|
|
23
|
-
// announcing "button" where the reader expected "tab, 2 of 5".
|
|
24
|
-
//
|
|
29
|
+
// announcing "button" where the reader expected "tab, 2 of 5". `Menu.Body` and
|
|
30
|
+
// `Combobox.List` state the same constraint about what may appear inside a
|
|
31
|
+
// menu and a listbox, which ARIA also requires and which nothing else checks.
|
|
32
|
+
// A library written in TypeScript can document those constraints; it cannot
|
|
33
|
+
// state them.
|
|
34
|
+
//
|
|
35
|
+
// # Styling is a default, not a dependency
|
|
36
|
+
//
|
|
37
|
+
// Nothing here imports StyleX, and nothing here has a StyleX-shaped type. A
|
|
38
|
+
// consumer styling with plain CSS, CSS Modules or anything else gets exactly
|
|
39
|
+
// the same components with exactly the same behaviour; the design-system layer
|
|
40
|
+
// that adds uf's default styles is built *on* these, not into them.
|
|
41
|
+
//
|
|
42
|
+
// # What these components promise React
|
|
43
|
+
//
|
|
44
|
+
// Nothing here mutates during a render, reads a ref during a render, or depends
|
|
45
|
+
// on a render happening exactly once — so React Compiler's memoization and
|
|
46
|
+
// ordinary `memo` are both safe, and none of it needs an escape hatch. The
|
|
47
|
+
// refs that exist (`triggerRef`, `pendingFocus`, the typeahead buffer) are
|
|
48
|
+
// written only from event handlers and effects, and nothing renders them.
|
|
49
|
+
//
|
|
50
|
+
// Where a component has to learn something the DOM knows — how many options a
|
|
51
|
+
// caller filtered down to, which item the arrow key should move to — it reads
|
|
52
|
+
// the document in an effect or an event handler and, if a render depends on
|
|
53
|
+
// the answer, puts it in state. It is deliberately *not* `useSyncExternalStore`:
|
|
54
|
+
// that is for a store whose value a render reads, and reading layout during a
|
|
55
|
+
// render is the thing it exists to prevent.
|
|
56
|
+
//
|
|
57
|
+
// # Server and client
|
|
58
|
+
//
|
|
59
|
+
// Every module that manages focus, listens to the document or holds state
|
|
60
|
+
// declares `"use client"`, because each of those needs a browser. That is a
|
|
61
|
+
// property of the components, not of the application: an RSC page may import
|
|
62
|
+
// this package from a Server Component, and only the parts that need the client
|
|
63
|
+
// join the client bundle.
|
|
64
|
+
//
|
|
65
|
+
// # How the package is laid out
|
|
66
|
+
//
|
|
67
|
+
// One root module per primitive, each with its own `exports` subpath, each
|
|
68
|
+
// named after the thing it implements:
|
|
69
|
+
//
|
|
70
|
+
// - `dialog.js` — the focus trap, focus restore, scroll lock and inert page.
|
|
71
|
+
// - `menu.js` — the arrow keys, typeahead, submenus and `Escape` stacking.
|
|
72
|
+
// - `combobox.js` — `aria-activedescendant` over a filtered list, and the
|
|
73
|
+
// count a screen reader is told.
|
|
74
|
+
// - `tabs.js` — the roving `tabindex`, and automatic versus manual activation.
|
|
75
|
+
// - `field.js` — the label, description, error and `aria-invalid` wiring.
|
|
76
|
+
// - `switch.js` and `checkbox.js` — the two two-state controls, apart because
|
|
77
|
+
// the third state and the `Enter` key genuinely differ between them.
|
|
78
|
+
//
|
|
79
|
+
// Every name below is exported from one of those, so a consumer may import
|
|
80
|
+
// `@uniflowed/ui` or `@uniflowed/ui/dialog` and get the same thing. The split
|
|
81
|
+
// is by primitive because that is the unit a reader looks for, the unit a
|
|
82
|
+
// bundler drops, and the unit the WAI-ARIA practices are written in.
|
|
83
|
+
//
|
|
84
|
+
// `internal/` holds three modules and nothing else, each a rule the primitives
|
|
85
|
+
// must apply identically and a consumer must not be able to apply differently:
|
|
86
|
+
// `merge-props.js` (the caller's props go on first, the component's semantics
|
|
87
|
+
// last), `controlled-state.js` (what "controlled" means here), and
|
|
88
|
+
// `roving-focus.js` (how a set of items is found and moved between). Each says
|
|
89
|
+
// in its own header why it is unreachable rather than exported. There is no
|
|
90
|
+
// `internal/props.js`-shaped bag of helpers: a module that cannot say what it
|
|
91
|
+
// is about does not belong in this package.
|
|
25
92
|
|
|
93
|
+
import { Checkbox } from "./checkbox.js";
|
|
26
94
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
95
|
+
ComboboxEmpty,
|
|
96
|
+
ComboboxInput,
|
|
97
|
+
ComboboxLabel,
|
|
98
|
+
ComboboxList,
|
|
99
|
+
ComboboxOption,
|
|
100
|
+
ComboboxRoot,
|
|
101
|
+
ComboboxStatus,
|
|
102
|
+
} from "./combobox.js";
|
|
33
103
|
import {
|
|
104
|
+
DialogBody,
|
|
34
105
|
DialogClose,
|
|
35
|
-
|
|
106
|
+
DialogDescription,
|
|
107
|
+
DialogFooter,
|
|
108
|
+
DialogHeader,
|
|
109
|
+
DialogOverlay,
|
|
36
110
|
DialogRoot,
|
|
37
111
|
DialogTitle,
|
|
38
112
|
DialogTrigger,
|
|
39
|
-
} from "./
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
113
|
+
} from "./dialog.js";
|
|
114
|
+
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from "./field.js";
|
|
115
|
+
import {
|
|
116
|
+
MenuBody,
|
|
117
|
+
MenuGroup,
|
|
118
|
+
MenuItem,
|
|
119
|
+
MenuLabel,
|
|
120
|
+
MenuRoot,
|
|
121
|
+
MenuSeparator,
|
|
122
|
+
MenuSub,
|
|
123
|
+
MenuSubTrigger,
|
|
124
|
+
MenuTrigger,
|
|
125
|
+
} from "./menu.js";
|
|
126
|
+
import { Switch } from "./switch.js";
|
|
127
|
+
import { TabsList, TabsPanel, TabsRoot, TabsTab } from "./tabs.js";
|
|
128
|
+
|
|
129
|
+
export type { ActivationMode } from "./tabs.js";
|
|
42
130
|
|
|
43
131
|
export { Checkbox, Switch };
|
|
44
132
|
|
|
45
133
|
/**
|
|
46
134
|
* An accessible form field.
|
|
47
135
|
*
|
|
48
|
-
* `Field.Control` takes a render function rather than rendering an `<input>`,
|
|
49
|
-
* because a field wraps a select, a textarea or somebody else's component just
|
|
50
|
-
* as often, and each needs the same attributes.
|
|
51
|
-
*
|
|
52
136
|
* <Field.Root invalid={error != null}>
|
|
53
137
|
* <Field.Label>Email</Field.Label>
|
|
54
138
|
* <Field.Control render={(props) => <input type="email" {...props} />} />
|
|
@@ -67,8 +151,11 @@ export const Field = {
|
|
|
67
151
|
/**
|
|
68
152
|
* Tabs, with the arrow-key behaviour the pattern requires.
|
|
69
153
|
*
|
|
154
|
+
* `activationMode="manual"` moves focus without selecting, for panels that cost
|
|
155
|
+
* something to show.
|
|
156
|
+
*
|
|
70
157
|
* <Tabs.Root defaultValue="one">
|
|
71
|
-
* <Tabs.List>
|
|
158
|
+
* <Tabs.List aria-label="Sections">
|
|
72
159
|
* <Tabs.Tab value="one">One</Tabs.Tab>
|
|
73
160
|
* <Tabs.Tab value="two">Two</Tabs.Tab>
|
|
74
161
|
* </Tabs.List>
|
|
@@ -87,17 +174,86 @@ export const Tabs = {
|
|
|
87
174
|
* A modal dialog: focus moved in, kept in, and given back.
|
|
88
175
|
*
|
|
89
176
|
* <Dialog.Root>
|
|
90
|
-
* <Dialog.Trigger>
|
|
91
|
-
* <Dialog.
|
|
92
|
-
*
|
|
93
|
-
* <Dialog.
|
|
94
|
-
*
|
|
177
|
+
* <Dialog.Trigger>Delete</Dialog.Trigger>
|
|
178
|
+
* <Dialog.Overlay />
|
|
179
|
+
* <Dialog.Body>
|
|
180
|
+
* <Dialog.Header>
|
|
181
|
+
* <Dialog.Title>Delete this project?</Dialog.Title>
|
|
182
|
+
* <Dialog.Description>This cannot be undone.</Dialog.Description>
|
|
183
|
+
* </Dialog.Header>
|
|
184
|
+
* <Dialog.Footer>
|
|
185
|
+
* <Dialog.Close>Cancel</Dialog.Close>
|
|
186
|
+
* </Dialog.Footer>
|
|
187
|
+
* </Dialog.Body>
|
|
95
188
|
* </Dialog.Root>
|
|
96
189
|
*/
|
|
97
190
|
export const Dialog = {
|
|
98
191
|
Root: DialogRoot,
|
|
99
192
|
Trigger: DialogTrigger,
|
|
100
|
-
|
|
193
|
+
Overlay: DialogOverlay,
|
|
194
|
+
Body: DialogBody,
|
|
195
|
+
Header: DialogHeader,
|
|
196
|
+
Footer: DialogFooter,
|
|
101
197
|
Title: DialogTitle,
|
|
198
|
+
Description: DialogDescription,
|
|
102
199
|
Close: DialogClose,
|
|
103
200
|
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A menu, with the keyboard map every native menu has had for thirty years.
|
|
204
|
+
*
|
|
205
|
+
* <Menu.Root>
|
|
206
|
+
* <Menu.Trigger>File</Menu.Trigger>
|
|
207
|
+
* <Menu.Body>
|
|
208
|
+
* <Menu.Group>
|
|
209
|
+
* <Menu.Label>Recent</Menu.Label>
|
|
210
|
+
* <Menu.Item onSelect={open}>Open…</Menu.Item>
|
|
211
|
+
* </Menu.Group>
|
|
212
|
+
* <Menu.Separator />
|
|
213
|
+
* <Menu.Sub>
|
|
214
|
+
* <Menu.SubTrigger>Export</Menu.SubTrigger>
|
|
215
|
+
* <Menu.Body>
|
|
216
|
+
* <Menu.Item onSelect={png}>PNG</Menu.Item>
|
|
217
|
+
* </Menu.Body>
|
|
218
|
+
* </Menu.Sub>
|
|
219
|
+
* </Menu.Body>
|
|
220
|
+
* </Menu.Root>
|
|
221
|
+
*/
|
|
222
|
+
export const Menu = {
|
|
223
|
+
Root: MenuRoot,
|
|
224
|
+
Trigger: MenuTrigger,
|
|
225
|
+
Body: MenuBody,
|
|
226
|
+
Item: MenuItem,
|
|
227
|
+
Separator: MenuSeparator,
|
|
228
|
+
Group: MenuGroup,
|
|
229
|
+
Label: MenuLabel,
|
|
230
|
+
Sub: MenuSub,
|
|
231
|
+
SubTrigger: MenuSubTrigger,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A text field with a list of options, navigated without leaving the field.
|
|
236
|
+
*
|
|
237
|
+
* The caller filters; the component keeps the ARIA wiring true while they do.
|
|
238
|
+
*
|
|
239
|
+
* <Combobox.Root inputValue={query} onInputValueChange={setQuery}>
|
|
240
|
+
* <Combobox.Label>Country</Combobox.Label>
|
|
241
|
+
* <Combobox.Input />
|
|
242
|
+
* <Combobox.List>
|
|
243
|
+
* {matches.map((each) => (
|
|
244
|
+
* <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
|
|
245
|
+
* ))}
|
|
246
|
+
* </Combobox.List>
|
|
247
|
+
* <Combobox.Empty>No matches.</Combobox.Empty>
|
|
248
|
+
* <Combobox.Status />
|
|
249
|
+
* </Combobox.Root>
|
|
250
|
+
*/
|
|
251
|
+
export const Combobox = {
|
|
252
|
+
Root: ComboboxRoot,
|
|
253
|
+
Label: ComboboxLabel,
|
|
254
|
+
Input: ComboboxInput,
|
|
255
|
+
List: ComboboxList,
|
|
256
|
+
Option: ComboboxOption,
|
|
257
|
+
Empty: ComboboxEmpty,
|
|
258
|
+
Status: ComboboxStatus,
|
|
259
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The one contract every part of this package makes about state.
|
|
4
|
+
//
|
|
5
|
+
// Each primitive here has a value somebody may want to own: a dialog's open,
|
|
6
|
+
// a tab set's selection, a switch's checked, a combobox's text. A library that
|
|
7
|
+
// only supports one of the two arrangements is unusable in the other half of
|
|
8
|
+
// applications — a form library owns the value, and a page that just wants tabs
|
|
9
|
+
// does not — so every one of them is uncontrolled by default and controlled the
|
|
10
|
+
// moment the corresponding prop is passed.
|
|
11
|
+
//
|
|
12
|
+
// Written once because the failure mode of writing it six times is that five of
|
|
13
|
+
// them agree and one does not, and the one that does not is a component that
|
|
14
|
+
// silently ignores the parent's value on the second render. The rules it fixes:
|
|
15
|
+
//
|
|
16
|
+
// * `undefined` means "not controlled", and `null` does not. A combobox with
|
|
17
|
+
// no selection is `value={null}` and is still controlled.
|
|
18
|
+
// * A controlled component never writes its internal state, so a parent that
|
|
19
|
+
// rejects a change actually rejects it, rather than the component moving
|
|
20
|
+
// and then being moved back on the next render.
|
|
21
|
+
// * `onChange` is called for both arrangements, so a caller can observe
|
|
22
|
+
// without taking ownership.
|
|
23
|
+
//
|
|
24
|
+
// # Why this is `internal/` and not a subpath
|
|
25
|
+
//
|
|
26
|
+
// A public `useControlled` would be a general-purpose hook, and general-purpose
|
|
27
|
+
// React hooks are `@uniflowed/hooks`' subject, not this package's. What lives
|
|
28
|
+
// here is narrower than that: the specific contract this package's components
|
|
29
|
+
// promise about their props. Exporting it would publish a second, weaker copy
|
|
30
|
+
// of somebody else's API.
|
|
31
|
+
|
|
32
|
+
import { useCallback, useState } from "@uniflowed/react";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A value the caller may own, and the setter that respects the answer.
|
|
36
|
+
*
|
|
37
|
+
* `controlled` is the prop; `fallback` is the `defaultValue`-shaped initial
|
|
38
|
+
* state used only while the caller owns nothing.
|
|
39
|
+
*/
|
|
40
|
+
export hook useControlled<T>(
|
|
41
|
+
controlled: T | void,
|
|
42
|
+
fallback: T,
|
|
43
|
+
onChange: ((next: T) => mixed) | void,
|
|
44
|
+
): [T, (next: T) => void] {
|
|
45
|
+
const [internal, setInternal] = useState<T>(fallback);
|
|
46
|
+
// `=== undefined` rather than `== null`: `null` is a legitimate controlled
|
|
47
|
+
// value — a combobox with nothing selected — and treating it as "give me the
|
|
48
|
+
// uncontrolled behaviour" made a cleared selection reappear on the next
|
|
49
|
+
// render.
|
|
50
|
+
const owned = controlled === undefined;
|
|
51
|
+
|
|
52
|
+
const set = useCallback(
|
|
53
|
+
(next: T) => {
|
|
54
|
+
if (owned) {
|
|
55
|
+
setInternal(next);
|
|
56
|
+
}
|
|
57
|
+
// Both arrangements report, so a caller can watch a value it does not
|
|
58
|
+
// own without having to take it over to do so.
|
|
59
|
+
onChange?.(next);
|
|
60
|
+
},
|
|
61
|
+
[owned, onChange],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return [owned ? internal : (controlled as $FlowFixMe), set];
|
|
65
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// One rule about prop order, stated once.
|
|
4
|
+
//
|
|
5
|
+
// `<div {...rest} role="dialog">` and `<div role="dialog" {...rest}>` are
|
|
6
|
+
// different components. The second lets a caller pass `role="button"` and get
|
|
7
|
+
// it; the first does not. That sounds like a matter of taste until you notice
|
|
8
|
+
// what else arrives in `rest`:
|
|
9
|
+
//
|
|
10
|
+
// * A caller `ref` replaced the ref the dialog uses to find its focus stops,
|
|
11
|
+
// so `bodyRef.current` stayed null, the Tab handler returned early, and the
|
|
12
|
+
// focus trap was *silently off* while the dialog still announced
|
|
13
|
+
// `aria-modal="true"`.
|
|
14
|
+
// * A caller `onClick` replaced a tab's selection handler, so clicking a tab
|
|
15
|
+
// did nothing.
|
|
16
|
+
// * A caller `onKeyDown` replaced the dialog's, so Escape stopped closing it.
|
|
17
|
+
//
|
|
18
|
+
// None of those fail loudly. So the rule is: the caller's props go on first and
|
|
19
|
+
// the component's own semantics go on last, and for the two kinds of prop where
|
|
20
|
+
// a caller legitimately wants *both* — event handlers and refs — they are
|
|
21
|
+
// composed rather than one replacing the other.
|
|
22
|
+
//
|
|
23
|
+
// # Why this is `internal/` and not a subpath
|
|
24
|
+
//
|
|
25
|
+
// It is not a "props utils" module and there is nothing else in it. It is the
|
|
26
|
+
// one policy every part of this package applies, extracted so that a new
|
|
27
|
+
// primitive cannot quietly apply a different one. Exporting it would invite a
|
|
28
|
+
// consumer to build a part that spreads `rest` last, which is the failure this
|
|
29
|
+
// exists to prevent — so it stays unreachable from outside the package.
|
|
30
|
+
|
|
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 };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Call the caller's handler and then the component's.
|
|
80
|
+
*
|
|
81
|
+
* The caller's runs first so it can inspect the event before the component acts
|
|
82
|
+
* on it, and the component's runs unless the caller stopped the event —
|
|
83
|
+
* `defaultPrevented` is the caller's way of saying "I handled this", which is
|
|
84
|
+
* the same contract the DOM uses.
|
|
85
|
+
*/
|
|
86
|
+
export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean }>(
|
|
87
|
+
theirs: mixed,
|
|
88
|
+
ours: (event: TEvent) => mixed,
|
|
89
|
+
): (event: TEvent) => mixed {
|
|
90
|
+
if (typeof theirs !== "function") {
|
|
91
|
+
return ours;
|
|
92
|
+
}
|
|
93
|
+
return (event: TEvent) => {
|
|
94
|
+
(theirs as $FlowFixMe)(event);
|
|
95
|
+
if (event.defaultPrevented !== true) {
|
|
96
|
+
ours(event);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Set both refs, whichever kinds they are. */
|
|
102
|
+
export function composeRefs<T>(
|
|
103
|
+
theirs: mixed,
|
|
104
|
+
ours: (value: T | null) => mixed,
|
|
105
|
+
): (value: T | null) => void {
|
|
106
|
+
return (value: T | null) => {
|
|
107
|
+
ours(value);
|
|
108
|
+
if (typeof theirs === "function") {
|
|
109
|
+
(theirs as $FlowFixMe)(value);
|
|
110
|
+
} else if (theirs != null && typeof theirs === "object") {
|
|
111
|
+
(theirs as $FlowFixMe).current = value;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A caller's props with the handlers and ref removed.
|
|
118
|
+
*
|
|
119
|
+
* They are pulled out because they have to be composed rather than spread, and
|
|
120
|
+
* leaving them in would put the caller's copy back on top of the composed one.
|
|
121
|
+
*/
|
|
122
|
+
export function withoutComposed(rest: Rest, names: $ReadOnlyArray<string>): Rest {
|
|
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];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return kept;
|
|
136
|
+
}
|