@uniflowed/ui 0.0.0-alpha.18 → 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.
@@ -0,0 +1,2163 @@
1
+ // @flow
2
+ //
3
+ // Interactions: the press, the hover, the focus ring, the long press, the drag
4
+ // and the key, each written once, so that every component above them inherits
5
+ // one answer instead of writing its own.
6
+ //
7
+ // The DOM has no "press". It has `pointerdown`, `mousedown`, `keydown`, `keyup`
8
+ // and `click`, and which of those arrive — in which order, carrying which
9
+ // `pointerType` — depends on the device, the browser, and whether assistive
10
+ // technology made the gesture up. A component that listens for one of them is
11
+ // right about one of those cases, and the cases it is wrong about are the ones
12
+ // nobody meets with a mouse on a laptop:
13
+ //
14
+ // * **`onClick` alone** is right for a mouse and for a screen reader, and
15
+ // silent at the keyboard on anything that is not a `<button>` or a link.
16
+ // `render` hands a part's props to whatever element a caller renders, so a
17
+ // `Dialog.Trigger` rendered as a `<span tabIndex={0}>` opened for a pointer
18
+ // and did nothing at all for `Enter` or `Space`.
19
+ // * **`onPointerDown` alone** fires for the right button, fires for a press
20
+ // the reader dragged away from and let go of somewhere else, and never
21
+ // fires for a screen reader, which clicks without a pointer.
22
+ // * **`Space` on `keydown`** presses on the way down, and a held key repeats
23
+ // its `keydown` — so holding `Space` on a switch flips it at the keyboard's
24
+ // repeat rate, where a native button waits for the key to come up and
25
+ // presses once.
26
+ // * **`pointerenter` as hover** counts a finger as a mouse. And on iOS a tap
27
+ // is followed by a *second* `pointerenter` whose `pointerType` is `"mouse"`
28
+ // (WebKit bug 214609), so a tooltip that ignores `"touch"` and trusts
29
+ // `"mouse"` opens after the very tap it promised not to open on — and stays
30
+ // open, because no pointer is ever going to leave.
31
+ // * **`:focus-visible`** is the platform's answer to "draw a focus ring?", and
32
+ // for focus that a *script* moved — which is how every menu, select and
33
+ // dialog in this package puts focus inside itself — the specification
34
+ // leaves the answer to each browser's heuristics rather than to the input
35
+ // the reader actually used.
36
+ //
37
+ // React Aria's interactions package is the reference for getting all of these
38
+ // right at once. This module is written against its documented semantics, and
39
+ // where it differs the difference is deliberate and argued where it is made.
40
+ //
41
+ // # A press
42
+ //
43
+ // `usePress` turns every route to activation into four events — `pressstart`,
44
+ // `pressup`, `pressend` and `press` — each saying which input made it, and an
45
+ // `isPressed` for a stylesheet to draw from. The rules, and what each prevents:
46
+ //
47
+ // * **A press ends where it started.** A pointer that goes down on the element
48
+ // and comes up somewhere else ends the press without pressing. Leaving ends
49
+ // it too, and coming back while still down starts it again — unless
50
+ // `shouldCancelOnPointerExit`, which takes the press back for good the
51
+ // moment the pointer leaves.
52
+ // * **Only the primary button.** A right click opens a context menu; it does
53
+ // not also press whatever was under it.
54
+ // * **A pointer press completes on the browser's own click.** `press` fires
55
+ // from the `click` that follows `pointerup`, not from `pointerup`. Firing on
56
+ // `pointerup` is the classic way to cause a ghost click: `onPress` removes
57
+ // the element, and the click the browser sends next lands on whatever was
58
+ // underneath it. The click also carries what the platform decided — a
59
+ // press released over a different child, a link followed, a form submitted
60
+ // — so the press and the platform cannot disagree about whether one
61
+ // happened.
62
+ // * **A click nothing pointed at is assistive technology's.** VoiceOver, JAWS,
63
+ // NVDA and TalkBack activate an element by clicking it with no pointer
64
+ // before the click, and that click is a whole press — `pressstart`,
65
+ // `pressup`, `pressend` and `press` together, with `pointerType: "virtual"`.
66
+ // * **Keys belong to the element that has focus.** `Enter` and `Space` press
67
+ // the focused element and never an ancestor that happens to contain it, so
68
+ // a link inside a pressable card is the link's.
69
+ // * **An element whose activation does more than press keeps it.** A link
70
+ // follows, a submit or reset button submits or resets its form, a checkable
71
+ // `<input>` checks and a `<summary>` opens its details, and the browser does
72
+ // each of those from the click it sends for `Enter` or `Space`. On those the
73
+ // press completes from that click, which is the only way `Enter` on a
74
+ // submit button can both press and submit — and nothing fires twice,
75
+ // because nothing but that click ever fires `press` for them.
76
+ // * **Everything else is given a button's keyboard, and the browser's click
77
+ // is claimed.** On a `<button type="button">`, whose click does nothing but
78
+ // press, and on a `<div>` or a `<span>` — anything `render` might put a part
79
+ // on — `Enter` presses on key down and `Space` on key up, the timing a
80
+ // native button has, and the keys' default actions are prevented so a
81
+ // native button is not clicked a second time. The press then completes
82
+ // through one click dispatched at the element — the click a button would
83
+ // have made — so every route to activation ends in exactly one click, and a
84
+ // component's click handler hears the keyboard as well as the pointer.
85
+ // `role="link"` keeps a link's keyboard (`Enter` only) and
86
+ // `role="checkbox"` and `role="radio"` keep a checkbox's (`Space` only;
87
+ // `Enter` is the form's).
88
+ // * **A held key is one press.** Its repeats are claimed and ignored.
89
+ // * **Focus moves to what was pressed**, without scrolling, which is what
90
+ // every browser but Safari already does for a button. `preventFocusOnPress`
91
+ // is how a control whose focus must stay put — an option in a list whose
92
+ // focus lives in the field — says so.
93
+ // * **Text selection is off while a pointer is down**, so a press that lasts
94
+ // does not select the label on its way — `allowTextSelectionOnPress` for
95
+ // the element that wants it.
96
+ // * **A press inside a pressable is the inner element's.** The outer one does
97
+ // not also press, unless a handler of the inner one calls
98
+ // `continuePropagation()`.
99
+ //
100
+ // Two of those differ from React Aria on purpose. React Aria completes `Enter`
101
+ // on key *up*; a native button completes it on key down, and a part rendered
102
+ // as a `<div>` must not press at a different moment from the same part rendered
103
+ // as a `<button>`. And React Aria keeps nested presses apart by stopping the
104
+ // event's propagation; this records which element answered the event instead,
105
+ // because a press stopped at the element never reaches a document listener in
106
+ // the bubbling phase — `@uniflowed/hooks/dom`'s `useClickOutside` is one — and
107
+ // "a press outside closes it" is a promise a component makes to a reader who
108
+ // pressed something else, not to the thing they pressed.
109
+ //
110
+ // # Hover
111
+ //
112
+ // `useHover` is a mouse's and a pen's. A finger has no hover, so a touch
113
+ // pointer is ignored outright, and so is a `"mouse"` pointer arriving within
114
+ // half a second of a touch — the emulated events a tap is followed by, which
115
+ // on iOS include that second `pointerenter`. The window is short on purpose: a
116
+ // laptop with a touchscreen whose reader taps and then reaches for the trackpad
117
+ // should get hover back.
118
+ //
119
+ // # Which input came last
120
+ //
121
+ // `useFocusRing` answers "is the ring drawn?" from one fact kept for the whole
122
+ // document: which kind of input the reader used most recently. A key makes it
123
+ // the keyboard's, a pointer going down makes it the pointer's, and a click
124
+ // with nothing before it makes it assistive technology's. The ring shows for
125
+ // anything but a pointer, and three refinements keep that honest:
126
+ //
127
+ // * a modifier on its own, or a shortcut, is not the keyboard taking over —
128
+ // `⌘C` after a click must not draw rings across the page;
129
+ // * typing into a text field is not either, except `Tab` and `Escape`, which
130
+ // are how a reader leaves one;
131
+ // * before anybody has done anything the answer is "not a pointer", so a
132
+ // field focused as the page loads shows its ring.
133
+ //
134
+ // React Aria also counts focus that arrives with no event before it as
135
+ // assistive technology's, and to tell that apart from a script calling
136
+ // `focus()` it replaces `HTMLElement.prototype.focus` for the whole page. This
137
+ // package will not rewrite a platform method underneath every other script on
138
+ // somebody's page, so focus that arrives with no event keeps whatever the last
139
+ // input was. What that costs is a screen reader moving focus without any event
140
+ // while the last input was a pointer: no ring is drawn for a reader who is
141
+ // almost never the one looking for it.
142
+ //
143
+ // # A long press, a drag, and a key
144
+ //
145
+ // `useLongPress` is `usePress` with a clock. When the clock runs out the press
146
+ // underneath is cancelled — a `pointercancel` at the element, which every
147
+ // press and every drag in this module ends on — and the click that the release
148
+ // sends is refused, so a long press on a link does not also follow it. It has
149
+ // no keyboard and cannot have one: a long press is a pointer asking for a
150
+ // second action, and the component offering that action owes a key for it too.
151
+ // `ContextMenu` has `Shift+F10`; `accessibilityDescription` is how a reader is
152
+ // told which.
153
+ //
154
+ // `useMove` reports how far a pointer travelled since the last event rather
155
+ // than where it is, which is what makes it the same for a mouse, a finger and
156
+ // an arrow key. It listens on the document once a drag begins, so a pointer
157
+ // that leaves the element is still dragging it. It ends on `pointerup` and
158
+ // `pointercancel`, and it also ends on a mouse move with no button held, which
159
+ // is what a drag looks like after its `pointerup` was swallowed — the menu a
160
+ // right click opens does exactly that — and which otherwise leaves a thumb
161
+ // following a pointer nobody is holding down.
162
+ //
163
+ // `useKeyboard` hands a handler the key and stops the event at the element
164
+ // unless the handler calls `continuePropagation()`. That is the opposite of the
165
+ // DOM's default and the right one for a control: a key a slider used must not
166
+ // also move the roving focus of a list around it, and a key it did not use is
167
+ // passed on by saying so rather than by remembering not to stop it.
168
+ //
169
+ // # Why this is a subpath, when `internal/` is not
170
+ //
171
+ // Every module under `internal/` is a rule about markup this package builds —
172
+ // that a tab lives under a `tablist`, that a part's props go on last — and its
173
+ // promise only holds because the components build both halves of it. These are
174
+ // rules about input devices instead. They hold for any element, and a control a
175
+ // consumer builds with them is not a weaker copy of a part here: it is the same
176
+ // press, the same hover and the same ring, from the same code.
177
+ //
178
+ // `mergeProps` is the one helper beside them, and it is not
179
+ // `internal/merge-props.js`. That module decides whose props win between a part
180
+ // and its caller. This one chains the handlers of several of these hooks aimed
181
+ // at one element the caller owns — `usePress`, `useHover` and `useFocusRing` on
182
+ // one button all want `onPointerDown`, `onPointerEnter` or `onFocus`, and a
183
+ // spread would keep only the last.
184
+ //
185
+ // # What these promise React
186
+ //
187
+ // Nothing here reads the DOM or writes a ref during a render. Listeners that a
188
+ // gesture needs are attached by the handler of the event that began it and
189
+ // removed when it ends or when the component unmounts; the two listeners that
190
+ // watch the whole document — which input came last, and whether a touch just
191
+ // happened — are attached in an effect by the first component that asks and
192
+ // removed by the last one to go. Which input came last is a store, read through
193
+ // `useSyncExternalStore` with a server snapshot of "nobody has done anything
194
+ // yet", so a prerender and the hydrating render agree.
195
+
196
+ "use client";
197
+
198
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "@uniflowed/react";
199
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
200
+
201
+ /** The input that produced an interaction. */
202
+ export type PointerType = "mouse" | "pen" | "touch" | "keyboard" | "virtual";
203
+
204
+ /** Which kind of input a reader used most recently. */
205
+ export type Modality = "keyboard" | "pointer" | "virtual";
206
+
207
+ /** A pointer with a position: what a hover, a drag or a long press can come from. */
208
+ export type PhysicalPointer = "mouse" | "pen" | "touch";
209
+
210
+ /**
211
+ * The part of an event these hooks read, from React or from the DOM.
212
+ *
213
+ * Inexact and named for the reason `internal/merge-props.js` gives for
214
+ * `PartEvent`: what arrives is React's synthetic event, uf does not merge Flow's
215
+ * `jsx.js` environment so nothing models one, and these are the members the
216
+ * handlers here actually read. A handler written for this type accepts a
217
+ * native event as well, which is what lets a listener on the document share
218
+ * its reading of a key or a pointer with a handler on the element.
219
+ */
220
+ export type InteractionEvent = {
221
+ readonly type: string,
222
+ readonly target: mixed,
223
+ readonly currentTarget: mixed,
224
+ readonly defaultPrevented: boolean,
225
+ readonly nativeEvent?: mixed,
226
+ readonly preventDefault: () => mixed,
227
+ readonly stopPropagation: () => mixed,
228
+ readonly key?: string,
229
+ readonly code?: string,
230
+ readonly repeat?: boolean,
231
+ readonly button?: number,
232
+ readonly buttons?: number,
233
+ readonly detail?: number,
234
+ readonly pointerId?: number,
235
+ readonly pointerType?: string,
236
+ readonly clientX?: number,
237
+ readonly clientY?: number,
238
+ readonly width?: number,
239
+ readonly height?: number,
240
+ readonly pressure?: number,
241
+ readonly relatedTarget?: mixed,
242
+ readonly altKey?: boolean,
243
+ readonly ctrlKey?: boolean,
244
+ readonly metaKey?: boolean,
245
+ readonly shiftKey?: boolean,
246
+ ...
247
+ };
248
+
249
+ /**
250
+ * Props on their way onto an element, as `mergeProps` returns them.
251
+ *
252
+ * `key` is named out of the indexer for the reason `Rest` in
253
+ * `internal/merge-props.js` gives at length: an indexer answers `mixed` for
254
+ * every name, React's `key` is `string | number`, and spreading one onto an
255
+ * element is rejected for a property that cannot be there.
256
+ */
257
+ export type InteractionProps = { readonly key?: empty, readonly [string]: mixed };
258
+
259
+ /** The modifier keys held while something happened. */
260
+ type Modifiers = {|
261
+ readonly altKey: boolean,
262
+ readonly ctrlKey: boolean,
263
+ readonly metaKey: boolean,
264
+ readonly shiftKey: boolean,
265
+ |};
266
+
267
+ /** Where something happened, relative to the element's own box. */
268
+ type Point = {| readonly x: number, readonly y: number |};
269
+
270
+ /**
271
+ * How long a `"mouse"` pointer is disbelieved after a touch, in milliseconds.
272
+ *
273
+ * Long enough to cover the emulated events a tap is followed by — which arrive
274
+ * in the same task on iOS, and after the click delay a page that allows zooming
275
+ * still has — and far shorter than a reader takes to move a hand from a
276
+ * touchscreen to a trackpad.
277
+ */
278
+ const EMULATED_MOUSE_WINDOW = 500;
279
+
280
+ /** How long a press has to last to be a long press, in milliseconds. */
281
+ const LONG_PRESS_THRESHOLD = 500;
282
+
283
+ /** Every pointer a long press can come from, which is the default. */
284
+ const EVERY_POINTER: $ReadOnlyArray<PhysicalPointer> = ["mouse", "pen", "touch"];
285
+
286
+ /** `<input>` types a reader does not type into. */
287
+ const NON_TEXT_INPUTS: Set<string> = new Set([
288
+ "button",
289
+ "checkbox",
290
+ "color",
291
+ "file",
292
+ "hidden",
293
+ "image",
294
+ "radio",
295
+ "range",
296
+ "reset",
297
+ "submit",
298
+ ]);
299
+
300
+ /**
301
+ * The elements a pointer focuses when it presses them.
302
+ *
303
+ * A browser focuses these on `mousedown`, and any element with a `tabindex` —
304
+ * including `-1`, which is what every roving item in this package carries.
305
+ */
306
+ const FOCUSABLE_BY_POINTER =
307
+ "a[href], area[href], button, input, select, textarea, summary, iframe, [contenteditable=''], [contenteditable='true']";
308
+
309
+ /** The modifier keys an event reports, with a missing one read as not held. */
310
+ function modifiersOf(source: mixed): Modifiers {
311
+ const event: $FlowFixMe = source;
312
+ return {
313
+ altKey: event?.altKey === true,
314
+ ctrlKey: event?.ctrlKey === true,
315
+ metaKey: event?.metaKey === true,
316
+ shiftKey: event?.shiftKey === true,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * The pointer an event names, as one of the three this module distinguishes.
322
+ *
323
+ * An empty or missing `pointerType` is a mouse: it is what a host without
324
+ * pointer types reports for one, and what a click synthesised by a test
325
+ * harness carries.
326
+ */
327
+ function physicalPointerOf(pointerType: mixed): PhysicalPointer {
328
+ return pointerType === "touch" ? "touch" : pointerType === "pen" ? "pen" : "mouse";
329
+ }
330
+
331
+ /** The element an event's `currentTarget` is, which is always an element here. */
332
+ function elementOf(target: mixed): HTMLElement {
333
+ return target as $FlowFixMe;
334
+ }
335
+
336
+ /** Whether `node` is `container` or inside it, for any value an event carried. */
337
+ function contains(container: HTMLElement, node: mixed): boolean {
338
+ const candidate: $FlowFixMe = node;
339
+ return (
340
+ candidate != null && typeof candidate.nodeType === "number" && container.contains(candidate)
341
+ );
342
+ }
343
+
344
+ /** Where an event happened relative to an element's box, or its corner when it has no position. */
345
+ function pointOf(source: mixed, element: HTMLElement): Point {
346
+ const event: $FlowFixMe = source;
347
+ const box = element.getBoundingClientRect();
348
+ const x = typeof event?.clientX === "number" ? event.clientX : box.left;
349
+ const y = typeof event?.clientY === "number" ? event.clientY : box.top;
350
+ return { x: x - box.left, y: y - box.top };
351
+ }
352
+
353
+ /** The user agent string, or nothing on a host without a navigator. */
354
+ function userAgent(): string {
355
+ const host: $FlowFixMe = globalThis;
356
+ const agent = host.navigator?.userAgent;
357
+ return typeof agent === "string" ? agent : "";
358
+ }
359
+
360
+ /** Whether this is Android, whose screen reader reports its clicks differently. */
361
+ function isAndroid(): boolean {
362
+ return /Android/i.test(userAgent());
363
+ }
364
+
365
+ /**
366
+ * Whether this is an Apple platform, where `Option` types characters.
367
+ *
368
+ * On macOS and iOS `Option+e` is how an accent is typed, so it is typing; on
369
+ * every other platform `Alt` held with a key is a shortcut, and a shortcut is
370
+ * not the keyboard taking over from the pointer.
371
+ */
372
+ function isApplePlatform(): boolean {
373
+ const host: $FlowFixMe = globalThis;
374
+ const platform = host.navigator?.userAgentData?.platform ?? host.navigator?.platform;
375
+ return typeof platform === "string" && /mac|iphone|ipad|ipod/i.test(platform);
376
+ }
377
+
378
+ /**
379
+ * Whether a pointer event is one assistive technology made up.
380
+ *
381
+ * The two shapes React Aria documents. VoiceOver on iOS sends pointer events
382
+ * with no contact area at all; TalkBack's double tap on Android sends a mouse
383
+ * pointer of unit size, with no pressure and no click count — a shape a real
384
+ * mouse on Android does not have, and one Safari's real mouse *does* have
385
+ * (Safari reports no pressure), which is why that half is asked only on
386
+ * Android.
387
+ */
388
+ function isVirtualPointer(event: InteractionEvent): boolean {
389
+ if (isAndroid()) {
390
+ return (
391
+ event.width === 1 &&
392
+ event.height === 1 &&
393
+ event.pressure === 0 &&
394
+ event.detail === 0 &&
395
+ event.pointerType === "mouse"
396
+ );
397
+ }
398
+ return event.width === 0 && event.height === 0;
399
+ }
400
+
401
+ /** The event the DOM dispatched, under whichever wrapper React put on it. */
402
+ function nativeOf(event: InteractionEvent): Event {
403
+ return (event.nativeEvent ?? event) as $FlowFixMe;
404
+ }
405
+
406
+ /**
407
+ * Whether a click came from something that did not point.
408
+ *
409
+ * A pointer's click counts itself: `detail` is how many clicks in a row it
410
+ * was, and it is never nought. A click with a count of nought and no pointer
411
+ * type is a screen reader's, or a script's. Firefox reports JAWS and NVDA
412
+ * clicks with an empty `pointerType` from a trusted event, and TalkBack on
413
+ * Android reports its click with the button still held.
414
+ */
415
+ function isVirtualClick(event: InteractionEvent): boolean {
416
+ const native: $FlowFixMe = nativeOf(event);
417
+ if (native.pointerType === "" && native.isTrusted === true) {
418
+ return true;
419
+ }
420
+ if (isAndroid() && typeof native.pointerType === "string" && native.pointerType !== "") {
421
+ return native.buttons === 1;
422
+ }
423
+ return native.detail === 0 && !native.pointerType;
424
+ }
425
+
426
+ /** Whether a reader types into this element, so the keys it receives are text. */
427
+ function isTextEntry(target: mixed): boolean {
428
+ const element: $FlowFixMe = target;
429
+ if (element == null || typeof element.tagName !== "string") {
430
+ return false;
431
+ }
432
+ if (element.isContentEditable === true) {
433
+ return true;
434
+ }
435
+ const tag = element.tagName.toUpperCase();
436
+ if (tag === "TEXTAREA") {
437
+ return true;
438
+ }
439
+ return tag === "INPUT" && !NON_TEXT_INPUTS.has(String(element.type ?? "text").toLowerCase());
440
+ }
441
+
442
+ /**
443
+ * Turn text selection off on an element, and hand back how to turn it on again.
444
+ *
445
+ * Inline, and restored to exactly what was there, for the reason
446
+ * `internal/disclosure.js` gives about its measuring pass: a stylesheet must
447
+ * never be left fighting an inline declaration this wrote.
448
+ */
449
+ function withoutTextSelection(element: HTMLElement | null): () => void {
450
+ if (element == null) {
451
+ return () => {};
452
+ }
453
+ const style = element.style;
454
+ const before = style.getPropertyValue("user-select");
455
+ const beforeWebkit = style.getPropertyValue("-webkit-user-select");
456
+ style.setProperty("user-select", "none");
457
+ style.setProperty("-webkit-user-select", "none");
458
+ return () => {
459
+ restoreProperty(style, "user-select", before);
460
+ restoreProperty(style, "-webkit-user-select", beforeWebkit);
461
+ };
462
+ }
463
+
464
+ /** Put one inline declaration back to what it was, including to nothing. */
465
+ function restoreProperty(style: CSSStyleDeclaration, name: string, value: string): void {
466
+ if (value === "") {
467
+ style.removeProperty(name);
468
+ } else {
469
+ style.setProperty(name, value);
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Focus what a pointer pressed, the way a browser does, without scrolling.
475
+ *
476
+ * Only an element a pointer would focus: a `<div>` with no `tabindex` is not
477
+ * focusable, and asking it to be does nothing anyway.
478
+ */
479
+ function focusWithoutScrolling(element: HTMLElement): void {
480
+ if (element.ownerDocument.activeElement === element) {
481
+ return;
482
+ }
483
+ if (!element.hasAttribute("tabindex") && !element.matches(FOCUSABLE_BY_POINTER)) {
484
+ return;
485
+ }
486
+ element.focus({ preventScroll: true });
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Which element answered an event
491
+ // ---------------------------------------------------------------------------
492
+
493
+ /**
494
+ * The element that answered each event, for a press inside a press.
495
+ *
496
+ * Keyed on the DOM's event rather than React's wrapper, because a document
497
+ * listener and a React handler see different wrappers of the same event. A
498
+ * `WeakMap`, so an event nobody holds any more takes its entry with it.
499
+ */
500
+ const answeredBy: WeakMap<Event, HTMLElement> = new WeakMap();
501
+
502
+ /** Whether a different element — one inside this one — already answered the event. */
503
+ function answeredElsewhere(event: InteractionEvent, element: HTMLElement): boolean {
504
+ const by = answeredBy.get(nativeOf(event));
505
+ return by != null && by !== element;
506
+ }
507
+
508
+ /**
509
+ * Record that `element` answered the event, unless something inside it did first.
510
+ *
511
+ * Two hooks on the *same* element are not nested and both see the event: a
512
+ * `usePress` for the click and a `useLongPress` for the hold are one control.
513
+ */
514
+ function markAnswered(event: InteractionEvent, element: HTMLElement): void {
515
+ const native = nativeOf(event);
516
+ if (!answeredBy.has(native)) {
517
+ answeredBy.set(native, element);
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // usePress
523
+ // ---------------------------------------------------------------------------
524
+
525
+ /** A moment in a press. */
526
+ export type PressEvent = {|
527
+ readonly type: "pressstart" | "pressend" | "pressup" | "press",
528
+ /** The input that made it. */
529
+ readonly pointerType: PointerType,
530
+ /** The element the press belongs to. */
531
+ readonly target: HTMLElement,
532
+ readonly altKey: boolean,
533
+ readonly ctrlKey: boolean,
534
+ readonly metaKey: boolean,
535
+ readonly shiftKey: boolean,
536
+ /** Where the pointer was, from the element's left edge; nought for a key or a screen reader. */
537
+ readonly x: number,
538
+ /** Where the pointer was, from the element's top edge; nought for a key or a screen reader. */
539
+ readonly y: number,
540
+ /**
541
+ * Let a pressable around this one receive the same press.
542
+ *
543
+ * By default a press belongs to the innermost pressable element; see the
544
+ * module header for why that is recorded rather than stopped.
545
+ */
546
+ readonly continuePropagation: () => void,
547
+ |};
548
+
549
+ /** What `usePress` is told. */
550
+ export type PressOptions = {|
551
+ /** Leave text selection alone while a pointer is down. */
552
+ readonly allowTextSelectionOnPress?: boolean,
553
+ /**
554
+ * No press, no hover state, and no activation of the element either: a click
555
+ * on a disabled link or submit button is prevented.
556
+ */
557
+ readonly isDisabled?: boolean,
558
+ /** The press completed, over the element. */
559
+ readonly onPress?: (event: PressEvent) => mixed,
560
+ /** `isPressed` changed. */
561
+ readonly onPressChange?: (isPressed: boolean) => mixed,
562
+ /** The press ended, pressed or not: released, left, cancelled or taken away. */
563
+ readonly onPressEnd?: (event: PressEvent) => mixed,
564
+ /** A press began, or a pointer still down came back over the element. */
565
+ readonly onPressStart?: (event: PressEvent) => mixed,
566
+ /** A pointer or a key was released over the element, whether or not the press began there. */
567
+ readonly onPressUp?: (event: PressEvent) => mixed,
568
+ /** Keep focus where it is when a pointer presses the element. */
569
+ readonly preventFocusOnPress?: boolean,
570
+ /** A pointer that leaves takes the press back for good, rather than until it returns. */
571
+ readonly shouldCancelOnPointerExit?: boolean,
572
+ |};
573
+
574
+ /** The handlers `usePress` needs on the element. */
575
+ export type PressProps = {|
576
+ readonly onClick: (event: InteractionEvent) => void,
577
+ readonly onDragStart: (event: InteractionEvent) => void,
578
+ readonly onKeyDown: (event: InteractionEvent) => void,
579
+ readonly onMouseDown: (event: InteractionEvent) => void,
580
+ readonly onPointerDown: (event: InteractionEvent) => void,
581
+ readonly onPointerEnter: (event: InteractionEvent) => void,
582
+ readonly onPointerLeave: (event: InteractionEvent) => void,
583
+ readonly onPointerUp: (event: InteractionEvent) => void,
584
+ |};
585
+
586
+ /** What `usePress` hands back. */
587
+ export type PressResult = {|
588
+ /** Whether a press is under way and over the element. */
589
+ readonly isPressed: boolean,
590
+ /** Spread onto the element, or merged with other hooks' props by `mergeProps`. */
591
+ readonly pressProps: PressProps,
592
+ |};
593
+
594
+ /** A pointer that is down on the element. */
595
+ type PointerPress = {|
596
+ cancelled: boolean,
597
+ over: boolean,
598
+ readonly pointerId: number,
599
+ readonly pointerType: PhysicalPointer,
600
+ readonly stop: () => void,
601
+ readonly target: HTMLElement,
602
+ |};
603
+
604
+ /** `Space` held down on the element. */
605
+ type KeyPress = {|
606
+ readonly native: boolean,
607
+ readonly stop: () => void,
608
+ readonly target: HTMLElement,
609
+ |};
610
+
611
+ /** A pointer press that ended over the element and is owed the click that follows. */
612
+ type OwedPress = {|
613
+ ...Modifiers,
614
+ readonly point: Point,
615
+ readonly pointerType: PhysicalPointer,
616
+ |};
617
+
618
+ /** Everything a press in progress is made of. Written by handlers, never read by a render. */
619
+ type PressState = {|
620
+ key: KeyPress | null,
621
+ keyClick: HTMLElement | null,
622
+ owed: OwedPress | null,
623
+ pointer: PointerPress | null,
624
+ pressed: boolean,
625
+ refuse: boolean,
626
+ |};
627
+
628
+ /** Which keys press an element, and whether the browser clicks it for them. */
629
+ type KeyRule = {| readonly enter: boolean, readonly native: boolean, readonly space: boolean |};
630
+
631
+ /**
632
+ * The keys that press `element`, or nothing when its keys are text.
633
+ *
634
+ * `native` names the elements whose own activation — following a link,
635
+ * submitting or resetting a form, checking a box, opening a `<summary>` — comes
636
+ * from the click the browser sends for a key. The press waits for that click on
637
+ * them, and claims the key everywhere else; the module header says why.
638
+ *
639
+ * The tag is asked before the role because the browser asks the tag: a
640
+ * `<button type="button" role="checkbox">` takes `Enter` like any button, so a
641
+ * part that wants `Enter` for something else — `Checkbox` submits the form with
642
+ * it — prevents the key first, and a press never sees it.
643
+ */
644
+ function keyRuleFor(element: HTMLElement): KeyRule | null {
645
+ if (element.isContentEditable) {
646
+ return null;
647
+ }
648
+ const tag = element.tagName.toUpperCase();
649
+ if (tag === "TEXTAREA" || tag === "SELECT") {
650
+ return null;
651
+ }
652
+ if (tag === "INPUT") {
653
+ const type = String((element as $FlowFixMe).type ?? "").toLowerCase();
654
+ if (type === "checkbox" || type === "radio") {
655
+ return { enter: false, native: true, space: true };
656
+ }
657
+ if (type === "submit" || type === "reset" || type === "image") {
658
+ return { enter: true, native: true, space: true };
659
+ }
660
+ if (type === "button") {
661
+ return { enter: true, native: false, space: true };
662
+ }
663
+ return null;
664
+ }
665
+ if (tag === "BUTTON") {
666
+ // `.type` rather than the attribute: a `<button>` with none is a submit
667
+ // button, and submitting is the activation a press cannot do instead.
668
+ const type = String((element as $FlowFixMe).type ?? "submit").toLowerCase();
669
+ return { enter: true, native: type === "submit" || type === "reset", space: true };
670
+ }
671
+ if (tag === "SUMMARY") {
672
+ return { enter: true, native: true, space: true };
673
+ }
674
+ if ((tag === "A" || tag === "AREA") && element.hasAttribute("href")) {
675
+ return { enter: true, native: true, space: false };
676
+ }
677
+ const role = element.getAttribute("role");
678
+ if (role === "link") {
679
+ return { enter: true, native: false, space: false };
680
+ }
681
+ if (role === "checkbox" || role === "radio") {
682
+ return { enter: false, native: false, space: true };
683
+ }
684
+ return { enter: true, native: false, space: true };
685
+ }
686
+
687
+ /**
688
+ * A press, from a pointer, a key or assistive technology, with one set of events.
689
+ *
690
+ * const { isPressed, pressProps } = usePress({ onPress: () => save() });
691
+ * return <div {...pressProps} data-pressed={isPressed} role="button" tabIndex={0}>Save</div>;
692
+ *
693
+ * The module header states every rule and what each one prevents. The events
694
+ * arrive in the order `pressstart`, `pressup`, `pressend`, `press`, and
695
+ * `onPressChange` reports every change to `isPressed` between them.
696
+ */
697
+ export hook usePress(options?: PressOptions): PressResult {
698
+ const [isPressed, setPressed] = useState(false);
699
+ const state = useRef<PressState>({
700
+ key: null,
701
+ keyClick: null,
702
+ owed: null,
703
+ pointer: null,
704
+ pressed: false,
705
+ refuse: false,
706
+ });
707
+
708
+ const emit = useStableCallback(
709
+ (
710
+ type: "pressstart" | "pressend" | "pressup" | "press",
711
+ pointerType: PointerType,
712
+ target: HTMLElement,
713
+ source: mixed,
714
+ point: Point | null,
715
+ ): boolean => {
716
+ let continued = false;
717
+ const event: PressEvent = {
718
+ ...modifiersOf(source),
719
+ continuePropagation: () => {
720
+ continued = true;
721
+ },
722
+ pointerType,
723
+ target,
724
+ type,
725
+ x: point?.x ?? 0,
726
+ y: point?.y ?? 0,
727
+ };
728
+ const handler =
729
+ type === "pressstart"
730
+ ? options?.onPressStart
731
+ : type === "pressup"
732
+ ? options?.onPressUp
733
+ : type === "pressend"
734
+ ? options?.onPressEnd
735
+ : options?.onPress;
736
+ handler?.(event);
737
+ return continued;
738
+ },
739
+ );
740
+
741
+ const change = useStableCallback((next: boolean) => {
742
+ const current = state.current;
743
+ if (current.pressed === next) {
744
+ return;
745
+ }
746
+ current.pressed = next;
747
+ setPressed(next);
748
+ options?.onPressChange?.(next);
749
+ });
750
+
751
+ /** End a pointer press without pressing: cancelled, dragged away, or disabled. */
752
+ const cancelPointer = useStableCallback((source: mixed) => {
753
+ const current = state.current;
754
+ const press = current.pointer;
755
+ if (press == null) {
756
+ return;
757
+ }
758
+ current.pointer = null;
759
+ current.owed = null;
760
+ press.stop();
761
+ if (press.over && !press.cancelled) {
762
+ change(false);
763
+ emit("pressend", press.pointerType, press.target, source, null);
764
+ }
765
+ });
766
+
767
+ const onDocumentPointerUp = useStableCallback((native: $FlowFixMe) => {
768
+ const current = state.current;
769
+ const press = current.pointer;
770
+ if (press == null || (native.pointerId ?? 0) !== press.pointerId) {
771
+ return;
772
+ }
773
+ current.pointer = null;
774
+ press.stop();
775
+ const released = contains(press.target, native.target);
776
+ if (press.cancelled) {
777
+ // Taken back when the pointer left. Coming back and letting go over the
778
+ // element still makes the browser click it, and that click is not a press.
779
+ current.refuse = released;
780
+ return;
781
+ }
782
+ if (!press.over) {
783
+ return;
784
+ }
785
+ change(false);
786
+ const point = pointOf(native, press.target);
787
+ emit("pressend", press.pointerType, press.target, native, point);
788
+ if (released) {
789
+ current.owed = { ...modifiersOf(native), point, pointerType: press.pointerType };
790
+ }
791
+ });
792
+
793
+ const onDocumentPointerCancel = useStableCallback((native: $FlowFixMe) => {
794
+ const press = state.current.pointer;
795
+ if (press != null && (native.pointerId ?? 0) === press.pointerId) {
796
+ cancelPointer(native);
797
+ }
798
+ });
799
+
800
+ const onPointerDown = useStableCallback((event: InteractionEvent) => {
801
+ const element = elementOf(event.currentTarget);
802
+ if (answeredElsewhere(event, element)) {
803
+ return;
804
+ }
805
+ const current = state.current;
806
+ // A new press settles whatever the last one left owing.
807
+ current.owed = null;
808
+ current.refuse = false;
809
+ current.keyClick = null;
810
+ if (options?.isDisabled === true) {
811
+ markAnswered(event, element);
812
+ return;
813
+ }
814
+ // A virtual pointer is left to the click it is followed by, which is where
815
+ // a screen reader's press is recognised.
816
+ if (event.button !== 0 || current.pointer != null || isVirtualPointer(event)) {
817
+ return;
818
+ }
819
+ const pointerId = event.pointerId ?? 0;
820
+ const pointerType = physicalPointerOf(event.pointerType);
821
+ // A touch or a pen is captured to the element it went down on, so without
822
+ // this the element would never hear the finger leave it.
823
+ const captured: $FlowFixMe = element;
824
+ if (
825
+ typeof captured.hasPointerCapture === "function" &&
826
+ captured.hasPointerCapture(pointerId) === true
827
+ ) {
828
+ captured.releasePointerCapture(pointerId);
829
+ }
830
+ if (options?.preventFocusOnPress !== true) {
831
+ focusWithoutScrolling(element);
832
+ }
833
+ const document = element.ownerDocument;
834
+ const restoreSelection =
835
+ options?.allowTextSelectionOnPress === true ? null : withoutTextSelection(element);
836
+ document.addEventListener("pointerup", onDocumentPointerUp, false);
837
+ document.addEventListener("pointercancel", onDocumentPointerCancel, false);
838
+ current.pointer = {
839
+ cancelled: false,
840
+ over: true,
841
+ pointerId,
842
+ pointerType,
843
+ stop: () => {
844
+ document.removeEventListener("pointerup", onDocumentPointerUp, false);
845
+ document.removeEventListener("pointercancel", onDocumentPointerCancel, false);
846
+ restoreSelection?.();
847
+ },
848
+ target: element,
849
+ };
850
+ const continued = emit("pressstart", pointerType, element, event, pointOf(event, element));
851
+ change(true);
852
+ if (!continued) {
853
+ markAnswered(event, element);
854
+ }
855
+ });
856
+
857
+ const onPointerUp = useStableCallback((event: InteractionEvent) => {
858
+ const element = elementOf(event.currentTarget);
859
+ if (
860
+ answeredElsewhere(event, element) ||
861
+ options?.isDisabled === true ||
862
+ event.button !== 0 ||
863
+ isVirtualPointer(event)
864
+ ) {
865
+ return;
866
+ }
867
+ const press = state.current.pointer;
868
+ if (
869
+ press != null &&
870
+ ((event.pointerId ?? 0) !== press.pointerId || press.cancelled || !press.over)
871
+ ) {
872
+ return;
873
+ }
874
+ const pointerType = press?.pointerType ?? physicalPointerOf(event.pointerType);
875
+ if (!emit("pressup", pointerType, element, event, pointOf(event, element))) {
876
+ markAnswered(event, element);
877
+ }
878
+ });
879
+
880
+ const onPointerLeave = useStableCallback((event: InteractionEvent) => {
881
+ const press = state.current.pointer;
882
+ if (press == null || (event.pointerId ?? 0) !== press.pointerId || !press.over) {
883
+ return;
884
+ }
885
+ press.over = false;
886
+ change(false);
887
+ emit("pressend", press.pointerType, press.target, event, pointOf(event, press.target));
888
+ if (options?.shouldCancelOnPointerExit === true) {
889
+ press.cancelled = true;
890
+ }
891
+ });
892
+
893
+ const onPointerEnter = useStableCallback((event: InteractionEvent) => {
894
+ const press = state.current.pointer;
895
+ if (
896
+ press == null ||
897
+ (event.pointerId ?? 0) !== press.pointerId ||
898
+ press.over ||
899
+ press.cancelled
900
+ ) {
901
+ return;
902
+ }
903
+ press.over = true;
904
+ emit("pressstart", press.pointerType, press.target, event, pointOf(event, press.target));
905
+ change(true);
906
+ });
907
+
908
+ // Safari starts a native drag without sending `pointercancel`, and a press
909
+ // that turned into a drag is not a press.
910
+ const onDragStart = useStableCallback((event: InteractionEvent) => {
911
+ cancelPointer(event);
912
+ });
913
+
914
+ // The default action of `mousedown` is what moves focus, for a mouse and for
915
+ // the emulated mouse a touch is followed by.
916
+ const onMouseDown = useStableCallback((event: InteractionEvent) => {
917
+ if (event.button === 0 && options?.preventFocusOnPress === true) {
918
+ event.preventDefault();
919
+ }
920
+ });
921
+
922
+ const onDocumentKeyUp = useStableCallback((native: $FlowFixMe) => {
923
+ const current = state.current;
924
+ const press = current.key;
925
+ if (press == null || (native.key !== " " && native.key !== "Spacebar")) {
926
+ return;
927
+ }
928
+ current.key = null;
929
+ press.stop();
930
+ // Released where it went down, which is where focus still is. A reader who
931
+ // moved focus while holding the key has taken the press elsewhere.
932
+ const released = contains(press.target, native.target);
933
+ if (released) {
934
+ emit("pressup", "keyboard", press.target, native, null);
935
+ }
936
+ change(false);
937
+ emit("pressend", "keyboard", press.target, native, null);
938
+ if (!released) {
939
+ return;
940
+ }
941
+ current.keyClick = press.target;
942
+ if (press.native) {
943
+ // The browser clicks it after this listener returns, and that click is
944
+ // the press; see the module header.
945
+ return;
946
+ }
947
+ // Claimed, so Firefox does not click a button on key up as well, and the
948
+ // click a button would have made is dispatched instead; see `onKeyDown`.
949
+ native.preventDefault();
950
+ press.target.click();
951
+ });
952
+
953
+ const onKeyPressBlur = useStableCallback((native: $FlowFixMe) => {
954
+ const current = state.current;
955
+ const press = current.key;
956
+ if (press == null) {
957
+ return;
958
+ }
959
+ current.key = null;
960
+ press.stop();
961
+ change(false);
962
+ emit("pressend", "keyboard", press.target, native, null);
963
+ });
964
+
965
+ const onKeyDown = useStableCallback((event: InteractionEvent) => {
966
+ const element = elementOf(event.currentTarget);
967
+ const key = event.key;
968
+ const space = key === " " || key === "Spacebar";
969
+ if (
970
+ (!space && key !== "Enter") ||
971
+ event.target !== element ||
972
+ event.defaultPrevented ||
973
+ options?.isDisabled === true
974
+ ) {
975
+ return;
976
+ }
977
+ const rule = keyRuleFor(element);
978
+ if (rule == null || (space ? !rule.space : !rule.enter)) {
979
+ return;
980
+ }
981
+ const current = state.current;
982
+ if (event.repeat === true) {
983
+ // One press for a held key. The repeat is claimed so that neither the
984
+ // page, which would scroll, nor the browser, which would click again,
985
+ // takes it instead.
986
+ event.preventDefault();
987
+ return;
988
+ }
989
+ if (current.key != null || current.pointer != null) {
990
+ return;
991
+ }
992
+ current.owed = null;
993
+ current.refuse = false;
994
+ current.keyClick = null;
995
+ if (!rule.native) {
996
+ event.preventDefault();
997
+ }
998
+ emit("pressstart", "keyboard", element, event, null);
999
+ change(true);
1000
+ if (space) {
1001
+ // Completed on key up, the way a button is. On the document, so a key
1002
+ // released after focus moved is still heard, and ended by a blur.
1003
+ const document = element.ownerDocument;
1004
+ document.addEventListener("keyup", onDocumentKeyUp, true);
1005
+ element.addEventListener("blur", onKeyPressBlur, false);
1006
+ current.key = {
1007
+ native: rule.native,
1008
+ stop: () => {
1009
+ document.removeEventListener("keyup", onDocumentKeyUp, true);
1010
+ element.removeEventListener("blur", onKeyPressBlur, false);
1011
+ },
1012
+ target: element,
1013
+ };
1014
+ return;
1015
+ }
1016
+ emit("pressup", "keyboard", element, event, null);
1017
+ change(false);
1018
+ emit("pressend", "keyboard", element, event, null);
1019
+ // The press completes on a click either way: the browser's own, for an
1020
+ // element whose click does something, or the one a button would have made,
1021
+ // dispatched here — so every route to activation ends in exactly one click,
1022
+ // and a click handler hears the keyboard as well as the pointer.
1023
+ current.keyClick = element;
1024
+ if (!rule.native) {
1025
+ element.click();
1026
+ }
1027
+ });
1028
+
1029
+ const onClick = useStableCallback((event: InteractionEvent) => {
1030
+ const element = elementOf(event.currentTarget);
1031
+ if (answeredElsewhere(event, element)) {
1032
+ return;
1033
+ }
1034
+ const current = state.current;
1035
+ if (options?.isDisabled === true) {
1036
+ // A disabled control does not act, and neither does the link or the
1037
+ // submit button it was rendered as.
1038
+ event.preventDefault();
1039
+ markAnswered(event, element);
1040
+ return;
1041
+ }
1042
+ if (current.refuse) {
1043
+ current.refuse = false;
1044
+ event.preventDefault();
1045
+ markAnswered(event, element);
1046
+ return;
1047
+ }
1048
+ const owed = current.owed;
1049
+ if (owed != null) {
1050
+ current.owed = null;
1051
+ if (!emit("press", owed.pointerType, element, owed, owed.point)) {
1052
+ markAnswered(event, element);
1053
+ }
1054
+ return;
1055
+ }
1056
+ if (current.pointer != null) {
1057
+ return;
1058
+ }
1059
+ if (current.keyClick === element) {
1060
+ current.keyClick = null;
1061
+ if (!emit("press", "keyboard", element, event, null)) {
1062
+ markAnswered(event, element);
1063
+ }
1064
+ return;
1065
+ }
1066
+ // A pointer's click with no press before it went down somewhere else, or
1067
+ // before anything here was listening, and is not a press of this element.
1068
+ if (!isVirtualClick(event)) {
1069
+ return;
1070
+ }
1071
+ let continued = emit("pressstart", "virtual", element, event, null);
1072
+ change(true);
1073
+ continued = emit("pressup", "virtual", element, event, null) || continued;
1074
+ change(false);
1075
+ continued = emit("pressend", "virtual", element, event, null) || continued;
1076
+ continued = emit("press", "virtual", element, event, null) || continued;
1077
+ if (!continued) {
1078
+ markAnswered(event, element);
1079
+ }
1080
+ });
1081
+
1082
+ // Disabled in the middle of a press: it ends there, unpressed.
1083
+ const isDisabled = options?.isDisabled === true;
1084
+ useEffect(() => {
1085
+ if (!isDisabled) {
1086
+ return;
1087
+ }
1088
+ cancelPointer(null);
1089
+ const current = state.current;
1090
+ const press = current.key;
1091
+ if (press != null) {
1092
+ current.key = null;
1093
+ press.stop();
1094
+ change(false);
1095
+ emit("pressend", "keyboard", press.target, null, null);
1096
+ }
1097
+ }, [isDisabled, cancelPointer, change, emit]);
1098
+
1099
+ // Taken away in the middle of a press: its listeners go with it.
1100
+ useEffect(
1101
+ () => () => {
1102
+ const current = state.current;
1103
+ current.pointer?.stop();
1104
+ current.key?.stop();
1105
+ current.pointer = null;
1106
+ current.key = null;
1107
+ },
1108
+ [],
1109
+ );
1110
+
1111
+ const pressProps = useMemo(
1112
+ () => ({
1113
+ onClick,
1114
+ onDragStart,
1115
+ onKeyDown,
1116
+ onMouseDown,
1117
+ onPointerDown,
1118
+ onPointerEnter,
1119
+ onPointerLeave,
1120
+ onPointerUp,
1121
+ }),
1122
+ [
1123
+ onClick,
1124
+ onDragStart,
1125
+ onKeyDown,
1126
+ onMouseDown,
1127
+ onPointerDown,
1128
+ onPointerEnter,
1129
+ onPointerLeave,
1130
+ onPointerUp,
1131
+ ],
1132
+ );
1133
+
1134
+ return { isPressed, pressProps };
1135
+ }
1136
+
1137
+ // ---------------------------------------------------------------------------
1138
+ // Which input came last
1139
+ // ---------------------------------------------------------------------------
1140
+
1141
+ /** The input used most recently, while anything is listening; `null` before any. */
1142
+ let lastModality: Modality | null = null;
1143
+
1144
+ /** What the last key or pointer went down on, so its own click is not mistaken for a screen reader's. */
1145
+ let interactionTarget: mixed = null;
1146
+
1147
+ /** Everything subscribed to `lastModality`. */
1148
+ const modalitySubscribers: Set<() => void> = new Set();
1149
+
1150
+ /** Removes the document listeners, while there are any. */
1151
+ let stopTrackingModality: (() => void) | null = null;
1152
+
1153
+ /** Record the input used most recently, and tell whoever is listening. */
1154
+ function announceModality(next: Modality): void {
1155
+ if (lastModality === next) {
1156
+ return;
1157
+ }
1158
+ lastModality = next;
1159
+ for (const subscriber of modalitySubscribers) {
1160
+ subscriber();
1161
+ }
1162
+ }
1163
+
1164
+ function onModalityKey(event: $FlowFixMe): void {
1165
+ const key = event.key;
1166
+ // A modifier on its own, or a shortcut, is not the keyboard taking over.
1167
+ if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
1168
+ return;
1169
+ }
1170
+ if (event.metaKey === true || event.ctrlKey === true) {
1171
+ return;
1172
+ }
1173
+ if (event.altKey === true && !isApplePlatform()) {
1174
+ return;
1175
+ }
1176
+ // Typing is not either — except the two keys that leave a field.
1177
+ if (isTextEntry(event.target) && key !== "Tab" && key !== "Escape") {
1178
+ return;
1179
+ }
1180
+ interactionTarget = event.target;
1181
+ announceModality("keyboard");
1182
+ }
1183
+
1184
+ function onModalityPointer(event: $FlowFixMe): void {
1185
+ interactionTarget = event.target;
1186
+ announceModality(isVirtualPointer(event) ? "virtual" : "pointer");
1187
+ }
1188
+
1189
+ function onModalityClick(event: $FlowFixMe): void {
1190
+ // The click a key or a pointer went on to make is theirs: the next click,
1191
+ // on what they went down on or on an ancestor both ends of the press were
1192
+ // in. Only the next one, so a screen reader's click on the page that follows
1193
+ // a mouse press inside it is not mistaken for that press's.
1194
+ const origin = interactionTarget;
1195
+ interactionTarget = null;
1196
+ if (origin != null && (origin === event.target || contains(elementOf(event.target), origin))) {
1197
+ return;
1198
+ }
1199
+ if (isVirtualClick(event)) {
1200
+ announceModality("virtual");
1201
+ }
1202
+ }
1203
+
1204
+ /**
1205
+ * Listen for which input is used, for as long as anybody is asking.
1206
+ *
1207
+ * Capture, on the document, so an event a component stops is still counted —
1208
+ * the reader still used that input.
1209
+ */
1210
+ function subscribeModality(subscriber: () => void): () => void {
1211
+ modalitySubscribers.add(subscriber);
1212
+ const host: $FlowFixMe = globalThis;
1213
+ const document = host.document;
1214
+ if (stopTrackingModality == null && document != null) {
1215
+ document.addEventListener("keydown", onModalityKey, true);
1216
+ document.addEventListener("keyup", onModalityKey, true);
1217
+ document.addEventListener("pointerdown", onModalityPointer, true);
1218
+ document.addEventListener("mousedown", onModalityPointer, true);
1219
+ document.addEventListener("click", onModalityClick, true);
1220
+ stopTrackingModality = () => {
1221
+ document.removeEventListener("keydown", onModalityKey, true);
1222
+ document.removeEventListener("keyup", onModalityKey, true);
1223
+ document.removeEventListener("pointerdown", onModalityPointer, true);
1224
+ document.removeEventListener("mousedown", onModalityPointer, true);
1225
+ document.removeEventListener("click", onModalityClick, true);
1226
+ };
1227
+ }
1228
+ return () => {
1229
+ modalitySubscribers.delete(subscriber);
1230
+ if (modalitySubscribers.size > 0 || stopTrackingModality == null) {
1231
+ return;
1232
+ }
1233
+ stopTrackingModality();
1234
+ stopTrackingModality = null;
1235
+ // Nothing was watching in between, so whatever was last seen may no longer
1236
+ // be true; "nobody has done anything yet" is the answer that draws rings.
1237
+ lastModality = null;
1238
+ interactionTarget = null;
1239
+ };
1240
+ }
1241
+
1242
+ function readModality(): Modality | null {
1243
+ return lastModality;
1244
+ }
1245
+
1246
+ function readServerModality(): Modality | null {
1247
+ return null;
1248
+ }
1249
+
1250
+ /**
1251
+ * The input used most recently, or `null` when nothing is listening for it.
1252
+ *
1253
+ * For an event handler deciding something now — whether focus it is about to
1254
+ * move should draw a ring. A render that depends on the answer reads
1255
+ * `useInteractionModality` instead, which also keeps the listening on.
1256
+ */
1257
+ export function getInteractionModality(): Modality | null {
1258
+ return stopTrackingModality == null ? null : lastModality;
1259
+ }
1260
+
1261
+ /** The input used most recently, re-rendering when it changes; `null` before any. */
1262
+ export hook useInteractionModality(): Modality | null {
1263
+ return useSyncExternalStore(subscribeModality, readModality, readServerModality);
1264
+ }
1265
+
1266
+ /** What `useFocusVisible` hands back. */
1267
+ export type FocusVisibleResult = {|
1268
+ /** Whether focus, wherever it is, should be drawn: anything but a pointer came last. */
1269
+ readonly isFocusVisible: boolean,
1270
+ |};
1271
+
1272
+ /**
1273
+ * Whether a focus ring should be drawn, for the page as a whole.
1274
+ *
1275
+ * `useFocusRing` is the one a control wants — it adds whether the control has
1276
+ * focus. This is for something that draws focus elsewhere, or an overlay that
1277
+ * decides whether to draw one on what it focused.
1278
+ */
1279
+ export hook useFocusVisible(): FocusVisibleResult {
1280
+ const modality = useInteractionModality();
1281
+ return { isFocusVisible: modality !== "pointer" };
1282
+ }
1283
+
1284
+ /** What `useFocusRing` is told. */
1285
+ export type FocusRingOptions = {|
1286
+ /** Count focus anywhere inside the element, not only on the element itself. */
1287
+ readonly within?: boolean,
1288
+ |};
1289
+
1290
+ /** The handlers `useFocusRing` needs on the element. */
1291
+ export type FocusRingProps = {|
1292
+ readonly onBlur: (event: InteractionEvent) => void,
1293
+ readonly onFocus: (event: InteractionEvent) => void,
1294
+ |};
1295
+
1296
+ /** What `useFocusRing` hands back. */
1297
+ export type FocusRingResult = {|
1298
+ readonly focusProps: FocusRingProps,
1299
+ /** Whether the element — or, with `within`, something inside it — has focus. */
1300
+ readonly isFocused: boolean,
1301
+ /** Whether it has focus and the input that came last was not a pointer. */
1302
+ readonly isFocusVisible: boolean,
1303
+ |};
1304
+
1305
+ /**
1306
+ * Whether an element has focus, and whether that focus should be drawn.
1307
+ *
1308
+ * const { focusProps, isFocusVisible } = useFocusRing();
1309
+ * return <button {...focusProps} data-focus-visible={isFocusVisible || undefined}>Save</button>;
1310
+ *
1311
+ * React Aria's version takes `isTextInput` and `autoFocus` as well. Neither is
1312
+ * needed here: typing into a text field is ignored for the whole document, and
1313
+ * focus that arrives before any input is drawn already, because nothing has
1314
+ * made it a pointer's.
1315
+ */
1316
+ export hook useFocusRing(options?: FocusRingOptions): FocusRingResult {
1317
+ const within = options?.within === true;
1318
+ const [isFocused, setFocused] = useState(false);
1319
+ const { isFocusVisible } = useFocusVisible();
1320
+ const watching = useRef<(() => void) | null>(null);
1321
+
1322
+ const stopWatching = useStableCallback(() => {
1323
+ watching.current?.();
1324
+ watching.current = null;
1325
+ });
1326
+
1327
+ const onFocus = useStableCallback((event: InteractionEvent) => {
1328
+ const element = elementOf(event.currentTarget);
1329
+ if (!within && event.target !== element) {
1330
+ return;
1331
+ }
1332
+ setFocused(true);
1333
+ if (watching.current != null) {
1334
+ return;
1335
+ }
1336
+ // A focused element taken out of the document takes its blur with it, so
1337
+ // the next focus anywhere else is how that is noticed.
1338
+ const document = element.ownerDocument;
1339
+ const onFocusElsewhere = (native: $FlowFixMe) => {
1340
+ if (!contains(element, native.target)) {
1341
+ stopWatching();
1342
+ setFocused(false);
1343
+ }
1344
+ };
1345
+ document.addEventListener("focusin", onFocusElsewhere, true);
1346
+ watching.current = () => document.removeEventListener("focusin", onFocusElsewhere, true);
1347
+ });
1348
+
1349
+ const onBlur = useStableCallback((event: InteractionEvent) => {
1350
+ const element = elementOf(event.currentTarget);
1351
+ if (within ? contains(element, event.relatedTarget) : event.target !== element) {
1352
+ return;
1353
+ }
1354
+ stopWatching();
1355
+ setFocused(false);
1356
+ });
1357
+
1358
+ useEffect(() => stopWatching, [stopWatching]);
1359
+
1360
+ const focusProps = useMemo(() => ({ onBlur, onFocus }), [onBlur, onFocus]);
1361
+ return { focusProps, isFocused, isFocusVisible: isFocused && isFocusVisible };
1362
+ }
1363
+
1364
+ // ---------------------------------------------------------------------------
1365
+ // useHover
1366
+ // ---------------------------------------------------------------------------
1367
+
1368
+ /** Whether a `"mouse"` pointer is to be disbelieved because a touch just happened. */
1369
+ let ignoreEmulatedMouse = false;
1370
+
1371
+ /** The clock that ends `ignoreEmulatedMouse`. */
1372
+ let emulatedMouseTimer: TimeoutID | null = null;
1373
+
1374
+ /** How many hovers are listening for touches, and how to stop. */
1375
+ let touchWatchers = 0;
1376
+ let stopWatchingTouches: (() => void) | null = null;
1377
+
1378
+ function onTouchPointer(event: $FlowFixMe): void {
1379
+ if (event.pointerType !== "touch") {
1380
+ return;
1381
+ }
1382
+ ignoreEmulatedMouse = true;
1383
+ if (emulatedMouseTimer != null) {
1384
+ clearTimeout(emulatedMouseTimer);
1385
+ }
1386
+ emulatedMouseTimer = setTimeout(() => {
1387
+ ignoreEmulatedMouse = false;
1388
+ emulatedMouseTimer = null;
1389
+ }, EMULATED_MOUSE_WINDOW);
1390
+ }
1391
+
1392
+ /** Watch for touches, for as long as any hover is mounted. */
1393
+ function watchTouches(): () => void {
1394
+ touchWatchers += 1;
1395
+ const host: $FlowFixMe = globalThis;
1396
+ const document = host.document;
1397
+ if (stopWatchingTouches == null && document != null) {
1398
+ document.addEventListener("pointerdown", onTouchPointer, true);
1399
+ document.addEventListener("pointerup", onTouchPointer, true);
1400
+ stopWatchingTouches = () => {
1401
+ document.removeEventListener("pointerdown", onTouchPointer, true);
1402
+ document.removeEventListener("pointerup", onTouchPointer, true);
1403
+ if (emulatedMouseTimer != null) {
1404
+ clearTimeout(emulatedMouseTimer);
1405
+ emulatedMouseTimer = null;
1406
+ }
1407
+ ignoreEmulatedMouse = false;
1408
+ };
1409
+ }
1410
+ return () => {
1411
+ touchWatchers -= 1;
1412
+ if (touchWatchers === 0 && stopWatchingTouches != null) {
1413
+ stopWatchingTouches();
1414
+ stopWatchingTouches = null;
1415
+ }
1416
+ };
1417
+ }
1418
+
1419
+ /** A hover beginning or ending. */
1420
+ export type HoverEvent = {|
1421
+ readonly type: "hoverstart" | "hoverend",
1422
+ /** A mouse or a pen: a finger has no hover. */
1423
+ readonly pointerType: "mouse" | "pen",
1424
+ readonly target: HTMLElement,
1425
+ |};
1426
+
1427
+ /** What `useHover` is told. */
1428
+ export type HoverOptions = {|
1429
+ /** No hover; a hover in progress ends. */
1430
+ readonly isDisabled?: boolean,
1431
+ readonly onHoverChange?: (isHovering: boolean) => mixed,
1432
+ readonly onHoverEnd?: (event: HoverEvent) => mixed,
1433
+ readonly onHoverStart?: (event: HoverEvent) => mixed,
1434
+ |};
1435
+
1436
+ /** The handlers `useHover` needs on the element. */
1437
+ export type HoverProps = {|
1438
+ readonly onPointerEnter: (event: InteractionEvent) => void,
1439
+ readonly onPointerLeave: (event: InteractionEvent) => void,
1440
+ |};
1441
+
1442
+ /** What `useHover` hands back. */
1443
+ export type HoverResult = {|
1444
+ readonly hoverProps: HoverProps,
1445
+ readonly isHovered: boolean,
1446
+ |};
1447
+
1448
+ /** A hover in progress. */
1449
+ type Hovering = {|
1450
+ readonly pointerType: "mouse" | "pen",
1451
+ readonly stop: () => void,
1452
+ readonly target: HTMLElement,
1453
+ |};
1454
+
1455
+ /**
1456
+ * Whether a mouse or a pen is over an element — and never a finger.
1457
+ *
1458
+ * const { hoverProps, isHovered } = useHover({ onHoverStart: preview });
1459
+ *
1460
+ * A touch pointer is ignored, and so is a mouse pointer within half a second of
1461
+ * a touch; see the module header for the iOS behaviour that makes the second
1462
+ * rule necessary.
1463
+ */
1464
+ export hook useHover(options?: HoverOptions): HoverResult {
1465
+ const [isHovered, setHovered] = useState(false);
1466
+ const hovering = useRef<Hovering | null>(null);
1467
+
1468
+ useEffect(() => watchTouches(), []);
1469
+
1470
+ const end = useStableCallback(() => {
1471
+ const current = hovering.current;
1472
+ if (current == null) {
1473
+ return;
1474
+ }
1475
+ hovering.current = null;
1476
+ current.stop();
1477
+ setHovered(false);
1478
+ options?.onHoverEnd?.({
1479
+ pointerType: current.pointerType,
1480
+ target: current.target,
1481
+ type: "hoverend",
1482
+ });
1483
+ options?.onHoverChange?.(false);
1484
+ });
1485
+
1486
+ const onPointerEnter = useStableCallback((event: InteractionEvent) => {
1487
+ if (options?.isDisabled === true || hovering.current != null) {
1488
+ return;
1489
+ }
1490
+ const pointer = physicalPointerOf(event.pointerType);
1491
+ if (pointer === "touch" || (pointer === "mouse" && ignoreEmulatedMouse)) {
1492
+ return;
1493
+ }
1494
+ const target = elementOf(event.currentTarget);
1495
+ const document = target.ownerDocument;
1496
+ // A pointer that turns up somewhere else without this element hearing it
1497
+ // leave — the element moved, or was covered — has left.
1498
+ const onPointerElsewhere = (native: $FlowFixMe) => {
1499
+ if (!contains(target, native.target)) {
1500
+ end();
1501
+ }
1502
+ };
1503
+ document.addEventListener("pointerover", onPointerElsewhere, true);
1504
+ hovering.current = {
1505
+ pointerType: pointer,
1506
+ stop: () => document.removeEventListener("pointerover", onPointerElsewhere, true),
1507
+ target,
1508
+ };
1509
+ setHovered(true);
1510
+ options?.onHoverStart?.({ pointerType: pointer, target, type: "hoverstart" });
1511
+ options?.onHoverChange?.(true);
1512
+ });
1513
+
1514
+ const onPointerLeave = useStableCallback((_event: InteractionEvent) => {
1515
+ end();
1516
+ });
1517
+
1518
+ const isDisabled = options?.isDisabled === true;
1519
+ useEffect(() => {
1520
+ if (isDisabled) {
1521
+ end();
1522
+ }
1523
+ }, [isDisabled, end]);
1524
+
1525
+ useEffect(
1526
+ () => () => {
1527
+ hovering.current?.stop();
1528
+ hovering.current = null;
1529
+ },
1530
+ [],
1531
+ );
1532
+
1533
+ const hoverProps = useMemo(
1534
+ () => ({ onPointerEnter, onPointerLeave }),
1535
+ [onPointerEnter, onPointerLeave],
1536
+ );
1537
+ return { hoverProps, isHovered };
1538
+ }
1539
+
1540
+ // ---------------------------------------------------------------------------
1541
+ // useLongPress
1542
+ // ---------------------------------------------------------------------------
1543
+
1544
+ /** A moment in a long press. */
1545
+ export type LongPressEvent = {|
1546
+ readonly type: "longpressstart" | "longpressend" | "longpress",
1547
+ readonly pointerType: PhysicalPointer,
1548
+ readonly target: HTMLElement,
1549
+ readonly altKey: boolean,
1550
+ readonly ctrlKey: boolean,
1551
+ readonly metaKey: boolean,
1552
+ readonly shiftKey: boolean,
1553
+ readonly x: number,
1554
+ readonly y: number,
1555
+ |};
1556
+
1557
+ /** What `useLongPress` is told. */
1558
+ export type LongPressOptions = {|
1559
+ /**
1560
+ * What a reader is told a long press does, as the element's description.
1561
+ *
1562
+ * A long press is invisible. A component that offers one owes a keyboard way
1563
+ * to do the same thing, and this is where it says what that is — "Long press
1564
+ * or press Shift+F10 for more actions".
1565
+ */
1566
+ readonly accessibilityDescription?: string,
1567
+ readonly isDisabled?: boolean,
1568
+ /** The press lasted long enough. The press underneath is cancelled, and its click refused. */
1569
+ readonly onLongPress?: (event: LongPressEvent) => mixed,
1570
+ /** The press that might have been a long one ended, whichever it turned out to be. */
1571
+ readonly onLongPressEnd?: (event: LongPressEvent) => mixed,
1572
+ /** A press began that could become a long press. */
1573
+ readonly onLongPressStart?: (event: LongPressEvent) => mixed,
1574
+ /**
1575
+ * Which pointers a long press may come from; every one of them by default.
1576
+ *
1577
+ * A context menu's long press is a touch's, because a mouse has a right
1578
+ * button for it — and a mouse held down on a row is starting a text
1579
+ * selection or a drag, not asking for a menu.
1580
+ */
1581
+ readonly pointerTypes?: $ReadOnlyArray<PhysicalPointer>,
1582
+ /** How long, in milliseconds. 500 by default. */
1583
+ readonly threshold?: number,
1584
+ |};
1585
+
1586
+ /** The handlers and the description `useLongPress` needs on the element. */
1587
+ export type LongPressProps = {|
1588
+ ...PressProps,
1589
+ readonly "aria-describedby"?: string,
1590
+ |};
1591
+
1592
+ /** What `useLongPress` hands back. */
1593
+ export type LongPressResult = {|
1594
+ readonly longPressProps: LongPressProps,
1595
+ |};
1596
+
1597
+ /** A description element shared by every long press that says the same thing. */
1598
+ type SharedDescription = {| readonly node: HTMLElement, users: number |};
1599
+
1600
+ const descriptions: Map<string, SharedDescription> = new Map();
1601
+ let descriptionCount = 0;
1602
+
1603
+ /**
1604
+ * The id of an element holding `text`, once there is one in the document.
1605
+ *
1606
+ * `hidden`, because a description is read by reference and never shown, and an
1607
+ * element referenced by `aria-describedby` is described from even when hidden.
1608
+ * The id is only handed out after the element exists, which is the rule this
1609
+ * package keeps everywhere: a reference to an id nothing has is announced as
1610
+ * nothing at all.
1611
+ */
1612
+ hook useDescription(text: string | void): string | void {
1613
+ const [id, setId] = useState<string | void>(undefined);
1614
+
1615
+ useEffect(() => {
1616
+ const host: $FlowFixMe = globalThis;
1617
+ const body: HTMLElement | null = host.document?.body ?? null;
1618
+ if (text == null || text === "" || body == null) {
1619
+ setId(undefined);
1620
+ return;
1621
+ }
1622
+ let shared: SharedDescription | void = descriptions.get(text);
1623
+ if (shared == null) {
1624
+ descriptionCount += 1;
1625
+ const node = body.ownerDocument.createElement("div");
1626
+ node.id = `uf-long-press-description-${String(descriptionCount)}`;
1627
+ node.hidden = true;
1628
+ node.textContent = text;
1629
+ body.appendChild(node);
1630
+ // Annotated, or the literal's `0` is the type of `users` from here on
1631
+ // and counting the next user is a type error.
1632
+ const created: SharedDescription = { node, users: 0 };
1633
+ descriptions.set(text, created);
1634
+ shared = created;
1635
+ }
1636
+ const entry: SharedDescription = shared;
1637
+ entry.users += 1;
1638
+ setId(entry.node.id);
1639
+ return () => {
1640
+ entry.users -= 1;
1641
+ if (entry.users === 0) {
1642
+ entry.node.remove();
1643
+ descriptions.delete(text);
1644
+ }
1645
+ };
1646
+ }, [text]);
1647
+
1648
+ return id;
1649
+ }
1650
+
1651
+ /**
1652
+ * End every press and every drag on `target`: a long press is the whole gesture.
1653
+ *
1654
+ * A `pointercancel` rather than a call into the other hooks, because the hooks
1655
+ * on an element are the caller's to combine and none of them knows about the
1656
+ * others; every one of them already ends on this event.
1657
+ */
1658
+ function cancelGesturesOn(
1659
+ target: HTMLElement,
1660
+ pointerId: number,
1661
+ pointerType: PhysicalPointer,
1662
+ ): void {
1663
+ const view: $FlowFixMe = target.ownerDocument.defaultView;
1664
+ if (view == null) {
1665
+ return;
1666
+ }
1667
+ const cancel =
1668
+ typeof view.PointerEvent === "function"
1669
+ ? new view.PointerEvent("pointercancel", { bubbles: true, pointerId, pointerType })
1670
+ : new view.Event("pointercancel", { bubbles: true });
1671
+ if (cancel.pointerId !== pointerId) {
1672
+ Object.defineProperty(cancel, "pointerId", { value: pointerId });
1673
+ }
1674
+ target.dispatchEvent(cancel);
1675
+ }
1676
+
1677
+ /**
1678
+ * Refuse the click that ends a long press, so a long press on a link does not follow it.
1679
+ *
1680
+ * On the document and in the capture phase, so the click reaches nothing inside
1681
+ * the element — not a `usePress` beside this hook, not a link's navigation. The
1682
+ * next press is where the refusal gives up, for the release that never clicks.
1683
+ */
1684
+ function refuseNextClick(target: HTMLElement): void {
1685
+ const document = target.ownerDocument;
1686
+ const refuse = (event: $FlowFixMe) => {
1687
+ stop();
1688
+ if (contains(target, event.target)) {
1689
+ event.preventDefault();
1690
+ event.stopPropagation();
1691
+ }
1692
+ };
1693
+ const giveUp = () => stop();
1694
+ const stop = () => {
1695
+ document.removeEventListener("click", refuse, true);
1696
+ document.removeEventListener("pointerdown", giveUp, true);
1697
+ };
1698
+ document.addEventListener("click", refuse, true);
1699
+ document.addEventListener("pointerdown", giveUp, true);
1700
+ }
1701
+
1702
+ /**
1703
+ * A press held long enough to mean something else.
1704
+ *
1705
+ * const { longPressProps } = useLongPress({
1706
+ * accessibilityDescription: "Long press or press Shift+F10 for more actions",
1707
+ * onLongPress: openMenu,
1708
+ * pointerTypes: ["touch", "pen"],
1709
+ * });
1710
+ *
1711
+ * Merged with a `usePress` on the same element, a long press cancels the press
1712
+ * — `onPress` does not follow `onLongPress`. See the module header for why it
1713
+ * has no keyboard of its own.
1714
+ */
1715
+ export hook useLongPress(options?: LongPressOptions): LongPressResult {
1716
+ const threshold = options?.threshold ?? LONG_PRESS_THRESHOLD;
1717
+ const timer = useRef<TimeoutID | null>(null);
1718
+ const pointerId = useRef(0);
1719
+ const holding = useRef<PressEvent | null>(null);
1720
+ const stopRefusingMenu = useRef<(() => void) | null>(null);
1721
+ const describedBy = useDescription(
1722
+ options?.isDisabled === true || options?.onLongPress == null
1723
+ ? undefined
1724
+ : options?.accessibilityDescription,
1725
+ );
1726
+
1727
+ const settle = useStableCallback(() => {
1728
+ if (timer.current != null) {
1729
+ clearTimeout(timer.current);
1730
+ timer.current = null;
1731
+ }
1732
+ stopRefusingMenu.current?.();
1733
+ stopRefusingMenu.current = null;
1734
+ });
1735
+
1736
+ const describe = (
1737
+ type: "longpressstart" | "longpressend" | "longpress",
1738
+ pointerType: PhysicalPointer,
1739
+ event: PressEvent,
1740
+ ): LongPressEvent => ({
1741
+ altKey: event.altKey,
1742
+ ctrlKey: event.ctrlKey,
1743
+ metaKey: event.metaKey,
1744
+ pointerType,
1745
+ shiftKey: event.shiftKey,
1746
+ target: event.target,
1747
+ type,
1748
+ x: event.x,
1749
+ y: event.y,
1750
+ });
1751
+
1752
+ const onPressStart = useStableCallback((event: PressEvent) => {
1753
+ const pointerType = event.pointerType;
1754
+ if (pointerType !== "mouse" && pointerType !== "pen" && pointerType !== "touch") {
1755
+ return;
1756
+ }
1757
+ if (!(options?.pointerTypes ?? EVERY_POINTER).includes(pointerType)) {
1758
+ return;
1759
+ }
1760
+ settle();
1761
+ holding.current = event;
1762
+ options?.onLongPressStart?.(describe("longpressstart", pointerType, event));
1763
+ const target = event.target;
1764
+ if (pointerType === "touch") {
1765
+ // A finger held down asks the platform for its own menu at about the
1766
+ // same moment; the long press is the answer instead.
1767
+ const refuseMenu = (native: $FlowFixMe) => native.preventDefault();
1768
+ target.addEventListener("contextmenu", refuseMenu, false);
1769
+ stopRefusingMenu.current = () => target.removeEventListener("contextmenu", refuseMenu, false);
1770
+ }
1771
+ const id = pointerId.current;
1772
+ timer.current = setTimeout(() => {
1773
+ timer.current = null;
1774
+ options?.onLongPress?.(describe("longpress", pointerType, event));
1775
+ refuseNextClick(target);
1776
+ cancelGesturesOn(target, id, pointerType);
1777
+ }, threshold);
1778
+ });
1779
+
1780
+ const onPressEnd = useStableCallback((event: PressEvent) => {
1781
+ const held = holding.current;
1782
+ if (held == null) {
1783
+ return;
1784
+ }
1785
+ holding.current = null;
1786
+ settle();
1787
+ const pointerType = held.pointerType;
1788
+ if (pointerType === "mouse" || pointerType === "pen" || pointerType === "touch") {
1789
+ options?.onLongPressEnd?.(describe("longpressend", pointerType, event));
1790
+ }
1791
+ });
1792
+
1793
+ const { pressProps } = usePress({
1794
+ isDisabled: options?.isDisabled,
1795
+ onPressEnd,
1796
+ onPressStart,
1797
+ });
1798
+
1799
+ const onPointerDown = useStableCallback((event: InteractionEvent) => {
1800
+ pointerId.current = event.pointerId ?? 0;
1801
+ pressProps.onPointerDown(event);
1802
+ });
1803
+
1804
+ useEffect(() => settle, [settle]);
1805
+
1806
+ const longPressProps = useMemo(
1807
+ () => ({ ...pressProps, "aria-describedby": describedBy, onPointerDown }),
1808
+ [pressProps, describedBy, onPointerDown],
1809
+ );
1810
+ return { longPressProps };
1811
+ }
1812
+
1813
+ // ---------------------------------------------------------------------------
1814
+ // useMove
1815
+ // ---------------------------------------------------------------------------
1816
+
1817
+ /** The input a move came from. */
1818
+ export type MovePointerType = PhysicalPointer | "keyboard";
1819
+
1820
+ /** A move beginning: the first movement after a pointer went down, or an arrow key. */
1821
+ export type MoveStartEvent = {|
1822
+ readonly type: "movestart",
1823
+ readonly pointerType: MovePointerType,
1824
+ ...Modifiers,
1825
+ |};
1826
+
1827
+ /** A movement, in pixels for a pointer and in steps of one for a key. */
1828
+ export type MoveMoveEvent = {|
1829
+ readonly type: "move",
1830
+ readonly pointerType: MovePointerType,
1831
+ /** How far right since the last event; negative is left. */
1832
+ readonly deltaX: number,
1833
+ /** How far down since the last event; negative is up. */
1834
+ readonly deltaY: number,
1835
+ ...Modifiers,
1836
+ |};
1837
+
1838
+ /** A move ending. */
1839
+ export type MoveEndEvent = {|
1840
+ readonly type: "moveend",
1841
+ readonly pointerType: MovePointerType,
1842
+ ...Modifiers,
1843
+ |};
1844
+
1845
+ /** What `useMove` is told. */
1846
+ export type MoveOptions = {|
1847
+ readonly onMove?: (event: MoveMoveEvent) => mixed,
1848
+ readonly onMoveEnd?: (event: MoveEndEvent) => mixed,
1849
+ readonly onMoveStart?: (event: MoveStartEvent) => mixed,
1850
+ |};
1851
+
1852
+ /** The handlers `useMove` needs on the element. */
1853
+ export type MoveProps = {|
1854
+ readonly onKeyDown: (event: InteractionEvent) => void,
1855
+ readonly onPointerDown: (event: InteractionEvent) => void,
1856
+ |};
1857
+
1858
+ /** What `useMove` hands back. */
1859
+ export type MoveResult = {|
1860
+ readonly moveProps: MoveProps,
1861
+ |};
1862
+
1863
+ /** A drag in progress. */
1864
+ type Dragging = {|
1865
+ lastX: number,
1866
+ lastY: number,
1867
+ moved: boolean,
1868
+ readonly pointerId: number,
1869
+ readonly pointerType: PhysicalPointer,
1870
+ restoreSelection: (() => void) | null,
1871
+ readonly stop: () => void,
1872
+ |};
1873
+
1874
+ /**
1875
+ * How far a pointer or an arrow key moved something, one event at a time.
1876
+ *
1877
+ * const { moveProps } = useMove({ onMove: ({ deltaX }) => resizeBy(deltaX) });
1878
+ *
1879
+ * A move begins with the first movement rather than with the press, so a click
1880
+ * that does not move is not a drag. Physical directions, not reading ones: a
1881
+ * caller whose axis runs the other way in a right-to-left page — a slider —
1882
+ * turns `deltaX` round itself, because only it knows that its axis does.
1883
+ */
1884
+ export hook useMove(options?: MoveOptions): MoveResult {
1885
+ const dragging = useRef<Dragging | null>(null);
1886
+
1887
+ const emitStart = useStableCallback((pointerType: MovePointerType, source: mixed) => {
1888
+ options?.onMoveStart?.({ ...modifiersOf(source), pointerType, type: "movestart" });
1889
+ });
1890
+ const emitMove = useStableCallback(
1891
+ (pointerType: MovePointerType, deltaX: number, deltaY: number, source: mixed) => {
1892
+ options?.onMove?.({ ...modifiersOf(source), deltaX, deltaY, pointerType, type: "move" });
1893
+ },
1894
+ );
1895
+ const emitEnd = useStableCallback((pointerType: MovePointerType, source: mixed) => {
1896
+ options?.onMoveEnd?.({ ...modifiersOf(source), pointerType, type: "moveend" });
1897
+ });
1898
+
1899
+ const finish = useStableCallback((native: $FlowFixMe) => {
1900
+ const current = dragging.current;
1901
+ if (current == null || (native.pointerId ?? 0) !== current.pointerId) {
1902
+ return;
1903
+ }
1904
+ dragging.current = null;
1905
+ current.stop();
1906
+ if (current.moved) {
1907
+ emitEnd(current.pointerType, native);
1908
+ }
1909
+ });
1910
+
1911
+ const onDocumentPointerMove = useStableCallback((native: $FlowFixMe) => {
1912
+ const current = dragging.current;
1913
+ if (current == null || (native.pointerId ?? 0) !== current.pointerId) {
1914
+ return;
1915
+ }
1916
+ // A mouse moving with no button held has already let go, somewhere this
1917
+ // never heard about: the menu a right click opens swallows the release.
1918
+ if (
1919
+ current.pointerType === "mouse" &&
1920
+ typeof native.buttons === "number" &&
1921
+ (native.buttons & 1) === 0
1922
+ ) {
1923
+ finish(native);
1924
+ return;
1925
+ }
1926
+ const x = typeof native.clientX === "number" ? native.clientX : current.lastX;
1927
+ const y = typeof native.clientY === "number" ? native.clientY : current.lastY;
1928
+ const deltaX = x - current.lastX;
1929
+ const deltaY = y - current.lastY;
1930
+ if (deltaX === 0 && deltaY === 0) {
1931
+ return;
1932
+ }
1933
+ current.lastX = x;
1934
+ current.lastY = y;
1935
+ if (!current.moved) {
1936
+ current.moved = true;
1937
+ // Only once it is a drag: a click that does not move may still select.
1938
+ current.restoreSelection = withoutTextSelection(
1939
+ native.target?.ownerDocument?.documentElement ?? null,
1940
+ );
1941
+ emitStart(current.pointerType, native);
1942
+ }
1943
+ emitMove(current.pointerType, deltaX, deltaY, native);
1944
+ });
1945
+
1946
+ const onPointerDown = useStableCallback((event: InteractionEvent) => {
1947
+ const element = elementOf(event.currentTarget);
1948
+ if (event.button !== 0 || dragging.current != null || answeredElsewhere(event, element)) {
1949
+ return;
1950
+ }
1951
+ markAnswered(event, element);
1952
+ const document = element.ownerDocument;
1953
+ document.addEventListener("pointermove", onDocumentPointerMove, false);
1954
+ document.addEventListener("pointerup", finish, false);
1955
+ document.addEventListener("pointercancel", finish, false);
1956
+ const drag: Dragging = {
1957
+ lastX: event.clientX ?? 0,
1958
+ lastY: event.clientY ?? 0,
1959
+ moved: false,
1960
+ pointerId: event.pointerId ?? 0,
1961
+ pointerType: physicalPointerOf(event.pointerType),
1962
+ restoreSelection: null,
1963
+ stop: () => {
1964
+ document.removeEventListener("pointermove", onDocumentPointerMove, false);
1965
+ document.removeEventListener("pointerup", finish, false);
1966
+ document.removeEventListener("pointercancel", finish, false);
1967
+ drag.restoreSelection?.();
1968
+ drag.restoreSelection = null;
1969
+ },
1970
+ };
1971
+ dragging.current = drag;
1972
+ });
1973
+
1974
+ const onKeyDown = useStableCallback((event: InteractionEvent) => {
1975
+ if (event.defaultPrevented) {
1976
+ return;
1977
+ }
1978
+ const key = event.key;
1979
+ const deltaX =
1980
+ key === "ArrowLeft" || key === "Left" ? -1 : key === "ArrowRight" || key === "Right" ? 1 : 0;
1981
+ const deltaY =
1982
+ key === "ArrowUp" || key === "Up" ? -1 : key === "ArrowDown" || key === "Down" ? 1 : 0;
1983
+ if (deltaX === 0 && deltaY === 0) {
1984
+ return;
1985
+ }
1986
+ // The arrow moved something, so it neither scrolls the page nor moves a
1987
+ // roving focus around this element.
1988
+ event.preventDefault();
1989
+ event.stopPropagation();
1990
+ emitStart("keyboard", event);
1991
+ emitMove("keyboard", deltaX, deltaY, event);
1992
+ emitEnd("keyboard", event);
1993
+ });
1994
+
1995
+ useEffect(
1996
+ () => () => {
1997
+ dragging.current?.stop();
1998
+ dragging.current = null;
1999
+ },
2000
+ [],
2001
+ );
2002
+
2003
+ const moveProps = useMemo(() => ({ onKeyDown, onPointerDown }), [onKeyDown, onPointerDown]);
2004
+ return { moveProps };
2005
+ }
2006
+
2007
+ // ---------------------------------------------------------------------------
2008
+ // useKeyboard
2009
+ // ---------------------------------------------------------------------------
2010
+
2011
+ /** A key, as `useKeyboard` hands it to a handler. */
2012
+ export type KeyboardInteraction = {|
2013
+ readonly type: "keydown" | "keyup",
2014
+ readonly key: string,
2015
+ readonly code: string,
2016
+ readonly repeat: boolean,
2017
+ readonly altKey: boolean,
2018
+ readonly ctrlKey: boolean,
2019
+ readonly metaKey: boolean,
2020
+ readonly shiftKey: boolean,
2021
+ /** The element the key went to, which may be inside the one listening. */
2022
+ readonly target: mixed,
2023
+ /** The element listening. */
2024
+ readonly currentTarget: HTMLElement,
2025
+ readonly isDefaultPrevented: () => boolean,
2026
+ readonly preventDefault: () => void,
2027
+ /**
2028
+ * Let the key reach the elements around this one.
2029
+ *
2030
+ * Stopping is the default, and there is no `stopPropagation` to call: see
2031
+ * the module header.
2032
+ */
2033
+ readonly continuePropagation: () => void,
2034
+ |};
2035
+
2036
+ /** What `useKeyboard` is told. */
2037
+ export type KeyboardOptions = {|
2038
+ /** Hear nothing and stop nothing. */
2039
+ readonly isDisabled?: boolean,
2040
+ readonly onKeyDown?: (event: KeyboardInteraction) => mixed,
2041
+ readonly onKeyUp?: (event: KeyboardInteraction) => mixed,
2042
+ |};
2043
+
2044
+ /** The handlers `useKeyboard` needs on the element — only the ones it was given. */
2045
+ export type KeyboardProps = {|
2046
+ readonly onKeyDown?: (event: InteractionEvent) => void,
2047
+ readonly onKeyUp?: (event: InteractionEvent) => void,
2048
+ |};
2049
+
2050
+ /** What `useKeyboard` hands back. */
2051
+ export type KeyboardResult = {|
2052
+ readonly keyboardProps: KeyboardProps,
2053
+ |};
2054
+
2055
+ /**
2056
+ * Keys on an element, stopped there unless a handler passes them on.
2057
+ *
2058
+ * const { keyboardProps } = useKeyboard({
2059
+ * onKeyDown: (event) => {
2060
+ * if (event.key === "Delete") remove();
2061
+ * else event.continuePropagation();
2062
+ * },
2063
+ * });
2064
+ *
2065
+ * A handler that is not given is not attached, so a `useKeyboard` with only
2066
+ * `onKeyDown` stops no `keyup`.
2067
+ */
2068
+ export hook useKeyboard(options?: KeyboardOptions): KeyboardResult {
2069
+ const route = useStableCallback((event: InteractionEvent, up: boolean) => {
2070
+ const handler = up ? options?.onKeyUp : options?.onKeyDown;
2071
+ if (handler == null) {
2072
+ return;
2073
+ }
2074
+ let continued = false;
2075
+ handler({
2076
+ ...modifiersOf(event),
2077
+ code: event.code ?? "",
2078
+ continuePropagation: () => {
2079
+ continued = true;
2080
+ },
2081
+ currentTarget: elementOf(event.currentTarget),
2082
+ isDefaultPrevented: () => event.defaultPrevented,
2083
+ key: event.key ?? "",
2084
+ preventDefault: () => {
2085
+ event.preventDefault();
2086
+ },
2087
+ repeat: event.repeat === true,
2088
+ target: event.target,
2089
+ type: up ? "keyup" : "keydown",
2090
+ });
2091
+ if (!continued) {
2092
+ event.stopPropagation();
2093
+ }
2094
+ });
2095
+
2096
+ const onKeyDown = useStableCallback((event: InteractionEvent) => route(event, false));
2097
+ const onKeyUp = useStableCallback((event: InteractionEvent) => route(event, true));
2098
+
2099
+ const disabled = options?.isDisabled === true;
2100
+ const hearsDown = options?.onKeyDown != null;
2101
+ const hearsUp = options?.onKeyUp != null;
2102
+ const keyboardProps = useMemo(
2103
+ () =>
2104
+ disabled
2105
+ ? {}
2106
+ : {
2107
+ onKeyDown: hearsDown ? onKeyDown : undefined,
2108
+ onKeyUp: hearsUp ? onKeyUp : undefined,
2109
+ },
2110
+ [disabled, hearsDown, hearsUp, onKeyDown, onKeyUp],
2111
+ );
2112
+ return { keyboardProps };
2113
+ }
2114
+
2115
+ // ---------------------------------------------------------------------------
2116
+ // mergeProps
2117
+ // ---------------------------------------------------------------------------
2118
+
2119
+ /**
2120
+ * Several hooks' props, for one element.
2121
+ *
2122
+ * <button {...mergeProps(pressProps, hoverProps, focusProps)}>Save</button>
2123
+ *
2124
+ * An event handler — a name that is `on` and a capital letter — present in more
2125
+ * than one is called in the order given, each of them; a `className` present in
2126
+ * more than one is joined; anything else is the last one given, and an
2127
+ * `undefined` does not replace what came before it. See the module header for
2128
+ * why this is not `internal/merge-props.js`.
2129
+ */
2130
+ export function mergeProps(
2131
+ ...sources: $ReadOnlyArray<?{ readonly [string]: mixed }>
2132
+ ): InteractionProps {
2133
+ const merged: { key?: empty, [string]: mixed } = {};
2134
+ for (const source of sources) {
2135
+ if (source == null) {
2136
+ continue;
2137
+ }
2138
+ for (const name of Object.keys(source)) {
2139
+ const value = source[name];
2140
+ // `key` is React's, and never arrives in props; see `InteractionProps`.
2141
+ if (value === undefined || name === "key") {
2142
+ continue;
2143
+ }
2144
+ const before = merged[name];
2145
+ if (typeof before === "function" && typeof value === "function" && /^on[A-Z]/.test(name)) {
2146
+ merged[name] = chain(before, value);
2147
+ } else if (name === "className" && typeof before === "string" && typeof value === "string") {
2148
+ merged[name] = `${before} ${value}`;
2149
+ } else {
2150
+ merged[name] = value;
2151
+ }
2152
+ }
2153
+ }
2154
+ return merged;
2155
+ }
2156
+
2157
+ /** Two handlers as one, called in order with the same arguments. */
2158
+ function chain(first: mixed, second: mixed): (...args: $ReadOnlyArray<mixed>) => void {
2159
+ return (...args: $ReadOnlyArray<mixed>) => {
2160
+ (first as $FlowFixMe)(...args);
2161
+ (second as $FlowFixMe)(...args);
2162
+ };
2163
+ }