@ringcentral/spring-base 1.7.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/LICENSE +22 -0
- package/README.md +61 -0
- package/chunks/twMerge.js +409 -0
- package/config.js +78 -0
- package/contexts.js +71 -0
- package/hooks.js +2101 -0
- package/index.d.ts +1 -0
- package/index.js +16 -0
- package/package.json +30 -0
- package/src/config.d.ts +40 -0
- package/src/contexts/ResponsiveContext/ResponsiveContext.d.ts +7 -0
- package/src/contexts/ResponsiveContext/breakpointList.d.ts +3 -0
- package/src/contexts/ResponsiveContext/getMatchDetail.d.ts +2 -0
- package/src/contexts/ResponsiveContext/index.d.ts +4 -0
- package/src/contexts/ResponsiveContext/types.d.ts +24 -0
- package/src/contexts/WindowContext.d.ts +9 -0
- package/src/contexts/index.d.ts +2 -0
- package/src/hooks/index.d.ts +43 -0
- package/src/hooks/useA11yKeyEvent/index.d.ts +1 -0
- package/src/hooks/useA11yKeyEvent/useA11yKeyEvent.d.ts +23 -0
- package/src/hooks/useActive/index.d.ts +1 -0
- package/src/hooks/useActive/useActive.d.ts +23 -0
- package/src/hooks/useAnnouncer/index.d.ts +1 -0
- package/src/hooks/useAnnouncer/useAnnouncer.d.ts +12 -0
- package/src/hooks/useAriaHideOutside/index.d.ts +1 -0
- package/src/hooks/useAriaHideOutside/useAriaHideOutside.d.ts +12 -0
- package/src/hooks/useAudio/index.d.ts +1 -0
- package/src/hooks/useAudio/useAudio.d.ts +21 -0
- package/src/hooks/useBodyScrollLock/index.d.ts +1 -0
- package/src/hooks/useBodyScrollLock/useBodyScrollLock.d.ts +25 -0
- package/src/hooks/useChange/index.d.ts +1 -0
- package/src/hooks/useChange/useChange.d.ts +12 -0
- package/src/hooks/useClickAway/index.d.ts +1 -0
- package/src/hooks/useClickAway/useClickAway.d.ts +22 -0
- package/src/hooks/useControlled/index.d.ts +1 -0
- package/src/hooks/useControlled/useControlled.d.ts +1 -0
- package/src/hooks/useDebounce/index.d.ts +1 -0
- package/src/hooks/useDebounce/useDebounce.d.ts +8 -0
- package/src/hooks/useEventCallback/index.d.ts +1 -0
- package/src/hooks/useEventCallback/useEventCallback.d.ts +1 -0
- package/src/hooks/useEventListener/index.d.ts +1 -0
- package/src/hooks/useEventListener/useEventListener.d.ts +32 -0
- package/src/hooks/useEver/index.d.ts +1 -0
- package/src/hooks/useEver/useEver.d.ts +13 -0
- package/src/hooks/useFocusVisible/index.d.ts +1 -0
- package/src/hooks/useFocusVisible/useFocusVisible.d.ts +22 -0
- package/src/hooks/useFontLoaded/index.d.ts +1 -0
- package/src/hooks/useFontLoaded/useFontLoaded.d.ts +13 -0
- package/src/hooks/useForceUpdate/index.d.ts +1 -0
- package/src/hooks/useForceUpdate/useForceUpdate.d.ts +5 -0
- package/src/hooks/useForkRef/index.d.ts +1 -0
- package/src/hooks/useForkRef/useForkRef.d.ts +19 -0
- package/src/hooks/useGlobalListener/createGlobalListener.d.ts +55 -0
- package/src/hooks/useGlobalListener/index.d.ts +2 -0
- package/src/hooks/useGlobalListener/useGlobalListener.d.ts +14 -0
- package/src/hooks/useHiddenTabindex/index.d.ts +1 -0
- package/src/hooks/useHiddenTabindex/useHiddenTabindex.d.ts +9 -0
- package/src/hooks/useHover/index.d.ts +2 -0
- package/src/hooks/useHover/useHover.d.ts +17 -0
- package/src/hooks/useHover/useHoverDirty.d.ts +8 -0
- package/src/hooks/useId/index.d.ts +1 -0
- package/src/hooks/useId/useId.d.ts +15 -0
- package/src/hooks/useImgLoaded/index.d.ts +1 -0
- package/src/hooks/useImgLoaded/useImgLoaded.d.ts +24 -0
- package/src/hooks/useInterval/index.d.ts +1 -0
- package/src/hooks/useInterval/useInterval.d.ts +43 -0
- package/src/hooks/useIsFocusVisible/index.d.ts +2 -0
- package/src/hooks/useIsFocusVisible/useIsFocusVisible.d.ts +9 -0
- package/src/hooks/useKeyDownOnce/index.d.ts +1 -0
- package/src/hooks/useKeyDownOnce/useKeyDownOnce.d.ts +15 -0
- package/src/hooks/useKeyboardMoveFocus/index.d.ts +3 -0
- package/src/hooks/useKeyboardMoveFocus/moveIndexInTwoDimension.d.ts +18 -0
- package/src/hooks/useKeyboardMoveFocus/useKeyboardMoveFocus.d.ts +49 -0
- package/src/hooks/useKeyboardMoveFocus/useOnlyOneFocusable.d.ts +17 -0
- package/src/hooks/useLongPress/index.d.ts +1 -0
- package/src/hooks/useLongPress/useLongPress.d.ts +39 -0
- package/src/hooks/useMountState/index.d.ts +1 -0
- package/src/hooks/useMountState/useMountState.d.ts +16 -0
- package/src/hooks/useOnLayoutReRender/index.d.ts +1 -0
- package/src/hooks/useOnLayoutReRender/useOnLayoutReRender.d.ts +5 -0
- package/src/hooks/useOnReRender/index.d.ts +1 -0
- package/src/hooks/useOnReRender/useOnReRender.d.ts +5 -0
- package/src/hooks/useOverflow/index.d.ts +1 -0
- package/src/hooks/useOverflow/useOverflow.d.ts +11 -0
- package/src/hooks/usePrevious/index.d.ts +1 -0
- package/src/hooks/usePrevious/usePrevious.d.ts +7 -0
- package/src/hooks/useRefState/index.d.ts +1 -0
- package/src/hooks/useRefState/useRefState.d.ts +21 -0
- package/src/hooks/useResizeObserver/index.d.ts +1 -0
- package/src/hooks/useResizeObserver/useResizeObserver.d.ts +24 -0
- package/src/hooks/useResponsiveMatch/index.d.ts +1 -0
- package/src/hooks/useResponsiveMatch/useResponsiveMatch.d.ts +7 -0
- package/src/hooks/useResultRef/index.d.ts +1 -0
- package/src/hooks/useResultRef/useResultRef.d.ts +10 -0
- package/src/hooks/useRetry/index.d.ts +1 -0
- package/src/hooks/useRetry/useRetry.d.ts +35 -0
- package/src/hooks/useSleep/index.d.ts +1 -0
- package/src/hooks/useSleep/useSleep.d.ts +22 -0
- package/src/hooks/useTabOut/index.d.ts +1 -0
- package/src/hooks/useTabOut/useTabOut.d.ts +10 -0
- package/src/hooks/useTestRunnerDetection.d.ts +7 -0
- package/src/hooks/useThrottle/index.d.ts +1 -0
- package/src/hooks/useThrottle/useThrottle.d.ts +8 -0
- package/src/hooks/useTimeout/index.d.ts +1 -0
- package/src/hooks/useTimeout/useTimeout.d.ts +2 -0
- package/src/hooks/useTouchMouseEvent/index.d.ts +1 -0
- package/src/hooks/useTouchMouseEvent/useTouchMouseEvent.d.ts +56 -0
- package/src/index.d.ts +5 -0
- package/src/typings/BaseColor.d.ts +14 -0
- package/src/typings/BaseSize.d.ts +15 -0
- package/src/typings/ComponentTypes.d.ts +50 -0
- package/src/typings/ExtractValue.d.ts +2 -0
- package/src/typings/OverridableTypes.d.ts +45 -0
- package/src/typings/UnionOmit.d.ts +3 -0
- package/src/typings/UnitMap.d.ts +28 -0
- package/src/typings/index.d.ts +7 -0
- package/src/utils/a11yKeyboardCode.d.ts +8 -0
- package/src/utils/ariaHideOutside.d.ts +9 -0
- package/src/utils/capitalize.d.ts +1 -0
- package/src/utils/checkDefaultPrevented.d.ts +3 -0
- package/src/utils/checkElementName.d.ts +2 -0
- package/src/utils/checkKeyboardEvent.d.ts +2 -0
- package/src/utils/classes/globalStateClasses.d.ts +14 -0
- package/src/utils/classes/index.d.ts +2 -0
- package/src/utils/classes/useClasses.d.ts +41 -0
- package/src/utils/combineProps.d.ts +90 -0
- package/src/utils/devlog.d.ts +7 -0
- package/src/utils/getRefElement.d.ts +6 -0
- package/src/utils/getTitle.d.ts +1 -0
- package/src/utils/index.d.ts +25 -0
- package/src/utils/isRef.d.ts +2 -0
- package/src/utils/isSuiElement.d.ts +1 -0
- package/src/utils/isUrl.d.ts +1 -0
- package/src/utils/isWebKit154.d.ts +9 -0
- package/src/utils/omit.d.ts +2 -0
- package/src/utils/outerSize.d.ts +8 -0
- package/src/utils/ownerDocument.d.ts +1 -0
- package/src/utils/ownerWindow.d.ts +1 -0
- package/src/utils/pick.d.ts +2 -0
- package/src/utils/preloadImg.d.ts +2 -0
- package/src/utils/removeClassName.d.ts +35 -0
- package/src/utils/runKeyHandler.d.ts +12 -0
- package/src/utils/selectionHandler.d.ts +23 -0
- package/src/utils/twMerge.d.ts +4 -0
- package/typings.js +1 -0
- package/utils.js +333 -0
package/hooks.js
ADDED
|
@@ -0,0 +1,2101 @@
|
|
|
1
|
+
import { unstable_useEventCallback, unstable_ownerDocument, unstable_Timeout, unstable_useForkRef } from '@mui/utils';
|
|
2
|
+
export { unstable_useControlled as useControlled } from '@mui/utils';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useState, useEffect, useRef, useCallback, useLayoutEffect, useMemo } from 'react';
|
|
5
|
+
import uniqueId from 'lodash/uniqueId';
|
|
6
|
+
import { useWindowContext, useResponsiveContext, getMatchDetail } from './contexts.js';
|
|
7
|
+
import { a as ariaHideOutside, g as getRefElement, l as logInDev, c as combineProps, o as ownerWindow } from './chunks/twMerge.js';
|
|
8
|
+
import 'lodash/isPlainObject';
|
|
9
|
+
import './config.js';
|
|
10
|
+
import 'lodash/omit';
|
|
11
|
+
import 'lodash/pick';
|
|
12
|
+
import debounce from 'lodash/debounce';
|
|
13
|
+
import noop from 'lodash/noop';
|
|
14
|
+
import throttle from 'lodash/throttle';
|
|
15
|
+
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
|
|
16
|
+
import 'tailwind-merge';
|
|
17
|
+
|
|
18
|
+
// this file template replace material-ui/core/utils method
|
|
19
|
+
// when upgrade mui that will not need assign type anymore
|
|
20
|
+
const useEventCallback = (func) => {
|
|
21
|
+
return unstable_useEventCallback(func);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const defaultCheckKeys = ['Enter', ' '];
|
|
25
|
+
/**
|
|
26
|
+
* bind event with a11y keyboard, `space`, `enter`, only trigger when key includes in `checkKeys`
|
|
27
|
+
*
|
|
28
|
+
* that will auto preventDefault by default, if you don't want that, set `false` with `preventDefault`
|
|
29
|
+
*/
|
|
30
|
+
const useA11yKeyEvent = (fn, { onlyOnFocus, preventDefault = true, checkKeys = defaultCheckKeys, } = {}) => {
|
|
31
|
+
return useEventCallback((event) => {
|
|
32
|
+
if (!fn || (onlyOnFocus && event.target !== event.currentTarget))
|
|
33
|
+
return;
|
|
34
|
+
if (checkKeys.includes(event.key)) {
|
|
35
|
+
if (preventDefault)
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
fn(event);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// base https://github.com/mui/material-ui/blob/master/packages/mui-base/src/useButton/useButton.ts
|
|
43
|
+
const isNativeButton = (el) => {
|
|
44
|
+
const tag = el.tagName;
|
|
45
|
+
return (tag === 'BUTTON' ||
|
|
46
|
+
(tag === 'INPUT' &&
|
|
47
|
+
['button', 'submit', 'reset'].includes(el?.type)) ||
|
|
48
|
+
(tag === 'A' && el?.href));
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* bind event with a11y keyboard, `space`. help not native button to implement `:active`
|
|
52
|
+
*
|
|
53
|
+
* that will auto preventDefault by default, if you don't want that, set `false` with `preventDefault`
|
|
54
|
+
*/
|
|
55
|
+
const useActive = ({ onKeyDown, onKeyUp, onClick, disabled = false, } = {}) => {
|
|
56
|
+
const [active, setActive] = useState(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (disabled)
|
|
59
|
+
setActive(false);
|
|
60
|
+
}, [disabled]);
|
|
61
|
+
if (disabled) {
|
|
62
|
+
return {
|
|
63
|
+
activeHandlers: { onKeyDown, onKeyUp, onClick },
|
|
64
|
+
active: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const handleKeyDown = (event) => {
|
|
68
|
+
onKeyDown?.(event);
|
|
69
|
+
if (event.defaultPrevented) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (event.target === event.currentTarget &&
|
|
73
|
+
!isNativeButton(event.target) &&
|
|
74
|
+
event.key === ' ') {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
}
|
|
77
|
+
if (event.target === event.currentTarget && event.key === ' ') {
|
|
78
|
+
setActive(true);
|
|
79
|
+
}
|
|
80
|
+
// Keyboard accessibility for non interactive elements
|
|
81
|
+
if (event.target === event.currentTarget &&
|
|
82
|
+
!isNativeButton(event.target) &&
|
|
83
|
+
event.key === 'Enter') {
|
|
84
|
+
onClick?.(event);
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const handleKeyUp = (event) => {
|
|
89
|
+
// calling preventDefault in keyUp on a <button> will not dispatch a click event if Space is pressed
|
|
90
|
+
// https://codesandbox.io/s/button-keyup-preventdefault-dn7f0
|
|
91
|
+
if (event.target === event.currentTarget) {
|
|
92
|
+
setActive(false);
|
|
93
|
+
}
|
|
94
|
+
onKeyUp?.(event);
|
|
95
|
+
// Keyboard accessibility for non interactive elements
|
|
96
|
+
if (event.target === event.currentTarget &&
|
|
97
|
+
!isNativeButton(event.target) &&
|
|
98
|
+
event.key === ' ' &&
|
|
99
|
+
!event.defaultPrevented) {
|
|
100
|
+
onClick?.(event);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
activeHandlers: { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick },
|
|
105
|
+
active,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* provide a ref for only calculate `cb` once
|
|
111
|
+
* prevent `useRef(getValue())`, that `getValue` calculate every re-render
|
|
112
|
+
*
|
|
113
|
+
* @param cb that get method of get this ref init value, only calculate once
|
|
114
|
+
* @returns same as `useRef` result
|
|
115
|
+
*
|
|
116
|
+
*/
|
|
117
|
+
function useResultRef(cb) {
|
|
118
|
+
const renderRef = useRef(0);
|
|
119
|
+
const ref = useRef();
|
|
120
|
+
if (renderRef.current === 0) {
|
|
121
|
+
ref.current = cb();
|
|
122
|
+
renderRef.current = 1;
|
|
123
|
+
}
|
|
124
|
+
return ref;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DEFAULT_PREFIX = 'spring-id-';
|
|
128
|
+
/**
|
|
129
|
+
* Generates a unique ID.
|
|
130
|
+
*
|
|
131
|
+
* @param id - Default component id or prefix id.
|
|
132
|
+
* @param prefixMode Whether enable prefix the ID with.
|
|
133
|
+
* @returns unique ID
|
|
134
|
+
* @example
|
|
135
|
+
* useId() => 'spring-id-xxxx'
|
|
136
|
+
* useId('') => 'spring-id-xxxx'
|
|
137
|
+
* useId(null) => 'spring-id-xxxx'
|
|
138
|
+
* useId(undefined) => 'spring-id-xxxx'
|
|
139
|
+
* useId('nameId') => 'nameId'
|
|
140
|
+
* useId('nameId', true) => 'nameId-xxxx'
|
|
141
|
+
*/
|
|
142
|
+
function useId(id, prefixMode) {
|
|
143
|
+
const { current: resultId } = useResultRef(() => {
|
|
144
|
+
if (id) {
|
|
145
|
+
if (prefixMode) {
|
|
146
|
+
return uniqueId(`${id}-`);
|
|
147
|
+
}
|
|
148
|
+
return id;
|
|
149
|
+
}
|
|
150
|
+
return uniqueId(DEFAULT_PREFIX);
|
|
151
|
+
});
|
|
152
|
+
return resultId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const visuallyHiddenStyles = `
|
|
156
|
+
clip-path: inset(100%);
|
|
157
|
+
clip: rect(1px, 1px, 1px, 1px);
|
|
158
|
+
height: 1px;
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
position: absolute;
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
width: 1px;
|
|
163
|
+
`;
|
|
164
|
+
/**
|
|
165
|
+
* read announce when need read message.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```ts
|
|
169
|
+
* const announce = useAnnouncer('simpleId');
|
|
170
|
+
*
|
|
171
|
+
* announce(`You click "Announce" button`);
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
const useAnnouncer = (id) => {
|
|
175
|
+
const _id = useId(id ? `rc-announcer-${id}` : 'rc-announcer', !id);
|
|
176
|
+
const ref = useRef(null);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const el = document.createElement('div');
|
|
179
|
+
ref.current = el;
|
|
180
|
+
el.id = _id;
|
|
181
|
+
el.setAttribute('aria-live', 'assertive');
|
|
182
|
+
el.setAttribute('aria-atomic', 'true');
|
|
183
|
+
el.setAttribute('aria-hidden', 'false');
|
|
184
|
+
el.setAttribute('style', visuallyHiddenStyles);
|
|
185
|
+
document.body.appendChild(el);
|
|
186
|
+
return () => {
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
const body = document.body;
|
|
189
|
+
if (body.contains(el)) {
|
|
190
|
+
body.removeChild(el);
|
|
191
|
+
}
|
|
192
|
+
if (el === ref.current) {
|
|
193
|
+
ref.current = null;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
}, [_id]);
|
|
198
|
+
/** announce method, call with message that want let reader speak */
|
|
199
|
+
return useCallback((message) => {
|
|
200
|
+
const el = ref.current;
|
|
201
|
+
if (el && message) {
|
|
202
|
+
el.setAttribute('aria-hidden', 'false');
|
|
203
|
+
el.textContent = message;
|
|
204
|
+
}
|
|
205
|
+
}, []);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const useAriaHideOutside = (props = {}) => {
|
|
209
|
+
const { enable = true } = props;
|
|
210
|
+
const { externalWindow, document } = useWindowContext();
|
|
211
|
+
const ariaHideOutsideRef = useRef(null);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (enable && ariaHideOutsideRef.current) {
|
|
214
|
+
return ariaHideOutside([ariaHideOutsideRef.current], document?.body);
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}, [enable, externalWindow, document?.body]);
|
|
218
|
+
return {
|
|
219
|
+
ariaHideOutsideRef: ariaHideOutsideRef,
|
|
220
|
+
/**
|
|
221
|
+
* Whether the `aria-hidden` attribute is being applied to outside elements.
|
|
222
|
+
* When true, elements outside the ref are hidden from screen readers.
|
|
223
|
+
*/
|
|
224
|
+
ariaHideEnabled: enable,
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* get Browser `Audio` instance, and auto destroy when component be destroyed.
|
|
230
|
+
*
|
|
231
|
+
*
|
|
232
|
+
* @param init a callback when you init that audio
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
*
|
|
236
|
+
* ```ts
|
|
237
|
+
* const audio = useAudio((instance) => {
|
|
238
|
+
* instance.src = 'https://your.audio'
|
|
239
|
+
* });
|
|
240
|
+
*
|
|
241
|
+
* const click = () => audio.play();
|
|
242
|
+
*
|
|
243
|
+
* click();
|
|
244
|
+
* ```
|
|
245
|
+
* @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}
|
|
246
|
+
* @see {@link https://chromium-review.googlesource.com/c/chromium/src/+/2816118}
|
|
247
|
+
*/
|
|
248
|
+
const useAudio = (init) => {
|
|
249
|
+
const audioRef = useResultRef(() => {
|
|
250
|
+
const audio = new Audio();
|
|
251
|
+
init?.(audio);
|
|
252
|
+
return audio;
|
|
253
|
+
});
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
return () => {
|
|
256
|
+
audioRef.current.srcObject = null;
|
|
257
|
+
// just for remove ref, confirm browser GC work correctly
|
|
258
|
+
audioRef.current = null;
|
|
259
|
+
};
|
|
260
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
+
}, []);
|
|
262
|
+
return audioRef.current;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* get previous render value
|
|
267
|
+
* @param cb that get method of target value
|
|
268
|
+
* @param isDefault if set `true`, the first time will get value from `cb`
|
|
269
|
+
* @returns previous value get from `cb`
|
|
270
|
+
*/
|
|
271
|
+
function usePrevious(cb, isDefault) {
|
|
272
|
+
const ref = useRef(isDefault ? cb() : undefined);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
ref.current = cb();
|
|
275
|
+
});
|
|
276
|
+
return ref.current;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* get value when prev listen value change
|
|
281
|
+
* @param cb that method you want calculate to get value
|
|
282
|
+
* @param listenCb prev compare listener value
|
|
283
|
+
*/
|
|
284
|
+
const useChange = (cb, listenCb) => {
|
|
285
|
+
const countRef = useRef(0);
|
|
286
|
+
const valueRef = useRef();
|
|
287
|
+
const prev = usePrevious(listenCb);
|
|
288
|
+
const curr = listenCb();
|
|
289
|
+
if (countRef.current === 0 || prev !== curr) {
|
|
290
|
+
countRef.current = 1;
|
|
291
|
+
valueRef.current = cb(prev, curr);
|
|
292
|
+
}
|
|
293
|
+
return valueRef.current;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* get value when prev deps one of array value change
|
|
297
|
+
* @param cb that method you want calculate to get value
|
|
298
|
+
* @param deps prev compare value array
|
|
299
|
+
*/
|
|
300
|
+
const useDepsChange = (cb, deps) => {
|
|
301
|
+
const countRef = useRef(0);
|
|
302
|
+
const valueRef = useRef();
|
|
303
|
+
const prev = usePrevious(() => deps, true);
|
|
304
|
+
const curr = deps;
|
|
305
|
+
if (countRef.current === 0 ||
|
|
306
|
+
curr.some((dep, index) => dep !== prev[index])) {
|
|
307
|
+
countRef.current = 1;
|
|
308
|
+
valueRef.current = cb(prev, curr);
|
|
309
|
+
}
|
|
310
|
+
return valueRef.current;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// base https://github.com/mui/material-ui/blob/master/packages/mui-base/src/ClickAwayListener/ClickAwayListener.tsx
|
|
314
|
+
function clickedRootScrollbar(event, doc) {
|
|
315
|
+
return (doc.documentElement.clientWidth < event.clientX ||
|
|
316
|
+
doc.documentElement.clientHeight < event.clientY);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* @deprecated Use `ClickAwayListener` from `@mui/base` instead.
|
|
320
|
+
*
|
|
321
|
+
* @param onClickAway Callback fired when a "click away" event is detected.
|
|
322
|
+
*/
|
|
323
|
+
const useClickAway = (onClickAway, props = {}) => {
|
|
324
|
+
const { disableReactTree = false, isEnabled = true } = props;
|
|
325
|
+
const movedRef = useRef(false);
|
|
326
|
+
const activatedRef = useRef(false);
|
|
327
|
+
const syntheticEventRef = useRef(false);
|
|
328
|
+
const [target, setTarget] = useState();
|
|
329
|
+
// This hook is usually triggered after target appears
|
|
330
|
+
// so we need to trigger re-render component in ref callback to make sure useEffect can get target correctly
|
|
331
|
+
const clickAwayRef = useEventCallback((e) => {
|
|
332
|
+
setTarget(e);
|
|
333
|
+
});
|
|
334
|
+
const shouldActive = Boolean(target);
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
if (!isEnabled)
|
|
337
|
+
return;
|
|
338
|
+
if (shouldActive) {
|
|
339
|
+
// Ensure that this component is not "activated" synchronously.
|
|
340
|
+
// https://github.com/facebook/react/issues/20074
|
|
341
|
+
const timer = setTimeout(() => {
|
|
342
|
+
activatedRef.current = true;
|
|
343
|
+
}, 0);
|
|
344
|
+
return () => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
activatedRef.current = false;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}, [isEnabled, shouldActive]);
|
|
351
|
+
// The handler doesn't take event.defaultPrevented into account:
|
|
352
|
+
//
|
|
353
|
+
// event.preventDefault() is meant to stop default behaviors like
|
|
354
|
+
// clicking a checkbox to check it, hitting a button to submit a form,
|
|
355
|
+
// and hitting left arrow to move the cursor in a text input etc.
|
|
356
|
+
// Only special HTML elements have these default behaviors.
|
|
357
|
+
const handleClickAway = useEventCallback((event) => {
|
|
358
|
+
// Given developers can stop the propagation of the synthetic event,
|
|
359
|
+
// we can only be confident with a positive value.
|
|
360
|
+
const insideReactTree = syntheticEventRef.current;
|
|
361
|
+
syntheticEventRef.current = false;
|
|
362
|
+
const doc = unstable_ownerDocument(target);
|
|
363
|
+
// 1. IE11 support, which trigger the handleClickAway even after the unbind
|
|
364
|
+
// 2. The child might render null.
|
|
365
|
+
// 3. Behave like a blur listener.
|
|
366
|
+
if (!activatedRef.current ||
|
|
367
|
+
!target ||
|
|
368
|
+
('clientX' in event && clickedRootScrollbar(event, doc))) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Do not act if user performed touchmove
|
|
372
|
+
if (movedRef.current) {
|
|
373
|
+
movedRef.current = false;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
let insideDOM;
|
|
377
|
+
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js
|
|
378
|
+
if (event.composedPath) {
|
|
379
|
+
insideDOM = event.composedPath().indexOf(target) > -1;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
insideDOM =
|
|
383
|
+
!doc.documentElement.contains(
|
|
384
|
+
// @ts-expect-error returns `false` as intended when not dispatched from a Node
|
|
385
|
+
event.target) ||
|
|
386
|
+
target.contains(
|
|
387
|
+
// @ts-expect-error returns `false` as intended when not dispatched from a Node
|
|
388
|
+
event.target);
|
|
389
|
+
}
|
|
390
|
+
if (!insideDOM && (disableReactTree || !insideReactTree)) {
|
|
391
|
+
onClickAway(event);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
// Keep track of mouse/touch events that bubbled up through the portal.
|
|
395
|
+
const handleSynthetic = (e) => {
|
|
396
|
+
syntheticEventRef.current = true;
|
|
397
|
+
};
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!isEnabled)
|
|
400
|
+
return;
|
|
401
|
+
if (target) {
|
|
402
|
+
const doc = unstable_ownerDocument(target);
|
|
403
|
+
const handleTouchMove = () => {
|
|
404
|
+
movedRef.current = true;
|
|
405
|
+
};
|
|
406
|
+
doc.addEventListener('touchend', handleClickAway);
|
|
407
|
+
doc.addEventListener('touchmove', handleTouchMove);
|
|
408
|
+
doc.addEventListener('click', handleClickAway);
|
|
409
|
+
return () => {
|
|
410
|
+
doc.removeEventListener('touchend', handleClickAway);
|
|
411
|
+
doc.removeEventListener('touchmove', handleTouchMove);
|
|
412
|
+
doc.removeEventListener('click', handleClickAway);
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}, [handleClickAway, isEnabled, target]);
|
|
417
|
+
return {
|
|
418
|
+
clickAwayRef,
|
|
419
|
+
clickAwayHandler: {
|
|
420
|
+
onClick: handleSynthetic,
|
|
421
|
+
onTouchEnd: handleSynthetic,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* provide a debounce method for debounce method
|
|
428
|
+
* @param fn method for debounce
|
|
429
|
+
* @param debounceTime debounce timeout value, default is `200`ms
|
|
430
|
+
* @param options debounce setting, default is `{trailing: true, leading: flase}`, detail see lodash
|
|
431
|
+
*/
|
|
432
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
433
|
+
const useDebounce = (fn, debounceTime = 200, options) => {
|
|
434
|
+
const memoFn = useEventCallback(fn);
|
|
435
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
436
|
+
const debounceFn = useCallback(debounce(memoFn, debounceTime, options), []);
|
|
437
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
438
|
+
useEffect(() => () => debounceFn.cancel(), []);
|
|
439
|
+
return debounceFn;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* bind event when component mount and auto remove event when destroy.
|
|
444
|
+
* can remove listener by call returned function or pass empty 'target'.
|
|
445
|
+
*
|
|
446
|
+
* @param target
|
|
447
|
+
* listener will be removed when target is changed to null,
|
|
448
|
+
* or rebinding to new element when `ref.current` change
|
|
449
|
+
* @returns a method object
|
|
450
|
+
* - listen() listen listener again, if that have listener, that will not listen again
|
|
451
|
+
* - remove() remove listener
|
|
452
|
+
* @example
|
|
453
|
+
* ```ts
|
|
454
|
+
* useEventListener(window, 'keyup', () => console.log('window key up'))
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
function useEventListener(target, key, callback, options) {
|
|
458
|
+
const { startImmediately = true, ...addEventListenerOptions } = options ?? {};
|
|
459
|
+
const listener = useEventCallback(callback);
|
|
460
|
+
const cancelRef = useRef(noop);
|
|
461
|
+
const bindRef = useRef(noop);
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (!target)
|
|
464
|
+
return;
|
|
465
|
+
const currentElm = getRefElement(target);
|
|
466
|
+
if (!currentElm) {
|
|
467
|
+
return cancelRef.current;
|
|
468
|
+
}
|
|
469
|
+
bindRef.current = () => currentElm.addEventListener(key, listener, addEventListenerOptions);
|
|
470
|
+
cancelRef.current = () => currentElm.removeEventListener(key, listener, addEventListenerOptions);
|
|
471
|
+
if (startImmediately) {
|
|
472
|
+
bindRef.current();
|
|
473
|
+
}
|
|
474
|
+
return cancelRef.current;
|
|
475
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
476
|
+
}, [target]);
|
|
477
|
+
return {
|
|
478
|
+
listen: () => bindRef.current(),
|
|
479
|
+
remove: () => cancelRef.current(),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const defaultEverTargetValue = (value) => !!value;
|
|
484
|
+
/**
|
|
485
|
+
* that value ever to be `you want value`
|
|
486
|
+
* Return true when the value ever to be `you want value` once.
|
|
487
|
+
*
|
|
488
|
+
* @example
|
|
489
|
+
* ```ts
|
|
490
|
+
* // once that isOpen is true, that result will always be `true`
|
|
491
|
+
* const isEverOpen = useEver(isOpen);
|
|
492
|
+
*
|
|
493
|
+
* const isEverToBe2 = useEver(count, (val) => val === 2);
|
|
494
|
+
* ```
|
|
495
|
+
*/
|
|
496
|
+
const useEver = (
|
|
497
|
+
/** to be checked value */
|
|
498
|
+
value,
|
|
499
|
+
/**
|
|
500
|
+
* value ever to be callback, default when value to be exited.
|
|
501
|
+
*
|
|
502
|
+
* @default (value) => !!value
|
|
503
|
+
*/
|
|
504
|
+
targetValue = defaultEverTargetValue) => {
|
|
505
|
+
const result = useRef(false);
|
|
506
|
+
if (!result.current) {
|
|
507
|
+
result.current = targetValue(value);
|
|
508
|
+
}
|
|
509
|
+
return result.current;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/* eslint-disable sonarjs/no-collapsible-if */
|
|
513
|
+
/* eslint-disable sonarjs/prefer-single-boolean-return */
|
|
514
|
+
let hadKeyboardEvent = true;
|
|
515
|
+
let hadFocusVisibleRecently = false;
|
|
516
|
+
const hadFocusVisibleRecentlyTimeout = new unstable_Timeout();
|
|
517
|
+
const inputTypesWhitelist = {
|
|
518
|
+
text: true,
|
|
519
|
+
search: true,
|
|
520
|
+
url: true,
|
|
521
|
+
tel: true,
|
|
522
|
+
email: true,
|
|
523
|
+
password: true,
|
|
524
|
+
number: true,
|
|
525
|
+
date: true,
|
|
526
|
+
month: true,
|
|
527
|
+
week: true,
|
|
528
|
+
time: true,
|
|
529
|
+
datetime: true,
|
|
530
|
+
'datetime-local': true,
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* Computes whether the given element should automatically trigger the
|
|
534
|
+
* `focus-visible` class being added, i.e. whether it should always match
|
|
535
|
+
* `:focus-visible` when focused.
|
|
536
|
+
* @param {Element} node
|
|
537
|
+
* @returns {boolean}
|
|
538
|
+
*/
|
|
539
|
+
function focusTriggersKeyboardModality(node) {
|
|
540
|
+
const { type, tagName } = node;
|
|
541
|
+
if (tagName === 'INPUT' &&
|
|
542
|
+
inputTypesWhitelist[type] &&
|
|
543
|
+
!node.readOnly) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
if (tagName === 'TEXTAREA' && !node.readOnly) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (node.isContentEditable) {
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Keep track of our keyboard modality state with `hadKeyboardEvent`.
|
|
556
|
+
* If the most recent user interaction was via the keyboard;
|
|
557
|
+
* and the key press did not include a meta, alt/option, or control key;
|
|
558
|
+
* then the modality is keyboard. Otherwise, the modality is not keyboard.
|
|
559
|
+
* @param {KeyboardEvent} event
|
|
560
|
+
*/
|
|
561
|
+
function handleKeyDown(event) {
|
|
562
|
+
if (event.metaKey || event.altKey || event.ctrlKey) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
hadKeyboardEvent = true;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* If at any point a user clicks with a pointing device, ensure that we change
|
|
569
|
+
* the modality away from keyboard.
|
|
570
|
+
* This avoids the situation where a user presses a key on an already focused
|
|
571
|
+
* element, and then clicks on a different element, focusing it with a
|
|
572
|
+
* pointing device, while we still think we're in keyboard modality.
|
|
573
|
+
*/
|
|
574
|
+
function handlePointerDown() {
|
|
575
|
+
hadKeyboardEvent = false;
|
|
576
|
+
}
|
|
577
|
+
function handleVisibilityChange() {
|
|
578
|
+
if (this.visibilityState === 'hidden') {
|
|
579
|
+
// If the tab becomes active again, the browser will handle calling focus
|
|
580
|
+
// on the element (Safari actually calls it twice).
|
|
581
|
+
// If this tab change caused a blur on an element with focus-visible,
|
|
582
|
+
// re-apply the class when the user switches back to the tab.
|
|
583
|
+
if (hadFocusVisibleRecently) {
|
|
584
|
+
hadKeyboardEvent = true;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function prepare(doc) {
|
|
589
|
+
doc.addEventListener('keydown', handleKeyDown, true);
|
|
590
|
+
doc.addEventListener('mousedown', handlePointerDown, true);
|
|
591
|
+
doc.addEventListener('pointerdown', handlePointerDown, true);
|
|
592
|
+
doc.addEventListener('touchstart', handlePointerDown, true);
|
|
593
|
+
doc.addEventListener('visibilitychange', handleVisibilityChange, true);
|
|
594
|
+
}
|
|
595
|
+
function teardown(doc) {
|
|
596
|
+
doc.removeEventListener('keydown', handleKeyDown, true);
|
|
597
|
+
doc.removeEventListener('mousedown', handlePointerDown, true);
|
|
598
|
+
doc.removeEventListener('pointerdown', handlePointerDown, true);
|
|
599
|
+
doc.removeEventListener('touchstart', handlePointerDown, true);
|
|
600
|
+
doc.removeEventListener('visibilitychange', handleVisibilityChange, true);
|
|
601
|
+
}
|
|
602
|
+
function isFocusVisible(event) {
|
|
603
|
+
const { target } = event;
|
|
604
|
+
try {
|
|
605
|
+
return target.matches(':focus-visible');
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
// Browsers not implementing :focus-visible will throw a SyntaxError.
|
|
609
|
+
// We use our own heuristic for those browsers.
|
|
610
|
+
// Rethrow might be better if it's not the expected error but do we really
|
|
611
|
+
// want to crash if focus-visible malfunctioned?
|
|
612
|
+
}
|
|
613
|
+
// No need for validFocusTarget check. The user does that by attaching it to
|
|
614
|
+
// focusable events only.
|
|
615
|
+
return hadKeyboardEvent || focusTriggersKeyboardModality(target);
|
|
616
|
+
}
|
|
617
|
+
function useIsFocusVisible() {
|
|
618
|
+
const ref = React.useCallback((node) => {
|
|
619
|
+
if (node != null) {
|
|
620
|
+
prepare(node.ownerDocument);
|
|
621
|
+
}
|
|
622
|
+
}, []);
|
|
623
|
+
const isFocusVisibleRef = React.useRef(false);
|
|
624
|
+
/**
|
|
625
|
+
* Should be called if a blur event is fired
|
|
626
|
+
*/
|
|
627
|
+
function handleBlurVisible() {
|
|
628
|
+
// checking against potential state variable does not suffice if we focus and blur synchronously.
|
|
629
|
+
// React wouldn't have time to trigger a re-render so `focusVisible` would be stale.
|
|
630
|
+
// Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events.
|
|
631
|
+
// This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751
|
|
632
|
+
// TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186).
|
|
633
|
+
if (isFocusVisibleRef.current) {
|
|
634
|
+
// To detect a tab/window switch, we look for a blur event followed
|
|
635
|
+
// rapidly by a visibility change.
|
|
636
|
+
// If we don't see a visibility change within 100ms, it's probably a
|
|
637
|
+
// regular focus change.
|
|
638
|
+
hadFocusVisibleRecently = true;
|
|
639
|
+
hadFocusVisibleRecentlyTimeout.start(100, () => {
|
|
640
|
+
hadFocusVisibleRecently = false;
|
|
641
|
+
});
|
|
642
|
+
isFocusVisibleRef.current = false;
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Should be called if a blur event is fired
|
|
649
|
+
*/
|
|
650
|
+
function handleFocusVisible(event) {
|
|
651
|
+
if (isFocusVisible(event)) {
|
|
652
|
+
isFocusVisibleRef.current = true;
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
isFocusVisibleRef,
|
|
659
|
+
onFocus: handleFocusVisible,
|
|
660
|
+
onBlur: handleBlurVisible,
|
|
661
|
+
ref,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* bind event with a11y keyboard, `space`, `enter`, only trigger when key includes in `checkKeys`
|
|
667
|
+
*
|
|
668
|
+
* that will auto preventDefault by default, if you don't want that, set `false` with `preventDefault`
|
|
669
|
+
*/
|
|
670
|
+
const useFocusVisible = ({ onFocusVisible, onFocus, onBlur, within = false, } = {}) => {
|
|
671
|
+
const { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref: focusVisibleRef, } = useIsFocusVisible();
|
|
672
|
+
const [focusVisible, setFocusVisible] = useState(false);
|
|
673
|
+
const [focusVisibleWithin, setFocusVisibleWithin] = useState(false);
|
|
674
|
+
const handleBlur = (event) => {
|
|
675
|
+
handleBlurVisible(event);
|
|
676
|
+
if (isFocusVisibleRef.current === false) {
|
|
677
|
+
setFocusVisible(false);
|
|
678
|
+
/**
|
|
679
|
+
* when focus element by press `tab`, will trigger focus event in next event loop
|
|
680
|
+
*
|
|
681
|
+
* if current target is containing related target and we don't check whether it is containing related target,
|
|
682
|
+
* `FocusVisibleWithin` will be false before `focus` trigger,
|
|
683
|
+
* then `FocusVisibleWithin` will be true after `focus` trigger
|
|
684
|
+
*
|
|
685
|
+
* And if current target is containing related target, `FocusVisibleWithin` will never be false
|
|
686
|
+
*/
|
|
687
|
+
if (within &&
|
|
688
|
+
!event.currentTarget.contains(event.relatedTarget)) {
|
|
689
|
+
setFocusVisibleWithin(false);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
onBlur?.(event);
|
|
693
|
+
};
|
|
694
|
+
const handleFocus = (event) => {
|
|
695
|
+
let updateState;
|
|
696
|
+
if (within) {
|
|
697
|
+
if (event.target === event.currentTarget) {
|
|
698
|
+
updateState = () => {
|
|
699
|
+
setFocusVisibleWithin(true);
|
|
700
|
+
setFocusVisible(true);
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
updateState = () => {
|
|
705
|
+
setFocusVisibleWithin(true);
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else if (event.target === event.currentTarget) {
|
|
710
|
+
updateState = () => {
|
|
711
|
+
setFocusVisible(true);
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
handleFocusVisible(event);
|
|
718
|
+
if (isFocusVisibleRef.current === true) {
|
|
719
|
+
updateState();
|
|
720
|
+
onFocusVisible?.(event);
|
|
721
|
+
}
|
|
722
|
+
onFocus?.(event);
|
|
723
|
+
};
|
|
724
|
+
return {
|
|
725
|
+
focusVisibleHandlers: {
|
|
726
|
+
onBlur: handleBlur,
|
|
727
|
+
onFocus: handleFocus,
|
|
728
|
+
},
|
|
729
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
730
|
+
focusVisibleRef: focusVisibleRef,
|
|
731
|
+
focusVisible,
|
|
732
|
+
focusVisibleWithin,
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* a method for trigger force update
|
|
738
|
+
* @returns force update method
|
|
739
|
+
*/
|
|
740
|
+
function useForceUpdate() {
|
|
741
|
+
const [, dispatch] = useState(Object.create(null));
|
|
742
|
+
return useEventCallback(() => {
|
|
743
|
+
dispatch(Object.create(null));
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* That method will give you ability fork same value `ref object` or `method`
|
|
749
|
+
* Example:
|
|
750
|
+
* ```ts
|
|
751
|
+
* const refA = useRef();
|
|
752
|
+
* const refB = useRef();
|
|
753
|
+
*
|
|
754
|
+
* const ref = useForkRef(refA, refB)
|
|
755
|
+
*
|
|
756
|
+
* useEffect(() => {
|
|
757
|
+
* console.log(refA); // get that current div dom
|
|
758
|
+
* console.log(refB); // also get that current div dom
|
|
759
|
+
* },[])
|
|
760
|
+
*
|
|
761
|
+
* return <div ref={ref} />
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
const useForkRef = unstable_useForkRef;
|
|
765
|
+
|
|
766
|
+
const globalListenerEventMap = new Map();
|
|
767
|
+
/**
|
|
768
|
+
* create globalListener handler for share one listener event
|
|
769
|
+
*
|
|
770
|
+
* @example
|
|
771
|
+
* ```ts
|
|
772
|
+
* const globalListener = createGlobalListener('focus', () => { console.log('focus') });
|
|
773
|
+
* const globalListener2 = createGlobalListener('focus', () => { console.log('focus') });
|
|
774
|
+
* globalListener.listen();
|
|
775
|
+
* globalListener2.listen();
|
|
776
|
+
*
|
|
777
|
+
* => // that will only create an event listener on window, but every callback will get emit value when event triggered
|
|
778
|
+
* ```
|
|
779
|
+
*/
|
|
780
|
+
const createGlobalListener = (key, listener, options, { customKey = key, target: targetProp = window, } = {}) => {
|
|
781
|
+
let listening = false;
|
|
782
|
+
const getMapValue = () => globalListenerEventMap.get(customKey);
|
|
783
|
+
const addListener = () => {
|
|
784
|
+
if (listening)
|
|
785
|
+
return;
|
|
786
|
+
const savedEvent = getMapValue();
|
|
787
|
+
if (!savedEvent?.exec) {
|
|
788
|
+
const exec = (e) => {
|
|
789
|
+
const event = getMapValue();
|
|
790
|
+
event?.listeners.forEach((c) => c(e));
|
|
791
|
+
};
|
|
792
|
+
globalListenerEventMap.set(customKey, {
|
|
793
|
+
exec,
|
|
794
|
+
listeners: new Set([listener]),
|
|
795
|
+
});
|
|
796
|
+
const target = getRefElement(targetProp);
|
|
797
|
+
target?.addEventListener(key, exec, options);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
savedEvent.listeners.add(listener);
|
|
801
|
+
}
|
|
802
|
+
listening = true;
|
|
803
|
+
};
|
|
804
|
+
const removeListener = () => {
|
|
805
|
+
if (!listening)
|
|
806
|
+
return;
|
|
807
|
+
listening = false;
|
|
808
|
+
const _savedEvent = getMapValue();
|
|
809
|
+
if (!_savedEvent)
|
|
810
|
+
return;
|
|
811
|
+
const { listeners } = _savedEvent;
|
|
812
|
+
listeners.delete(listener);
|
|
813
|
+
if (listeners.size === 0) {
|
|
814
|
+
const target = getRefElement(targetProp);
|
|
815
|
+
target?.removeEventListener(key, _savedEvent.exec, options);
|
|
816
|
+
globalListenerEventMap.delete(customKey);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
return {
|
|
820
|
+
listen: () => addListener(),
|
|
821
|
+
/**
|
|
822
|
+
* remove listener
|
|
823
|
+
*/
|
|
824
|
+
remove: () => removeListener(),
|
|
825
|
+
/**
|
|
826
|
+
* current listener state
|
|
827
|
+
*/
|
|
828
|
+
get state() {
|
|
829
|
+
return {
|
|
830
|
+
/**
|
|
831
|
+
* current listener state count
|
|
832
|
+
*/
|
|
833
|
+
get count() {
|
|
834
|
+
return getMapValue()?.listeners.size || 0;
|
|
835
|
+
},
|
|
836
|
+
/**
|
|
837
|
+
* is that be listening
|
|
838
|
+
*/
|
|
839
|
+
listening,
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* bind global event, when you bind same key event,
|
|
847
|
+
* that will reuse one event listener and trigger both callback once that listener be triggered.
|
|
848
|
+
*
|
|
849
|
+
* also you can control listener with method `listen` and `remove`
|
|
850
|
+
* and get listener `state` for check listener count number and current listing state.
|
|
851
|
+
*
|
|
852
|
+
* config:
|
|
853
|
+
* - `target`: custom binding event target, default is `window`
|
|
854
|
+
* - `customKey`: custom key for determining different event group, default is same as `key`
|
|
855
|
+
* - `startImmediately`: start listener immediately, default is `true`
|
|
856
|
+
*
|
|
857
|
+
* * `key`, `options` only work when set first time, never change after first render
|
|
858
|
+
*
|
|
859
|
+
* @example
|
|
860
|
+
* ```ts
|
|
861
|
+
* useGlobalListener('keyup', () => console.log('window key up1'))
|
|
862
|
+
*
|
|
863
|
+
* useGlobalListener('keyup', () => console.log('window key up2'))
|
|
864
|
+
*
|
|
865
|
+
* useGlobalListener('touchend', () => console.log('window key up3'), {
|
|
866
|
+
* target: someWantHostElement,
|
|
867
|
+
* customKey: 'hostTouchend',
|
|
868
|
+
* })
|
|
869
|
+
*
|
|
870
|
+
* useGlobalListener('touchend', () => console.log('window key up3'), { passive: false }, {
|
|
871
|
+
* target: someWantHostElement,
|
|
872
|
+
* customKey: 'hostTouchend',
|
|
873
|
+
* })
|
|
874
|
+
*
|
|
875
|
+
* => // that will only create an event listener on `window`, but every callback will get emit value when event triggered
|
|
876
|
+
* ```
|
|
877
|
+
*/
|
|
878
|
+
function useGlobalListener(key, listener, ...args) {
|
|
879
|
+
const { options, config } = getListenerOverloadOption(args);
|
|
880
|
+
const { customKey = key, target: targetProp = window, startImmediately = true, } = config || {};
|
|
881
|
+
const _listener = useEventCallback(listener);
|
|
882
|
+
const { current: globalListener } = useRef(createGlobalListener(key, _listener, options, {
|
|
883
|
+
customKey,
|
|
884
|
+
target: targetProp,
|
|
885
|
+
}));
|
|
886
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
887
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
if (customKey === key) {
|
|
890
|
+
if (Object.keys(options).length > 0) {
|
|
891
|
+
logInDev({
|
|
892
|
+
scope: 'useGlobalListener',
|
|
893
|
+
message: 'when have set options, suggest you also bind customKey, otherwise, it may use previous listener without same options',
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
const target = getRefElement(targetProp);
|
|
897
|
+
if (!('document' in target && 'screen' in target)) {
|
|
898
|
+
logInDev({
|
|
899
|
+
scope: 'useGlobalListener',
|
|
900
|
+
message: 'When you custom binding event target, you must custom key for determining different event group',
|
|
901
|
+
level: 'error',
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
906
|
+
}, []);
|
|
907
|
+
}
|
|
908
|
+
useEffect(() => {
|
|
909
|
+
if (startImmediately) {
|
|
910
|
+
globalListener.listen();
|
|
911
|
+
}
|
|
912
|
+
return globalListener.remove;
|
|
913
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
914
|
+
}, []);
|
|
915
|
+
return globalListener;
|
|
916
|
+
}
|
|
917
|
+
function getListenerOverloadOption(args) {
|
|
918
|
+
let options;
|
|
919
|
+
let config;
|
|
920
|
+
if (typeof args[0] === 'boolean') {
|
|
921
|
+
options = args[0];
|
|
922
|
+
config = args[1];
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
const { startImmediately, customKey, target, ...restOptions } = {
|
|
926
|
+
...(args[0] || {}),
|
|
927
|
+
...(args[1] || {}),
|
|
928
|
+
};
|
|
929
|
+
options = restOptions;
|
|
930
|
+
config = {
|
|
931
|
+
startImmediately,
|
|
932
|
+
customKey,
|
|
933
|
+
target,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return { options, config };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* group hidden logic, when binding events element focus,
|
|
941
|
+
* that hiddenRef will auto be `tabindex={-1}` *
|
|
942
|
+
*/
|
|
943
|
+
const useHiddenTabindex = (target) => {
|
|
944
|
+
const handleFocus = useEventCallback(() => {
|
|
945
|
+
const hiddenElm = getRefElement(target);
|
|
946
|
+
if (hiddenElm && hiddenElm.tabIndex === 0) {
|
|
947
|
+
hiddenElm.tabIndex = -1;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
const handleBlur = useEventCallback(() => {
|
|
951
|
+
const hiddenElm = getRefElement(target);
|
|
952
|
+
if (hiddenElm && hiddenElm.tabIndex === -1) {
|
|
953
|
+
hiddenElm.tabIndex = 0;
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
onFocus: handleFocus,
|
|
958
|
+
onBlur: handleBlur,
|
|
959
|
+
};
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
const useHover = ({ onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, disabled = false, disableReactTree = true, } = {}) => {
|
|
963
|
+
const [hover, setHover] = useState(false);
|
|
964
|
+
const hoverRef = useRef(false);
|
|
965
|
+
useEffect(() => {
|
|
966
|
+
if (disabled) {
|
|
967
|
+
setHover(false);
|
|
968
|
+
}
|
|
969
|
+
else if (!disabled && hoverRef.current) {
|
|
970
|
+
setHover(true);
|
|
971
|
+
}
|
|
972
|
+
}, [disabled]);
|
|
973
|
+
const updateHoverState = (hover, event) => {
|
|
974
|
+
if (disableReactTree &&
|
|
975
|
+
!event.currentTarget.contains(event.target)) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (!disabled)
|
|
979
|
+
setHover(hover);
|
|
980
|
+
hoverRef.current = hover;
|
|
981
|
+
};
|
|
982
|
+
const onMouseEnter = (event) => {
|
|
983
|
+
handleMouseEnter?.(event);
|
|
984
|
+
updateHoverState(true, event);
|
|
985
|
+
};
|
|
986
|
+
const onMouseLeave = (event) => {
|
|
987
|
+
handleMouseLeave?.(event);
|
|
988
|
+
updateHoverState(false, event);
|
|
989
|
+
};
|
|
990
|
+
return {
|
|
991
|
+
hoverHandlers: { onMouseEnter, onMouseLeave },
|
|
992
|
+
hover,
|
|
993
|
+
};
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const useHoverDirty = ({ target, disabled = false, }) => {
|
|
997
|
+
const [hover, setHover] = useState(false);
|
|
998
|
+
useEffect(() => {
|
|
999
|
+
if (disabled || !target)
|
|
1000
|
+
setHover(false);
|
|
1001
|
+
}, [disabled, target]);
|
|
1002
|
+
useEventListener(target, 'mouseenter', () => {
|
|
1003
|
+
if (disabled)
|
|
1004
|
+
return;
|
|
1005
|
+
setHover(true);
|
|
1006
|
+
});
|
|
1007
|
+
useEventListener(target, 'mouseleave', () => {
|
|
1008
|
+
if (disabled)
|
|
1009
|
+
return;
|
|
1010
|
+
setHover(false);
|
|
1011
|
+
});
|
|
1012
|
+
return {
|
|
1013
|
+
hover,
|
|
1014
|
+
};
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* get load state of an image by passing src or srcSet
|
|
1019
|
+
* - false: not load yet
|
|
1020
|
+
* - 'loaded': successfully loaded
|
|
1021
|
+
* - 'error': fail to load
|
|
1022
|
+
*
|
|
1023
|
+
* @example
|
|
1024
|
+
* ```ts
|
|
1025
|
+
* const loaded = useImgLoaded({
|
|
1026
|
+
* src,
|
|
1027
|
+
* srcSet,
|
|
1028
|
+
* });
|
|
1029
|
+
* ```
|
|
1030
|
+
*/
|
|
1031
|
+
const useImgLoaded = ({ crossOrigin, referrerPolicy, src, srcSet, }) => {
|
|
1032
|
+
const [loaded, setLoaded] = useState(false);
|
|
1033
|
+
useEffect(() => {
|
|
1034
|
+
if (!src && !srcSet) {
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
setLoaded(false);
|
|
1038
|
+
let active = true;
|
|
1039
|
+
const image = new Image();
|
|
1040
|
+
image.onload = () => {
|
|
1041
|
+
if (!active) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
setLoaded('loaded');
|
|
1045
|
+
};
|
|
1046
|
+
image.onerror = () => {
|
|
1047
|
+
if (!active) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
setLoaded('error');
|
|
1051
|
+
};
|
|
1052
|
+
crossOrigin && (image.crossOrigin = crossOrigin);
|
|
1053
|
+
referrerPolicy && (image.referrerPolicy = referrerPolicy);
|
|
1054
|
+
if (src) {
|
|
1055
|
+
image.src = src;
|
|
1056
|
+
}
|
|
1057
|
+
if (srcSet) {
|
|
1058
|
+
image.srcset = srcSet;
|
|
1059
|
+
}
|
|
1060
|
+
return () => {
|
|
1061
|
+
active = false;
|
|
1062
|
+
};
|
|
1063
|
+
}, [crossOrigin, referrerPolicy, src, srcSet]);
|
|
1064
|
+
return loaded;
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* controllable interval, provide easy way to control interval
|
|
1069
|
+
*
|
|
1070
|
+
* - cancel: cancel current interval
|
|
1071
|
+
* - pause: pause current interval
|
|
1072
|
+
* - play: play current interval
|
|
1073
|
+
* - getRunning: return current interval running state
|
|
1074
|
+
*
|
|
1075
|
+
* @example
|
|
1076
|
+
* ```ts
|
|
1077
|
+
* const { cancel, play, pause } = useInterval(() => {
|
|
1078
|
+
* console.log('interval be triggered');
|
|
1079
|
+
* }, 1000)
|
|
1080
|
+
*
|
|
1081
|
+
* // get current run times
|
|
1082
|
+
* useInterval((times) => {
|
|
1083
|
+
* console.log('current times start from 1', times);
|
|
1084
|
+
* }, 2000)
|
|
1085
|
+
*
|
|
1086
|
+
* // not start interval immediately
|
|
1087
|
+
* useInterval((times) => {
|
|
1088
|
+
* console.log('current times start from 1', times);
|
|
1089
|
+
* }, 2000, false)
|
|
1090
|
+
* ```
|
|
1091
|
+
*/
|
|
1092
|
+
const useInterval = (
|
|
1093
|
+
/** callback with times, times start from 1 */
|
|
1094
|
+
callback,
|
|
1095
|
+
/** what delay time of that interval */
|
|
1096
|
+
delay = 0,
|
|
1097
|
+
/** is that start interval immediately */
|
|
1098
|
+
startImmediately = true) => {
|
|
1099
|
+
const intervalRef = useRef();
|
|
1100
|
+
const countRef = useRef(0);
|
|
1101
|
+
const getRunning = useEventCallback(() => !!intervalRef.current);
|
|
1102
|
+
const savedCallback = useEventCallback((times) => {
|
|
1103
|
+
callback(times);
|
|
1104
|
+
});
|
|
1105
|
+
const pause = useCallback(() => {
|
|
1106
|
+
if (intervalRef.current) {
|
|
1107
|
+
clearInterval(intervalRef.current);
|
|
1108
|
+
intervalRef.current = undefined;
|
|
1109
|
+
}
|
|
1110
|
+
}, []);
|
|
1111
|
+
const cancel = useCallback(() => {
|
|
1112
|
+
pause();
|
|
1113
|
+
countRef.current = 0;
|
|
1114
|
+
}, [pause]);
|
|
1115
|
+
const play = useCallback(() => {
|
|
1116
|
+
const isRunning = getRunning();
|
|
1117
|
+
if (isRunning)
|
|
1118
|
+
return;
|
|
1119
|
+
intervalRef.current = setInterval(() => {
|
|
1120
|
+
countRef.current++;
|
|
1121
|
+
savedCallback(countRef.current);
|
|
1122
|
+
}, delay);
|
|
1123
|
+
}, [delay, getRunning, savedCallback]);
|
|
1124
|
+
useEffect(() => {
|
|
1125
|
+
if (startImmediately) {
|
|
1126
|
+
play();
|
|
1127
|
+
}
|
|
1128
|
+
return () => cancel();
|
|
1129
|
+
}, [cancel, play, startImmediately]);
|
|
1130
|
+
return {
|
|
1131
|
+
/**
|
|
1132
|
+
* cancel current interval
|
|
1133
|
+
*/
|
|
1134
|
+
cancel,
|
|
1135
|
+
/**
|
|
1136
|
+
* pause current interval
|
|
1137
|
+
*/
|
|
1138
|
+
pause,
|
|
1139
|
+
/**
|
|
1140
|
+
* play current interval
|
|
1141
|
+
*/
|
|
1142
|
+
play,
|
|
1143
|
+
/**
|
|
1144
|
+
* return current interval running state
|
|
1145
|
+
*/
|
|
1146
|
+
getRunning,
|
|
1147
|
+
};
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Provide user trigger `onKeyDown` only one time when hold press on key.
|
|
1152
|
+
* @param includeKeys that check include keys
|
|
1153
|
+
* @param methods trigger when first keydown or keyup
|
|
1154
|
+
* @returns
|
|
1155
|
+
*/
|
|
1156
|
+
const useKeyDownOnce = (includeKeys, { onKeyDown, onKeyUp }) => {
|
|
1157
|
+
const isKeyDownMapRef = useRef({});
|
|
1158
|
+
const handleKeyDown = useEventCallback((e) => {
|
|
1159
|
+
if (includeKeys.includes(e.key)) {
|
|
1160
|
+
if (!isKeyDownMapRef.current[e.key]) {
|
|
1161
|
+
onKeyDown?.(e);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
e.preventDefault();
|
|
1165
|
+
e.stopPropagation();
|
|
1166
|
+
}
|
|
1167
|
+
isKeyDownMapRef.current[e.key] = true;
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
onKeyDown?.(e);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
const handleKeyUp = useEventCallback((e) => {
|
|
1174
|
+
if (includeKeys.includes(e.key)) {
|
|
1175
|
+
isKeyDownMapRef.current[e.key] = false;
|
|
1176
|
+
}
|
|
1177
|
+
onKeyUp?.(e);
|
|
1178
|
+
});
|
|
1179
|
+
return {
|
|
1180
|
+
onKeyDown: handleKeyDown,
|
|
1181
|
+
onKeyUp: handleKeyUp,
|
|
1182
|
+
};
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* that can move index in column array
|
|
1187
|
+
*/
|
|
1188
|
+
function moveIndexInTwoDimension(direction, options) {
|
|
1189
|
+
const { currIndex, columns, total, infinite } = options;
|
|
1190
|
+
// start index never small than 0
|
|
1191
|
+
// const index = Math.max(currIndex, 0);
|
|
1192
|
+
const lastIndex = total - 1;
|
|
1193
|
+
const isInOrder = infinite === 'order';
|
|
1194
|
+
const fillTotal = Math.ceil(total / columns) * columns;
|
|
1195
|
+
let resultIndex;
|
|
1196
|
+
switch (direction) {
|
|
1197
|
+
case 'ArrowUp': {
|
|
1198
|
+
const toIndex = (Math.max(currIndex, 0) - columns + fillTotal) % fillTotal;
|
|
1199
|
+
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case 'ArrowRight': {
|
|
1203
|
+
if (isInOrder) {
|
|
1204
|
+
const tmp = currIndex + 1;
|
|
1205
|
+
resultIndex = tmp > lastIndex ? 0 : tmp;
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
const toIndex = Math.floor(currIndex / columns) * columns + ((currIndex + 1) % columns);
|
|
1209
|
+
resultIndex = infinite ? toIndex : Math.max(toIndex, currIndex);
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
case 'ArrowDown': {
|
|
1213
|
+
const toIndex = (currIndex + columns) % fillTotal;
|
|
1214
|
+
resultIndex = infinite ? toIndex : Math.max(toIndex, currIndex);
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
case 'ArrowLeft': {
|
|
1218
|
+
if (isInOrder) {
|
|
1219
|
+
const tmp = currIndex - 1;
|
|
1220
|
+
resultIndex = tmp < 0 ? lastIndex : tmp;
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
const toIndex = Math.floor(currIndex / columns) * columns +
|
|
1224
|
+
((currIndex - 1 + columns) % columns);
|
|
1225
|
+
resultIndex = infinite ? toIndex : Math.min(toIndex, currIndex);
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
case 'Home':
|
|
1229
|
+
resultIndex = 0;
|
|
1230
|
+
break;
|
|
1231
|
+
case 'End':
|
|
1232
|
+
resultIndex = lastIndex;
|
|
1233
|
+
break;
|
|
1234
|
+
default:
|
|
1235
|
+
resultIndex = currIndex;
|
|
1236
|
+
}
|
|
1237
|
+
if (!infinite && resultIndex >= total) {
|
|
1238
|
+
return currIndex;
|
|
1239
|
+
}
|
|
1240
|
+
// * when that bigger then sourceTotal, mean that touch end line
|
|
1241
|
+
// * move to current full latest and move again.
|
|
1242
|
+
if (resultIndex >= total) {
|
|
1243
|
+
if (direction === 'ArrowRight') {
|
|
1244
|
+
resultIndex = fillTotal - 1;
|
|
1245
|
+
}
|
|
1246
|
+
return moveIndexInTwoDimension(direction, {
|
|
1247
|
+
...options,
|
|
1248
|
+
currIndex: resultIndex,
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
return resultIndex;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* use keyboard move index in two dimensional
|
|
1256
|
+
*/
|
|
1257
|
+
const useKeyboardMoveFocus = ({ infinite, options = [], focusedIndexRef, onFocusedIndexChange, getOptionDisabled, getOptionSearchText, columns = 1, endSearchDebounceTime = 500, }) => {
|
|
1258
|
+
const total = options.length;
|
|
1259
|
+
const searchText = useRef('');
|
|
1260
|
+
const debounceEndSearch = useDebounce(() => (searchText.current = ''), endSearchDebounceTime);
|
|
1261
|
+
const searchTextAndFocus = (event) => {
|
|
1262
|
+
const key = event.key;
|
|
1263
|
+
if (!getOptionSearchText || key.length !== 1)
|
|
1264
|
+
return;
|
|
1265
|
+
debounceEndSearch();
|
|
1266
|
+
searchText.current += key.toLowerCase();
|
|
1267
|
+
const matchedIndex = options.findIndex((option) => {
|
|
1268
|
+
const text = getOptionSearchText(option);
|
|
1269
|
+
return Boolean(!getOptionDisabled?.(option) &&
|
|
1270
|
+
text &&
|
|
1271
|
+
text.toLowerCase().startsWith(searchText.current));
|
|
1272
|
+
});
|
|
1273
|
+
if (matchedIndex > -1) {
|
|
1274
|
+
onFocusedIndexChange(event, matchedIndex);
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
const onKeyFocusedIndexHandle = (event) => {
|
|
1278
|
+
let currIndex = focusedIndexRef.current;
|
|
1279
|
+
const key = event.key;
|
|
1280
|
+
let findDirection;
|
|
1281
|
+
if (total === 0)
|
|
1282
|
+
return;
|
|
1283
|
+
// eslint-disable-next-line no-constant-condition
|
|
1284
|
+
while (true) {
|
|
1285
|
+
const toIndex = findDirection
|
|
1286
|
+
? findDirection === 'next'
|
|
1287
|
+
? currIndex + 1
|
|
1288
|
+
: currIndex - 1
|
|
1289
|
+
: moveIndexInTwoDimension(key, {
|
|
1290
|
+
currIndex,
|
|
1291
|
+
columns,
|
|
1292
|
+
total,
|
|
1293
|
+
infinite,
|
|
1294
|
+
});
|
|
1295
|
+
if (toIndex === currIndex) {
|
|
1296
|
+
searchTextAndFocus(event);
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
if (!getOptionDisabled?.(options[toIndex])) {
|
|
1300
|
+
if (toIndex !== focusedIndexRef.current) {
|
|
1301
|
+
onFocusedIndexChange(event, toIndex);
|
|
1302
|
+
}
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
// * mean that item is disabled, use same direction find next
|
|
1306
|
+
else {
|
|
1307
|
+
// * find toIndex is same as current, mean that already find all
|
|
1308
|
+
if (focusedIndexRef.current !== -1 &&
|
|
1309
|
+
toIndex === Math.max(focusedIndexRef.current, 0)) {
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
// * if no item is focused currently and toIndex is already the last item checked,
|
|
1313
|
+
// * it means no item in the list is focusable
|
|
1314
|
+
if (focusedIndexRef.current === -1) {
|
|
1315
|
+
// from bottom to top and the first item in the list is not focusable
|
|
1316
|
+
if ((Math.abs(currIndex - toIndex) !== 1 || currIndex > toIndex) &&
|
|
1317
|
+
toIndex === 0) {
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
// from top to bottom and the last item in the list is not focusable
|
|
1321
|
+
if (Math.abs(currIndex - toIndex) === 1 &&
|
|
1322
|
+
currIndex < toIndex &&
|
|
1323
|
+
toIndex === total - 1) {
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
currIndex = toIndex;
|
|
1328
|
+
// * if that is Hom or End, find use increase or decrease
|
|
1329
|
+
if (key === 'Home') {
|
|
1330
|
+
findDirection = 'next';
|
|
1331
|
+
}
|
|
1332
|
+
else if (key === 'End') {
|
|
1333
|
+
findDirection = 'prev';
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
const getPrevFocusableOption = useCallback((index = focusedIndexRef.current,
|
|
1339
|
+
/** is that infinite search */
|
|
1340
|
+
isInfinite = true) => {
|
|
1341
|
+
for (let i = index; i >= 0; i--) {
|
|
1342
|
+
if (!getOptionDisabled?.(options[i])) {
|
|
1343
|
+
return i;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (isInfinite) {
|
|
1347
|
+
// loop find
|
|
1348
|
+
for (let i = total - 1; i > index; i--) {
|
|
1349
|
+
if (!getOptionDisabled?.(options[i])) {
|
|
1350
|
+
return i;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return -1;
|
|
1355
|
+
}, [focusedIndexRef, getOptionDisabled, options, total]);
|
|
1356
|
+
const getNextFocusableOption = useCallback((index = focusedIndexRef.current,
|
|
1357
|
+
/** is that infinite search */
|
|
1358
|
+
isInfinite = true) => {
|
|
1359
|
+
for (let i = index; i < total; i++) {
|
|
1360
|
+
if (!getOptionDisabled?.(options[i])) {
|
|
1361
|
+
return i;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (isInfinite) {
|
|
1365
|
+
// loop find
|
|
1366
|
+
for (let i = 0; i < index; i++) {
|
|
1367
|
+
if (!getOptionDisabled?.(options[i])) {
|
|
1368
|
+
return i;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return -1;
|
|
1373
|
+
}, [focusedIndexRef, getOptionDisabled, options, total]);
|
|
1374
|
+
return {
|
|
1375
|
+
/**
|
|
1376
|
+
* Get previous focusable option(include current index).
|
|
1377
|
+
*/
|
|
1378
|
+
getPrevFocusableOption,
|
|
1379
|
+
/**
|
|
1380
|
+
* Get next focusable option(include current index).
|
|
1381
|
+
*/
|
|
1382
|
+
getNextFocusableOption,
|
|
1383
|
+
/**
|
|
1384
|
+
* Use to handle keyboard event.
|
|
1385
|
+
*/
|
|
1386
|
+
onKeyFocusedIndexHandle,
|
|
1387
|
+
};
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
const MOVE_ATTR_KEY = 'data-move-focused';
|
|
1391
|
+
/**
|
|
1392
|
+
* when focus in container below element with binding `getItemProps`,
|
|
1393
|
+
* that will only have one element can be focusable.
|
|
1394
|
+
*/
|
|
1395
|
+
const useOnlyOneFocusable = ({ focusedIndexRef, containerRef, }) => {
|
|
1396
|
+
const moveFocusedId = useId(MOVE_ATTR_KEY, true);
|
|
1397
|
+
const getIndexElm = (toIndex) => containerRef.current?.querySelector(`[${moveFocusedId}="${toIndex}"]`);
|
|
1398
|
+
const setIndexTabIndex = (toIndex) => {
|
|
1399
|
+
const toElm = getIndexElm(toIndex);
|
|
1400
|
+
if (toElm) {
|
|
1401
|
+
const prevElm = containerRef.current?.querySelector(`[${moveFocusedId}][tabindex="0"]`);
|
|
1402
|
+
prevElm?.setAttribute('tabindex', '-1');
|
|
1403
|
+
toElm.setAttribute('tabindex', '0');
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
const focusIndex = (toIndex) => {
|
|
1407
|
+
const toElm = getIndexElm(toIndex);
|
|
1408
|
+
toElm?.focus();
|
|
1409
|
+
};
|
|
1410
|
+
return {
|
|
1411
|
+
moveFocusedId,
|
|
1412
|
+
focusIndex,
|
|
1413
|
+
setIndexTabIndex,
|
|
1414
|
+
getItemProps: (index, options) => {
|
|
1415
|
+
return combineProps({
|
|
1416
|
+
[moveFocusedId]: index,
|
|
1417
|
+
tabIndex: index === focusedIndexRef.current ? 0 : -1,
|
|
1418
|
+
onFocus: () => {
|
|
1419
|
+
focusedIndexRef.current = index;
|
|
1420
|
+
setIndexTabIndex(index);
|
|
1421
|
+
},
|
|
1422
|
+
}, options);
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Provide a utils for switch trigger touch event or mouse event between different device
|
|
1429
|
+
*
|
|
1430
|
+
* - Trigger `touch` event in `touch support` device
|
|
1431
|
+
* - onTouchStart
|
|
1432
|
+
* - onTouchEnd
|
|
1433
|
+
* - Trigger `mouse` event in `non touch support` device
|
|
1434
|
+
* - onMouseDown
|
|
1435
|
+
* - onMouseUp
|
|
1436
|
+
*
|
|
1437
|
+
* ### Once that is touch support device, that mouse event will never can be trigger
|
|
1438
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent
|
|
1439
|
+
*
|
|
1440
|
+
* Event order:
|
|
1441
|
+
* - touchstart
|
|
1442
|
+
* - Zero or more touchmove events, depending on movement of the finger(s)
|
|
1443
|
+
* - touchend
|
|
1444
|
+
* - mousemove
|
|
1445
|
+
* - mousedown
|
|
1446
|
+
* - mouseup
|
|
1447
|
+
* - click
|
|
1448
|
+
*/
|
|
1449
|
+
const useTouchMouseEvent = ({ onMouseDown: onMouseDownArg, onMouseUp: onMouseUpArg, onTouchStart: onTouchStartArg, onTouchEnd: onTouchEndArg, }, { actionRef } = {}) => {
|
|
1450
|
+
const stopMouseEventRef = useRef(false);
|
|
1451
|
+
const onTouchStart = useEventCallback((e) => {
|
|
1452
|
+
stopMouseEventRef.current = true;
|
|
1453
|
+
onTouchStartArg?.(e);
|
|
1454
|
+
});
|
|
1455
|
+
function checkInside(elm) {
|
|
1456
|
+
const hostElement = getRefElement(actionRef);
|
|
1457
|
+
if (hostElement) {
|
|
1458
|
+
return !!(elm && isElmEqualOrContain(elm, hostElement));
|
|
1459
|
+
}
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
const onTouchEnd = useEventCallback((e) => {
|
|
1463
|
+
let isInside;
|
|
1464
|
+
if (actionRef && e.touches.length < 2 && e.changedTouches.length < 2) {
|
|
1465
|
+
const touch = e.touches[0] || e.changedTouches[0];
|
|
1466
|
+
const elm = unstable_ownerDocument(e.currentTarget).elementFromPoint(touch.pageX, touch.pageY);
|
|
1467
|
+
isInside = !!elm && checkInside(elm);
|
|
1468
|
+
}
|
|
1469
|
+
onTouchEndArg?.(e, isInside);
|
|
1470
|
+
});
|
|
1471
|
+
const onMouseDown = useEventCallback((e) => {
|
|
1472
|
+
if (stopMouseEventRef.current) {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
onMouseDownArg?.(e);
|
|
1476
|
+
});
|
|
1477
|
+
const onMouseUp = useEventCallback((e) => {
|
|
1478
|
+
let isInside;
|
|
1479
|
+
if (stopMouseEventRef.current)
|
|
1480
|
+
return;
|
|
1481
|
+
if (actionRef) {
|
|
1482
|
+
const elm = e.target;
|
|
1483
|
+
isInside = !!elm && checkInside(elm);
|
|
1484
|
+
}
|
|
1485
|
+
onMouseUpArg?.(e, isInside);
|
|
1486
|
+
});
|
|
1487
|
+
return {
|
|
1488
|
+
onTouchStart,
|
|
1489
|
+
onTouchEnd,
|
|
1490
|
+
onMouseDown,
|
|
1491
|
+
onMouseUp,
|
|
1492
|
+
};
|
|
1493
|
+
};
|
|
1494
|
+
/**
|
|
1495
|
+
* check is element equal or contains target element
|
|
1496
|
+
*/
|
|
1497
|
+
const isElmEqualOrContain = (sourceTarget, containTarget) => {
|
|
1498
|
+
const elm = getRefElement(sourceTarget);
|
|
1499
|
+
const target = getRefElement(containTarget);
|
|
1500
|
+
return elm && target && (target === elm || target.contains(elm));
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
const isTouchEvent = (ev) => {
|
|
1504
|
+
return 'touches' in ev;
|
|
1505
|
+
};
|
|
1506
|
+
/**
|
|
1507
|
+
* Provide longPress helper, both `click`/`tab`/`keydown` will trigger event
|
|
1508
|
+
*
|
|
1509
|
+
* - Trigger `onTap` when user action leave small than delay time.
|
|
1510
|
+
* - Trigger `onPress` when action time is long than delay time.
|
|
1511
|
+
*
|
|
1512
|
+
* @default UseLongPressOptions { delay = 300 }
|
|
1513
|
+
*/
|
|
1514
|
+
const useLongPress = ({ onTap, onPress }, { onTouchStart: onTouchStartArg, onTouchEnd: onTouchEndArg, onMouseDown, onKeyDown, onKeyUp, onMouseUp: onMouseUpArg, } = {}, { isPreventDefault = true, delay = 300, externalWindow = window, } = {}) => {
|
|
1515
|
+
const isEmittedRef = useRef(false);
|
|
1516
|
+
const isA11yDownRef = useRef(false);
|
|
1517
|
+
const actionRef = useRef(null);
|
|
1518
|
+
const reasonRef = useRef('click');
|
|
1519
|
+
const emitLongPress = useDebounce((e) => {
|
|
1520
|
+
// * only when have onPress set have emit
|
|
1521
|
+
if (onPress) {
|
|
1522
|
+
onPress(e, reasonRef.current);
|
|
1523
|
+
isEmittedRef.current = true;
|
|
1524
|
+
}
|
|
1525
|
+
}, delay);
|
|
1526
|
+
const end = (e, isInside) => {
|
|
1527
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1528
|
+
globalMouseUpListener.remove();
|
|
1529
|
+
if (!isEmittedRef.current && isInside) {
|
|
1530
|
+
onTap?.(e, reasonRef.current);
|
|
1531
|
+
}
|
|
1532
|
+
reasonRef.current = 'click';
|
|
1533
|
+
isEmittedRef.current = false;
|
|
1534
|
+
emitLongPress.cancel();
|
|
1535
|
+
};
|
|
1536
|
+
const start = (e) => {
|
|
1537
|
+
// ! mouse up only trigger when up on element, so we host that in document
|
|
1538
|
+
globalMouseUpListener.listen();
|
|
1539
|
+
const isTouch = e.type === 'touchstart';
|
|
1540
|
+
if (isTouch) {
|
|
1541
|
+
reasonRef.current = 'tap';
|
|
1542
|
+
}
|
|
1543
|
+
emitLongPress(e);
|
|
1544
|
+
};
|
|
1545
|
+
const { onMouseUp, onTouchEnd, onTouchStart, ...events } = useTouchMouseEvent({
|
|
1546
|
+
onTouchStart: (e) => {
|
|
1547
|
+
// prevent long touch leave element focus
|
|
1548
|
+
if (isPreventDefault) {
|
|
1549
|
+
e.preventDefault();
|
|
1550
|
+
}
|
|
1551
|
+
onTouchStartArg?.(e);
|
|
1552
|
+
start(e);
|
|
1553
|
+
},
|
|
1554
|
+
onTouchEnd: (e, isInside) => {
|
|
1555
|
+
// prevent leave element focus
|
|
1556
|
+
if (isPreventDefault) {
|
|
1557
|
+
e.preventDefault();
|
|
1558
|
+
}
|
|
1559
|
+
onTouchEndArg?.(e, isInside);
|
|
1560
|
+
end(e, isInside);
|
|
1561
|
+
},
|
|
1562
|
+
onMouseDown: (e) => {
|
|
1563
|
+
if (isPreventDefault) {
|
|
1564
|
+
e.preventDefault();
|
|
1565
|
+
}
|
|
1566
|
+
onMouseDown?.(e);
|
|
1567
|
+
start(e);
|
|
1568
|
+
},
|
|
1569
|
+
onMouseUp: (e, isInside) => {
|
|
1570
|
+
// prevent leave element focus
|
|
1571
|
+
if (isPreventDefault) {
|
|
1572
|
+
e.preventDefault();
|
|
1573
|
+
}
|
|
1574
|
+
onMouseUpArg?.(e);
|
|
1575
|
+
end(e, isInside);
|
|
1576
|
+
},
|
|
1577
|
+
}, { actionRef });
|
|
1578
|
+
// use event listener for make event `passive` work in safari
|
|
1579
|
+
useEventListener(actionRef, 'touchstart', onTouchStart, {
|
|
1580
|
+
passive: false,
|
|
1581
|
+
});
|
|
1582
|
+
// use event listener for make event `passive` work in safari
|
|
1583
|
+
useEventListener(actionRef, 'touchend', onTouchEnd, {
|
|
1584
|
+
passive: false,
|
|
1585
|
+
});
|
|
1586
|
+
const globalMouseUpListener = useGlobalListener('mouseup', onMouseUp, {
|
|
1587
|
+
target: externalWindow,
|
|
1588
|
+
startImmediately: false,
|
|
1589
|
+
});
|
|
1590
|
+
const handleDeleteKeyDown = useA11yKeyEvent((e) => {
|
|
1591
|
+
reasonRef.current = 'keyboard';
|
|
1592
|
+
// * keydown when hold will trigger many time, show when current is a11y down, not trigger again
|
|
1593
|
+
if (!isA11yDownRef.current) {
|
|
1594
|
+
emitLongPress(e);
|
|
1595
|
+
}
|
|
1596
|
+
isA11yDownRef.current = true;
|
|
1597
|
+
}, { preventDefault: false });
|
|
1598
|
+
const handleKeyDown = useEventCallback((e) => {
|
|
1599
|
+
onKeyDown?.(e);
|
|
1600
|
+
handleDeleteKeyDown(e);
|
|
1601
|
+
});
|
|
1602
|
+
const handleKeyUp = useEventCallback((e) => {
|
|
1603
|
+
onKeyUp?.(e);
|
|
1604
|
+
if (isA11yDownRef.current)
|
|
1605
|
+
end(e, true);
|
|
1606
|
+
isA11yDownRef.current = false;
|
|
1607
|
+
});
|
|
1608
|
+
return {
|
|
1609
|
+
ref: actionRef,
|
|
1610
|
+
onKeyDown: handleKeyDown,
|
|
1611
|
+
onKeyUp: handleKeyUp,
|
|
1612
|
+
...events,
|
|
1613
|
+
};
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* get that component mounted state `ref`
|
|
1618
|
+
*
|
|
1619
|
+
* @example
|
|
1620
|
+
*
|
|
1621
|
+
* ```ts
|
|
1622
|
+
const isMountedRef = useMountState();
|
|
1623
|
+
|
|
1624
|
+
if (isMountedRef.current) {
|
|
1625
|
+
console.log('component mounted');
|
|
1626
|
+
}
|
|
1627
|
+
*
|
|
1628
|
+
* ```
|
|
1629
|
+
*/
|
|
1630
|
+
const useMountState = () => {
|
|
1631
|
+
const isMountedRef = useRef(false);
|
|
1632
|
+
useEffect(() => {
|
|
1633
|
+
isMountedRef.current = true;
|
|
1634
|
+
return () => {
|
|
1635
|
+
isMountedRef.current = false;
|
|
1636
|
+
};
|
|
1637
|
+
}, []);
|
|
1638
|
+
return isMountedRef;
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* only trigger when re-render (in useLayoutEffect), not trigger on component first render
|
|
1643
|
+
*/
|
|
1644
|
+
const useOnLayoutReRender = (cb, deps) => {
|
|
1645
|
+
const count = useRef(0);
|
|
1646
|
+
const method = useEventCallback(cb);
|
|
1647
|
+
useLayoutEffect(() => {
|
|
1648
|
+
if (count.current === 0) {
|
|
1649
|
+
count.current = 1;
|
|
1650
|
+
return () => {
|
|
1651
|
+
//
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
return method();
|
|
1655
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1656
|
+
}, deps);
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* only trigger when re-render, not trigger on component first render
|
|
1661
|
+
*/
|
|
1662
|
+
const useOnReRender = (cb, deps) => {
|
|
1663
|
+
const count = useRef(0);
|
|
1664
|
+
const method = useEventCallback(cb);
|
|
1665
|
+
useEffect(() => {
|
|
1666
|
+
if (count.current === 0) {
|
|
1667
|
+
count.current = 1;
|
|
1668
|
+
return () => {
|
|
1669
|
+
//
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
return method();
|
|
1673
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1674
|
+
}, deps);
|
|
1675
|
+
};
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* get resize event with auto disconnect when layout destroy.
|
|
1679
|
+
* bring with debounce for event not too many
|
|
1680
|
+
* @param ref that ref for element
|
|
1681
|
+
* @param callback callback when element resize
|
|
1682
|
+
* @param performance options that improve performance
|
|
1683
|
+
*/
|
|
1684
|
+
const useResizeObserver = (
|
|
1685
|
+
/** target element ref */
|
|
1686
|
+
target,
|
|
1687
|
+
/** trigger when `ResizeObserver` emit */
|
|
1688
|
+
callback,
|
|
1689
|
+
/** performance options, `mode`, `time`, `ignoreFireWhenObserve` */
|
|
1690
|
+
performance = {}) => {
|
|
1691
|
+
const { mode = 'debounce', time: performanceTime = 200, ignoreFireWhenObserve, options, } = performance;
|
|
1692
|
+
const countRef = useRef(0);
|
|
1693
|
+
const cb = useEventCallback((...args) => {
|
|
1694
|
+
if (ignoreFireWhenObserve && countRef.current === 0) {
|
|
1695
|
+
countRef.current = 1;
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
return callback(...args);
|
|
1699
|
+
});
|
|
1700
|
+
const el = getRefElement(target);
|
|
1701
|
+
const currentWindow = el ? ownerWindow(el) : window;
|
|
1702
|
+
const observerRef = useResultRef(() => {
|
|
1703
|
+
let resizeCallback;
|
|
1704
|
+
if (mode === 'debounce') {
|
|
1705
|
+
resizeCallback = debounce(cb, performanceTime, options);
|
|
1706
|
+
}
|
|
1707
|
+
else if (mode === 'throttle') {
|
|
1708
|
+
resizeCallback = throttle(cb, performanceTime, options);
|
|
1709
|
+
}
|
|
1710
|
+
else {
|
|
1711
|
+
resizeCallback = cb;
|
|
1712
|
+
}
|
|
1713
|
+
return new currentWindow.ResizeObserver(resizeCallback);
|
|
1714
|
+
});
|
|
1715
|
+
useLayoutEffect(() => {
|
|
1716
|
+
if (!currentWindow?.ResizeObserver) {
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const el = getRefElement(target);
|
|
1720
|
+
const observer = observerRef.current;
|
|
1721
|
+
if (!el) {
|
|
1722
|
+
throw new Error('please check element exist before bind resize observer');
|
|
1723
|
+
}
|
|
1724
|
+
observer.observe(el);
|
|
1725
|
+
return () => {
|
|
1726
|
+
observer.disconnect();
|
|
1727
|
+
};
|
|
1728
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1729
|
+
}, [el]);
|
|
1730
|
+
return observerRef.current;
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* trigger when overflow state change
|
|
1735
|
+
*/
|
|
1736
|
+
const useOverflow = (
|
|
1737
|
+
/** target element ref */
|
|
1738
|
+
target,
|
|
1739
|
+
/** trigger when `ResizeObserver` emit */
|
|
1740
|
+
cb,
|
|
1741
|
+
/**
|
|
1742
|
+
* performance options, `mode`, `time`, `ignoreFireWhenObserve`
|
|
1743
|
+
*
|
|
1744
|
+
* @default { mode: 'throttle' time: 200 }
|
|
1745
|
+
*/
|
|
1746
|
+
performance = {
|
|
1747
|
+
mode: 'throttle',
|
|
1748
|
+
time: 200,
|
|
1749
|
+
}) => {
|
|
1750
|
+
// * not have default value for that can trigger change first time.
|
|
1751
|
+
const showRef = useRef();
|
|
1752
|
+
return useResizeObserver(target, () => {
|
|
1753
|
+
const element = getRefElement(target);
|
|
1754
|
+
if (!element)
|
|
1755
|
+
return;
|
|
1756
|
+
const { scrollWidth, clientWidth, scrollHeight, clientHeight } = element;
|
|
1757
|
+
const show = scrollWidth > clientWidth || scrollHeight > clientHeight;
|
|
1758
|
+
if (showRef.current === show)
|
|
1759
|
+
return;
|
|
1760
|
+
// only emit when state change
|
|
1761
|
+
showRef.current = show;
|
|
1762
|
+
cb(show, {
|
|
1763
|
+
scrollWidth,
|
|
1764
|
+
clientWidth,
|
|
1765
|
+
scrollHeight,
|
|
1766
|
+
clientHeight,
|
|
1767
|
+
});
|
|
1768
|
+
}, performance);
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Returns a stateful like state with Ref, and a function to update it current value.
|
|
1773
|
+
* can trigger update value without re-render
|
|
1774
|
+
*
|
|
1775
|
+
* @param value init value
|
|
1776
|
+
* @param customUpdate you can custom you update method, or just set your `forceUpdate` to reuse same instance
|
|
1777
|
+
* @returns an array with [ref, update]
|
|
1778
|
+
*
|
|
1779
|
+
* @example
|
|
1780
|
+
* ```ts
|
|
1781
|
+
* const [inputRef, updateInput] = useRefState(123);
|
|
1782
|
+
*
|
|
1783
|
+
* if(needReRender){
|
|
1784
|
+
* updateInput(456); // trigger re-render
|
|
1785
|
+
* } else {
|
|
1786
|
+
* updateInput(456, false); // not trigger re-render
|
|
1787
|
+
* }
|
|
1788
|
+
* ```
|
|
1789
|
+
*/
|
|
1790
|
+
const useRefState = (value, customUpdate) => {
|
|
1791
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
1792
|
+
const forceUpdate = customUpdate ?? useForceUpdate();
|
|
1793
|
+
const innerValueRef = useRef(value);
|
|
1794
|
+
/**
|
|
1795
|
+
* update ref value, set isUpdate to `false` will not trigger re-render
|
|
1796
|
+
*/
|
|
1797
|
+
const setInnerValue = useEventCallback((toValue,
|
|
1798
|
+
/**
|
|
1799
|
+
* is re-render
|
|
1800
|
+
* @default true
|
|
1801
|
+
*/
|
|
1802
|
+
isUpdate = true) => {
|
|
1803
|
+
innerValueRef.current = toValue;
|
|
1804
|
+
if (isUpdate) {
|
|
1805
|
+
forceUpdate();
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
return [innerValueRef, setInnerValue];
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* get all matched breakpoints
|
|
1813
|
+
* @param defaultBreakpoint use defaultBreakpoint when Responsive init (will pass undefined)
|
|
1814
|
+
*/
|
|
1815
|
+
const useResponsiveMatch = (defaultBreakpoint) => {
|
|
1816
|
+
const _breakpoint = useResponsiveContext();
|
|
1817
|
+
const breakpoint = _breakpoint === undefined ? defaultBreakpoint : _breakpoint;
|
|
1818
|
+
if (breakpoint === undefined)
|
|
1819
|
+
return {};
|
|
1820
|
+
const matchDetail = getMatchDetail(breakpoint);
|
|
1821
|
+
return matchDetail.matchMap;
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* cancelable sleep method.
|
|
1826
|
+
*
|
|
1827
|
+
* - sleep: a promise sleep
|
|
1828
|
+
* - cancel: cancel that sleep event
|
|
1829
|
+
* - getSleeping: get current sleeping state
|
|
1830
|
+
*/
|
|
1831
|
+
const useSleep = () => {
|
|
1832
|
+
const timeoutIdRef = useRef();
|
|
1833
|
+
const rejectRef = useRef();
|
|
1834
|
+
const sleepingRef = useRef(false);
|
|
1835
|
+
const clearCurrTimeout = () => {
|
|
1836
|
+
const id = timeoutIdRef.current;
|
|
1837
|
+
sleepingRef.current = false;
|
|
1838
|
+
if (id !== undefined)
|
|
1839
|
+
clearTimeout(id);
|
|
1840
|
+
};
|
|
1841
|
+
const sleep = (time) => new Promise((resolve, reject) => {
|
|
1842
|
+
clearCurrTimeout();
|
|
1843
|
+
sleepingRef.current = true;
|
|
1844
|
+
rejectRef.current = reject;
|
|
1845
|
+
timeoutIdRef.current = setTimeout(() => {
|
|
1846
|
+
sleepingRef.current = false;
|
|
1847
|
+
resolve();
|
|
1848
|
+
}, time);
|
|
1849
|
+
});
|
|
1850
|
+
const cancel = () => {
|
|
1851
|
+
clearCurrTimeout();
|
|
1852
|
+
rejectRef.current?.();
|
|
1853
|
+
};
|
|
1854
|
+
useEffect(() => {
|
|
1855
|
+
return () => {
|
|
1856
|
+
clearCurrTimeout();
|
|
1857
|
+
};
|
|
1858
|
+
}, []);
|
|
1859
|
+
return {
|
|
1860
|
+
/**
|
|
1861
|
+
* sleep method, that will be cancelable with `cancel`,
|
|
1862
|
+
* and when trigger many time that will always clear previous sleep
|
|
1863
|
+
*/
|
|
1864
|
+
sleep,
|
|
1865
|
+
/**
|
|
1866
|
+
* cancel current sleep timer
|
|
1867
|
+
*/
|
|
1868
|
+
cancel,
|
|
1869
|
+
/**
|
|
1870
|
+
* return is current is sleeping
|
|
1871
|
+
*/
|
|
1872
|
+
getSleeping: () => sleepingRef.current,
|
|
1873
|
+
};
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1876
|
+
/* eslint-disable no-await-in-loop */
|
|
1877
|
+
/**
|
|
1878
|
+
* retry times when exec callback fail
|
|
1879
|
+
*
|
|
1880
|
+
* * `retry`: that will auto add `times` end of your call back
|
|
1881
|
+
* * `cancel`: cancel that retry event, that only cancel inner interval promise, **if you need cancel your `cb` promise, you should handle by yourself.**
|
|
1882
|
+
*/
|
|
1883
|
+
const useRetry = (
|
|
1884
|
+
/**
|
|
1885
|
+
* exec callback, when you want to end that retry, return `true` with your callback
|
|
1886
|
+
*/
|
|
1887
|
+
cb,
|
|
1888
|
+
/** retry options */
|
|
1889
|
+
options) => {
|
|
1890
|
+
const { sleep, cancel: cancelSleep } = useSleep();
|
|
1891
|
+
const isCancelRef = useRef(true);
|
|
1892
|
+
const complete = () => {
|
|
1893
|
+
isCancelRef.current = true;
|
|
1894
|
+
options?.onComplete?.();
|
|
1895
|
+
};
|
|
1896
|
+
const cancel = () => {
|
|
1897
|
+
if (!isCancelRef.current) {
|
|
1898
|
+
// * when outside cancel
|
|
1899
|
+
options?.onCancel?.();
|
|
1900
|
+
complete();
|
|
1901
|
+
}
|
|
1902
|
+
cancelSleep();
|
|
1903
|
+
};
|
|
1904
|
+
const retry = async (...args) => {
|
|
1905
|
+
cancel();
|
|
1906
|
+
isCancelRef.current = false;
|
|
1907
|
+
options?.onStart?.();
|
|
1908
|
+
// * when exec success, just return directly.
|
|
1909
|
+
if (await cb(...args)) {
|
|
1910
|
+
if (!isCancelRef.current) {
|
|
1911
|
+
options?.onSuccess?.();
|
|
1912
|
+
complete();
|
|
1913
|
+
}
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (!options)
|
|
1917
|
+
return;
|
|
1918
|
+
const { retryTimes, intervalTime, onSuccess } = options;
|
|
1919
|
+
for (let i = 0; i < retryTimes; i++) {
|
|
1920
|
+
if (isCancelRef.current) {
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
try {
|
|
1924
|
+
await sleep(intervalTime);
|
|
1925
|
+
}
|
|
1926
|
+
catch (error) {
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (await cb(...args, i)) {
|
|
1930
|
+
if (!isCancelRef.current) {
|
|
1931
|
+
onSuccess?.();
|
|
1932
|
+
complete();
|
|
1933
|
+
}
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
complete();
|
|
1938
|
+
};
|
|
1939
|
+
return {
|
|
1940
|
+
/**
|
|
1941
|
+
* retry method, that will be cancelable with `cancel`,
|
|
1942
|
+
* and when trigger many time that will always clear previous retry
|
|
1943
|
+
*/
|
|
1944
|
+
retry,
|
|
1945
|
+
/**
|
|
1946
|
+
* cancel current retry
|
|
1947
|
+
*/
|
|
1948
|
+
cancel,
|
|
1949
|
+
};
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
/**
|
|
1953
|
+
* @param onTabOut Callback fired when a "tab out" event is detected.
|
|
1954
|
+
*/
|
|
1955
|
+
const useTabOut = (onTabOut) => {
|
|
1956
|
+
const lastKeyDownIsTabRef = useRef(false);
|
|
1957
|
+
const handleKeyDown = (e) => {
|
|
1958
|
+
if (e.key === 'Tab') {
|
|
1959
|
+
lastKeyDownIsTabRef.current = true;
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
const handleKeyUp = (e) => {
|
|
1963
|
+
lastKeyDownIsTabRef.current = false;
|
|
1964
|
+
};
|
|
1965
|
+
const handleBlur = (e) => {
|
|
1966
|
+
if (!e.currentTarget.contains(e.relatedTarget) &&
|
|
1967
|
+
lastKeyDownIsTabRef.current) {
|
|
1968
|
+
onTabOut(e);
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
return {
|
|
1972
|
+
tabOutHandler: {
|
|
1973
|
+
onKeyDown: handleKeyDown,
|
|
1974
|
+
onKeyUp: handleKeyUp,
|
|
1975
|
+
onBlur: handleBlur,
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* provide a throttle method for throttle method
|
|
1982
|
+
* @param fn method for throttle
|
|
1983
|
+
* @param throttleTime throttle timeout value, default is `200`ms
|
|
1984
|
+
* @param options throttle setting, default is `{trailing: true, leading: true}`, detail see lodash
|
|
1985
|
+
*/
|
|
1986
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1987
|
+
const useThrottle = (fn, throttleTime = 200, options) => {
|
|
1988
|
+
const memoFn = useEventCallback(fn);
|
|
1989
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1990
|
+
const throttleFn = useCallback(throttle(memoFn, throttleTime, options), []);
|
|
1991
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1992
|
+
useEffect(() => () => throttleFn.cancel(), []);
|
|
1993
|
+
return throttleFn;
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Internal hook to detect if the component is running in a test runner environment
|
|
1998
|
+
* Used by Storybook stories to disable network requests and animations during testing
|
|
1999
|
+
* @returns {boolean} true if running in test runner (headless browser), false otherwise
|
|
2000
|
+
* @internal
|
|
2001
|
+
*/
|
|
2002
|
+
const useTestRunnerDetection = () => {
|
|
2003
|
+
return useMemo(() => {
|
|
2004
|
+
return (typeof window !== 'undefined' &&
|
|
2005
|
+
(window.navigator?.webdriver || window.chrome?.webdriver));
|
|
2006
|
+
}, []);
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
function useTimeout(callback, delay) {
|
|
2010
|
+
const savedCallback = useRef(callback);
|
|
2011
|
+
savedCallback.current = callback;
|
|
2012
|
+
// Set up the timeout.
|
|
2013
|
+
useEffect(() => {
|
|
2014
|
+
// Don't schedule if no delay is specified.
|
|
2015
|
+
// Note: 0 is a valid value for delay.
|
|
2016
|
+
if (!delay && delay !== 0) {
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
const id = setTimeout(() => savedCallback.current(), delay);
|
|
2020
|
+
return () => clearTimeout(id);
|
|
2021
|
+
}, [delay]);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* Check if Font Loading API is available in the current environment
|
|
2026
|
+
* @returns boolean indicating if document.fonts.ready is available
|
|
2027
|
+
*/
|
|
2028
|
+
const isFontLoadingAPIAvailable = () => {
|
|
2029
|
+
return (typeof document !== 'undefined' &&
|
|
2030
|
+
document.fonts &&
|
|
2031
|
+
typeof document.fonts.ready === 'object' &&
|
|
2032
|
+
typeof document.fonts.ready.then === 'function');
|
|
2033
|
+
};
|
|
2034
|
+
/**
|
|
2035
|
+
* Hook to detect when fonts are loaded
|
|
2036
|
+
*
|
|
2037
|
+
* This hook provides graceful degradation:
|
|
2038
|
+
* - When Font Loading API is not available (older browsers, SSR, test environments),
|
|
2039
|
+
* it assumes fonts are loaded to prevent blocking UI
|
|
2040
|
+
* - When disabled, it immediately returns true without listening to font events
|
|
2041
|
+
* - When enabled and API is available, it listens for font loading completion
|
|
2042
|
+
*
|
|
2043
|
+
* @param enabled - Whether to listen for font loading events (default: true)
|
|
2044
|
+
* @returns boolean indicating whether fonts are loaded
|
|
2045
|
+
*/
|
|
2046
|
+
const useFontLoaded = (enabled = true) => {
|
|
2047
|
+
const [loaded, setLoaded] = useState(() => !enabled || !isFontLoadingAPIAvailable());
|
|
2048
|
+
useEffect(() => {
|
|
2049
|
+
if (!enabled || !isFontLoadingAPIAvailable()) {
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
let cancelled = false;
|
|
2053
|
+
document.fonts.ready.then(() => {
|
|
2054
|
+
if (!cancelled) {
|
|
2055
|
+
setLoaded(true);
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
return () => {
|
|
2059
|
+
cancelled = true;
|
|
2060
|
+
};
|
|
2061
|
+
}, [enabled]);
|
|
2062
|
+
return loaded;
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
/**
|
|
2066
|
+
* Hook to lock body scroll while allowing scroll on target element
|
|
2067
|
+
* Uses body-scroll-lock library for cross-platform compatibility
|
|
2068
|
+
*
|
|
2069
|
+
* @param isLocked - Whether scroll should be locked
|
|
2070
|
+
* @param targetElement - Element that should remain scrollable (optional)
|
|
2071
|
+
* @param options - Configuration options
|
|
2072
|
+
*/
|
|
2073
|
+
const useBodyScrollLock = (isLocked, targetElement, options = {}) => {
|
|
2074
|
+
const { disabled = false, reserveScrollBarGap = true, allowTouchMove, } = options;
|
|
2075
|
+
const { document } = useWindowContext();
|
|
2076
|
+
useEffect(() => {
|
|
2077
|
+
if (disabled || !document?.body)
|
|
2078
|
+
return;
|
|
2079
|
+
const element = targetElement || document.body;
|
|
2080
|
+
if (isLocked) {
|
|
2081
|
+
disableBodyScroll(element, {
|
|
2082
|
+
reserveScrollBarGap,
|
|
2083
|
+
allowTouchMove,
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
return () => {
|
|
2087
|
+
if (isLocked) {
|
|
2088
|
+
enableBodyScroll(element);
|
|
2089
|
+
}
|
|
2090
|
+
};
|
|
2091
|
+
}, [
|
|
2092
|
+
isLocked,
|
|
2093
|
+
targetElement,
|
|
2094
|
+
disabled,
|
|
2095
|
+
reserveScrollBarGap,
|
|
2096
|
+
allowTouchMove,
|
|
2097
|
+
document,
|
|
2098
|
+
]);
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
export { createGlobalListener, globalListenerEventMap, isElmEqualOrContain, isTouchEvent, moveIndexInTwoDimension, teardown, useA11yKeyEvent, useActive, useAnnouncer, useAriaHideOutside, useAudio, useBodyScrollLock, useChange, useClickAway, useDebounce, useDepsChange, useEventCallback, useEventListener, useEver, useFocusVisible, useFontLoaded, useForceUpdate, useForkRef, 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, visuallyHiddenStyles };
|