@boostdev/design-system-components 1.0.3 → 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.
- package/AGENTS.md +5 -5
- package/dist/client.cjs +236 -153
- package/dist/client.css +547 -483
- package/dist/client.d.cts +53 -94
- package/dist/client.d.ts +53 -94
- package/dist/client.js +237 -163
- package/dist/index.cjs +236 -153
- package/dist/index.css +547 -483
- package/dist/index.d.cts +53 -94
- package/dist/index.d.ts +53 -94
- package/dist/index.js +237 -163
- package/dist/native/index.cjs +991 -0
- package/dist/native/index.d.cts +208 -0
- package/dist/native/index.d.ts +208 -0
- package/dist/native/index.js +968 -0
- package/dist/web-components.d.ts +303 -0
- package/dist/web-components.js +1968 -0
- package/package.json +27 -5
- package/src/components/interaction/Button/Button.module.css +10 -7
- package/src/components/interaction/Button/Button.native.spec.tsx +35 -0
- package/src/components/interaction/Button/Button.native.stories.tsx +42 -0
- package/src/components/interaction/Button/Button.native.tsx +95 -0
- package/src/components/interaction/Button/Button.stories.tsx +1 -1
- package/src/components/interaction/Button/Button.tsx +2 -2
- package/src/components/interaction/Command/Command.mdx +16 -0
- package/src/components/interaction/Command/Command.stories.tsx +1 -1
- package/src/components/interaction/Command/Command.tsx +2 -2
- package/src/components/interaction/Dialog/Dialog.stories.tsx +1 -1
- package/src/components/interaction/Dialog/Dialog.tsx +2 -2
- package/src/components/interaction/Drawer/Drawer.mdx +14 -0
- package/src/components/interaction/Drawer/Drawer.stories.tsx +1 -1
- package/src/components/interaction/Drawer/Drawer.tsx +2 -2
- package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +1 -1
- package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/components/interaction/Popover/Popover.mdx +17 -0
- package/src/components/interaction/Popover/Popover.module.css +70 -13
- package/src/components/interaction/Popover/Popover.spec.tsx +68 -4
- package/src/components/interaction/Popover/Popover.stories.tsx +1 -1
- package/src/components/interaction/Popover/Popover.tsx +76 -20
- package/src/components/interaction/Rating/Rating.stories.tsx +1 -1
- package/src/components/interaction/Rating/Rating.tsx +2 -2
- package/src/components/interaction/Toast/Toast.module.css +6 -1
- package/src/components/interaction/Toast/Toast.spec.tsx +21 -0
- package/src/components/interaction/Toast/Toast.stories.tsx +1 -1
- package/src/components/interaction/Toast/Toast.tsx +22 -2
- package/src/components/interaction/form/Checkbox/Checkbox.mdx +2 -2
- package/src/components/interaction/form/Checkbox/Checkbox.module.css +3 -3
- package/src/components/interaction/form/Checkbox/Checkbox.native.spec.tsx +42 -0
- package/src/components/interaction/form/Checkbox/Checkbox.native.stories.tsx +36 -0
- package/src/components/interaction/form/Checkbox/Checkbox.native.tsx +80 -0
- package/src/components/interaction/form/Checkbox/Checkbox.stories.tsx +1 -1
- package/src/components/interaction/form/Checkbox/Checkbox.tsx +2 -2
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.mdx +44 -0
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.stories.tsx +1 -1
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.tsx +2 -2
- package/src/components/interaction/form/Combobox/Combobox.mdx +21 -0
- package/src/components/interaction/form/Combobox/Combobox.module.css +1 -1
- package/src/components/interaction/form/Combobox/Combobox.stories.tsx +1 -1
- package/src/components/interaction/form/Combobox/Combobox.tsx +2 -2
- package/src/components/interaction/form/FileInput/FileInput.mdx +14 -0
- package/src/components/interaction/form/FileInput/FileInput.module.css +1 -1
- package/src/components/interaction/form/FileInput/FileInput.stories.tsx +1 -1
- package/src/components/interaction/form/FileInput/FileInput.tsx +2 -2
- package/src/components/interaction/form/FormInput/FormInput.stories.tsx +1 -1
- package/src/components/interaction/form/FormInput/FormInput.tsx +2 -2
- package/src/components/interaction/form/NumberInput/NumberInput.mdx +16 -0
- package/src/components/interaction/form/NumberInput/NumberInput.stories.tsx +1 -1
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +2 -2
- package/src/components/interaction/form/Radio/Radio.mdx +3 -3
- package/src/components/interaction/form/Radio/Radio.module.css +3 -3
- package/src/components/interaction/form/Radio/Radio.native.stories.tsx +46 -0
- package/src/components/interaction/form/Radio/Radio.native.tsx +79 -0
- package/src/components/interaction/form/Radio/Radio.stories.tsx +1 -1
- package/src/components/interaction/form/Radio/Radio.tsx +2 -2
- package/src/components/interaction/form/RadioGroup/RadioGroup.mdx +45 -0
- package/src/components/interaction/form/RadioGroup/RadioGroup.stories.tsx +1 -1
- package/src/components/interaction/form/RadioGroup/RadioGroup.tsx +2 -2
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +1 -1
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +2 -2
- package/src/components/interaction/form/Select/Select.stories.tsx +1 -1
- package/src/components/interaction/form/Select/Select.tsx +2 -2
- package/src/components/interaction/form/Slider/Slider.mdx +1 -1
- package/src/components/interaction/form/Slider/Slider.module.css +4 -4
- package/src/components/interaction/form/Slider/Slider.stories.tsx +1 -1
- package/src/components/interaction/form/Slider/Slider.tsx +2 -2
- package/src/components/interaction/form/Switch/Switch.mdx +4 -4
- package/src/components/interaction/form/Switch/Switch.module.css +3 -3
- package/src/components/interaction/form/Switch/Switch.native.spec.tsx +60 -0
- package/src/components/interaction/form/Switch/Switch.native.stories.tsx +35 -0
- package/src/components/interaction/form/Switch/Switch.native.tsx +59 -0
- package/src/components/interaction/form/Switch/Switch.stories.tsx +1 -1
- package/src/components/interaction/form/Switch/Switch.tsx +2 -2
- package/src/components/interaction/form/Textarea/Textarea.mdx +17 -0
- package/src/components/interaction/form/Textarea/Textarea.stories.tsx +1 -1
- package/src/components/interaction/form/Textarea/Textarea.tsx +2 -2
- package/src/components/interaction/form/atoms/InputContainer.tsx +2 -1
- package/src/components/interaction/form/atoms/Label.native.stories.tsx +18 -0
- package/src/components/interaction/form/atoms/Label.native.tsx +29 -0
- package/src/components/interaction/form/atoms/Label.tsx +5 -3
- package/src/components/interaction/form/atoms/Message.native.spec.tsx +30 -0
- package/src/components/interaction/form/atoms/Message.native.stories.tsx +22 -0
- package/src/components/interaction/form/atoms/Message.native.tsx +34 -0
- package/src/components/interaction/form/atoms/Message.tsx +5 -3
- package/src/components/layout/ButtonGroup/ButtonGroup.native.stories.tsx +50 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.native.tsx +34 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +1 -1
- package/src/components/layout/ButtonGroup/ButtonGroup.tsx +2 -2
- package/src/components/layout/Card/Card.native.stories.tsx +53 -0
- package/src/components/layout/Card/Card.native.tsx +89 -0
- package/src/components/layout/Card/Card.stories.tsx +1 -1
- package/src/components/layout/Card/Card.tsx +2 -2
- package/src/components/layout/IconWrapper/IconWrapper.mdx +2 -2
- package/src/components/layout/IconWrapper/IconWrapper.native.spec.tsx +39 -0
- package/src/components/layout/IconWrapper/IconWrapper.native.stories.tsx +41 -0
- package/src/components/layout/IconWrapper/IconWrapper.native.tsx +20 -0
- package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +1 -1
- package/src/components/layout/IconWrapper/IconWrapper.tsx +2 -2
- package/src/components/layout/SectionHeader/SectionHeader.mdx +13 -0
- package/src/components/layout/SectionHeader/SectionHeader.native.stories.tsx +38 -0
- package/src/components/layout/SectionHeader/SectionHeader.native.tsx +79 -0
- package/src/components/layout/SectionHeader/SectionHeader.stories.tsx +1 -1
- package/src/components/layout/SectionHeader/SectionHeader.tsx +2 -2
- package/src/components/ui/Accordion/Accordion.mdx +14 -0
- package/src/components/ui/Accordion/Accordion.stories.tsx +1 -1
- package/src/components/ui/Accordion/Accordion.tsx +2 -2
- package/src/components/ui/Alert/Alert.native.stories.tsx +43 -0
- package/src/components/ui/Alert/Alert.native.tsx +94 -0
- package/src/components/ui/Alert/Alert.stories.tsx +1 -1
- package/src/components/ui/Alert/Alert.tsx +2 -2
- package/src/components/ui/Avatar/Avatar.native.spec.tsx +47 -0
- package/src/components/ui/Avatar/Avatar.native.stories.tsx +34 -0
- package/src/components/ui/Avatar/Avatar.native.tsx +85 -0
- package/src/components/ui/Avatar/Avatar.stories.tsx +1 -1
- package/src/components/ui/Avatar/Avatar.tsx +2 -2
- package/src/components/ui/Badge/Badge.mdx +2 -2
- package/src/components/ui/Badge/Badge.native.stories.tsx +36 -0
- package/src/components/ui/Badge/Badge.native.tsx +50 -0
- package/src/components/ui/Badge/Badge.stories.tsx +1 -1
- package/src/components/ui/Badge/Badge.tsx +2 -2
- package/src/components/ui/Breadcrumb/Breadcrumb.stories.tsx +1 -1
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +2 -2
- package/src/components/ui/Calendar/Calendar.mdx +16 -0
- package/src/components/ui/Calendar/Calendar.stories.tsx +1 -1
- package/src/components/ui/Calendar/Calendar.tsx +2 -2
- package/src/components/ui/Carousel/Carousel.stories.tsx +1 -1
- package/src/components/ui/Carousel/Carousel.tsx +2 -2
- package/src/components/ui/Collapsible/Collapsible.stories.tsx +1 -1
- package/src/components/ui/Collapsible/Collapsible.tsx +2 -2
- package/src/components/ui/DescriptionList/DescriptionList.stories.tsx +1 -1
- package/src/components/ui/DescriptionList/DescriptionList.tsx +2 -2
- package/src/components/ui/Link/Link.mdx +14 -0
- package/src/components/ui/Link/Link.stories.tsx +1 -1
- package/src/components/ui/Link/Link.tsx +2 -2
- package/src/components/ui/Loading/Loading.native.spec.tsx +24 -0
- package/src/components/ui/Loading/Loading.native.stories.tsx +33 -0
- package/src/components/ui/Loading/Loading.native.tsx +29 -0
- package/src/components/ui/Loading/Loading.stories.tsx +1 -1
- package/src/components/ui/Loading/Loading.tsx +2 -2
- package/src/components/ui/NotificationBanner/NotificationBanner.native.stories.tsx +39 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.native.tsx +76 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +1 -1
- package/src/components/ui/NotificationBanner/NotificationBanner.tsx +2 -2
- package/src/components/ui/Pagination/Pagination.module.css +1 -1
- package/src/components/ui/Pagination/Pagination.stories.tsx +1 -1
- package/src/components/ui/Pagination/Pagination.tsx +2 -2
- package/src/components/ui/Progress/Progress.mdx +1 -1
- package/src/components/ui/Progress/Progress.module.css +1 -1
- package/src/components/ui/Progress/Progress.native.stories.tsx +34 -0
- package/src/components/ui/Progress/Progress.native.tsx +84 -0
- package/src/components/ui/Progress/Progress.stories.tsx +1 -1
- package/src/components/ui/Progress/Progress.tsx +2 -2
- package/src/components/ui/ProgressCircle/ProgressCircle.module.css +1 -1
- package/src/components/ui/ProgressCircle/ProgressCircle.stories.tsx +1 -1
- package/src/components/ui/ProgressCircle/ProgressCircle.tsx +2 -2
- package/src/components/ui/Separator/Separator.mdx +14 -0
- package/src/components/ui/Separator/Separator.native.stories.tsx +42 -0
- package/src/components/ui/Separator/Separator.native.tsx +32 -0
- package/src/components/ui/Separator/Separator.stories.tsx +1 -1
- package/src/components/ui/Separator/Separator.tsx +2 -2
- package/src/components/ui/Skeleton/Skeleton.native.stories.tsx +33 -0
- package/src/components/ui/Skeleton/Skeleton.native.tsx +41 -0
- package/src/components/ui/Skeleton/Skeleton.stories.tsx +1 -1
- package/src/components/ui/Skeleton/Skeleton.tsx +2 -3
- package/src/components/ui/SkipLink/SkipLink.stories.tsx +1 -1
- package/src/components/ui/SkipLink/SkipLink.tsx +5 -3
- package/src/components/ui/Table/Table.mdx +14 -0
- package/src/components/ui/Table/Table.stories.tsx +1 -1
- package/src/components/ui/Table/Table.tsx +2 -2
- package/src/components/ui/Tabs/Tabs.stories.tsx +1 -1
- package/src/components/ui/Tabs/Tabs.tsx +2 -2
- package/src/components/ui/Tooltip/Tooltip.mdx +14 -0
- package/src/components/ui/Tooltip/Tooltip.stories.tsx +1 -1
- package/src/components/ui/Tooltip/Tooltip.tsx +2 -2
- package/src/components/ui/Typography/Typography.mdx +13 -0
- package/src/components/ui/Typography/Typography.native.stories.tsx +38 -0
- package/src/components/ui/Typography/Typography.native.tsx +65 -0
- package/src/components/ui/Typography/Typography.stories.tsx +1 -1
- package/src/components/ui/Typography/Typography.tsx +2 -2
- package/src/css/bdc.css +8 -0
- package/src/index.ts +1 -0
- package/src/native/ThemeContext.tsx +28 -0
- package/src/native/tokens.ts +13 -0
- package/src/native.ts +39 -0
- package/src/react-augment.d.ts +13 -0
- package/src/stories/DesignSystem/DarkMode.mdx +130 -0
- package/src/types.ts +2 -0
- package/src/typings.d.ts +3 -0
- package/src/web-components/globals.ts +61 -0
- package/src/web-components/index.ts +12 -0
- package/src/web-components/interaction/BdsButton.stories.tsx +60 -0
- package/src/web-components/interaction/BdsPopover.stories.tsx +70 -0
- package/src/web-components/interaction/BdsToastProvider.stories.tsx +73 -0
- package/src/web-components/interaction/bds-button.spec.ts +95 -0
- package/src/web-components/interaction/bds-button.ts +293 -0
- package/src/web-components/interaction/bds-popover.spec.ts +126 -0
- package/src/web-components/interaction/bds-popover.ts +217 -0
- package/src/web-components/interaction/bds-toast-provider.spec.ts +122 -0
- package/src/web-components/interaction/bds-toast-provider.ts +211 -0
- package/src/web-components/test/helpers.ts +14 -0
- package/src/web-components/ui/BdsAlert.stories.tsx +60 -0
- package/src/web-components/ui/BdsBadge.stories.tsx +37 -0
- package/src/web-components/ui/bds-alert.spec.ts +109 -0
- package/src/web-components/ui/bds-alert.ts +209 -0
- package/src/web-components/ui/bds-badge.spec.ts +51 -0
- package/src/web-components/ui/bds-badge.ts +88 -0
|
@@ -2,6 +2,15 @@ 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 — provide a minimal stub so the
|
|
6
|
+
// polyfill guard in Popover.tsx doesn't throw.
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
Object.defineProperty(globalThis, 'CSS', {
|
|
9
|
+
value: { supports: () => true },
|
|
10
|
+
configurable: true,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
5
14
|
describe('Popover', () => {
|
|
6
15
|
it('renders the trigger', () => {
|
|
7
16
|
render(
|
|
@@ -18,7 +27,9 @@ describe('Popover', () => {
|
|
|
18
27
|
<button type="button">Open</button>
|
|
19
28
|
</Popover>
|
|
20
29
|
);
|
|
21
|
-
|
|
30
|
+
// The dialog element is always in DOM; closed state = no open attribute.
|
|
31
|
+
const dialog = document.querySelector('dialog');
|
|
32
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
22
33
|
});
|
|
23
34
|
|
|
24
35
|
it('shows the panel when trigger is clicked', async () => {
|
|
@@ -29,7 +40,7 @@ describe('Popover', () => {
|
|
|
29
40
|
</Popover>
|
|
30
41
|
);
|
|
31
42
|
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
32
|
-
expect(
|
|
43
|
+
expect(document.querySelector('dialog')).toHaveAttribute('open');
|
|
33
44
|
});
|
|
34
45
|
|
|
35
46
|
it('closes the panel when trigger is clicked again', async () => {
|
|
@@ -41,7 +52,7 @@ describe('Popover', () => {
|
|
|
41
52
|
);
|
|
42
53
|
await user.click(screen.getByRole('button', { name: 'Toggle' }));
|
|
43
54
|
await user.click(screen.getByRole('button', { name: 'Toggle' }));
|
|
44
|
-
expect(
|
|
55
|
+
expect(document.querySelector('dialog')).not.toHaveAttribute('open');
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
it('closes the panel when Escape is pressed', async () => {
|
|
@@ -53,7 +64,7 @@ describe('Popover', () => {
|
|
|
53
64
|
);
|
|
54
65
|
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
55
66
|
await user.keyboard('{Escape}');
|
|
56
|
-
expect(
|
|
67
|
+
expect(document.querySelector('dialog')).not.toHaveAttribute('open');
|
|
57
68
|
});
|
|
58
69
|
|
|
59
70
|
it('sets aria-expanded on the trigger when open', async () => {
|
|
@@ -69,6 +80,21 @@ describe('Popover', () => {
|
|
|
69
80
|
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
70
81
|
});
|
|
71
82
|
|
|
83
|
+
it('sets anchor-name on the trigger and position-anchor on the panel', () => {
|
|
84
|
+
render(
|
|
85
|
+
<Popover content={<p>Panel</p>}>
|
|
86
|
+
<button type="button">Open</button>
|
|
87
|
+
</Popover>
|
|
88
|
+
);
|
|
89
|
+
const trigger = screen.getByRole('button');
|
|
90
|
+
const dialog = document.querySelector('dialog')!;
|
|
91
|
+
// Both should reference the same CSS anchor ident.
|
|
92
|
+
const anchorName = trigger.style.getPropertyValue('anchor-name');
|
|
93
|
+
const positionAnchor = dialog.style.getPropertyValue('position-anchor');
|
|
94
|
+
expect(anchorName).toMatch(/^--popover-/);
|
|
95
|
+
expect(positionAnchor).toBe(anchorName);
|
|
96
|
+
});
|
|
97
|
+
|
|
72
98
|
it('renders role="region" with aria-label when aria-label prop is provided', async () => {
|
|
73
99
|
const user = userEvent.setup();
|
|
74
100
|
render(
|
|
@@ -90,4 +116,42 @@ describe('Popover', () => {
|
|
|
90
116
|
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
91
117
|
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
92
118
|
});
|
|
119
|
+
|
|
120
|
+
it('constrains maxHeight when trigger is near the bottom of the viewport', async () => {
|
|
121
|
+
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
|
+
render(
|
|
129
|
+
<Popover content={<p>Panel</p>} placement="bottom">
|
|
130
|
+
<button type="button">Open</button>
|
|
131
|
+
</Popover>
|
|
132
|
+
);
|
|
133
|
+
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
134
|
+
const dialog = document.querySelector('dialog')!;
|
|
135
|
+
// max-height = vh - rect.bottom - GAP * 2 = 600 - 550 - 16 = 34
|
|
136
|
+
expect(dialog.style.maxHeight).toBe('34px');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('constrains maxWidth when trigger is near the right edge of the viewport', async () => {
|
|
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
|
+
});
|
|
147
|
+
render(
|
|
148
|
+
<Popover content={<p>Panel</p>} placement="bottom">
|
|
149
|
+
<button type="button">Open</button>
|
|
150
|
+
</Popover>
|
|
151
|
+
);
|
|
152
|
+
await user.click(screen.getByRole('button', { name: 'Open' }));
|
|
153
|
+
const dialog = document.querySelector('dialog')!;
|
|
154
|
+
// max-width = vw - rect.left - GAP = 400 - 350 - 8 = 42
|
|
155
|
+
expect(dialog.style.maxWidth).toBe('42px');
|
|
156
|
+
});
|
|
93
157
|
});
|
|
@@ -3,7 +3,7 @@ import { Popover } from './Popover';
|
|
|
3
3
|
import { Button } from '../Button/Button';
|
|
4
4
|
|
|
5
5
|
const meta = {
|
|
6
|
-
title: 'Interaction/Popover',
|
|
6
|
+
title: 'React/Interaction/Popover',
|
|
7
7
|
component: Popover,
|
|
8
8
|
argTypes: {
|
|
9
9
|
placement: { control: 'radio', options: ['top', 'bottom', 'left', 'right'] },
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ReactNode,
|
|
4
4
|
cloneElement,
|
|
5
5
|
isValidElement,
|
|
6
|
+
useCallback,
|
|
6
7
|
useEffect,
|
|
7
8
|
useId,
|
|
8
9
|
useRef,
|
|
@@ -10,12 +11,23 @@ import {
|
|
|
10
11
|
} from 'react';
|
|
11
12
|
import css from './Popover.module.css';
|
|
12
13
|
import { cn } from '@boostdev/design-system-foundation';
|
|
14
|
+
import type { WithClassName } from '../../../types';
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
const GAP = 8; // --bds-space_xs
|
|
17
|
+
|
|
18
|
+
// Load the CSS Anchor Positioning polyfill once, only in browsers that need it.
|
|
19
|
+
// The CSS.supports guard is defensive: older environments may not expose it at all.
|
|
20
|
+
if (
|
|
21
|
+
typeof window !== 'undefined' &&
|
|
22
|
+
!(typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('anchor-name: --a'))
|
|
23
|
+
) {
|
|
24
|
+
import('@oddbird/css-anchor-positioning').catch(() => {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PopoverProps extends WithClassName {
|
|
15
28
|
children: ReactElement;
|
|
16
29
|
content: ReactNode;
|
|
17
30
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
18
|
-
className?: string;
|
|
19
31
|
'aria-label'?: string;
|
|
20
32
|
}
|
|
21
33
|
|
|
@@ -28,35 +40,77 @@ export function Popover({
|
|
|
28
40
|
}: Readonly<PopoverProps>) {
|
|
29
41
|
const [isOpen, setIsOpen] = useState(false);
|
|
30
42
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
31
|
-
const
|
|
43
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
44
|
+
const rawId = useId();
|
|
45
|
+
// CSS anchor-name must be a valid custom-ident: strip non-word characters.
|
|
46
|
+
const anchorName = `--popover-${rawId.replace(/\W/g, '') || 'a'}`;
|
|
47
|
+
const panelId = rawId;
|
|
32
48
|
|
|
49
|
+
// Light-dismiss: close on outside click or Escape key.
|
|
33
50
|
useEffect(() => {
|
|
34
51
|
if (!isOpen) return;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!containerRef.current?.contains(e.target as Node)) {
|
|
52
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
53
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
38
54
|
setIsOpen(false);
|
|
39
55
|
}
|
|
40
56
|
};
|
|
41
57
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
42
58
|
if (e.key === 'Escape') setIsOpen(false);
|
|
43
59
|
};
|
|
44
|
-
|
|
45
|
-
document.addEventListener('pointerdown', handlePointerDown);
|
|
60
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
46
61
|
document.addEventListener('keydown', handleKeyDown);
|
|
47
62
|
return () => {
|
|
48
|
-
document.removeEventListener('
|
|
63
|
+
document.removeEventListener('mousedown', handleMouseDown);
|
|
49
64
|
document.removeEventListener('keydown', handleKeyDown);
|
|
50
65
|
};
|
|
51
66
|
}, [isOpen]);
|
|
52
67
|
|
|
68
|
+
// CSS anchor positioning handles placement. JS only constrains max-height/max-width
|
|
69
|
+
// so tall content scrolls rather than overflows the viewport.
|
|
70
|
+
const constrainPanel = useCallback(() => {
|
|
71
|
+
const dialog = dialogRef.current;
|
|
72
|
+
const container = containerRef.current;
|
|
73
|
+
if (!dialog || !container) return;
|
|
74
|
+
const rect = container.getBoundingClientRect();
|
|
75
|
+
const vw = window.innerWidth;
|
|
76
|
+
const vh = window.innerHeight;
|
|
77
|
+
dialog.style.maxHeight = '';
|
|
78
|
+
dialog.style.maxWidth = '';
|
|
79
|
+
if (placement === 'bottom') {
|
|
80
|
+
dialog.style.maxHeight = `${Math.max(0, vh - rect.bottom - GAP * 2)}px`;
|
|
81
|
+
dialog.style.maxWidth = `${Math.max(0, vw - rect.left - GAP)}px`;
|
|
82
|
+
} else if (placement === 'top') {
|
|
83
|
+
dialog.style.maxHeight = `${Math.max(0, rect.top - GAP * 2)}px`;
|
|
84
|
+
dialog.style.maxWidth = `${Math.max(0, vw - rect.left - GAP)}px`;
|
|
85
|
+
} else if (placement === 'right') {
|
|
86
|
+
dialog.style.maxHeight = `${Math.max(0, vh - rect.top - GAP)}px`;
|
|
87
|
+
dialog.style.maxWidth = `${Math.max(0, vw - rect.right - GAP * 2)}px`;
|
|
88
|
+
} else {
|
|
89
|
+
dialog.style.maxHeight = `${Math.max(0, vh - rect.top - GAP)}px`;
|
|
90
|
+
dialog.style.maxWidth = `${Math.max(0, rect.left - GAP * 2)}px`;
|
|
91
|
+
}
|
|
92
|
+
}, [placement]);
|
|
93
|
+
|
|
94
|
+
const toggle = useCallback(() => {
|
|
95
|
+
if (isOpen) {
|
|
96
|
+
setIsOpen(false);
|
|
97
|
+
} else {
|
|
98
|
+
constrainPanel();
|
|
99
|
+
setIsOpen(true);
|
|
100
|
+
}
|
|
101
|
+
}, [isOpen, constrainPanel]);
|
|
102
|
+
|
|
53
103
|
const trigger = isValidElement(children)
|
|
54
104
|
? cloneElement(children as ReactElement<Record<string, unknown>>, {
|
|
55
105
|
'aria-expanded': isOpen,
|
|
56
106
|
'aria-controls': panelId,
|
|
57
107
|
'aria-haspopup': true,
|
|
108
|
+
style: {
|
|
109
|
+
...((children.props as Record<string, unknown>).style as object | undefined),
|
|
110
|
+
anchorName,
|
|
111
|
+
},
|
|
58
112
|
onClick: (e: MouseEvent) => {
|
|
59
|
-
|
|
113
|
+
toggle();
|
|
60
114
|
const existingOnClick = (children.props as Record<string, unknown>).onClick;
|
|
61
115
|
if (typeof existingOnClick === 'function') existingOnClick(e);
|
|
62
116
|
},
|
|
@@ -66,16 +120,18 @@ export function Popover({
|
|
|
66
120
|
return (
|
|
67
121
|
<span ref={containerRef} className={cn(css.wrapper, className)}>
|
|
68
122
|
{trigger}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
123
|
+
<dialog
|
|
124
|
+
ref={dialogRef}
|
|
125
|
+
id={panelId}
|
|
126
|
+
open={isOpen}
|
|
127
|
+
data-placement={placement}
|
|
128
|
+
role={ariaLabel ? 'region' : undefined}
|
|
129
|
+
aria-label={ariaLabel}
|
|
130
|
+
className={css.panel}
|
|
131
|
+
style={{ positionAnchor: anchorName }}
|
|
132
|
+
>
|
|
133
|
+
{content}
|
|
134
|
+
</dialog>
|
|
79
135
|
</span>
|
|
80
136
|
);
|
|
81
137
|
}
|
|
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
|
|
2
2
|
import { Rating } from './Rating';
|
|
3
3
|
|
|
4
4
|
const meta = {
|
|
5
|
-
title: 'Interaction/Rating',
|
|
5
|
+
title: 'React/Interaction/Rating',
|
|
6
6
|
component: Rating,
|
|
7
7
|
argTypes: {
|
|
8
8
|
value: { control: { type: 'range', min: 0, max: 5, step: 1 } },
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import css from './Rating.module.css';
|
|
2
2
|
import { cn } from '@boostdev/design-system-foundation';
|
|
3
|
+
import type { WithClassName } from '../../../types';
|
|
3
4
|
|
|
4
|
-
interface RatingProps {
|
|
5
|
+
interface RatingProps extends WithClassName {
|
|
5
6
|
value: number;
|
|
6
7
|
max?: number;
|
|
7
|
-
className?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function Rating({ value, max = 5, className }: RatingProps) {
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
@layer component {
|
|
2
2
|
.toastContainer {
|
|
3
3
|
position: fixed;
|
|
4
|
+
inset: unset; /* override UA popover default (inset: 0) */
|
|
4
5
|
bottom: var(--bds-space_xl);
|
|
5
6
|
right: var(--bds-space_xl);
|
|
6
7
|
display: flex;
|
|
7
8
|
flex-direction: column;
|
|
8
9
|
gap: var(--bds-space_m);
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
/* z-index removed — top layer renders above all fixed/sticky elements */
|
|
12
|
+
border: none; /* override UA popover default */
|
|
13
|
+
padding: 0; /* override UA popover default */
|
|
14
|
+
background: transparent; /* override UA popover default */
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
.toast {
|
|
@@ -32,6 +32,27 @@ describe('Toast', () => {
|
|
|
32
32
|
vi.useRealTimers();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('renders the toast container as a manual popover', () => {
|
|
36
|
+
render(
|
|
37
|
+
<ToastProvider>
|
|
38
|
+
<div />
|
|
39
|
+
</ToastProvider>
|
|
40
|
+
);
|
|
41
|
+
const container = document.querySelector('[popover]');
|
|
42
|
+
expect(container).toHaveAttribute('popover', 'manual');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders the toast container as a labelled notifications region', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ToastProvider>
|
|
48
|
+
<div />
|
|
49
|
+
</ToastProvider>
|
|
50
|
+
);
|
|
51
|
+
const container = document.querySelector('[popover="manual"]');
|
|
52
|
+
expect(container).toHaveAttribute('role', 'region');
|
|
53
|
+
expect(container).toHaveAttribute('aria-label', 'Notifications');
|
|
54
|
+
});
|
|
55
|
+
|
|
35
56
|
it('throws when useToast is used outside ToastProvider', () => {
|
|
36
57
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
37
58
|
const Bad = () => { useToast(); return null; };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useMemo } from 'react';
|
|
1
|
+
import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useMemo, useRef } from 'react';
|
|
2
2
|
import css from './Toast.module.css';
|
|
3
3
|
import { cn } from '@boostdev/design-system-foundation';
|
|
4
4
|
|
|
@@ -18,6 +18,7 @@ const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
|
18
18
|
|
|
19
19
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
20
20
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
21
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
21
22
|
|
|
22
23
|
const showToast = useCallback((message: string, variant: ToastVariant) => {
|
|
23
24
|
const id = Math.random().toString(36).substring(2, 9);
|
|
@@ -28,12 +29,31 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
28
29
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
|
29
30
|
}, []);
|
|
30
31
|
|
|
32
|
+
// Sync the popover's top-layer visibility with the toast queue.
|
|
33
|
+
// showPopover() is a no-op when already open (safe to call multiple times).
|
|
34
|
+
// hidePopover() throws InvalidStateError when already hidden, so catch silently.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const container = containerRef.current;
|
|
37
|
+
if (!container) return;
|
|
38
|
+
if (toasts.length > 0) {
|
|
39
|
+
container.showPopover?.();
|
|
40
|
+
} else {
|
|
41
|
+
try { container.hidePopover?.(); } catch { /* already hidden on initial mount */ }
|
|
42
|
+
}
|
|
43
|
+
}, [toasts.length]);
|
|
44
|
+
|
|
31
45
|
const value = useMemo(() => ({ showToast }), [showToast]);
|
|
32
46
|
|
|
33
47
|
return (
|
|
34
48
|
<ToastContext.Provider value={value}>
|
|
35
49
|
{children}
|
|
36
|
-
<div
|
|
50
|
+
<div
|
|
51
|
+
ref={containerRef}
|
|
52
|
+
popover="manual"
|
|
53
|
+
role="region"
|
|
54
|
+
aria-label="Notifications"
|
|
55
|
+
className={css.toastContainer}
|
|
56
|
+
>
|
|
37
57
|
{toasts.map(toast => (
|
|
38
58
|
<ToastItem
|
|
39
59
|
key={toast.id}
|
|
@@ -44,8 +44,8 @@ Binary toggle for opt-in/opt-out choices. Renders a styled native `<input type="
|
|
|
44
44
|
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
|
|
45
45
|
</thead>
|
|
46
46
|
<tbody>
|
|
47
|
-
<tr><td>`--checkbox_color-active`</td><td>`var(--
|
|
48
|
-
<tr><td>`--checkbox_color-on-active`</td><td>`var(--
|
|
47
|
+
<tr><td>`--checkbox_color-active`</td><td>`var(--bdc-color_active)`</td><td>Checked background</td></tr>
|
|
48
|
+
<tr><td>`--checkbox_color-on-active`</td><td>`var(--bdc-color_on-active)`</td><td>Checkmark colour</td></tr>
|
|
49
49
|
</tbody>
|
|
50
50
|
</table>
|
|
51
51
|
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
.checkbox:checked {
|
|
36
|
-
background-color: var(--checkbox_color-active, var(--
|
|
37
|
-
--bdc_color: var(--checkbox_color-active, var(--
|
|
36
|
+
background-color: var(--checkbox_color-active, var(--bdc-color_active));
|
|
37
|
+
--bdc_color: var(--checkbox_color-active, var(--bdc-color_active));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
.checkbox:checked::after {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
top: 0.2em;
|
|
45
45
|
width: 0.5em;
|
|
46
46
|
height: 1em;
|
|
47
|
-
border: solid var(--checkbox_color-on-active, var(--
|
|
47
|
+
border: solid var(--checkbox_color-on-active, var(--bdc-color_on-active));
|
|
48
48
|
border-width: 0 2px 2px 0;
|
|
49
49
|
transform: rotate(45deg);
|
|
50
50
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import { Checkbox } from './Checkbox.native';
|
|
4
|
+
|
|
5
|
+
function renderWithTheme(ui: React.ReactElement) {
|
|
6
|
+
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Checkbox (native)', () => {
|
|
10
|
+
it('has checkbox role with label', () => {
|
|
11
|
+
renderWithTheme(<Checkbox label="Accept terms" name="terms" />);
|
|
12
|
+
expect(screen.getByRole('checkbox', { name: 'Accept terms' })).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shows the checkmark when checked', () => {
|
|
16
|
+
renderWithTheme(<Checkbox label="Accept terms" name="terms" checked={true} />);
|
|
17
|
+
expect(screen.getByText('✓')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('hides the checkmark when unchecked', () => {
|
|
21
|
+
renderWithTheme(<Checkbox label="Accept terms" name="terms" checked={false} />);
|
|
22
|
+
expect(screen.queryByText('✓')).not.toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('checkmark uses theme color (contrast regression: was hardcoded #fff)', () => {
|
|
26
|
+
// Previously the checkmark had color: '#fff' hardcoded in the StyleSheet.
|
|
27
|
+
// Now it uses colors.colorOnInteractive from the theme, which is #000 in dark
|
|
28
|
+
// mode — fixing the 2.52:1 contrast failure on the dark interactive blue.
|
|
29
|
+
renderWithTheme(<Checkbox label="Accept terms" name="terms" checked={true} />);
|
|
30
|
+
const checkmark = screen.getByText('✓');
|
|
31
|
+
// The color must be applied as an inline style (overrides StyleSheet), not absent.
|
|
32
|
+
// In light mode colorOnInteractive=#fff; in dark mode it is #000.
|
|
33
|
+
expect(checkmark.style.color).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders with error message', () => {
|
|
37
|
+
renderWithTheme(
|
|
38
|
+
<Checkbox label="Accept terms" name="terms" error="You must accept." />,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByRole('alert')).toHaveTextContent('You must accept.');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
4
|
+
import { Checkbox } from './Checkbox.native';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
tags: ['!stable', 'alpha'],
|
|
8
|
+
title: 'React Native/Form/Checkbox',
|
|
9
|
+
component: Checkbox,
|
|
10
|
+
decorators: [(Story) => <ThemeProvider><Story /></ThemeProvider>],
|
|
11
|
+
parameters: { layout: 'padded' },
|
|
12
|
+
} satisfies Meta<typeof Checkbox>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Unchecked: Story = { args: { label: 'Accept terms', name: 'terms', checked: false } };
|
|
18
|
+
export const Checked: Story = { args: { label: 'Accept terms', name: 'terms', checked: true } };
|
|
19
|
+
export const Disabled: Story = { args: { label: 'Accept terms', name: 'terms', disabled: true } };
|
|
20
|
+
export const DisabledChecked: Story = { args: { label: 'Accept terms', name: 'terms', checked: true, disabled: true } };
|
|
21
|
+
export const WithError: Story = {
|
|
22
|
+
args: { label: 'Accept terms', name: 'terms', error: 'You must accept the terms.' },
|
|
23
|
+
};
|
|
24
|
+
export const WithHint: Story = {
|
|
25
|
+
args: { label: 'Subscribe to newsletter', name: 'newsletter', hint: 'Max one email per week.' },
|
|
26
|
+
};
|
|
27
|
+
export const Interactive: Story = {
|
|
28
|
+
render: () => {
|
|
29
|
+
const [checked, setChecked] = useState(false);
|
|
30
|
+
return (
|
|
31
|
+
<ThemeProvider>
|
|
32
|
+
<Checkbox label="Accept terms and conditions" name="terms" checked={checked} onChange={setChecked} />
|
|
33
|
+
</ThemeProvider>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { StyleSheet, View, Pressable, StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import { spacing, border } from '../../../../native/tokens';
|
|
4
|
+
import { useTheme } from '../../../../native/ThemeContext';
|
|
5
|
+
import { Label } from '../atoms/Label.native';
|
|
6
|
+
import { Message } from '../atoms/Message.native';
|
|
7
|
+
|
|
8
|
+
interface CheckboxProps {
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
name: string;
|
|
11
|
+
checked?: boolean;
|
|
12
|
+
onChange?: (checked: boolean) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BOX = 22;
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
container: {
|
|
23
|
+
gap: spacing.xxs,
|
|
24
|
+
},
|
|
25
|
+
row: {
|
|
26
|
+
flexDirection: 'row',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: spacing.xs,
|
|
29
|
+
minHeight: 44,
|
|
30
|
+
},
|
|
31
|
+
box: {
|
|
32
|
+
width: BOX,
|
|
33
|
+
height: BOX,
|
|
34
|
+
borderRadius: border.radius.xs,
|
|
35
|
+
borderWidth: 2,
|
|
36
|
+
alignItems: 'center',
|
|
37
|
+
justifyContent: 'center',
|
|
38
|
+
},
|
|
39
|
+
checkmark: {
|
|
40
|
+
fontSize: 14,
|
|
41
|
+
fontWeight: '700',
|
|
42
|
+
lineHeight: 16,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function Checkbox({
|
|
47
|
+
label,
|
|
48
|
+
name,
|
|
49
|
+
checked = false,
|
|
50
|
+
onChange,
|
|
51
|
+
disabled = false,
|
|
52
|
+
error,
|
|
53
|
+
hint,
|
|
54
|
+
style,
|
|
55
|
+
}: CheckboxProps) {
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
|
|
58
|
+
const borderColor = error ? colors.colorError : checked ? colors.colorInteractive : colors.colorBgSubtle;
|
|
59
|
+
const bgColor = checked ? colors.colorInteractive : 'transparent';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<View style={[styles.container, style]}>
|
|
63
|
+
<Pressable
|
|
64
|
+
onPress={() => !disabled && onChange?.(!checked)}
|
|
65
|
+
accessibilityRole="checkbox"
|
|
66
|
+
accessibilityLabel={typeof label === 'string' ? label : name}
|
|
67
|
+
accessibilityState={{ checked, disabled }}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
style={({ pressed }) => [styles.row, pressed && { opacity: 0.7 }, disabled && { opacity: 0.4 }]}
|
|
70
|
+
>
|
|
71
|
+
<View style={[styles.box, { borderColor, backgroundColor: bgColor }]}>
|
|
72
|
+
{checked && <Label label="✓" style={[styles.checkmark, { color: colors.colorOnInteractive }]} />}
|
|
73
|
+
</View>
|
|
74
|
+
<Label label={label} />
|
|
75
|
+
</Pressable>
|
|
76
|
+
<Message type="error" message={error} />
|
|
77
|
+
<Message type="hint" message={hint} />
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -4,13 +4,13 @@ import { Message } from '../atoms/Message';
|
|
|
4
4
|
import { Label } from '../atoms/Label';
|
|
5
5
|
import { cn } from '@boostdev/design-system-foundation';
|
|
6
6
|
import { InputContainer } from '../atoms/InputContainer';
|
|
7
|
+
import type { WithClassName } from '../../../../types';
|
|
7
8
|
|
|
8
|
-
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
9
|
+
interface CheckboxProps extends WithClassName, Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
9
10
|
label: ReactNode;
|
|
10
11
|
name: string;
|
|
11
12
|
error?: string;
|
|
12
13
|
hint?: string;
|
|
13
|
-
className?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function Checkbox({ label, name, error, hint, className, ...props }: CheckboxProps) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
2
|
+
import * as Stories from './CheckboxGroup.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# CheckboxGroup
|
|
7
|
+
|
|
8
|
+
Groups multiple `Checkbox` inputs under a shared label. Renders a `<fieldset>` with a `<legend>` for accessible grouping.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
- Multi-select option sets where several choices can be active simultaneously
|
|
12
|
+
- Any time two or more checkboxes share a common label or question
|
|
13
|
+
|
|
14
|
+
## When not to use
|
|
15
|
+
- A single independent toggle — use `Checkbox` on its own
|
|
16
|
+
- Mutually exclusive choices — use `RadioGroup`
|
|
17
|
+
|
|
18
|
+
## Examples
|
|
19
|
+
|
|
20
|
+
### Default
|
|
21
|
+
<Canvas of={Stories.Default} />
|
|
22
|
+
|
|
23
|
+
### With error
|
|
24
|
+
<Canvas of={Stories.WithError} />
|
|
25
|
+
|
|
26
|
+
### With hint
|
|
27
|
+
<Canvas of={Stories.WithHint} />
|
|
28
|
+
|
|
29
|
+
### Required
|
|
30
|
+
<Canvas of={Stories.Required} />
|
|
31
|
+
|
|
32
|
+
### Disabled
|
|
33
|
+
<Canvas of={Stories.Disabled} />
|
|
34
|
+
|
|
35
|
+
## Props
|
|
36
|
+
|
|
37
|
+
<ArgTypes of={Stories} />
|
|
38
|
+
|
|
39
|
+
## Accessibility
|
|
40
|
+
|
|
41
|
+
- Wraps checkboxes in a `<fieldset>` with a `<legend>` — screen readers announce the group label before each option
|
|
42
|
+
- `disabled` on the group sets the HTML `disabled` attribute on the `<fieldset>`, which disables all child inputs
|
|
43
|
+
- `aria-required` is set on the `<fieldset>` when `required` is true
|
|
44
|
+
- Error and hint messages are linked via `aria-describedby`
|