@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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ ## 1.1.0
2
+
3
+ ### Minor Changes
4
+
5
+ - [`780c4e073e406`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/780c4e073e406) -
6
+ Add adaptor components
7
+
8
+ ### Patch Changes
9
+
10
+ - Updated dependencies
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Teams App Internal Popup Adaptor
2
+
3
+ Internal popup trigger adapter shared by People and Teams profile card packages
4
+ (`@atlassian/team-profilecard`, `@atlassian/user-profile-card`).
5
+
6
+ Provides the `PopupTriggerWithHover` component plus the underlying hover/click/preload trigger ref
7
+ hooks used to lazily load and open profile card popups via Relay entry points.
8
+
9
+ This package is intended for internal use within the People and Teams Collective only.
10
+
11
+ ## Exports
12
+
13
+ - `@atlaskit/teams-app-internal-popup-adaptor/popup-trigger-with-hover` - the `PopupTriggerWithHover`
14
+ component along with the `PopupTriggerWithHoverProps` and `TriggerMode` types.
15
+
16
+ The hover/click/preload trigger ref hooks and DOM helper utilities are internal implementation
17
+ details of `PopupTriggerWithHover` and are not exposed.
@@ -0,0 +1,141 @@
1
+ import React from 'react';
2
+
3
+ import { act, fireEvent, render, screen, userEvent } from '@atlassian/testing-library';
4
+
5
+ import { PopupTriggerWithHover } from '../../src/PopupTriggerWithHover';
6
+
7
+ jest.mock('@atlassian/relay-environment-provider', () => ({
8
+ useRelayEnvironmentProvider: () => ({}),
9
+ }));
10
+
11
+ jest.mock('react-relay', () => ({
12
+ loadEntryPoint: () => ({
13
+ dispose: jest.fn(),
14
+ entryPoints: {},
15
+ extraProps: null,
16
+ getPreloadProps: () => ({}),
17
+ }),
18
+ }));
19
+
20
+ jest.mock('@atlassian/internal-entry-point-container', () => ({
21
+ InternalEntryPointContainer: ({ runtimeProps }: { runtimeProps: Record<string, unknown> }) => (
22
+ <div data-testid="popup-content" data-runtime-props={JSON.stringify(Object.keys(runtimeProps))}>
23
+ popup content
24
+ </div>
25
+ ),
26
+ }));
27
+
28
+ const buildEntryPoint = () => ({
29
+ root: {
30
+ getModuleName: () => 'test-entry-point',
31
+ },
32
+ getPreloadProps: () => ({ entryPoints: {}, queries: {}, extraProps: null }),
33
+ });
34
+
35
+ const renderTrigger = (
36
+ overrides: Partial<React.ComponentProps<typeof PopupTriggerWithHover>> = {},
37
+ ) =>
38
+ render(
39
+ <PopupTriggerWithHover
40
+ entryPoint={buildEntryPoint() as never}
41
+ placement="bottom-start"
42
+ trigger={({ ref, ...triggerProps }) => (
43
+ <button ref={ref} {...triggerProps} type="button" data-testid="trigger">
44
+ open
45
+ </button>
46
+ )}
47
+ {...overrides}
48
+ />,
49
+ );
50
+
51
+ beforeEach(() => {
52
+ jest.useFakeTimers();
53
+ });
54
+
55
+ afterEach(() => {
56
+ jest.useRealTimers();
57
+ });
58
+
59
+ test('renders only the trigger before the popup is opened', () => {
60
+ renderTrigger();
61
+
62
+ expect(screen.getByTestId('trigger')).toBeInTheDocument();
63
+ expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument();
64
+ });
65
+
66
+ test('clicking the trigger opens the popup and reports the open method', async () => {
67
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
68
+ const onOpen = jest.fn();
69
+
70
+ renderTrigger({ onOpen });
71
+
72
+ await user.click(screen.getByTestId('trigger'));
73
+
74
+ expect(screen.getByTestId('popup-content')).toBeInTheDocument();
75
+ expect(onOpen).toHaveBeenCalledWith('click');
76
+ });
77
+
78
+ test('clicking the trigger again closes the popup', async () => {
79
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
80
+
81
+ renderTrigger();
82
+
83
+ await user.click(screen.getByTestId('trigger'));
84
+ expect(screen.getByTestId('popup-content')).toBeInTheDocument();
85
+
86
+ await user.click(screen.getByTestId('trigger'));
87
+ expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument();
88
+ });
89
+
90
+ test('hover triggerMode opens the popup after the hover delay', () => {
91
+ const onOpen = jest.fn();
92
+
93
+ renderTrigger({ onOpen, triggerMode: 'hover' });
94
+
95
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
96
+ act(() => {
97
+ jest.advanceTimersByTime(800);
98
+ });
99
+
100
+ expect(screen.getByTestId('popup-content')).toBeInTheDocument();
101
+ expect(onOpen).toHaveBeenCalledWith('hover');
102
+ });
103
+
104
+ test('hover triggerMode does not open on click', () => {
105
+ const onOpen = jest.fn();
106
+
107
+ renderTrigger({ onOpen, triggerMode: 'hover' });
108
+
109
+ fireEvent.click(screen.getByTestId('trigger'));
110
+
111
+ expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument();
112
+ expect(onOpen).not.toHaveBeenCalled();
113
+ });
114
+
115
+ test('triggerMode array of click and hover supports both interactions', async () => {
116
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
117
+ const onOpen = jest.fn();
118
+
119
+ renderTrigger({ onOpen, triggerMode: ['click', 'hover'] });
120
+
121
+ await user.click(screen.getByTestId('trigger'));
122
+ expect(onOpen).toHaveBeenLastCalledWith('click');
123
+ });
124
+
125
+ test('the popup content receives onClose and onContentResized in its runtime props', async () => {
126
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
127
+
128
+ renderTrigger();
129
+
130
+ await user.click(screen.getByTestId('trigger'));
131
+
132
+ const runtimeProps = JSON.parse(
133
+ screen.getByTestId('popup-content').getAttribute('data-runtime-props') ?? '[]',
134
+ );
135
+ expect(runtimeProps).toEqual(expect.arrayContaining(['onContentResized', 'onClose']));
136
+ });
137
+
138
+ test('capture and report a11y violations', async () => {
139
+ const { container } = renderTrigger();
140
+ await expect(container).toBeAccessible();
141
+ });
@@ -0,0 +1,99 @@
1
+ import React, { useEffect } from 'react';
2
+
3
+ import { act, render } from '@atlassian/testing-library';
4
+
5
+ import { useHoverDelay } from '../../src/useHoverDelay';
6
+
7
+ const HoverDelayTrigger = ({
8
+ callback,
9
+ delay,
10
+ action,
11
+ }: {
12
+ callback: () => void;
13
+ delay: number;
14
+ action: 'schedule' | 'clear' | 'scheduleAndClear' | 'scheduleTwice';
15
+ }) => {
16
+ const { schedule, clear } = useHoverDelay(callback, delay);
17
+
18
+ useEffect(() => {
19
+ if (action === 'schedule') {
20
+ schedule();
21
+ } else if (action === 'clear') {
22
+ schedule();
23
+ clear();
24
+ } else if (action === 'scheduleTwice') {
25
+ schedule();
26
+ setTimeout(() => schedule(), 100);
27
+ }
28
+ }, [action, schedule, clear]);
29
+
30
+ return <div data-testid="harness" />;
31
+ };
32
+
33
+ beforeEach(() => {
34
+ jest.useFakeTimers();
35
+ });
36
+
37
+ afterEach(() => {
38
+ jest.useRealTimers();
39
+ });
40
+
41
+ test('schedule fires the callback after the configured delay', () => {
42
+ const callback = jest.fn();
43
+
44
+ render(<HoverDelayTrigger callback={callback} delay={500} action="schedule" />);
45
+ expect(callback).not.toHaveBeenCalled();
46
+
47
+ act(() => {
48
+ jest.advanceTimersByTime(499);
49
+ });
50
+ expect(callback).not.toHaveBeenCalled();
51
+
52
+ act(() => {
53
+ jest.advanceTimersByTime(1);
54
+ });
55
+ expect(callback).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ test('clear cancels a pending callback', () => {
59
+ const callback = jest.fn();
60
+
61
+ render(<HoverDelayTrigger callback={callback} delay={200} action="clear" />);
62
+
63
+ act(() => {
64
+ jest.advanceTimersByTime(1000);
65
+ });
66
+
67
+ expect(callback).not.toHaveBeenCalled();
68
+ });
69
+
70
+ test('schedule replaces an in-flight timer rather than stacking it', () => {
71
+ const callback = jest.fn();
72
+
73
+ render(<HoverDelayTrigger callback={callback} delay={300} action="scheduleTwice" />);
74
+
75
+ // At 100ms the second schedule() fires, resetting the 300ms timer
76
+ act(() => {
77
+ jest.advanceTimersByTime(399);
78
+ });
79
+ expect(callback).not.toHaveBeenCalled();
80
+
81
+ act(() => {
82
+ jest.advanceTimersByTime(1);
83
+ });
84
+ expect(callback).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ test('unmounting clears any pending timer', () => {
88
+ const callback = jest.fn();
89
+
90
+ const { unmount } = render(<HoverDelayTrigger callback={callback} delay={100} action="schedule" />);
91
+
92
+ unmount();
93
+
94
+ act(() => {
95
+ jest.advanceTimersByTime(1000);
96
+ });
97
+
98
+ expect(callback).not.toHaveBeenCalled();
99
+ });
@@ -0,0 +1,121 @@
1
+ import React, { type MutableRefObject, useRef, useState } from 'react';
2
+
3
+ import { act, fireEvent, render, screen } from '@atlassian/testing-library';
4
+
5
+ import { useHoverTriggerRef } from '../../src/useHoverTriggerRef';
6
+
7
+ const HoverTrigger = ({
8
+ onOpen,
9
+ onClose,
10
+ isDisabled = false,
11
+ openedByRef,
12
+ }: {
13
+ onOpen: () => void;
14
+ onClose?: () => void;
15
+ isDisabled?: boolean;
16
+ openedByRef?: MutableRefObject<'hover' | 'click' | null>;
17
+ }) => {
18
+ const [open, setOpen] = useState(false);
19
+ const fallbackRef = useRef<'hover' | 'click' | null>(null);
20
+ const { triggerRef, contentRef } = useHoverTriggerRef({
21
+ onOpen: () => {
22
+ setOpen(true);
23
+ onOpen();
24
+ },
25
+ onClose: () => {
26
+ setOpen(false);
27
+ onClose?.();
28
+ },
29
+ isOpen: open,
30
+ isDisabled,
31
+ openedByRef: openedByRef ?? fallbackRef,
32
+ });
33
+
34
+ return (
35
+ <>
36
+ <button type="button" ref={triggerRef} data-testid="trigger">
37
+ open
38
+ </button>
39
+ {open && (
40
+ <div ref={contentRef} data-testid="content">
41
+ popup content
42
+ </div>
43
+ )}
44
+ </>
45
+ );
46
+ };
47
+
48
+ beforeEach(() => {
49
+ jest.useFakeTimers();
50
+ });
51
+
52
+ afterEach(() => {
53
+ jest.useRealTimers();
54
+ });
55
+
56
+ test('hovering the trigger opens the popup after the open delay', () => {
57
+ const onOpen = jest.fn();
58
+ const openedByRef: MutableRefObject<'hover' | 'click' | null> = { current: null };
59
+
60
+ render(<HoverTrigger onOpen={onOpen} openedByRef={openedByRef} />);
61
+
62
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
63
+
64
+ act(() => {
65
+ jest.advanceTimersByTime(800);
66
+ });
67
+
68
+ expect(onOpen).toHaveBeenCalledTimes(1);
69
+ expect(openedByRef.current).toBe('hover');
70
+ });
71
+
72
+ test('leaving the trigger before the open delay cancels the open', () => {
73
+ const onOpen = jest.fn();
74
+
75
+ render(<HoverTrigger onOpen={onOpen} />);
76
+
77
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
78
+ act(() => {
79
+ jest.advanceTimersByTime(400);
80
+ });
81
+ fireEvent.mouseLeave(screen.getByTestId('trigger'));
82
+ act(() => {
83
+ jest.advanceTimersByTime(2000);
84
+ });
85
+
86
+ expect(onOpen).not.toHaveBeenCalled();
87
+ });
88
+
89
+ test('hovering content keeps the popup open even after leaving the trigger', () => {
90
+ const onOpen = jest.fn();
91
+ const onClose = jest.fn();
92
+
93
+ render(<HoverTrigger onOpen={onOpen} onClose={onClose} />);
94
+
95
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
96
+ act(() => {
97
+ jest.advanceTimersByTime(800);
98
+ });
99
+ expect(onOpen).toHaveBeenCalledTimes(1);
100
+
101
+ fireEvent.mouseLeave(screen.getByTestId('trigger'));
102
+ fireEvent.mouseEnter(screen.getByTestId('content'));
103
+ act(() => {
104
+ jest.advanceTimersByTime(1000);
105
+ });
106
+
107
+ expect(onClose).not.toHaveBeenCalled();
108
+ });
109
+
110
+ test('isDisabled prevents hover from opening the popup', () => {
111
+ const onOpen = jest.fn();
112
+
113
+ render(<HoverTrigger onOpen={onOpen} isDisabled />);
114
+
115
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
116
+ act(() => {
117
+ jest.advanceTimersByTime(2000);
118
+ });
119
+
120
+ expect(onOpen).not.toHaveBeenCalled();
121
+ });
@@ -0,0 +1,118 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ import { fireEvent, render, screen } from '@atlassian/testing-library';
4
+
5
+ import { type OnLoad, usePreloadRef } from '../../src/usePreloadRef';
6
+
7
+ type FakeReference = { dispose: jest.Mock };
8
+
9
+ const buildReference = (): FakeReference => ({ dispose: jest.fn() });
10
+
11
+ const PreloadTrigger = ({
12
+ load,
13
+ onLoad,
14
+ openOnMount = false,
15
+ }: {
16
+ load: () => FakeReference;
17
+ onLoad: OnLoad<FakeReference>;
18
+ openOnMount?: boolean;
19
+ }) => {
20
+ const ref = usePreloadRef({ load, onLoad });
21
+ const openedRef = useRef(false);
22
+
23
+ useEffect(() => {
24
+ if (openOnMount && !openedRef.current) {
25
+ openedRef.current = true;
26
+ ref.loadAndOpen();
27
+ }
28
+ }, [openOnMount, ref]);
29
+
30
+ return (
31
+ <button type="button" ref={ref} data-testid="trigger">
32
+ open
33
+ </button>
34
+ );
35
+ };
36
+
37
+ beforeEach(() => {
38
+ jest.useFakeTimers();
39
+ });
40
+
41
+ afterEach(() => {
42
+ jest.useRealTimers();
43
+ });
44
+
45
+ test('loadAndOpen invokes load and onLoad immediately when no preload is in flight', () => {
46
+ const reference = buildReference();
47
+ const load = jest.fn(() => reference);
48
+ const onLoad = jest.fn();
49
+
50
+ render(<PreloadTrigger load={load} onLoad={onLoad} openOnMount />);
51
+
52
+ expect(load).toHaveBeenCalledTimes(1);
53
+ expect(onLoad).toHaveBeenCalledTimes(1);
54
+ expect(onLoad).toHaveBeenCalledWith(expect.objectContaining({ reference }));
55
+ });
56
+
57
+ test('hovering the trigger schedules a preload after the intent delay', () => {
58
+ const reference = buildReference();
59
+ const load = jest.fn(() => reference);
60
+ const onLoad = jest.fn();
61
+
62
+ render(<PreloadTrigger load={load} onLoad={onLoad} />);
63
+
64
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
65
+ expect(load).not.toHaveBeenCalled();
66
+
67
+ jest.advanceTimersByTime(200);
68
+ expect(load).toHaveBeenCalledTimes(1);
69
+ expect(onLoad).not.toHaveBeenCalled();
70
+ });
71
+
72
+ test('mouseleave before the intent delay cancels the preload', () => {
73
+ const reference = buildReference();
74
+ const load = jest.fn(() => reference);
75
+ const onLoad = jest.fn();
76
+
77
+ render(<PreloadTrigger load={load} onLoad={onLoad} />);
78
+
79
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
80
+ jest.advanceTimersByTime(100);
81
+ fireEvent.mouseLeave(screen.getByTestId('trigger'));
82
+ jest.advanceTimersByTime(1000);
83
+
84
+ expect(load).not.toHaveBeenCalled();
85
+ });
86
+
87
+ test('preloaded reference is reused by loadAndOpen instead of loading again', () => {
88
+ const reference = buildReference();
89
+ const load = jest.fn(() => reference);
90
+ const onLoad = jest.fn();
91
+
92
+ const { rerender } = render(<PreloadTrigger load={load} onLoad={onLoad} />);
93
+
94
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
95
+ jest.advanceTimersByTime(200);
96
+ expect(load).toHaveBeenCalledTimes(1);
97
+
98
+ rerender(<PreloadTrigger load={load} onLoad={onLoad} openOnMount />);
99
+
100
+ expect(load).toHaveBeenCalledTimes(1);
101
+ expect(onLoad).toHaveBeenCalledTimes(1);
102
+ expect(onLoad).toHaveBeenCalledWith(expect.objectContaining({ reference }));
103
+ });
104
+
105
+ test('a stale preload is disposed after the max-age timeout', () => {
106
+ const reference = buildReference();
107
+ const load = jest.fn(() => reference);
108
+ const onLoad = jest.fn();
109
+
110
+ render(<PreloadTrigger load={load} onLoad={onLoad} />);
111
+
112
+ fireEvent.mouseEnter(screen.getByTestId('trigger'));
113
+ jest.advanceTimersByTime(200);
114
+ expect(load).toHaveBeenCalledTimes(1);
115
+
116
+ jest.advanceTimersByTime(5 * 60 * 1000);
117
+ expect(reference.dispose).toHaveBeenCalledTimes(1);
118
+ });
@@ -0,0 +1,104 @@
1
+ import React, { type MutableRefObject, useRef } from 'react';
2
+
3
+ import { fireEvent, render, screen, userEvent } from '@atlassian/testing-library';
4
+
5
+ import { usePressableTriggerRef } from '../../src/usePressableTriggerRef';
6
+
7
+ const PressableTrigger = ({
8
+ onOpen,
9
+ onClose,
10
+ isOpen = false,
11
+ isDisabled = false,
12
+ openedByRef,
13
+ }: {
14
+ onOpen: () => void;
15
+ onClose?: () => void;
16
+ isOpen?: boolean;
17
+ isDisabled?: boolean;
18
+ openedByRef?: MutableRefObject<'hover' | 'click' | null>;
19
+ }) => {
20
+ const fallbackRef = useRef<'hover' | 'click' | null>(null);
21
+ const ref = usePressableTriggerRef({
22
+ onOpen,
23
+ onClose,
24
+ isOpen,
25
+ isDisabled,
26
+ openedByRef: openedByRef ?? fallbackRef,
27
+ });
28
+
29
+ return (
30
+ <button type="button" ref={ref} data-testid="trigger">
31
+ open
32
+ </button>
33
+ );
34
+ };
35
+
36
+ test('clicking the trigger opens the popup and records the open method as click', () => {
37
+ const onOpen = jest.fn();
38
+ const openedByRef: MutableRefObject<'hover' | 'click' | null> = { current: null };
39
+
40
+ render(<PressableTrigger onOpen={onOpen} openedByRef={openedByRef} />);
41
+
42
+ fireEvent.click(screen.getByTestId('trigger'));
43
+
44
+ expect(onOpen).toHaveBeenCalledTimes(1);
45
+ expect(openedByRef.current).toBe('click');
46
+ });
47
+
48
+ test('clicking again when already open invokes onClose instead of onOpen', () => {
49
+ const onOpen = jest.fn();
50
+ const onClose = jest.fn();
51
+
52
+ render(<PressableTrigger onOpen={onOpen} onClose={onClose} isOpen />);
53
+
54
+ fireEvent.click(screen.getByTestId('trigger'));
55
+
56
+ expect(onOpen).not.toHaveBeenCalled();
57
+ expect(onClose).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ test('isDisabled stops click from triggering onOpen', () => {
61
+ const onOpen = jest.fn();
62
+
63
+ render(<PressableTrigger onOpen={onOpen} isDisabled />);
64
+
65
+ fireEvent.click(screen.getByTestId('trigger'));
66
+
67
+ expect(onOpen).not.toHaveBeenCalled();
68
+ });
69
+
70
+ test('pressing Enter on the trigger opens the popup', async () => {
71
+ const user = userEvent.setup();
72
+ const onOpen = jest.fn();
73
+
74
+ render(<PressableTrigger onOpen={onOpen} />);
75
+
76
+ screen.getByTestId('trigger').focus();
77
+ await user.keyboard('{Enter}');
78
+
79
+ expect(onOpen).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ test('pressing Space on the trigger opens the popup', async () => {
83
+ const user = userEvent.setup();
84
+ const onOpen = jest.fn();
85
+
86
+ render(<PressableTrigger onOpen={onOpen} />);
87
+
88
+ screen.getByTestId('trigger').focus();
89
+ await user.keyboard(' ');
90
+
91
+ expect(onOpen).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ test('keyboard activation works even when click is disabled, preserving accessibility', async () => {
95
+ const user = userEvent.setup();
96
+ const onOpen = jest.fn();
97
+
98
+ render(<PressableTrigger onOpen={onOpen} isDisabled />);
99
+
100
+ screen.getByTestId('trigger').focus();
101
+ await user.keyboard('{Enter}');
102
+
103
+ expect(onOpen).toHaveBeenCalledTimes(1);
104
+ });
@@ -0,0 +1,86 @@
1
+ import {
2
+ getChildPortalFromParentPopup,
3
+ hasRelatedTarget,
4
+ isMovingToKudos,
5
+ } from '../../src/utils';
6
+
7
+ const buildEventWithRelatedTarget = (relatedTarget: EventTarget | null): Event => {
8
+ return new MouseEvent('mouseleave', { relatedTarget } as MouseEventInit);
9
+ };
10
+
11
+ test('hasRelatedTarget reports true for events that carry a relatedTarget', () => {
12
+ const button = document.createElement('button');
13
+ const event = buildEventWithRelatedTarget(button);
14
+
15
+ expect(hasRelatedTarget(event)).toBe(true);
16
+ });
17
+
18
+ test('hasRelatedTarget reports false for events without a relatedTarget property', () => {
19
+ const event = new Event('focus');
20
+
21
+ expect(hasRelatedTarget(event)).toBe(false);
22
+ });
23
+
24
+ test('isMovingToKudos returns false when relatedTarget is not an HTMLElement', () => {
25
+ expect(isMovingToKudos(null)).toBe(false);
26
+ });
27
+
28
+ test('isMovingToKudos returns true when relatedTarget contains a give-kudos iframe inside an atlaskit portal', () => {
29
+ const portal = document.createElement('div');
30
+ portal.classList.add('atlaskit-portal');
31
+ const iframe = document.createElement('iframe');
32
+ iframe.title = 'give kudos dialog';
33
+ iframe.src = 'https://give-kudos.example.com';
34
+ portal.appendChild(iframe);
35
+ document.body.appendChild(portal);
36
+
37
+ expect(isMovingToKudos(portal)).toBe(true);
38
+
39
+ document.body.removeChild(portal);
40
+ });
41
+
42
+ test('isMovingToKudos returns false when the iframe inside the portal is unrelated', () => {
43
+ const portal = document.createElement('div');
44
+ portal.classList.add('atlaskit-portal');
45
+ const iframe = document.createElement('iframe');
46
+ iframe.title = 'unrelated content';
47
+ iframe.src = 'https://example.com/other';
48
+ portal.appendChild(iframe);
49
+ document.body.appendChild(portal);
50
+
51
+ expect(isMovingToKudos(portal)).toBe(false);
52
+
53
+ document.body.removeChild(portal);
54
+ });
55
+
56
+ test('getChildPortalFromParentPopup returns null when no parent popup is provided', () => {
57
+ const portal = document.createElement('div');
58
+ portal.classList.add('atlaskit-portal');
59
+
60
+ expect(getChildPortalFromParentPopup(portal, null)).toBeNull();
61
+ });
62
+
63
+ test('getChildPortalFromParentPopup returns the portal when a trigger inside the popup controls a child in that portal', () => {
64
+ const popup = document.createElement('div');
65
+ const trigger = document.createElement('button');
66
+ trigger.setAttribute('aria-controls', 'child-popup');
67
+ popup.appendChild(trigger);
68
+
69
+ const portal = document.createElement('div');
70
+ portal.classList.add('atlaskit-portal');
71
+ const child = document.createElement('div');
72
+ child.id = 'child-popup';
73
+ portal.appendChild(child);
74
+ document.body.appendChild(portal);
75
+
76
+ expect(getChildPortalFromParentPopup(portal, popup)).toBe(portal);
77
+
78
+ document.body.removeChild(portal);
79
+ });
80
+
81
+ test('getChildPortalFromParentPopup returns null when the related element is not inside an atlaskit portal', () => {
82
+ const popup = document.createElement('div');
83
+ const unrelated = document.createElement('button');
84
+
85
+ expect(getChildPortalFromParentPopup(unrelated, popup)).toBeNull();
86
+ });