@atlaskit/teams-app-internal-popup-adaptor 1.1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +17 -0
  3. package/__tests__/unit/PopupTriggerWithHover.test.tsx +141 -0
  4. package/__tests__/unit/useHoverDelay.test.tsx +99 -0
  5. package/__tests__/unit/useHoverTriggerRef.test.tsx +121 -0
  6. package/__tests__/unit/usePreloadRef.test.tsx +118 -0
  7. package/__tests__/unit/usePressableTriggerRef.test.tsx +104 -0
  8. package/__tests__/unit/utils.test.tsx +86 -0
  9. package/afm-cc/tsconfig.json +40 -0
  10. package/afm-products/tsconfig.json +40 -0
  11. package/dist/cjs/PopupTriggerWithHover.js +158 -0
  12. package/dist/cjs/index.js +12 -0
  13. package/dist/cjs/useHoverDelay.js +38 -0
  14. package/dist/cjs/useHoverTriggerRef.js +155 -0
  15. package/dist/cjs/usePreloadRef.js +119 -0
  16. package/dist/cjs/usePressableTriggerRef.js +69 -0
  17. package/dist/cjs/utils.js +48 -0
  18. package/dist/es2019/PopupTriggerWithHover.js +139 -0
  19. package/dist/es2019/index.js +1 -0
  20. package/dist/es2019/useHoverDelay.js +32 -0
  21. package/dist/es2019/useHoverTriggerRef.js +139 -0
  22. package/dist/es2019/usePreloadRef.js +115 -0
  23. package/dist/es2019/usePressableTriggerRef.js +62 -0
  24. package/dist/es2019/utils.js +40 -0
  25. package/dist/esm/PopupTriggerWithHover.js +149 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/useHoverDelay.js +34 -0
  28. package/dist/esm/useHoverTriggerRef.js +149 -0
  29. package/dist/esm/usePreloadRef.js +113 -0
  30. package/dist/esm/usePressableTriggerRef.js +63 -0
  31. package/dist/esm/utils.js +41 -0
  32. package/dist/types/PopupTriggerWithHover.d.ts +28 -0
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/useHoverDelay.d.ts +8 -0
  35. package/dist/types/useHoverTriggerRef.d.ts +19 -0
  36. package/dist/types/usePreloadRef.d.ts +13 -0
  37. package/dist/types/usePressableTriggerRef.d.ts +9 -0
  38. package/dist/types/utils.d.ts +16 -0
  39. package/dist/types-ts4.5/PopupTriggerWithHover.d.ts +28 -0
  40. package/dist/types-ts4.5/index.d.ts +1 -0
  41. package/dist/types-ts4.5/useHoverDelay.d.ts +8 -0
  42. package/dist/types-ts4.5/useHoverTriggerRef.d.ts +19 -0
  43. package/dist/types-ts4.5/usePreloadRef.d.ts +13 -0
  44. package/dist/types-ts4.5/usePressableTriggerRef.d.ts +9 -0
  45. package/dist/types-ts4.5/utils.d.ts +16 -0
  46. package/package.json +81 -0
  47. package/popup-trigger-with-hover/package.json +17 -0
  48. package/src/PopupTriggerWithHover.tsx +240 -0
  49. package/src/index.ts +5 -0
  50. package/src/useHoverDelay.ts +42 -0
  51. package/src/useHoverTriggerRef.ts +177 -0
  52. package/src/usePreloadRef.ts +152 -0
  53. package/src/usePressableTriggerRef.ts +89 -0
  54. package/src/utils.ts +49 -0
  55. package/tsconfig.app.json +46 -0
  56. package/tsconfig.dev.json +45 -0
  57. package/tsconfig.json +19 -0
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "atlassian": {
3
+ "team": "People and Teams Collective"
4
+ },
5
+ "repository": "https://bitbucket.org/atlassian/atlassian-frontend-monorepo",
6
+ "main": "dist/cjs/index.js",
7
+ "module": "dist/esm/index.js",
8
+ "module:es2019": "dist/es2019/index.js",
9
+ "types": "dist/types/index.d.ts",
10
+ "typesVersions": {
11
+ ">=4.5 <5.9": {
12
+ "*": [
13
+ "dist/types-ts4.5/*",
14
+ "dist/types-ts4.5/index.d.ts"
15
+ ]
16
+ }
17
+ },
18
+ "sideEffects": [
19
+ "*.compiled.css"
20
+ ],
21
+ "atlaskit:src": "src/PopupTriggerWithHover.tsx",
22
+ "dependencies": {
23
+ "@atlaskit/popup": "^4.19.0",
24
+ "@atlaskit/spinner": "^19.1.0",
25
+ "@atlassian/entry-point-types": "^0.0.0",
26
+ "@atlassian/internal-entry-point-container": "^1.1.0",
27
+ "@atlassian/react-async": "^0.21.0",
28
+ "@atlassian/relay-environment-provider": "^8.2.0",
29
+ "@babel/runtime": "^7.0.0",
30
+ "@compiled/react": "^0.20.0",
31
+ "bind-event-listener": "^3.0.0",
32
+ "react-relay": "^20.1.1",
33
+ "use-callback-ref": "^1.2.3"
34
+ },
35
+ "peerDependencies": {
36
+ "react": "^18.2.0"
37
+ },
38
+ "devDependencies": {
39
+ "@atlassian/feature-flags-test-utils": "^1.1.0",
40
+ "@atlassian/testing-library": "^0.5.0",
41
+ "react-dom": "^18.2.0",
42
+ "relay-test-utils": "^20.1.1"
43
+ },
44
+ "techstack": {
45
+ "@atlassian/frontend": {
46
+ "import-structure": [
47
+ "atlassian-conventions"
48
+ ],
49
+ "circular-dependencies": [
50
+ "file-and-folder-level"
51
+ ]
52
+ },
53
+ "@repo/internal": {
54
+ "dom-events": "use-bind-event-listener",
55
+ "design-tokens": [
56
+ "color"
57
+ ],
58
+ "theming": [
59
+ "react-context"
60
+ ],
61
+ "ui-components": [
62
+ "lite-mode"
63
+ ],
64
+ "styling": [
65
+ "static",
66
+ "compiled"
67
+ ],
68
+ "imports": [
69
+ "import-no-extraneous-disable-for-examples-and-docs"
70
+ ]
71
+ }
72
+ },
73
+ "name": "@atlaskit/teams-app-internal-popup-adaptor",
74
+ "version": "1.1.0",
75
+ "description": "Internal popup trigger adapter shared by People and Teams profile card packages (hover/click/preload behavior)",
76
+ "author": "Atlassian Pty Ltd",
77
+ "publishConfig": {
78
+ "registry": "https://packages.atlassian.com/api/npm/npm-remote"
79
+ },
80
+ "platform-feature-flags": {}
81
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@atlaskit/teams-app-internal-popup-adaptor/popup-trigger-with-hover",
3
+ "main": "../dist/cjs/PopupTriggerWithHover.js",
4
+ "module": "../dist/esm/PopupTriggerWithHover.js",
5
+ "module:es2019": "../dist/es2019/PopupTriggerWithHover.js",
6
+ "sideEffects": [
7
+ "*.compiled.css"
8
+ ],
9
+ "types": "../dist/types/PopupTriggerWithHover.d.ts",
10
+ "typesVersions": {
11
+ ">=4.5 <5.9": {
12
+ "*": [
13
+ "../dist/types-ts4.5/PopupTriggerWithHover.d.ts"
14
+ ]
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,240 @@
1
+ import React, {
2
+ type ReactNode,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ import { loadEntryPoint } from 'react-relay';
12
+ import { mergeRefs } from 'use-callback-ref';
13
+
14
+ import { type ContentProps, Popup, type PopupProps, type TriggerProps } from '@atlaskit/popup';
15
+ import Spinner from '@atlaskit/spinner';
16
+ import type {
17
+ AnyEntryPoint,
18
+ EntryPointRef,
19
+ GetRuntimePropsFromEntryPoint,
20
+ ParamsOfEntryPoint,
21
+ RuntimePropsOfEntryPoint,
22
+ } from '@atlassian/entry-point-types';
23
+ import {
24
+ type ErrorCallback,
25
+ type ErrorFallback,
26
+ InternalEntryPointContainer,
27
+ } from '@atlassian/internal-entry-point-container';
28
+ import type { JSResourceReference } from '@atlassian/react-async';
29
+ import { useRelayEnvironmentProvider } from '@atlassian/relay-environment-provider';
30
+
31
+ import { useHoverTriggerRef } from './useHoverTriggerRef';
32
+ import { usePreloadRef } from './usePreloadRef';
33
+ import { usePressableTriggerRef } from './usePressableTriggerRef';
34
+
35
+ export type TriggerMode = 'hover' | 'click' | Array<'hover' | 'click'>;
36
+
37
+ const emptyEntryPointParams = {} as ParamsOfEntryPoint<AnyEntryPoint>;
38
+ const emptyEntryPointProps = {} as RuntimePropsOfEntryPoint<AnyEntryPoint>;
39
+
40
+ type UnionOmit<T, K extends string | number | symbol> = T extends unknown ? Omit<T, K> : never;
41
+
42
+ type CustomPopupProps = UnionOmit<PopupProps, 'isOpen' | 'content'>;
43
+ type EntryPointPropsType<TEntryPoint> = GetRuntimePropsFromEntryPoint<TEntryPoint>;
44
+
45
+ const FallbackComponent = ({
46
+ fallback,
47
+ onUnmount,
48
+ }: {
49
+ fallback?: ReactNode;
50
+ onUnmount: () => void;
51
+ }) => {
52
+ useEffect(() => {
53
+ return () => onUnmount();
54
+ }, [onUnmount]);
55
+
56
+ return <>{fallback || <Spinner />}</>;
57
+ };
58
+
59
+ export type PopupTriggerWithHoverProps<TEntryPoint> = {
60
+ entryPoint: TEntryPoint;
61
+ entryPointParams?: ParamsOfEntryPoint<TEntryPoint>;
62
+ entryPointProps?: Omit<EntryPointPropsType<TEntryPoint>, 'onClose' | 'onContentResized'> & {
63
+ onClose?: () => void;
64
+ onContentResized?: ContentProps['update'];
65
+ };
66
+ errorFallback?: ErrorFallback;
67
+ fallback?: ReactNode;
68
+ forcedReportErrorUFO?: boolean;
69
+ onError?: ErrorCallback;
70
+ onOpen?: (method: 'hover' | 'click') => void;
71
+ trigger: (triggerProps: TriggerProps) => ReactNode;
72
+ /**
73
+ * How the popup should be triggered. Defaults to `'click'` when omitted.
74
+ */
75
+ triggerMode?: TriggerMode;
76
+ } & CustomPopupProps;
77
+
78
+ export function PopupTriggerWithHover<TEntryPoint extends AnyEntryPoint>({
79
+ trigger,
80
+ triggerMode = 'click',
81
+ entryPoint,
82
+ entryPointParams,
83
+ entryPointProps,
84
+ fallback,
85
+ errorFallback,
86
+ onError,
87
+ forcedReportErrorUFO,
88
+ onOpen,
89
+ onClose,
90
+ ...popupProps
91
+ }: PopupTriggerWithHoverProps<TEntryPoint>): React.JSX.Element {
92
+ const environmentProvider = useRelayEnvironmentProvider();
93
+
94
+ const id = useMemo((): string => {
95
+ return (entryPoint.root as JSResourceReference<unknown>).getModuleName() || 'unknown';
96
+ }, [entryPoint]);
97
+
98
+ const [isOpen, setIsOpen] = useState(false);
99
+ const [content, setContent] = useState<ReactNode | null>(null);
100
+
101
+ const updateRef = useRef<(() => void) | null>(null);
102
+ const openedByRef = useRef<'hover' | 'click' | null>(null);
103
+ const isOpenRef = useRef(isOpen);
104
+ const triggers = Array.isArray(triggerMode) ? triggerMode : [triggerMode];
105
+
106
+ useLayoutEffect(() => {
107
+ isOpenRef.current = isOpen;
108
+ }, [isOpen]);
109
+
110
+ const handleOpen = useCallback(
111
+ (method: 'hover' | 'click' | null) => {
112
+ setIsOpen(true);
113
+ if (method) {
114
+ onOpen?.(method);
115
+ }
116
+ },
117
+ [onOpen],
118
+ );
119
+
120
+ const handleClose = useCallback(
121
+ (
122
+ event?: Event | React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element> | null,
123
+ currentLevel?: any,
124
+ ) => {
125
+ setIsOpen(false);
126
+ onClose?.(event ?? null, currentLevel);
127
+ },
128
+ [onClose],
129
+ );
130
+
131
+ const load = useCallback(
132
+ () =>
133
+ loadEntryPoint<TEntryPoint>(
134
+ environmentProvider,
135
+ entryPoint,
136
+ entryPointParams ?? emptyEntryPointParams,
137
+ ),
138
+ [entryPoint, entryPointParams, environmentProvider],
139
+ );
140
+
141
+ const onLoad = useCallback(
142
+ ({ reference: entryPointReference }: { reference: EntryPointRef<TEntryPoint> }) => {
143
+ if (isOpenRef.current) {
144
+ return;
145
+ }
146
+
147
+ handleOpen(openedByRef.current);
148
+
149
+ setContent(
150
+ <InternalEntryPointContainer
151
+ id={id}
152
+ entryPointReference={entryPointReference}
153
+ fallback={
154
+ <FallbackComponent fallback={fallback} onUnmount={() => updateRef.current?.()} />
155
+ }
156
+ errorFallback={errorFallback}
157
+ runtimeProps={
158
+ {
159
+ ...(entryPointProps ?? emptyEntryPointProps),
160
+ onContentResized: () => {
161
+ updateRef.current?.();
162
+ },
163
+ onClose: handleClose,
164
+ } as unknown as EntryPointPropsType<TEntryPoint>
165
+ }
166
+ onError={onError}
167
+ forcedError={forcedReportErrorUFO}
168
+ />,
169
+ );
170
+ },
171
+ [
172
+ setContent,
173
+ id,
174
+ entryPointProps,
175
+ fallback,
176
+ forcedReportErrorUFO,
177
+ errorFallback,
178
+ handleOpen,
179
+ onError,
180
+ handleClose,
181
+ ],
182
+ );
183
+
184
+ const preloadRef = usePreloadRef<EntryPointRef<TEntryPoint>>({
185
+ load,
186
+ onLoad,
187
+ });
188
+
189
+ const pressableRef = usePressableTriggerRef({
190
+ onOpen: preloadRef.loadAndOpen,
191
+ onClose: handleClose,
192
+ isOpen,
193
+ isDisabled: !triggers.includes('click'),
194
+ openedByRef,
195
+ });
196
+
197
+ const { triggerRef: hoverTriggerRef, contentRef: hoverContentRef } = useHoverTriggerRef({
198
+ onOpen: preloadRef.loadAndOpen,
199
+ onClose: handleClose,
200
+ isOpen,
201
+ isDisabled: !triggers.includes('hover'),
202
+ openedByRef,
203
+ });
204
+
205
+ useEffect(() => {
206
+ if (!isOpen) {
207
+ openedByRef.current = null;
208
+ }
209
+ }, [isOpen]);
210
+
211
+ const popupTrigger = useCallback(
212
+ (triggerProps: TriggerProps) => {
213
+ const mergedRef = mergeRefs([preloadRef, pressableRef, hoverTriggerRef, triggerProps.ref]);
214
+ return trigger({ ...triggerProps, ref: mergedRef });
215
+ },
216
+ [trigger, preloadRef, pressableRef, hoverTriggerRef],
217
+ );
218
+
219
+ const popupContent = useCallback(
220
+ ({ update }: ContentProps) => {
221
+ updateRef.current = update;
222
+ return (
223
+ <div ref={hoverContentRef} role="none">
224
+ {content}
225
+ </div>
226
+ );
227
+ },
228
+ [content, hoverContentRef],
229
+ );
230
+
231
+ return (
232
+ <Popup
233
+ {...popupProps}
234
+ isOpen={isOpen}
235
+ onClose={handleClose}
236
+ trigger={popupTrigger}
237
+ content={popupContent}
238
+ />
239
+ );
240
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ PopupTriggerWithHover,
3
+ type PopupTriggerWithHoverProps,
4
+ type TriggerMode,
5
+ } from './PopupTriggerWithHover';
@@ -0,0 +1,42 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * Hook for managing delayed hover actions with timeout management.
5
+ */
6
+ function useHoverDelay(
7
+ callback: () => void,
8
+ delay: number,
9
+ ): {
10
+ schedule: () => void;
11
+ clear: () => void;
12
+ } {
13
+ const timeoutRef = useRef<number | null>(null);
14
+
15
+ const callbackRef = useRef(callback);
16
+
17
+ useEffect(() => {
18
+ callbackRef.current = callback;
19
+ }, [callback]);
20
+
21
+ const clear = useCallback((): void => {
22
+ if (timeoutRef.current) {
23
+ clearTimeout(timeoutRef.current);
24
+ timeoutRef.current = null;
25
+ }
26
+ }, []);
27
+
28
+ const schedule = useCallback((): void => {
29
+ clear();
30
+ timeoutRef.current = window.setTimeout(() => {
31
+ callbackRef.current();
32
+ }, delay);
33
+ }, [delay, clear]);
34
+
35
+ useEffect(() => {
36
+ return () => clear();
37
+ }, [clear]);
38
+
39
+ return { schedule, clear };
40
+ }
41
+
42
+ export { useHoverDelay };
@@ -0,0 +1,177 @@
1
+ import { type MutableRefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
2
+
3
+ import { bind, type UnbindFn } from 'bind-event-listener';
4
+
5
+ import { useHoverDelay } from './useHoverDelay';
6
+ import { getChildPortalFromParentPopup, hasRelatedTarget, isMovingToKudos } from './utils';
7
+
8
+ const HOVER_OPEN_DELAY = 800;
9
+ const HOVER_CLOSE_DELAY = 200;
10
+
11
+ export type UseHoverTriggerRefProps = {
12
+ onOpen: () => void;
13
+ onClose?: () => void;
14
+ isOpen?: boolean;
15
+ isDisabled?: boolean;
16
+ openedByRef?: MutableRefObject<'hover' | 'click' | null>;
17
+ delays?: {
18
+ hoverOpen?: number;
19
+ hoverClose?: number;
20
+ };
21
+ };
22
+
23
+ /**
24
+ * Hover behavior for popup trigger and content with delayed open/close.
25
+ */
26
+ export function useHoverTriggerRef({
27
+ onOpen,
28
+ onClose,
29
+ isOpen = false,
30
+ isDisabled = false,
31
+ openedByRef,
32
+ delays = {},
33
+ }: UseHoverTriggerRefProps): {
34
+ triggerRef: (element: HTMLElement | null) => void;
35
+ contentRef: (element: HTMLElement | null) => void;
36
+ } {
37
+ const { hoverOpen = HOVER_OPEN_DELAY, hoverClose = HOVER_CLOSE_DELAY } = delays;
38
+
39
+ const triggerNodeRef = useRef<HTMLElement | null>(null);
40
+ const triggerListeners = useRef<UnbindFn[]>([]);
41
+ const contentNodeRef = useRef<HTMLElement | null>(null);
42
+ const contentListeners = useRef<Map<Element, UnbindFn>>(new Map());
43
+ const isOpenRef = useRef(isOpen);
44
+
45
+ useLayoutEffect(() => {
46
+ isOpenRef.current = isOpen;
47
+ }, [isOpen]);
48
+
49
+ const openDelay = useHoverDelay(() => {
50
+ if (isOpenRef.current) {
51
+ return;
52
+ }
53
+ if (openedByRef) {
54
+ openedByRef.current = 'hover';
55
+ }
56
+ onOpen();
57
+ }, hoverOpen);
58
+
59
+ const closeDelay = useHoverDelay(() => {
60
+ if (!isOpenRef.current) {
61
+ return;
62
+ }
63
+ if (!openedByRef || openedByRef.current === 'hover') {
64
+ onClose?.();
65
+ }
66
+ }, hoverClose);
67
+
68
+ const openDelayRef = useRef(openDelay);
69
+ const closeDelayRef = useRef(closeDelay);
70
+ openDelayRef.current = openDelay;
71
+ closeDelayRef.current = closeDelay;
72
+
73
+ const handleTriggerEnter = useCallback(() => {
74
+ closeDelayRef.current.clear();
75
+ openDelayRef.current.schedule();
76
+ }, []);
77
+
78
+ const handleTriggerLeave = useCallback(() => {
79
+ openDelayRef.current.clear();
80
+ closeDelayRef.current.schedule();
81
+ }, []);
82
+
83
+ const triggerRef = useCallback(
84
+ (element: HTMLElement | null): void => {
85
+ if (element === triggerNodeRef.current) {
86
+ return;
87
+ }
88
+
89
+ triggerListeners.current.forEach((unbind) => unbind());
90
+ triggerListeners.current = [];
91
+ triggerNodeRef.current = element;
92
+
93
+ if (element && !isDisabled) {
94
+ triggerListeners.current.push(
95
+ bind(element, { type: 'mouseenter', listener: handleTriggerEnter }),
96
+ bind(element, { type: 'mouseleave', listener: handleTriggerLeave }),
97
+ );
98
+ }
99
+ },
100
+ [handleTriggerEnter, handleTriggerLeave, isDisabled],
101
+ );
102
+
103
+ const handleContentEnter = useCallback(() => {
104
+ closeDelayRef.current.clear();
105
+ }, []);
106
+
107
+ const cleanupContentListeners = useCallback(() => {
108
+ contentListeners.current.forEach((unbind) => unbind());
109
+ contentListeners.current.clear();
110
+ }, []);
111
+
112
+ const attachContentListeners = useCallback(
113
+ (element: Element) => {
114
+ if (contentListeners.current.has(element)) {
115
+ return;
116
+ }
117
+
118
+ const handleMouseLeave = (event: Event) => {
119
+ if (!hasRelatedTarget(event)) {
120
+ return;
121
+ }
122
+
123
+ const relatedTarget = event.relatedTarget;
124
+
125
+ if (isMovingToKudos(relatedTarget)) {
126
+ return;
127
+ }
128
+
129
+ const childPortal = getChildPortalFromParentPopup(relatedTarget, contentNodeRef.current);
130
+ if (childPortal) {
131
+ attachContentListeners(childPortal);
132
+ return;
133
+ }
134
+
135
+ closeDelayRef.current.schedule();
136
+ };
137
+
138
+ const unbindEnter = bind(element, { type: 'mouseenter', listener: handleContentEnter });
139
+ const unbindLeave = bind(element, { type: 'mouseleave', listener: handleMouseLeave });
140
+
141
+ contentListeners.current.set(element, () => {
142
+ unbindEnter();
143
+ unbindLeave();
144
+ });
145
+ },
146
+ [handleContentEnter],
147
+ );
148
+
149
+ const contentRef = useCallback(
150
+ (element: HTMLElement | null): void => {
151
+ if (element === contentNodeRef.current) {
152
+ return;
153
+ }
154
+
155
+ cleanupContentListeners();
156
+ contentNodeRef.current = element;
157
+
158
+ if (element && !isDisabled) {
159
+ attachContentListeners(element);
160
+ }
161
+ },
162
+ [attachContentListeners, cleanupContentListeners, isDisabled],
163
+ );
164
+
165
+ useEffect(() => {
166
+ const listeners = contentListeners.current;
167
+ const triggers = triggerListeners.current;
168
+ return () => {
169
+ openDelayRef.current.clear();
170
+ closeDelayRef.current.clear();
171
+ triggers.forEach((unbind) => unbind());
172
+ listeners.forEach((unbind) => unbind());
173
+ };
174
+ }, []);
175
+
176
+ return { triggerRef, contentRef };
177
+ }
@@ -0,0 +1,152 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+
3
+ import { bind, type UnbindFn } from 'bind-event-listener';
4
+
5
+ const PRELOAD_MAX_AGE = 5 * 60 * 1000;
6
+ const TIME_TO_INTENT = 200;
7
+
8
+ export type OnLoad<T> = (obj: { dispose: () => void; reference: T }) => void;
9
+
10
+ export type UsePreloadRefProps<T> = {
11
+ load: () => T;
12
+ onLoad: OnLoad<T>;
13
+ };
14
+
15
+ export function usePreloadRef<T extends { dispose: () => void }>({
16
+ load,
17
+ onLoad,
18
+ }: UsePreloadRefProps<T>): ((element: HTMLElement | null) => void) & {
19
+ loadAndOpen: () => void;
20
+ } {
21
+ const nodeRef = useRef<HTMLElement | null>(null);
22
+ const eventListeners = useRef<UnbindFn[]>([]);
23
+
24
+ const { loadAndOpen, preloadReference, cancelPreload } = useMemo(() => {
25
+ const request: {
26
+ load?: { dispose: () => void; reference: T };
27
+ preload?: { dispose: () => void; reference: T };
28
+ preloadIntentTimeout?: ReturnType<typeof setTimeout>;
29
+ preloadMaxAgeTimeout?: ReturnType<typeof setTimeout>;
30
+ } = {};
31
+
32
+ const clearPreloadTimeouts = () => {
33
+ if (request.preloadIntentTimeout != null) {
34
+ clearTimeout(request.preloadIntentTimeout);
35
+ delete request.preloadIntentTimeout;
36
+ }
37
+
38
+ if (request.preloadMaxAgeTimeout != null) {
39
+ clearTimeout(request.preloadMaxAgeTimeout);
40
+ delete request.preloadMaxAgeTimeout;
41
+ }
42
+ };
43
+
44
+ const cancelPreload = () => {
45
+ clearPreloadTimeouts();
46
+ request.preload?.dispose();
47
+ };
48
+
49
+ const loadReference = () => {
50
+ if (request.load) {
51
+ return request.load;
52
+ }
53
+
54
+ const reference = request.preload?.reference ?? load();
55
+ if (request.preload) {
56
+ delete request.preload;
57
+ }
58
+
59
+ request.load = {
60
+ reference,
61
+ dispose() {
62
+ reference.dispose();
63
+ delete request.load;
64
+ },
65
+ };
66
+
67
+ return request.load;
68
+ };
69
+
70
+ const loadAndOpen = () => {
71
+ clearPreloadTimeouts();
72
+ onLoad(loadReference());
73
+ };
74
+
75
+ const preloadReference = () => {
76
+ if (request.preloadIntentTimeout || request.preload) {
77
+ return;
78
+ }
79
+
80
+ request.preloadIntentTimeout = setTimeout(() => {
81
+ delete request.preloadIntentTimeout;
82
+
83
+ const reference = load();
84
+
85
+ request.preload = {
86
+ reference,
87
+ dispose() {
88
+ reference.dispose();
89
+ delete request.preload;
90
+ },
91
+ };
92
+
93
+ request.preloadMaxAgeTimeout = setTimeout(() => {
94
+ delete request.preloadMaxAgeTimeout;
95
+ request.preload?.dispose();
96
+ }, PRELOAD_MAX_AGE);
97
+ }, TIME_TO_INTENT);
98
+ };
99
+
100
+ return {
101
+ loadAndOpen,
102
+ preloadReference,
103
+ cancelPreload,
104
+ };
105
+ }, [load, onLoad]);
106
+
107
+ const cleanUpEventListeners = useCallback(() => {
108
+ eventListeners.current.forEach((unbind) => {
109
+ unbind();
110
+ });
111
+ eventListeners.current = [];
112
+ }, []);
113
+
114
+ const addEventListener = useCallback(
115
+ (element: HTMLElement, type: string, callback: EventListener) => {
116
+ const unbind = bind(element, {
117
+ type,
118
+ listener: callback,
119
+ });
120
+
121
+ eventListeners.current.push(unbind);
122
+ },
123
+ [],
124
+ );
125
+
126
+ const refCallback = useCallback(
127
+ (element: HTMLElement | null) => {
128
+ if (element !== nodeRef.current) {
129
+ if (nodeRef.current) {
130
+ cancelPreload();
131
+ cleanUpEventListeners();
132
+ }
133
+ if (element) {
134
+ addEventListener(element, 'mouseenter', preloadReference);
135
+ addEventListener(element, 'mouseleave', cancelPreload);
136
+ }
137
+ nodeRef.current = element;
138
+ }
139
+ },
140
+ [preloadReference, cancelPreload, addEventListener, cleanUpEventListeners],
141
+ );
142
+
143
+ useEffect(() => {
144
+ return () => {
145
+ cleanUpEventListeners();
146
+ };
147
+ }, [cleanUpEventListeners]);
148
+
149
+ return Object.assign(refCallback, {
150
+ loadAndOpen,
151
+ });
152
+ }