@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
package/dist/web-components.d.ts
CHANGED
package/dist/web-components.js
CHANGED
|
@@ -1719,15 +1719,23 @@ var BdsPopover = class extends i3 {
|
|
|
1719
1719
|
this.style.setProperty("--bds-popover-anchor", this._anchorName);
|
|
1720
1720
|
document.addEventListener("keydown", this._onKeydown);
|
|
1721
1721
|
document.addEventListener("mousedown", this._onOutsideClick);
|
|
1722
|
+
this.addEventListener("command", this._onCommand);
|
|
1722
1723
|
}
|
|
1723
1724
|
disconnectedCallback() {
|
|
1724
1725
|
super.disconnectedCallback();
|
|
1725
1726
|
document.removeEventListener("keydown", this._onKeydown);
|
|
1726
1727
|
document.removeEventListener("mousedown", this._onOutsideClick);
|
|
1728
|
+
this.removeEventListener("command", this._onCommand);
|
|
1727
1729
|
}
|
|
1728
1730
|
_onKeydown = (e3) => {
|
|
1729
1731
|
if (this.open && e3.key === "Escape") this._close();
|
|
1730
1732
|
};
|
|
1733
|
+
_onCommand = (e3) => {
|
|
1734
|
+
const command = e3.command;
|
|
1735
|
+
if (command === "--toggle") this._toggle();
|
|
1736
|
+
else if (command === "--open") this._open();
|
|
1737
|
+
else if (command === "--close") this._close();
|
|
1738
|
+
};
|
|
1731
1739
|
_onOutsideClick = (e3) => {
|
|
1732
1740
|
if (this.open && !this.contains(e3.target)) this._close();
|
|
1733
1741
|
};
|
|
@@ -1739,10 +1747,12 @@ var BdsPopover = class extends i3 {
|
|
|
1739
1747
|
}
|
|
1740
1748
|
}
|
|
1741
1749
|
_open() {
|
|
1750
|
+
if (this.open) return;
|
|
1742
1751
|
this.open = true;
|
|
1743
1752
|
this.dispatchEvent(new CustomEvent("bds-open", { bubbles: true, composed: true }));
|
|
1744
1753
|
}
|
|
1745
1754
|
_close() {
|
|
1755
|
+
if (!this.open) return;
|
|
1746
1756
|
this.open = false;
|
|
1747
1757
|
this.dispatchEvent(new CustomEvent("bds-close", { bubbles: true, composed: true }));
|
|
1748
1758
|
}
|
package/package.json
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
cursor: pointer;
|
|
42
42
|
scroll-padding-block-end: var(--bds-space_l);
|
|
43
43
|
justify-content: center;
|
|
44
|
-
|
|
44
|
+
block-size: var(--button_height, 3em);
|
|
45
45
|
font-size: var(--button_font-size, inherit);
|
|
46
46
|
white-space: nowrap;
|
|
47
47
|
line-height: 1;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
2
|
+
import * as Stories from './Button.native.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# Button (React Native)
|
|
7
|
+
|
|
8
|
+
React Native button component using `Pressable`. Matches the web `Button` API as closely as possible, replacing `onClick` with `onPress` and `aria-label` with `accessibilityLabel`.
|
|
9
|
+
|
|
10
|
+
> **Status: alpha** — API may change before stable release.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { Button } from '@boostdev/components';
|
|
16
|
+
|
|
17
|
+
<Button variant="default" size="medium" onPress={() => console.log('pressed')}>
|
|
18
|
+
Save
|
|
19
|
+
</Button>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Wrap your tree in `ThemeProvider` (once, at the app root):
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { ThemeProvider } from '@boostdev/components/native/ThemeContext';
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
return <ThemeProvider><YourApp /></ThemeProvider>;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Examples
|
|
33
|
+
|
|
34
|
+
### All variants & sizes
|
|
35
|
+
<Canvas of={Stories.AllVariants} />
|
|
36
|
+
|
|
37
|
+
### Default
|
|
38
|
+
<Canvas of={Stories.Default} />
|
|
39
|
+
|
|
40
|
+
### Ghost
|
|
41
|
+
<Canvas of={Stories.Ghost} />
|
|
42
|
+
|
|
43
|
+
### Sizes
|
|
44
|
+
<Canvas of={Stories.Small} />
|
|
45
|
+
<Canvas of={Stories.Medium} />
|
|
46
|
+
<Canvas of={Stories.Large} />
|
|
47
|
+
|
|
48
|
+
### Disabled
|
|
49
|
+
<Canvas of={Stories.Disabled} />
|
|
50
|
+
|
|
51
|
+
## Props
|
|
52
|
+
|
|
53
|
+
<ArgTypes of={Stories} />
|
|
54
|
+
|
|
55
|
+
## Props reference
|
|
56
|
+
|
|
57
|
+
| Prop | Type | Default | Description |
|
|
58
|
+
|------|------|---------|-------------|
|
|
59
|
+
| `variant` | `"default" \| "ghost"` | `"default"` | Visual style |
|
|
60
|
+
| `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and padding scale |
|
|
61
|
+
| `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
|
|
62
|
+
| `onPress` | `() => void` | — | Called when the button is pressed |
|
|
63
|
+
| `iconStart` | `ReactNode` | — | Icon before the label |
|
|
64
|
+
| `iconEnd` | `ReactNode` | — | Icon after the label |
|
|
65
|
+
| `accessibilityLabel` | string | — | Screen reader label (required for icon-only buttons) |
|
|
66
|
+
| `style` | `StyleProp<ViewStyle>` | — | Override container styles |
|
|
67
|
+
|
|
68
|
+
## Accessibility
|
|
69
|
+
|
|
70
|
+
- Provide `accessibilityLabel` for icon-only buttons
|
|
71
|
+
- `disabled` communicates the disabled state to assistive technologies
|
|
72
|
+
- Pressed state reduces opacity to 0.75 for visual feedback
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
border: none;
|
|
5
5
|
border-radius: var(--bds-border_radius--m);
|
|
6
6
|
background: transparent;
|
|
7
|
-
|
|
8
|
-
max-
|
|
7
|
+
inline-size: min(40rem, 90vw);
|
|
8
|
+
max-block-size: 70vh;
|
|
9
9
|
overflow: visible;
|
|
10
10
|
margin-block-start: 10vh;
|
|
11
11
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
border-radius: var(--bds-border_radius--m);
|
|
24
24
|
box-shadow: var(--command_shadow, var(--bds-shadow_xl));
|
|
25
25
|
overflow: hidden;
|
|
26
|
-
max-
|
|
26
|
+
max-block-size: 70vh;
|
|
27
27
|
border: var(--command_border, none);
|
|
28
28
|
--bdc_color: currentcolor;
|
|
29
29
|
--bdc-outline_radius: var(--bds-border_radius--m);
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
.searchIcon {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
inline-size: 1.25rem;
|
|
46
|
+
block-size: 1.25rem;
|
|
47
47
|
color: var(--bds-color_on-bg);
|
|
48
48
|
flex-shrink: 0;
|
|
49
49
|
}
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
font-size: var(--bds-font_size--body);
|
|
57
57
|
color: var(--bds-color_on-bg);
|
|
58
58
|
background: transparent;
|
|
59
|
-
min-
|
|
59
|
+
min-inline-size: 0;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
.search::placeholder {
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
.list {
|
|
81
|
-
overflow-
|
|
81
|
+
overflow-block: auto;
|
|
82
82
|
list-style: none;
|
|
83
83
|
margin: 0;
|
|
84
84
|
padding: var(--bds-space_xs) 0;
|
|
@@ -59,6 +59,16 @@ describe('Command', () => {
|
|
|
59
59
|
expect(screen.getByText('Save current file')).toBeInTheDocument();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
it('dialog has an id for invoker targeting', () => {
|
|
63
|
+
render(<Command isOpen items={items} onClose={() => {}} />);
|
|
64
|
+
expect(document.querySelector('dialog')).toHaveAttribute('id');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses provided id on dialog', () => {
|
|
68
|
+
render(<Command id="cmd-palette" isOpen items={items} onClose={() => {}} />);
|
|
69
|
+
expect(document.querySelector('dialog')).toHaveAttribute('id', 'cmd-palette');
|
|
70
|
+
});
|
|
71
|
+
|
|
62
72
|
it('locks body scroll when open', () => {
|
|
63
73
|
render(<Command isOpen items={items} onClose={() => {}} />);
|
|
64
74
|
expect(document.body.style.overflow).toBe('hidden');
|
|
@@ -99,4 +109,31 @@ describe('Command', () => {
|
|
|
99
109
|
|
|
100
110
|
document.body.removeChild(trigger);
|
|
101
111
|
});
|
|
112
|
+
|
|
113
|
+
describe('command event (Invoker Commands API)', () => {
|
|
114
|
+
function makeCommandEvent(command: string) {
|
|
115
|
+
const event = new Event('command');
|
|
116
|
+
Object.defineProperty(event, 'command', { value: command, configurable: true });
|
|
117
|
+
return event;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('locks scroll and calls onOpen on show-modal command', () => {
|
|
121
|
+
const onOpen = vi.fn();
|
|
122
|
+
render(<Command isOpen={false} items={items} onOpen={onOpen} onClose={() => {}} />);
|
|
123
|
+
const dialog = document.querySelector('dialog')!;
|
|
124
|
+
dialog.dispatchEvent(makeCommandEvent('show-modal'));
|
|
125
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
126
|
+
expect(onOpen).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('restores scroll and calls onClose on close command', () => {
|
|
130
|
+
const onClose = vi.fn();
|
|
131
|
+
render(<Command isOpen items={items} onClose={onClose} />);
|
|
132
|
+
document.body.style.overflow = 'hidden';
|
|
133
|
+
const dialog = document.querySelector('dialog')!;
|
|
134
|
+
dialog.dispatchEvent(makeCommandEvent('close'));
|
|
135
|
+
expect(document.body.style.overflow).toBe('');
|
|
136
|
+
expect(onClose).toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
102
139
|
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useId, KeyboardEvent, useMemo } 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 './Command.module.css';
|
|
3
8
|
import { cn } from '@boostdev/design-system-foundation';
|
|
4
9
|
import type { WithClassName } from '../../../types';
|
|
@@ -13,14 +18,18 @@ export interface CommandItem {
|
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
interface CommandProps extends WithClassName {
|
|
21
|
+
id?: string;
|
|
16
22
|
isOpen: boolean;
|
|
23
|
+
onOpen?: () => void;
|
|
17
24
|
onClose: () => void;
|
|
18
25
|
items: CommandItem[];
|
|
19
26
|
placeholder?: string;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function Command({
|
|
30
|
+
id: idProp,
|
|
23
31
|
isOpen,
|
|
32
|
+
onOpen,
|
|
24
33
|
onClose,
|
|
25
34
|
items,
|
|
26
35
|
placeholder = 'Search commands…',
|
|
@@ -28,6 +37,8 @@ export function Command({
|
|
|
28
37
|
}: Readonly<CommandProps>) {
|
|
29
38
|
const [query, setQuery] = useState('');
|
|
30
39
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
40
|
+
const generatedId = useId();
|
|
41
|
+
const dialogId = idProp ?? generatedId;
|
|
31
42
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
32
43
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
33
44
|
const listboxId = useId();
|
|
@@ -56,14 +67,14 @@ export function Command({
|
|
|
56
67
|
useEffect(() => {
|
|
57
68
|
const dialog = dialogRef.current;
|
|
58
69
|
if (!dialog) return;
|
|
59
|
-
if (isOpen) {
|
|
70
|
+
if (isOpen && !dialog.open) {
|
|
60
71
|
triggerRef.current = document.activeElement;
|
|
61
72
|
dialog.showModal();
|
|
62
73
|
document.body.style.overflow = 'hidden';
|
|
63
74
|
setQuery('');
|
|
64
75
|
setActiveIndex(0);
|
|
65
76
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
66
|
-
} else if (dialog.open) {
|
|
77
|
+
} else if (!isOpen && dialog.open) {
|
|
67
78
|
dialog.close();
|
|
68
79
|
document.body.style.overflow = '';
|
|
69
80
|
(triggerRef.current as HTMLElement | null)?.focus();
|
|
@@ -77,6 +88,29 @@ export function Command({
|
|
|
77
88
|
setActiveIndex(0);
|
|
78
89
|
}, [query]);
|
|
79
90
|
|
|
91
|
+
// Invoker Commands API: sync state when opened/closed via commandfor/command.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const dialog = dialogRef.current;
|
|
94
|
+
if (!dialog) return;
|
|
95
|
+
const handleCommand = (e: CommandEvent) => {
|
|
96
|
+
if (e.command === 'show-modal') {
|
|
97
|
+
triggerRef.current = document.activeElement;
|
|
98
|
+
document.body.style.overflow = 'hidden';
|
|
99
|
+
setQuery('');
|
|
100
|
+
setActiveIndex(0);
|
|
101
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
102
|
+
onOpen?.();
|
|
103
|
+
} else if (e.command === 'close') {
|
|
104
|
+
document.body.style.overflow = '';
|
|
105
|
+
const trigger = triggerRef.current;
|
|
106
|
+
setTimeout(() => (trigger as HTMLElement | null)?.focus(), 0);
|
|
107
|
+
onClose();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
dialog.addEventListener('command', handleCommand);
|
|
111
|
+
return () => dialog.removeEventListener('command', handleCommand);
|
|
112
|
+
}, [onOpen, onClose]);
|
|
113
|
+
|
|
80
114
|
const handleCancel = (e: React.SyntheticEvent) => {
|
|
81
115
|
e.preventDefault();
|
|
82
116
|
onClose();
|
|
@@ -106,6 +140,7 @@ export function Command({
|
|
|
106
140
|
return (
|
|
107
141
|
<dialog
|
|
108
142
|
ref={dialogRef}
|
|
143
|
+
id={dialogId}
|
|
109
144
|
className={cn(css.dialog, className)}
|
|
110
145
|
aria-label="Command palette"
|
|
111
146
|
aria-modal="true"
|
|
@@ -12,29 +12,26 @@
|
|
|
12
12
|
background-color: var(--dialog_color ,var(--bds-color_grey--subtle));
|
|
13
13
|
color: var(--dialog_on-color, var(--bds-color_on-grey--subtle));
|
|
14
14
|
padding: var(--bds-space_l);
|
|
15
|
-
max-
|
|
16
|
-
max-
|
|
15
|
+
max-inline-size: 90svw;
|
|
16
|
+
max-block-size: 90svh;
|
|
17
17
|
overflow: visible;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
.dialogContent {
|
|
21
21
|
display: grid;
|
|
22
22
|
gap: var(--bds-grid_gap);
|
|
23
|
-
overflow-
|
|
24
|
-
min-
|
|
23
|
+
overflow-block: auto;
|
|
24
|
+
min-block-size: 0;
|
|
25
25
|
flex: 1;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
.
|
|
28
|
+
.closeButton {
|
|
29
|
+
all: unset;
|
|
29
30
|
position: absolute;
|
|
30
31
|
inset-block-start: -1em;
|
|
31
32
|
inset-inline-end: -1em;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.closeButton {
|
|
35
|
-
all: unset;
|
|
36
|
-
width: 2em;
|
|
37
|
-
height: 2em;
|
|
33
|
+
inline-size: 2em;
|
|
34
|
+
block-size: 2em;
|
|
38
35
|
display: flex;
|
|
39
36
|
align-items: center;
|
|
40
37
|
justify-content: center;
|
|
@@ -50,8 +47,8 @@
|
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
.closeButton svg {
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
inline-size: 1.25em;
|
|
51
|
+
block-size: 1.25em;
|
|
55
52
|
}
|
|
56
53
|
|
|
57
54
|
.dialog::backdrop {
|
|
@@ -29,7 +29,26 @@ describe('Dialog', () => {
|
|
|
29
29
|
expect(screen.getByRole('button', { name: /close dialog/i })).toBeInTheDocument();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it('
|
|
32
|
+
it('close button is type="button"', () => {
|
|
33
|
+
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
34
|
+
expect(screen.getByRole('button', { name: /close dialog/i })).toHaveAttribute('type', 'button');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('close button always uses commandfor/command', () => {
|
|
38
|
+
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
39
|
+
const closeBtn = screen.getByRole('button', { name: /close dialog/i });
|
|
40
|
+
expect(closeBtn).toHaveAttribute('commandfor');
|
|
41
|
+
expect(closeBtn).toHaveAttribute('command', 'close');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('close button commandfor matches dialog id', () => {
|
|
45
|
+
render(<Dialog id="my-dialog" isOpen><span>Content</span></Dialog>);
|
|
46
|
+
const closeBtn = screen.getByRole('button', { name: /close dialog/i });
|
|
47
|
+
expect(closeBtn).toHaveAttribute('commandfor', 'my-dialog');
|
|
48
|
+
expect(document.querySelector('dialog')).toHaveAttribute('id', 'my-dialog');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('close button click via polyfill calls onClose', async () => {
|
|
33
52
|
const user = userEvent.setup();
|
|
34
53
|
const onClose = vi.fn();
|
|
35
54
|
render(<Dialog isOpen onClose={onClose}><span>Content</span></Dialog>);
|
|
@@ -42,6 +61,12 @@ describe('Dialog', () => {
|
|
|
42
61
|
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
43
62
|
});
|
|
44
63
|
|
|
64
|
+
it('does not call showModal again when dialog is already open', () => {
|
|
65
|
+
const { rerender } = render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
66
|
+
rerender(<Dialog isOpen><span>Content</span></Dialog>);
|
|
67
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
45
70
|
it('locks body scroll when open', () => {
|
|
46
71
|
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
47
72
|
expect(document.body.style.overflow).toBe('hidden');
|
|
@@ -77,4 +102,31 @@ describe('Dialog', () => {
|
|
|
77
102
|
|
|
78
103
|
document.body.removeChild(trigger);
|
|
79
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('command event (Invoker Commands API)', () => {
|
|
107
|
+
function makeCommandEvent(command: string) {
|
|
108
|
+
const event = new Event('command');
|
|
109
|
+
Object.defineProperty(event, 'command', { value: command, configurable: true });
|
|
110
|
+
return event;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('locks scroll and calls onOpen on show-modal command', () => {
|
|
114
|
+
const onOpen = vi.fn();
|
|
115
|
+
render(<Dialog onOpen={onOpen}><span>Content</span></Dialog>);
|
|
116
|
+
const dialog = document.querySelector('dialog')!;
|
|
117
|
+
dialog.dispatchEvent(makeCommandEvent('show-modal'));
|
|
118
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
119
|
+
expect(onOpen).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('restores scroll and calls onClose on close command', () => {
|
|
123
|
+
const onClose = vi.fn();
|
|
124
|
+
render(<Dialog isOpen onClose={onClose}><span>Content</span></Dialog>);
|
|
125
|
+
document.body.style.overflow = 'hidden';
|
|
126
|
+
const dialog = document.querySelector('dialog')!;
|
|
127
|
+
dialog.dispatchEvent(makeCommandEvent('close'));
|
|
128
|
+
expect(document.body.style.overflow).toBe('');
|
|
129
|
+
expect(onClose).toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
80
132
|
});
|
|
@@ -33,3 +33,20 @@ export const Interactive: Story = {
|
|
|
33
33
|
);
|
|
34
34
|
},
|
|
35
35
|
};
|
|
36
|
+
|
|
37
|
+
// Open and close using the Invoker Commands API — no JS event handlers needed.
|
|
38
|
+
// The browser wires up the buttons to the dialog natively via commandfor/command.
|
|
39
|
+
export const InvokerCommands: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<>
|
|
42
|
+
<Button type="button" commandfor="invoker-dialog" command="show-modal">
|
|
43
|
+
Open Dialog (no JS)
|
|
44
|
+
</Button>
|
|
45
|
+
<Dialog id="invoker-dialog">
|
|
46
|
+
<h2>Invoker Dialog</h2>
|
|
47
|
+
<p>This dialog was opened with a declarative HTML invoker command — no onClick needed.</p>
|
|
48
|
+
<p>The close button (×) inside also uses an invoker command.</p>
|
|
49
|
+
</Dialog>
|
|
50
|
+
</>
|
|
51
|
+
),
|
|
52
|
+
};
|
|
@@ -1,4 +1,8 @@
|
|
|
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();
|
|
2
6
|
|
|
3
7
|
const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
4
8
|
import css from './Dialog.module.css';
|
|
@@ -7,22 +11,28 @@ import type { WithClassName } from '../../../types';
|
|
|
7
11
|
|
|
8
12
|
interface DialogProps extends WithClassName {
|
|
9
13
|
children: ReactNode;
|
|
14
|
+
id?: string;
|
|
10
15
|
isOpen?: boolean;
|
|
16
|
+
onOpen?: () => void;
|
|
11
17
|
onClose?: () => void;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export function Dialog({ children, isOpen = false, className, onClose }: DialogProps) {
|
|
20
|
+
export function Dialog({ children, id: idProp, isOpen = false, className, onOpen, onClose }: DialogProps) {
|
|
21
|
+
const generatedId = useId();
|
|
22
|
+
const id = idProp ?? generatedId;
|
|
23
|
+
|
|
15
24
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
16
25
|
const triggerRef = useRef<Element | null>(null);
|
|
17
26
|
|
|
27
|
+
// Controlled mode: open/close in response to the isOpen prop.
|
|
18
28
|
useEffect(() => {
|
|
19
29
|
const dialog = dialogRef.current;
|
|
20
30
|
if (!dialog) return;
|
|
21
|
-
if (isOpen) {
|
|
31
|
+
if (isOpen && !dialog.open) {
|
|
22
32
|
triggerRef.current = document.activeElement;
|
|
23
33
|
dialog.showModal();
|
|
24
34
|
document.body.style.overflow = 'hidden';
|
|
25
|
-
} else if (dialog.open) {
|
|
35
|
+
} else if (!isOpen && dialog.open) {
|
|
26
36
|
dialog.close();
|
|
27
37
|
document.body.style.overflow = '';
|
|
28
38
|
(triggerRef.current as HTMLElement | null)?.focus();
|
|
@@ -32,6 +42,30 @@ export function Dialog({ children, isOpen = false, className, onClose }: DialogP
|
|
|
32
42
|
};
|
|
33
43
|
}, [isOpen]);
|
|
34
44
|
|
|
45
|
+
// Invoker Commands API: sync state when the dialog is opened or closed via
|
|
46
|
+
// a <button commandfor="id" command="show-modal|close"> without JS.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const dialog = dialogRef.current;
|
|
49
|
+
if (!dialog) return;
|
|
50
|
+
const handleCommand = (e: CommandEvent) => {
|
|
51
|
+
if (e.command === 'show-modal') {
|
|
52
|
+
triggerRef.current = document.activeElement;
|
|
53
|
+
document.body.style.overflow = 'hidden';
|
|
54
|
+
onOpen?.();
|
|
55
|
+
} else if (e.command === 'close') {
|
|
56
|
+
document.body.style.overflow = '';
|
|
57
|
+
// Defer focus restoration: the dialog is still open when the command
|
|
58
|
+
// event fires (the action runs after), so focusing outside would be
|
|
59
|
+
// blocked by the focus-trap. setTimeout defers until after close.
|
|
60
|
+
const trigger = triggerRef.current;
|
|
61
|
+
setTimeout(() => (trigger as HTMLElement | null)?.focus(), 0);
|
|
62
|
+
onClose?.();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
dialog.addEventListener('command', handleCommand);
|
|
66
|
+
return () => dialog.removeEventListener('command', handleCommand);
|
|
67
|
+
}, [onOpen, onClose]);
|
|
68
|
+
|
|
35
69
|
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
|
36
70
|
if (e.target === dialogRef.current) onClose?.();
|
|
37
71
|
};
|
|
@@ -59,24 +93,24 @@ export function Dialog({ children, isOpen = false, className, onClose }: DialogP
|
|
|
59
93
|
return (
|
|
60
94
|
<dialog
|
|
61
95
|
ref={dialogRef}
|
|
96
|
+
id={id}
|
|
62
97
|
className={cn(className, css.dialog)}
|
|
63
98
|
aria-modal="true"
|
|
64
99
|
onClick={handleBackdropClick}
|
|
65
100
|
onCancel={handleCancel}
|
|
66
101
|
onKeyDown={handleKeyDown}
|
|
67
102
|
>
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
</form>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={css.closeButton}
|
|
106
|
+
commandfor={id}
|
|
107
|
+
command="close"
|
|
108
|
+
aria-label="Close dialog"
|
|
109
|
+
>
|
|
110
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
111
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6L6 18M6 6l12 12" />
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
80
114
|
<div className={css.dialogContent}>
|
|
81
115
|
{children}
|
|
82
116
|
</div>
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
inset: 0;
|
|
5
5
|
margin: 0;
|
|
6
6
|
padding: 0;
|
|
7
|
-
|
|
8
|
-
max-
|
|
9
|
-
|
|
10
|
-
max-
|
|
7
|
+
inline-size: 100%;
|
|
8
|
+
max-inline-size: 100%;
|
|
9
|
+
block-size: 100%;
|
|
10
|
+
max-block-size: 100%;
|
|
11
11
|
border: none;
|
|
12
12
|
background: transparent;
|
|
13
13
|
overflow: hidden;
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
inset-block: 0;
|
|
24
24
|
display: flex;
|
|
25
25
|
flex-direction: column;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
inline-size: min(28rem, 90vw);
|
|
27
|
+
block-size: 100%;
|
|
28
28
|
background-color: var(--drawer_color, var(--bds-color_bg));
|
|
29
29
|
color: var(--drawer_on-color, var(--bds-color_on-bg));
|
|
30
30
|
box-shadow: var(--bds-shadow_xl);
|
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
display: flex;
|
|
55
55
|
align-items: center;
|
|
56
56
|
justify-content: center;
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
inline-size: 2rem;
|
|
58
|
+
block-size: 2rem;
|
|
59
59
|
border-radius: 50%;
|
|
60
60
|
cursor: pointer;
|
|
61
61
|
transition: var(--bds-animation_transition);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
.closeButton svg {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
inline-size: 1.25rem;
|
|
66
|
+
block-size: 1.25rem;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
.closeButton:focus-visible {
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
|
|
80
80
|
.body {
|
|
81
81
|
flex: 1;
|
|
82
|
-
overflow-
|
|
82
|
+
overflow-block: auto;
|
|
83
83
|
padding: var(--bds-space_l);
|
|
84
84
|
display: flex;
|
|
85
85
|
flex-direction: column;
|