@boostdev/design-system-components 1.1.0 → 1.1.1
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/AGENTS.md +46 -5
- package/dist/client.cjs +206 -113
- package/dist/client.css +664 -624
- package/dist/client.d.cts +9 -3
- package/dist/client.d.ts +9 -3
- package/dist/client.js +241 -149
- package/dist/index.cjs +206 -113
- package/dist/index.css +664 -624
- package/dist/index.d.cts +9 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +241 -149
- package/dist/web-components.d.ts +1 -0
- package/dist/web-components.js +10 -0
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.module.css +1 -1
- package/src/components/interaction/Button/Button.native.mdx +72 -0
- package/src/components/interaction/Command/Command.module.css +7 -7
- package/src/components/interaction/Command/Command.spec.tsx +37 -0
- package/src/components/interaction/Command/Command.tsx +37 -2
- package/src/components/interaction/Dialog/Dialog.module.css +10 -13
- package/src/components/interaction/Dialog/Dialog.spec.tsx +53 -1
- package/src/components/interaction/Dialog/Dialog.stories.tsx +17 -0
- package/src/components/interaction/Dialog/Dialog.tsx +50 -16
- package/src/components/interaction/Drawer/Drawer.module.css +11 -11
- package/src/components/interaction/Drawer/Drawer.spec.tsx +48 -1
- package/src/components/interaction/Drawer/Drawer.tsx +38 -4
- package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +6 -6
- package/src/components/interaction/Popover/Popover.module.css +74 -15
- package/src/components/interaction/Popover/Popover.spec.tsx +94 -59
- package/src/components/interaction/Popover/Popover.tsx +44 -60
- package/src/components/interaction/Toast/Toast.module.css +7 -7
- package/src/components/interaction/form/Checkbox/Checkbox.module.css +6 -6
- package/src/components/interaction/form/Checkbox/Checkbox.native.mdx +74 -0
- package/src/components/interaction/form/Combobox/Combobox.module.css +7 -8
- package/src/components/interaction/form/FileInput/FileInput.module.css +4 -4
- package/src/components/interaction/form/NumberInput/NumberInput.module.css +1 -1
- package/src/components/interaction/form/Radio/Radio.module.css +6 -6
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +5 -6
- package/src/components/interaction/form/Select/Select.module.css +3 -3
- package/src/components/interaction/form/Slider/Slider.module.css +6 -6
- package/src/components/interaction/form/Switch/Switch.module.css +8 -8
- package/src/components/interaction/form/Textarea/Textarea.module.css +1 -1
- package/src/components/layout/IconWrapper/IconWrapper.module.css +2 -2
- package/src/components/ui/Accordion/Accordion.module.css +5 -5
- package/src/components/ui/Alert/Alert.native.mdx +62 -0
- package/src/components/ui/Avatar/Avatar.native.mdx +50 -0
- package/src/components/ui/Badge/Badge.native.mdx +54 -0
- package/src/components/ui/Calendar/Calendar.module.css +7 -7
- package/src/components/ui/Carousel/Carousel.module.css +5 -5
- package/src/components/ui/Collapsible/Collapsible.module.css +4 -4
- package/src/components/ui/Link/Link.module.css +2 -2
- package/src/components/ui/Loading/Loading.module.css +7 -7
- package/src/components/ui/NotificationBanner/NotificationBanner.module.css +3 -3
- package/src/components/ui/Pagination/Pagination.module.css +6 -6
- package/src/components/ui/Progress/Progress.module.css +5 -5
- package/src/components/ui/Separator/Separator.module.css +3 -3
- package/src/components/ui/Skeleton/Skeleton.module.css +1 -1
- package/src/components/ui/Table/Table.module.css +9 -9
- package/src/components/ui/Tabs/Tabs.module.css +3 -3
- package/src/components/ui/Tooltip/Tooltip.module.css +8 -8
- package/src/components/ui/Typography/Typography.native.mdx +56 -0
- package/src/polyfill-invoker-commands.ts +68 -0
- package/src/react-augment.d.ts +19 -0
- package/src/stories/ReactNative.mdx +121 -0
- package/src/web-components/interaction/BdsButton.mdx +106 -0
- package/src/web-components/interaction/BdsPopover.mdx +120 -0
- package/src/web-components/interaction/BdsToastProvider.mdx +94 -0
- package/src/web-components/interaction/bds-popover.spec.ts +49 -1
- package/src/web-components/interaction/bds-popover.ts +12 -0
- package/src/web-components/test/helpers.ts +8 -0
- package/src/web-components/ui/BdsAlert.mdx +90 -0
- package/src/web-components/ui/BdsBadge.mdx +74 -0
|
@@ -24,7 +24,21 @@ describe('Drawer', () => {
|
|
|
24
24
|
expect(screen.getByText('Navigation items')).toBeInTheDocument();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it('
|
|
27
|
+
it('close button uses commandfor/command', () => {
|
|
28
|
+
render(<Drawer isOpen title="Menu" onClose={() => {}}>Content</Drawer>);
|
|
29
|
+
const closeBtn = screen.getByRole('button', { name: 'Close drawer' });
|
|
30
|
+
expect(closeBtn).toHaveAttribute('commandfor');
|
|
31
|
+
expect(closeBtn).toHaveAttribute('command', 'close');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('close button commandfor matches dialog id', () => {
|
|
35
|
+
render(<Drawer id="my-drawer" isOpen title="Menu" onClose={() => {}}>Content</Drawer>);
|
|
36
|
+
const closeBtn = screen.getByRole('button', { name: 'Close drawer' });
|
|
37
|
+
expect(closeBtn).toHaveAttribute('commandfor', 'my-drawer');
|
|
38
|
+
expect(document.querySelector('dialog')).toHaveAttribute('id', 'my-drawer');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('close button click via polyfill calls onClose', async () => {
|
|
28
42
|
const onClose = vi.fn();
|
|
29
43
|
render(<Drawer isOpen title="Menu" onClose={onClose}>Content</Drawer>);
|
|
30
44
|
await userEvent.click(screen.getByRole('button', { name: 'Close drawer' }));
|
|
@@ -42,6 +56,12 @@ describe('Drawer', () => {
|
|
|
42
56
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
|
|
43
57
|
});
|
|
44
58
|
|
|
59
|
+
it('does not call showModal again when already open', () => {
|
|
60
|
+
const { rerender } = render(<Drawer isOpen title="Test" onClose={() => {}}>Body</Drawer>);
|
|
61
|
+
rerender(<Drawer isOpen title="Test" onClose={() => {}}>Body</Drawer>);
|
|
62
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
45
65
|
it('locks body scroll when open', () => {
|
|
46
66
|
render(<Drawer isOpen title="Settings" onClose={() => {}}>Content</Drawer>);
|
|
47
67
|
expect(document.body.style.overflow).toBe('hidden');
|
|
@@ -88,4 +108,31 @@ describe('Drawer', () => {
|
|
|
88
108
|
|
|
89
109
|
document.body.removeChild(trigger);
|
|
90
110
|
});
|
|
111
|
+
|
|
112
|
+
describe('command event (Invoker Commands API)', () => {
|
|
113
|
+
function makeCommandEvent(command: string) {
|
|
114
|
+
const event = new Event('command');
|
|
115
|
+
Object.defineProperty(event, 'command', { value: command, configurable: true });
|
|
116
|
+
return event;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it('locks scroll and calls onOpen on show-modal command', () => {
|
|
120
|
+
const onOpen = vi.fn();
|
|
121
|
+
render(<Drawer title="Menu" isOpen={false} onOpen={onOpen} onClose={() => {}}>Content</Drawer>);
|
|
122
|
+
const dialog = document.querySelector('dialog')!;
|
|
123
|
+
dialog.dispatchEvent(makeCommandEvent('show-modal'));
|
|
124
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
125
|
+
expect(onOpen).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('restores scroll and calls onClose on close command', () => {
|
|
129
|
+
const onClose = vi.fn();
|
|
130
|
+
render(<Drawer title="Menu" isOpen onClose={onClose}>Content</Drawer>);
|
|
131
|
+
document.body.style.overflow = 'hidden';
|
|
132
|
+
const dialog = document.querySelector('dialog')!;
|
|
133
|
+
dialog.dispatchEvent(makeCommandEvent('close'));
|
|
134
|
+
expect(document.body.style.overflow).toBe('');
|
|
135
|
+
expect(onClose).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
91
138
|
});
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { ReactNode, useEffect, useRef } from 'react';
|
|
1
|
+
import { ReactNode, useEffect, useId, useRef } from 'react';
|
|
2
|
+
import { installInvokerCommandsPolyfill } from '../../../polyfill-invoker-commands';
|
|
3
|
+
|
|
4
|
+
// Install once at module load — no-op if native support is present.
|
|
5
|
+
installInvokerCommandsPolyfill();
|
|
6
|
+
|
|
2
7
|
import css from './Drawer.module.css';
|
|
3
8
|
import { cn } from '@boostdev/design-system-foundation';
|
|
4
9
|
import type { WithClassName } from '../../../types';
|
|
5
10
|
|
|
6
11
|
interface DrawerProps extends WithClassName {
|
|
12
|
+
id?: string;
|
|
7
13
|
isOpen: boolean;
|
|
14
|
+
onOpen?: () => void;
|
|
8
15
|
onClose: () => void;
|
|
9
16
|
title: ReactNode;
|
|
10
17
|
children: ReactNode;
|
|
@@ -13,7 +20,9 @@ interface DrawerProps extends WithClassName {
|
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function Drawer({
|
|
23
|
+
id: idProp,
|
|
16
24
|
isOpen,
|
|
25
|
+
onOpen,
|
|
17
26
|
onClose,
|
|
18
27
|
title,
|
|
19
28
|
children,
|
|
@@ -21,17 +30,20 @@ export function Drawer({
|
|
|
21
30
|
ariaLabel,
|
|
22
31
|
className,
|
|
23
32
|
}: Readonly<DrawerProps>) {
|
|
33
|
+
const generatedId = useId();
|
|
34
|
+
const id = idProp ?? generatedId;
|
|
35
|
+
|
|
24
36
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
25
37
|
const triggerRef = useRef<Element | null>(null);
|
|
26
38
|
|
|
27
39
|
useEffect(() => {
|
|
28
40
|
const dialog = dialogRef.current;
|
|
29
41
|
if (!dialog) return;
|
|
30
|
-
if (isOpen) {
|
|
42
|
+
if (isOpen && !dialog.open) {
|
|
31
43
|
triggerRef.current = document.activeElement;
|
|
32
44
|
dialog.showModal();
|
|
33
45
|
document.body.style.overflow = 'hidden';
|
|
34
|
-
} else if (dialog.open) {
|
|
46
|
+
} else if (!isOpen && dialog.open) {
|
|
35
47
|
dialog.close();
|
|
36
48
|
document.body.style.overflow = '';
|
|
37
49
|
(triggerRef.current as HTMLElement | null)?.focus();
|
|
@@ -41,6 +53,26 @@ export function Drawer({
|
|
|
41
53
|
};
|
|
42
54
|
}, [isOpen]);
|
|
43
55
|
|
|
56
|
+
// Invoker Commands API: sync state when opened/closed via commandfor/command.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const dialog = dialogRef.current;
|
|
59
|
+
if (!dialog) return;
|
|
60
|
+
const handleCommand = (e: CommandEvent) => {
|
|
61
|
+
if (e.command === 'show-modal') {
|
|
62
|
+
triggerRef.current = document.activeElement;
|
|
63
|
+
document.body.style.overflow = 'hidden';
|
|
64
|
+
onOpen?.();
|
|
65
|
+
} else if (e.command === 'close') {
|
|
66
|
+
document.body.style.overflow = '';
|
|
67
|
+
const trigger = triggerRef.current;
|
|
68
|
+
setTimeout(() => (trigger as HTMLElement | null)?.focus(), 0);
|
|
69
|
+
onClose();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
dialog.addEventListener('command', handleCommand);
|
|
73
|
+
return () => dialog.removeEventListener('command', handleCommand);
|
|
74
|
+
}, [onOpen, onClose]);
|
|
75
|
+
|
|
44
76
|
// Close on backdrop click
|
|
45
77
|
const handleClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
|
46
78
|
if (e.target === dialogRef.current) onClose();
|
|
@@ -55,6 +87,7 @@ export function Drawer({
|
|
|
55
87
|
return (
|
|
56
88
|
<dialog
|
|
57
89
|
ref={dialogRef}
|
|
90
|
+
id={id}
|
|
58
91
|
className={cn(css.drawer, css[`--side_${side}`], className)}
|
|
59
92
|
aria-label={ariaLabel}
|
|
60
93
|
aria-modal="true"
|
|
@@ -67,7 +100,8 @@ export function Drawer({
|
|
|
67
100
|
<button
|
|
68
101
|
type="button"
|
|
69
102
|
className={css.closeButton}
|
|
70
|
-
|
|
103
|
+
commandfor={id}
|
|
104
|
+
command="close"
|
|
71
105
|
aria-label="Close drawer"
|
|
72
106
|
>
|
|
73
107
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
.menu {
|
|
8
8
|
position: absolute;
|
|
9
|
-
|
|
9
|
+
inset-block-start: calc(100% + var(--bds-space_xxs));
|
|
10
10
|
z-index: var(--bds-z-index_dropdown);
|
|
11
|
-
min-
|
|
11
|
+
min-inline-size: 10rem;
|
|
12
12
|
padding: var(--bds-space_xxs) 0;
|
|
13
13
|
border: var(--dropdown_border, none);
|
|
14
14
|
--bdc_color: currentcolor;
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
margin: 0;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
.--placement_bottom-start {
|
|
28
|
-
.--placement_bottom-end {
|
|
27
|
+
.--placement_bottom-start { inset-inline-start: 0; }
|
|
28
|
+
.--placement_bottom-end { inset-inline-end: 0; }
|
|
29
29
|
|
|
30
30
|
.separator {
|
|
31
31
|
border: none;
|
|
32
|
-
border-
|
|
32
|
+
border-block-start: 1px solid currentcolor;
|
|
33
33
|
margin: var(--bds-space_xxs) 0;
|
|
34
34
|
opacity: 0.15;
|
|
35
35
|
}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
display: flex;
|
|
40
40
|
align-items: center;
|
|
41
41
|
gap: var(--bds-space_xs);
|
|
42
|
-
|
|
42
|
+
inline-size: 100%;
|
|
43
43
|
padding: var(--bds-space_xs) var(--bds-space_m);
|
|
44
44
|
font-family: var(--bds-font_family--body);
|
|
45
45
|
font-size: var(--bds-font_size--body);
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
@layer component {
|
|
2
2
|
.wrapper {
|
|
3
|
-
position: relative;
|
|
4
3
|
display: inline-flex;
|
|
4
|
+
|
|
5
|
+
/* No position: relative — the panel is position: fixed so it doesn't need
|
|
6
|
+
the wrapper as a containing block. */
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
.panel {
|
|
8
|
-
/*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
position:
|
|
10
|
+
/* Fixed positioning: containing block is the viewport.
|
|
11
|
+
- CSS anchor() values resolve in viewport space → polyfill applies them correctly.
|
|
12
|
+
- Ancestor overflow / stacking contexts cannot clip the panel. */
|
|
13
|
+
position: fixed;
|
|
14
|
+
|
|
15
|
+
/* Reset UA [popover] defaults (inset: 0, margin: auto, border, padding). */
|
|
16
|
+
inset: unset;
|
|
12
17
|
margin: 0;
|
|
13
|
-
overflow-
|
|
18
|
+
overflow-block: auto;
|
|
14
19
|
z-index: var(--popover_z-index, 100);
|
|
15
|
-
min-
|
|
20
|
+
min-inline-size: 12rem;
|
|
21
|
+
max-inline-size: var(--popover_max-width, 20rem);
|
|
22
|
+
max-block-size: var(--popover_max-height, 80dvh);
|
|
16
23
|
padding: var(--bds-space_m);
|
|
17
24
|
border: var(--popover_border, none);
|
|
18
25
|
--bdc_color: currentcolor;
|
|
@@ -34,28 +41,28 @@
|
|
|
34
41
|
.panel[data-placement='bottom'] {
|
|
35
42
|
top: anchor(bottom);
|
|
36
43
|
left: anchor(left);
|
|
37
|
-
margin-
|
|
44
|
+
margin-block-start: var(--bds-space_xs, 8px);
|
|
38
45
|
position-try-fallbacks: --popover-top, --popover-right, --popover-left;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
.panel[data-placement='top'] {
|
|
42
49
|
bottom: anchor(top);
|
|
43
50
|
left: anchor(left);
|
|
44
|
-
margin-
|
|
51
|
+
margin-block-end: var(--bds-space_xs, 8px);
|
|
45
52
|
position-try-fallbacks: --popover-bottom, --popover-right, --popover-left;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
.panel[data-placement='right'] {
|
|
49
56
|
top: anchor(top);
|
|
50
57
|
left: anchor(right);
|
|
51
|
-
margin-
|
|
58
|
+
margin-inline-start: var(--bds-space_xs, 8px);
|
|
52
59
|
position-try-fallbacks: --popover-left, --popover-bottom, --popover-top;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
.panel[data-placement='left'] {
|
|
56
63
|
top: anchor(top);
|
|
57
64
|
right: anchor(left);
|
|
58
|
-
margin-
|
|
65
|
+
margin-inline-end: var(--bds-space_xs, 8px);
|
|
59
66
|
position-try-fallbacks: --popover-right, --popover-bottom, --popover-top;
|
|
60
67
|
}
|
|
61
68
|
|
|
@@ -69,7 +76,7 @@
|
|
|
69
76
|
bottom: unset;
|
|
70
77
|
left: anchor(left);
|
|
71
78
|
margin: 0;
|
|
72
|
-
margin-
|
|
79
|
+
margin-block-start: var(--bds-space_xs, 8px);
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
@position-try --popover-top {
|
|
@@ -78,7 +85,7 @@
|
|
|
78
85
|
bottom: anchor(top);
|
|
79
86
|
left: anchor(left);
|
|
80
87
|
margin: 0;
|
|
81
|
-
margin-
|
|
88
|
+
margin-block-end: var(--bds-space_xs, 8px);
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
@position-try --popover-right {
|
|
@@ -87,7 +94,7 @@
|
|
|
87
94
|
bottom: unset;
|
|
88
95
|
left: anchor(right);
|
|
89
96
|
margin: 0;
|
|
90
|
-
margin-
|
|
97
|
+
margin-inline-start: var(--bds-space_xs, 8px);
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
@position-try --popover-left {
|
|
@@ -96,6 +103,58 @@
|
|
|
96
103
|
bottom: unset;
|
|
97
104
|
left: unset;
|
|
98
105
|
margin: 0;
|
|
99
|
-
margin-
|
|
106
|
+
margin-inline-end: var(--bds-space_xs, 8px);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Full-width fallback blocks for narrow screens.
|
|
110
|
+
Defined at layer level (not inside @media) because @position-try cannot
|
|
111
|
+
be nested inside @media. These preserve left:8px/right:8px so the
|
|
112
|
+
panel stays edge-to-edge even when a @position-try fallback fires. */
|
|
113
|
+
|
|
114
|
+
@position-try --popover-bottom-fullwidth {
|
|
115
|
+
top: anchor(bottom);
|
|
116
|
+
right: var(--bds-space_xs, 8px);
|
|
117
|
+
bottom: unset;
|
|
118
|
+
left: var(--bds-space_xs, 8px);
|
|
119
|
+
margin: 0;
|
|
120
|
+
margin-block-start: var(--bds-space_xs, 8px);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@position-try --popover-top-fullwidth {
|
|
124
|
+
top: unset;
|
|
125
|
+
right: var(--bds-space_xs, 8px);
|
|
126
|
+
bottom: anchor(top);
|
|
127
|
+
left: var(--bds-space_xs, 8px);
|
|
128
|
+
margin: 0;
|
|
129
|
+
margin-block-end: var(--bds-space_xs, 8px);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Narrow screens (≤420px): span full viewport width and only flip vertically.
|
|
133
|
+
max-width: none overrides the base 20rem cap; left/right edge constraints
|
|
134
|
+
handle the width instead. The panel still touches the anchor on the
|
|
135
|
+
vertical axis (top/bottom via anchor() is not overridden here). */
|
|
136
|
+
@media (width <= 420px) {
|
|
137
|
+
.panel[data-placement='bottom'],
|
|
138
|
+
.panel[data-placement='top'],
|
|
139
|
+
.panel[data-placement='left'],
|
|
140
|
+
.panel[data-placement='right'] {
|
|
141
|
+
left: var(--bds-space_xs, 8px);
|
|
142
|
+
right: var(--bds-space_xs, 8px);
|
|
143
|
+
inline-size: 100%;
|
|
144
|
+
max-inline-size: calc(100svw - 2 * var(--bds-space_xs, 8px));
|
|
145
|
+
position-try-fallbacks: --popover-top-fullwidth, --popover-bottom-fullwidth;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Wide + short screens (e.g. landscape mobile, split-screen): when the
|
|
150
|
+
viewport has more horizontal room than vertical, prefer side placements. */
|
|
151
|
+
@media (aspect-ratio >= 2/1) {
|
|
152
|
+
.panel[data-placement='bottom'] {
|
|
153
|
+
position-try-fallbacks: --popover-top, --popover-right, --popover-left;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.panel[data-placement='top'] {
|
|
157
|
+
position-try-fallbacks: --popover-bottom, --popover-right, --popover-left;
|
|
158
|
+
}
|
|
100
159
|
}
|
|
101
160
|
}
|
|
@@ -2,8 +2,7 @@ import { render, screen } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { Popover } from './Popover';
|
|
4
4
|
|
|
5
|
-
// jsdom doesn't implement CSS.supports —
|
|
6
|
-
// polyfill guard in Popover.tsx doesn't throw.
|
|
5
|
+
// jsdom doesn't implement CSS.supports — stub so the anchor polyfill guard doesn't throw.
|
|
7
6
|
beforeAll(() => {
|
|
8
7
|
Object.defineProperty(globalThis, 'CSS', {
|
|
9
8
|
value: { supports: () => true },
|
|
@@ -11,6 +10,39 @@ beforeAll(() => {
|
|
|
11
10
|
});
|
|
12
11
|
});
|
|
13
12
|
|
|
13
|
+
// jsdom doesn't implement the Popover API — mock showPopover/hidePopover/togglePopover
|
|
14
|
+
// and dispatch the `toggle` event so our state-sync listener fires.
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
17
|
+
const event = new Event('toggle');
|
|
18
|
+
Object.defineProperty(event, 'newState', { value: 'open', configurable: true });
|
|
19
|
+
this.dispatchEvent(event);
|
|
20
|
+
});
|
|
21
|
+
HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
|
|
22
|
+
const event = new Event('toggle');
|
|
23
|
+
Object.defineProperty(event, 'newState', { value: 'closed', configurable: true });
|
|
24
|
+
this.dispatchEvent(event);
|
|
25
|
+
});
|
|
26
|
+
HTMLElement.prototype.togglePopover = vi.fn(function (this: HTMLElement) {
|
|
27
|
+
const isCurrentlyOpen = this.getAttribute('data-popover-open') === 'true';
|
|
28
|
+
if (isCurrentlyOpen) {
|
|
29
|
+
this.setAttribute('data-popover-open', 'false');
|
|
30
|
+
const event = new Event('toggle');
|
|
31
|
+
Object.defineProperty(event, 'newState', { value: 'closed', configurable: true });
|
|
32
|
+
this.dispatchEvent(event);
|
|
33
|
+
} else {
|
|
34
|
+
this.setAttribute('data-popover-open', 'true');
|
|
35
|
+
const event = new Event('toggle');
|
|
36
|
+
Object.defineProperty(event, 'newState', { value: 'open', configurable: true });
|
|
37
|
+
this.dispatchEvent(event);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function getPanel() {
|
|
43
|
+
return document.querySelector<HTMLElement>('[popover]')!;
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
describe('Popover', () => {
|
|
15
47
|
it('renders the trigger', () => {
|
|
16
48
|
render(
|
|
@@ -21,15 +53,23 @@ describe('Popover', () => {
|
|
|
21
53
|
expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
|
|
22
54
|
});
|
|
23
55
|
|
|
24
|
-
it('
|
|
56
|
+
it('renders the panel as a [popover] element', () => {
|
|
57
|
+
render(
|
|
58
|
+
<Popover content={<p>Panel content</p>}>
|
|
59
|
+
<button type="button">Open</button>
|
|
60
|
+
</Popover>
|
|
61
|
+
);
|
|
62
|
+
expect(getPanel()).toBeInTheDocument();
|
|
63
|
+
expect(getPanel()).toHaveAttribute('popover', 'auto');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('panel is not open by default', () => {
|
|
25
67
|
render(
|
|
26
68
|
<Popover content={<p>Panel content</p>}>
|
|
27
69
|
<button type="button">Open</button>
|
|
28
70
|
</Popover>
|
|
29
71
|
);
|
|
30
|
-
|
|
31
|
-
const dialog = document.querySelector('dialog');
|
|
32
|
-
expect(dialog).not.toHaveAttribute('open');
|
|
72
|
+
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false');
|
|
33
73
|
});
|
|
34
74
|
|
|
35
75
|
it('shows the panel when trigger is clicked', async () => {
|
|
@@ -40,44 +80,44 @@ describe('Popover', () => {
|
|
|
40
80
|
</Popover>
|
|
41
81
|
);
|
|
42
82
|
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
43
|
-
expect(
|
|
83
|
+
expect(HTMLElement.prototype.togglePopover).toHaveBeenCalled();
|
|
44
84
|
});
|
|
45
85
|
|
|
46
|
-
it('
|
|
86
|
+
it('sets aria-expanded true after opening', async () => {
|
|
47
87
|
const user = userEvent.setup();
|
|
48
88
|
render(
|
|
49
|
-
<Popover content={<p>Panel
|
|
50
|
-
<button type="button">
|
|
89
|
+
<Popover content={<p>Panel</p>}>
|
|
90
|
+
<button type="button">Open</button>
|
|
51
91
|
</Popover>
|
|
52
92
|
);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
93
|
+
const trigger = screen.getByRole('button');
|
|
94
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
95
|
+
await user.click(trigger);
|
|
96
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
56
97
|
});
|
|
57
98
|
|
|
58
|
-
it('closes the panel when
|
|
99
|
+
it('closes the panel when trigger is clicked again', async () => {
|
|
59
100
|
const user = userEvent.setup();
|
|
60
101
|
render(
|
|
61
102
|
<Popover content={<p>Panel content</p>}>
|
|
62
|
-
<button type="button">
|
|
103
|
+
<button type="button">Toggle</button>
|
|
63
104
|
</Popover>
|
|
64
105
|
);
|
|
65
|
-
await user.click(screen.getByRole('button', { name: '
|
|
66
|
-
await user.
|
|
67
|
-
expect(
|
|
106
|
+
await user.click(screen.getByRole('button', { name: 'Toggle' }));
|
|
107
|
+
await user.click(screen.getByRole('button', { name: 'Toggle' }));
|
|
108
|
+
expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-expanded', 'false');
|
|
68
109
|
});
|
|
69
110
|
|
|
70
|
-
it('
|
|
71
|
-
const user = userEvent.setup();
|
|
111
|
+
it('trigger uses commandfor/command="toggle-popover"', () => {
|
|
72
112
|
render(
|
|
73
113
|
<Popover content={<p>Panel</p>}>
|
|
74
114
|
<button type="button">Open</button>
|
|
75
115
|
</Popover>
|
|
76
116
|
);
|
|
77
117
|
const trigger = screen.getByRole('button');
|
|
78
|
-
expect(trigger).toHaveAttribute('
|
|
79
|
-
|
|
80
|
-
expect(trigger
|
|
118
|
+
expect(trigger).toHaveAttribute('commandfor');
|
|
119
|
+
expect(trigger).toHaveAttribute('command', 'toggle-popover');
|
|
120
|
+
expect(trigger.getAttribute('commandfor')).toBe(getPanel().id);
|
|
81
121
|
});
|
|
82
122
|
|
|
83
123
|
it('sets anchor-name on the trigger and position-anchor on the panel', () => {
|
|
@@ -87,71 +127,66 @@ describe('Popover', () => {
|
|
|
87
127
|
</Popover>
|
|
88
128
|
);
|
|
89
129
|
const trigger = screen.getByRole('button');
|
|
90
|
-
const
|
|
91
|
-
// Both should reference the same CSS anchor ident.
|
|
130
|
+
const panel = getPanel();
|
|
92
131
|
const anchorName = trigger.style.getPropertyValue('anchor-name');
|
|
93
|
-
const positionAnchor =
|
|
132
|
+
const positionAnchor = panel.style.getPropertyValue('position-anchor');
|
|
94
133
|
expect(anchorName).toMatch(/^--popover-/);
|
|
95
134
|
expect(positionAnchor).toBe(anchorName);
|
|
96
135
|
});
|
|
97
136
|
|
|
98
|
-
it('
|
|
99
|
-
const user = userEvent.setup();
|
|
137
|
+
it('each instance gets a unique anchor-name', () => {
|
|
100
138
|
render(
|
|
101
|
-
|
|
139
|
+
<>
|
|
140
|
+
<Popover content={<p>A</p>}>
|
|
141
|
+
<button type="button">A</button>
|
|
142
|
+
</Popover>
|
|
143
|
+
<Popover content={<p>B</p>}>
|
|
144
|
+
<button type="button">B</button>
|
|
145
|
+
</Popover>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
const [btnA, btnB] = screen.getAllByRole('button');
|
|
149
|
+
expect(btnA.style.getPropertyValue('anchor-name')).not.toBe(
|
|
150
|
+
btnB.style.getPropertyValue('anchor-name')
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('sets data-placement from the placement prop', () => {
|
|
155
|
+
render(
|
|
156
|
+
<Popover content={<p>Panel</p>} placement="top">
|
|
102
157
|
<button type="button">Open</button>
|
|
103
158
|
</Popover>
|
|
104
159
|
);
|
|
105
|
-
|
|
106
|
-
expect(screen.getByRole('region', { name: 'Filter options' })).toBeInTheDocument();
|
|
160
|
+
expect(getPanel()).toHaveAttribute('data-placement', 'top');
|
|
107
161
|
});
|
|
108
162
|
|
|
109
|
-
it('
|
|
110
|
-
const user = userEvent.setup();
|
|
163
|
+
it('defaults data-placement to "bottom"', () => {
|
|
111
164
|
render(
|
|
112
165
|
<Popover content={<p>Panel</p>}>
|
|
113
166
|
<button type="button">Open</button>
|
|
114
167
|
</Popover>
|
|
115
168
|
);
|
|
116
|
-
|
|
117
|
-
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
169
|
+
expect(getPanel()).toHaveAttribute('data-placement', 'bottom');
|
|
118
170
|
});
|
|
119
171
|
|
|
120
|
-
it('
|
|
172
|
+
it('renders role="region" with aria-label when aria-label prop is provided', async () => {
|
|
121
173
|
const user = userEvent.setup();
|
|
122
|
-
Object.defineProperty(window, 'innerHeight', { value: 600, configurable: true });
|
|
123
|
-
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
|
124
|
-
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
125
|
-
top: 530, bottom: 550, left: 100, right: 200, width: 100, height: 20,
|
|
126
|
-
x: 100, y: 530, toJSON: () => {},
|
|
127
|
-
});
|
|
128
174
|
render(
|
|
129
|
-
<Popover content={<p>
|
|
175
|
+
<Popover content={<p>Filters</p>} aria-label="Filter options">
|
|
130
176
|
<button type="button">Open</button>
|
|
131
177
|
</Popover>
|
|
132
178
|
);
|
|
133
179
|
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
expect(dialog.style.maxHeight).toBe('34px');
|
|
180
|
+
expect(getPanel()).toHaveAttribute('role', 'region');
|
|
181
|
+
expect(getPanel()).toHaveAttribute('aria-label', 'Filter options');
|
|
137
182
|
});
|
|
138
183
|
|
|
139
|
-
it('
|
|
140
|
-
const user = userEvent.setup();
|
|
141
|
-
Object.defineProperty(window, 'innerHeight', { value: 600, configurable: true });
|
|
142
|
-
Object.defineProperty(window, 'innerWidth', { value: 400, configurable: true });
|
|
143
|
-
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
144
|
-
top: 100, bottom: 120, left: 350, right: 390, width: 40, height: 20,
|
|
145
|
-
x: 350, y: 100, toJSON: () => {},
|
|
146
|
-
});
|
|
184
|
+
it('does not render role="region" when no aria-label is provided', () => {
|
|
147
185
|
render(
|
|
148
|
-
<Popover content={<p>Panel</p>}
|
|
186
|
+
<Popover content={<p>Panel</p>}>
|
|
149
187
|
<button type="button">Open</button>
|
|
150
188
|
</Popover>
|
|
151
189
|
);
|
|
152
|
-
|
|
153
|
-
const dialog = document.querySelector('dialog')!;
|
|
154
|
-
// max-width = vw - rect.left - GAP = 400 - 350 - 8 = 42
|
|
155
|
-
expect(dialog.style.maxWidth).toBe('42px');
|
|
190
|
+
expect(getPanel()).not.toHaveAttribute('role');
|
|
156
191
|
});
|
|
157
192
|
});
|