@ringcentral/spring-base 1.12.0 → 1.13.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/config.js +32 -12
- package/contexts.js +42 -24
- package/hooks.js +1630 -613
- package/index.js +15 -17
- package/liveAnnouncer.js +303 -1
- package/package.json +2 -2
- package/src/hooks/useButton/useButton.d.ts +1 -1
- package/typings.js +7 -0
- package/utils.js +835 -81
- package/chunks/index.js +0 -234
- package/chunks/twMerge.js +0 -503
package/hooks.js
CHANGED
|
@@ -1,72 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const useEventCallback = (func) => {
|
|
1
|
+
import { unstable_Timeout, unstable_useControlled, unstable_useEventCallback, unstable_useForkRef } from "@mui/utils";
|
|
2
|
+
import react, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import uniqueId from "lodash/uniqueId";
|
|
4
|
+
import { useButton } from "@mui/base";
|
|
5
|
+
import debounce from "lodash/debounce";
|
|
6
|
+
import noop from "lodash/noop";
|
|
7
|
+
import throttle from "lodash/throttle";
|
|
8
|
+
import { getMatchDetail, useWindowContext, useResponsiveContext } from "./contexts.js";
|
|
9
|
+
import { LIVE_ANNOUNCER_ATTR, announce, applyVisuallyHiddenStyles } from "./liveAnnouncer.js";
|
|
10
|
+
import { createAriaHideOutsideController, getRefElement, logInDev, ownerWindow, combineProps, ownerDocument as unstable_ownerDocument } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
import * as __rspack_external_react from "react";
|
|
13
|
+
|
|
14
|
+
// this file template replace material-ui/core/utils method
|
|
15
|
+
// when upgrade mui that will not need assign type anymore
|
|
16
|
+
|
|
17
|
+
const useEventCallback = (func)=>{
|
|
20
18
|
return unstable_useEventCallback(func);
|
|
21
19
|
};
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
const defaultCheckKeys = [
|
|
25
|
+
'Enter',
|
|
26
|
+
' '
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* bind event with a11y keyboard, `space`, `enter`, only trigger when key includes in `checkKeys`
|
|
30
|
+
*
|
|
31
|
+
* that will auto preventDefault by default, if you don't want that, set `false` with `preventDefault`
|
|
32
|
+
*/ const useA11yKeyEvent = (fn, { onlyOnFocus, preventDefault = true, checkKeys = defaultCheckKeys } = {})=>{
|
|
33
|
+
return useEventCallback((event)=>{
|
|
34
|
+
if (!fn || onlyOnFocus && event.target !== event.currentTarget) return;
|
|
28
35
|
if (checkKeys.includes(event.key)) {
|
|
29
|
-
if (preventDefault)
|
|
30
|
-
event.preventDefault();
|
|
36
|
+
if (preventDefault) event.preventDefault();
|
|
31
37
|
fn(event);
|
|
32
38
|
}
|
|
33
39
|
});
|
|
34
40
|
};
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
// base https://github.com/mui/material-ui/blob/master/packages/mui-base/src/useButton/useButton.ts
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
const isNativeButton = (el)=>{
|
|
37
49
|
const tag = el.tagName;
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
return tag === 'BUTTON' || tag === 'INPUT' && [
|
|
51
|
+
'button',
|
|
52
|
+
'submit',
|
|
53
|
+
'reset'
|
|
54
|
+
].includes(el?.type) || tag === 'A' && el?.href;
|
|
42
55
|
};
|
|
43
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Bind event with a11y keyboard (`space`) to help non-native button elements implement `:active` state.
|
|
58
|
+
*
|
|
59
|
+
* Also handles mouse interactions with proper window context support.
|
|
60
|
+
*/ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false } = {})=>{
|
|
44
61
|
const [active, setActive] = useState(false);
|
|
45
62
|
const activeTargetRef = useRef(null);
|
|
46
63
|
const { document: targetDocument = document } = useWindowContext();
|
|
47
|
-
useEffect(()
|
|
64
|
+
useEffect(()=>{
|
|
48
65
|
if (disabled) {
|
|
49
66
|
setActive(false);
|
|
50
67
|
activeTargetRef.current = null;
|
|
51
68
|
}
|
|
52
|
-
}, [
|
|
69
|
+
}, [
|
|
70
|
+
disabled
|
|
71
|
+
]);
|
|
53
72
|
if (disabled) {
|
|
54
73
|
return {
|
|
55
|
-
activeHandlers: {
|
|
56
|
-
|
|
74
|
+
activeHandlers: {
|
|
75
|
+
onKeyDown,
|
|
76
|
+
onKeyUp,
|
|
77
|
+
onMouseDown,
|
|
78
|
+
onClick
|
|
79
|
+
},
|
|
80
|
+
active: false
|
|
57
81
|
};
|
|
58
82
|
}
|
|
59
|
-
const handleMouseDown = (event)
|
|
83
|
+
const handleMouseDown = (event)=>{
|
|
60
84
|
onMouseDown?.(event);
|
|
61
85
|
if (event.defaultPrevented) {
|
|
62
86
|
return;
|
|
63
87
|
}
|
|
64
88
|
setActive(true);
|
|
65
|
-
targetDocument.addEventListener('mouseup', ()
|
|
89
|
+
targetDocument.addEventListener('mouseup', ()=>{
|
|
66
90
|
setActive(false);
|
|
67
|
-
}, {
|
|
91
|
+
}, {
|
|
92
|
+
once: true
|
|
93
|
+
});
|
|
68
94
|
};
|
|
69
|
-
const handleKeyDown = (event)
|
|
95
|
+
const handleKeyDown = (event)=>{
|
|
70
96
|
const isSpaceKey = event.key === ' ';
|
|
71
97
|
onKeyDown?.(event);
|
|
72
98
|
if (event.defaultPrevented) {
|
|
@@ -82,15 +108,16 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
82
108
|
}
|
|
83
109
|
setActive(true);
|
|
84
110
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
event.key === 'Enter') {
|
|
111
|
+
// Keyboard accessibility for non interactive elements
|
|
112
|
+
if (event.target === event.currentTarget && !isNativeButton(event.target) && event.key === 'Enter') {
|
|
88
113
|
onClick?.(event);
|
|
89
114
|
event.preventDefault();
|
|
90
115
|
}
|
|
91
116
|
};
|
|
92
|
-
const handleKeyUp = (event)
|
|
117
|
+
const handleKeyUp = (event)=>{
|
|
93
118
|
const isSpaceKey = event.key === ' ';
|
|
119
|
+
// calling preventDefault in keyUp on a <button> will not dispatch a click event if Space is pressed
|
|
120
|
+
// https://codesandbox.io/s/button-keyup-preventdefault-dn7f0
|
|
94
121
|
if (event.target === event.currentTarget) {
|
|
95
122
|
setActive(false);
|
|
96
123
|
}
|
|
@@ -99,11 +126,8 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
99
126
|
if (isSpaceKey) {
|
|
100
127
|
activeTargetRef.current = null;
|
|
101
128
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
!isNativeButton(event.target) &&
|
|
105
|
-
isSpaceKey &&
|
|
106
|
-
!event.defaultPrevented) {
|
|
129
|
+
// Keyboard accessibility for non interactive elements
|
|
130
|
+
if (hadMatchingSpaceKeyDown && event.target === event.currentTarget && !isNativeButton(event.target) && isSpaceKey && !event.defaultPrevented) {
|
|
107
131
|
onClick?.(event);
|
|
108
132
|
}
|
|
109
133
|
};
|
|
@@ -112,13 +136,27 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
112
136
|
onKeyDown: handleKeyDown,
|
|
113
137
|
onKeyUp: handleKeyUp,
|
|
114
138
|
onMouseDown: handleMouseDown,
|
|
115
|
-
onClick
|
|
139
|
+
onClick
|
|
116
140
|
},
|
|
117
|
-
active
|
|
141
|
+
active
|
|
118
142
|
};
|
|
119
143
|
};
|
|
120
144
|
|
|
121
|
-
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* provide a ref for only calculate `cb` once
|
|
154
|
+
* prevent `useRef(getValue())`, that `getValue` calculate every re-render
|
|
155
|
+
*
|
|
156
|
+
* @param cb that get method of get this ref init value, only calculate once
|
|
157
|
+
* @returns same as `useRef` result
|
|
158
|
+
*
|
|
159
|
+
*/ function useResultRef(cb) {
|
|
122
160
|
const renderRef = useRef(0);
|
|
123
161
|
const ref = useRef();
|
|
124
162
|
if (renderRef.current === 0) {
|
|
@@ -128,9 +166,27 @@ function useResultRef(cb) {
|
|
|
128
166
|
return ref;
|
|
129
167
|
}
|
|
130
168
|
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
131
174
|
const DEFAULT_PREFIX = 'spring-id-';
|
|
132
|
-
|
|
133
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Generates a unique ID.
|
|
177
|
+
*
|
|
178
|
+
* @param id - Default component id or prefix id.
|
|
179
|
+
* @param prefixMode Whether enable prefix the ID with.
|
|
180
|
+
* @returns unique ID
|
|
181
|
+
* @example
|
|
182
|
+
* useId() => 'spring-id-xxxx'
|
|
183
|
+
* useId('') => 'spring-id-xxxx'
|
|
184
|
+
* useId(null) => 'spring-id-xxxx'
|
|
185
|
+
* useId(undefined) => 'spring-id-xxxx'
|
|
186
|
+
* useId('nameId') => 'nameId'
|
|
187
|
+
* useId('nameId', true) => 'nameId-xxxx'
|
|
188
|
+
*/ function useLegacyId(id, prefixMode) {
|
|
189
|
+
const { current: resultId } = useResultRef(()=>{
|
|
134
190
|
if (id) {
|
|
135
191
|
if (prefixMode) {
|
|
136
192
|
return uniqueId(`${id}-`);
|
|
@@ -141,7 +197,7 @@ function useLegacyId(id, prefixMode) {
|
|
|
141
197
|
});
|
|
142
198
|
return resultId;
|
|
143
199
|
}
|
|
144
|
-
const reactUseId =
|
|
200
|
+
const reactUseId = react.useId;
|
|
145
201
|
function useReactId(id, prefixMode) {
|
|
146
202
|
const reactId = reactUseId();
|
|
147
203
|
if (id) {
|
|
@@ -151,50 +207,71 @@ function useReactId(id, prefixMode) {
|
|
|
151
207
|
}
|
|
152
208
|
const useId = reactUseId ? useReactId : useLegacyId;
|
|
153
209
|
|
|
154
|
-
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
const isUseAnnouncerOptions = (value)=>{
|
|
155
217
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
156
218
|
};
|
|
157
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Instance-owned live announcer. Each hook call manages its own body region.
|
|
221
|
+
*
|
|
222
|
+
* Prefer `announce` (or `useGlobalAnnouncer` in a pop-out window) so the page
|
|
223
|
+
* shares one polite/assertive live-region pair per document. Use this hook
|
|
224
|
+
* only when you need an isolated region that must not share that pair.
|
|
225
|
+
*
|
|
226
|
+
* `id` only labels the region's DOM `id` (`rc-announcer-${id}`). The same `id`
|
|
227
|
+
* does not share or reuse one node: each hook call still owns its own region.
|
|
228
|
+
* Pass distinct ids when you set one.
|
|
229
|
+
*
|
|
230
|
+
* @param idOrOptions - Optional DOM id label, or options when no id is needed
|
|
231
|
+
* @param maybeOptions - Options when the first argument is an id
|
|
232
|
+
* @returns announce function that accepts a message string
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```ts
|
|
236
|
+
* const announceLocal = useAnnouncer('feature-a', { politeness: 'polite' });
|
|
237
|
+
* announceLocal('Saved');
|
|
238
|
+
*
|
|
239
|
+
* // Do not call useAnnouncer inside list items or other high-cardinality trees.
|
|
240
|
+
* // Lift one instance to the page/feature boundary and pass `announce` down.
|
|
241
|
+
* ```
|
|
242
|
+
*/ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
158
243
|
const id = typeof idOrOptions === 'string' ? idOrOptions : undefined;
|
|
159
|
-
const options = isUseAnnouncerOptions(idOrOptions)
|
|
160
|
-
? idOrOptions
|
|
161
|
-
: maybeOptions;
|
|
244
|
+
const options = isUseAnnouncerOptions(idOrOptions) ? idOrOptions : maybeOptions;
|
|
162
245
|
const politeness = options?.politeness ?? 'assertive';
|
|
246
|
+
// TODO(breaking-change): rename the generated id prefix from `rc-announcer`
|
|
247
|
+
// to `sui-announcer` in the next major cleanup.
|
|
163
248
|
const _id = useId(id ? `rc-announcer-${id}` : 'rc-announcer', !id);
|
|
164
|
-
const { document: targetDocument, externalWindow, globalThis: contextGlobal
|
|
249
|
+
const { document: targetDocument, externalWindow, globalThis: contextGlobal } = useWindowContext();
|
|
165
250
|
const win = externalWindow ?? contextGlobal;
|
|
166
251
|
const ref = useRef(null);
|
|
167
252
|
const lastMessageRef = useRef();
|
|
168
253
|
const pendingTimerRef = useRef(null);
|
|
169
254
|
const pendingRemovalRef = useRef(null);
|
|
170
|
-
const clearPendingTimer = useCallback(()
|
|
255
|
+
const clearPendingTimer = useCallback(()=>{
|
|
171
256
|
pendingTimerRef.current?.();
|
|
172
257
|
pendingTimerRef.current = null;
|
|
173
258
|
}, []);
|
|
174
|
-
const clearPendingRemoval = useCallback(()
|
|
259
|
+
const clearPendingRemoval = useCallback(()=>{
|
|
175
260
|
pendingRemovalRef.current?.();
|
|
176
261
|
pendingRemovalRef.current = null;
|
|
177
262
|
}, []);
|
|
178
|
-
useEffect(()
|
|
179
|
-
const currentDocument = targetDocument ??
|
|
180
|
-
(typeof contextGlobal.document !== 'undefined'
|
|
181
|
-
? contextGlobal.document
|
|
182
|
-
: undefined);
|
|
263
|
+
useEffect(()=>{
|
|
264
|
+
const currentDocument = targetDocument ?? (typeof contextGlobal.document !== 'undefined' ? contextGlobal.document : undefined);
|
|
183
265
|
if (!currentDocument?.body) {
|
|
184
266
|
return undefined;
|
|
185
267
|
}
|
|
186
268
|
const currentEl = ref.current;
|
|
187
|
-
const canReuseCurrentEl = currentEl?.ownerDocument === currentDocument &&
|
|
188
|
-
|
|
189
|
-
currentDocument.body.contains(currentEl);
|
|
190
|
-
const el = canReuseCurrentEl
|
|
191
|
-
? currentEl
|
|
192
|
-
: currentDocument.createElement('div');
|
|
269
|
+
const canReuseCurrentEl = currentEl?.ownerDocument === currentDocument && currentEl.id === _id && currentDocument.body.contains(currentEl);
|
|
270
|
+
const el = canReuseCurrentEl ? currentEl : currentDocument.createElement('div');
|
|
193
271
|
if (canReuseCurrentEl) {
|
|
194
272
|
clearPendingRemoval();
|
|
195
273
|
el.setAttribute('aria-live', politeness);
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
274
|
+
} else {
|
|
198
275
|
ref.current = el;
|
|
199
276
|
el.id = _id;
|
|
200
277
|
el.setAttribute('aria-live', politeness);
|
|
@@ -204,9 +281,9 @@ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
|
204
281
|
applyVisuallyHiddenStyles(el);
|
|
205
282
|
currentDocument.body.appendChild(el);
|
|
206
283
|
}
|
|
207
|
-
return ()
|
|
284
|
+
return ()=>{
|
|
208
285
|
clearPendingTimer();
|
|
209
|
-
const timerId = win.setTimeout(()
|
|
286
|
+
const timerId = win.setTimeout(()=>{
|
|
210
287
|
const body = currentDocument.body;
|
|
211
288
|
if (body.contains(el)) {
|
|
212
289
|
body.removeChild(el);
|
|
@@ -218,7 +295,7 @@ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
|
218
295
|
pendingRemovalRef.current = null;
|
|
219
296
|
}
|
|
220
297
|
});
|
|
221
|
-
const cancelRemoval = ()
|
|
298
|
+
const cancelRemoval = ()=>{
|
|
222
299
|
win.clearTimeout(timerId);
|
|
223
300
|
};
|
|
224
301
|
pendingRemovalRef.current = cancelRemoval;
|
|
@@ -230,12 +307,17 @@ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
|
230
307
|
contextGlobal,
|
|
231
308
|
win,
|
|
232
309
|
clearPendingTimer,
|
|
233
|
-
clearPendingRemoval
|
|
310
|
+
clearPendingRemoval
|
|
234
311
|
]);
|
|
235
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Announce method that calls screen readers to speak the message.
|
|
314
|
+
* Uses zero-width space technique to ensure repeated messages are re-announced.
|
|
315
|
+
*
|
|
316
|
+
* The technique sets content to a zero-width space (invisible) then to the message,
|
|
317
|
+
* forcing a content change that screen readers detect as new content.
|
|
318
|
+
*/ return useCallback((message)=>{
|
|
236
319
|
const el = ref.current;
|
|
237
|
-
if (!el)
|
|
238
|
-
return;
|
|
320
|
+
if (!el) return;
|
|
239
321
|
clearPendingTimer();
|
|
240
322
|
if (!message) {
|
|
241
323
|
el.textContent = '';
|
|
@@ -247,8 +329,8 @@ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
|
247
329
|
lastMessageRef.current = message;
|
|
248
330
|
return;
|
|
249
331
|
}
|
|
250
|
-
el.textContent = '\u200B';
|
|
251
|
-
const timerId = win.setTimeout(()
|
|
332
|
+
el.textContent = '\u200B'; // Zero-width space (invisible but different content)
|
|
333
|
+
const timerId = win.setTimeout(()=>{
|
|
252
334
|
if (el && ref.current === el) {
|
|
253
335
|
el.textContent = message;
|
|
254
336
|
lastMessageRef.current = message;
|
|
@@ -257,67 +339,113 @@ function useAnnouncer(idOrOptions, maybeOptions) {
|
|
|
257
339
|
pendingTimerRef.current = null;
|
|
258
340
|
}
|
|
259
341
|
}, 100);
|
|
260
|
-
const cancelTimer = ()
|
|
342
|
+
const cancelTimer = ()=>{
|
|
261
343
|
win.clearTimeout(timerId);
|
|
262
344
|
};
|
|
263
345
|
pendingTimerRef.current = cancelTimer;
|
|
264
|
-
}, [
|
|
346
|
+
}, [
|
|
347
|
+
clearPendingTimer,
|
|
348
|
+
win
|
|
349
|
+
]);
|
|
265
350
|
}
|
|
266
351
|
|
|
267
|
-
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Window-scoped wrapper around `announce`. Fills in
|
|
360
|
+
* `useWindowContext().document` so pop-out callers do not need to pass `{ document }`.
|
|
361
|
+
* Shares one polite/assertive pair per document, not per app.
|
|
362
|
+
*
|
|
363
|
+
* Unlike `useAnnouncer`, this does not mount an isolated region per hook call.
|
|
364
|
+
*
|
|
365
|
+
* Do not `destroyAnnouncer` on unmount. The window owner should call
|
|
366
|
+
* `destroyAnnouncer(win.document)` when the pop-out window closes.
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```ts
|
|
370
|
+
* const announce = useGlobalAnnouncer();
|
|
371
|
+
* announce('Saved', 'polite');
|
|
372
|
+
* ```
|
|
373
|
+
*/ function useGlobalAnnouncer() {
|
|
268
374
|
const { document: targetDocument } = useWindowContext();
|
|
269
|
-
return useCallback((message, politeness = 'assertive', timeoutOrOptions)
|
|
270
|
-
const timeout = typeof timeoutOrOptions === 'number'
|
|
271
|
-
? timeoutOrOptions
|
|
272
|
-
: timeoutOrOptions?.timeout;
|
|
375
|
+
return useCallback((message, politeness = 'assertive', timeoutOrOptions)=>{
|
|
376
|
+
const timeout = typeof timeoutOrOptions === 'number' ? timeoutOrOptions : timeoutOrOptions?.timeout;
|
|
273
377
|
announce(message, politeness, {
|
|
274
378
|
timeout,
|
|
275
|
-
document: targetDocument
|
|
379
|
+
document: targetDocument
|
|
276
380
|
});
|
|
277
|
-
}, [
|
|
381
|
+
}, [
|
|
382
|
+
targetDocument
|
|
383
|
+
]);
|
|
278
384
|
}
|
|
279
385
|
|
|
280
|
-
|
|
281
|
-
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Spring UI's useButton hook that wraps MUI Base's useButton with enhanced active state management.
|
|
394
|
+
*
|
|
395
|
+
* Uses the correct document reference from WindowContext to ensure event listeners
|
|
396
|
+
* are attached to the appropriate document.
|
|
397
|
+
*
|
|
398
|
+
* @param parameters - Same parameters as MUI Base's useButton
|
|
399
|
+
* @returns Button props with proper active state management
|
|
400
|
+
*/ function useButton_useButton(parameters) {
|
|
401
|
+
const muiButtonResult = useButton(parameters);
|
|
282
402
|
const { active: customActive, activeHandlers } = useActive({
|
|
283
|
-
disabled: parameters.disabled
|
|
403
|
+
disabled: parameters.disabled
|
|
284
404
|
});
|
|
285
405
|
const originalGetRootProps = muiButtonResult.getRootProps;
|
|
286
|
-
const getRootProps = (externalProps)
|
|
406
|
+
const getRootProps = (externalProps)=>{
|
|
287
407
|
const mergedProps = combineProps(externalProps, activeHandlers);
|
|
288
408
|
return originalGetRootProps(mergedProps);
|
|
289
409
|
};
|
|
290
410
|
return {
|
|
291
411
|
...muiButtonResult,
|
|
292
412
|
active: customActive,
|
|
293
|
-
getRootProps
|
|
413
|
+
getRootProps
|
|
294
414
|
};
|
|
295
415
|
}
|
|
296
416
|
|
|
297
|
-
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
const useAriaHideOutside = (props = {})=>{
|
|
298
424
|
const { enable = true, shouldUseInert = false } = props;
|
|
299
425
|
const { document } = useWindowContext();
|
|
300
426
|
const ariaHideOutsideRef = useRef(null);
|
|
301
427
|
const branchNodesRef = useRef(new Set());
|
|
302
428
|
const controllerRef = useRef();
|
|
303
|
-
const setAriaHideOutsideTarget = useCallback((node, isActive)
|
|
429
|
+
const setAriaHideOutsideTarget = useCallback((node, isActive)=>{
|
|
304
430
|
if (isActive) {
|
|
305
431
|
branchNodesRef.current.add(node);
|
|
306
432
|
controllerRef.current?.addTarget(node);
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
433
|
+
} else {
|
|
309
434
|
branchNodesRef.current.delete(node);
|
|
310
435
|
controllerRef.current?.removeTarget(node);
|
|
311
436
|
}
|
|
312
437
|
}, []);
|
|
313
|
-
useLayoutEffect(()
|
|
438
|
+
useLayoutEffect(()=>{
|
|
314
439
|
if (enable && ariaHideOutsideRef.current) {
|
|
315
|
-
const controller = createAriaHideOutsideController([
|
|
440
|
+
const controller = createAriaHideOutsideController([
|
|
441
|
+
ariaHideOutsideRef.current,
|
|
442
|
+
...branchNodesRef.current
|
|
443
|
+
], {
|
|
316
444
|
root: document?.body,
|
|
317
|
-
shouldUseInert
|
|
445
|
+
shouldUseInert
|
|
318
446
|
});
|
|
319
447
|
controllerRef.current = controller;
|
|
320
|
-
return ()
|
|
448
|
+
return ()=>{
|
|
321
449
|
if (controllerRef.current === controller) {
|
|
322
450
|
controllerRef.current = undefined;
|
|
323
451
|
}
|
|
@@ -326,38 +454,87 @@ const useAriaHideOutside = (props = {}) => {
|
|
|
326
454
|
}
|
|
327
455
|
controllerRef.current = undefined;
|
|
328
456
|
return undefined;
|
|
329
|
-
}, [
|
|
457
|
+
}, [
|
|
458
|
+
enable,
|
|
459
|
+
shouldUseInert,
|
|
460
|
+
document?.body
|
|
461
|
+
]);
|
|
330
462
|
return {
|
|
331
463
|
ariaHideOutsideRef: ariaHideOutsideRef,
|
|
332
|
-
|
|
333
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Whether outside elements are made unavailable to assistive technology.
|
|
466
|
+
*/ ariaHideEnabled: enable,
|
|
467
|
+
setAriaHideOutsideTarget
|
|
334
468
|
};
|
|
335
469
|
};
|
|
336
470
|
|
|
337
|
-
|
|
338
|
-
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* get Browser `Audio` instance, and auto destroy when component be destroyed.
|
|
478
|
+
*
|
|
479
|
+
*
|
|
480
|
+
* @param init a callback when you init that audio
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
*
|
|
484
|
+
* ```ts
|
|
485
|
+
* const audio = useAudio((instance) => {
|
|
486
|
+
* instance.src = 'https://your.audio'
|
|
487
|
+
* });
|
|
488
|
+
*
|
|
489
|
+
* const click = () => audio.play();
|
|
490
|
+
*
|
|
491
|
+
* click();
|
|
492
|
+
* ```
|
|
493
|
+
* @see stories {@link https://develop-rcui-int.fiji.gliprc.com/?path=/story/%F0%9F%94%A7-foundation-hooks-useaudio--simple-announcer 🔧-foundation-hooks-useaudio--simple-announcer}
|
|
494
|
+
* @see {@link https://chromium-review.googlesource.com/c/chromium/src/+/2816118}
|
|
495
|
+
*/ const useAudio = (init)=>{
|
|
496
|
+
const audioRef = useResultRef(()=>{
|
|
339
497
|
const audio = new Audio();
|
|
340
498
|
init?.(audio);
|
|
341
499
|
return audio;
|
|
342
500
|
});
|
|
343
|
-
useEffect(()
|
|
501
|
+
useEffect(()=>{
|
|
344
502
|
const audio = audioRef.current;
|
|
345
|
-
return ()
|
|
503
|
+
return ()=>{
|
|
346
504
|
audio.srcObject = null;
|
|
347
505
|
};
|
|
506
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
348
507
|
}, []);
|
|
349
508
|
return audioRef.current;
|
|
350
509
|
};
|
|
351
510
|
|
|
352
|
-
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* get previous render value
|
|
518
|
+
* @param cb that get method of target value
|
|
519
|
+
* @param isDefault if set `true`, the first time will get value from `cb`
|
|
520
|
+
* @returns previous value get from `cb`
|
|
521
|
+
*/ function usePrevious(cb, isDefault) {
|
|
353
522
|
const ref = useRef(isDefault ? cb() : undefined);
|
|
354
|
-
useEffect(()
|
|
523
|
+
useEffect(()=>{
|
|
355
524
|
ref.current = cb();
|
|
356
525
|
});
|
|
357
526
|
return ref.current;
|
|
358
527
|
}
|
|
359
528
|
|
|
360
|
-
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* get value when prev listen value change
|
|
535
|
+
* @param cb that method you want calculate to get value
|
|
536
|
+
* @param listenCb prev compare listener value
|
|
537
|
+
*/ const useChange = (cb, listenCb)=>{
|
|
361
538
|
const countRef = useRef(0);
|
|
362
539
|
const valueRef = useRef();
|
|
363
540
|
const prev = usePrevious(listenCb);
|
|
@@ -368,138 +545,234 @@ const useChange = (cb, listenCb) => {
|
|
|
368
545
|
}
|
|
369
546
|
return valueRef.current;
|
|
370
547
|
};
|
|
371
|
-
|
|
548
|
+
/**
|
|
549
|
+
* get value when prev deps one of array value change
|
|
550
|
+
* @param cb that method you want calculate to get value
|
|
551
|
+
* @param deps prev compare value array
|
|
552
|
+
*/ const useDepsChange = (cb, deps)=>{
|
|
372
553
|
const countRef = useRef(0);
|
|
373
554
|
const valueRef = useRef();
|
|
374
|
-
const prev = usePrevious(()
|
|
555
|
+
const prev = usePrevious(()=>deps, true);
|
|
375
556
|
const curr = deps;
|
|
376
|
-
if (countRef.current === 0 ||
|
|
377
|
-
curr.some((dep, index) => dep !== prev[index])) {
|
|
557
|
+
if (countRef.current === 0 || curr.some((dep, index)=>dep !== prev[index])) {
|
|
378
558
|
countRef.current = 1;
|
|
379
559
|
valueRef.current = cb(prev, curr);
|
|
380
560
|
}
|
|
381
561
|
return valueRef.current;
|
|
382
562
|
};
|
|
383
563
|
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
// base https://github.com/mui/material-ui/blob/master/packages/mui-base/src/ClickAwayListener/ClickAwayListener.tsx
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
|
|
384
571
|
function clickedRootScrollbar(event, doc) {
|
|
385
|
-
return
|
|
386
|
-
doc.documentElement.clientHeight < event.clientY);
|
|
572
|
+
return doc.documentElement.clientWidth < event.clientX || doc.documentElement.clientHeight < event.clientY;
|
|
387
573
|
}
|
|
388
|
-
|
|
574
|
+
/**
|
|
575
|
+
* @deprecated Use `ClickAwayListener` from `@mui/base` instead.
|
|
576
|
+
*
|
|
577
|
+
* @param onClickAway Callback fired when a "click away" event is detected.
|
|
578
|
+
*/ const useClickAway = (onClickAway, props = {})=>{
|
|
389
579
|
const { disableReactTree = false, isEnabled = true } = props;
|
|
390
580
|
const movedRef = useRef(false);
|
|
391
581
|
const activatedRef = useRef(false);
|
|
392
582
|
const syntheticEventRef = useRef(false);
|
|
393
583
|
const [target, setTarget] = useState();
|
|
394
|
-
|
|
584
|
+
// This hook is usually triggered after target appears
|
|
585
|
+
// so we need to trigger re-render component in ref callback to make sure useEffect can get target correctly
|
|
586
|
+
const clickAwayRef = useEventCallback((e)=>{
|
|
395
587
|
setTarget(e);
|
|
396
588
|
});
|
|
397
589
|
const shouldActive = Boolean(target);
|
|
398
|
-
useEffect(()
|
|
399
|
-
if (!isEnabled)
|
|
400
|
-
return;
|
|
590
|
+
useEffect(()=>{
|
|
591
|
+
if (!isEnabled) return;
|
|
401
592
|
if (shouldActive) {
|
|
402
|
-
|
|
593
|
+
// Ensure that this component is not "activated" synchronously.
|
|
594
|
+
// https://github.com/facebook/react/issues/20074
|
|
595
|
+
const timer = setTimeout(()=>{
|
|
403
596
|
activatedRef.current = true;
|
|
404
597
|
}, 0);
|
|
405
|
-
return ()
|
|
598
|
+
return ()=>{
|
|
406
599
|
clearTimeout(timer);
|
|
407
600
|
activatedRef.current = false;
|
|
408
601
|
};
|
|
409
602
|
}
|
|
410
603
|
return undefined;
|
|
411
|
-
}, [
|
|
412
|
-
|
|
604
|
+
}, [
|
|
605
|
+
isEnabled,
|
|
606
|
+
shouldActive
|
|
607
|
+
]);
|
|
608
|
+
// The handler doesn't take event.defaultPrevented into account:
|
|
609
|
+
//
|
|
610
|
+
// event.preventDefault() is meant to stop default behaviors like
|
|
611
|
+
// clicking a checkbox to check it, hitting a button to submit a form,
|
|
612
|
+
// and hitting left arrow to move the cursor in a text input etc.
|
|
613
|
+
// Only special HTML elements have these default behaviors.
|
|
614
|
+
const handleClickAway = useEventCallback((event)=>{
|
|
615
|
+
// Given developers can stop the propagation of the synthetic event,
|
|
616
|
+
// we can only be confident with a positive value.
|
|
413
617
|
const insideReactTree = syntheticEventRef.current;
|
|
414
618
|
syntheticEventRef.current = false;
|
|
415
619
|
const doc = unstable_ownerDocument(target);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
620
|
+
// 1. IE11 support, which trigger the handleClickAway even after the unbind
|
|
621
|
+
// 2. The child might render null.
|
|
622
|
+
// 3. Behave like a blur listener.
|
|
623
|
+
if (!activatedRef.current || !target || 'clientX' in event && clickedRootScrollbar(event, doc)) {
|
|
419
624
|
return;
|
|
420
625
|
}
|
|
626
|
+
// Do not act if user performed touchmove
|
|
421
627
|
if (movedRef.current) {
|
|
422
628
|
movedRef.current = false;
|
|
423
629
|
return;
|
|
424
630
|
}
|
|
425
631
|
let insideDOM;
|
|
632
|
+
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js
|
|
426
633
|
if (event.composedPath) {
|
|
427
634
|
insideDOM = event.composedPath().indexOf(target) > -1;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
target.contains(event.target);
|
|
635
|
+
} else {
|
|
636
|
+
insideDOM = !doc.documentElement.contains(// @ts-expect-error returns `false` as intended when not dispatched from a Node
|
|
637
|
+
event.target) || target.contains(// @ts-expect-error returns `false` as intended when not dispatched from a Node
|
|
638
|
+
event.target);
|
|
433
639
|
}
|
|
434
640
|
if (!insideDOM && (disableReactTree || !insideReactTree)) {
|
|
435
641
|
onClickAway(event);
|
|
436
642
|
}
|
|
437
643
|
});
|
|
438
|
-
|
|
644
|
+
// Keep track of mouse/touch events that bubbled up through the portal.
|
|
645
|
+
const handleSynthetic = (e)=>{
|
|
439
646
|
syntheticEventRef.current = true;
|
|
440
647
|
};
|
|
441
|
-
useEffect(()
|
|
442
|
-
if (!isEnabled)
|
|
443
|
-
return;
|
|
648
|
+
useEffect(()=>{
|
|
649
|
+
if (!isEnabled) return;
|
|
444
650
|
if (target) {
|
|
445
651
|
const doc = unstable_ownerDocument(target);
|
|
446
|
-
const handleTouchMove = ()
|
|
652
|
+
const handleTouchMove = ()=>{
|
|
447
653
|
movedRef.current = true;
|
|
448
654
|
};
|
|
449
655
|
doc.addEventListener('touchend', handleClickAway);
|
|
450
656
|
doc.addEventListener('touchmove', handleTouchMove);
|
|
451
657
|
doc.addEventListener('click', handleClickAway);
|
|
452
|
-
return ()
|
|
658
|
+
return ()=>{
|
|
453
659
|
doc.removeEventListener('touchend', handleClickAway);
|
|
454
660
|
doc.removeEventListener('touchmove', handleTouchMove);
|
|
455
661
|
doc.removeEventListener('click', handleClickAway);
|
|
456
662
|
};
|
|
457
663
|
}
|
|
458
664
|
return undefined;
|
|
459
|
-
}, [
|
|
665
|
+
}, [
|
|
666
|
+
handleClickAway,
|
|
667
|
+
isEnabled,
|
|
668
|
+
target
|
|
669
|
+
]);
|
|
460
670
|
return {
|
|
461
671
|
clickAwayRef,
|
|
462
672
|
clickAwayHandler: {
|
|
463
673
|
onClick: handleSynthetic,
|
|
464
|
-
onTouchEnd: handleSynthetic
|
|
465
|
-
}
|
|
674
|
+
onTouchEnd: handleSynthetic
|
|
675
|
+
}
|
|
466
676
|
};
|
|
467
677
|
};
|
|
468
678
|
|
|
469
|
-
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* provide a debounce method for debounce method
|
|
693
|
+
* @param fn method for debounce
|
|
694
|
+
* @param debounceTime debounce timeout value, default is `200`ms
|
|
695
|
+
* @param options debounce setting, default is `{trailing: true, leading: flase}`, detail see lodash
|
|
696
|
+
*/ // oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
|
697
|
+
const useDebounce = (fn, debounceTime = 200, options)=>{
|
|
470
698
|
const memoFn = useEventCallback(fn);
|
|
699
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
471
700
|
const debounceFn = useCallback(debounce(memoFn, debounceTime, options), []);
|
|
472
|
-
|
|
701
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
702
|
+
useEffect(()=>()=>debounceFn.cancel(), []);
|
|
473
703
|
return debounceFn;
|
|
474
704
|
};
|
|
475
705
|
|
|
476
|
-
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* bind event when component mount and auto remove event when destroy.
|
|
716
|
+
* can remove listener by call returned function or pass empty 'target'.
|
|
717
|
+
*
|
|
718
|
+
* @param target
|
|
719
|
+
* listener will be removed when target is changed to null,
|
|
720
|
+
* or rebinding to new element when `ref.current` change
|
|
721
|
+
* @returns a method object
|
|
722
|
+
* - listen() listen listener again, if that have listener, that will not listen again
|
|
723
|
+
* - remove() remove listener
|
|
724
|
+
* @example
|
|
725
|
+
* ```ts
|
|
726
|
+
* useEventListener(window, 'keyup', () => console.log('window key up'))
|
|
727
|
+
* ```
|
|
728
|
+
*/ function useEventListener(target, key, callback, options) {
|
|
477
729
|
const { startImmediately = true, ...addEventListenerOptions } = options ?? {};
|
|
478
730
|
const listener = useEventCallback(callback);
|
|
479
731
|
const cancelRef = useRef(noop);
|
|
480
732
|
const bindRef = useRef(noop);
|
|
481
|
-
useEffect(()
|
|
482
|
-
if (!target)
|
|
483
|
-
return;
|
|
733
|
+
useEffect(()=>{
|
|
734
|
+
if (!target) return;
|
|
484
735
|
const currentElm = getRefElement(target);
|
|
485
736
|
if (!currentElm) {
|
|
486
737
|
return cancelRef.current;
|
|
487
738
|
}
|
|
488
|
-
bindRef.current = ()
|
|
489
|
-
cancelRef.current = ()
|
|
739
|
+
bindRef.current = ()=>currentElm.addEventListener(key, listener, addEventListenerOptions);
|
|
740
|
+
cancelRef.current = ()=>currentElm.removeEventListener(key, listener, addEventListenerOptions);
|
|
490
741
|
if (startImmediately) {
|
|
491
742
|
bindRef.current();
|
|
492
743
|
}
|
|
493
744
|
return cancelRef.current;
|
|
494
|
-
|
|
745
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
746
|
+
}, [
|
|
747
|
+
target
|
|
748
|
+
]);
|
|
495
749
|
return {
|
|
496
|
-
listen: ()
|
|
497
|
-
remove: ()
|
|
750
|
+
listen: ()=>bindRef.current(),
|
|
751
|
+
remove: ()=>cancelRef.current()
|
|
498
752
|
};
|
|
499
753
|
}
|
|
500
754
|
|
|
501
|
-
|
|
502
|
-
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
const defaultEverTargetValue = (value)=>!!value;
|
|
760
|
+
/**
|
|
761
|
+
* that value ever to be `you want value`
|
|
762
|
+
* Return true when the value ever to be `you want value` once.
|
|
763
|
+
*
|
|
764
|
+
* @example
|
|
765
|
+
* ```ts
|
|
766
|
+
* // once that isOpen is true, that result will always be `true`
|
|
767
|
+
* const isEverOpen = useEver(isOpen);
|
|
768
|
+
*
|
|
769
|
+
* const isEverToBe2 = useEver(count, (val) => val === 2);
|
|
770
|
+
* ```
|
|
771
|
+
*/ const useEver = (/** to be checked value */ value, /**
|
|
772
|
+
* value ever to be callback, default when value to be exited.
|
|
773
|
+
*
|
|
774
|
+
* @default (value) => !!value
|
|
775
|
+
*/ targetValue = defaultEverTargetValue)=>{
|
|
503
776
|
const result = useRef(false);
|
|
504
777
|
if (!result.current) {
|
|
505
778
|
result.current = targetValue(value);
|
|
@@ -507,6 +780,15 @@ const useEver = (value, targetValue = defaultEverTargetValue) => {
|
|
|
507
780
|
return result.current;
|
|
508
781
|
};
|
|
509
782
|
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
/* oxlint-disable sonarjs/no-collapsible-if */ /* oxlint-disable sonarjs/prefer-single-boolean-return */
|
|
789
|
+
// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js
|
|
790
|
+
|
|
791
|
+
|
|
510
792
|
let hadKeyboardEvent = true;
|
|
511
793
|
let hadFocusVisibleRecently = false;
|
|
512
794
|
const hadFocusVisibleRecentlyTimeout = new unstable_Timeout();
|
|
@@ -523,13 +805,17 @@ const inputTypesWhitelist = {
|
|
|
523
805
|
week: true,
|
|
524
806
|
time: true,
|
|
525
807
|
datetime: true,
|
|
526
|
-
'datetime-local': true
|
|
808
|
+
'datetime-local': true
|
|
527
809
|
};
|
|
528
|
-
|
|
810
|
+
/**
|
|
811
|
+
* Computes whether the given element should automatically trigger the
|
|
812
|
+
* `focus-visible` class being added, i.e. whether it should always match
|
|
813
|
+
* `:focus-visible` when focused.
|
|
814
|
+
* @param {Element} node
|
|
815
|
+
* @returns {boolean}
|
|
816
|
+
*/ function focusTriggersKeyboardModality(node) {
|
|
529
817
|
const { type, tagName } = node;
|
|
530
|
-
if (tagName === 'INPUT' &&
|
|
531
|
-
inputTypesWhitelist[type] &&
|
|
532
|
-
!node.readOnly) {
|
|
818
|
+
if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) {
|
|
533
819
|
return true;
|
|
534
820
|
}
|
|
535
821
|
if (tagName === 'TEXTAREA' && !node.readOnly) {
|
|
@@ -540,31 +826,47 @@ function focusTriggersKeyboardModality(node) {
|
|
|
540
826
|
}
|
|
541
827
|
return false;
|
|
542
828
|
}
|
|
543
|
-
|
|
829
|
+
/**
|
|
830
|
+
* Keep track of our keyboard modality state with `hadKeyboardEvent`.
|
|
831
|
+
* If the most recent user interaction was via the keyboard;
|
|
832
|
+
* and the key press did not include a meta, alt/option, or control key;
|
|
833
|
+
* then the modality is keyboard. Otherwise, the modality is not keyboard.
|
|
834
|
+
* @param {KeyboardEvent} event
|
|
835
|
+
*/ function useIsFocusVisible_handleKeyDown(event) {
|
|
544
836
|
if (event.metaKey || event.altKey || event.ctrlKey) {
|
|
545
837
|
return;
|
|
546
838
|
}
|
|
547
839
|
hadKeyboardEvent = true;
|
|
548
840
|
}
|
|
549
|
-
|
|
841
|
+
/**
|
|
842
|
+
* If at any point a user clicks with a pointing device, ensure that we change
|
|
843
|
+
* the modality away from keyboard.
|
|
844
|
+
* This avoids the situation where a user presses a key on an already focused
|
|
845
|
+
* element, and then clicks on a different element, focusing it with a
|
|
846
|
+
* pointing device, while we still think we're in keyboard modality.
|
|
847
|
+
*/ function handlePointerDown() {
|
|
550
848
|
hadKeyboardEvent = false;
|
|
551
849
|
}
|
|
552
850
|
function handleVisibilityChange() {
|
|
553
851
|
if (this.visibilityState === 'hidden') {
|
|
852
|
+
// If the tab becomes active again, the browser will handle calling focus
|
|
853
|
+
// on the element (Safari actually calls it twice).
|
|
854
|
+
// If this tab change caused a blur on an element with focus-visible,
|
|
855
|
+
// re-apply the class when the user switches back to the tab.
|
|
554
856
|
if (hadFocusVisibleRecently) {
|
|
555
857
|
hadKeyboardEvent = true;
|
|
556
858
|
}
|
|
557
859
|
}
|
|
558
860
|
}
|
|
559
861
|
function prepare(doc) {
|
|
560
|
-
doc.addEventListener('keydown',
|
|
862
|
+
doc.addEventListener('keydown', useIsFocusVisible_handleKeyDown, true);
|
|
561
863
|
doc.addEventListener('mousedown', handlePointerDown, true);
|
|
562
864
|
doc.addEventListener('pointerdown', handlePointerDown, true);
|
|
563
865
|
doc.addEventListener('touchstart', handlePointerDown, true);
|
|
564
866
|
doc.addEventListener('visibilitychange', handleVisibilityChange, true);
|
|
565
867
|
}
|
|
566
868
|
function teardown(doc) {
|
|
567
|
-
doc.removeEventListener('keydown',
|
|
869
|
+
doc.removeEventListener('keydown', useIsFocusVisible_handleKeyDown, true);
|
|
568
870
|
doc.removeEventListener('mousedown', handlePointerDown, true);
|
|
569
871
|
doc.removeEventListener('pointerdown', handlePointerDown, true);
|
|
570
872
|
doc.removeEventListener('touchstart', handlePointerDown, true);
|
|
@@ -574,22 +876,38 @@ function isFocusVisible(event) {
|
|
|
574
876
|
const { target } = event;
|
|
575
877
|
try {
|
|
576
878
|
return target.matches(':focus-visible');
|
|
879
|
+
} catch (error) {
|
|
880
|
+
// Browsers not implementing :focus-visible will throw a SyntaxError.
|
|
881
|
+
// We use our own heuristic for those browsers.
|
|
882
|
+
// Rethrow might be better if it's not the expected error but do we really
|
|
883
|
+
// want to crash if focus-visible malfunctioned?
|
|
577
884
|
}
|
|
578
|
-
|
|
579
|
-
|
|
885
|
+
// No need for validFocusTarget check. The user does that by attaching it to
|
|
886
|
+
// focusable events only.
|
|
580
887
|
return hadKeyboardEvent || focusTriggersKeyboardModality(target);
|
|
581
888
|
}
|
|
582
889
|
function useIsFocusVisible() {
|
|
583
|
-
const ref =
|
|
890
|
+
const ref = __rspack_external_react.useCallback((node)=>{
|
|
584
891
|
if (node != null) {
|
|
585
892
|
prepare(node.ownerDocument);
|
|
586
893
|
}
|
|
587
894
|
}, []);
|
|
588
|
-
const isFocusVisibleRef =
|
|
589
|
-
|
|
895
|
+
const isFocusVisibleRef = __rspack_external_react.useRef(false);
|
|
896
|
+
/**
|
|
897
|
+
* Should be called if a blur event is fired
|
|
898
|
+
*/ function handleBlurVisible() {
|
|
899
|
+
// checking against potential state variable does not suffice if we focus and blur synchronously.
|
|
900
|
+
// React wouldn't have time to trigger a re-render so `focusVisible` would be stale.
|
|
901
|
+
// Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events.
|
|
902
|
+
// This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751
|
|
903
|
+
// TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186).
|
|
590
904
|
if (isFocusVisibleRef.current) {
|
|
905
|
+
// To detect a tab/window switch, we look for a blur event followed
|
|
906
|
+
// rapidly by a visibility change.
|
|
907
|
+
// If we don't see a visibility change within 100ms, it's probably a
|
|
908
|
+
// regular focus change.
|
|
591
909
|
hadFocusVisibleRecently = true;
|
|
592
|
-
hadFocusVisibleRecentlyTimeout.start(100, ()
|
|
910
|
+
hadFocusVisibleRecentlyTimeout.start(100, ()=>{
|
|
593
911
|
hadFocusVisibleRecently = false;
|
|
594
912
|
});
|
|
595
913
|
isFocusVisibleRef.current = false;
|
|
@@ -597,7 +915,9 @@ function useIsFocusVisible() {
|
|
|
597
915
|
}
|
|
598
916
|
return false;
|
|
599
917
|
}
|
|
600
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Should be called if a blur event is fired
|
|
920
|
+
*/ function handleFocusVisible(event) {
|
|
601
921
|
if (isFocusVisible(event)) {
|
|
602
922
|
isFocusVisibleRef.current = true;
|
|
603
923
|
return true;
|
|
@@ -608,75 +928,105 @@ function useIsFocusVisible() {
|
|
|
608
928
|
isFocusVisibleRef,
|
|
609
929
|
onFocus: handleFocusVisible,
|
|
610
930
|
onBlur: handleBlurVisible,
|
|
611
|
-
ref
|
|
931
|
+
ref
|
|
612
932
|
};
|
|
613
933
|
}
|
|
614
934
|
|
|
615
|
-
|
|
616
|
-
|
|
935
|
+
// NOTE: this hook has been removed from @mui/utils in https://github.com/mui/material-ui/pull/42467
|
|
936
|
+
// TODO: update related usage in components (remove useIsFocusVisible and use isFocusVisible instead, prefer to use native :focus-visible in new components):
|
|
937
|
+
// reference: https://github.com/mui/material-ui/commit/089c40ca82becca90a9e865f78b76b3816983c41#diff-cf7c814aad470aa459786651c67c4ea5df1004df21668cf3f7941a1fbd3a7ab5
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* bind event with a11y keyboard, `space`, `enter`, only trigger when key includes in `checkKeys`
|
|
945
|
+
*
|
|
946
|
+
* that will auto preventDefault by default, if you don't want that, set `false` with `preventDefault`
|
|
947
|
+
*/ const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false } = {})=>{
|
|
948
|
+
const { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref: focusVisibleRef } = useIsFocusVisible();
|
|
617
949
|
const [focusVisible, setFocusVisible] = useState(false);
|
|
618
950
|
const [focusVisibleWithin, setFocusVisibleWithin] = useState(false);
|
|
951
|
+
// Used only when `within: true` — tracks the global focus listener that
|
|
952
|
+
// detects focus leaving the container via DOM unmount (where the synthetic
|
|
953
|
+
// blur event is unreliable). Mirrors the pattern used by React Aria's
|
|
954
|
+
// useFocusWithin hook.
|
|
619
955
|
const globalFocusListenerRef = useRef(null);
|
|
620
956
|
const containerRef = useRef(null);
|
|
957
|
+
// Store ownerDocument from the container so the listener is registered on
|
|
958
|
+
// the correct document when rendering inside iframes or detached windows.
|
|
959
|
+
// Initialized to null to avoid referencing `document` during SSR.
|
|
621
960
|
const ownerDocumentRef = useRef(null);
|
|
622
|
-
const removeGlobalFocusListener = useCallback(()
|
|
961
|
+
const removeGlobalFocusListener = useCallback(()=>{
|
|
623
962
|
if (globalFocusListenerRef.current) {
|
|
624
963
|
ownerDocumentRef.current?.removeEventListener('focus', globalFocusListenerRef.current, true);
|
|
625
964
|
globalFocusListenerRef.current = null;
|
|
626
965
|
}
|
|
627
966
|
}, []);
|
|
628
|
-
useEffect(()
|
|
967
|
+
useEffect(()=>{
|
|
629
968
|
return removeGlobalFocusListener;
|
|
630
|
-
}, [
|
|
631
|
-
|
|
969
|
+
}, [
|
|
970
|
+
removeGlobalFocusListener
|
|
971
|
+
]);
|
|
972
|
+
const handleBlur = (event)=>{
|
|
632
973
|
handleBlurVisible(event);
|
|
633
974
|
if (isFocusVisibleRef.current === false) {
|
|
634
975
|
setFocusVisible(false);
|
|
635
|
-
|
|
636
|
-
|
|
976
|
+
/**
|
|
977
|
+
* when focus element by press `tab`, will trigger focus event in next event loop
|
|
978
|
+
*
|
|
979
|
+
* if current target is containing related target and we don't check whether it is containing related target,
|
|
980
|
+
* `FocusVisibleWithin` will be false before `focus` trigger,
|
|
981
|
+
* then `FocusVisibleWithin` will be true after `focus` trigger
|
|
982
|
+
*
|
|
983
|
+
* And if current target is containing related target, `FocusVisibleWithin` will never be false
|
|
984
|
+
*/ if (within && !event.currentTarget.contains(event.relatedTarget)) {
|
|
637
985
|
setFocusVisibleWithin(false);
|
|
638
986
|
}
|
|
639
987
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
988
|
+
// When focus leaves the container via normal interaction (Tab / click),
|
|
989
|
+
// remove the global listener — it is only needed for the unmount path.
|
|
990
|
+
if (within && globalFocusListenerRef.current && !event.currentTarget.contains(event.relatedTarget)) {
|
|
643
991
|
removeGlobalFocusListener();
|
|
644
992
|
}
|
|
645
993
|
onBlur?.(event);
|
|
646
994
|
};
|
|
647
|
-
const handleFocus = (event)
|
|
995
|
+
const handleFocus = (event)=>{
|
|
648
996
|
let updateState;
|
|
649
997
|
if (within) {
|
|
650
998
|
if (event.target === event.currentTarget) {
|
|
651
|
-
updateState = ()
|
|
999
|
+
updateState = ()=>{
|
|
652
1000
|
setFocusVisibleWithin(true);
|
|
653
1001
|
setFocusVisible(true);
|
|
654
1002
|
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
updateState = () => {
|
|
1003
|
+
} else {
|
|
1004
|
+
updateState = ()=>{
|
|
658
1005
|
setFocusVisibleWithin(true);
|
|
659
1006
|
};
|
|
660
1007
|
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
updateState = () => {
|
|
1008
|
+
} else if (event.target === event.currentTarget) {
|
|
1009
|
+
updateState = ()=>{
|
|
664
1010
|
setFocusVisible(true);
|
|
665
1011
|
};
|
|
666
|
-
}
|
|
667
|
-
else {
|
|
1012
|
+
} else {
|
|
668
1013
|
return;
|
|
669
1014
|
}
|
|
670
1015
|
handleFocusVisible(event);
|
|
671
1016
|
if (isFocusVisibleRef.current === true) {
|
|
672
1017
|
updateState();
|
|
673
1018
|
onFocusVisible?.(event);
|
|
1019
|
+
// Register a global focus listener so that if the focused descendant is
|
|
1020
|
+
// removed from the DOM (making the synthetic blur unreliable), we still
|
|
1021
|
+
// detect when focus moves outside and reset focusVisibleWithin correctly.
|
|
1022
|
+
// Mirrors the approach in React Aria's useFocusWithin:
|
|
1023
|
+
// https://github.com/adobe/react-spectrum/blob/main/packages/react-aria/src/interactions/useFocusWithin.ts
|
|
674
1024
|
if (within) {
|
|
675
1025
|
removeGlobalFocusListener();
|
|
676
1026
|
containerRef.current = event.currentTarget;
|
|
677
1027
|
ownerDocumentRef.current = event.currentTarget.ownerDocument;
|
|
678
1028
|
const ownerDoc = ownerDocumentRef.current;
|
|
679
|
-
const listener = (e)
|
|
1029
|
+
const listener = (e)=>{
|
|
680
1030
|
if (e.target && !containerRef.current?.contains(e.target)) {
|
|
681
1031
|
setFocusVisibleWithin(false);
|
|
682
1032
|
ownerDoc.removeEventListener('focus', listener, true);
|
|
@@ -686,8 +1036,7 @@ const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false, } =
|
|
|
686
1036
|
globalFocusListenerRef.current = listener;
|
|
687
1037
|
ownerDoc.addEventListener('focus', listener, true);
|
|
688
1038
|
}
|
|
689
|
-
}
|
|
690
|
-
else if (within) {
|
|
1039
|
+
} else if (within) {
|
|
691
1040
|
setFocusVisibleWithin(false);
|
|
692
1041
|
removeGlobalFocusListener();
|
|
693
1042
|
}
|
|
@@ -696,55 +1045,97 @@ const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false, } =
|
|
|
696
1045
|
return {
|
|
697
1046
|
focusVisibleHandlers: {
|
|
698
1047
|
onBlur: handleBlur,
|
|
699
|
-
onFocus: handleFocus
|
|
1048
|
+
onFocus: handleFocus
|
|
700
1049
|
},
|
|
1050
|
+
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
|
701
1051
|
focusVisibleRef: focusVisibleRef,
|
|
702
1052
|
focusVisible,
|
|
703
|
-
focusVisibleWithin
|
|
1053
|
+
focusVisibleWithin
|
|
704
1054
|
};
|
|
705
1055
|
};
|
|
706
1056
|
|
|
707
|
-
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* a method for trigger force update
|
|
1064
|
+
* @returns force update method
|
|
1065
|
+
*/ function useForceUpdate() {
|
|
708
1066
|
const [, dispatch] = useState(Object.create(null));
|
|
709
|
-
return useEventCallback(()
|
|
1067
|
+
return useEventCallback(()=>{
|
|
710
1068
|
dispatch(Object.create(null));
|
|
711
1069
|
});
|
|
712
1070
|
}
|
|
713
1071
|
|
|
714
|
-
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* That method will give you ability fork same value `ref object` or `method`
|
|
1078
|
+
* Example:
|
|
1079
|
+
* ```ts
|
|
1080
|
+
* const refA = useRef();
|
|
1081
|
+
* const refB = useRef();
|
|
1082
|
+
*
|
|
1083
|
+
* const ref = useForkRef(refA, refB)
|
|
1084
|
+
*
|
|
1085
|
+
* useEffect(() => {
|
|
1086
|
+
* console.log(refA); // get that current div dom
|
|
1087
|
+
* console.log(refB); // also get that current div dom
|
|
1088
|
+
* },[])
|
|
1089
|
+
*
|
|
1090
|
+
* return <div ref={ref} />
|
|
1091
|
+
* ```
|
|
1092
|
+
*/ const useForkRef = unstable_useForkRef;
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
|
|
715
1096
|
|
|
716
1097
|
const globalListenerEventMap = new Map();
|
|
717
|
-
|
|
1098
|
+
/**
|
|
1099
|
+
* create globalListener handler for share one listener event
|
|
1100
|
+
*
|
|
1101
|
+
* @example
|
|
1102
|
+
* ```ts
|
|
1103
|
+
* const globalListener = createGlobalListener('focus', () => { console.log('focus') });
|
|
1104
|
+
* const globalListener2 = createGlobalListener('focus', () => { console.log('focus') });
|
|
1105
|
+
* globalListener.listen();
|
|
1106
|
+
* globalListener2.listen();
|
|
1107
|
+
*
|
|
1108
|
+
* => // that will only create an event listener on window, but every callback will get emit value when event triggered
|
|
1109
|
+
* ```
|
|
1110
|
+
*/ const createGlobalListener = (key, listener, options, { customKey = key, target: targetProp = window } = {})=>{
|
|
718
1111
|
let listening = false;
|
|
719
|
-
const getMapValue = ()
|
|
720
|
-
const addListener = ()
|
|
721
|
-
if (listening)
|
|
722
|
-
return;
|
|
1112
|
+
const getMapValue = ()=>globalListenerEventMap.get(customKey);
|
|
1113
|
+
const addListener = ()=>{
|
|
1114
|
+
if (listening) return;
|
|
723
1115
|
const savedEvent = getMapValue();
|
|
724
1116
|
if (!savedEvent?.exec) {
|
|
725
|
-
const exec = (e)
|
|
1117
|
+
const exec = (e)=>{
|
|
726
1118
|
const event = getMapValue();
|
|
727
|
-
event?.listeners.forEach((c)
|
|
1119
|
+
event?.listeners.forEach((c)=>c(e));
|
|
728
1120
|
};
|
|
729
1121
|
globalListenerEventMap.set(customKey, {
|
|
730
1122
|
exec,
|
|
731
|
-
listeners: new Set([
|
|
1123
|
+
listeners: new Set([
|
|
1124
|
+
listener
|
|
1125
|
+
])
|
|
732
1126
|
});
|
|
733
1127
|
const target = getRefElement(targetProp);
|
|
734
1128
|
target?.addEventListener(key, exec, options);
|
|
735
|
-
}
|
|
736
|
-
else {
|
|
1129
|
+
} else {
|
|
737
1130
|
savedEvent.listeners.add(listener);
|
|
738
1131
|
}
|
|
739
1132
|
listening = true;
|
|
740
1133
|
};
|
|
741
|
-
const removeListener = ()
|
|
742
|
-
if (!listening)
|
|
743
|
-
return;
|
|
1134
|
+
const removeListener = ()=>{
|
|
1135
|
+
if (!listening) return;
|
|
744
1136
|
listening = false;
|
|
745
1137
|
const _savedEvent = getMapValue();
|
|
746
|
-
if (!_savedEvent)
|
|
747
|
-
return;
|
|
1138
|
+
if (!_savedEvent) return;
|
|
748
1139
|
const { listeners } = _savedEvent;
|
|
749
1140
|
listeners.delete(listener);
|
|
750
1141
|
if (listeners.size === 0) {
|
|
@@ -754,34 +1145,80 @@ const createGlobalListener = (key, listener, options, { customKey = key, target:
|
|
|
754
1145
|
}
|
|
755
1146
|
};
|
|
756
1147
|
return {
|
|
757
|
-
listen: ()
|
|
758
|
-
|
|
759
|
-
|
|
1148
|
+
listen: ()=>addListener(),
|
|
1149
|
+
/**
|
|
1150
|
+
* remove listener
|
|
1151
|
+
*/ remove: ()=>removeListener(),
|
|
1152
|
+
/**
|
|
1153
|
+
* current listener state
|
|
1154
|
+
*/ get state () {
|
|
760
1155
|
return {
|
|
761
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* current listener state count
|
|
1158
|
+
*/ get count () {
|
|
762
1159
|
return getMapValue()?.listeners.size || 0;
|
|
763
1160
|
},
|
|
764
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* is that be listening
|
|
1163
|
+
*/ listening
|
|
765
1164
|
};
|
|
766
|
-
}
|
|
1165
|
+
}
|
|
767
1166
|
};
|
|
768
1167
|
};
|
|
769
1168
|
|
|
770
|
-
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* bind global event, when you bind same key event,
|
|
1176
|
+
* that will reuse one event listener and trigger both callback once that listener be triggered.
|
|
1177
|
+
*
|
|
1178
|
+
* also you can control listener with method `listen` and `remove`
|
|
1179
|
+
* and get listener `state` for check listener count number and current listing state.
|
|
1180
|
+
*
|
|
1181
|
+
* config:
|
|
1182
|
+
* - `target`: custom binding event target, default is `window`
|
|
1183
|
+
* - `customKey`: custom key for determining different event group, default is same as `key`
|
|
1184
|
+
* - `startImmediately`: start listener immediately, default is `true`
|
|
1185
|
+
*
|
|
1186
|
+
* * `key`, `options` only work when set first time, never change after first render
|
|
1187
|
+
*
|
|
1188
|
+
* @example
|
|
1189
|
+
* ```ts
|
|
1190
|
+
* useGlobalListener('keyup', () => console.log('window key up1'))
|
|
1191
|
+
*
|
|
1192
|
+
* useGlobalListener('keyup', () => console.log('window key up2'))
|
|
1193
|
+
*
|
|
1194
|
+
* useGlobalListener('touchend', () => console.log('window key up3'), {
|
|
1195
|
+
* target: someWantHostElement,
|
|
1196
|
+
* customKey: 'hostTouchend',
|
|
1197
|
+
* })
|
|
1198
|
+
*
|
|
1199
|
+
* useGlobalListener('touchend', () => console.log('window key up3'), { passive: false }, {
|
|
1200
|
+
* target: someWantHostElement,
|
|
1201
|
+
* customKey: 'hostTouchend',
|
|
1202
|
+
* })
|
|
1203
|
+
*
|
|
1204
|
+
* => // that will only create an event listener on `window`, but every callback will get emit value when event triggered
|
|
1205
|
+
* ```
|
|
1206
|
+
*/ function useGlobalListener(key, listener, ...args) {
|
|
771
1207
|
const { options, config } = getListenerOverloadOption(args);
|
|
772
|
-
const { customKey = key, target: targetProp = window, startImmediately = true
|
|
1208
|
+
const { customKey = key, target: targetProp = window, startImmediately = true } = config || {};
|
|
773
1209
|
const _listener = useEventCallback(listener);
|
|
774
1210
|
const { current: globalListener } = useRef(createGlobalListener(key, _listener, options, {
|
|
775
1211
|
customKey,
|
|
776
|
-
target: targetProp
|
|
1212
|
+
target: targetProp
|
|
777
1213
|
}));
|
|
778
1214
|
if (process.env.NODE_ENV !== 'production') {
|
|
779
|
-
|
|
1215
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
1216
|
+
useEffect(()=>{
|
|
780
1217
|
if (customKey === key) {
|
|
781
1218
|
if (Object.keys(options).length > 0) {
|
|
782
1219
|
logInDev({
|
|
783
1220
|
scope: 'useGlobalListener',
|
|
784
|
-
message: 'when have set options, suggest you also bind customKey, otherwise, it may use previous listener without same options'
|
|
1221
|
+
message: 'when have set options, suggest you also bind customKey, otherwise, it may use previous listener without same options'
|
|
785
1222
|
});
|
|
786
1223
|
}
|
|
787
1224
|
const target = getRefElement(targetProp);
|
|
@@ -789,17 +1226,19 @@ function useGlobalListener(key, listener, ...args) {
|
|
|
789
1226
|
logInDev({
|
|
790
1227
|
scope: 'useGlobalListener',
|
|
791
1228
|
message: 'When you custom binding event target, you must custom key for determining different event group',
|
|
792
|
-
level: 'error'
|
|
1229
|
+
level: 'error'
|
|
793
1230
|
});
|
|
794
1231
|
}
|
|
795
1232
|
}
|
|
1233
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
796
1234
|
}, []);
|
|
797
1235
|
}
|
|
798
|
-
useEffect(()
|
|
1236
|
+
useEffect(()=>{
|
|
799
1237
|
if (startImmediately) {
|
|
800
1238
|
globalListener.listen();
|
|
801
1239
|
}
|
|
802
1240
|
return globalListener.remove;
|
|
1241
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
803
1242
|
}, []);
|
|
804
1243
|
return globalListener;
|
|
805
1244
|
}
|
|
@@ -809,30 +1248,40 @@ function getListenerOverloadOption(args) {
|
|
|
809
1248
|
if (typeof args[0] === 'boolean') {
|
|
810
1249
|
options = args[0];
|
|
811
1250
|
config = args[1];
|
|
812
|
-
}
|
|
813
|
-
else {
|
|
1251
|
+
} else {
|
|
814
1252
|
const { startImmediately, customKey, target, ...restOptions } = {
|
|
815
1253
|
...args[0],
|
|
816
|
-
...args[1]
|
|
1254
|
+
...args[1]
|
|
817
1255
|
};
|
|
818
1256
|
options = restOptions;
|
|
819
1257
|
config = {
|
|
820
1258
|
startImmediately,
|
|
821
1259
|
customKey,
|
|
822
|
-
target
|
|
1260
|
+
target
|
|
823
1261
|
};
|
|
824
1262
|
}
|
|
825
|
-
return {
|
|
1263
|
+
return {
|
|
1264
|
+
options,
|
|
1265
|
+
config
|
|
1266
|
+
};
|
|
826
1267
|
}
|
|
827
1268
|
|
|
828
|
-
|
|
829
|
-
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* group hidden logic, when binding events element focus,
|
|
1276
|
+
* that hiddenRef will auto be `tabindex={-1}` *
|
|
1277
|
+
*/ const useHiddenTabindex = (target)=>{
|
|
1278
|
+
const handleFocus = useEventCallback(()=>{
|
|
830
1279
|
const hiddenElm = getRefElement(target);
|
|
831
1280
|
if (hiddenElm && hiddenElm.tabIndex === 0) {
|
|
832
1281
|
hiddenElm.tabIndex = -1;
|
|
833
1282
|
}
|
|
834
1283
|
});
|
|
835
|
-
const handleBlur = useEventCallback(()
|
|
1284
|
+
const handleBlur = useEventCallback(()=>{
|
|
836
1285
|
const hiddenElm = getRefElement(target);
|
|
837
1286
|
if (hiddenElm && hiddenElm.tabIndex === -1) {
|
|
838
1287
|
hiddenElm.tabIndex = 0;
|
|
@@ -840,86 +1289,111 @@ const useHiddenTabindex = (target) => {
|
|
|
840
1289
|
});
|
|
841
1290
|
return {
|
|
842
1291
|
onFocus: handleFocus,
|
|
843
|
-
onBlur: handleBlur
|
|
1292
|
+
onBlur: handleBlur
|
|
844
1293
|
};
|
|
845
1294
|
};
|
|
846
1295
|
|
|
847
|
-
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
const useHover = ({ onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, disabled = false, disableReactTree = true } = {})=>{
|
|
848
1301
|
const [hover, setHover] = useState(false);
|
|
849
1302
|
const hoverRef = useRef(false);
|
|
850
|
-
useEffect(()
|
|
1303
|
+
useEffect(()=>{
|
|
851
1304
|
if (disabled) {
|
|
852
1305
|
setHover(false);
|
|
853
|
-
}
|
|
854
|
-
else if (!disabled && hoverRef.current) {
|
|
1306
|
+
} else if (!disabled && hoverRef.current) {
|
|
855
1307
|
setHover(true);
|
|
856
1308
|
}
|
|
857
|
-
}, [
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1309
|
+
}, [
|
|
1310
|
+
disabled
|
|
1311
|
+
]);
|
|
1312
|
+
const updateHoverState = (hover, event)=>{
|
|
1313
|
+
if (disableReactTree && !event.currentTarget.contains(event.target)) {
|
|
861
1314
|
return;
|
|
862
1315
|
}
|
|
863
|
-
if (!disabled)
|
|
864
|
-
setHover(hover);
|
|
1316
|
+
if (!disabled) setHover(hover);
|
|
865
1317
|
hoverRef.current = hover;
|
|
866
1318
|
};
|
|
867
|
-
const onMouseEnter = (event)
|
|
1319
|
+
const onMouseEnter = (event)=>{
|
|
868
1320
|
handleMouseEnter?.(event);
|
|
869
1321
|
updateHoverState(true, event);
|
|
870
1322
|
};
|
|
871
|
-
const onMouseLeave = (event)
|
|
1323
|
+
const onMouseLeave = (event)=>{
|
|
872
1324
|
handleMouseLeave?.(event);
|
|
873
1325
|
updateHoverState(false, event);
|
|
874
1326
|
};
|
|
875
1327
|
return {
|
|
876
|
-
hoverHandlers: {
|
|
877
|
-
|
|
1328
|
+
hoverHandlers: {
|
|
1329
|
+
onMouseEnter,
|
|
1330
|
+
onMouseLeave
|
|
1331
|
+
},
|
|
1332
|
+
hover
|
|
878
1333
|
};
|
|
879
1334
|
};
|
|
880
1335
|
|
|
881
|
-
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
const useHoverDirty = ({ target, disabled = false })=>{
|
|
882
1340
|
const [hover, setHover] = useState(false);
|
|
883
|
-
useEffect(()
|
|
884
|
-
if (disabled || !target)
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1341
|
+
useEffect(()=>{
|
|
1342
|
+
if (disabled || !target) setHover(false);
|
|
1343
|
+
}, [
|
|
1344
|
+
disabled,
|
|
1345
|
+
target
|
|
1346
|
+
]);
|
|
1347
|
+
useEventListener(target, 'mouseenter', ()=>{
|
|
1348
|
+
if (disabled) return;
|
|
890
1349
|
setHover(true);
|
|
891
1350
|
});
|
|
892
|
-
useEventListener(target, 'mouseleave', ()
|
|
893
|
-
if (disabled)
|
|
894
|
-
return;
|
|
1351
|
+
useEventListener(target, 'mouseleave', ()=>{
|
|
1352
|
+
if (disabled) return;
|
|
895
1353
|
setHover(false);
|
|
896
1354
|
});
|
|
897
1355
|
return {
|
|
898
|
-
hover
|
|
1356
|
+
hover
|
|
899
1357
|
};
|
|
900
1358
|
};
|
|
901
1359
|
|
|
902
|
-
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
1366
|
+
/**
|
|
1367
|
+
* get load state of an image by passing src or srcSet
|
|
1368
|
+
* - false: not load yet
|
|
1369
|
+
* - 'loaded': successfully loaded
|
|
1370
|
+
* - 'error': fail to load
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* ```ts
|
|
1374
|
+
* const loaded = useImgLoaded({
|
|
1375
|
+
* src,
|
|
1376
|
+
* srcSet,
|
|
1377
|
+
* });
|
|
1378
|
+
* ```
|
|
1379
|
+
*/ const useImgLoaded = ({ crossOrigin, referrerPolicy, src, srcSet })=>{
|
|
903
1380
|
const [loaded, setLoaded] = useState(false);
|
|
904
|
-
|
|
1381
|
+
useIsoLayoutEffect(()=>{
|
|
905
1382
|
if (!src && !srcSet) {
|
|
1383
|
+
setLoaded('error');
|
|
906
1384
|
return undefined;
|
|
907
1385
|
}
|
|
908
1386
|
setLoaded(false);
|
|
909
1387
|
let active = true;
|
|
910
|
-
const image = new Image();
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
image.
|
|
918
|
-
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
setLoaded('error');
|
|
922
|
-
};
|
|
1388
|
+
const image = new window.Image();
|
|
1389
|
+
const updateLoaded = (status)=>()=>{
|
|
1390
|
+
if (!active) {
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
setLoaded(status);
|
|
1394
|
+
};
|
|
1395
|
+
image.onload = updateLoaded('loaded');
|
|
1396
|
+
image.onerror = updateLoaded('error');
|
|
923
1397
|
crossOrigin && (image.crossOrigin = crossOrigin);
|
|
924
1398
|
referrerPolicy && (image.referrerPolicy = referrerPolicy);
|
|
925
1399
|
if (src) {
|
|
@@ -928,71 +1402,133 @@ const useImgLoaded = ({ crossOrigin, referrerPolicy, src, srcSet, }) => {
|
|
|
928
1402
|
if (srcSet) {
|
|
929
1403
|
image.srcset = srcSet;
|
|
930
1404
|
}
|
|
931
|
-
|
|
1405
|
+
if (image.complete) {
|
|
1406
|
+
setLoaded(image.naturalWidth > 0 ? 'loaded' : 'error');
|
|
1407
|
+
}
|
|
1408
|
+
return ()=>{
|
|
932
1409
|
active = false;
|
|
933
1410
|
};
|
|
934
|
-
}, [
|
|
1411
|
+
}, [
|
|
1412
|
+
crossOrigin,
|
|
1413
|
+
referrerPolicy,
|
|
1414
|
+
src,
|
|
1415
|
+
srcSet
|
|
1416
|
+
]);
|
|
935
1417
|
return loaded;
|
|
936
1418
|
};
|
|
937
1419
|
|
|
938
|
-
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* controllable interval, provide easy way to control interval
|
|
1427
|
+
*
|
|
1428
|
+
* - cancel: cancel current interval
|
|
1429
|
+
* - pause: pause current interval
|
|
1430
|
+
* - play: play current interval
|
|
1431
|
+
* - getRunning: return current interval running state
|
|
1432
|
+
*
|
|
1433
|
+
* @example
|
|
1434
|
+
* ```ts
|
|
1435
|
+
* const { cancel, play, pause } = useInterval(() => {
|
|
1436
|
+
* console.log('interval be triggered');
|
|
1437
|
+
* }, 1000)
|
|
1438
|
+
*
|
|
1439
|
+
* // get current run times
|
|
1440
|
+
* useInterval((times) => {
|
|
1441
|
+
* console.log('current times start from 1', times);
|
|
1442
|
+
* }, 2000)
|
|
1443
|
+
*
|
|
1444
|
+
* // not start interval immediately
|
|
1445
|
+
* useInterval((times) => {
|
|
1446
|
+
* console.log('current times start from 1', times);
|
|
1447
|
+
* }, 2000, false)
|
|
1448
|
+
* ```
|
|
1449
|
+
*/ const useInterval = (/** callback with times, times start from 1 */ callback, /** what delay time of that interval */ delay = 0, /** is that start interval immediately */ startImmediately = true)=>{
|
|
939
1450
|
const intervalRef = useRef();
|
|
940
1451
|
const countRef = useRef(0);
|
|
941
|
-
const getRunning = useEventCallback(()
|
|
942
|
-
const savedCallback = useEventCallback((times)
|
|
1452
|
+
const getRunning = useEventCallback(()=>!!intervalRef.current);
|
|
1453
|
+
const savedCallback = useEventCallback((times)=>{
|
|
943
1454
|
callback(times);
|
|
944
1455
|
});
|
|
945
|
-
const pause = useCallback(()
|
|
1456
|
+
const pause = useCallback(()=>{
|
|
946
1457
|
if (intervalRef.current) {
|
|
947
1458
|
clearInterval(intervalRef.current);
|
|
948
1459
|
intervalRef.current = undefined;
|
|
949
1460
|
}
|
|
950
1461
|
}, []);
|
|
951
|
-
const cancel = useCallback(()
|
|
1462
|
+
const cancel = useCallback(()=>{
|
|
952
1463
|
pause();
|
|
953
1464
|
countRef.current = 0;
|
|
954
|
-
}, [
|
|
955
|
-
|
|
1465
|
+
}, [
|
|
1466
|
+
pause
|
|
1467
|
+
]);
|
|
1468
|
+
const play = useCallback(()=>{
|
|
956
1469
|
const isRunning = getRunning();
|
|
957
|
-
if (isRunning)
|
|
958
|
-
|
|
959
|
-
intervalRef.current = setInterval(() => {
|
|
1470
|
+
if (isRunning) return;
|
|
1471
|
+
intervalRef.current = setInterval(()=>{
|
|
960
1472
|
countRef.current++;
|
|
961
1473
|
savedCallback(countRef.current);
|
|
962
1474
|
}, delay);
|
|
963
|
-
}, [
|
|
964
|
-
|
|
1475
|
+
}, [
|
|
1476
|
+
delay,
|
|
1477
|
+
getRunning,
|
|
1478
|
+
savedCallback
|
|
1479
|
+
]);
|
|
1480
|
+
useEffect(()=>{
|
|
965
1481
|
if (startImmediately) {
|
|
966
1482
|
play();
|
|
967
1483
|
}
|
|
968
|
-
return ()
|
|
969
|
-
}, [
|
|
970
|
-
return {
|
|
1484
|
+
return ()=>cancel();
|
|
1485
|
+
}, [
|
|
971
1486
|
cancel,
|
|
972
|
-
pause,
|
|
973
1487
|
play,
|
|
974
|
-
|
|
1488
|
+
startImmediately
|
|
1489
|
+
]);
|
|
1490
|
+
return {
|
|
1491
|
+
/**
|
|
1492
|
+
* cancel current interval
|
|
1493
|
+
*/ cancel,
|
|
1494
|
+
/**
|
|
1495
|
+
* pause current interval
|
|
1496
|
+
*/ pause,
|
|
1497
|
+
/**
|
|
1498
|
+
* play current interval
|
|
1499
|
+
*/ play,
|
|
1500
|
+
/**
|
|
1501
|
+
* return current interval running state
|
|
1502
|
+
*/ getRunning
|
|
975
1503
|
};
|
|
976
1504
|
};
|
|
977
1505
|
|
|
978
|
-
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Provide user trigger `onKeyDown` only one time when hold press on key.
|
|
1513
|
+
* @param includeKeys that check include keys
|
|
1514
|
+
* @param methods trigger when first keydown or keyup
|
|
1515
|
+
* @returns
|
|
1516
|
+
*/ const useKeyDownOnce = (includeKeys, { onKeyDown, onKeyUp })=>{
|
|
979
1517
|
const isKeyDownMapRef = useRef({});
|
|
980
|
-
const handleKeyDown = useEventCallback((e)
|
|
1518
|
+
const handleKeyDown = useEventCallback((e)=>{
|
|
981
1519
|
if (includeKeys.includes(e.key)) {
|
|
982
1520
|
if (!isKeyDownMapRef.current[e.key]) {
|
|
983
1521
|
onKeyDown?.(e);
|
|
984
|
-
}
|
|
985
|
-
else {
|
|
1522
|
+
} else {
|
|
986
1523
|
e.preventDefault();
|
|
987
1524
|
e.stopPropagation();
|
|
988
1525
|
}
|
|
989
1526
|
isKeyDownMapRef.current[e.key] = true;
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
1527
|
+
} else {
|
|
992
1528
|
onKeyDown?.(e);
|
|
993
1529
|
}
|
|
994
1530
|
});
|
|
995
|
-
const handleKeyUp = useEventCallback((e)
|
|
1531
|
+
const handleKeyUp = useEventCallback((e)=>{
|
|
996
1532
|
if (includeKeys.includes(e.key)) {
|
|
997
1533
|
isKeyDownMapRef.current[e.key] = false;
|
|
998
1534
|
}
|
|
@@ -1000,48 +1536,57 @@ const useKeyDownOnce = (includeKeys, { onKeyDown, onKeyUp }) => {
|
|
|
1000
1536
|
});
|
|
1001
1537
|
return {
|
|
1002
1538
|
onKeyDown: handleKeyDown,
|
|
1003
|
-
onKeyUp: handleKeyUp
|
|
1539
|
+
onKeyUp: handleKeyUp
|
|
1004
1540
|
};
|
|
1005
1541
|
};
|
|
1006
1542
|
|
|
1007
|
-
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* that can move index in column array
|
|
1547
|
+
*/ function moveIndexInTwoDimension(direction, options) {
|
|
1008
1548
|
const { currIndex, columns, total, infinite } = options;
|
|
1549
|
+
// start index never small than 0
|
|
1550
|
+
// const index = Math.max(currIndex, 0);
|
|
1009
1551
|
const lastIndex = total - 1;
|
|
1010
1552
|
const isInOrder = infinite === 'order';
|
|
1011
1553
|
const fillTotal = Math.ceil(total / columns) * columns;
|
|
1012
1554
|
let resultIndex;
|
|
1013
|
-
switch
|
|
1014
|
-
case 'ArrowUp':
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
}
|
|
1019
|
-
case 'ArrowRight': {
|
|
1020
|
-
if (isInOrder) {
|
|
1021
|
-
const tmp = currIndex + 1;
|
|
1022
|
-
resultIndex = tmp > lastIndex ? 0 : tmp;
|
|
1555
|
+
switch(direction){
|
|
1556
|
+
case 'ArrowUp':
|
|
1557
|
+
{
|
|
1558
|
+
const toIndex = (Math.max(currIndex, 0) - columns + fillTotal) % fillTotal;
|
|
1559
|
+
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
1023
1560
|
break;
|
|
1024
1561
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1562
|
+
case 'ArrowRight':
|
|
1563
|
+
{
|
|
1564
|
+
if (isInOrder) {
|
|
1565
|
+
const tmp = currIndex + 1;
|
|
1566
|
+
resultIndex = tmp > lastIndex ? 0 : tmp;
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
const toIndex = Math.floor(currIndex / columns) * columns + (currIndex + 1) % columns;
|
|
1570
|
+
resultIndex = infinite ? toIndex : Math.max(toIndex, currIndex);
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
case 'ArrowDown':
|
|
1574
|
+
{
|
|
1575
|
+
const toIndex = (currIndex + columns) % fillTotal;
|
|
1576
|
+
resultIndex = infinite ? toIndex : Math.max(toIndex, currIndex);
|
|
1577
|
+
break;
|
|
1578
|
+
}
|
|
1579
|
+
case 'ArrowLeft':
|
|
1580
|
+
{
|
|
1581
|
+
if (isInOrder) {
|
|
1582
|
+
const tmp = currIndex - 1;
|
|
1583
|
+
resultIndex = tmp < 0 ? lastIndex : tmp;
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
const toIndex = Math.floor(currIndex / columns) * columns + (currIndex - 1 + columns) % columns;
|
|
1587
|
+
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
1038
1588
|
break;
|
|
1039
1589
|
}
|
|
1040
|
-
const toIndex = Math.floor(currIndex / columns) * columns +
|
|
1041
|
-
((currIndex - 1 + columns) % columns);
|
|
1042
|
-
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
1043
|
-
break;
|
|
1044
|
-
}
|
|
1045
1590
|
case 'Home':
|
|
1046
1591
|
resultIndex = 0;
|
|
1047
1592
|
break;
|
|
@@ -1054,55 +1599,56 @@ function moveIndexInTwoDimension(direction, options) {
|
|
|
1054
1599
|
if (!infinite && resultIndex >= total) {
|
|
1055
1600
|
return currIndex;
|
|
1056
1601
|
}
|
|
1602
|
+
// * when that bigger then sourceTotal, mean that touch end line
|
|
1603
|
+
// * move to current full latest and move again.
|
|
1057
1604
|
if (resultIndex >= total) {
|
|
1058
1605
|
if (direction === 'ArrowRight') {
|
|
1059
1606
|
resultIndex = fillTotal - 1;
|
|
1060
1607
|
}
|
|
1061
1608
|
return moveIndexInTwoDimension(direction, {
|
|
1062
1609
|
...options,
|
|
1063
|
-
currIndex: resultIndex
|
|
1610
|
+
currIndex: resultIndex
|
|
1064
1611
|
});
|
|
1065
1612
|
}
|
|
1066
1613
|
return resultIndex;
|
|
1067
1614
|
}
|
|
1068
1615
|
|
|
1069
|
-
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* use keyboard move index in two dimensional
|
|
1622
|
+
*/ const useKeyboardMoveFocus = ({ infinite, options = [], focusedIndexRef, onFocusedIndexChange, getOptionDisabled, getOptionSearchText, columns = 1, endSearchDebounceTime = 500 })=>{
|
|
1070
1623
|
const total = options.length;
|
|
1071
1624
|
const searchText = useRef('');
|
|
1072
|
-
const debounceEndSearch = useDebounce(()
|
|
1073
|
-
const searchTextAndFocus = (event)
|
|
1625
|
+
const debounceEndSearch = useDebounce(()=>searchText.current = '', endSearchDebounceTime);
|
|
1626
|
+
const searchTextAndFocus = (event)=>{
|
|
1074
1627
|
const key = event.key;
|
|
1075
|
-
if (!getOptionSearchText || key.length !== 1)
|
|
1076
|
-
return;
|
|
1628
|
+
if (!getOptionSearchText || key.length !== 1) return;
|
|
1077
1629
|
debounceEndSearch();
|
|
1078
1630
|
searchText.current += key.toLowerCase();
|
|
1079
|
-
const matchedIndex = options.findIndex((option)
|
|
1631
|
+
const matchedIndex = options.findIndex((option)=>{
|
|
1080
1632
|
const text = getOptionSearchText(option);
|
|
1081
|
-
return Boolean(!getOptionDisabled?.(option) &&
|
|
1082
|
-
text &&
|
|
1083
|
-
text.toLowerCase().startsWith(searchText.current));
|
|
1633
|
+
return Boolean(!getOptionDisabled?.(option) && text && text.toLowerCase().startsWith(searchText.current));
|
|
1084
1634
|
});
|
|
1085
1635
|
if (matchedIndex > -1) {
|
|
1086
1636
|
onFocusedIndexChange(event, matchedIndex);
|
|
1087
1637
|
}
|
|
1088
1638
|
};
|
|
1089
|
-
const onKeyFocusedIndexHandle = (event)
|
|
1639
|
+
const onKeyFocusedIndexHandle = (event)=>{
|
|
1090
1640
|
let currIndex = focusedIndexRef.current;
|
|
1091
1641
|
const key = event.key;
|
|
1092
1642
|
let findDirection;
|
|
1093
|
-
if (total === 0)
|
|
1094
|
-
|
|
1095
|
-
while
|
|
1096
|
-
const toIndex = findDirection
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
columns,
|
|
1103
|
-
total,
|
|
1104
|
-
infinite,
|
|
1105
|
-
});
|
|
1643
|
+
if (total === 0) return;
|
|
1644
|
+
// oxlint-disable-next-line no-constant-condition
|
|
1645
|
+
while(true){
|
|
1646
|
+
const toIndex = findDirection ? findDirection === 'next' ? currIndex + 1 : currIndex - 1 : moveIndexInTwoDimension(key, {
|
|
1647
|
+
currIndex,
|
|
1648
|
+
columns,
|
|
1649
|
+
total,
|
|
1650
|
+
infinite
|
|
1651
|
+
});
|
|
1106
1652
|
if (toIndex === currIndex) {
|
|
1107
1653
|
searchTextAndFocus(event);
|
|
1108
1654
|
break;
|
|
@@ -1112,76 +1658,99 @@ const useKeyboardMoveFocus = ({ infinite, options = [], focusedIndexRef, onFocus
|
|
|
1112
1658
|
onFocusedIndexChange(event, toIndex);
|
|
1113
1659
|
}
|
|
1114
1660
|
break;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
if (focusedIndexRef.current !== -1 &&
|
|
1118
|
-
toIndex === Math.max(focusedIndexRef.current, 0)) {
|
|
1661
|
+
} else {
|
|
1662
|
+
// * find toIndex is same as current, mean that already find all
|
|
1663
|
+
if (focusedIndexRef.current !== -1 && toIndex === Math.max(focusedIndexRef.current, 0)) {
|
|
1119
1664
|
break;
|
|
1120
1665
|
}
|
|
1666
|
+
// * if no item is focused currently and toIndex is already the last item checked,
|
|
1667
|
+
// * it means no item in the list is focusable
|
|
1121
1668
|
if (focusedIndexRef.current === -1) {
|
|
1122
|
-
|
|
1123
|
-
|
|
1669
|
+
// from bottom to top and the first item in the list is not focusable
|
|
1670
|
+
if ((Math.abs(currIndex - toIndex) !== 1 || currIndex > toIndex) && toIndex === 0) {
|
|
1124
1671
|
break;
|
|
1125
1672
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
toIndex === total - 1) {
|
|
1673
|
+
// from top to bottom and the last item in the list is not focusable
|
|
1674
|
+
if (Math.abs(currIndex - toIndex) === 1 && currIndex < toIndex && toIndex === total - 1) {
|
|
1129
1675
|
break;
|
|
1130
1676
|
}
|
|
1131
1677
|
}
|
|
1132
1678
|
currIndex = toIndex;
|
|
1679
|
+
// * if that is Hom or End, find use increase or decrease
|
|
1133
1680
|
if (key === 'Home') {
|
|
1134
1681
|
findDirection = 'next';
|
|
1135
|
-
}
|
|
1136
|
-
else if (key === 'End') {
|
|
1682
|
+
} else if (key === 'End') {
|
|
1137
1683
|
findDirection = 'prev';
|
|
1138
1684
|
}
|
|
1139
1685
|
}
|
|
1140
1686
|
}
|
|
1141
1687
|
};
|
|
1142
|
-
const getPrevFocusableOption = useCallback((index = focusedIndexRef.current, isInfinite = true)
|
|
1143
|
-
for
|
|
1688
|
+
const getPrevFocusableOption = useCallback((index = focusedIndexRef.current, /** is that infinite search */ isInfinite = true)=>{
|
|
1689
|
+
for(let i = index; i >= 0; i--){
|
|
1144
1690
|
if (!getOptionDisabled?.(options[i])) {
|
|
1145
1691
|
return i;
|
|
1146
1692
|
}
|
|
1147
1693
|
}
|
|
1148
1694
|
if (isInfinite) {
|
|
1149
|
-
|
|
1695
|
+
// loop find
|
|
1696
|
+
for(let i = total - 1; i > index; i--){
|
|
1150
1697
|
if (!getOptionDisabled?.(options[i])) {
|
|
1151
1698
|
return i;
|
|
1152
1699
|
}
|
|
1153
1700
|
}
|
|
1154
1701
|
}
|
|
1155
1702
|
return -1;
|
|
1156
|
-
}, [
|
|
1157
|
-
|
|
1158
|
-
|
|
1703
|
+
}, [
|
|
1704
|
+
focusedIndexRef,
|
|
1705
|
+
getOptionDisabled,
|
|
1706
|
+
options,
|
|
1707
|
+
total
|
|
1708
|
+
]);
|
|
1709
|
+
const getNextFocusableOption = useCallback((index = focusedIndexRef.current, /** is that infinite search */ isInfinite = true)=>{
|
|
1710
|
+
for(let i = index; i < total; i++){
|
|
1159
1711
|
if (!getOptionDisabled?.(options[i])) {
|
|
1160
1712
|
return i;
|
|
1161
1713
|
}
|
|
1162
1714
|
}
|
|
1163
1715
|
if (isInfinite) {
|
|
1164
|
-
|
|
1716
|
+
// loop find
|
|
1717
|
+
for(let i = 0; i < index; i++){
|
|
1165
1718
|
if (!getOptionDisabled?.(options[i])) {
|
|
1166
1719
|
return i;
|
|
1167
1720
|
}
|
|
1168
1721
|
}
|
|
1169
1722
|
}
|
|
1170
1723
|
return -1;
|
|
1171
|
-
}, [
|
|
1724
|
+
}, [
|
|
1725
|
+
focusedIndexRef,
|
|
1726
|
+
getOptionDisabled,
|
|
1727
|
+
options,
|
|
1728
|
+
total
|
|
1729
|
+
]);
|
|
1172
1730
|
return {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1731
|
+
/**
|
|
1732
|
+
* Get previous focusable option(include current index).
|
|
1733
|
+
*/ getPrevFocusableOption,
|
|
1734
|
+
/**
|
|
1735
|
+
* Get next focusable option(include current index).
|
|
1736
|
+
*/ getNextFocusableOption,
|
|
1737
|
+
/**
|
|
1738
|
+
* Use to handle keyboard event.
|
|
1739
|
+
*/ onKeyFocusedIndexHandle
|
|
1176
1740
|
};
|
|
1177
1741
|
};
|
|
1178
1742
|
|
|
1743
|
+
|
|
1744
|
+
|
|
1179
1745
|
const MOVE_ATTR_KEY = 'data-move-focused';
|
|
1180
1746
|
const MOVE_INDEX_ATTR_KEY = 'data-move-index';
|
|
1181
|
-
|
|
1747
|
+
/**
|
|
1748
|
+
* when focus in container below element with binding `getItemProps`,
|
|
1749
|
+
* that will only have one element can be focusable.
|
|
1750
|
+
*/ const useOnlyOneFocusable = ({ focusedIndexRef, containerRef })=>{
|
|
1182
1751
|
const moveFocusedId = useId();
|
|
1183
|
-
const getIndexElm = (toIndex)
|
|
1184
|
-
const setIndexTabIndex = (toIndex)
|
|
1752
|
+
const getIndexElm = (toIndex)=>containerRef.current?.querySelector(`[${MOVE_ATTR_KEY}="${moveFocusedId}"][${MOVE_INDEX_ATTR_KEY}="${toIndex}"]`);
|
|
1753
|
+
const setIndexTabIndex = (toIndex)=>{
|
|
1185
1754
|
const toElm = getIndexElm(toIndex);
|
|
1186
1755
|
if (toElm) {
|
|
1187
1756
|
const prevElm = containerRef.current?.querySelector(`[${MOVE_ATTR_KEY}="${moveFocusedId}"][tabindex="0"]`);
|
|
@@ -1189,7 +1758,7 @@ const useOnlyOneFocusable = ({ focusedIndexRef, containerRef, }) => {
|
|
|
1189
1758
|
toElm.setAttribute('tabindex', '0');
|
|
1190
1759
|
}
|
|
1191
1760
|
};
|
|
1192
|
-
const focusIndex = (toIndex)
|
|
1761
|
+
const focusIndex = (toIndex)=>{
|
|
1193
1762
|
const toElm = getIndexElm(toIndex);
|
|
1194
1763
|
toElm?.focus();
|
|
1195
1764
|
};
|
|
@@ -1197,23 +1766,53 @@ const useOnlyOneFocusable = ({ focusedIndexRef, containerRef, }) => {
|
|
|
1197
1766
|
moveFocusedId,
|
|
1198
1767
|
focusIndex,
|
|
1199
1768
|
setIndexTabIndex,
|
|
1200
|
-
getItemProps: (index, options)
|
|
1769
|
+
getItemProps: (index, options)=>{
|
|
1201
1770
|
return combineProps({
|
|
1202
1771
|
[MOVE_ATTR_KEY]: moveFocusedId,
|
|
1203
1772
|
[MOVE_INDEX_ATTR_KEY]: String(index),
|
|
1204
1773
|
tabIndex: index === focusedIndexRef.current ? 0 : -1,
|
|
1205
|
-
onFocus: ()
|
|
1774
|
+
onFocus: ()=>{
|
|
1206
1775
|
focusedIndexRef.current = index;
|
|
1207
1776
|
setIndexTabIndex(index);
|
|
1208
|
-
}
|
|
1777
|
+
}
|
|
1209
1778
|
}, options ?? {});
|
|
1210
|
-
}
|
|
1779
|
+
}
|
|
1211
1780
|
};
|
|
1212
1781
|
};
|
|
1213
1782
|
|
|
1214
|
-
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Provide a utils for switch trigger touch event or mouse event between different device
|
|
1794
|
+
*
|
|
1795
|
+
* - Trigger `touch` event in `touch support` device
|
|
1796
|
+
* - onTouchStart
|
|
1797
|
+
* - onTouchEnd
|
|
1798
|
+
* - Trigger `mouse` event in `non touch support` device
|
|
1799
|
+
* - onMouseDown
|
|
1800
|
+
* - onMouseUp
|
|
1801
|
+
*
|
|
1802
|
+
* ### Once that is touch support device, that mouse event will never can be trigger
|
|
1803
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent
|
|
1804
|
+
*
|
|
1805
|
+
* Event order:
|
|
1806
|
+
* - touchstart
|
|
1807
|
+
* - Zero or more touchmove events, depending on movement of the finger(s)
|
|
1808
|
+
* - touchend
|
|
1809
|
+
* - mousemove
|
|
1810
|
+
* - mousedown
|
|
1811
|
+
* - mouseup
|
|
1812
|
+
* - click
|
|
1813
|
+
*/ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpArg, onTouchStart: onTouchStartArg, onTouchEnd: onTouchEndArg }, { actionRef } = {})=>{
|
|
1215
1814
|
const stopMouseEventRef = useRef(false);
|
|
1216
|
-
const onTouchStart = useEventCallback((e)
|
|
1815
|
+
const onTouchStart = useEventCallback((e)=>{
|
|
1217
1816
|
stopMouseEventRef.current = true;
|
|
1218
1817
|
onTouchStartArg?.(e);
|
|
1219
1818
|
});
|
|
@@ -1224,7 +1823,7 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1224
1823
|
}
|
|
1225
1824
|
return false;
|
|
1226
1825
|
}
|
|
1227
|
-
const onTouchEnd = useEventCallback((e)
|
|
1826
|
+
const onTouchEnd = useEventCallback((e)=>{
|
|
1228
1827
|
let isInside;
|
|
1229
1828
|
if (actionRef && e.touches.length < 2 && e.changedTouches.length < 2) {
|
|
1230
1829
|
const touch = e.touches[0] || e.changedTouches[0];
|
|
@@ -1233,16 +1832,15 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1233
1832
|
}
|
|
1234
1833
|
onTouchEndArg?.(e, isInside);
|
|
1235
1834
|
});
|
|
1236
|
-
const onMouseDown = useEventCallback((e)
|
|
1835
|
+
const onMouseDown = useEventCallback((e)=>{
|
|
1237
1836
|
if (stopMouseEventRef.current) {
|
|
1238
1837
|
return;
|
|
1239
1838
|
}
|
|
1240
1839
|
onMouseDownArg?.(e);
|
|
1241
1840
|
});
|
|
1242
|
-
const onMouseUp = useEventCallback((e)
|
|
1841
|
+
const onMouseUp = useEventCallback((e)=>{
|
|
1243
1842
|
let isInside;
|
|
1244
|
-
if (stopMouseEventRef.current)
|
|
1245
|
-
return;
|
|
1843
|
+
if (stopMouseEventRef.current) return;
|
|
1246
1844
|
if (actionRef) {
|
|
1247
1845
|
const elm = e.target;
|
|
1248
1846
|
isInside = !!elm && checkInside(elm);
|
|
@@ -1253,30 +1851,50 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1253
1851
|
onTouchStart,
|
|
1254
1852
|
onTouchEnd,
|
|
1255
1853
|
onMouseDown,
|
|
1256
|
-
onMouseUp
|
|
1854
|
+
onMouseUp
|
|
1257
1855
|
};
|
|
1258
1856
|
};
|
|
1259
|
-
|
|
1857
|
+
/**
|
|
1858
|
+
* check is element equal or contains target element
|
|
1859
|
+
*/ const isElmEqualOrContain = (sourceTarget, containTarget)=>{
|
|
1260
1860
|
const elm = getRefElement(sourceTarget);
|
|
1261
1861
|
const target = getRefElement(containTarget);
|
|
1262
1862
|
return elm && target && (target === elm || target.contains(elm));
|
|
1263
1863
|
};
|
|
1264
1864
|
|
|
1265
|
-
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
const isTouchEvent = (ev)=>{
|
|
1266
1875
|
return 'touches' in ev;
|
|
1267
1876
|
};
|
|
1268
|
-
|
|
1877
|
+
/**
|
|
1878
|
+
* Provide longPress helper, both `click`/`tab`/`keydown` will trigger event
|
|
1879
|
+
*
|
|
1880
|
+
* - Trigger `onTap` when user action leave small than delay time.
|
|
1881
|
+
* - Trigger `onPress` when action time is long than delay time.
|
|
1882
|
+
*
|
|
1883
|
+
* @default UseLongPressOptions { delay = 300 }
|
|
1884
|
+
*/ const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTouchEnd: onTouchEndArg, onMouseDown, onKeyDown, onKeyUp, onMouseUp: onMouseUpArg } = {}, { isPreventDefault = true, delay = 300, externalWindow = window } = {})=>{
|
|
1269
1885
|
const isEmittedRef = useRef(false);
|
|
1270
1886
|
const isA11yDownRef = useRef(false);
|
|
1271
1887
|
const actionRef = useRef(null);
|
|
1272
1888
|
const reasonRef = useRef('click');
|
|
1273
|
-
const emitLongPress = useDebounce((e)
|
|
1889
|
+
const emitLongPress = useDebounce((e)=>{
|
|
1890
|
+
// * only when have onPress set have emit
|
|
1274
1891
|
if (onPress) {
|
|
1275
1892
|
onPress(e, reasonRef.current);
|
|
1276
1893
|
isEmittedRef.current = true;
|
|
1277
1894
|
}
|
|
1278
1895
|
}, delay);
|
|
1279
|
-
const end = (e, isInside)
|
|
1896
|
+
const end = (e, isInside)=>{
|
|
1897
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1280
1898
|
globalMouseUpListener.remove();
|
|
1281
1899
|
if (!isEmittedRef.current && isInside) {
|
|
1282
1900
|
onTap?.(e, reasonRef.current);
|
|
@@ -1285,7 +1903,8 @@ const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTou
|
|
|
1285
1903
|
isEmittedRef.current = false;
|
|
1286
1904
|
emitLongPress.cancel();
|
|
1287
1905
|
};
|
|
1288
|
-
const start = (e)
|
|
1906
|
+
const start = (e)=>{
|
|
1907
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1289
1908
|
globalMouseUpListener.listen();
|
|
1290
1909
|
const isTouch = e.type === 'touchstart';
|
|
1291
1910
|
if (isTouch) {
|
|
@@ -1294,111 +1913,173 @@ const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTou
|
|
|
1294
1913
|
emitLongPress(e);
|
|
1295
1914
|
};
|
|
1296
1915
|
const { onMouseUp, onTouchEnd, onTouchStart, ...events } = useTouchMouseEvent({
|
|
1297
|
-
onTouchStart: (e)
|
|
1916
|
+
onTouchStart: (e)=>{
|
|
1917
|
+
// prevent long touch leave element focus
|
|
1298
1918
|
if (isPreventDefault) {
|
|
1299
1919
|
e.preventDefault();
|
|
1300
1920
|
}
|
|
1301
1921
|
onTouchStartArg?.(e);
|
|
1302
1922
|
start(e);
|
|
1303
1923
|
},
|
|
1304
|
-
onTouchEnd: (e, isInside)
|
|
1924
|
+
onTouchEnd: (e, isInside)=>{
|
|
1925
|
+
// prevent leave element focus
|
|
1305
1926
|
if (isPreventDefault) {
|
|
1306
1927
|
e.preventDefault();
|
|
1307
1928
|
}
|
|
1308
1929
|
onTouchEndArg?.(e, isInside);
|
|
1309
1930
|
end(e, isInside);
|
|
1310
1931
|
},
|
|
1311
|
-
onMouseDown: (e)
|
|
1932
|
+
onMouseDown: (e)=>{
|
|
1312
1933
|
if (isPreventDefault) {
|
|
1313
1934
|
e.preventDefault();
|
|
1314
1935
|
}
|
|
1315
1936
|
onMouseDown?.(e);
|
|
1316
1937
|
start(e);
|
|
1317
1938
|
},
|
|
1318
|
-
onMouseUp: (e, isInside)
|
|
1939
|
+
onMouseUp: (e, isInside)=>{
|
|
1940
|
+
// prevent leave element focus
|
|
1319
1941
|
if (isPreventDefault) {
|
|
1320
1942
|
e.preventDefault();
|
|
1321
1943
|
}
|
|
1322
1944
|
onMouseUpArg?.(e);
|
|
1323
1945
|
end(e, isInside);
|
|
1324
|
-
}
|
|
1325
|
-
}, {
|
|
1946
|
+
}
|
|
1947
|
+
}, {
|
|
1948
|
+
actionRef
|
|
1949
|
+
});
|
|
1950
|
+
// use event listener for make event `passive` work in safari
|
|
1326
1951
|
useEventListener(actionRef, 'touchstart', onTouchStart, {
|
|
1327
|
-
passive: false
|
|
1952
|
+
passive: false
|
|
1328
1953
|
});
|
|
1954
|
+
// use event listener for make event `passive` work in safari
|
|
1329
1955
|
useEventListener(actionRef, 'touchend', onTouchEnd, {
|
|
1330
|
-
passive: false
|
|
1956
|
+
passive: false
|
|
1331
1957
|
});
|
|
1332
1958
|
const globalMouseUpListener = useGlobalListener('mouseup', onMouseUp, {
|
|
1333
1959
|
target: externalWindow,
|
|
1334
|
-
startImmediately: false
|
|
1960
|
+
startImmediately: false
|
|
1335
1961
|
});
|
|
1336
|
-
const handleDeleteKeyDown = useA11yKeyEvent((e)
|
|
1962
|
+
const handleDeleteKeyDown = useA11yKeyEvent((e)=>{
|
|
1337
1963
|
reasonRef.current = 'keyboard';
|
|
1964
|
+
// * keydown when hold will trigger many time, show when current is a11y down, not trigger again
|
|
1338
1965
|
if (!isA11yDownRef.current) {
|
|
1339
1966
|
emitLongPress(e);
|
|
1340
1967
|
}
|
|
1341
1968
|
isA11yDownRef.current = true;
|
|
1342
|
-
}, {
|
|
1343
|
-
|
|
1969
|
+
}, {
|
|
1970
|
+
preventDefault: false
|
|
1971
|
+
});
|
|
1972
|
+
const handleKeyDown = useEventCallback((e)=>{
|
|
1344
1973
|
onKeyDown?.(e);
|
|
1345
1974
|
handleDeleteKeyDown(e);
|
|
1346
1975
|
});
|
|
1347
|
-
const handleKeyUp = useEventCallback((e)
|
|
1976
|
+
const handleKeyUp = useEventCallback((e)=>{
|
|
1348
1977
|
onKeyUp?.(e);
|
|
1349
|
-
if (isA11yDownRef.current)
|
|
1350
|
-
end(e, true);
|
|
1978
|
+
if (isA11yDownRef.current) end(e, true);
|
|
1351
1979
|
isA11yDownRef.current = false;
|
|
1352
1980
|
});
|
|
1353
1981
|
return {
|
|
1354
1982
|
ref: actionRef,
|
|
1355
1983
|
onKeyDown: handleKeyDown,
|
|
1356
1984
|
onKeyUp: handleKeyUp,
|
|
1357
|
-
...events
|
|
1985
|
+
...events
|
|
1358
1986
|
};
|
|
1359
1987
|
};
|
|
1360
1988
|
|
|
1361
|
-
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* get that component mounted state `ref`
|
|
1995
|
+
*
|
|
1996
|
+
* @example
|
|
1997
|
+
*
|
|
1998
|
+
* ```ts
|
|
1999
|
+
const isMountedRef = useMountState();
|
|
2000
|
+
|
|
2001
|
+
if (isMountedRef.current) {
|
|
2002
|
+
console.log('component mounted');
|
|
2003
|
+
}
|
|
2004
|
+
*
|
|
2005
|
+
* ```
|
|
2006
|
+
*/ const useMountState = ()=>{
|
|
1362
2007
|
const isMountedRef = useRef(false);
|
|
1363
|
-
useEffect(()
|
|
2008
|
+
useEffect(()=>{
|
|
1364
2009
|
isMountedRef.current = true;
|
|
1365
|
-
return ()
|
|
2010
|
+
return ()=>{
|
|
1366
2011
|
isMountedRef.current = false;
|
|
1367
2012
|
};
|
|
1368
2013
|
}, []);
|
|
1369
2014
|
return isMountedRef;
|
|
1370
2015
|
};
|
|
1371
2016
|
|
|
1372
|
-
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* only trigger when re-render (in useLayoutEffect), not trigger on component first render
|
|
2024
|
+
*/ const useOnLayoutReRender = (cb, deps)=>{
|
|
1373
2025
|
const count = useRef(0);
|
|
1374
2026
|
const method = useEventCallback(cb);
|
|
1375
|
-
useLayoutEffect(()
|
|
2027
|
+
useLayoutEffect(()=>{
|
|
1376
2028
|
if (count.current === 0) {
|
|
1377
2029
|
count.current = 1;
|
|
1378
|
-
return ()
|
|
2030
|
+
return ()=>{
|
|
2031
|
+
//
|
|
1379
2032
|
};
|
|
1380
2033
|
}
|
|
1381
2034
|
return method();
|
|
2035
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1382
2036
|
}, deps);
|
|
1383
2037
|
};
|
|
1384
2038
|
|
|
1385
|
-
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* only trigger when re-render, not trigger on component first render
|
|
2046
|
+
*/ const useOnReRender = (cb, deps)=>{
|
|
1386
2047
|
const count = useRef(0);
|
|
1387
2048
|
const method = useEventCallback(cb);
|
|
1388
|
-
useEffect(()
|
|
2049
|
+
useEffect(()=>{
|
|
1389
2050
|
if (count.current === 0) {
|
|
1390
2051
|
count.current = 1;
|
|
1391
|
-
return ()
|
|
2052
|
+
return ()=>{
|
|
2053
|
+
//
|
|
1392
2054
|
};
|
|
1393
2055
|
}
|
|
1394
2056
|
return method();
|
|
2057
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1395
2058
|
}, deps);
|
|
1396
2059
|
};
|
|
1397
2060
|
|
|
1398
|
-
|
|
1399
|
-
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* get resize event with auto disconnect when layout destroy.
|
|
2075
|
+
* bring with debounce for event not too many
|
|
2076
|
+
* @param ref that ref for element
|
|
2077
|
+
* @param callback callback when element resize
|
|
2078
|
+
* @param performance options that improve performance
|
|
2079
|
+
*/ const useResizeObserver = (/** target element ref */ target, /** trigger when `ResizeObserver` emit */ callback, /** performance options, `mode`, `time`, `ignoreFireWhenObserve` */ performance = {})=>{
|
|
2080
|
+
const { mode = 'debounce', time: performanceTime = 200, ignoreFireWhenObserve, options } = performance;
|
|
1400
2081
|
const countRef = useRef(0);
|
|
1401
|
-
const cb = useEventCallback((...args)
|
|
2082
|
+
const cb = useEventCallback((...args)=>{
|
|
1402
2083
|
if (ignoreFireWhenObserve && countRef.current === 0) {
|
|
1403
2084
|
countRef.current = 1;
|
|
1404
2085
|
return;
|
|
@@ -1407,20 +2088,18 @@ const useResizeObserver = (target, callback, performance = {}) => {
|
|
|
1407
2088
|
});
|
|
1408
2089
|
const el = getRefElement(target);
|
|
1409
2090
|
const currentWindow = el ? ownerWindow(el) : window;
|
|
1410
|
-
const observerRef = useResultRef(()
|
|
2091
|
+
const observerRef = useResultRef(()=>{
|
|
1411
2092
|
let resizeCallback;
|
|
1412
2093
|
if (mode === 'debounce') {
|
|
1413
2094
|
resizeCallback = debounce(cb, performanceTime, options);
|
|
1414
|
-
}
|
|
1415
|
-
else if (mode === 'throttle') {
|
|
2095
|
+
} else if (mode === 'throttle') {
|
|
1416
2096
|
resizeCallback = throttle(cb, performanceTime, options);
|
|
1417
|
-
}
|
|
1418
|
-
else {
|
|
2097
|
+
} else {
|
|
1419
2098
|
resizeCallback = cb;
|
|
1420
2099
|
}
|
|
1421
2100
|
return new currentWindow.ResizeObserver(resizeCallback);
|
|
1422
2101
|
});
|
|
1423
|
-
useLayoutEffect(()
|
|
2102
|
+
useLayoutEffect(()=>{
|
|
1424
2103
|
if (!currentWindow?.ResizeObserver) {
|
|
1425
2104
|
return;
|
|
1426
2105
|
}
|
|
@@ -1430,110 +2109,193 @@ const useResizeObserver = (target, callback, performance = {}) => {
|
|
|
1430
2109
|
throw new Error('please check element exist before bind resize observer');
|
|
1431
2110
|
}
|
|
1432
2111
|
observer.observe(el);
|
|
1433
|
-
return ()
|
|
2112
|
+
return ()=>{
|
|
1434
2113
|
observer.disconnect();
|
|
1435
2114
|
};
|
|
1436
|
-
|
|
2115
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2116
|
+
}, [
|
|
2117
|
+
el
|
|
2118
|
+
]);
|
|
1437
2119
|
return observerRef.current;
|
|
1438
2120
|
};
|
|
1439
2121
|
|
|
1440
|
-
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* trigger when overflow state change
|
|
2129
|
+
*/ const useOverflow = (/** target element ref */ target, /** trigger when `ResizeObserver` emit */ cb, /**
|
|
2130
|
+
* performance options, `mode`, `time`, `ignoreFireWhenObserve`
|
|
2131
|
+
*
|
|
2132
|
+
* @default { mode: 'throttle' time: 200 }
|
|
2133
|
+
*/ performance = {
|
|
1441
2134
|
mode: 'throttle',
|
|
1442
|
-
time: 200
|
|
1443
|
-
})
|
|
2135
|
+
time: 200
|
|
2136
|
+
})=>{
|
|
2137
|
+
// * not have default value for that can trigger change first time.
|
|
1444
2138
|
const showRef = useRef();
|
|
1445
|
-
return useResizeObserver(target, ()
|
|
2139
|
+
return useResizeObserver(target, ()=>{
|
|
1446
2140
|
const element = getRefElement(target);
|
|
1447
|
-
if (!element)
|
|
1448
|
-
return;
|
|
2141
|
+
if (!element) return;
|
|
1449
2142
|
const { scrollWidth, clientWidth, scrollHeight, clientHeight } = element;
|
|
1450
2143
|
const show = scrollWidth > clientWidth || scrollHeight > clientHeight;
|
|
1451
|
-
if (showRef.current === show)
|
|
1452
|
-
|
|
2144
|
+
if (showRef.current === show) return;
|
|
2145
|
+
// only emit when state change
|
|
1453
2146
|
showRef.current = show;
|
|
1454
2147
|
cb(show, {
|
|
1455
2148
|
scrollWidth,
|
|
1456
2149
|
clientWidth,
|
|
1457
2150
|
scrollHeight,
|
|
1458
|
-
clientHeight
|
|
2151
|
+
clientHeight
|
|
1459
2152
|
});
|
|
1460
2153
|
}, performance);
|
|
1461
2154
|
};
|
|
1462
2155
|
|
|
1463
|
-
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Returns a stateful like state with Ref, and a function to update it current value.
|
|
2164
|
+
* can trigger update value without re-render
|
|
2165
|
+
*
|
|
2166
|
+
* @param value init value
|
|
2167
|
+
* @param customUpdate you can custom you update method, or just set your `forceUpdate` to reuse same instance
|
|
2168
|
+
* @returns an array with [ref, update]
|
|
2169
|
+
*
|
|
2170
|
+
* @example
|
|
2171
|
+
* ```ts
|
|
2172
|
+
* const [inputRef, updateInput] = useRefState(123);
|
|
2173
|
+
*
|
|
2174
|
+
* if(needReRender){
|
|
2175
|
+
* updateInput(456); // trigger re-render
|
|
2176
|
+
* } else {
|
|
2177
|
+
* updateInput(456, false); // not trigger re-render
|
|
2178
|
+
* }
|
|
2179
|
+
* ```
|
|
2180
|
+
*/ const useRefState = (value, customUpdate)=>{
|
|
2181
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
1464
2182
|
const forceUpdate = customUpdate ?? useForceUpdate();
|
|
1465
2183
|
const innerValueRef = useRef(value);
|
|
1466
|
-
|
|
2184
|
+
/**
|
|
2185
|
+
* update ref value, set isUpdate to `false` will not trigger re-render
|
|
2186
|
+
*/ const setInnerValue = useEventCallback((toValue, /**
|
|
2187
|
+
* is re-render
|
|
2188
|
+
* @default true
|
|
2189
|
+
*/ isUpdate = true)=>{
|
|
1467
2190
|
innerValueRef.current = toValue;
|
|
1468
2191
|
if (isUpdate) {
|
|
1469
2192
|
forceUpdate();
|
|
1470
2193
|
}
|
|
1471
2194
|
});
|
|
1472
|
-
return [
|
|
2195
|
+
return [
|
|
2196
|
+
innerValueRef,
|
|
2197
|
+
setInnerValue
|
|
2198
|
+
];
|
|
1473
2199
|
};
|
|
1474
2200
|
|
|
1475
|
-
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* get all matched breakpoints
|
|
2206
|
+
* @param defaultBreakpoint use defaultBreakpoint when Responsive init (will pass undefined)
|
|
2207
|
+
*/ const useResponsiveMatch = (defaultBreakpoint)=>{
|
|
1476
2208
|
const _breakpoint = useResponsiveContext();
|
|
1477
2209
|
const breakpoint = _breakpoint === undefined ? defaultBreakpoint : _breakpoint;
|
|
1478
|
-
if (breakpoint === undefined)
|
|
1479
|
-
return {};
|
|
2210
|
+
if (breakpoint === undefined) return {};
|
|
1480
2211
|
const matchDetail = getMatchDetail(breakpoint);
|
|
1481
2212
|
return matchDetail.matchMap;
|
|
1482
2213
|
};
|
|
1483
2214
|
|
|
1484
|
-
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* cancelable sleep method.
|
|
2223
|
+
*
|
|
2224
|
+
* - sleep: a promise sleep
|
|
2225
|
+
* - cancel: cancel that sleep event
|
|
2226
|
+
* - getSleeping: get current sleeping state
|
|
2227
|
+
*/ const useSleep = ()=>{
|
|
1485
2228
|
const timeoutIdRef = useRef();
|
|
1486
2229
|
const rejectRef = useRef();
|
|
1487
2230
|
const sleepingRef = useRef(false);
|
|
1488
|
-
const clearCurrTimeout = ()
|
|
2231
|
+
const clearCurrTimeout = ()=>{
|
|
1489
2232
|
const id = timeoutIdRef.current;
|
|
1490
2233
|
sleepingRef.current = false;
|
|
1491
|
-
if (id !== undefined)
|
|
1492
|
-
clearTimeout(id);
|
|
2234
|
+
if (id !== undefined) clearTimeout(id);
|
|
1493
2235
|
};
|
|
1494
|
-
const sleep = (time)
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const cancel = ()
|
|
2236
|
+
const sleep = (time)=>new Promise((resolve, reject)=>{
|
|
2237
|
+
clearCurrTimeout();
|
|
2238
|
+
sleepingRef.current = true;
|
|
2239
|
+
rejectRef.current = reject;
|
|
2240
|
+
timeoutIdRef.current = setTimeout(()=>{
|
|
2241
|
+
sleepingRef.current = false;
|
|
2242
|
+
resolve();
|
|
2243
|
+
}, time);
|
|
2244
|
+
});
|
|
2245
|
+
const cancel = ()=>{
|
|
1504
2246
|
clearCurrTimeout();
|
|
1505
2247
|
rejectRef.current?.();
|
|
1506
2248
|
};
|
|
1507
|
-
useEffect(()
|
|
1508
|
-
return ()
|
|
2249
|
+
useEffect(()=>{
|
|
2250
|
+
return ()=>{
|
|
1509
2251
|
clearCurrTimeout();
|
|
1510
2252
|
};
|
|
1511
2253
|
}, []);
|
|
1512
2254
|
return {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
2255
|
+
/**
|
|
2256
|
+
* sleep method, that will be cancelable with `cancel`,
|
|
2257
|
+
* and when trigger many time that will always clear previous sleep
|
|
2258
|
+
*/ sleep,
|
|
2259
|
+
/**
|
|
2260
|
+
* cancel current sleep timer
|
|
2261
|
+
*/ cancel,
|
|
2262
|
+
/**
|
|
2263
|
+
* return is current is sleeping
|
|
2264
|
+
*/ getSleeping: ()=>sleepingRef.current
|
|
1516
2265
|
};
|
|
1517
2266
|
};
|
|
1518
2267
|
|
|
1519
|
-
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
/* oxlint-disable no-await-in-loop */
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* retry times when exec callback fail
|
|
2274
|
+
*
|
|
2275
|
+
* * `retry`: that will auto add `times` end of your call back
|
|
2276
|
+
* * `cancel`: cancel that retry event, that only cancel inner interval promise, **if you need cancel your `cb` promise, you should handle by yourself.**
|
|
2277
|
+
*/ const useRetry = (/**
|
|
2278
|
+
* exec callback, when you want to end that retry, return `true` with your callback
|
|
2279
|
+
*/ cb, /** retry options */ options)=>{
|
|
1520
2280
|
const { sleep, cancel: cancelSleep } = useSleep();
|
|
1521
2281
|
const isCancelRef = useRef(true);
|
|
1522
|
-
const complete = ()
|
|
2282
|
+
const complete = ()=>{
|
|
1523
2283
|
isCancelRef.current = true;
|
|
1524
2284
|
options?.onComplete?.();
|
|
1525
2285
|
};
|
|
1526
|
-
const cancel = ()
|
|
2286
|
+
const cancel = ()=>{
|
|
1527
2287
|
if (!isCancelRef.current) {
|
|
2288
|
+
// * when outside cancel
|
|
1528
2289
|
options?.onCancel?.();
|
|
1529
2290
|
complete();
|
|
1530
2291
|
}
|
|
1531
2292
|
cancelSleep();
|
|
1532
2293
|
};
|
|
1533
|
-
const retry = async (...args)
|
|
2294
|
+
const retry = async (...args)=>{
|
|
1534
2295
|
cancel();
|
|
1535
2296
|
isCancelRef.current = false;
|
|
1536
2297
|
options?.onStart?.();
|
|
2298
|
+
// * when exec success, just return directly.
|
|
1537
2299
|
if (await cb(...args)) {
|
|
1538
2300
|
if (!isCancelRef.current) {
|
|
1539
2301
|
options?.onSuccess?.();
|
|
@@ -1541,17 +2303,15 @@ const useRetry = (cb, options) => {
|
|
|
1541
2303
|
}
|
|
1542
2304
|
return;
|
|
1543
2305
|
}
|
|
1544
|
-
if (!options)
|
|
1545
|
-
return;
|
|
2306
|
+
if (!options) return;
|
|
1546
2307
|
const { retryTimes, intervalTime, onSuccess } = options;
|
|
1547
|
-
for
|
|
2308
|
+
for(let i = 0; i < retryTimes; i++){
|
|
1548
2309
|
if (isCancelRef.current) {
|
|
1549
2310
|
return;
|
|
1550
2311
|
}
|
|
1551
2312
|
try {
|
|
1552
2313
|
await sleep(intervalTime);
|
|
1553
|
-
}
|
|
1554
|
-
catch (error) {
|
|
2314
|
+
} catch (error) {
|
|
1555
2315
|
return;
|
|
1556
2316
|
}
|
|
1557
2317
|
if (await cb(...args, i)) {
|
|
@@ -1565,24 +2325,34 @@ const useRetry = (cb, options) => {
|
|
|
1565
2325
|
complete();
|
|
1566
2326
|
};
|
|
1567
2327
|
return {
|
|
1568
|
-
|
|
1569
|
-
|
|
2328
|
+
/**
|
|
2329
|
+
* retry method, that will be cancelable with `cancel`,
|
|
2330
|
+
* and when trigger many time that will always clear previous retry
|
|
2331
|
+
*/ retry,
|
|
2332
|
+
/**
|
|
2333
|
+
* cancel current retry
|
|
2334
|
+
*/ cancel
|
|
1570
2335
|
};
|
|
1571
2336
|
};
|
|
1572
2337
|
|
|
1573
|
-
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* @param onTabOut Callback fired when a "tab out" event is detected.
|
|
2344
|
+
*/ const useTabOut = (onTabOut)=>{
|
|
1574
2345
|
const lastKeyDownIsTabRef = useRef(false);
|
|
1575
|
-
const handleKeyDown = (e)
|
|
2346
|
+
const handleKeyDown = (e)=>{
|
|
1576
2347
|
if (e.key === 'Tab') {
|
|
1577
2348
|
lastKeyDownIsTabRef.current = true;
|
|
1578
2349
|
}
|
|
1579
2350
|
};
|
|
1580
|
-
const handleKeyUp = (e)
|
|
2351
|
+
const handleKeyUp = (e)=>{
|
|
1581
2352
|
lastKeyDownIsTabRef.current = false;
|
|
1582
2353
|
};
|
|
1583
|
-
const handleBlur = (e)
|
|
1584
|
-
if (!e.currentTarget.contains(e.relatedTarget) &&
|
|
1585
|
-
lastKeyDownIsTabRef.current) {
|
|
2354
|
+
const handleBlur = (e)=>{
|
|
2355
|
+
if (!e.currentTarget.contains(e.relatedTarget) && lastKeyDownIsTabRef.current) {
|
|
1586
2356
|
onTabOut(e);
|
|
1587
2357
|
}
|
|
1588
2358
|
};
|
|
@@ -1590,63 +2360,153 @@ const useTabOut = (onTabOut) => {
|
|
|
1590
2360
|
tabOutHandler: {
|
|
1591
2361
|
onKeyDown: handleKeyDown,
|
|
1592
2362
|
onKeyUp: handleKeyUp,
|
|
1593
|
-
onBlur: handleBlur
|
|
1594
|
-
}
|
|
2363
|
+
onBlur: handleBlur
|
|
2364
|
+
}
|
|
1595
2365
|
};
|
|
1596
2366
|
};
|
|
1597
2367
|
|
|
1598
|
-
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* provide a throttle method for throttle method
|
|
2377
|
+
* @param fn method for throttle
|
|
2378
|
+
* @param throttleTime throttle timeout value, default is `200`ms
|
|
2379
|
+
* @param options throttle setting, default is `{trailing: true, leading: true}`, detail see lodash
|
|
2380
|
+
*/ // oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2381
|
+
const useThrottle = (fn, throttleTime = 200, options)=>{
|
|
1599
2382
|
const memoFn = useEventCallback(fn);
|
|
2383
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1600
2384
|
const throttleFn = useCallback(throttle(memoFn, throttleTime, options), []);
|
|
1601
|
-
|
|
2385
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2386
|
+
useEffect(()=>()=>throttleFn.cancel(), []);
|
|
1602
2387
|
return throttleFn;
|
|
1603
2388
|
};
|
|
1604
2389
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2390
|
+
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* Internal hook to detect if the component is running in a test runner environment
|
|
2396
|
+
* Used by Storybook stories to disable network requests and animations during testing
|
|
2397
|
+
* @returns {boolean} true if running in test runner (headless browser), false otherwise
|
|
2398
|
+
* @internal
|
|
2399
|
+
*/ const useTestRunnerDetection = ()=>{
|
|
2400
|
+
return useMemo(()=>{
|
|
2401
|
+
return typeof window !== 'undefined' && (window.navigator?.webdriver || window.chrome?.webdriver);
|
|
1609
2402
|
}, []);
|
|
1610
2403
|
};
|
|
1611
2404
|
|
|
2405
|
+
|
|
2406
|
+
|
|
1612
2407
|
function useTimeout(callback, delay) {
|
|
1613
2408
|
const savedCallback = useRef(callback);
|
|
1614
2409
|
savedCallback.current = callback;
|
|
1615
|
-
|
|
2410
|
+
// Set up the timeout.
|
|
2411
|
+
useEffect(()=>{
|
|
2412
|
+
// Don't schedule if no delay is specified.
|
|
2413
|
+
// Note: 0 is a valid value for delay.
|
|
1616
2414
|
if (!delay && delay !== 0) {
|
|
1617
2415
|
return;
|
|
1618
2416
|
}
|
|
1619
|
-
const id = setTimeout(()
|
|
1620
|
-
return ()
|
|
1621
|
-
}, [
|
|
2417
|
+
const id = setTimeout(()=>savedCallback.current(), delay);
|
|
2418
|
+
return ()=>clearTimeout(id);
|
|
2419
|
+
}, [
|
|
2420
|
+
delay
|
|
2421
|
+
]);
|
|
1622
2422
|
}
|
|
2423
|
+
/* export default */ var useTimeout_useTimeout = ((/* unused pure expression or super */ null && (useTimeout)));
|
|
2424
|
+
|
|
1623
2425
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
/**
|
|
2430
|
+
* Check if Font Loading API is available in the current environment
|
|
2431
|
+
* @returns boolean indicating if document.fonts.ready is available
|
|
2432
|
+
*/ const isFontLoadingAPIAvailable = ()=>{
|
|
2433
|
+
return typeof document !== 'undefined' && document.fonts && typeof document.fonts.ready === 'object' && typeof document.fonts.ready.then === 'function';
|
|
1629
2434
|
};
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
2435
|
+
/**
|
|
2436
|
+
* Hook to detect when fonts are loaded
|
|
2437
|
+
*
|
|
2438
|
+
* This hook provides graceful degradation:
|
|
2439
|
+
* - When Font Loading API is not available (older browsers, SSR, test environments),
|
|
2440
|
+
* it assumes fonts are loaded to prevent blocking UI
|
|
2441
|
+
* - When disabled, it immediately returns true without listening to font events
|
|
2442
|
+
* - When enabled and API is available, it listens for font loading completion
|
|
2443
|
+
*
|
|
2444
|
+
* @param enabled - Whether to listen for font loading events (default: true)
|
|
2445
|
+
* @returns boolean indicating whether fonts are loaded
|
|
2446
|
+
*/ const useFontLoaded = (enabled = true)=>{
|
|
2447
|
+
const [loaded, setLoaded] = useState(()=>!enabled || !isFontLoadingAPIAvailable());
|
|
2448
|
+
useEffect(()=>{
|
|
1633
2449
|
if (!enabled || !isFontLoadingAPIAvailable()) {
|
|
1634
2450
|
return;
|
|
1635
2451
|
}
|
|
1636
2452
|
let cancelled = false;
|
|
1637
|
-
document.fonts.ready.then(()
|
|
2453
|
+
document.fonts.ready.then(()=>{
|
|
1638
2454
|
if (!cancelled) {
|
|
1639
2455
|
setLoaded(true);
|
|
1640
2456
|
}
|
|
1641
2457
|
});
|
|
1642
|
-
return ()
|
|
2458
|
+
return ()=>{
|
|
1643
2459
|
cancelled = true;
|
|
1644
2460
|
};
|
|
1645
|
-
}, [
|
|
2461
|
+
}, [
|
|
2462
|
+
enabled
|
|
2463
|
+
]);
|
|
1646
2464
|
return loaded;
|
|
1647
2465
|
};
|
|
1648
2466
|
|
|
1649
|
-
|
|
2467
|
+
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* bodyScrollLock.ts
|
|
2472
|
+
*
|
|
2473
|
+
* Standalone scroll-lock utility with no external runtime dependencies.
|
|
2474
|
+
*
|
|
2475
|
+
* Features
|
|
2476
|
+
* --------
|
|
2477
|
+
* - Pure, caller-managed state: every function accepts an explicit `WindowState` and
|
|
2478
|
+
* `win`. There are no module-level globals — state lifetime and storage are the
|
|
2479
|
+
* caller's responsibility (see `useBodyScrollLock.ts` for the per-window storage).
|
|
2480
|
+
* - Ordered lock entries: every lock call registers an entry in insertion
|
|
2481
|
+
* order. Body lock stays enabled while any entry is active, while all
|
|
2482
|
+
* entries (active or inactive) may continue to contribute bounded inner
|
|
2483
|
+
* scrolling boundaries.
|
|
2484
|
+
* - iOS (touchmove-based locking):
|
|
2485
|
+
* • Active entries decide whether body scroll should currently be locked.
|
|
2486
|
+
* • All entries may contribute element boundaries or allowTouchMove-based
|
|
2487
|
+
* inner-scrolling candidates.
|
|
2488
|
+
* • Matching targets run bounded DOM-walk checks, allowing inner
|
|
2489
|
+
* scrollables to move until they hit their boundary.
|
|
2490
|
+
* • Pinch-zoom allowed — skips locking when touches.length > 1 OR when
|
|
2491
|
+
* ctrlKey is set (covers trackpad-pinch on hybrid devices where the OS
|
|
2492
|
+
* synthesises a ctrl+touchmove).
|
|
2493
|
+
* • cancelable guard — never calls preventDefault on non-cancelable events.
|
|
2494
|
+
* - Non-iOS: sets `body { overflow: hidden }` and optionally reserves the
|
|
2495
|
+
* scrollbar gap to prevent layout shift.
|
|
2496
|
+
*
|
|
2497
|
+
* API
|
|
2498
|
+
* ---
|
|
2499
|
+
* lock(state, win, targetElement?, options?) — acquire or reactivate a lock entry
|
|
2500
|
+
* unlock(state, win, targetElement?, options?) — remove a lock entry
|
|
2501
|
+
* pause(state, win, targetElement?, options?) — temporarily disable an entry in place
|
|
2502
|
+
* clearAllLocks(state, win) — internal recovery reset for one window
|
|
2503
|
+
*
|
|
2504
|
+
* `targetElement` should be the container element that defines the lock
|
|
2505
|
+
* boundary for an element-scoped lock. It scopes the iOS touch boundary:
|
|
2506
|
+
* scrollable descendants of that container may keep scrolling, while touches
|
|
2507
|
+
* outside it are prevented. Without `targetElement`, the lock is anonymous and
|
|
2508
|
+
* iOS treats it as a pure global body lock.
|
|
2509
|
+
*/ function createInitialState() {
|
|
1650
2510
|
return {
|
|
1651
2511
|
entries: [],
|
|
1652
2512
|
savedBodyOverflow: '',
|
|
@@ -1656,48 +2516,47 @@ function createInitialState() {
|
|
|
1656
2516
|
touchStartY: 0,
|
|
1657
2517
|
touchStartX: 0,
|
|
1658
2518
|
nonIOSLocked: false,
|
|
1659
|
-
nonIOSReservedScrollBarGap: false
|
|
2519
|
+
nonIOSReservedScrollBarGap: false
|
|
1660
2520
|
};
|
|
1661
2521
|
}
|
|
1662
2522
|
function isIOS(win) {
|
|
1663
2523
|
const { platform, maxTouchPoints, userAgent } = win.navigator;
|
|
1664
|
-
return
|
|
1665
|
-
(platform === 'MacIntel' && maxTouchPoints > 1) ||
|
|
1666
|
-
/iPad|iPhone|iPod/.test(userAgent));
|
|
2524
|
+
return /iP(ad|hone|od)/.test(platform) || platform === 'MacIntel' && maxTouchPoints > 1 || /iPad|iPhone|iPod/.test(userAgent);
|
|
1667
2525
|
}
|
|
1668
2526
|
function isScrollableElement(el, win) {
|
|
1669
2527
|
const { overflow, overflowX, overflowY } = win.getComputedStyle(el);
|
|
1670
|
-
if (!/(auto|scroll|overlay)/.test(overflow + overflowX + overflowY))
|
|
1671
|
-
|
|
1672
|
-
return (el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1);
|
|
2528
|
+
if (!/(auto|scroll|overlay)/.test(overflow + overflowX + overflowY)) return false;
|
|
2529
|
+
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
|
1673
2530
|
}
|
|
1674
2531
|
function isScrollBoundaryHit(scrollable, dx, dy) {
|
|
1675
2532
|
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
1676
|
-
if (dy > 0 && scrollable.scrollTop <= 0)
|
|
1677
|
-
|
|
1678
|
-
return (dy < 0 &&
|
|
1679
|
-
scrollable.scrollTop + scrollable.clientHeight >=
|
|
1680
|
-
scrollable.scrollHeight - 1);
|
|
2533
|
+
if (dy > 0 && scrollable.scrollTop <= 0) return true;
|
|
2534
|
+
return dy < 0 && scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 1;
|
|
1681
2535
|
}
|
|
1682
|
-
if (dx > 0 && scrollable.scrollLeft <= 0)
|
|
1683
|
-
|
|
1684
|
-
return (dx < 0 &&
|
|
1685
|
-
scrollable.scrollLeft + scrollable.clientWidth >= scrollable.scrollWidth - 1);
|
|
2536
|
+
if (dx > 0 && scrollable.scrollLeft <= 0) return true;
|
|
2537
|
+
return dx < 0 && scrollable.scrollLeft + scrollable.clientWidth >= scrollable.scrollWidth - 1;
|
|
1686
2538
|
}
|
|
1687
|
-
|
|
2539
|
+
/**
|
|
2540
|
+
* Walk up the DOM from `el` to find the nearest scrollable ancestor.
|
|
2541
|
+
*
|
|
2542
|
+
* Always stops before `<body>` and `<html>` so body scroll is never returned.
|
|
2543
|
+
* When `boundary` is provided (element lock), the walk also stops at that
|
|
2544
|
+
* container element, preventing it from finding scrollable ancestors outside
|
|
2545
|
+
* the active lock boundary. When `boundary` is null (anonymous lock), the walk
|
|
2546
|
+
* has no element boundary of its own and may find scrollable elements higher in
|
|
2547
|
+
* the tree.
|
|
2548
|
+
*/ function findNearestScrollableAncestorWithinBoundary(el, win, boundary) {
|
|
1688
2549
|
const { body, documentElement } = win.document;
|
|
1689
2550
|
let cur = el;
|
|
1690
|
-
while
|
|
1691
|
-
if (isScrollableElement(cur, win))
|
|
1692
|
-
|
|
1693
|
-
if (cur === boundary)
|
|
1694
|
-
break;
|
|
2551
|
+
while(cur && cur !== body && cur !== documentElement){
|
|
2552
|
+
if (isScrollableElement(cur, win)) return cur;
|
|
2553
|
+
if (cur === boundary) break;
|
|
1695
2554
|
cur = cur.parentElement;
|
|
1696
2555
|
}
|
|
1697
2556
|
return null;
|
|
1698
2557
|
}
|
|
1699
2558
|
function getActiveEntries(state) {
|
|
1700
|
-
return state.entries.filter((entry)
|
|
2559
|
+
return state.entries.filter((entry)=>entry.active);
|
|
1701
2560
|
}
|
|
1702
2561
|
function getBoundaryForEntry(topEntry) {
|
|
1703
2562
|
return topEntry.kind === 'element' ? topEntry.element : null;
|
|
@@ -1716,10 +2575,9 @@ function getEntryScopedScrollable(topEntry, target, win, { allowAnonymous = fals
|
|
|
1716
2575
|
return getScrollableWithinBoundary(target, win, boundary);
|
|
1717
2576
|
}
|
|
1718
2577
|
function getAllowTouchMoveScrollable(entry, target, win) {
|
|
1719
|
-
if (entry.allowTouchMove?.(target) !== true)
|
|
1720
|
-
return null;
|
|
2578
|
+
if (entry.allowTouchMove?.(target) !== true) return null;
|
|
1721
2579
|
return getEntryScopedScrollable(entry, target, win, {
|
|
1722
|
-
allowAnonymous: true
|
|
2580
|
+
allowAnonymous: true
|
|
1723
2581
|
});
|
|
1724
2582
|
}
|
|
1725
2583
|
function canEntryAllowInnerScroll(entry, target, win, dx, dy) {
|
|
@@ -1735,10 +2593,10 @@ function canEntryAllowInnerScroll(entry, target, win, dx, dy) {
|
|
|
1735
2593
|
}
|
|
1736
2594
|
function findEntryIndex(state, targetElement, entryKey) {
|
|
1737
2595
|
if (entryKey) {
|
|
1738
|
-
return state.entries.findIndex((entry)
|
|
2596
|
+
return state.entries.findIndex((entry)=>entry.key === entryKey);
|
|
1739
2597
|
}
|
|
1740
2598
|
if (targetElement) {
|
|
1741
|
-
for
|
|
2599
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1742
2600
|
const entry = state.entries[i];
|
|
1743
2601
|
if (entry.kind === 'element' && entry.element === targetElement) {
|
|
1744
2602
|
return i;
|
|
@@ -1746,7 +2604,7 @@ function findEntryIndex(state, targetElement, entryKey) {
|
|
|
1746
2604
|
}
|
|
1747
2605
|
return -1;
|
|
1748
2606
|
}
|
|
1749
|
-
for
|
|
2607
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1750
2608
|
if (state.entries[i].kind === 'anonymous') {
|
|
1751
2609
|
return i;
|
|
1752
2610
|
}
|
|
@@ -1770,32 +2628,26 @@ function ensureEntry(state, targetElement, options = {}) {
|
|
|
1770
2628
|
element: targetElement ?? null,
|
|
1771
2629
|
active: true,
|
|
1772
2630
|
reserveScrollBarGap: Boolean(options.reserveScrollBarGap),
|
|
1773
|
-
allowTouchMove: options.allowTouchMove
|
|
2631
|
+
allowTouchMove: options.allowTouchMove
|
|
1774
2632
|
};
|
|
1775
2633
|
state.entries.push(entry);
|
|
1776
2634
|
return entry;
|
|
1777
2635
|
}
|
|
1778
2636
|
function shouldPreventTouchMove(e, state, win) {
|
|
1779
|
-
if (e.touches.length > 1)
|
|
1780
|
-
|
|
1781
|
-
if (e.
|
|
1782
|
-
return false;
|
|
1783
|
-
if (!e.cancelable)
|
|
1784
|
-
return false;
|
|
2637
|
+
if (e.touches.length > 1) return false;
|
|
2638
|
+
if (e.ctrlKey) return false;
|
|
2639
|
+
if (!e.cancelable) return false;
|
|
1785
2640
|
const touch = e.touches[0];
|
|
1786
|
-
if (!touch)
|
|
1787
|
-
return false;
|
|
2641
|
+
if (!touch) return false;
|
|
1788
2642
|
const dy = touch.clientY - state.touchStartY;
|
|
1789
2643
|
const dx = touch.clientX - state.touchStartX;
|
|
1790
|
-
if (dy === 0 && dx === 0)
|
|
1791
|
-
|
|
1792
|
-
if (getActiveEntries(state).length === 0)
|
|
1793
|
-
return false;
|
|
2644
|
+
if (dy === 0 && dx === 0) return false;
|
|
2645
|
+
if (getActiveEntries(state).length === 0) return false;
|
|
1794
2646
|
const target = e.target;
|
|
1795
2647
|
if (!(target instanceof win.Element)) {
|
|
1796
2648
|
return true;
|
|
1797
2649
|
}
|
|
1798
|
-
for
|
|
2650
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1799
2651
|
if (canEntryAllowInnerScroll(state.entries[i], target, win, dx, dy)) {
|
|
1800
2652
|
return false;
|
|
1801
2653
|
}
|
|
@@ -1804,22 +2656,28 @@ function shouldPreventTouchMove(e, state, win) {
|
|
|
1804
2656
|
}
|
|
1805
2657
|
function lockIOS(state, win) {
|
|
1806
2658
|
const doc = win.document;
|
|
1807
|
-
|
|
2659
|
+
// touchStartY/X are reset to 0 when listeners are first attached. If a touch
|
|
2660
|
+
// gesture is already in progress when the lock activates, the first touchmove
|
|
2661
|
+
// will compute dx/dy relative to (0,0) rather than the actual start point.
|
|
2662
|
+
// There is no Web API to read current touch coordinates without an event, so
|
|
2663
|
+
// this is an accepted limitation: one touchmove event may fire incorrect
|
|
2664
|
+
// boundary checks until the next touchstart resets the baseline.
|
|
2665
|
+
state.docTouchStartHandler = (e)=>{
|
|
1808
2666
|
if (e.touches.length > 0) {
|
|
1809
2667
|
state.touchStartY = e.touches[0].clientY;
|
|
1810
2668
|
state.touchStartX = e.touches[0].clientX;
|
|
1811
2669
|
}
|
|
1812
2670
|
};
|
|
1813
|
-
state.docTouchMoveHandler = (e)
|
|
2671
|
+
state.docTouchMoveHandler = (e)=>{
|
|
1814
2672
|
if (shouldPreventTouchMove(e, state, win)) {
|
|
1815
2673
|
e.preventDefault();
|
|
1816
2674
|
}
|
|
1817
2675
|
};
|
|
1818
2676
|
doc.addEventListener('touchstart', state.docTouchStartHandler, {
|
|
1819
|
-
passive: true
|
|
2677
|
+
passive: true
|
|
1820
2678
|
});
|
|
1821
2679
|
doc.addEventListener('touchmove', state.docTouchMoveHandler, {
|
|
1822
|
-
passive: false
|
|
2680
|
+
passive: false
|
|
1823
2681
|
});
|
|
1824
2682
|
}
|
|
1825
2683
|
function unlockIOS(state, win) {
|
|
@@ -1850,28 +2708,30 @@ function unlockNonIOS(state, win) {
|
|
|
1850
2708
|
if (state.savedBodyPaddingRight !== null) {
|
|
1851
2709
|
if (state.savedBodyPaddingRight === '') {
|
|
1852
2710
|
body.style.removeProperty('padding-right');
|
|
1853
|
-
}
|
|
1854
|
-
else {
|
|
2711
|
+
} else {
|
|
1855
2712
|
body.style.paddingRight = state.savedBodyPaddingRight;
|
|
1856
2713
|
}
|
|
1857
2714
|
state.savedBodyPaddingRight = null;
|
|
1858
2715
|
}
|
|
1859
2716
|
}
|
|
2717
|
+
// Non-iOS keeps body overflow locked while any entry remains active.
|
|
1860
2718
|
function bodyLockDesired(state) {
|
|
1861
2719
|
return getActiveEntries(state).length > 0;
|
|
1862
2720
|
}
|
|
2721
|
+
// Scrollbar-gap compensation is aggregated across active entries so stacked
|
|
2722
|
+
// locks do not add/remove body padding while the window remains locked.
|
|
1863
2723
|
function shouldReserveScrollBarGap(state) {
|
|
1864
|
-
return state.entries.some((entry)
|
|
2724
|
+
return state.entries.some((entry)=>entry.active && entry.reserveScrollBarGap);
|
|
1865
2725
|
}
|
|
1866
2726
|
function syncIOS(state, win) {
|
|
2727
|
+
// Listeners stay attached while any entry exists so disabled entries can
|
|
2728
|
+
// continue contributing inner-scroll boundaries without repeated churn.
|
|
1867
2729
|
const desired = state.entries.length > 0;
|
|
1868
2730
|
const attached = state.docTouchMoveHandler !== null;
|
|
1869
|
-
if (desired === attached)
|
|
1870
|
-
return;
|
|
2731
|
+
if (desired === attached) return;
|
|
1871
2732
|
if (desired) {
|
|
1872
2733
|
lockIOS(state, win);
|
|
1873
|
-
}
|
|
1874
|
-
else {
|
|
2734
|
+
} else {
|
|
1875
2735
|
unlockIOS(state, win);
|
|
1876
2736
|
}
|
|
1877
2737
|
}
|
|
@@ -1879,8 +2739,7 @@ function syncNonIOS(state, win) {
|
|
|
1879
2739
|
const desired = bodyLockDesired(state);
|
|
1880
2740
|
const reserveScrollBarGap = shouldReserveScrollBarGap(state);
|
|
1881
2741
|
if (!desired) {
|
|
1882
|
-
if (!state.nonIOSLocked)
|
|
1883
|
-
return;
|
|
2742
|
+
if (!state.nonIOSLocked) return;
|
|
1884
2743
|
unlockNonIOS(state, win);
|
|
1885
2744
|
state.nonIOSLocked = false;
|
|
1886
2745
|
state.nonIOSReservedScrollBarGap = false;
|
|
@@ -1888,23 +2747,23 @@ function syncNonIOS(state, win) {
|
|
|
1888
2747
|
}
|
|
1889
2748
|
if (!state.nonIOSLocked) {
|
|
1890
2749
|
lockNonIOS(state, win, {
|
|
1891
|
-
reserveScrollBarGap
|
|
2750
|
+
reserveScrollBarGap
|
|
1892
2751
|
});
|
|
1893
2752
|
state.nonIOSLocked = true;
|
|
1894
2753
|
state.nonIOSReservedScrollBarGap = reserveScrollBarGap;
|
|
1895
2754
|
return;
|
|
1896
2755
|
}
|
|
1897
|
-
if (reserveScrollBarGap === state.nonIOSReservedScrollBarGap)
|
|
1898
|
-
return;
|
|
2756
|
+
if (reserveScrollBarGap === state.nonIOSReservedScrollBarGap) return;
|
|
1899
2757
|
unlockNonIOS(state, win);
|
|
1900
|
-
lockNonIOS(state, win, {
|
|
2758
|
+
lockNonIOS(state, win, {
|
|
2759
|
+
reserveScrollBarGap
|
|
2760
|
+
});
|
|
1901
2761
|
state.nonIOSReservedScrollBarGap = reserveScrollBarGap;
|
|
1902
2762
|
}
|
|
1903
2763
|
function syncScrollLock(state, win) {
|
|
1904
2764
|
if (isIOS(win)) {
|
|
1905
2765
|
syncIOS(state, win);
|
|
1906
|
-
}
|
|
1907
|
-
else {
|
|
2766
|
+
} else {
|
|
1908
2767
|
syncNonIOS(state, win);
|
|
1909
2768
|
}
|
|
1910
2769
|
}
|
|
@@ -1913,23 +2772,50 @@ function activateEntry(state, win, targetElement, options = {}) {
|
|
|
1913
2772
|
entry.active = true;
|
|
1914
2773
|
syncScrollLock(state, win);
|
|
1915
2774
|
}
|
|
1916
|
-
|
|
2775
|
+
/**
|
|
2776
|
+
* Acquire or re-enable a body-scroll lock entry. Calling on a paused entry
|
|
2777
|
+
* re-activates it without changing its position in the stack.
|
|
2778
|
+
*/ function lock(state, win, targetElement, options = {}) {
|
|
1917
2779
|
activateEntry(state, win, targetElement, options);
|
|
1918
2780
|
}
|
|
1919
|
-
|
|
2781
|
+
/**
|
|
2782
|
+
* Release a body-scroll lock entry.
|
|
2783
|
+
*
|
|
2784
|
+
* When `options.entryKey` is provided, it takes precedence and removes that
|
|
2785
|
+
* exact entry.
|
|
2786
|
+
* `unlock()` without a target only removes the most recent anonymous entry.
|
|
2787
|
+
* `unlock(targetElement)` removes the most recent matching element entry.
|
|
2788
|
+
*/ function unlock(state, win, targetElement, options = {}) {
|
|
1920
2789
|
const index = findEntryIndex(state, targetElement, options.entryKey);
|
|
1921
|
-
if (index < 0)
|
|
1922
|
-
return;
|
|
2790
|
+
if (index < 0) return;
|
|
1923
2791
|
state.entries.splice(index, 1);
|
|
1924
2792
|
syncScrollLock(state, win);
|
|
1925
2793
|
}
|
|
1926
|
-
|
|
2794
|
+
/**
|
|
2795
|
+
* Temporarily disable a lock entry without changing its position in the stack.
|
|
2796
|
+
*/ function bodyScrollLock_pause(state, win, targetElement, options = {}) {
|
|
1927
2797
|
const index = findEntryIndex(state, targetElement, options.entryKey);
|
|
1928
|
-
if (index < 0 || state.entries[index].active === false)
|
|
1929
|
-
return;
|
|
2798
|
+
if (index < 0 || state.entries[index].active === false) return;
|
|
1930
2799
|
state.entries[index].active = false;
|
|
1931
2800
|
syncScrollLock(state, win);
|
|
1932
2801
|
}
|
|
2802
|
+
/**
|
|
2803
|
+
* Internal recovery helper: forcibly clear all lock entries and restore the
|
|
2804
|
+
* window to an unlocked state.
|
|
2805
|
+
*
|
|
2806
|
+
* This bypasses normal per-entry lock/pause/unlock lifecycles and is intended
|
|
2807
|
+
* for testing or emergency recovery only.
|
|
2808
|
+
*/ function clearAllLocks(state, win) {
|
|
2809
|
+
state.entries = [];
|
|
2810
|
+
if (state.docTouchStartHandler || state.docTouchMoveHandler) {
|
|
2811
|
+
unlockIOS(state, win);
|
|
2812
|
+
}
|
|
2813
|
+
if (state.nonIOSLocked || state.savedBodyOverflow !== '' || state.savedBodyPaddingRight !== null) {
|
|
2814
|
+
unlockNonIOS(state, win);
|
|
2815
|
+
}
|
|
2816
|
+
Object.assign(state, createInitialState());
|
|
2817
|
+
}
|
|
2818
|
+
|
|
1933
2819
|
|
|
1934
2820
|
const SCROLL_LOCK_KEY = '__SUI_SCROLL_LOCK__';
|
|
1935
2821
|
function getStoredScrollLockState(win) {
|
|
@@ -1940,66 +2826,120 @@ function setStoredScrollLockState(win, state) {
|
|
|
1940
2826
|
global[SCROLL_LOCK_KEY] = state;
|
|
1941
2827
|
return state;
|
|
1942
2828
|
}
|
|
1943
|
-
|
|
2829
|
+
/**
|
|
2830
|
+
* Get (or lazily create) the scroll-lock state for a given window.
|
|
2831
|
+
* State is stored on the window object under a unique key so it is
|
|
2832
|
+
* automatically isolated per window.
|
|
2833
|
+
*/ function getOrCreateScrollLockState(win) {
|
|
1944
2834
|
const state = getStoredScrollLockState(win);
|
|
1945
2835
|
if (!state) {
|
|
1946
2836
|
return setStoredScrollLockState(win, createInitialState());
|
|
1947
2837
|
}
|
|
1948
2838
|
return state;
|
|
1949
2839
|
}
|
|
2840
|
+
function deleteStoredScrollLockState(win) {
|
|
2841
|
+
delete win[SCROLL_LOCK_KEY];
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
|
|
2847
|
+
|
|
1950
2848
|
|
|
1951
2849
|
function applyLockState(state, win, targetElement, options, disabled) {
|
|
1952
2850
|
lock(state, win, targetElement, options);
|
|
1953
2851
|
if (disabled) {
|
|
1954
|
-
|
|
2852
|
+
bodyScrollLock_pause(state, win, targetElement, options);
|
|
1955
2853
|
}
|
|
1956
2854
|
}
|
|
1957
2855
|
function useResolvedElement(target) {
|
|
1958
|
-
const [resolvedElement, setResolvedElement] = useState(()
|
|
2856
|
+
const [resolvedElement, setResolvedElement] = useState(()=>getRefElement(target) ?? null);
|
|
1959
2857
|
const targetRef = useRef(target);
|
|
1960
2858
|
targetRef.current = target;
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2859
|
+
// Re-evaluate after each render so a stable ref object can still refresh the
|
|
2860
|
+
// resolved element when ref.current appears or changes after commit.
|
|
2861
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2862
|
+
useEffect(()=>{
|
|
2863
|
+
const nextResolvedElement = getRefElement(targetRef.current) ?? null;
|
|
2864
|
+
setResolvedElement((current)=>current === nextResolvedElement ? current : nextResolvedElement);
|
|
1965
2865
|
});
|
|
1966
2866
|
return resolvedElement;
|
|
1967
2867
|
}
|
|
1968
|
-
|
|
2868
|
+
/**
|
|
2869
|
+
* Lock body scroll while allowing bounded inner scrolling within an optional
|
|
2870
|
+
* target container.
|
|
2871
|
+
*
|
|
2872
|
+
* `targetElement` may be a concrete `HTMLElement` or a React `RefObject`.
|
|
2873
|
+
* When wiring this hook to a React overlay container, prefer passing a
|
|
2874
|
+
* concrete element from a callback ref / state bridge so the lock updates as
|
|
2875
|
+
* soon as the DOM node becomes available.
|
|
2876
|
+
*
|
|
2877
|
+
* @example
|
|
2878
|
+
* ```tsx
|
|
2879
|
+
* const [targetElement, setTargetElement] = useState<HTMLDivElement | null>(null);
|
|
2880
|
+
*
|
|
2881
|
+
* useBodyScrollLock(open, targetElement);
|
|
2882
|
+
*
|
|
2883
|
+
* return <div ref={setTargetElement}>...</div>;
|
|
2884
|
+
* ```
|
|
2885
|
+
*
|
|
2886
|
+
* @example
|
|
2887
|
+
* ```tsx
|
|
2888
|
+
* const targetElement = document.querySelector<HTMLElement>('#drawer-root');
|
|
2889
|
+
*
|
|
2890
|
+
* useBodyScrollLock(open, targetElement);
|
|
2891
|
+
* ```
|
|
2892
|
+
*/ const useBodyScrollLock = (isLocked, targetElement, options = {})=>{
|
|
1969
2893
|
const { disabled = false, reserveScrollBarGap = true } = options;
|
|
1970
2894
|
const { allowTouchMove } = options;
|
|
1971
2895
|
const entryKeyRef = useRef(Symbol('use-body-scroll-lock-entry'));
|
|
1972
2896
|
const resolvedElement = useResolvedElement(targetElement);
|
|
2897
|
+
// globalThis is always defined including SSR; in a browser globalThis === window,
|
|
2898
|
+
// so the Win cast below is safe.
|
|
1973
2899
|
const { globalThis: global } = useWindowContext();
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2900
|
+
// Lifetime: create one stable entry per hook instance while locked, even if
|
|
2901
|
+
// the resolved element is still null. The follow-up sync effect upgrades
|
|
2902
|
+
// anonymous → element in place once a concrete target becomes available.
|
|
2903
|
+
useEffect(()=>{
|
|
2904
|
+
if (!isLocked) return;
|
|
1977
2905
|
const win = global;
|
|
1978
2906
|
const state = getOrCreateScrollLockState(global);
|
|
1979
2907
|
const entryKey = entryKeyRef.current;
|
|
1980
2908
|
const syncOptions = {
|
|
1981
2909
|
reserveScrollBarGap,
|
|
1982
2910
|
allowTouchMove,
|
|
1983
|
-
entryKey
|
|
2911
|
+
entryKey
|
|
1984
2912
|
};
|
|
1985
2913
|
applyLockState(state, win, resolvedElement, syncOptions, disabled);
|
|
1986
|
-
return ()
|
|
2914
|
+
return ()=>{
|
|
2915
|
+
// Read the current live state rather than the captured one so that an
|
|
2916
|
+
// external clearAllLocks + delete cycle doesn't leave a dangling entry
|
|
2917
|
+
// in a freshly-created state object.
|
|
1987
2918
|
const currentState = getStoredScrollLockState(global);
|
|
1988
2919
|
if (currentState) {
|
|
1989
|
-
unlock(currentState, win, resolvedElement, {
|
|
2920
|
+
unlock(currentState, win, resolvedElement, {
|
|
2921
|
+
entryKey
|
|
2922
|
+
});
|
|
1990
2923
|
}
|
|
1991
2924
|
};
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2925
|
+
// targetElement and disabled are intentionally omitted so this entry keeps
|
|
2926
|
+
// its original stack position while the sync effect below mutates it.
|
|
2927
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2928
|
+
}, [
|
|
2929
|
+
isLocked,
|
|
2930
|
+
global
|
|
2931
|
+
]);
|
|
2932
|
+
// Sync current kind (anonymous vs element), disabled state, and gap preference
|
|
2933
|
+
// onto the stable entry without changing insertion order.
|
|
2934
|
+
useEffect(()=>{
|
|
2935
|
+
if (!isLocked) return;
|
|
1996
2936
|
const win = global;
|
|
1997
2937
|
const state = getOrCreateScrollLockState(global);
|
|
1998
2938
|
const entryKey = entryKeyRef.current;
|
|
1999
2939
|
const syncOptions = {
|
|
2000
2940
|
reserveScrollBarGap,
|
|
2001
2941
|
allowTouchMove,
|
|
2002
|
-
entryKey
|
|
2942
|
+
entryKey
|
|
2003
2943
|
};
|
|
2004
2944
|
applyLockState(state, win, resolvedElement, syncOptions, disabled);
|
|
2005
2945
|
}, [
|
|
@@ -2008,18 +2948,95 @@ const useBodyScrollLock = (isLocked, targetElement, options = {}) => {
|
|
|
2008
2948
|
isLocked,
|
|
2009
2949
|
global,
|
|
2010
2950
|
resolvedElement,
|
|
2011
|
-
reserveScrollBarGap
|
|
2951
|
+
reserveScrollBarGap
|
|
2012
2952
|
]);
|
|
2013
2953
|
};
|
|
2014
2954
|
|
|
2015
|
-
|
|
2955
|
+
|
|
2956
|
+
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* Hook to get external window props for Virtuoso component.
|
|
2960
|
+
*
|
|
2961
|
+
* The 'externalWindow' prop exists only in the forked version of Virtuoso.
|
|
2962
|
+
* Passing 'externalWindow' to the original Virtuoso component will result in a React error.
|
|
2963
|
+
*
|
|
2964
|
+
* If 'externalWindow' is present in the context, it indicates that the user is using the forked version.
|
|
2965
|
+
* Therefore, we only return 'externalWindow' when it exists and is not the same as the global window,
|
|
2966
|
+
* ensuring compatibility with detached window scenarios.
|
|
2967
|
+
*
|
|
2968
|
+
* @returns Props object with externalWindow if in detached window context, undefined otherwise
|
|
2969
|
+
*
|
|
2970
|
+
* @example
|
|
2971
|
+
* ```tsx
|
|
2972
|
+
* const externalWindowProps = useVirtuosoExternalWindow();
|
|
2973
|
+
*
|
|
2974
|
+
* return (
|
|
2975
|
+
* <Virtuoso
|
|
2976
|
+
* {...externalWindowProps}
|
|
2977
|
+
* // other props
|
|
2978
|
+
* />
|
|
2979
|
+
* );
|
|
2980
|
+
* ```
|
|
2981
|
+
*/ const useVirtuosoExternalWindow = ()=>{
|
|
2016
2982
|
const { externalWindow } = useWindowContext();
|
|
2983
|
+
// Only return externalWindow props when window is not the same as the global window
|
|
2984
|
+
// This is needed for detached window scenarios with the forked virtuoso
|
|
2017
2985
|
if (externalWindow && externalWindow !== globalThis.window) {
|
|
2018
2986
|
return {
|
|
2019
|
-
externalWindow
|
|
2987
|
+
externalWindow
|
|
2020
2988
|
};
|
|
2021
2989
|
}
|
|
2022
2990
|
return undefined;
|
|
2023
2991
|
};
|
|
2024
2992
|
|
|
2025
|
-
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
|
|
2997
|
+
|
|
2998
|
+
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
|
+
|
|
3006
|
+
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
|
|
3010
|
+
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
|
|
3014
|
+
|
|
3015
|
+
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
|
|
3026
|
+
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
|
|
3033
|
+
|
|
3034
|
+
|
|
3035
|
+
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
|
|
3041
|
+
|
|
3042
|
+
export { createGlobalListener, globalListenerEventMap, isElmEqualOrContain, isTouchEvent, moveIndexInTwoDimension, teardown, unstable_useControlled as useControlled, useA11yKeyEvent, useActive, useAnnouncer, useAriaHideOutside, useAudio, useBodyScrollLock, useButton_useButton as useButton, useChange, useClickAway, useDebounce, useDepsChange, useEventCallback, useEventListener, useEver, useFocusVisible, useFontLoaded, useForceUpdate, useForkRef, useGlobalAnnouncer, useGlobalListener, useHiddenTabindex, useHover, useHoverDirty, useId, useImgLoaded, useInterval, useIsFocusVisible, useKeyDownOnce, useKeyboardMoveFocus, useLongPress, useMountState, useOnLayoutReRender, useOnReRender, useOnlyOneFocusable, useOverflow, usePrevious, useRefState, useResizeObserver, useResponsiveMatch, useResultRef, useRetry, useSleep, useTabOut, useTestRunnerDetection, useThrottle, useTimeout, useTouchMouseEvent, useVirtuosoExternalWindow };
|