@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.
Files changed (225) hide show
  1. package/AGENTS.md +5 -5
  2. package/dist/client.cjs +236 -153
  3. package/dist/client.css +547 -483
  4. package/dist/client.d.cts +53 -94
  5. package/dist/client.d.ts +53 -94
  6. package/dist/client.js +237 -163
  7. package/dist/index.cjs +236 -153
  8. package/dist/index.css +547 -483
  9. package/dist/index.d.cts +53 -94
  10. package/dist/index.d.ts +53 -94
  11. package/dist/index.js +237 -163
  12. package/dist/native/index.cjs +991 -0
  13. package/dist/native/index.d.cts +208 -0
  14. package/dist/native/index.d.ts +208 -0
  15. package/dist/native/index.js +968 -0
  16. package/dist/web-components.d.ts +303 -0
  17. package/dist/web-components.js +1968 -0
  18. package/package.json +27 -5
  19. package/src/components/interaction/Button/Button.module.css +10 -7
  20. package/src/components/interaction/Button/Button.native.spec.tsx +35 -0
  21. package/src/components/interaction/Button/Button.native.stories.tsx +42 -0
  22. package/src/components/interaction/Button/Button.native.tsx +95 -0
  23. package/src/components/interaction/Button/Button.stories.tsx +1 -1
  24. package/src/components/interaction/Button/Button.tsx +2 -2
  25. package/src/components/interaction/Command/Command.mdx +16 -0
  26. package/src/components/interaction/Command/Command.stories.tsx +1 -1
  27. package/src/components/interaction/Command/Command.tsx +2 -2
  28. package/src/components/interaction/Dialog/Dialog.stories.tsx +1 -1
  29. package/src/components/interaction/Dialog/Dialog.tsx +2 -2
  30. package/src/components/interaction/Drawer/Drawer.mdx +14 -0
  31. package/src/components/interaction/Drawer/Drawer.stories.tsx +1 -1
  32. package/src/components/interaction/Drawer/Drawer.tsx +2 -2
  33. package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +1 -1
  34. package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +2 -2
  35. package/src/components/interaction/Popover/Popover.mdx +17 -0
  36. package/src/components/interaction/Popover/Popover.module.css +70 -13
  37. package/src/components/interaction/Popover/Popover.spec.tsx +68 -4
  38. package/src/components/interaction/Popover/Popover.stories.tsx +1 -1
  39. package/src/components/interaction/Popover/Popover.tsx +76 -20
  40. package/src/components/interaction/Rating/Rating.stories.tsx +1 -1
  41. package/src/components/interaction/Rating/Rating.tsx +2 -2
  42. package/src/components/interaction/Toast/Toast.module.css +6 -1
  43. package/src/components/interaction/Toast/Toast.spec.tsx +21 -0
  44. package/src/components/interaction/Toast/Toast.stories.tsx +1 -1
  45. package/src/components/interaction/Toast/Toast.tsx +22 -2
  46. package/src/components/interaction/form/Checkbox/Checkbox.mdx +2 -2
  47. package/src/components/interaction/form/Checkbox/Checkbox.module.css +3 -3
  48. package/src/components/interaction/form/Checkbox/Checkbox.native.spec.tsx +42 -0
  49. package/src/components/interaction/form/Checkbox/Checkbox.native.stories.tsx +36 -0
  50. package/src/components/interaction/form/Checkbox/Checkbox.native.tsx +80 -0
  51. package/src/components/interaction/form/Checkbox/Checkbox.stories.tsx +1 -1
  52. package/src/components/interaction/form/Checkbox/Checkbox.tsx +2 -2
  53. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.mdx +44 -0
  54. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.stories.tsx +1 -1
  55. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.tsx +2 -2
  56. package/src/components/interaction/form/Combobox/Combobox.mdx +21 -0
  57. package/src/components/interaction/form/Combobox/Combobox.module.css +1 -1
  58. package/src/components/interaction/form/Combobox/Combobox.stories.tsx +1 -1
  59. package/src/components/interaction/form/Combobox/Combobox.tsx +2 -2
  60. package/src/components/interaction/form/FileInput/FileInput.mdx +14 -0
  61. package/src/components/interaction/form/FileInput/FileInput.module.css +1 -1
  62. package/src/components/interaction/form/FileInput/FileInput.stories.tsx +1 -1
  63. package/src/components/interaction/form/FileInput/FileInput.tsx +2 -2
  64. package/src/components/interaction/form/FormInput/FormInput.stories.tsx +1 -1
  65. package/src/components/interaction/form/FormInput/FormInput.tsx +2 -2
  66. package/src/components/interaction/form/NumberInput/NumberInput.mdx +16 -0
  67. package/src/components/interaction/form/NumberInput/NumberInput.stories.tsx +1 -1
  68. package/src/components/interaction/form/NumberInput/NumberInput.tsx +2 -2
  69. package/src/components/interaction/form/Radio/Radio.mdx +3 -3
  70. package/src/components/interaction/form/Radio/Radio.module.css +3 -3
  71. package/src/components/interaction/form/Radio/Radio.native.stories.tsx +46 -0
  72. package/src/components/interaction/form/Radio/Radio.native.tsx +79 -0
  73. package/src/components/interaction/form/Radio/Radio.stories.tsx +1 -1
  74. package/src/components/interaction/form/Radio/Radio.tsx +2 -2
  75. package/src/components/interaction/form/RadioGroup/RadioGroup.mdx +45 -0
  76. package/src/components/interaction/form/RadioGroup/RadioGroup.stories.tsx +1 -1
  77. package/src/components/interaction/form/RadioGroup/RadioGroup.tsx +2 -2
  78. package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +1 -1
  79. package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +2 -2
  80. package/src/components/interaction/form/Select/Select.stories.tsx +1 -1
  81. package/src/components/interaction/form/Select/Select.tsx +2 -2
  82. package/src/components/interaction/form/Slider/Slider.mdx +1 -1
  83. package/src/components/interaction/form/Slider/Slider.module.css +4 -4
  84. package/src/components/interaction/form/Slider/Slider.stories.tsx +1 -1
  85. package/src/components/interaction/form/Slider/Slider.tsx +2 -2
  86. package/src/components/interaction/form/Switch/Switch.mdx +4 -4
  87. package/src/components/interaction/form/Switch/Switch.module.css +3 -3
  88. package/src/components/interaction/form/Switch/Switch.native.spec.tsx +60 -0
  89. package/src/components/interaction/form/Switch/Switch.native.stories.tsx +35 -0
  90. package/src/components/interaction/form/Switch/Switch.native.tsx +59 -0
  91. package/src/components/interaction/form/Switch/Switch.stories.tsx +1 -1
  92. package/src/components/interaction/form/Switch/Switch.tsx +2 -2
  93. package/src/components/interaction/form/Textarea/Textarea.mdx +17 -0
  94. package/src/components/interaction/form/Textarea/Textarea.stories.tsx +1 -1
  95. package/src/components/interaction/form/Textarea/Textarea.tsx +2 -2
  96. package/src/components/interaction/form/atoms/InputContainer.tsx +2 -1
  97. package/src/components/interaction/form/atoms/Label.native.stories.tsx +18 -0
  98. package/src/components/interaction/form/atoms/Label.native.tsx +29 -0
  99. package/src/components/interaction/form/atoms/Label.tsx +5 -3
  100. package/src/components/interaction/form/atoms/Message.native.spec.tsx +30 -0
  101. package/src/components/interaction/form/atoms/Message.native.stories.tsx +22 -0
  102. package/src/components/interaction/form/atoms/Message.native.tsx +34 -0
  103. package/src/components/interaction/form/atoms/Message.tsx +5 -3
  104. package/src/components/layout/ButtonGroup/ButtonGroup.native.stories.tsx +50 -0
  105. package/src/components/layout/ButtonGroup/ButtonGroup.native.tsx +34 -0
  106. package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +1 -1
  107. package/src/components/layout/ButtonGroup/ButtonGroup.tsx +2 -2
  108. package/src/components/layout/Card/Card.native.stories.tsx +53 -0
  109. package/src/components/layout/Card/Card.native.tsx +89 -0
  110. package/src/components/layout/Card/Card.stories.tsx +1 -1
  111. package/src/components/layout/Card/Card.tsx +2 -2
  112. package/src/components/layout/IconWrapper/IconWrapper.mdx +2 -2
  113. package/src/components/layout/IconWrapper/IconWrapper.native.spec.tsx +39 -0
  114. package/src/components/layout/IconWrapper/IconWrapper.native.stories.tsx +41 -0
  115. package/src/components/layout/IconWrapper/IconWrapper.native.tsx +20 -0
  116. package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +1 -1
  117. package/src/components/layout/IconWrapper/IconWrapper.tsx +2 -2
  118. package/src/components/layout/SectionHeader/SectionHeader.mdx +13 -0
  119. package/src/components/layout/SectionHeader/SectionHeader.native.stories.tsx +38 -0
  120. package/src/components/layout/SectionHeader/SectionHeader.native.tsx +79 -0
  121. package/src/components/layout/SectionHeader/SectionHeader.stories.tsx +1 -1
  122. package/src/components/layout/SectionHeader/SectionHeader.tsx +2 -2
  123. package/src/components/ui/Accordion/Accordion.mdx +14 -0
  124. package/src/components/ui/Accordion/Accordion.stories.tsx +1 -1
  125. package/src/components/ui/Accordion/Accordion.tsx +2 -2
  126. package/src/components/ui/Alert/Alert.native.stories.tsx +43 -0
  127. package/src/components/ui/Alert/Alert.native.tsx +94 -0
  128. package/src/components/ui/Alert/Alert.stories.tsx +1 -1
  129. package/src/components/ui/Alert/Alert.tsx +2 -2
  130. package/src/components/ui/Avatar/Avatar.native.spec.tsx +47 -0
  131. package/src/components/ui/Avatar/Avatar.native.stories.tsx +34 -0
  132. package/src/components/ui/Avatar/Avatar.native.tsx +85 -0
  133. package/src/components/ui/Avatar/Avatar.stories.tsx +1 -1
  134. package/src/components/ui/Avatar/Avatar.tsx +2 -2
  135. package/src/components/ui/Badge/Badge.mdx +2 -2
  136. package/src/components/ui/Badge/Badge.native.stories.tsx +36 -0
  137. package/src/components/ui/Badge/Badge.native.tsx +50 -0
  138. package/src/components/ui/Badge/Badge.stories.tsx +1 -1
  139. package/src/components/ui/Badge/Badge.tsx +2 -2
  140. package/src/components/ui/Breadcrumb/Breadcrumb.stories.tsx +1 -1
  141. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +2 -2
  142. package/src/components/ui/Calendar/Calendar.mdx +16 -0
  143. package/src/components/ui/Calendar/Calendar.stories.tsx +1 -1
  144. package/src/components/ui/Calendar/Calendar.tsx +2 -2
  145. package/src/components/ui/Carousel/Carousel.stories.tsx +1 -1
  146. package/src/components/ui/Carousel/Carousel.tsx +2 -2
  147. package/src/components/ui/Collapsible/Collapsible.stories.tsx +1 -1
  148. package/src/components/ui/Collapsible/Collapsible.tsx +2 -2
  149. package/src/components/ui/DescriptionList/DescriptionList.stories.tsx +1 -1
  150. package/src/components/ui/DescriptionList/DescriptionList.tsx +2 -2
  151. package/src/components/ui/Link/Link.mdx +14 -0
  152. package/src/components/ui/Link/Link.stories.tsx +1 -1
  153. package/src/components/ui/Link/Link.tsx +2 -2
  154. package/src/components/ui/Loading/Loading.native.spec.tsx +24 -0
  155. package/src/components/ui/Loading/Loading.native.stories.tsx +33 -0
  156. package/src/components/ui/Loading/Loading.native.tsx +29 -0
  157. package/src/components/ui/Loading/Loading.stories.tsx +1 -1
  158. package/src/components/ui/Loading/Loading.tsx +2 -2
  159. package/src/components/ui/NotificationBanner/NotificationBanner.native.stories.tsx +39 -0
  160. package/src/components/ui/NotificationBanner/NotificationBanner.native.tsx +76 -0
  161. package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +1 -1
  162. package/src/components/ui/NotificationBanner/NotificationBanner.tsx +2 -2
  163. package/src/components/ui/Pagination/Pagination.module.css +1 -1
  164. package/src/components/ui/Pagination/Pagination.stories.tsx +1 -1
  165. package/src/components/ui/Pagination/Pagination.tsx +2 -2
  166. package/src/components/ui/Progress/Progress.mdx +1 -1
  167. package/src/components/ui/Progress/Progress.module.css +1 -1
  168. package/src/components/ui/Progress/Progress.native.stories.tsx +34 -0
  169. package/src/components/ui/Progress/Progress.native.tsx +84 -0
  170. package/src/components/ui/Progress/Progress.stories.tsx +1 -1
  171. package/src/components/ui/Progress/Progress.tsx +2 -2
  172. package/src/components/ui/ProgressCircle/ProgressCircle.module.css +1 -1
  173. package/src/components/ui/ProgressCircle/ProgressCircle.stories.tsx +1 -1
  174. package/src/components/ui/ProgressCircle/ProgressCircle.tsx +2 -2
  175. package/src/components/ui/Separator/Separator.mdx +14 -0
  176. package/src/components/ui/Separator/Separator.native.stories.tsx +42 -0
  177. package/src/components/ui/Separator/Separator.native.tsx +32 -0
  178. package/src/components/ui/Separator/Separator.stories.tsx +1 -1
  179. package/src/components/ui/Separator/Separator.tsx +2 -2
  180. package/src/components/ui/Skeleton/Skeleton.native.stories.tsx +33 -0
  181. package/src/components/ui/Skeleton/Skeleton.native.tsx +41 -0
  182. package/src/components/ui/Skeleton/Skeleton.stories.tsx +1 -1
  183. package/src/components/ui/Skeleton/Skeleton.tsx +2 -3
  184. package/src/components/ui/SkipLink/SkipLink.stories.tsx +1 -1
  185. package/src/components/ui/SkipLink/SkipLink.tsx +5 -3
  186. package/src/components/ui/Table/Table.mdx +14 -0
  187. package/src/components/ui/Table/Table.stories.tsx +1 -1
  188. package/src/components/ui/Table/Table.tsx +2 -2
  189. package/src/components/ui/Tabs/Tabs.stories.tsx +1 -1
  190. package/src/components/ui/Tabs/Tabs.tsx +2 -2
  191. package/src/components/ui/Tooltip/Tooltip.mdx +14 -0
  192. package/src/components/ui/Tooltip/Tooltip.stories.tsx +1 -1
  193. package/src/components/ui/Tooltip/Tooltip.tsx +2 -2
  194. package/src/components/ui/Typography/Typography.mdx +13 -0
  195. package/src/components/ui/Typography/Typography.native.stories.tsx +38 -0
  196. package/src/components/ui/Typography/Typography.native.tsx +65 -0
  197. package/src/components/ui/Typography/Typography.stories.tsx +1 -1
  198. package/src/components/ui/Typography/Typography.tsx +2 -2
  199. package/src/css/bdc.css +8 -0
  200. package/src/index.ts +1 -0
  201. package/src/native/ThemeContext.tsx +28 -0
  202. package/src/native/tokens.ts +13 -0
  203. package/src/native.ts +39 -0
  204. package/src/react-augment.d.ts +13 -0
  205. package/src/stories/DesignSystem/DarkMode.mdx +130 -0
  206. package/src/types.ts +2 -0
  207. package/src/typings.d.ts +3 -0
  208. package/src/web-components/globals.ts +61 -0
  209. package/src/web-components/index.ts +12 -0
  210. package/src/web-components/interaction/BdsButton.stories.tsx +60 -0
  211. package/src/web-components/interaction/BdsPopover.stories.tsx +70 -0
  212. package/src/web-components/interaction/BdsToastProvider.stories.tsx +73 -0
  213. package/src/web-components/interaction/bds-button.spec.ts +95 -0
  214. package/src/web-components/interaction/bds-button.ts +293 -0
  215. package/src/web-components/interaction/bds-popover.spec.ts +126 -0
  216. package/src/web-components/interaction/bds-popover.ts +217 -0
  217. package/src/web-components/interaction/bds-toast-provider.spec.ts +122 -0
  218. package/src/web-components/interaction/bds-toast-provider.ts +211 -0
  219. package/src/web-components/test/helpers.ts +14 -0
  220. package/src/web-components/ui/BdsAlert.stories.tsx +60 -0
  221. package/src/web-components/ui/BdsBadge.stories.tsx +37 -0
  222. package/src/web-components/ui/bds-alert.spec.ts +109 -0
  223. package/src/web-components/ui/bds-alert.ts +209 -0
  224. package/src/web-components/ui/bds-badge.spec.ts +51 -0
  225. 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
- expect(screen.queryByText('Panel content')).not.toBeInTheDocument();
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(screen.getByText('Panel content')).toBeInTheDocument();
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(screen.queryByText('Panel content')).not.toBeInTheDocument();
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(screen.queryByText('Panel content')).not.toBeInTheDocument();
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
- interface PopoverProps {
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 panelId = useId();
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
- const handlePointerDown = (e: PointerEvent) => {
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('pointerdown', handlePointerDown);
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
- setIsOpen(prev => !prev);
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
- {isOpen && (
70
- <div
71
- id={panelId}
72
- role={ariaLabel ? 'region' : undefined}
73
- aria-label={ariaLabel}
74
- className={cn(css.panel, css[`--placement_${placement}`])}
75
- >
76
- {content}
77
- </div>
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
- z-index: var(--bds-z-index_toast);
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; };
@@ -3,7 +3,7 @@ import { ToastProvider, useToast } from './Toast';
3
3
  import { Button } from '../Button/Button';
4
4
 
5
5
  const meta = {
6
- title: 'Interaction/Toast',
6
+ title: 'React/Interaction/Toast',
7
7
  component: ToastProvider,
8
8
  } satisfies Meta<typeof ToastProvider>;
9
9
 
@@ -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 className={css.toastContainer}>
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(--bds-active)`</td><td>Checked background</td></tr>
48
- <tr><td>`--checkbox_color-on-active`</td><td>`var(--bds-on-active)`</td><td>Checkmark colour</td></tr>
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(--bds-color_green));
37
- --bdc_color: var(--checkbox_color-active, var(--bds-color_green));
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(--bds-color_on-green));
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
+ }
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { Checkbox } from './Checkbox';
3
3
 
4
4
  const meta = {
5
- title: 'Form/Checkbox',
5
+ title: 'React/Form/Checkbox',
6
6
  component: Checkbox,
7
7
  } satisfies Meta<typeof Checkbox>;
8
8
 
@@ -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`