@structuralists/scaffolding 0.2.0 → 0.3.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/package.json +1 -1
- package/src/components/Content/Menu/index.tsx +9 -14
- package/src/components/Overlays/Popover/Popover.test.tsx +75 -0
- package/src/components/Overlays/Popover/index.tsx +10 -15
- package/src/globalEscapeHatches.ts +33 -0
- package/src/hooks/useClickOutside/index.ts +33 -4
- package/src/hooks/useClickOutside/useClickOutside.test.tsx +104 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { createPortal } from 'react-dom';
|
|
10
10
|
import type { MenuItem, MenuProps } from './types';
|
|
11
11
|
import { cx } from '../../../utils';
|
|
12
|
+
import { useClickOutside } from '../../../hooks/useClickOutside';
|
|
12
13
|
import styles from './styles.module.css';
|
|
13
14
|
|
|
14
15
|
const isBranch = (
|
|
@@ -205,26 +206,20 @@ export const Menu = (props: MenuProps) => {
|
|
|
205
206
|
);
|
|
206
207
|
}, [open]);
|
|
207
208
|
|
|
209
|
+
useClickOutside({
|
|
210
|
+
ref: panelRef,
|
|
211
|
+
extraRefs: [triggerRef],
|
|
212
|
+
onOutside: () => setOpen(false),
|
|
213
|
+
enabled: open,
|
|
214
|
+
});
|
|
215
|
+
|
|
208
216
|
useEffect(() => {
|
|
209
217
|
if (!open) return;
|
|
210
|
-
|
|
211
|
-
const handleMouseDown = (e: MouseEvent) => {
|
|
212
|
-
const target = e.target as Node;
|
|
213
|
-
if (triggerRef.current?.contains(target)) return;
|
|
214
|
-
if (panelRef.current?.contains(target)) return;
|
|
215
|
-
setOpen(false);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
218
|
const handleKey = (e: KeyboardEvent) => {
|
|
219
219
|
if (e.key === 'Escape') setOpen(false);
|
|
220
220
|
};
|
|
221
|
-
|
|
222
|
-
window.addEventListener('mousedown', handleMouseDown);
|
|
223
221
|
window.addEventListener('keydown', handleKey);
|
|
224
|
-
return () =>
|
|
225
|
-
window.removeEventListener('mousedown', handleMouseDown);
|
|
226
|
-
window.removeEventListener('keydown', handleKey);
|
|
227
|
-
};
|
|
222
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
228
223
|
}, [open]);
|
|
229
224
|
|
|
230
225
|
return (
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { render, fireEvent, cleanup } from '@testing-library/react';
|
|
4
|
+
import { Popover } from './index';
|
|
5
|
+
import { globalEscapeHatches } from '../../../globalEscapeHatches';
|
|
6
|
+
|
|
7
|
+
const Harness = (props: { onClose: () => void; isOpen?: boolean }) => {
|
|
8
|
+
const { onClose, isOpen = true } = props;
|
|
9
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
10
|
+
return (
|
|
11
|
+
<div>
|
|
12
|
+
<button ref={anchorRef} type="button">
|
|
13
|
+
anchor
|
|
14
|
+
</button>
|
|
15
|
+
<Popover anchorRef={anchorRef} isOpen={isOpen} onClose={onClose}>
|
|
16
|
+
<div>popover-body</div>
|
|
17
|
+
</Popover>
|
|
18
|
+
<div data-testid="outside">elsewhere</div>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('Popover outside-click integration', () => {
|
|
24
|
+
test('dismisses on a normal pointerdown outside', () => {
|
|
25
|
+
const onClose = mock(() => {});
|
|
26
|
+
const { getByTestId } = render(<Harness onClose={onClose} />);
|
|
27
|
+
fireEvent.pointerDown(getByTestId('outside'));
|
|
28
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('does not dismiss when clicking the anchor', () => {
|
|
33
|
+
const onClose = mock(() => {});
|
|
34
|
+
const { getByText } = render(<Harness onClose={onClose} />);
|
|
35
|
+
fireEvent.pointerDown(getByText('anchor'));
|
|
36
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
37
|
+
cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('does not dismiss when clicking inside the panel', () => {
|
|
41
|
+
const onClose = mock(() => {});
|
|
42
|
+
const { getByText } = render(<Harness onClose={onClose} />);
|
|
43
|
+
fireEvent.pointerDown(getByText('popover-body'));
|
|
44
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
45
|
+
cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// The motivating case for globalEscapeHatches: a host (e.g. a canvas-based
|
|
49
|
+
// UI library) captures pointer events before they reach the document, so
|
|
50
|
+
// the normal pointerdown listener never fires. The escape hatch lets the
|
|
51
|
+
// host's "click on background" handler still dismiss the popover.
|
|
52
|
+
test('globalEscapeHatches.onTriggerOutsideClick dismisses an open popover', () => {
|
|
53
|
+
const onClose = mock(() => {});
|
|
54
|
+
render(<Harness onClose={onClose} />);
|
|
55
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
56
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
57
|
+
cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('escape hatch is a no-op when the popover is closed', () => {
|
|
61
|
+
const onClose = mock(() => {});
|
|
62
|
+
render(<Harness onClose={onClose} isOpen={false} />);
|
|
63
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
64
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
65
|
+
cleanup();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('dismisses on Escape', () => {
|
|
69
|
+
const onClose = mock(() => {});
|
|
70
|
+
render(<Harness onClose={onClose} />);
|
|
71
|
+
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
72
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
73
|
+
cleanup();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { createPortal } from 'react-dom';
|
|
10
10
|
import type { PopoverPlacement, PopoverProps } from './types';
|
|
11
11
|
import { cx } from '../../../utils';
|
|
12
|
+
import { useClickOutside } from '../../../hooks/useClickOutside';
|
|
12
13
|
import styles from './styles.module.css';
|
|
13
14
|
|
|
14
15
|
const GAP = 6;
|
|
@@ -100,27 +101,21 @@ export const Popover = (props: PopoverProps) => {
|
|
|
100
101
|
);
|
|
101
102
|
}, [isOpen, anchorRef, placement, matchAnchorWidth]);
|
|
102
103
|
|
|
104
|
+
useClickOutside({
|
|
105
|
+
ref: panelRef,
|
|
106
|
+
extraRefs: [anchorRef],
|
|
107
|
+
onOutside: onClose,
|
|
108
|
+
enabled: isOpen,
|
|
109
|
+
});
|
|
110
|
+
|
|
103
111
|
useEffect(() => {
|
|
104
112
|
if (!isOpen) return;
|
|
105
|
-
|
|
106
|
-
const handleMouseDown = (e: MouseEvent) => {
|
|
107
|
-
const target = e.target as Node;
|
|
108
|
-
if (panelRef.current?.contains(target)) return;
|
|
109
|
-
if (anchorRef.current?.contains(target)) return;
|
|
110
|
-
onClose();
|
|
111
|
-
};
|
|
112
|
-
|
|
113
113
|
const handleKey = (e: KeyboardEvent) => {
|
|
114
114
|
if (e.key === 'Escape') onClose();
|
|
115
115
|
};
|
|
116
|
-
|
|
117
|
-
window.addEventListener('mousedown', handleMouseDown);
|
|
118
116
|
window.addEventListener('keydown', handleKey);
|
|
119
|
-
return () =>
|
|
120
|
-
|
|
121
|
-
window.removeEventListener('keydown', handleKey);
|
|
122
|
-
};
|
|
123
|
-
}, [isOpen, anchorRef, onClose]);
|
|
117
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
118
|
+
}, [isOpen, onClose]);
|
|
124
119
|
|
|
125
120
|
// Capture the anchor at open time so a rerender that swaps the element
|
|
126
121
|
// still restores focus to whatever the user originally engaged.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Escape hatches for cases where the host environment swallows the events
|
|
2
|
+
* our overlays normally listen for — e.g. a canvas-based UI library that
|
|
3
|
+
* captures pointer events before they reach the document, so the
|
|
4
|
+
* `pointerdown` listener that dismisses popovers/menus/slide-overlays
|
|
5
|
+
* never fires.
|
|
6
|
+
*
|
|
7
|
+
* Call these from your integration glue. They read clearly at the call
|
|
8
|
+
* site — from the import alone it's obvious what's happening and why. */
|
|
9
|
+
|
|
10
|
+
const outsideClickHandlers = new Set<() => void>();
|
|
11
|
+
|
|
12
|
+
/** Register a handler that should run when something asks the library to
|
|
13
|
+
* simulate an outside click. Returns an unregister function. Wired up
|
|
14
|
+
* internally by `useClickOutside`; not part of the public API. */
|
|
15
|
+
export const registerOutsideClickHandler = (handler: () => void) => {
|
|
16
|
+
outsideClickHandlers.add(handler);
|
|
17
|
+
return () => {
|
|
18
|
+
outsideClickHandlers.delete(handler);
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const globalEscapeHatches = {
|
|
23
|
+
/** Dismiss every currently open overlay whose dismissal is wired through
|
|
24
|
+
* `useClickOutside` (popovers, menus, slide-in overlays, and anything
|
|
25
|
+
* built on top of them). No-op when nothing is open. */
|
|
26
|
+
onTriggerOutsideClick: () => {
|
|
27
|
+
// Snapshot first — handlers deregister themselves as a side effect of
|
|
28
|
+
// closing, which would mutate the live set mid-iteration.
|
|
29
|
+
for (const handler of [...outsideClickHandlers]) {
|
|
30
|
+
handler();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { useEffect, useRef, type RefObject } from 'react';
|
|
2
2
|
import { useStableCallback } from '../useStableCallback';
|
|
3
|
+
import { registerOutsideClickHandler } from '../../globalEscapeHatches';
|
|
3
4
|
|
|
4
5
|
type UseClickOutsideArgs<Element extends HTMLElement> = {
|
|
5
6
|
/** Optional external ref. When omitted, the hook owns an internal ref —
|
|
6
7
|
* attach the returned `ref` to the element you want to guard. */
|
|
7
8
|
ref?: RefObject<Element | null>;
|
|
9
|
+
/** Additional refs whose contents should also count as "inside" — e.g. an
|
|
10
|
+
* anchoring trigger button that lives outside the guarded panel but
|
|
11
|
+
* shouldn't dismiss it when clicked. */
|
|
12
|
+
extraRefs?: ReadonlyArray<RefObject<HTMLElement | null>>;
|
|
8
13
|
/** Callback fired on a `pointerdown` outside the guarded element. When
|
|
9
14
|
* `undefined`, the listener is detached (lets callers gate via a prop
|
|
10
15
|
* without conditionally calling the hook). */
|
|
@@ -26,32 +31,56 @@ type UseClickOutsideReturn<Element extends HTMLElement> = {
|
|
|
26
31
|
* The callback is internally stabilized, so callers can pass an inline
|
|
27
32
|
* function without retriggering the listener attach/detach effect.
|
|
28
33
|
*
|
|
34
|
+
* Also auto-registers with `globalEscapeHatches.onTriggerOutsideClick`,
|
|
35
|
+
* so hosts that swallow pointer events before they reach the document
|
|
36
|
+
* (e.g. canvas-based UI libraries) can still trigger dismissal manually.
|
|
37
|
+
*
|
|
29
38
|
* Note: clicks inside portal'd descendants of the guarded element
|
|
30
39
|
* (popovers, tooltips, menus) count as outside since they are not
|
|
31
|
-
* DOM-contained. Compose with
|
|
32
|
-
*
|
|
40
|
+
* DOM-contained. Compose with `extraRefs` or a custom predicate if
|
|
41
|
+
* that is not desired. */
|
|
33
42
|
export const useClickOutside = <Element extends HTMLElement = HTMLElement>(
|
|
34
43
|
args: UseClickOutsideArgs<Element>,
|
|
35
44
|
): UseClickOutsideReturn<Element> => {
|
|
36
|
-
const { ref: passedRef, onOutside, enabled = true } = args;
|
|
45
|
+
const { ref: passedRef, extraRefs, onOutside, enabled = true } = args;
|
|
37
46
|
|
|
38
47
|
const innerRef = useRef<Element | null>(null);
|
|
39
48
|
const ref = passedRef ?? innerRef;
|
|
40
49
|
|
|
50
|
+
// Held in a ref so a fresh array literal from the caller (e.g. inline
|
|
51
|
+
// `[anchorRef]`) doesn't churn the listener-attach effect every render.
|
|
52
|
+
const extraRefsRef = useRef(extraRefs);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
extraRefsRef.current = extraRefs;
|
|
55
|
+
}, [extraRefs]);
|
|
56
|
+
|
|
41
57
|
const stableOnOutside = useStableCallback({ callback: onOutside });
|
|
42
58
|
const isActive = enabled && onOutside != null;
|
|
43
59
|
|
|
44
60
|
useEffect(() => {
|
|
45
61
|
if (!isActive) return;
|
|
46
62
|
const handler = (event: PointerEvent) => {
|
|
63
|
+
if (!(event.target instanceof Node)) return;
|
|
64
|
+
const target = event.target;
|
|
47
65
|
const el = ref.current;
|
|
48
66
|
if (el == null) return;
|
|
49
|
-
if (
|
|
67
|
+
if (el.contains(target)) return;
|
|
68
|
+
const extras = extraRefsRef.current;
|
|
69
|
+
if (extras) {
|
|
70
|
+
for (const r of extras) {
|
|
71
|
+
if (r.current?.contains(target)) return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
50
74
|
stableOnOutside();
|
|
51
75
|
};
|
|
52
76
|
document.addEventListener('pointerdown', handler);
|
|
53
77
|
return () => document.removeEventListener('pointerdown', handler);
|
|
54
78
|
}, [ref, isActive, stableOnOutside]);
|
|
55
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!isActive) return;
|
|
82
|
+
return registerOutsideClickHandler(stableOnOutside);
|
|
83
|
+
}, [isActive, stableOnOutside]);
|
|
84
|
+
|
|
56
85
|
return { ref };
|
|
57
86
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { render, fireEvent, cleanup } from '@testing-library/react';
|
|
4
|
+
import { useClickOutside } from './index';
|
|
5
|
+
import { globalEscapeHatches } from '../../globalEscapeHatches';
|
|
6
|
+
|
|
7
|
+
const Guarded = (props: {
|
|
8
|
+
onOutside: () => void;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
withExtra?: boolean;
|
|
11
|
+
}) => {
|
|
12
|
+
const { onOutside, enabled, withExtra } = props;
|
|
13
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
14
|
+
const extraRef = useRef<HTMLButtonElement>(null);
|
|
15
|
+
useClickOutside({
|
|
16
|
+
ref: panelRef,
|
|
17
|
+
extraRefs: withExtra ? [extraRef] : undefined,
|
|
18
|
+
onOutside,
|
|
19
|
+
enabled,
|
|
20
|
+
});
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<button ref={extraRef} type="button">
|
|
24
|
+
trigger
|
|
25
|
+
</button>
|
|
26
|
+
<div ref={panelRef}>
|
|
27
|
+
<span>panel-child</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div data-testid="outside">elsewhere</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('useClickOutside', () => {
|
|
35
|
+
test('fires onOutside for a pointerdown outside the guarded element', () => {
|
|
36
|
+
const onOutside = mock(() => {});
|
|
37
|
+
const { getByTestId } = render(<Guarded onOutside={onOutside} />);
|
|
38
|
+
fireEvent.pointerDown(getByTestId('outside'));
|
|
39
|
+
expect(onOutside).toHaveBeenCalledTimes(1);
|
|
40
|
+
cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('does not fire for a pointerdown inside the guarded element', () => {
|
|
44
|
+
const onOutside = mock(() => {});
|
|
45
|
+
const { getByText } = render(<Guarded onOutside={onOutside} />);
|
|
46
|
+
fireEvent.pointerDown(getByText('panel-child'));
|
|
47
|
+
expect(onOutside).not.toHaveBeenCalled();
|
|
48
|
+
cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('treats extraRefs as inside', () => {
|
|
52
|
+
const onOutside = mock(() => {});
|
|
53
|
+
const { getByText } = render(<Guarded onOutside={onOutside} withExtra />);
|
|
54
|
+
fireEvent.pointerDown(getByText('trigger'));
|
|
55
|
+
expect(onOutside).not.toHaveBeenCalled();
|
|
56
|
+
cleanup();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('detaches when disabled', () => {
|
|
60
|
+
const onOutside = mock(() => {});
|
|
61
|
+
const { getByTestId } = render(
|
|
62
|
+
<Guarded onOutside={onOutside} enabled={false} />,
|
|
63
|
+
);
|
|
64
|
+
fireEvent.pointerDown(getByTestId('outside'));
|
|
65
|
+
expect(onOutside).not.toHaveBeenCalled();
|
|
66
|
+
cleanup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('globalEscapeHatches.onTriggerOutsideClick dismisses active consumers', () => {
|
|
70
|
+
const onOutside = mock(() => {});
|
|
71
|
+
render(<Guarded onOutside={onOutside} />);
|
|
72
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
73
|
+
expect(onOutside).toHaveBeenCalledTimes(1);
|
|
74
|
+
cleanup();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('disabled consumers are skipped by the escape hatch', () => {
|
|
78
|
+
const onOutside = mock(() => {});
|
|
79
|
+
render(<Guarded onOutside={onOutside} enabled={false} />);
|
|
80
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
81
|
+
expect(onOutside).not.toHaveBeenCalled();
|
|
82
|
+
cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('unmounted consumers deregister from the escape hatch', () => {
|
|
86
|
+
const onOutside = mock(() => {});
|
|
87
|
+
const { unmount } = render(<Guarded onOutside={onOutside} />);
|
|
88
|
+
unmount();
|
|
89
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
90
|
+
expect(onOutside).not.toHaveBeenCalled();
|
|
91
|
+
cleanup();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('fires all active consumers when multiple are mounted', () => {
|
|
95
|
+
const onOutsideA = mock(() => {});
|
|
96
|
+
const onOutsideB = mock(() => {});
|
|
97
|
+
render(<Guarded onOutside={onOutsideA} />);
|
|
98
|
+
render(<Guarded onOutside={onOutsideB} />);
|
|
99
|
+
globalEscapeHatches.onTriggerOutsideClick();
|
|
100
|
+
expect(onOutsideA).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(onOutsideB).toHaveBeenCalledTimes(1);
|
|
102
|
+
cleanup();
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -87,3 +87,4 @@ export { LongText, type LongTextProps } from './components/Primitives/LongText';
|
|
|
87
87
|
export { type BackgroundToken } from './tokens';
|
|
88
88
|
export { useStableCallback } from './hooks/useStableCallback';
|
|
89
89
|
export { useClickOutside } from './hooks/useClickOutside';
|
|
90
|
+
export { globalEscapeHatches } from './globalEscapeHatches';
|