@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -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
- window.removeEventListener('mousedown', handleMouseDown);
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 additional refs or a custom predicate
32
- * if that is not desired. */
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 (event.target instanceof Node && el.contains(event.target)) return;
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';