@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37

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 (58) 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 +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
@@ -0,0 +1,285 @@
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
+ // # The other half of the rule: which element the props land on
24
+ //
25
+ // Everything above decides what goes onto the element. `RenderProp` is what
26
+ // decides *which element*, and it is here rather than in a module of its own
27
+ // because it is the same policy read from the other end: a part computes one
28
+ // props object, and either puts it on the element it would have chosen or hands
29
+ // it to the caller to put on theirs. Two ways of composing a caller's props
30
+ // would be two chances to get the order wrong; one shape, stated once, is what
31
+ // keeps a part that renders somebody else's element from being a weaker part.
32
+ //
33
+ // # Why this is `internal/` and not a subpath
34
+ //
35
+ // It is not a "props utils" module and there is nothing else in it. It is the
36
+ // one policy every part of this package applies, extracted so that a new
37
+ // primitive cannot quietly apply a different one. Exporting it would invite a
38
+ // consumer to build a part that spreads `rest` last, which is the failure this
39
+ // exists to prevent — so it stays unreachable from outside the package.
40
+
41
+ import type { Node } from "@uniflowed/react";
42
+
43
+ /**
44
+ * Props on their way onto an element: what a caller hands a part, and what
45
+ * `Field.Control` hands back for a caller to spread.
46
+ *
47
+ * `key` is named out of the indexer rather than left to it, and that one
48
+ * property is the whole subtlety of this type. React takes `key` off the
49
+ * attributes before a component is called, so a part's props never contain
50
+ * one — but an indexer does not know that, and `{ readonly [string]: mixed }`
51
+ * answers `mixed` for every name, `key` included. React's `key` is
52
+ * `string | number`, so every intrinsic this package rendered was rejected for
53
+ * a property that cannot be there:
54
+ *
55
+ * error[incompatible-type]: Cannot create button element because in
56
+ * property key: Either unknown is incompatible with string. Or unknown is
57
+ * incompatible with number.
58
+ *
59
+ * thirty-two times, one per element, which was 32 of `@uniflowed/ui`'s 73 type
60
+ * errors. `key?: empty` states what React already guarantees, and the errors
61
+ * are the checker agreeing.
62
+ *
63
+ * # Two answers that look better than they are
64
+ *
65
+ * **`readonly key?: string | number`** — React's own type for the property —
66
+ * also silences the error, and is a lie in the shape of a fix. It says a
67
+ * caller may pass a `key` here; a part would then spread it onto its element,
68
+ * which is the "spreading a key into JSX" mistake React 19 added a warning
69
+ * for. `empty` is the same repair and a true sentence. It reads oddly for
70
+ * about a second and then reads as exactly what it is: there is no value you
71
+ * can pass under this name.
72
+ *
73
+ * **`React.PropsOf<"button">`** — the props of the element actually being
74
+ * rendered, which is what this type would like to say — cannot be written
75
+ * here. uf does not merge Flow's `jsx.js` environment, deliberately and for
76
+ * reasons `crates/uf_check/src/upstream/environments.rs` gives, so
77
+ * `$JSXIntrinsics` is the bare-bones table in `lib/react.js`, every
78
+ * intrinsic's `props` is `any`, and `React.PropsOf` itself reads as an
79
+ * any-typed value. Nothing about an element is checked here except its `key`:
80
+ * `<button className={5} nonsenseAttr={{}} />` is not an error today. A named
81
+ * type per element would therefore not be React's contract but a hand-written
82
+ * copy of `jsx.js` living in a UI package, drifting from the DOM on its own
83
+ * schedule — and it would still need an indexer for `data-*` and `aria-*`,
84
+ * which is where this started. So it stays one `Rest`, and the day
85
+ * `$JSXIntrinsics` is real is the day this becomes `React.PropsOf` and the
86
+ * parts say which element they render.
87
+ */
88
+ export type Rest = { readonly key?: empty, readonly [string]: mixed };
89
+
90
+ /**
91
+ * The escape hatch: a caller's element in place of the part's own.
92
+ *
93
+ * `@uniflowed/ui` has no copy step — `packages/ui/index.js`'s header argues
94
+ * that at length — and the thing a copy step is *for* is changing the markup. A
95
+ * part that always renders a `<button>` cannot be the link a menu of links
96
+ * needs; a heading fixed at `<h2>` is wrong inside an accordion. This is what
97
+ * replaces owning the source: the part still computes every attribute, every
98
+ * composed handler and every id, and hands them to the caller to put on
99
+ * whatever element they wanted.
100
+ *
101
+ * One name and one signature everywhere, which is the point. `asChild` clones a
102
+ * child and hopes its props survive; this hands the props over explicitly, so a
103
+ * caller can see what they are getting, decide the order themselves, and drop
104
+ * one deliberately. The part is still the part — `Menu.Body`'s `renders*` still
105
+ * rejects a `<div>` where a `Menu.Item` belongs, because the escape hatch
106
+ * changes the element the item renders and not what the item *is*. That
107
+ * constraint is exactly what a copied source loses.
108
+ *
109
+ * # What is in the props, and what is not
110
+ *
111
+ * Everything the part would have put on its own element, in the order
112
+ * `withProps` fixes: the caller's `rest` underneath, the part's own semantics on
113
+ * top, handlers and refs composed rather than replaced. `children` is in there
114
+ * too, so `render={(props) => <a href={to} {...props} />}` renders what was
115
+ * written between the tags — a part whose children were silently dropped
116
+ * because the caller spread the props and forgot them is the kind of quiet
117
+ * wrongness this package exists to not have. JSX children win over a spread, so
118
+ * `<a {...props}>Other</a>` still says what it says.
119
+ *
120
+ * What is *not* in there is anything true of the element rather than of the
121
+ * part: `type="button"` is the only one in practice, and it stays on the
122
+ * `<button>` branch. Handing it to a caller rendering an `<a>` would put an
123
+ * attribute the HTML has no meaning for on their link.
124
+ */
125
+ export type RenderProp = (props: Rest) => Node;
126
+
127
+ /**
128
+ * What a part's own handler reads of the event it is handed.
129
+ *
130
+ * Inexact, and named rather than inferred, for the same reason `menu.js`'s
131
+ * `MenuSelect` is: what arrives is React's synthetic event, uf does not merge
132
+ * Flow's `jsx.js` environment so `lib/react.js` models no such thing, and these
133
+ * are the members the handlers in this package actually read.
134
+ *
135
+ * It exists because of `RenderProp`. A handler written inside a JSX attribute
136
+ * gets its parameter's type from the attribute, which for an intrinsic is
137
+ * `any`; the escape hatch has to build the props *before* there is an element
138
+ * to put them on, so the same handler in an object literal has an indexer's
139
+ * `mixed` for context and Flow asks for an annotation. This is that annotation,
140
+ * written once rather than at every handler in the package.
141
+ *
142
+ * One shape for keys and for presses, which is the one thing it is not honest
143
+ * about: `key` and the modifiers belong to a keyboard event and a click has no
144
+ * `key`. It is a parameter annotation for handlers this package writes rather
145
+ * than a description of an event, nothing widens `mixed` into it, and the day
146
+ * `$JSXIntrinsics` is real — the day `Rest` becomes `React.PropsOf`, which its
147
+ * own comment is waiting for — is the day this is React's event types instead.
148
+ */
149
+ export type PartEvent = {
150
+ readonly defaultPrevented: boolean,
151
+ readonly key: string,
152
+ readonly altKey: boolean,
153
+ readonly ctrlKey: boolean,
154
+ readonly metaKey: boolean,
155
+ readonly shiftKey: boolean,
156
+ readonly currentTarget: mixed,
157
+ readonly preventDefault: () => mixed,
158
+ readonly stopPropagation: () => mixed,
159
+ ...
160
+ };
161
+
162
+ /**
163
+ * A caller's props on their way to another *part of this package*, rather than
164
+ * onto an intrinsic element.
165
+ *
166
+ * `Rest` names `key` out of its indexer and types it `empty`, which is a true
167
+ * sentence and is what stopped thirty-two intrinsics being rejected for a
168
+ * property that cannot be there. It has a second consequence, and it only shows
169
+ * up the first time one part of this package renders another —
170
+ * `ToggleGroup.Root` rendering a `RadioGroup.Root`, which is how `single` mode
171
+ * avoids being a second copy of the radio group. Creating
172
+ * `<RadioGroup.Root {...rest} />` has Flow check the props object against that
173
+ * component's own `...rest: Rest`, `key` included, and the indexer answers
174
+ * `mixed` for it rather than the named `empty`:
175
+ *
176
+ * error[incompatible-type]: Cannot create RadioGroupRoot element because in
177
+ * property key: unknown is incompatible with empty.
178
+ *
179
+ * So a part is spreadable onto a `<div>` and not onto a sibling part. That is a
180
+ * hole in the type rather than a fact about the props, and this is the one
181
+ * place it is papered over — a named function rather than an `as $FlowFixMe` at
182
+ * the call site, so there is somewhere to say what is and is not lost.
183
+ *
184
+ * What is lost is nothing that was ever checked. Every element this package
185
+ * renders has `any`-typed props today, for the reason `Rest` gives above: uf
186
+ * does not merge Flow's `jsx.js` environment, so `$JSXIntrinsics` is the
187
+ * bare-bones table in `lib/react.js` and `key` is the only property of an
188
+ * element anything verifies. On the day that changes and `Rest` becomes
189
+ * `React.PropsOf`, this function is what gets deleted.
190
+ */
191
+ export function forwarded(rest: Rest): $FlowFixMe {
192
+ return rest;
193
+ }
194
+
195
+ /**
196
+ * Call the caller's handler and then the component's.
197
+ *
198
+ * The caller's runs first so it can inspect the event before the component acts
199
+ * on it, and the component's runs unless the caller stopped the event —
200
+ * `defaultPrevented` is the caller's way of saying "I handled this", which is
201
+ * the same contract the DOM uses.
202
+ */
203
+ export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean, ... }>(
204
+ theirs: mixed,
205
+ ours: (event: TEvent) => mixed,
206
+ ): (event: TEvent) => mixed {
207
+ if (typeof theirs !== "function") {
208
+ return ours;
209
+ }
210
+ return (event: TEvent) => {
211
+ (theirs as $FlowFixMe)(event);
212
+ if (event.defaultPrevented !== true) {
213
+ ours(event);
214
+ }
215
+ };
216
+ }
217
+
218
+ /** Set both refs, whichever kinds they are. */
219
+ export function composeRefs<T>(
220
+ theirs: mixed,
221
+ ours: (value: T | null) => mixed,
222
+ ): (value: T | null) => void {
223
+ return (value: T | null) => {
224
+ ours(value);
225
+ if (typeof theirs === "function") {
226
+ (theirs as $FlowFixMe)(value);
227
+ } else if (theirs != null && typeof theirs === "object") {
228
+ (theirs as $FlowFixMe).current = value;
229
+ }
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Two sets of props, the component's on top.
235
+ *
236
+ * The same rule as everywhere else in this package, applied where the element
237
+ * is the *caller's* rather than the component's: `Tooltip.Trigger` and
238
+ * `HoverCard.Trigger` hand their attributes to a render function so a caller
239
+ * can put them on a link or a menu item of their own, and the attributes that
240
+ * make the trigger work — the `aria-describedby` naming the content, the ref
241
+ * the content is measured against — have to survive whatever the caller passed
242
+ * alongside them.
243
+ *
244
+ * A spread would say this in one line and cannot be written: Flow declines to
245
+ * compute a type for `{ ...base, name: value }` when `base` has an indexer,
246
+ * because the indexer may overwrite the named key in a way it cannot track.
247
+ * The loop is that spread, with `key` dropped for the reason `withoutComposed`
248
+ * gives.
249
+ */
250
+ export function withProps(base: Rest, ours: Rest): Rest {
251
+ const merged: { key?: empty, [string]: mixed } = {};
252
+ for (const name of Object.keys(base)) {
253
+ if (name !== "key") {
254
+ merged[name] = base[name];
255
+ }
256
+ }
257
+ for (const name of Object.keys(ours)) {
258
+ if (name !== "key") {
259
+ merged[name] = ours[name];
260
+ }
261
+ }
262
+ return merged;
263
+ }
264
+
265
+ /**
266
+ * A caller's props with the handlers and ref removed.
267
+ *
268
+ * They are pulled out because they have to be composed rather than spread, and
269
+ * leaving them in would put the caller's copy back on top of the composed one.
270
+ */
271
+ export function withoutComposed(rest: Rest, names: $ReadOnlyArray<string>): Rest {
272
+ const kept: { key?: empty, [string]: mixed } = {};
273
+ for (const name of Object.keys(rest)) {
274
+ // `key` is dropped whatever the caller asked to compose, because it is the
275
+ // one name the indexer does not speak for: writing `rest[name]` under it
276
+ // would put a `mixed` back where `Rest` promises nothing can be, and Flow
277
+ // says so. Nothing is lost — React removed the `key` long before this ran,
278
+ // so this is the type-level statement made at runtime rather than a filter
279
+ // that ever has work to do.
280
+ if (name !== "key" && !names.includes(name)) {
281
+ kept[name] = rest[name];
282
+ }
283
+ }
284
+ return kept;
285
+ }
@@ -0,0 +1,147 @@
1
+ // @flow
2
+ //
3
+ // A value in a range: the arithmetic, and which way the arrow keys move it.
4
+ //
5
+ // Three components in this package report a number between two others —
6
+ // `slider.js`, `resizable.js` and `progress.js` — and all three make the same
7
+ // four promises through `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and
8
+ // `aria-valuetext`. A reader is told a number, and the number has to be true:
9
+ // a thumb announced as 73 that the next `ArrowRight` moves to 75 has told them
10
+ // the step is 2 when it is 1, and a value announced outside its own bounds has
11
+ // told them the control is broken.
12
+ //
13
+ // So the arithmetic lives once. It is four small functions, and each of them
14
+ // is a rule that was got wrong somewhere before it was written down:
15
+ //
16
+ // * **Snapping is measured from the minimum, not from zero.** A slider from
17
+ // 5 to 100 in steps of 10 has values 5, 15, 25 — not 10, 20, 30. Rounding
18
+ // `value / step` produces the second list, and the reader who presses
19
+ // `Home` then `ArrowRight` lands on 10 from a minimum of 5, which is a
20
+ // first step of 5 on a slider that says its step is 10.
21
+ // * **Clamping happens after snapping.** Snapping a value near the top can
22
+ // push it past the maximum, and a slider whose `aria-valuenow` is greater
23
+ // than its `aria-valuemax` is a contradiction a screen reader reads out
24
+ // loud.
25
+ // * **Floating point has to be cleaned up.** `0.1 + 0.2` is not `0.3`, and a
26
+ // slider stepping by `0.1` announces `0.30000000000000004` — which is not
27
+ // a rounding error to the person hearing it, it is the control being
28
+ // absurd. The number is rounded to the precision the step implies.
29
+ // * **A fraction of an empty range is not a division.** `min === max` is a
30
+ // legal range with one value in it, and dividing by its width is `NaN`,
31
+ // which reaches the page as `left: NaN%` and lays the whole control out at
32
+ // the origin.
33
+ //
34
+ // # Which way is forward
35
+ //
36
+ // `ArrowRight` adds a step in a left-to-right page and subtracts one in a
37
+ // right-to-left page, because the reader is asking for "further along" and
38
+ // further along is the other way. `isReversed` answers that from the element
39
+ // the key arrived on, which is the answer `packages/ui/index.js` prescribes:
40
+ // the direction is something the DOM knows, so it is read in an event handler
41
+ // rather than provided through a context a caller has to remember to render.
42
+ //
43
+ // Both a `dir` attribute and the computed `direction` are consulted, in that
44
+ // order, and neither alone is enough. An attribute walk misses a page that
45
+ // sets `direction` only in CSS. The computed value misses a page whose host
46
+ // does not compute inherited `direction` at all, which includes the DOM these
47
+ // tests run on — so a control that trusted it alone would pass every test and
48
+ // walk the wrong way in the one place it mattered. The one arrangement not
49
+ // answered is a `dir="rtl"` an author then contradicts with a CSS
50
+ // `direction: ltr`, which is a page disagreeing with itself.
51
+ //
52
+ // The same question is open for `movementFor` in `internal/roving-focus.js`,
53
+ // where a horizontal `Tabs.List` still walks the wrong way for an RTL reader —
54
+ // ubugeeei-prod/uf#253, which now has this to call rather than a second copy
55
+ // of it to write.
56
+ //
57
+ // # Why this is `internal/` and not a subpath
58
+ //
59
+ // `clamp` and a stepping function are the two most generic names in
60
+ // programming, and exporting them from a UI package would be publishing a
61
+ // numeric utility library under the wrong name. What is here is narrower than
62
+ // it looks: it is the arithmetic that keeps this package's four ARIA value
63
+ // attributes true about each other, and it is worth nothing to anyone who is
64
+ // not writing one of those components.
65
+
66
+ import type { Orientation } from "./roving-focus.js";
67
+
68
+ /** `value`, held inside `[lower, upper]`. */
69
+ export function clamp(value: number, lower: number, upper: number): number {
70
+ if (value < lower) {
71
+ return lower;
72
+ }
73
+ return value > upper ? upper : value;
74
+ }
75
+
76
+ /**
77
+ * `value`, moved onto the nearest step and then held inside the range.
78
+ *
79
+ * The steps start at `lower`, not at zero. A `step` of zero or less means the
80
+ * value is continuous, which is what a slider bound to a pixel measurement
81
+ * wants, and dividing by it would be the other kind of infinity.
82
+ */
83
+ export function snap(value: number, lower: number, upper: number, step: number): number {
84
+ if (step <= 0) {
85
+ return clamp(value, lower, upper);
86
+ }
87
+ const stepped = lower + Math.round((value - lower) / step) * step;
88
+ // Snapping can overshoot the top when the range is not a whole number of
89
+ // steps — 0 to 95 by 10 rounds 95 up to 100 — and an `aria-valuenow` above
90
+ // `aria-valuemax` is a contradiction a screen reader reads out loud.
91
+ return clamp(round(stepped, step), lower, upper);
92
+ }
93
+
94
+ /**
95
+ * `value` as a fraction of the range, for a caller to draw with.
96
+ *
97
+ * `0` for an empty range rather than the `NaN` the division gives, because
98
+ * `min === max` is a legal range with exactly one value in it and `NaN`
99
+ * reaches the page as `left: NaN%`.
100
+ */
101
+ export function fraction(value: number, lower: number, upper: number): number {
102
+ const width = upper - lower;
103
+ return width <= 0 ? 0 : clamp((value - lower) / width, 0, 1);
104
+ }
105
+
106
+ /**
107
+ * Whether a positive step moves *backwards* along `orientation` on this page.
108
+ *
109
+ * Only ever true for a horizontal control in a right-to-left page: vertical
110
+ * axes are not mirrored by writing direction, and `Home` and `End` are the
111
+ * first and last value in both directions rather than the left and right one.
112
+ */
113
+ export function isReversed(element: HTMLElement | null, orientation: Orientation): boolean {
114
+ if (element == null || orientation !== "horizontal") {
115
+ return false;
116
+ }
117
+ // The attribute first, because it is the answer every host agrees on.
118
+ // Inherited `direction` is something a DOM implementation may decline to
119
+ // compute — uf's own test DOM reports `ltr` for an element inside
120
+ // `dir="rtl"` — and a control that reads only the computed value walks the
121
+ // wrong way there while looking correct everywhere it was tried by hand.
122
+ const declared = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase();
123
+ if (declared === "rtl" || declared === "ltr") {
124
+ return declared === "rtl";
125
+ }
126
+ // No `dir` anywhere above it, so the page either is left-to-right or said so
127
+ // in CSS, and only the computed value can tell the two apart.
128
+ const view: $FlowFixMe = element.ownerDocument?.defaultView;
129
+ return view?.getComputedStyle?.(element)?.direction === "rtl";
130
+ }
131
+
132
+ /**
133
+ * `value` with the digits `step` cannot reach removed.
134
+ *
135
+ * Stepping by `0.1` from `0` reaches `0.30000000000000004`, which a screen
136
+ * reader says in full. The number of decimals `step` implies is how many the
137
+ * value is allowed to have.
138
+ */
139
+ function round(value: number, step: number): number {
140
+ const text = String(step);
141
+ const point = text.indexOf(".");
142
+ if (point < 0) {
143
+ return value;
144
+ }
145
+ const decimals = text.length - point - 1;
146
+ return Number(value.toFixed(decimals));
147
+ }