@ringcentral/spring-base 1.11.2 → 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 +1688 -615
- package/index.js +15 -16
- package/liveAnnouncer.js +303 -0
- package/package.json +5 -5
- package/src/hooks/index.d.ts +1 -0
- package/src/hooks/useAnnouncer/useAnnouncer.d.ts +5 -2
- package/src/hooks/useButton/useButton.d.ts +1 -1
- package/src/hooks/useGlobalAnnouncer/index.d.ts +1 -0
- package/src/hooks/useGlobalAnnouncer/useGlobalAnnouncer.d.ts +5 -0
- package/src/hooks/useId/useId.d.ts +3 -1
- package/src/hooks/useKeyboardMoveFocus/useOnlyOneFocusable.d.ts +4 -1
- package/src/index.d.ts +1 -0
- package/src/liveAnnouncer/LiveAnnouncer.d.ts +25 -0
- package/src/liveAnnouncer/announce.d.ts +4 -0
- package/src/liveAnnouncer/index.d.ts +3 -0
- package/src/liveAnnouncer/types.d.ts +8 -0
- package/src/liveAnnouncer/visuallyHidden.d.ts +3 -0
- package/typings.js +7 -0
- package/utils.js +835 -81
- package/chunks/twMerge.js +0 -487
package/hooks.js
CHANGED
|
@@ -1,71 +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
|
-
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)=>{
|
|
19
18
|
return unstable_useEventCallback(func);
|
|
20
19
|
};
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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;
|
|
27
35
|
if (checkKeys.includes(event.key)) {
|
|
28
|
-
if (preventDefault)
|
|
29
|
-
event.preventDefault();
|
|
36
|
+
if (preventDefault) event.preventDefault();
|
|
30
37
|
fn(event);
|
|
31
38
|
}
|
|
32
39
|
});
|
|
33
40
|
};
|
|
34
41
|
|
|
35
|
-
|
|
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)=>{
|
|
36
49
|
const tag = el.tagName;
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
return tag === 'BUTTON' || tag === 'INPUT' && [
|
|
51
|
+
'button',
|
|
52
|
+
'submit',
|
|
53
|
+
'reset'
|
|
54
|
+
].includes(el?.type) || tag === 'A' && el?.href;
|
|
41
55
|
};
|
|
42
|
-
|
|
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 } = {})=>{
|
|
43
61
|
const [active, setActive] = useState(false);
|
|
44
62
|
const activeTargetRef = useRef(null);
|
|
45
63
|
const { document: targetDocument = document } = useWindowContext();
|
|
46
|
-
useEffect(()
|
|
64
|
+
useEffect(()=>{
|
|
47
65
|
if (disabled) {
|
|
48
66
|
setActive(false);
|
|
49
67
|
activeTargetRef.current = null;
|
|
50
68
|
}
|
|
51
|
-
}, [
|
|
69
|
+
}, [
|
|
70
|
+
disabled
|
|
71
|
+
]);
|
|
52
72
|
if (disabled) {
|
|
53
73
|
return {
|
|
54
|
-
activeHandlers: {
|
|
55
|
-
|
|
74
|
+
activeHandlers: {
|
|
75
|
+
onKeyDown,
|
|
76
|
+
onKeyUp,
|
|
77
|
+
onMouseDown,
|
|
78
|
+
onClick
|
|
79
|
+
},
|
|
80
|
+
active: false
|
|
56
81
|
};
|
|
57
82
|
}
|
|
58
|
-
const handleMouseDown = (event)
|
|
83
|
+
const handleMouseDown = (event)=>{
|
|
59
84
|
onMouseDown?.(event);
|
|
60
85
|
if (event.defaultPrevented) {
|
|
61
86
|
return;
|
|
62
87
|
}
|
|
63
88
|
setActive(true);
|
|
64
|
-
targetDocument.addEventListener('mouseup', ()
|
|
89
|
+
targetDocument.addEventListener('mouseup', ()=>{
|
|
65
90
|
setActive(false);
|
|
66
|
-
}, {
|
|
91
|
+
}, {
|
|
92
|
+
once: true
|
|
93
|
+
});
|
|
67
94
|
};
|
|
68
|
-
const handleKeyDown = (event)
|
|
95
|
+
const handleKeyDown = (event)=>{
|
|
69
96
|
const isSpaceKey = event.key === ' ';
|
|
70
97
|
onKeyDown?.(event);
|
|
71
98
|
if (event.defaultPrevented) {
|
|
@@ -81,15 +108,16 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
81
108
|
}
|
|
82
109
|
setActive(true);
|
|
83
110
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
event.key === 'Enter') {
|
|
111
|
+
// Keyboard accessibility for non interactive elements
|
|
112
|
+
if (event.target === event.currentTarget && !isNativeButton(event.target) && event.key === 'Enter') {
|
|
87
113
|
onClick?.(event);
|
|
88
114
|
event.preventDefault();
|
|
89
115
|
}
|
|
90
116
|
};
|
|
91
|
-
const handleKeyUp = (event)
|
|
117
|
+
const handleKeyUp = (event)=>{
|
|
92
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
|
|
93
121
|
if (event.target === event.currentTarget) {
|
|
94
122
|
setActive(false);
|
|
95
123
|
}
|
|
@@ -98,11 +126,8 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
98
126
|
if (isSpaceKey) {
|
|
99
127
|
activeTargetRef.current = null;
|
|
100
128
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
!isNativeButton(event.target) &&
|
|
104
|
-
isSpaceKey &&
|
|
105
|
-
!event.defaultPrevented) {
|
|
129
|
+
// Keyboard accessibility for non interactive elements
|
|
130
|
+
if (hadMatchingSpaceKeyDown && event.target === event.currentTarget && !isNativeButton(event.target) && isSpaceKey && !event.defaultPrevented) {
|
|
106
131
|
onClick?.(event);
|
|
107
132
|
}
|
|
108
133
|
};
|
|
@@ -111,13 +136,27 @@ const useActive = ({ onKeyDown, onKeyUp, onMouseDown, onClick, disabled = false,
|
|
|
111
136
|
onKeyDown: handleKeyDown,
|
|
112
137
|
onKeyUp: handleKeyUp,
|
|
113
138
|
onMouseDown: handleMouseDown,
|
|
114
|
-
onClick
|
|
139
|
+
onClick
|
|
115
140
|
},
|
|
116
|
-
active
|
|
141
|
+
active
|
|
117
142
|
};
|
|
118
143
|
};
|
|
119
144
|
|
|
120
|
-
|
|
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) {
|
|
121
160
|
const renderRef = useRef(0);
|
|
122
161
|
const ref = useRef();
|
|
123
162
|
if (renderRef.current === 0) {
|
|
@@ -127,9 +166,27 @@ function useResultRef(cb) {
|
|
|
127
166
|
return ref;
|
|
128
167
|
}
|
|
129
168
|
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
130
174
|
const DEFAULT_PREFIX = 'spring-id-';
|
|
131
|
-
|
|
132
|
-
|
|
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(()=>{
|
|
133
190
|
if (id) {
|
|
134
191
|
if (prefixMode) {
|
|
135
192
|
return uniqueId(`${id}-`);
|
|
@@ -140,47 +197,93 @@ function useId(id, prefixMode) {
|
|
|
140
197
|
});
|
|
141
198
|
return resultId;
|
|
142
199
|
}
|
|
200
|
+
const reactUseId = react.useId;
|
|
201
|
+
function useReactId(id, prefixMode) {
|
|
202
|
+
const reactId = reactUseId();
|
|
203
|
+
if (id) {
|
|
204
|
+
return prefixMode ? `${id}-${reactId}` : id;
|
|
205
|
+
}
|
|
206
|
+
return `${DEFAULT_PREFIX}${reactId}`;
|
|
207
|
+
}
|
|
208
|
+
const useId = reactUseId ? useReactId : useLegacyId;
|
|
209
|
+
|
|
143
210
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
const isUseAnnouncerOptions = (value)=>{
|
|
217
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
218
|
+
};
|
|
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) {
|
|
243
|
+
const id = typeof idOrOptions === 'string' ? idOrOptions : undefined;
|
|
244
|
+
const options = isUseAnnouncerOptions(idOrOptions) ? idOrOptions : maybeOptions;
|
|
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.
|
|
154
248
|
const _id = useId(id ? `rc-announcer-${id}` : 'rc-announcer', !id);
|
|
155
|
-
const { document: targetDocument, externalWindow, globalThis: contextGlobal
|
|
249
|
+
const { document: targetDocument, externalWindow, globalThis: contextGlobal } = useWindowContext();
|
|
156
250
|
const win = externalWindow ?? contextGlobal;
|
|
157
251
|
const ref = useRef(null);
|
|
158
252
|
const lastMessageRef = useRef();
|
|
159
253
|
const pendingTimerRef = useRef(null);
|
|
160
|
-
const
|
|
254
|
+
const pendingRemovalRef = useRef(null);
|
|
255
|
+
const clearPendingTimer = useCallback(()=>{
|
|
161
256
|
pendingTimerRef.current?.();
|
|
162
257
|
pendingTimerRef.current = null;
|
|
163
258
|
}, []);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
259
|
+
const clearPendingRemoval = useCallback(()=>{
|
|
260
|
+
pendingRemovalRef.current?.();
|
|
261
|
+
pendingRemovalRef.current = null;
|
|
262
|
+
}, []);
|
|
263
|
+
useEffect(()=>{
|
|
264
|
+
const currentDocument = targetDocument ?? (typeof contextGlobal.document !== 'undefined' ? contextGlobal.document : undefined);
|
|
169
265
|
if (!currentDocument?.body) {
|
|
170
266
|
return undefined;
|
|
171
267
|
}
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
el
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
268
|
+
const currentEl = ref.current;
|
|
269
|
+
const canReuseCurrentEl = currentEl?.ownerDocument === currentDocument && currentEl.id === _id && currentDocument.body.contains(currentEl);
|
|
270
|
+
const el = canReuseCurrentEl ? currentEl : currentDocument.createElement('div');
|
|
271
|
+
if (canReuseCurrentEl) {
|
|
272
|
+
clearPendingRemoval();
|
|
273
|
+
el.setAttribute('aria-live', politeness);
|
|
274
|
+
} else {
|
|
275
|
+
ref.current = el;
|
|
276
|
+
el.id = _id;
|
|
277
|
+
el.setAttribute('aria-live', politeness);
|
|
278
|
+
el.setAttribute('aria-atomic', 'true');
|
|
279
|
+
el.setAttribute('aria-hidden', 'false');
|
|
280
|
+
el.setAttribute(LIVE_ANNOUNCER_ATTR, 'true');
|
|
281
|
+
applyVisuallyHiddenStyles(el);
|
|
282
|
+
currentDocument.body.appendChild(el);
|
|
283
|
+
}
|
|
284
|
+
return ()=>{
|
|
182
285
|
clearPendingTimer();
|
|
183
|
-
win.setTimeout(()
|
|
286
|
+
const timerId = win.setTimeout(()=>{
|
|
184
287
|
const body = currentDocument.body;
|
|
185
288
|
if (body.contains(el)) {
|
|
186
289
|
body.removeChild(el);
|
|
@@ -188,13 +291,33 @@ const useAnnouncer = (id) => {
|
|
|
188
291
|
if (el === ref.current) {
|
|
189
292
|
ref.current = null;
|
|
190
293
|
}
|
|
294
|
+
if (pendingRemovalRef.current === cancelRemoval) {
|
|
295
|
+
pendingRemovalRef.current = null;
|
|
296
|
+
}
|
|
191
297
|
});
|
|
298
|
+
const cancelRemoval = ()=>{
|
|
299
|
+
win.clearTimeout(timerId);
|
|
300
|
+
};
|
|
301
|
+
pendingRemovalRef.current = cancelRemoval;
|
|
192
302
|
};
|
|
193
|
-
}, [
|
|
194
|
-
|
|
303
|
+
}, [
|
|
304
|
+
_id,
|
|
305
|
+
politeness,
|
|
306
|
+
targetDocument,
|
|
307
|
+
contextGlobal,
|
|
308
|
+
win,
|
|
309
|
+
clearPendingTimer,
|
|
310
|
+
clearPendingRemoval
|
|
311
|
+
]);
|
|
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)=>{
|
|
195
319
|
const el = ref.current;
|
|
196
|
-
if (!el)
|
|
197
|
-
return;
|
|
320
|
+
if (!el) return;
|
|
198
321
|
clearPendingTimer();
|
|
199
322
|
if (!message) {
|
|
200
323
|
el.textContent = '';
|
|
@@ -206,8 +329,8 @@ const useAnnouncer = (id) => {
|
|
|
206
329
|
lastMessageRef.current = message;
|
|
207
330
|
return;
|
|
208
331
|
}
|
|
209
|
-
el.textContent = '\u200B';
|
|
210
|
-
const timerId = win.setTimeout(()
|
|
332
|
+
el.textContent = '\u200B'; // Zero-width space (invisible but different content)
|
|
333
|
+
const timerId = win.setTimeout(()=>{
|
|
211
334
|
if (el && ref.current === el) {
|
|
212
335
|
el.textContent = message;
|
|
213
336
|
lastMessageRef.current = message;
|
|
@@ -216,54 +339,113 @@ const useAnnouncer = (id) => {
|
|
|
216
339
|
pendingTimerRef.current = null;
|
|
217
340
|
}
|
|
218
341
|
}, 100);
|
|
219
|
-
const cancelTimer = ()
|
|
342
|
+
const cancelTimer = ()=>{
|
|
220
343
|
win.clearTimeout(timerId);
|
|
221
344
|
};
|
|
222
345
|
pendingTimerRef.current = cancelTimer;
|
|
223
|
-
}, [
|
|
224
|
-
|
|
346
|
+
}, [
|
|
347
|
+
clearPendingTimer,
|
|
348
|
+
win
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
|
|
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() {
|
|
374
|
+
const { document: targetDocument } = useWindowContext();
|
|
375
|
+
return useCallback((message, politeness = 'assertive', timeoutOrOptions)=>{
|
|
376
|
+
const timeout = typeof timeoutOrOptions === 'number' ? timeoutOrOptions : timeoutOrOptions?.timeout;
|
|
377
|
+
announce(message, politeness, {
|
|
378
|
+
timeout,
|
|
379
|
+
document: targetDocument
|
|
380
|
+
});
|
|
381
|
+
}, [
|
|
382
|
+
targetDocument
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
225
388
|
|
|
226
|
-
|
|
227
|
-
|
|
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);
|
|
228
402
|
const { active: customActive, activeHandlers } = useActive({
|
|
229
|
-
disabled: parameters.disabled
|
|
403
|
+
disabled: parameters.disabled
|
|
230
404
|
});
|
|
231
405
|
const originalGetRootProps = muiButtonResult.getRootProps;
|
|
232
|
-
const getRootProps = (externalProps)
|
|
406
|
+
const getRootProps = (externalProps)=>{
|
|
233
407
|
const mergedProps = combineProps(externalProps, activeHandlers);
|
|
234
408
|
return originalGetRootProps(mergedProps);
|
|
235
409
|
};
|
|
236
410
|
return {
|
|
237
411
|
...muiButtonResult,
|
|
238
412
|
active: customActive,
|
|
239
|
-
getRootProps
|
|
413
|
+
getRootProps
|
|
240
414
|
};
|
|
241
415
|
}
|
|
242
416
|
|
|
243
|
-
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
const useAriaHideOutside = (props = {})=>{
|
|
244
424
|
const { enable = true, shouldUseInert = false } = props;
|
|
245
425
|
const { document } = useWindowContext();
|
|
246
426
|
const ariaHideOutsideRef = useRef(null);
|
|
247
427
|
const branchNodesRef = useRef(new Set());
|
|
248
428
|
const controllerRef = useRef();
|
|
249
|
-
const setAriaHideOutsideTarget = useCallback((node, isActive)
|
|
429
|
+
const setAriaHideOutsideTarget = useCallback((node, isActive)=>{
|
|
250
430
|
if (isActive) {
|
|
251
431
|
branchNodesRef.current.add(node);
|
|
252
432
|
controllerRef.current?.addTarget(node);
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
433
|
+
} else {
|
|
255
434
|
branchNodesRef.current.delete(node);
|
|
256
435
|
controllerRef.current?.removeTarget(node);
|
|
257
436
|
}
|
|
258
437
|
}, []);
|
|
259
|
-
useLayoutEffect(()
|
|
438
|
+
useLayoutEffect(()=>{
|
|
260
439
|
if (enable && ariaHideOutsideRef.current) {
|
|
261
|
-
const controller = createAriaHideOutsideController([
|
|
440
|
+
const controller = createAriaHideOutsideController([
|
|
441
|
+
ariaHideOutsideRef.current,
|
|
442
|
+
...branchNodesRef.current
|
|
443
|
+
], {
|
|
262
444
|
root: document?.body,
|
|
263
|
-
shouldUseInert
|
|
445
|
+
shouldUseInert
|
|
264
446
|
});
|
|
265
447
|
controllerRef.current = controller;
|
|
266
|
-
return ()
|
|
448
|
+
return ()=>{
|
|
267
449
|
if (controllerRef.current === controller) {
|
|
268
450
|
controllerRef.current = undefined;
|
|
269
451
|
}
|
|
@@ -272,38 +454,87 @@ const useAriaHideOutside = (props = {}) => {
|
|
|
272
454
|
}
|
|
273
455
|
controllerRef.current = undefined;
|
|
274
456
|
return undefined;
|
|
275
|
-
}, [
|
|
457
|
+
}, [
|
|
458
|
+
enable,
|
|
459
|
+
shouldUseInert,
|
|
460
|
+
document?.body
|
|
461
|
+
]);
|
|
276
462
|
return {
|
|
277
463
|
ariaHideOutsideRef: ariaHideOutsideRef,
|
|
278
|
-
|
|
279
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Whether outside elements are made unavailable to assistive technology.
|
|
466
|
+
*/ ariaHideEnabled: enable,
|
|
467
|
+
setAriaHideOutsideTarget
|
|
280
468
|
};
|
|
281
469
|
};
|
|
282
470
|
|
|
283
|
-
|
|
284
|
-
|
|
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(()=>{
|
|
285
497
|
const audio = new Audio();
|
|
286
498
|
init?.(audio);
|
|
287
499
|
return audio;
|
|
288
500
|
});
|
|
289
|
-
useEffect(()
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
501
|
+
useEffect(()=>{
|
|
502
|
+
const audio = audioRef.current;
|
|
503
|
+
return ()=>{
|
|
504
|
+
audio.srcObject = null;
|
|
293
505
|
};
|
|
506
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
294
507
|
}, []);
|
|
295
508
|
return audioRef.current;
|
|
296
509
|
};
|
|
297
510
|
|
|
298
|
-
|
|
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) {
|
|
299
522
|
const ref = useRef(isDefault ? cb() : undefined);
|
|
300
|
-
useEffect(()
|
|
523
|
+
useEffect(()=>{
|
|
301
524
|
ref.current = cb();
|
|
302
525
|
});
|
|
303
526
|
return ref.current;
|
|
304
527
|
}
|
|
305
528
|
|
|
306
|
-
|
|
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)=>{
|
|
307
538
|
const countRef = useRef(0);
|
|
308
539
|
const valueRef = useRef();
|
|
309
540
|
const prev = usePrevious(listenCb);
|
|
@@ -314,138 +545,234 @@ const useChange = (cb, listenCb) => {
|
|
|
314
545
|
}
|
|
315
546
|
return valueRef.current;
|
|
316
547
|
};
|
|
317
|
-
|
|
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)=>{
|
|
318
553
|
const countRef = useRef(0);
|
|
319
554
|
const valueRef = useRef();
|
|
320
|
-
const prev = usePrevious(()
|
|
555
|
+
const prev = usePrevious(()=>deps, true);
|
|
321
556
|
const curr = deps;
|
|
322
|
-
if (countRef.current === 0 ||
|
|
323
|
-
curr.some((dep, index) => dep !== prev[index])) {
|
|
557
|
+
if (countRef.current === 0 || curr.some((dep, index)=>dep !== prev[index])) {
|
|
324
558
|
countRef.current = 1;
|
|
325
559
|
valueRef.current = cb(prev, curr);
|
|
326
560
|
}
|
|
327
561
|
return valueRef.current;
|
|
328
562
|
};
|
|
329
563
|
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
// base https://github.com/mui/material-ui/blob/master/packages/mui-base/src/ClickAwayListener/ClickAwayListener.tsx
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
|
|
330
571
|
function clickedRootScrollbar(event, doc) {
|
|
331
|
-
return
|
|
332
|
-
doc.documentElement.clientHeight < event.clientY);
|
|
572
|
+
return doc.documentElement.clientWidth < event.clientX || doc.documentElement.clientHeight < event.clientY;
|
|
333
573
|
}
|
|
334
|
-
|
|
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 = {})=>{
|
|
335
579
|
const { disableReactTree = false, isEnabled = true } = props;
|
|
336
580
|
const movedRef = useRef(false);
|
|
337
581
|
const activatedRef = useRef(false);
|
|
338
582
|
const syntheticEventRef = useRef(false);
|
|
339
583
|
const [target, setTarget] = useState();
|
|
340
|
-
|
|
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)=>{
|
|
341
587
|
setTarget(e);
|
|
342
588
|
});
|
|
343
589
|
const shouldActive = Boolean(target);
|
|
344
|
-
useEffect(()
|
|
345
|
-
if (!isEnabled)
|
|
346
|
-
return;
|
|
590
|
+
useEffect(()=>{
|
|
591
|
+
if (!isEnabled) return;
|
|
347
592
|
if (shouldActive) {
|
|
348
|
-
|
|
593
|
+
// Ensure that this component is not "activated" synchronously.
|
|
594
|
+
// https://github.com/facebook/react/issues/20074
|
|
595
|
+
const timer = setTimeout(()=>{
|
|
349
596
|
activatedRef.current = true;
|
|
350
597
|
}, 0);
|
|
351
|
-
return ()
|
|
598
|
+
return ()=>{
|
|
352
599
|
clearTimeout(timer);
|
|
353
600
|
activatedRef.current = false;
|
|
354
601
|
};
|
|
355
602
|
}
|
|
356
603
|
return undefined;
|
|
357
|
-
}, [
|
|
358
|
-
|
|
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.
|
|
359
617
|
const insideReactTree = syntheticEventRef.current;
|
|
360
618
|
syntheticEventRef.current = false;
|
|
361
619
|
const doc = unstable_ownerDocument(target);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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)) {
|
|
365
624
|
return;
|
|
366
625
|
}
|
|
626
|
+
// Do not act if user performed touchmove
|
|
367
627
|
if (movedRef.current) {
|
|
368
628
|
movedRef.current = false;
|
|
369
629
|
return;
|
|
370
630
|
}
|
|
371
631
|
let insideDOM;
|
|
632
|
+
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js
|
|
372
633
|
if (event.composedPath) {
|
|
373
634
|
insideDOM = event.composedPath().indexOf(target) > -1;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
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);
|
|
379
639
|
}
|
|
380
640
|
if (!insideDOM && (disableReactTree || !insideReactTree)) {
|
|
381
641
|
onClickAway(event);
|
|
382
642
|
}
|
|
383
643
|
});
|
|
384
|
-
|
|
644
|
+
// Keep track of mouse/touch events that bubbled up through the portal.
|
|
645
|
+
const handleSynthetic = (e)=>{
|
|
385
646
|
syntheticEventRef.current = true;
|
|
386
647
|
};
|
|
387
|
-
useEffect(()
|
|
388
|
-
if (!isEnabled)
|
|
389
|
-
return;
|
|
648
|
+
useEffect(()=>{
|
|
649
|
+
if (!isEnabled) return;
|
|
390
650
|
if (target) {
|
|
391
651
|
const doc = unstable_ownerDocument(target);
|
|
392
|
-
const handleTouchMove = ()
|
|
652
|
+
const handleTouchMove = ()=>{
|
|
393
653
|
movedRef.current = true;
|
|
394
654
|
};
|
|
395
655
|
doc.addEventListener('touchend', handleClickAway);
|
|
396
656
|
doc.addEventListener('touchmove', handleTouchMove);
|
|
397
657
|
doc.addEventListener('click', handleClickAway);
|
|
398
|
-
return ()
|
|
658
|
+
return ()=>{
|
|
399
659
|
doc.removeEventListener('touchend', handleClickAway);
|
|
400
660
|
doc.removeEventListener('touchmove', handleTouchMove);
|
|
401
661
|
doc.removeEventListener('click', handleClickAway);
|
|
402
662
|
};
|
|
403
663
|
}
|
|
404
664
|
return undefined;
|
|
405
|
-
}, [
|
|
665
|
+
}, [
|
|
666
|
+
handleClickAway,
|
|
667
|
+
isEnabled,
|
|
668
|
+
target
|
|
669
|
+
]);
|
|
406
670
|
return {
|
|
407
671
|
clickAwayRef,
|
|
408
672
|
clickAwayHandler: {
|
|
409
673
|
onClick: handleSynthetic,
|
|
410
|
-
onTouchEnd: handleSynthetic
|
|
411
|
-
}
|
|
674
|
+
onTouchEnd: handleSynthetic
|
|
675
|
+
}
|
|
412
676
|
};
|
|
413
677
|
};
|
|
414
678
|
|
|
415
|
-
|
|
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)=>{
|
|
416
698
|
const memoFn = useEventCallback(fn);
|
|
699
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
417
700
|
const debounceFn = useCallback(debounce(memoFn, debounceTime, options), []);
|
|
418
|
-
|
|
701
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
702
|
+
useEffect(()=>()=>debounceFn.cancel(), []);
|
|
419
703
|
return debounceFn;
|
|
420
704
|
};
|
|
421
705
|
|
|
422
|
-
|
|
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) {
|
|
423
729
|
const { startImmediately = true, ...addEventListenerOptions } = options ?? {};
|
|
424
730
|
const listener = useEventCallback(callback);
|
|
425
731
|
const cancelRef = useRef(noop);
|
|
426
732
|
const bindRef = useRef(noop);
|
|
427
|
-
useEffect(()
|
|
428
|
-
if (!target)
|
|
429
|
-
return;
|
|
733
|
+
useEffect(()=>{
|
|
734
|
+
if (!target) return;
|
|
430
735
|
const currentElm = getRefElement(target);
|
|
431
736
|
if (!currentElm) {
|
|
432
737
|
return cancelRef.current;
|
|
433
738
|
}
|
|
434
|
-
bindRef.current = ()
|
|
435
|
-
cancelRef.current = ()
|
|
739
|
+
bindRef.current = ()=>currentElm.addEventListener(key, listener, addEventListenerOptions);
|
|
740
|
+
cancelRef.current = ()=>currentElm.removeEventListener(key, listener, addEventListenerOptions);
|
|
436
741
|
if (startImmediately) {
|
|
437
742
|
bindRef.current();
|
|
438
743
|
}
|
|
439
744
|
return cancelRef.current;
|
|
440
|
-
|
|
745
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
746
|
+
}, [
|
|
747
|
+
target
|
|
748
|
+
]);
|
|
441
749
|
return {
|
|
442
|
-
listen: ()
|
|
443
|
-
remove: ()
|
|
750
|
+
listen: ()=>bindRef.current(),
|
|
751
|
+
remove: ()=>cancelRef.current()
|
|
444
752
|
};
|
|
445
753
|
}
|
|
446
754
|
|
|
447
|
-
|
|
448
|
-
|
|
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)=>{
|
|
449
776
|
const result = useRef(false);
|
|
450
777
|
if (!result.current) {
|
|
451
778
|
result.current = targetValue(value);
|
|
@@ -453,6 +780,15 @@ const useEver = (value, targetValue = defaultEverTargetValue) => {
|
|
|
453
780
|
return result.current;
|
|
454
781
|
};
|
|
455
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
|
+
|
|
456
792
|
let hadKeyboardEvent = true;
|
|
457
793
|
let hadFocusVisibleRecently = false;
|
|
458
794
|
const hadFocusVisibleRecentlyTimeout = new unstable_Timeout();
|
|
@@ -469,13 +805,17 @@ const inputTypesWhitelist = {
|
|
|
469
805
|
week: true,
|
|
470
806
|
time: true,
|
|
471
807
|
datetime: true,
|
|
472
|
-
'datetime-local': true
|
|
808
|
+
'datetime-local': true
|
|
473
809
|
};
|
|
474
|
-
|
|
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) {
|
|
475
817
|
const { type, tagName } = node;
|
|
476
|
-
if (tagName === 'INPUT' &&
|
|
477
|
-
inputTypesWhitelist[type] &&
|
|
478
|
-
!node.readOnly) {
|
|
818
|
+
if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) {
|
|
479
819
|
return true;
|
|
480
820
|
}
|
|
481
821
|
if (tagName === 'TEXTAREA' && !node.readOnly) {
|
|
@@ -486,31 +826,47 @@ function focusTriggersKeyboardModality(node) {
|
|
|
486
826
|
}
|
|
487
827
|
return false;
|
|
488
828
|
}
|
|
489
|
-
|
|
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) {
|
|
490
836
|
if (event.metaKey || event.altKey || event.ctrlKey) {
|
|
491
837
|
return;
|
|
492
838
|
}
|
|
493
839
|
hadKeyboardEvent = true;
|
|
494
840
|
}
|
|
495
|
-
|
|
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() {
|
|
496
848
|
hadKeyboardEvent = false;
|
|
497
849
|
}
|
|
498
850
|
function handleVisibilityChange() {
|
|
499
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.
|
|
500
856
|
if (hadFocusVisibleRecently) {
|
|
501
857
|
hadKeyboardEvent = true;
|
|
502
858
|
}
|
|
503
859
|
}
|
|
504
860
|
}
|
|
505
861
|
function prepare(doc) {
|
|
506
|
-
doc.addEventListener('keydown',
|
|
862
|
+
doc.addEventListener('keydown', useIsFocusVisible_handleKeyDown, true);
|
|
507
863
|
doc.addEventListener('mousedown', handlePointerDown, true);
|
|
508
864
|
doc.addEventListener('pointerdown', handlePointerDown, true);
|
|
509
865
|
doc.addEventListener('touchstart', handlePointerDown, true);
|
|
510
866
|
doc.addEventListener('visibilitychange', handleVisibilityChange, true);
|
|
511
867
|
}
|
|
512
868
|
function teardown(doc) {
|
|
513
|
-
doc.removeEventListener('keydown',
|
|
869
|
+
doc.removeEventListener('keydown', useIsFocusVisible_handleKeyDown, true);
|
|
514
870
|
doc.removeEventListener('mousedown', handlePointerDown, true);
|
|
515
871
|
doc.removeEventListener('pointerdown', handlePointerDown, true);
|
|
516
872
|
doc.removeEventListener('touchstart', handlePointerDown, true);
|
|
@@ -520,22 +876,38 @@ function isFocusVisible(event) {
|
|
|
520
876
|
const { target } = event;
|
|
521
877
|
try {
|
|
522
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?
|
|
523
884
|
}
|
|
524
|
-
|
|
525
|
-
|
|
885
|
+
// No need for validFocusTarget check. The user does that by attaching it to
|
|
886
|
+
// focusable events only.
|
|
526
887
|
return hadKeyboardEvent || focusTriggersKeyboardModality(target);
|
|
527
888
|
}
|
|
528
889
|
function useIsFocusVisible() {
|
|
529
|
-
const ref =
|
|
890
|
+
const ref = __rspack_external_react.useCallback((node)=>{
|
|
530
891
|
if (node != null) {
|
|
531
892
|
prepare(node.ownerDocument);
|
|
532
893
|
}
|
|
533
894
|
}, []);
|
|
534
|
-
const isFocusVisibleRef =
|
|
535
|
-
|
|
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).
|
|
536
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.
|
|
537
909
|
hadFocusVisibleRecently = true;
|
|
538
|
-
hadFocusVisibleRecentlyTimeout.start(100, ()
|
|
910
|
+
hadFocusVisibleRecentlyTimeout.start(100, ()=>{
|
|
539
911
|
hadFocusVisibleRecently = false;
|
|
540
912
|
});
|
|
541
913
|
isFocusVisibleRef.current = false;
|
|
@@ -543,7 +915,9 @@ function useIsFocusVisible() {
|
|
|
543
915
|
}
|
|
544
916
|
return false;
|
|
545
917
|
}
|
|
546
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Should be called if a blur event is fired
|
|
920
|
+
*/ function handleFocusVisible(event) {
|
|
547
921
|
if (isFocusVisible(event)) {
|
|
548
922
|
isFocusVisibleRef.current = true;
|
|
549
923
|
return true;
|
|
@@ -554,75 +928,105 @@ function useIsFocusVisible() {
|
|
|
554
928
|
isFocusVisibleRef,
|
|
555
929
|
onFocus: handleFocusVisible,
|
|
556
930
|
onBlur: handleBlurVisible,
|
|
557
|
-
ref
|
|
931
|
+
ref
|
|
558
932
|
};
|
|
559
933
|
}
|
|
560
934
|
|
|
561
|
-
|
|
562
|
-
|
|
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();
|
|
563
949
|
const [focusVisible, setFocusVisible] = useState(false);
|
|
564
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.
|
|
565
955
|
const globalFocusListenerRef = useRef(null);
|
|
566
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.
|
|
567
960
|
const ownerDocumentRef = useRef(null);
|
|
568
|
-
const removeGlobalFocusListener = useCallback(()
|
|
961
|
+
const removeGlobalFocusListener = useCallback(()=>{
|
|
569
962
|
if (globalFocusListenerRef.current) {
|
|
570
963
|
ownerDocumentRef.current?.removeEventListener('focus', globalFocusListenerRef.current, true);
|
|
571
964
|
globalFocusListenerRef.current = null;
|
|
572
965
|
}
|
|
573
966
|
}, []);
|
|
574
|
-
useEffect(()
|
|
967
|
+
useEffect(()=>{
|
|
575
968
|
return removeGlobalFocusListener;
|
|
576
|
-
}, [
|
|
577
|
-
|
|
969
|
+
}, [
|
|
970
|
+
removeGlobalFocusListener
|
|
971
|
+
]);
|
|
972
|
+
const handleBlur = (event)=>{
|
|
578
973
|
handleBlurVisible(event);
|
|
579
974
|
if (isFocusVisibleRef.current === false) {
|
|
580
975
|
setFocusVisible(false);
|
|
581
|
-
|
|
582
|
-
|
|
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)) {
|
|
583
985
|
setFocusVisibleWithin(false);
|
|
584
986
|
}
|
|
585
987
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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)) {
|
|
589
991
|
removeGlobalFocusListener();
|
|
590
992
|
}
|
|
591
993
|
onBlur?.(event);
|
|
592
994
|
};
|
|
593
|
-
const handleFocus = (event)
|
|
995
|
+
const handleFocus = (event)=>{
|
|
594
996
|
let updateState;
|
|
595
997
|
if (within) {
|
|
596
998
|
if (event.target === event.currentTarget) {
|
|
597
|
-
updateState = ()
|
|
999
|
+
updateState = ()=>{
|
|
598
1000
|
setFocusVisibleWithin(true);
|
|
599
1001
|
setFocusVisible(true);
|
|
600
1002
|
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
updateState = () => {
|
|
1003
|
+
} else {
|
|
1004
|
+
updateState = ()=>{
|
|
604
1005
|
setFocusVisibleWithin(true);
|
|
605
1006
|
};
|
|
606
1007
|
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
updateState = () => {
|
|
1008
|
+
} else if (event.target === event.currentTarget) {
|
|
1009
|
+
updateState = ()=>{
|
|
610
1010
|
setFocusVisible(true);
|
|
611
1011
|
};
|
|
612
|
-
}
|
|
613
|
-
else {
|
|
1012
|
+
} else {
|
|
614
1013
|
return;
|
|
615
1014
|
}
|
|
616
1015
|
handleFocusVisible(event);
|
|
617
1016
|
if (isFocusVisibleRef.current === true) {
|
|
618
1017
|
updateState();
|
|
619
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
|
|
620
1024
|
if (within) {
|
|
621
1025
|
removeGlobalFocusListener();
|
|
622
1026
|
containerRef.current = event.currentTarget;
|
|
623
1027
|
ownerDocumentRef.current = event.currentTarget.ownerDocument;
|
|
624
1028
|
const ownerDoc = ownerDocumentRef.current;
|
|
625
|
-
const listener = (e)
|
|
1029
|
+
const listener = (e)=>{
|
|
626
1030
|
if (e.target && !containerRef.current?.contains(e.target)) {
|
|
627
1031
|
setFocusVisibleWithin(false);
|
|
628
1032
|
ownerDoc.removeEventListener('focus', listener, true);
|
|
@@ -632,8 +1036,7 @@ const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false, } =
|
|
|
632
1036
|
globalFocusListenerRef.current = listener;
|
|
633
1037
|
ownerDoc.addEventListener('focus', listener, true);
|
|
634
1038
|
}
|
|
635
|
-
}
|
|
636
|
-
else if (within) {
|
|
1039
|
+
} else if (within) {
|
|
637
1040
|
setFocusVisibleWithin(false);
|
|
638
1041
|
removeGlobalFocusListener();
|
|
639
1042
|
}
|
|
@@ -642,55 +1045,97 @@ const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false, } =
|
|
|
642
1045
|
return {
|
|
643
1046
|
focusVisibleHandlers: {
|
|
644
1047
|
onBlur: handleBlur,
|
|
645
|
-
onFocus: handleFocus
|
|
1048
|
+
onFocus: handleFocus
|
|
646
1049
|
},
|
|
1050
|
+
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
|
647
1051
|
focusVisibleRef: focusVisibleRef,
|
|
648
1052
|
focusVisible,
|
|
649
|
-
focusVisibleWithin
|
|
1053
|
+
focusVisibleWithin
|
|
650
1054
|
};
|
|
651
1055
|
};
|
|
652
1056
|
|
|
653
|
-
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* a method for trigger force update
|
|
1064
|
+
* @returns force update method
|
|
1065
|
+
*/ function useForceUpdate() {
|
|
654
1066
|
const [, dispatch] = useState(Object.create(null));
|
|
655
|
-
return useEventCallback(()
|
|
1067
|
+
return useEventCallback(()=>{
|
|
656
1068
|
dispatch(Object.create(null));
|
|
657
1069
|
});
|
|
658
1070
|
}
|
|
659
1071
|
|
|
660
|
-
|
|
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
|
+
|
|
661
1096
|
|
|
662
1097
|
const globalListenerEventMap = new Map();
|
|
663
|
-
|
|
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 } = {})=>{
|
|
664
1111
|
let listening = false;
|
|
665
|
-
const getMapValue = ()
|
|
666
|
-
const addListener = ()
|
|
667
|
-
if (listening)
|
|
668
|
-
return;
|
|
1112
|
+
const getMapValue = ()=>globalListenerEventMap.get(customKey);
|
|
1113
|
+
const addListener = ()=>{
|
|
1114
|
+
if (listening) return;
|
|
669
1115
|
const savedEvent = getMapValue();
|
|
670
1116
|
if (!savedEvent?.exec) {
|
|
671
|
-
const exec = (e)
|
|
1117
|
+
const exec = (e)=>{
|
|
672
1118
|
const event = getMapValue();
|
|
673
|
-
event?.listeners.forEach((c)
|
|
1119
|
+
event?.listeners.forEach((c)=>c(e));
|
|
674
1120
|
};
|
|
675
1121
|
globalListenerEventMap.set(customKey, {
|
|
676
1122
|
exec,
|
|
677
|
-
listeners: new Set([
|
|
1123
|
+
listeners: new Set([
|
|
1124
|
+
listener
|
|
1125
|
+
])
|
|
678
1126
|
});
|
|
679
1127
|
const target = getRefElement(targetProp);
|
|
680
1128
|
target?.addEventListener(key, exec, options);
|
|
681
|
-
}
|
|
682
|
-
else {
|
|
1129
|
+
} else {
|
|
683
1130
|
savedEvent.listeners.add(listener);
|
|
684
1131
|
}
|
|
685
1132
|
listening = true;
|
|
686
1133
|
};
|
|
687
|
-
const removeListener = ()
|
|
688
|
-
if (!listening)
|
|
689
|
-
return;
|
|
1134
|
+
const removeListener = ()=>{
|
|
1135
|
+
if (!listening) return;
|
|
690
1136
|
listening = false;
|
|
691
1137
|
const _savedEvent = getMapValue();
|
|
692
|
-
if (!_savedEvent)
|
|
693
|
-
return;
|
|
1138
|
+
if (!_savedEvent) return;
|
|
694
1139
|
const { listeners } = _savedEvent;
|
|
695
1140
|
listeners.delete(listener);
|
|
696
1141
|
if (listeners.size === 0) {
|
|
@@ -700,34 +1145,80 @@ const createGlobalListener = (key, listener, options, { customKey = key, target:
|
|
|
700
1145
|
}
|
|
701
1146
|
};
|
|
702
1147
|
return {
|
|
703
|
-
listen: ()
|
|
704
|
-
|
|
705
|
-
|
|
1148
|
+
listen: ()=>addListener(),
|
|
1149
|
+
/**
|
|
1150
|
+
* remove listener
|
|
1151
|
+
*/ remove: ()=>removeListener(),
|
|
1152
|
+
/**
|
|
1153
|
+
* current listener state
|
|
1154
|
+
*/ get state () {
|
|
706
1155
|
return {
|
|
707
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* current listener state count
|
|
1158
|
+
*/ get count () {
|
|
708
1159
|
return getMapValue()?.listeners.size || 0;
|
|
709
1160
|
},
|
|
710
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* is that be listening
|
|
1163
|
+
*/ listening
|
|
711
1164
|
};
|
|
712
|
-
}
|
|
1165
|
+
}
|
|
713
1166
|
};
|
|
714
1167
|
};
|
|
715
1168
|
|
|
716
|
-
|
|
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) {
|
|
717
1207
|
const { options, config } = getListenerOverloadOption(args);
|
|
718
|
-
const { customKey = key, target: targetProp = window, startImmediately = true
|
|
1208
|
+
const { customKey = key, target: targetProp = window, startImmediately = true } = config || {};
|
|
719
1209
|
const _listener = useEventCallback(listener);
|
|
720
1210
|
const { current: globalListener } = useRef(createGlobalListener(key, _listener, options, {
|
|
721
1211
|
customKey,
|
|
722
|
-
target: targetProp
|
|
1212
|
+
target: targetProp
|
|
723
1213
|
}));
|
|
724
1214
|
if (process.env.NODE_ENV !== 'production') {
|
|
725
|
-
|
|
1215
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
1216
|
+
useEffect(()=>{
|
|
726
1217
|
if (customKey === key) {
|
|
727
1218
|
if (Object.keys(options).length > 0) {
|
|
728
1219
|
logInDev({
|
|
729
1220
|
scope: 'useGlobalListener',
|
|
730
|
-
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'
|
|
731
1222
|
});
|
|
732
1223
|
}
|
|
733
1224
|
const target = getRefElement(targetProp);
|
|
@@ -735,17 +1226,19 @@ function useGlobalListener(key, listener, ...args) {
|
|
|
735
1226
|
logInDev({
|
|
736
1227
|
scope: 'useGlobalListener',
|
|
737
1228
|
message: 'When you custom binding event target, you must custom key for determining different event group',
|
|
738
|
-
level: 'error'
|
|
1229
|
+
level: 'error'
|
|
739
1230
|
});
|
|
740
1231
|
}
|
|
741
1232
|
}
|
|
1233
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
742
1234
|
}, []);
|
|
743
1235
|
}
|
|
744
|
-
useEffect(()
|
|
1236
|
+
useEffect(()=>{
|
|
745
1237
|
if (startImmediately) {
|
|
746
1238
|
globalListener.listen();
|
|
747
1239
|
}
|
|
748
1240
|
return globalListener.remove;
|
|
1241
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
749
1242
|
}, []);
|
|
750
1243
|
return globalListener;
|
|
751
1244
|
}
|
|
@@ -755,30 +1248,40 @@ function getListenerOverloadOption(args) {
|
|
|
755
1248
|
if (typeof args[0] === 'boolean') {
|
|
756
1249
|
options = args[0];
|
|
757
1250
|
config = args[1];
|
|
758
|
-
}
|
|
759
|
-
else {
|
|
1251
|
+
} else {
|
|
760
1252
|
const { startImmediately, customKey, target, ...restOptions } = {
|
|
761
1253
|
...args[0],
|
|
762
|
-
...args[1]
|
|
1254
|
+
...args[1]
|
|
763
1255
|
};
|
|
764
1256
|
options = restOptions;
|
|
765
1257
|
config = {
|
|
766
1258
|
startImmediately,
|
|
767
1259
|
customKey,
|
|
768
|
-
target
|
|
1260
|
+
target
|
|
769
1261
|
};
|
|
770
1262
|
}
|
|
771
|
-
return {
|
|
1263
|
+
return {
|
|
1264
|
+
options,
|
|
1265
|
+
config
|
|
1266
|
+
};
|
|
772
1267
|
}
|
|
773
1268
|
|
|
774
|
-
|
|
775
|
-
|
|
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(()=>{
|
|
776
1279
|
const hiddenElm = getRefElement(target);
|
|
777
1280
|
if (hiddenElm && hiddenElm.tabIndex === 0) {
|
|
778
1281
|
hiddenElm.tabIndex = -1;
|
|
779
1282
|
}
|
|
780
1283
|
});
|
|
781
|
-
const handleBlur = useEventCallback(()
|
|
1284
|
+
const handleBlur = useEventCallback(()=>{
|
|
782
1285
|
const hiddenElm = getRefElement(target);
|
|
783
1286
|
if (hiddenElm && hiddenElm.tabIndex === -1) {
|
|
784
1287
|
hiddenElm.tabIndex = 0;
|
|
@@ -786,86 +1289,111 @@ const useHiddenTabindex = (target) => {
|
|
|
786
1289
|
});
|
|
787
1290
|
return {
|
|
788
1291
|
onFocus: handleFocus,
|
|
789
|
-
onBlur: handleBlur
|
|
1292
|
+
onBlur: handleBlur
|
|
790
1293
|
};
|
|
791
1294
|
};
|
|
792
1295
|
|
|
793
|
-
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
const useHover = ({ onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, disabled = false, disableReactTree = true } = {})=>{
|
|
794
1301
|
const [hover, setHover] = useState(false);
|
|
795
1302
|
const hoverRef = useRef(false);
|
|
796
|
-
useEffect(()
|
|
1303
|
+
useEffect(()=>{
|
|
797
1304
|
if (disabled) {
|
|
798
1305
|
setHover(false);
|
|
799
|
-
}
|
|
800
|
-
else if (!disabled && hoverRef.current) {
|
|
1306
|
+
} else if (!disabled && hoverRef.current) {
|
|
801
1307
|
setHover(true);
|
|
802
1308
|
}
|
|
803
|
-
}, [
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1309
|
+
}, [
|
|
1310
|
+
disabled
|
|
1311
|
+
]);
|
|
1312
|
+
const updateHoverState = (hover, event)=>{
|
|
1313
|
+
if (disableReactTree && !event.currentTarget.contains(event.target)) {
|
|
807
1314
|
return;
|
|
808
1315
|
}
|
|
809
|
-
if (!disabled)
|
|
810
|
-
setHover(hover);
|
|
1316
|
+
if (!disabled) setHover(hover);
|
|
811
1317
|
hoverRef.current = hover;
|
|
812
1318
|
};
|
|
813
|
-
const onMouseEnter = (event)
|
|
1319
|
+
const onMouseEnter = (event)=>{
|
|
814
1320
|
handleMouseEnter?.(event);
|
|
815
1321
|
updateHoverState(true, event);
|
|
816
1322
|
};
|
|
817
|
-
const onMouseLeave = (event)
|
|
1323
|
+
const onMouseLeave = (event)=>{
|
|
818
1324
|
handleMouseLeave?.(event);
|
|
819
1325
|
updateHoverState(false, event);
|
|
820
1326
|
};
|
|
821
1327
|
return {
|
|
822
|
-
hoverHandlers: {
|
|
823
|
-
|
|
1328
|
+
hoverHandlers: {
|
|
1329
|
+
onMouseEnter,
|
|
1330
|
+
onMouseLeave
|
|
1331
|
+
},
|
|
1332
|
+
hover
|
|
824
1333
|
};
|
|
825
1334
|
};
|
|
826
1335
|
|
|
827
|
-
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
const useHoverDirty = ({ target, disabled = false })=>{
|
|
828
1340
|
const [hover, setHover] = useState(false);
|
|
829
|
-
useEffect(()
|
|
830
|
-
if (disabled || !target)
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1341
|
+
useEffect(()=>{
|
|
1342
|
+
if (disabled || !target) setHover(false);
|
|
1343
|
+
}, [
|
|
1344
|
+
disabled,
|
|
1345
|
+
target
|
|
1346
|
+
]);
|
|
1347
|
+
useEventListener(target, 'mouseenter', ()=>{
|
|
1348
|
+
if (disabled) return;
|
|
836
1349
|
setHover(true);
|
|
837
1350
|
});
|
|
838
|
-
useEventListener(target, 'mouseleave', ()
|
|
839
|
-
if (disabled)
|
|
840
|
-
return;
|
|
1351
|
+
useEventListener(target, 'mouseleave', ()=>{
|
|
1352
|
+
if (disabled) return;
|
|
841
1353
|
setHover(false);
|
|
842
1354
|
});
|
|
843
1355
|
return {
|
|
844
|
-
hover
|
|
1356
|
+
hover
|
|
845
1357
|
};
|
|
846
1358
|
};
|
|
847
1359
|
|
|
848
|
-
|
|
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 })=>{
|
|
849
1380
|
const [loaded, setLoaded] = useState(false);
|
|
850
|
-
|
|
1381
|
+
useIsoLayoutEffect(()=>{
|
|
851
1382
|
if (!src && !srcSet) {
|
|
1383
|
+
setLoaded('error');
|
|
852
1384
|
return undefined;
|
|
853
1385
|
}
|
|
854
1386
|
setLoaded(false);
|
|
855
1387
|
let active = true;
|
|
856
|
-
const image = new Image();
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
image.
|
|
864
|
-
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
setLoaded('error');
|
|
868
|
-
};
|
|
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');
|
|
869
1397
|
crossOrigin && (image.crossOrigin = crossOrigin);
|
|
870
1398
|
referrerPolicy && (image.referrerPolicy = referrerPolicy);
|
|
871
1399
|
if (src) {
|
|
@@ -874,71 +1402,133 @@ const useImgLoaded = ({ crossOrigin, referrerPolicy, src, srcSet, }) => {
|
|
|
874
1402
|
if (srcSet) {
|
|
875
1403
|
image.srcset = srcSet;
|
|
876
1404
|
}
|
|
877
|
-
|
|
1405
|
+
if (image.complete) {
|
|
1406
|
+
setLoaded(image.naturalWidth > 0 ? 'loaded' : 'error');
|
|
1407
|
+
}
|
|
1408
|
+
return ()=>{
|
|
878
1409
|
active = false;
|
|
879
1410
|
};
|
|
880
|
-
}, [
|
|
1411
|
+
}, [
|
|
1412
|
+
crossOrigin,
|
|
1413
|
+
referrerPolicy,
|
|
1414
|
+
src,
|
|
1415
|
+
srcSet
|
|
1416
|
+
]);
|
|
881
1417
|
return loaded;
|
|
882
1418
|
};
|
|
883
1419
|
|
|
884
|
-
|
|
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)=>{
|
|
885
1450
|
const intervalRef = useRef();
|
|
886
1451
|
const countRef = useRef(0);
|
|
887
|
-
const getRunning = useEventCallback(()
|
|
888
|
-
const savedCallback = useEventCallback((times)
|
|
1452
|
+
const getRunning = useEventCallback(()=>!!intervalRef.current);
|
|
1453
|
+
const savedCallback = useEventCallback((times)=>{
|
|
889
1454
|
callback(times);
|
|
890
1455
|
});
|
|
891
|
-
const pause = useCallback(()
|
|
1456
|
+
const pause = useCallback(()=>{
|
|
892
1457
|
if (intervalRef.current) {
|
|
893
1458
|
clearInterval(intervalRef.current);
|
|
894
1459
|
intervalRef.current = undefined;
|
|
895
1460
|
}
|
|
896
1461
|
}, []);
|
|
897
|
-
const cancel = useCallback(()
|
|
1462
|
+
const cancel = useCallback(()=>{
|
|
898
1463
|
pause();
|
|
899
1464
|
countRef.current = 0;
|
|
900
|
-
}, [
|
|
901
|
-
|
|
1465
|
+
}, [
|
|
1466
|
+
pause
|
|
1467
|
+
]);
|
|
1468
|
+
const play = useCallback(()=>{
|
|
902
1469
|
const isRunning = getRunning();
|
|
903
|
-
if (isRunning)
|
|
904
|
-
|
|
905
|
-
intervalRef.current = setInterval(() => {
|
|
1470
|
+
if (isRunning) return;
|
|
1471
|
+
intervalRef.current = setInterval(()=>{
|
|
906
1472
|
countRef.current++;
|
|
907
1473
|
savedCallback(countRef.current);
|
|
908
1474
|
}, delay);
|
|
909
|
-
}, [
|
|
910
|
-
|
|
1475
|
+
}, [
|
|
1476
|
+
delay,
|
|
1477
|
+
getRunning,
|
|
1478
|
+
savedCallback
|
|
1479
|
+
]);
|
|
1480
|
+
useEffect(()=>{
|
|
911
1481
|
if (startImmediately) {
|
|
912
1482
|
play();
|
|
913
1483
|
}
|
|
914
|
-
return ()
|
|
915
|
-
}, [
|
|
916
|
-
return {
|
|
1484
|
+
return ()=>cancel();
|
|
1485
|
+
}, [
|
|
917
1486
|
cancel,
|
|
918
|
-
pause,
|
|
919
1487
|
play,
|
|
920
|
-
|
|
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
|
|
921
1503
|
};
|
|
922
1504
|
};
|
|
923
1505
|
|
|
924
|
-
|
|
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 })=>{
|
|
925
1517
|
const isKeyDownMapRef = useRef({});
|
|
926
|
-
const handleKeyDown = useEventCallback((e)
|
|
1518
|
+
const handleKeyDown = useEventCallback((e)=>{
|
|
927
1519
|
if (includeKeys.includes(e.key)) {
|
|
928
1520
|
if (!isKeyDownMapRef.current[e.key]) {
|
|
929
1521
|
onKeyDown?.(e);
|
|
930
|
-
}
|
|
931
|
-
else {
|
|
1522
|
+
} else {
|
|
932
1523
|
e.preventDefault();
|
|
933
1524
|
e.stopPropagation();
|
|
934
1525
|
}
|
|
935
1526
|
isKeyDownMapRef.current[e.key] = true;
|
|
936
|
-
}
|
|
937
|
-
else {
|
|
1527
|
+
} else {
|
|
938
1528
|
onKeyDown?.(e);
|
|
939
1529
|
}
|
|
940
1530
|
});
|
|
941
|
-
const handleKeyUp = useEventCallback((e)
|
|
1531
|
+
const handleKeyUp = useEventCallback((e)=>{
|
|
942
1532
|
if (includeKeys.includes(e.key)) {
|
|
943
1533
|
isKeyDownMapRef.current[e.key] = false;
|
|
944
1534
|
}
|
|
@@ -946,48 +1536,57 @@ const useKeyDownOnce = (includeKeys, { onKeyDown, onKeyUp }) => {
|
|
|
946
1536
|
});
|
|
947
1537
|
return {
|
|
948
1538
|
onKeyDown: handleKeyDown,
|
|
949
|
-
onKeyUp: handleKeyUp
|
|
1539
|
+
onKeyUp: handleKeyUp
|
|
950
1540
|
};
|
|
951
1541
|
};
|
|
952
1542
|
|
|
953
|
-
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* that can move index in column array
|
|
1547
|
+
*/ function moveIndexInTwoDimension(direction, options) {
|
|
954
1548
|
const { currIndex, columns, total, infinite } = options;
|
|
1549
|
+
// start index never small than 0
|
|
1550
|
+
// const index = Math.max(currIndex, 0);
|
|
955
1551
|
const lastIndex = total - 1;
|
|
956
1552
|
const isInOrder = infinite === 'order';
|
|
957
1553
|
const fillTotal = Math.ceil(total / columns) * columns;
|
|
958
1554
|
let resultIndex;
|
|
959
|
-
switch
|
|
960
|
-
case 'ArrowUp':
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
}
|
|
965
|
-
case 'ArrowRight': {
|
|
966
|
-
if (isInOrder) {
|
|
967
|
-
const tmp = currIndex + 1;
|
|
968
|
-
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);
|
|
969
1560
|
break;
|
|
970
1561
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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);
|
|
984
1588
|
break;
|
|
985
1589
|
}
|
|
986
|
-
const toIndex = Math.floor(currIndex / columns) * columns +
|
|
987
|
-
((currIndex - 1 + columns) % columns);
|
|
988
|
-
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
989
|
-
break;
|
|
990
|
-
}
|
|
991
1590
|
case 'Home':
|
|
992
1591
|
resultIndex = 0;
|
|
993
1592
|
break;
|
|
@@ -1000,55 +1599,56 @@ function moveIndexInTwoDimension(direction, options) {
|
|
|
1000
1599
|
if (!infinite && resultIndex >= total) {
|
|
1001
1600
|
return currIndex;
|
|
1002
1601
|
}
|
|
1602
|
+
// * when that bigger then sourceTotal, mean that touch end line
|
|
1603
|
+
// * move to current full latest and move again.
|
|
1003
1604
|
if (resultIndex >= total) {
|
|
1004
1605
|
if (direction === 'ArrowRight') {
|
|
1005
1606
|
resultIndex = fillTotal - 1;
|
|
1006
1607
|
}
|
|
1007
1608
|
return moveIndexInTwoDimension(direction, {
|
|
1008
1609
|
...options,
|
|
1009
|
-
currIndex: resultIndex
|
|
1610
|
+
currIndex: resultIndex
|
|
1010
1611
|
});
|
|
1011
1612
|
}
|
|
1012
1613
|
return resultIndex;
|
|
1013
1614
|
}
|
|
1014
1615
|
|
|
1015
|
-
|
|
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 })=>{
|
|
1016
1623
|
const total = options.length;
|
|
1017
1624
|
const searchText = useRef('');
|
|
1018
|
-
const debounceEndSearch = useDebounce(()
|
|
1019
|
-
const searchTextAndFocus = (event)
|
|
1625
|
+
const debounceEndSearch = useDebounce(()=>searchText.current = '', endSearchDebounceTime);
|
|
1626
|
+
const searchTextAndFocus = (event)=>{
|
|
1020
1627
|
const key = event.key;
|
|
1021
|
-
if (!getOptionSearchText || key.length !== 1)
|
|
1022
|
-
return;
|
|
1628
|
+
if (!getOptionSearchText || key.length !== 1) return;
|
|
1023
1629
|
debounceEndSearch();
|
|
1024
1630
|
searchText.current += key.toLowerCase();
|
|
1025
|
-
const matchedIndex = options.findIndex((option)
|
|
1631
|
+
const matchedIndex = options.findIndex((option)=>{
|
|
1026
1632
|
const text = getOptionSearchText(option);
|
|
1027
|
-
return Boolean(!getOptionDisabled?.(option) &&
|
|
1028
|
-
text &&
|
|
1029
|
-
text.toLowerCase().startsWith(searchText.current));
|
|
1633
|
+
return Boolean(!getOptionDisabled?.(option) && text && text.toLowerCase().startsWith(searchText.current));
|
|
1030
1634
|
});
|
|
1031
1635
|
if (matchedIndex > -1) {
|
|
1032
1636
|
onFocusedIndexChange(event, matchedIndex);
|
|
1033
1637
|
}
|
|
1034
1638
|
};
|
|
1035
|
-
const onKeyFocusedIndexHandle = (event)
|
|
1639
|
+
const onKeyFocusedIndexHandle = (event)=>{
|
|
1036
1640
|
let currIndex = focusedIndexRef.current;
|
|
1037
1641
|
const key = event.key;
|
|
1038
1642
|
let findDirection;
|
|
1039
|
-
if (total === 0)
|
|
1040
|
-
|
|
1041
|
-
while
|
|
1042
|
-
const toIndex = findDirection
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
columns,
|
|
1049
|
-
total,
|
|
1050
|
-
infinite,
|
|
1051
|
-
});
|
|
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
|
+
});
|
|
1052
1652
|
if (toIndex === currIndex) {
|
|
1053
1653
|
searchTextAndFocus(event);
|
|
1054
1654
|
break;
|
|
@@ -1058,83 +1658,107 @@ const useKeyboardMoveFocus = ({ infinite, options = [], focusedIndexRef, onFocus
|
|
|
1058
1658
|
onFocusedIndexChange(event, toIndex);
|
|
1059
1659
|
}
|
|
1060
1660
|
break;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
if (focusedIndexRef.current !== -1 &&
|
|
1064
|
-
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)) {
|
|
1065
1664
|
break;
|
|
1066
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
|
|
1067
1668
|
if (focusedIndexRef.current === -1) {
|
|
1068
|
-
|
|
1069
|
-
|
|
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) {
|
|
1070
1671
|
break;
|
|
1071
1672
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
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) {
|
|
1075
1675
|
break;
|
|
1076
1676
|
}
|
|
1077
1677
|
}
|
|
1078
1678
|
currIndex = toIndex;
|
|
1679
|
+
// * if that is Hom or End, find use increase or decrease
|
|
1079
1680
|
if (key === 'Home') {
|
|
1080
1681
|
findDirection = 'next';
|
|
1081
|
-
}
|
|
1082
|
-
else if (key === 'End') {
|
|
1682
|
+
} else if (key === 'End') {
|
|
1083
1683
|
findDirection = 'prev';
|
|
1084
1684
|
}
|
|
1085
1685
|
}
|
|
1086
1686
|
}
|
|
1087
1687
|
};
|
|
1088
|
-
const getPrevFocusableOption = useCallback((index = focusedIndexRef.current, isInfinite = true)
|
|
1089
|
-
for
|
|
1688
|
+
const getPrevFocusableOption = useCallback((index = focusedIndexRef.current, /** is that infinite search */ isInfinite = true)=>{
|
|
1689
|
+
for(let i = index; i >= 0; i--){
|
|
1090
1690
|
if (!getOptionDisabled?.(options[i])) {
|
|
1091
1691
|
return i;
|
|
1092
1692
|
}
|
|
1093
1693
|
}
|
|
1094
1694
|
if (isInfinite) {
|
|
1095
|
-
|
|
1695
|
+
// loop find
|
|
1696
|
+
for(let i = total - 1; i > index; i--){
|
|
1096
1697
|
if (!getOptionDisabled?.(options[i])) {
|
|
1097
1698
|
return i;
|
|
1098
1699
|
}
|
|
1099
1700
|
}
|
|
1100
1701
|
}
|
|
1101
1702
|
return -1;
|
|
1102
|
-
}, [
|
|
1103
|
-
|
|
1104
|
-
|
|
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++){
|
|
1105
1711
|
if (!getOptionDisabled?.(options[i])) {
|
|
1106
1712
|
return i;
|
|
1107
1713
|
}
|
|
1108
1714
|
}
|
|
1109
1715
|
if (isInfinite) {
|
|
1110
|
-
|
|
1716
|
+
// loop find
|
|
1717
|
+
for(let i = 0; i < index; i++){
|
|
1111
1718
|
if (!getOptionDisabled?.(options[i])) {
|
|
1112
1719
|
return i;
|
|
1113
1720
|
}
|
|
1114
1721
|
}
|
|
1115
1722
|
}
|
|
1116
1723
|
return -1;
|
|
1117
|
-
}, [
|
|
1724
|
+
}, [
|
|
1725
|
+
focusedIndexRef,
|
|
1726
|
+
getOptionDisabled,
|
|
1727
|
+
options,
|
|
1728
|
+
total
|
|
1729
|
+
]);
|
|
1118
1730
|
return {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
1122
1740
|
};
|
|
1123
1741
|
};
|
|
1124
1742
|
|
|
1743
|
+
|
|
1744
|
+
|
|
1125
1745
|
const MOVE_ATTR_KEY = 'data-move-focused';
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1746
|
+
const MOVE_INDEX_ATTR_KEY = 'data-move-index';
|
|
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 })=>{
|
|
1751
|
+
const moveFocusedId = useId();
|
|
1752
|
+
const getIndexElm = (toIndex)=>containerRef.current?.querySelector(`[${MOVE_ATTR_KEY}="${moveFocusedId}"][${MOVE_INDEX_ATTR_KEY}="${toIndex}"]`);
|
|
1753
|
+
const setIndexTabIndex = (toIndex)=>{
|
|
1130
1754
|
const toElm = getIndexElm(toIndex);
|
|
1131
1755
|
if (toElm) {
|
|
1132
|
-
const prevElm = containerRef.current?.querySelector(`[${moveFocusedId}][tabindex="0"]`);
|
|
1756
|
+
const prevElm = containerRef.current?.querySelector(`[${MOVE_ATTR_KEY}="${moveFocusedId}"][tabindex="0"]`);
|
|
1133
1757
|
prevElm?.setAttribute('tabindex', '-1');
|
|
1134
1758
|
toElm.setAttribute('tabindex', '0');
|
|
1135
1759
|
}
|
|
1136
1760
|
};
|
|
1137
|
-
const focusIndex = (toIndex)
|
|
1761
|
+
const focusIndex = (toIndex)=>{
|
|
1138
1762
|
const toElm = getIndexElm(toIndex);
|
|
1139
1763
|
toElm?.focus();
|
|
1140
1764
|
};
|
|
@@ -1142,22 +1766,53 @@ const useOnlyOneFocusable = ({ focusedIndexRef, containerRef, }) => {
|
|
|
1142
1766
|
moveFocusedId,
|
|
1143
1767
|
focusIndex,
|
|
1144
1768
|
setIndexTabIndex,
|
|
1145
|
-
getItemProps: (index, options)
|
|
1769
|
+
getItemProps: (index, options)=>{
|
|
1146
1770
|
return combineProps({
|
|
1147
|
-
[
|
|
1771
|
+
[MOVE_ATTR_KEY]: moveFocusedId,
|
|
1772
|
+
[MOVE_INDEX_ATTR_KEY]: String(index),
|
|
1148
1773
|
tabIndex: index === focusedIndexRef.current ? 0 : -1,
|
|
1149
|
-
onFocus: ()
|
|
1774
|
+
onFocus: ()=>{
|
|
1150
1775
|
focusedIndexRef.current = index;
|
|
1151
1776
|
setIndexTabIndex(index);
|
|
1152
|
-
}
|
|
1153
|
-
}, options);
|
|
1154
|
-
}
|
|
1777
|
+
}
|
|
1778
|
+
}, options ?? {});
|
|
1779
|
+
}
|
|
1155
1780
|
};
|
|
1156
1781
|
};
|
|
1157
1782
|
|
|
1158
|
-
|
|
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 } = {})=>{
|
|
1159
1814
|
const stopMouseEventRef = useRef(false);
|
|
1160
|
-
const onTouchStart = useEventCallback((e)
|
|
1815
|
+
const onTouchStart = useEventCallback((e)=>{
|
|
1161
1816
|
stopMouseEventRef.current = true;
|
|
1162
1817
|
onTouchStartArg?.(e);
|
|
1163
1818
|
});
|
|
@@ -1168,7 +1823,7 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1168
1823
|
}
|
|
1169
1824
|
return false;
|
|
1170
1825
|
}
|
|
1171
|
-
const onTouchEnd = useEventCallback((e)
|
|
1826
|
+
const onTouchEnd = useEventCallback((e)=>{
|
|
1172
1827
|
let isInside;
|
|
1173
1828
|
if (actionRef && e.touches.length < 2 && e.changedTouches.length < 2) {
|
|
1174
1829
|
const touch = e.touches[0] || e.changedTouches[0];
|
|
@@ -1177,16 +1832,15 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1177
1832
|
}
|
|
1178
1833
|
onTouchEndArg?.(e, isInside);
|
|
1179
1834
|
});
|
|
1180
|
-
const onMouseDown = useEventCallback((e)
|
|
1835
|
+
const onMouseDown = useEventCallback((e)=>{
|
|
1181
1836
|
if (stopMouseEventRef.current) {
|
|
1182
1837
|
return;
|
|
1183
1838
|
}
|
|
1184
1839
|
onMouseDownArg?.(e);
|
|
1185
1840
|
});
|
|
1186
|
-
const onMouseUp = useEventCallback((e)
|
|
1841
|
+
const onMouseUp = useEventCallback((e)=>{
|
|
1187
1842
|
let isInside;
|
|
1188
|
-
if (stopMouseEventRef.current)
|
|
1189
|
-
return;
|
|
1843
|
+
if (stopMouseEventRef.current) return;
|
|
1190
1844
|
if (actionRef) {
|
|
1191
1845
|
const elm = e.target;
|
|
1192
1846
|
isInside = !!elm && checkInside(elm);
|
|
@@ -1197,30 +1851,50 @@ const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpA
|
|
|
1197
1851
|
onTouchStart,
|
|
1198
1852
|
onTouchEnd,
|
|
1199
1853
|
onMouseDown,
|
|
1200
|
-
onMouseUp
|
|
1854
|
+
onMouseUp
|
|
1201
1855
|
};
|
|
1202
1856
|
};
|
|
1203
|
-
|
|
1857
|
+
/**
|
|
1858
|
+
* check is element equal or contains target element
|
|
1859
|
+
*/ const isElmEqualOrContain = (sourceTarget, containTarget)=>{
|
|
1204
1860
|
const elm = getRefElement(sourceTarget);
|
|
1205
1861
|
const target = getRefElement(containTarget);
|
|
1206
1862
|
return elm && target && (target === elm || target.contains(elm));
|
|
1207
1863
|
};
|
|
1208
1864
|
|
|
1209
|
-
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
const isTouchEvent = (ev)=>{
|
|
1210
1875
|
return 'touches' in ev;
|
|
1211
1876
|
};
|
|
1212
|
-
|
|
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 } = {})=>{
|
|
1213
1885
|
const isEmittedRef = useRef(false);
|
|
1214
1886
|
const isA11yDownRef = useRef(false);
|
|
1215
1887
|
const actionRef = useRef(null);
|
|
1216
1888
|
const reasonRef = useRef('click');
|
|
1217
|
-
const emitLongPress = useDebounce((e)
|
|
1889
|
+
const emitLongPress = useDebounce((e)=>{
|
|
1890
|
+
// * only when have onPress set have emit
|
|
1218
1891
|
if (onPress) {
|
|
1219
1892
|
onPress(e, reasonRef.current);
|
|
1220
1893
|
isEmittedRef.current = true;
|
|
1221
1894
|
}
|
|
1222
1895
|
}, delay);
|
|
1223
|
-
const end = (e, isInside)
|
|
1896
|
+
const end = (e, isInside)=>{
|
|
1897
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1224
1898
|
globalMouseUpListener.remove();
|
|
1225
1899
|
if (!isEmittedRef.current && isInside) {
|
|
1226
1900
|
onTap?.(e, reasonRef.current);
|
|
@@ -1229,7 +1903,8 @@ const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTou
|
|
|
1229
1903
|
isEmittedRef.current = false;
|
|
1230
1904
|
emitLongPress.cancel();
|
|
1231
1905
|
};
|
|
1232
|
-
const start = (e)
|
|
1906
|
+
const start = (e)=>{
|
|
1907
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1233
1908
|
globalMouseUpListener.listen();
|
|
1234
1909
|
const isTouch = e.type === 'touchstart';
|
|
1235
1910
|
if (isTouch) {
|
|
@@ -1238,111 +1913,173 @@ const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTou
|
|
|
1238
1913
|
emitLongPress(e);
|
|
1239
1914
|
};
|
|
1240
1915
|
const { onMouseUp, onTouchEnd, onTouchStart, ...events } = useTouchMouseEvent({
|
|
1241
|
-
onTouchStart: (e)
|
|
1916
|
+
onTouchStart: (e)=>{
|
|
1917
|
+
// prevent long touch leave element focus
|
|
1242
1918
|
if (isPreventDefault) {
|
|
1243
1919
|
e.preventDefault();
|
|
1244
1920
|
}
|
|
1245
1921
|
onTouchStartArg?.(e);
|
|
1246
1922
|
start(e);
|
|
1247
1923
|
},
|
|
1248
|
-
onTouchEnd: (e, isInside)
|
|
1924
|
+
onTouchEnd: (e, isInside)=>{
|
|
1925
|
+
// prevent leave element focus
|
|
1249
1926
|
if (isPreventDefault) {
|
|
1250
1927
|
e.preventDefault();
|
|
1251
1928
|
}
|
|
1252
1929
|
onTouchEndArg?.(e, isInside);
|
|
1253
1930
|
end(e, isInside);
|
|
1254
1931
|
},
|
|
1255
|
-
onMouseDown: (e)
|
|
1932
|
+
onMouseDown: (e)=>{
|
|
1256
1933
|
if (isPreventDefault) {
|
|
1257
1934
|
e.preventDefault();
|
|
1258
1935
|
}
|
|
1259
1936
|
onMouseDown?.(e);
|
|
1260
1937
|
start(e);
|
|
1261
1938
|
},
|
|
1262
|
-
onMouseUp: (e, isInside)
|
|
1939
|
+
onMouseUp: (e, isInside)=>{
|
|
1940
|
+
// prevent leave element focus
|
|
1263
1941
|
if (isPreventDefault) {
|
|
1264
1942
|
e.preventDefault();
|
|
1265
1943
|
}
|
|
1266
1944
|
onMouseUpArg?.(e);
|
|
1267
1945
|
end(e, isInside);
|
|
1268
|
-
}
|
|
1269
|
-
}, {
|
|
1946
|
+
}
|
|
1947
|
+
}, {
|
|
1948
|
+
actionRef
|
|
1949
|
+
});
|
|
1950
|
+
// use event listener for make event `passive` work in safari
|
|
1270
1951
|
useEventListener(actionRef, 'touchstart', onTouchStart, {
|
|
1271
|
-
passive: false
|
|
1952
|
+
passive: false
|
|
1272
1953
|
});
|
|
1954
|
+
// use event listener for make event `passive` work in safari
|
|
1273
1955
|
useEventListener(actionRef, 'touchend', onTouchEnd, {
|
|
1274
|
-
passive: false
|
|
1956
|
+
passive: false
|
|
1275
1957
|
});
|
|
1276
1958
|
const globalMouseUpListener = useGlobalListener('mouseup', onMouseUp, {
|
|
1277
1959
|
target: externalWindow,
|
|
1278
|
-
startImmediately: false
|
|
1960
|
+
startImmediately: false
|
|
1279
1961
|
});
|
|
1280
|
-
const handleDeleteKeyDown = useA11yKeyEvent((e)
|
|
1962
|
+
const handleDeleteKeyDown = useA11yKeyEvent((e)=>{
|
|
1281
1963
|
reasonRef.current = 'keyboard';
|
|
1964
|
+
// * keydown when hold will trigger many time, show when current is a11y down, not trigger again
|
|
1282
1965
|
if (!isA11yDownRef.current) {
|
|
1283
1966
|
emitLongPress(e);
|
|
1284
1967
|
}
|
|
1285
1968
|
isA11yDownRef.current = true;
|
|
1286
|
-
}, {
|
|
1287
|
-
|
|
1969
|
+
}, {
|
|
1970
|
+
preventDefault: false
|
|
1971
|
+
});
|
|
1972
|
+
const handleKeyDown = useEventCallback((e)=>{
|
|
1288
1973
|
onKeyDown?.(e);
|
|
1289
1974
|
handleDeleteKeyDown(e);
|
|
1290
1975
|
});
|
|
1291
|
-
const handleKeyUp = useEventCallback((e)
|
|
1976
|
+
const handleKeyUp = useEventCallback((e)=>{
|
|
1292
1977
|
onKeyUp?.(e);
|
|
1293
|
-
if (isA11yDownRef.current)
|
|
1294
|
-
end(e, true);
|
|
1978
|
+
if (isA11yDownRef.current) end(e, true);
|
|
1295
1979
|
isA11yDownRef.current = false;
|
|
1296
1980
|
});
|
|
1297
1981
|
return {
|
|
1298
1982
|
ref: actionRef,
|
|
1299
1983
|
onKeyDown: handleKeyDown,
|
|
1300
1984
|
onKeyUp: handleKeyUp,
|
|
1301
|
-
...events
|
|
1985
|
+
...events
|
|
1302
1986
|
};
|
|
1303
1987
|
};
|
|
1304
1988
|
|
|
1305
|
-
|
|
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 = ()=>{
|
|
1306
2007
|
const isMountedRef = useRef(false);
|
|
1307
|
-
useEffect(()
|
|
2008
|
+
useEffect(()=>{
|
|
1308
2009
|
isMountedRef.current = true;
|
|
1309
|
-
return ()
|
|
2010
|
+
return ()=>{
|
|
1310
2011
|
isMountedRef.current = false;
|
|
1311
2012
|
};
|
|
1312
2013
|
}, []);
|
|
1313
2014
|
return isMountedRef;
|
|
1314
2015
|
};
|
|
1315
2016
|
|
|
1316
|
-
|
|
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)=>{
|
|
1317
2025
|
const count = useRef(0);
|
|
1318
2026
|
const method = useEventCallback(cb);
|
|
1319
|
-
useLayoutEffect(()
|
|
2027
|
+
useLayoutEffect(()=>{
|
|
1320
2028
|
if (count.current === 0) {
|
|
1321
2029
|
count.current = 1;
|
|
1322
|
-
return ()
|
|
2030
|
+
return ()=>{
|
|
2031
|
+
//
|
|
1323
2032
|
};
|
|
1324
2033
|
}
|
|
1325
2034
|
return method();
|
|
2035
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1326
2036
|
}, deps);
|
|
1327
2037
|
};
|
|
1328
2038
|
|
|
1329
|
-
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* only trigger when re-render, not trigger on component first render
|
|
2046
|
+
*/ const useOnReRender = (cb, deps)=>{
|
|
1330
2047
|
const count = useRef(0);
|
|
1331
2048
|
const method = useEventCallback(cb);
|
|
1332
|
-
useEffect(()
|
|
2049
|
+
useEffect(()=>{
|
|
1333
2050
|
if (count.current === 0) {
|
|
1334
2051
|
count.current = 1;
|
|
1335
|
-
return ()
|
|
2052
|
+
return ()=>{
|
|
2053
|
+
//
|
|
1336
2054
|
};
|
|
1337
2055
|
}
|
|
1338
2056
|
return method();
|
|
2057
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1339
2058
|
}, deps);
|
|
1340
2059
|
};
|
|
1341
2060
|
|
|
1342
|
-
|
|
1343
|
-
|
|
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;
|
|
1344
2081
|
const countRef = useRef(0);
|
|
1345
|
-
const cb = useEventCallback((...args)
|
|
2082
|
+
const cb = useEventCallback((...args)=>{
|
|
1346
2083
|
if (ignoreFireWhenObserve && countRef.current === 0) {
|
|
1347
2084
|
countRef.current = 1;
|
|
1348
2085
|
return;
|
|
@@ -1351,20 +2088,18 @@ const useResizeObserver = (target, callback, performance = {}) => {
|
|
|
1351
2088
|
});
|
|
1352
2089
|
const el = getRefElement(target);
|
|
1353
2090
|
const currentWindow = el ? ownerWindow(el) : window;
|
|
1354
|
-
const observerRef = useResultRef(()
|
|
2091
|
+
const observerRef = useResultRef(()=>{
|
|
1355
2092
|
let resizeCallback;
|
|
1356
2093
|
if (mode === 'debounce') {
|
|
1357
2094
|
resizeCallback = debounce(cb, performanceTime, options);
|
|
1358
|
-
}
|
|
1359
|
-
else if (mode === 'throttle') {
|
|
2095
|
+
} else if (mode === 'throttle') {
|
|
1360
2096
|
resizeCallback = throttle(cb, performanceTime, options);
|
|
1361
|
-
}
|
|
1362
|
-
else {
|
|
2097
|
+
} else {
|
|
1363
2098
|
resizeCallback = cb;
|
|
1364
2099
|
}
|
|
1365
2100
|
return new currentWindow.ResizeObserver(resizeCallback);
|
|
1366
2101
|
});
|
|
1367
|
-
useLayoutEffect(()
|
|
2102
|
+
useLayoutEffect(()=>{
|
|
1368
2103
|
if (!currentWindow?.ResizeObserver) {
|
|
1369
2104
|
return;
|
|
1370
2105
|
}
|
|
@@ -1374,110 +2109,193 @@ const useResizeObserver = (target, callback, performance = {}) => {
|
|
|
1374
2109
|
throw new Error('please check element exist before bind resize observer');
|
|
1375
2110
|
}
|
|
1376
2111
|
observer.observe(el);
|
|
1377
|
-
return ()
|
|
2112
|
+
return ()=>{
|
|
1378
2113
|
observer.disconnect();
|
|
1379
2114
|
};
|
|
1380
|
-
|
|
2115
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2116
|
+
}, [
|
|
2117
|
+
el
|
|
2118
|
+
]);
|
|
1381
2119
|
return observerRef.current;
|
|
1382
2120
|
};
|
|
1383
2121
|
|
|
1384
|
-
|
|
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 = {
|
|
1385
2134
|
mode: 'throttle',
|
|
1386
|
-
time: 200
|
|
1387
|
-
})
|
|
2135
|
+
time: 200
|
|
2136
|
+
})=>{
|
|
2137
|
+
// * not have default value for that can trigger change first time.
|
|
1388
2138
|
const showRef = useRef();
|
|
1389
|
-
return useResizeObserver(target, ()
|
|
2139
|
+
return useResizeObserver(target, ()=>{
|
|
1390
2140
|
const element = getRefElement(target);
|
|
1391
|
-
if (!element)
|
|
1392
|
-
return;
|
|
2141
|
+
if (!element) return;
|
|
1393
2142
|
const { scrollWidth, clientWidth, scrollHeight, clientHeight } = element;
|
|
1394
2143
|
const show = scrollWidth > clientWidth || scrollHeight > clientHeight;
|
|
1395
|
-
if (showRef.current === show)
|
|
1396
|
-
|
|
2144
|
+
if (showRef.current === show) return;
|
|
2145
|
+
// only emit when state change
|
|
1397
2146
|
showRef.current = show;
|
|
1398
2147
|
cb(show, {
|
|
1399
2148
|
scrollWidth,
|
|
1400
2149
|
clientWidth,
|
|
1401
2150
|
scrollHeight,
|
|
1402
|
-
clientHeight
|
|
2151
|
+
clientHeight
|
|
1403
2152
|
});
|
|
1404
2153
|
}, performance);
|
|
1405
2154
|
};
|
|
1406
2155
|
|
|
1407
|
-
|
|
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
|
|
1408
2182
|
const forceUpdate = customUpdate ?? useForceUpdate();
|
|
1409
2183
|
const innerValueRef = useRef(value);
|
|
1410
|
-
|
|
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)=>{
|
|
1411
2190
|
innerValueRef.current = toValue;
|
|
1412
2191
|
if (isUpdate) {
|
|
1413
2192
|
forceUpdate();
|
|
1414
2193
|
}
|
|
1415
2194
|
});
|
|
1416
|
-
return [
|
|
2195
|
+
return [
|
|
2196
|
+
innerValueRef,
|
|
2197
|
+
setInnerValue
|
|
2198
|
+
];
|
|
1417
2199
|
};
|
|
1418
2200
|
|
|
1419
|
-
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* get all matched breakpoints
|
|
2206
|
+
* @param defaultBreakpoint use defaultBreakpoint when Responsive init (will pass undefined)
|
|
2207
|
+
*/ const useResponsiveMatch = (defaultBreakpoint)=>{
|
|
1420
2208
|
const _breakpoint = useResponsiveContext();
|
|
1421
2209
|
const breakpoint = _breakpoint === undefined ? defaultBreakpoint : _breakpoint;
|
|
1422
|
-
if (breakpoint === undefined)
|
|
1423
|
-
return {};
|
|
2210
|
+
if (breakpoint === undefined) return {};
|
|
1424
2211
|
const matchDetail = getMatchDetail(breakpoint);
|
|
1425
2212
|
return matchDetail.matchMap;
|
|
1426
2213
|
};
|
|
1427
2214
|
|
|
1428
|
-
|
|
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 = ()=>{
|
|
1429
2228
|
const timeoutIdRef = useRef();
|
|
1430
2229
|
const rejectRef = useRef();
|
|
1431
2230
|
const sleepingRef = useRef(false);
|
|
1432
|
-
const clearCurrTimeout = ()
|
|
2231
|
+
const clearCurrTimeout = ()=>{
|
|
1433
2232
|
const id = timeoutIdRef.current;
|
|
1434
2233
|
sleepingRef.current = false;
|
|
1435
|
-
if (id !== undefined)
|
|
1436
|
-
clearTimeout(id);
|
|
2234
|
+
if (id !== undefined) clearTimeout(id);
|
|
1437
2235
|
};
|
|
1438
|
-
const sleep = (time)
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
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 = ()=>{
|
|
1448
2246
|
clearCurrTimeout();
|
|
1449
2247
|
rejectRef.current?.();
|
|
1450
2248
|
};
|
|
1451
|
-
useEffect(()
|
|
1452
|
-
return ()
|
|
2249
|
+
useEffect(()=>{
|
|
2250
|
+
return ()=>{
|
|
1453
2251
|
clearCurrTimeout();
|
|
1454
2252
|
};
|
|
1455
2253
|
}, []);
|
|
1456
2254
|
return {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
|
1460
2265
|
};
|
|
1461
2266
|
};
|
|
1462
2267
|
|
|
1463
|
-
|
|
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)=>{
|
|
1464
2280
|
const { sleep, cancel: cancelSleep } = useSleep();
|
|
1465
2281
|
const isCancelRef = useRef(true);
|
|
1466
|
-
const complete = ()
|
|
2282
|
+
const complete = ()=>{
|
|
1467
2283
|
isCancelRef.current = true;
|
|
1468
2284
|
options?.onComplete?.();
|
|
1469
2285
|
};
|
|
1470
|
-
const cancel = ()
|
|
2286
|
+
const cancel = ()=>{
|
|
1471
2287
|
if (!isCancelRef.current) {
|
|
2288
|
+
// * when outside cancel
|
|
1472
2289
|
options?.onCancel?.();
|
|
1473
2290
|
complete();
|
|
1474
2291
|
}
|
|
1475
2292
|
cancelSleep();
|
|
1476
2293
|
};
|
|
1477
|
-
const retry = async (...args)
|
|
2294
|
+
const retry = async (...args)=>{
|
|
1478
2295
|
cancel();
|
|
1479
2296
|
isCancelRef.current = false;
|
|
1480
2297
|
options?.onStart?.();
|
|
2298
|
+
// * when exec success, just return directly.
|
|
1481
2299
|
if (await cb(...args)) {
|
|
1482
2300
|
if (!isCancelRef.current) {
|
|
1483
2301
|
options?.onSuccess?.();
|
|
@@ -1485,17 +2303,15 @@ const useRetry = (cb, options) => {
|
|
|
1485
2303
|
}
|
|
1486
2304
|
return;
|
|
1487
2305
|
}
|
|
1488
|
-
if (!options)
|
|
1489
|
-
return;
|
|
2306
|
+
if (!options) return;
|
|
1490
2307
|
const { retryTimes, intervalTime, onSuccess } = options;
|
|
1491
|
-
for
|
|
2308
|
+
for(let i = 0; i < retryTimes; i++){
|
|
1492
2309
|
if (isCancelRef.current) {
|
|
1493
2310
|
return;
|
|
1494
2311
|
}
|
|
1495
2312
|
try {
|
|
1496
2313
|
await sleep(intervalTime);
|
|
1497
|
-
}
|
|
1498
|
-
catch (error) {
|
|
2314
|
+
} catch (error) {
|
|
1499
2315
|
return;
|
|
1500
2316
|
}
|
|
1501
2317
|
if (await cb(...args, i)) {
|
|
@@ -1509,24 +2325,34 @@ const useRetry = (cb, options) => {
|
|
|
1509
2325
|
complete();
|
|
1510
2326
|
};
|
|
1511
2327
|
return {
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
|
1514
2335
|
};
|
|
1515
2336
|
};
|
|
1516
2337
|
|
|
1517
|
-
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* @param onTabOut Callback fired when a "tab out" event is detected.
|
|
2344
|
+
*/ const useTabOut = (onTabOut)=>{
|
|
1518
2345
|
const lastKeyDownIsTabRef = useRef(false);
|
|
1519
|
-
const handleKeyDown = (e)
|
|
2346
|
+
const handleKeyDown = (e)=>{
|
|
1520
2347
|
if (e.key === 'Tab') {
|
|
1521
2348
|
lastKeyDownIsTabRef.current = true;
|
|
1522
2349
|
}
|
|
1523
2350
|
};
|
|
1524
|
-
const handleKeyUp = (e)
|
|
2351
|
+
const handleKeyUp = (e)=>{
|
|
1525
2352
|
lastKeyDownIsTabRef.current = false;
|
|
1526
2353
|
};
|
|
1527
|
-
const handleBlur = (e)
|
|
1528
|
-
if (!e.currentTarget.contains(e.relatedTarget) &&
|
|
1529
|
-
lastKeyDownIsTabRef.current) {
|
|
2354
|
+
const handleBlur = (e)=>{
|
|
2355
|
+
if (!e.currentTarget.contains(e.relatedTarget) && lastKeyDownIsTabRef.current) {
|
|
1530
2356
|
onTabOut(e);
|
|
1531
2357
|
}
|
|
1532
2358
|
};
|
|
@@ -1534,63 +2360,153 @@ const useTabOut = (onTabOut) => {
|
|
|
1534
2360
|
tabOutHandler: {
|
|
1535
2361
|
onKeyDown: handleKeyDown,
|
|
1536
2362
|
onKeyUp: handleKeyUp,
|
|
1537
|
-
onBlur: handleBlur
|
|
1538
|
-
}
|
|
2363
|
+
onBlur: handleBlur
|
|
2364
|
+
}
|
|
1539
2365
|
};
|
|
1540
2366
|
};
|
|
1541
2367
|
|
|
1542
|
-
|
|
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)=>{
|
|
1543
2382
|
const memoFn = useEventCallback(fn);
|
|
2383
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
1544
2384
|
const throttleFn = useCallback(throttle(memoFn, throttleTime, options), []);
|
|
1545
|
-
|
|
2385
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
2386
|
+
useEffect(()=>()=>throttleFn.cancel(), []);
|
|
1546
2387
|
return throttleFn;
|
|
1547
2388
|
};
|
|
1548
2389
|
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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);
|
|
1553
2402
|
}, []);
|
|
1554
2403
|
};
|
|
1555
2404
|
|
|
2405
|
+
|
|
2406
|
+
|
|
1556
2407
|
function useTimeout(callback, delay) {
|
|
1557
2408
|
const savedCallback = useRef(callback);
|
|
1558
2409
|
savedCallback.current = callback;
|
|
1559
|
-
|
|
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.
|
|
1560
2414
|
if (!delay && delay !== 0) {
|
|
1561
2415
|
return;
|
|
1562
2416
|
}
|
|
1563
|
-
const id = setTimeout(()
|
|
1564
|
-
return ()
|
|
1565
|
-
}, [
|
|
2417
|
+
const id = setTimeout(()=>savedCallback.current(), delay);
|
|
2418
|
+
return ()=>clearTimeout(id);
|
|
2419
|
+
}, [
|
|
2420
|
+
delay
|
|
2421
|
+
]);
|
|
1566
2422
|
}
|
|
2423
|
+
/* export default */ var useTimeout_useTimeout = ((/* unused pure expression or super */ null && (useTimeout)));
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
|
|
1567
2427
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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';
|
|
1573
2434
|
};
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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(()=>{
|
|
1577
2449
|
if (!enabled || !isFontLoadingAPIAvailable()) {
|
|
1578
2450
|
return;
|
|
1579
2451
|
}
|
|
1580
2452
|
let cancelled = false;
|
|
1581
|
-
document.fonts.ready.then(()
|
|
2453
|
+
document.fonts.ready.then(()=>{
|
|
1582
2454
|
if (!cancelled) {
|
|
1583
2455
|
setLoaded(true);
|
|
1584
2456
|
}
|
|
1585
2457
|
});
|
|
1586
|
-
return ()
|
|
2458
|
+
return ()=>{
|
|
1587
2459
|
cancelled = true;
|
|
1588
2460
|
};
|
|
1589
|
-
}, [
|
|
2461
|
+
}, [
|
|
2462
|
+
enabled
|
|
2463
|
+
]);
|
|
1590
2464
|
return loaded;
|
|
1591
2465
|
};
|
|
1592
2466
|
|
|
1593
|
-
|
|
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() {
|
|
1594
2510
|
return {
|
|
1595
2511
|
entries: [],
|
|
1596
2512
|
savedBodyOverflow: '',
|
|
@@ -1600,48 +2516,47 @@ function createInitialState() {
|
|
|
1600
2516
|
touchStartY: 0,
|
|
1601
2517
|
touchStartX: 0,
|
|
1602
2518
|
nonIOSLocked: false,
|
|
1603
|
-
nonIOSReservedScrollBarGap: false
|
|
2519
|
+
nonIOSReservedScrollBarGap: false
|
|
1604
2520
|
};
|
|
1605
2521
|
}
|
|
1606
2522
|
function isIOS(win) {
|
|
1607
2523
|
const { platform, maxTouchPoints, userAgent } = win.navigator;
|
|
1608
|
-
return
|
|
1609
|
-
(platform === 'MacIntel' && maxTouchPoints > 1) ||
|
|
1610
|
-
/iPad|iPhone|iPod/.test(userAgent));
|
|
2524
|
+
return /iP(ad|hone|od)/.test(platform) || platform === 'MacIntel' && maxTouchPoints > 1 || /iPad|iPhone|iPod/.test(userAgent);
|
|
1611
2525
|
}
|
|
1612
2526
|
function isScrollableElement(el, win) {
|
|
1613
2527
|
const { overflow, overflowX, overflowY } = win.getComputedStyle(el);
|
|
1614
|
-
if (!/(auto|scroll|overlay)/.test(overflow + overflowX + overflowY))
|
|
1615
|
-
|
|
1616
|
-
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;
|
|
1617
2530
|
}
|
|
1618
2531
|
function isScrollBoundaryHit(scrollable, dx, dy) {
|
|
1619
2532
|
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
1620
|
-
if (dy > 0 && scrollable.scrollTop <= 0)
|
|
1621
|
-
|
|
1622
|
-
return (dy < 0 &&
|
|
1623
|
-
scrollable.scrollTop + scrollable.clientHeight >=
|
|
1624
|
-
scrollable.scrollHeight - 1);
|
|
2533
|
+
if (dy > 0 && scrollable.scrollTop <= 0) return true;
|
|
2534
|
+
return dy < 0 && scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 1;
|
|
1625
2535
|
}
|
|
1626
|
-
if (dx > 0 && scrollable.scrollLeft <= 0)
|
|
1627
|
-
|
|
1628
|
-
return (dx < 0 &&
|
|
1629
|
-
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;
|
|
1630
2538
|
}
|
|
1631
|
-
|
|
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) {
|
|
1632
2549
|
const { body, documentElement } = win.document;
|
|
1633
2550
|
let cur = el;
|
|
1634
|
-
while
|
|
1635
|
-
if (isScrollableElement(cur, win))
|
|
1636
|
-
|
|
1637
|
-
if (cur === boundary)
|
|
1638
|
-
break;
|
|
2551
|
+
while(cur && cur !== body && cur !== documentElement){
|
|
2552
|
+
if (isScrollableElement(cur, win)) return cur;
|
|
2553
|
+
if (cur === boundary) break;
|
|
1639
2554
|
cur = cur.parentElement;
|
|
1640
2555
|
}
|
|
1641
2556
|
return null;
|
|
1642
2557
|
}
|
|
1643
2558
|
function getActiveEntries(state) {
|
|
1644
|
-
return state.entries.filter((entry)
|
|
2559
|
+
return state.entries.filter((entry)=>entry.active);
|
|
1645
2560
|
}
|
|
1646
2561
|
function getBoundaryForEntry(topEntry) {
|
|
1647
2562
|
return topEntry.kind === 'element' ? topEntry.element : null;
|
|
@@ -1660,10 +2575,9 @@ function getEntryScopedScrollable(topEntry, target, win, { allowAnonymous = fals
|
|
|
1660
2575
|
return getScrollableWithinBoundary(target, win, boundary);
|
|
1661
2576
|
}
|
|
1662
2577
|
function getAllowTouchMoveScrollable(entry, target, win) {
|
|
1663
|
-
if (entry.allowTouchMove?.(target) !== true)
|
|
1664
|
-
return null;
|
|
2578
|
+
if (entry.allowTouchMove?.(target) !== true) return null;
|
|
1665
2579
|
return getEntryScopedScrollable(entry, target, win, {
|
|
1666
|
-
allowAnonymous: true
|
|
2580
|
+
allowAnonymous: true
|
|
1667
2581
|
});
|
|
1668
2582
|
}
|
|
1669
2583
|
function canEntryAllowInnerScroll(entry, target, win, dx, dy) {
|
|
@@ -1679,10 +2593,10 @@ function canEntryAllowInnerScroll(entry, target, win, dx, dy) {
|
|
|
1679
2593
|
}
|
|
1680
2594
|
function findEntryIndex(state, targetElement, entryKey) {
|
|
1681
2595
|
if (entryKey) {
|
|
1682
|
-
return state.entries.findIndex((entry)
|
|
2596
|
+
return state.entries.findIndex((entry)=>entry.key === entryKey);
|
|
1683
2597
|
}
|
|
1684
2598
|
if (targetElement) {
|
|
1685
|
-
for
|
|
2599
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1686
2600
|
const entry = state.entries[i];
|
|
1687
2601
|
if (entry.kind === 'element' && entry.element === targetElement) {
|
|
1688
2602
|
return i;
|
|
@@ -1690,7 +2604,7 @@ function findEntryIndex(state, targetElement, entryKey) {
|
|
|
1690
2604
|
}
|
|
1691
2605
|
return -1;
|
|
1692
2606
|
}
|
|
1693
|
-
for
|
|
2607
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1694
2608
|
if (state.entries[i].kind === 'anonymous') {
|
|
1695
2609
|
return i;
|
|
1696
2610
|
}
|
|
@@ -1714,32 +2628,26 @@ function ensureEntry(state, targetElement, options = {}) {
|
|
|
1714
2628
|
element: targetElement ?? null,
|
|
1715
2629
|
active: true,
|
|
1716
2630
|
reserveScrollBarGap: Boolean(options.reserveScrollBarGap),
|
|
1717
|
-
allowTouchMove: options.allowTouchMove
|
|
2631
|
+
allowTouchMove: options.allowTouchMove
|
|
1718
2632
|
};
|
|
1719
2633
|
state.entries.push(entry);
|
|
1720
2634
|
return entry;
|
|
1721
2635
|
}
|
|
1722
2636
|
function shouldPreventTouchMove(e, state, win) {
|
|
1723
|
-
if (e.touches.length > 1)
|
|
1724
|
-
|
|
1725
|
-
if (e.
|
|
1726
|
-
return false;
|
|
1727
|
-
if (!e.cancelable)
|
|
1728
|
-
return false;
|
|
2637
|
+
if (e.touches.length > 1) return false;
|
|
2638
|
+
if (e.ctrlKey) return false;
|
|
2639
|
+
if (!e.cancelable) return false;
|
|
1729
2640
|
const touch = e.touches[0];
|
|
1730
|
-
if (!touch)
|
|
1731
|
-
return false;
|
|
2641
|
+
if (!touch) return false;
|
|
1732
2642
|
const dy = touch.clientY - state.touchStartY;
|
|
1733
2643
|
const dx = touch.clientX - state.touchStartX;
|
|
1734
|
-
if (dy === 0 && dx === 0)
|
|
1735
|
-
|
|
1736
|
-
if (getActiveEntries(state).length === 0)
|
|
1737
|
-
return false;
|
|
2644
|
+
if (dy === 0 && dx === 0) return false;
|
|
2645
|
+
if (getActiveEntries(state).length === 0) return false;
|
|
1738
2646
|
const target = e.target;
|
|
1739
2647
|
if (!(target instanceof win.Element)) {
|
|
1740
2648
|
return true;
|
|
1741
2649
|
}
|
|
1742
|
-
for
|
|
2650
|
+
for(let i = state.entries.length - 1; i >= 0; i--){
|
|
1743
2651
|
if (canEntryAllowInnerScroll(state.entries[i], target, win, dx, dy)) {
|
|
1744
2652
|
return false;
|
|
1745
2653
|
}
|
|
@@ -1748,22 +2656,28 @@ function shouldPreventTouchMove(e, state, win) {
|
|
|
1748
2656
|
}
|
|
1749
2657
|
function lockIOS(state, win) {
|
|
1750
2658
|
const doc = win.document;
|
|
1751
|
-
|
|
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)=>{
|
|
1752
2666
|
if (e.touches.length > 0) {
|
|
1753
2667
|
state.touchStartY = e.touches[0].clientY;
|
|
1754
2668
|
state.touchStartX = e.touches[0].clientX;
|
|
1755
2669
|
}
|
|
1756
2670
|
};
|
|
1757
|
-
state.docTouchMoveHandler = (e)
|
|
2671
|
+
state.docTouchMoveHandler = (e)=>{
|
|
1758
2672
|
if (shouldPreventTouchMove(e, state, win)) {
|
|
1759
2673
|
e.preventDefault();
|
|
1760
2674
|
}
|
|
1761
2675
|
};
|
|
1762
2676
|
doc.addEventListener('touchstart', state.docTouchStartHandler, {
|
|
1763
|
-
passive: true
|
|
2677
|
+
passive: true
|
|
1764
2678
|
});
|
|
1765
2679
|
doc.addEventListener('touchmove', state.docTouchMoveHandler, {
|
|
1766
|
-
passive: false
|
|
2680
|
+
passive: false
|
|
1767
2681
|
});
|
|
1768
2682
|
}
|
|
1769
2683
|
function unlockIOS(state, win) {
|
|
@@ -1794,28 +2708,30 @@ function unlockNonIOS(state, win) {
|
|
|
1794
2708
|
if (state.savedBodyPaddingRight !== null) {
|
|
1795
2709
|
if (state.savedBodyPaddingRight === '') {
|
|
1796
2710
|
body.style.removeProperty('padding-right');
|
|
1797
|
-
}
|
|
1798
|
-
else {
|
|
2711
|
+
} else {
|
|
1799
2712
|
body.style.paddingRight = state.savedBodyPaddingRight;
|
|
1800
2713
|
}
|
|
1801
2714
|
state.savedBodyPaddingRight = null;
|
|
1802
2715
|
}
|
|
1803
2716
|
}
|
|
2717
|
+
// Non-iOS keeps body overflow locked while any entry remains active.
|
|
1804
2718
|
function bodyLockDesired(state) {
|
|
1805
2719
|
return getActiveEntries(state).length > 0;
|
|
1806
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.
|
|
1807
2723
|
function shouldReserveScrollBarGap(state) {
|
|
1808
|
-
return state.entries.some((entry)
|
|
2724
|
+
return state.entries.some((entry)=>entry.active && entry.reserveScrollBarGap);
|
|
1809
2725
|
}
|
|
1810
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.
|
|
1811
2729
|
const desired = state.entries.length > 0;
|
|
1812
2730
|
const attached = state.docTouchMoveHandler !== null;
|
|
1813
|
-
if (desired === attached)
|
|
1814
|
-
return;
|
|
2731
|
+
if (desired === attached) return;
|
|
1815
2732
|
if (desired) {
|
|
1816
2733
|
lockIOS(state, win);
|
|
1817
|
-
}
|
|
1818
|
-
else {
|
|
2734
|
+
} else {
|
|
1819
2735
|
unlockIOS(state, win);
|
|
1820
2736
|
}
|
|
1821
2737
|
}
|
|
@@ -1823,8 +2739,7 @@ function syncNonIOS(state, win) {
|
|
|
1823
2739
|
const desired = bodyLockDesired(state);
|
|
1824
2740
|
const reserveScrollBarGap = shouldReserveScrollBarGap(state);
|
|
1825
2741
|
if (!desired) {
|
|
1826
|
-
if (!state.nonIOSLocked)
|
|
1827
|
-
return;
|
|
2742
|
+
if (!state.nonIOSLocked) return;
|
|
1828
2743
|
unlockNonIOS(state, win);
|
|
1829
2744
|
state.nonIOSLocked = false;
|
|
1830
2745
|
state.nonIOSReservedScrollBarGap = false;
|
|
@@ -1832,23 +2747,23 @@ function syncNonIOS(state, win) {
|
|
|
1832
2747
|
}
|
|
1833
2748
|
if (!state.nonIOSLocked) {
|
|
1834
2749
|
lockNonIOS(state, win, {
|
|
1835
|
-
reserveScrollBarGap
|
|
2750
|
+
reserveScrollBarGap
|
|
1836
2751
|
});
|
|
1837
2752
|
state.nonIOSLocked = true;
|
|
1838
2753
|
state.nonIOSReservedScrollBarGap = reserveScrollBarGap;
|
|
1839
2754
|
return;
|
|
1840
2755
|
}
|
|
1841
|
-
if (reserveScrollBarGap === state.nonIOSReservedScrollBarGap)
|
|
1842
|
-
return;
|
|
2756
|
+
if (reserveScrollBarGap === state.nonIOSReservedScrollBarGap) return;
|
|
1843
2757
|
unlockNonIOS(state, win);
|
|
1844
|
-
lockNonIOS(state, win, {
|
|
2758
|
+
lockNonIOS(state, win, {
|
|
2759
|
+
reserveScrollBarGap
|
|
2760
|
+
});
|
|
1845
2761
|
state.nonIOSReservedScrollBarGap = reserveScrollBarGap;
|
|
1846
2762
|
}
|
|
1847
2763
|
function syncScrollLock(state, win) {
|
|
1848
2764
|
if (isIOS(win)) {
|
|
1849
2765
|
syncIOS(state, win);
|
|
1850
|
-
}
|
|
1851
|
-
else {
|
|
2766
|
+
} else {
|
|
1852
2767
|
syncNonIOS(state, win);
|
|
1853
2768
|
}
|
|
1854
2769
|
}
|
|
@@ -1857,23 +2772,50 @@ function activateEntry(state, win, targetElement, options = {}) {
|
|
|
1857
2772
|
entry.active = true;
|
|
1858
2773
|
syncScrollLock(state, win);
|
|
1859
2774
|
}
|
|
1860
|
-
|
|
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 = {}) {
|
|
1861
2779
|
activateEntry(state, win, targetElement, options);
|
|
1862
2780
|
}
|
|
1863
|
-
|
|
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 = {}) {
|
|
1864
2789
|
const index = findEntryIndex(state, targetElement, options.entryKey);
|
|
1865
|
-
if (index < 0)
|
|
1866
|
-
return;
|
|
2790
|
+
if (index < 0) return;
|
|
1867
2791
|
state.entries.splice(index, 1);
|
|
1868
2792
|
syncScrollLock(state, win);
|
|
1869
2793
|
}
|
|
1870
|
-
|
|
2794
|
+
/**
|
|
2795
|
+
* Temporarily disable a lock entry without changing its position in the stack.
|
|
2796
|
+
*/ function bodyScrollLock_pause(state, win, targetElement, options = {}) {
|
|
1871
2797
|
const index = findEntryIndex(state, targetElement, options.entryKey);
|
|
1872
|
-
if (index < 0 || state.entries[index].active === false)
|
|
1873
|
-
return;
|
|
2798
|
+
if (index < 0 || state.entries[index].active === false) return;
|
|
1874
2799
|
state.entries[index].active = false;
|
|
1875
2800
|
syncScrollLock(state, win);
|
|
1876
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
|
+
|
|
1877
2819
|
|
|
1878
2820
|
const SCROLL_LOCK_KEY = '__SUI_SCROLL_LOCK__';
|
|
1879
2821
|
function getStoredScrollLockState(win) {
|
|
@@ -1884,66 +2826,120 @@ function setStoredScrollLockState(win, state) {
|
|
|
1884
2826
|
global[SCROLL_LOCK_KEY] = state;
|
|
1885
2827
|
return state;
|
|
1886
2828
|
}
|
|
1887
|
-
|
|
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) {
|
|
1888
2834
|
const state = getStoredScrollLockState(win);
|
|
1889
2835
|
if (!state) {
|
|
1890
2836
|
return setStoredScrollLockState(win, createInitialState());
|
|
1891
2837
|
}
|
|
1892
2838
|
return state;
|
|
1893
2839
|
}
|
|
2840
|
+
function deleteStoredScrollLockState(win) {
|
|
2841
|
+
delete win[SCROLL_LOCK_KEY];
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
|
|
2847
|
+
|
|
1894
2848
|
|
|
1895
2849
|
function applyLockState(state, win, targetElement, options, disabled) {
|
|
1896
2850
|
lock(state, win, targetElement, options);
|
|
1897
2851
|
if (disabled) {
|
|
1898
|
-
|
|
2852
|
+
bodyScrollLock_pause(state, win, targetElement, options);
|
|
1899
2853
|
}
|
|
1900
2854
|
}
|
|
1901
2855
|
function useResolvedElement(target) {
|
|
1902
|
-
const [resolvedElement, setResolvedElement] = useState(()
|
|
2856
|
+
const [resolvedElement, setResolvedElement] = useState(()=>getRefElement(target) ?? null);
|
|
1903
2857
|
const targetRef = useRef(target);
|
|
1904
2858
|
targetRef.current = target;
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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);
|
|
1909
2865
|
});
|
|
1910
2866
|
return resolvedElement;
|
|
1911
2867
|
}
|
|
1912
|
-
|
|
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 = {})=>{
|
|
1913
2893
|
const { disabled = false, reserveScrollBarGap = true } = options;
|
|
1914
2894
|
const { allowTouchMove } = options;
|
|
1915
2895
|
const entryKeyRef = useRef(Symbol('use-body-scroll-lock-entry'));
|
|
1916
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.
|
|
1917
2899
|
const { globalThis: global } = useWindowContext();
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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;
|
|
1921
2905
|
const win = global;
|
|
1922
2906
|
const state = getOrCreateScrollLockState(global);
|
|
1923
2907
|
const entryKey = entryKeyRef.current;
|
|
1924
2908
|
const syncOptions = {
|
|
1925
2909
|
reserveScrollBarGap,
|
|
1926
2910
|
allowTouchMove,
|
|
1927
|
-
entryKey
|
|
2911
|
+
entryKey
|
|
1928
2912
|
};
|
|
1929
2913
|
applyLockState(state, win, resolvedElement, syncOptions, disabled);
|
|
1930
|
-
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.
|
|
1931
2918
|
const currentState = getStoredScrollLockState(global);
|
|
1932
2919
|
if (currentState) {
|
|
1933
|
-
unlock(currentState, win, resolvedElement, {
|
|
2920
|
+
unlock(currentState, win, resolvedElement, {
|
|
2921
|
+
entryKey
|
|
2922
|
+
});
|
|
1934
2923
|
}
|
|
1935
2924
|
};
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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;
|
|
1940
2936
|
const win = global;
|
|
1941
2937
|
const state = getOrCreateScrollLockState(global);
|
|
1942
2938
|
const entryKey = entryKeyRef.current;
|
|
1943
2939
|
const syncOptions = {
|
|
1944
2940
|
reserveScrollBarGap,
|
|
1945
2941
|
allowTouchMove,
|
|
1946
|
-
entryKey
|
|
2942
|
+
entryKey
|
|
1947
2943
|
};
|
|
1948
2944
|
applyLockState(state, win, resolvedElement, syncOptions, disabled);
|
|
1949
2945
|
}, [
|
|
@@ -1952,18 +2948,95 @@ const useBodyScrollLock = (isLocked, targetElement, options = {}) => {
|
|
|
1952
2948
|
isLocked,
|
|
1953
2949
|
global,
|
|
1954
2950
|
resolvedElement,
|
|
1955
|
-
reserveScrollBarGap
|
|
2951
|
+
reserveScrollBarGap
|
|
1956
2952
|
]);
|
|
1957
2953
|
};
|
|
1958
2954
|
|
|
1959
|
-
|
|
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 = ()=>{
|
|
1960
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
|
|
1961
2985
|
if (externalWindow && externalWindow !== globalThis.window) {
|
|
1962
2986
|
return {
|
|
1963
|
-
externalWindow
|
|
2987
|
+
externalWindow
|
|
1964
2988
|
};
|
|
1965
2989
|
}
|
|
1966
2990
|
return undefined;
|
|
1967
2991
|
};
|
|
1968
2992
|
|
|
1969
|
-
|
|
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 };
|