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