@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.
- package/accordion.js +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +550 -0
- package/carousel.js +410 -0
- package/checkbox.js +264 -0
- package/collapsible.js +169 -0
- package/combobox.js +728 -0
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +523 -0
- package/drawer.js +490 -0
- package/field.js +387 -0
- package/hover-card.js +330 -0
- package/index.js +1699 -22
- package/input-otp.js +218 -0
- package/interactions.js +2163 -0
- package/internal/anchor.js +565 -0
- package/internal/controlled-state.js +65 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +285 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +823 -0
- package/menubar.js +287 -0
- package/navigation-menu.js +251 -0
- package/package.json +9 -9
- package/pagination.js +209 -0
- package/popover.js +343 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +902 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +300 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +81 -0
- package/table.js +502 -0
- package/tabs.js +289 -0
- package/toast.js +592 -0
- package/toggle-group.js +283 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
- package/internal/dialog.js +0 -236
- package/internal/field.js +0 -161
- package/internal/props.js +0 -78
- package/internal/switch.js +0 -122
- 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
|
+
}
|