@boostdev/design-system-components 0.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.
Files changed (233) hide show
  1. package/AGENTS.md +72 -0
  2. package/README.md +396 -0
  3. package/dist/index.cjs +2273 -0
  4. package/dist/index.css +2543 -0
  5. package/dist/index.d.cts +453 -0
  6. package/dist/index.d.ts +453 -0
  7. package/dist/index.js +2221 -0
  8. package/package.json +143 -0
  9. package/src/components/interaction/Button/Button.module.css +136 -0
  10. package/src/components/interaction/Button/Button.spec.tsx +50 -0
  11. package/src/components/interaction/Button/Button.stories.tsx +43 -0
  12. package/src/components/interaction/Button/Button.tsx +68 -0
  13. package/src/components/interaction/Button/index.ts +1 -0
  14. package/src/components/interaction/Command/Command.module.css +128 -0
  15. package/src/components/interaction/Command/Command.spec.tsx +60 -0
  16. package/src/components/interaction/Command/Command.stories.tsx +35 -0
  17. package/src/components/interaction/Command/Command.tsx +161 -0
  18. package/src/components/interaction/Command/index.ts +2 -0
  19. package/src/components/interaction/Dialog/Dialog.module.css +39 -0
  20. package/src/components/interaction/Dialog/Dialog.spec.tsx +43 -0
  21. package/src/components/interaction/Dialog/Dialog.stories.tsx +36 -0
  22. package/src/components/interaction/Dialog/Dialog.tsx +42 -0
  23. package/src/components/interaction/Dialog/index.ts +1 -0
  24. package/src/components/interaction/Drawer/Drawer.module.css +98 -0
  25. package/src/components/interaction/Drawer/Drawer.spec.tsx +43 -0
  26. package/src/components/interaction/Drawer/Drawer.stories.tsx +46 -0
  27. package/src/components/interaction/Drawer/Drawer.tsx +71 -0
  28. package/src/components/interaction/Drawer/index.ts +1 -0
  29. package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +68 -0
  30. package/src/components/interaction/DropdownMenu/DropdownMenu.spec.tsx +74 -0
  31. package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +68 -0
  32. package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +137 -0
  33. package/src/components/interaction/DropdownMenu/index.ts +1 -0
  34. package/src/components/interaction/Popover/Popover.module.css +39 -0
  35. package/src/components/interaction/Popover/Popover.spec.tsx +72 -0
  36. package/src/components/interaction/Popover/Popover.stories.tsx +47 -0
  37. package/src/components/interaction/Popover/Popover.tsx +78 -0
  38. package/src/components/interaction/Popover/index.ts +1 -0
  39. package/src/components/interaction/Rating/Rating.module.css +16 -0
  40. package/src/components/interaction/Rating/Rating.spec.tsx +30 -0
  41. package/src/components/interaction/Rating/Rating.stories.tsx +29 -0
  42. package/src/components/interaction/Rating/Rating.tsx +30 -0
  43. package/src/components/interaction/Rating/index.ts +1 -0
  44. package/src/components/interaction/Toast/Toast.module.css +48 -0
  45. package/src/components/interaction/Toast/Toast.spec.tsx +41 -0
  46. package/src/components/interaction/Toast/Toast.stories.tsx +57 -0
  47. package/src/components/interaction/Toast/Toast.tsx +64 -0
  48. package/src/components/interaction/Toast/index.ts +1 -0
  49. package/src/components/interaction/form/Checkbox/Checkbox.module.css +61 -0
  50. package/src/components/interaction/form/Checkbox/Checkbox.spec.tsx +39 -0
  51. package/src/components/interaction/form/Checkbox/Checkbox.stories.tsx +17 -0
  52. package/src/components/interaction/form/Checkbox/Checkbox.tsx +39 -0
  53. package/src/components/interaction/form/Checkbox/index.ts +1 -0
  54. package/src/components/interaction/form/Combobox/Combobox.module.css +104 -0
  55. package/src/components/interaction/form/Combobox/Combobox.spec.tsx +81 -0
  56. package/src/components/interaction/form/Combobox/Combobox.stories.tsx +25 -0
  57. package/src/components/interaction/form/Combobox/Combobox.tsx +182 -0
  58. package/src/components/interaction/form/Combobox/index.ts +1 -0
  59. package/src/components/interaction/form/FileInput/FileInput.module.css +79 -0
  60. package/src/components/interaction/form/FileInput/FileInput.spec.tsx +53 -0
  61. package/src/components/interaction/form/FileInput/FileInput.stories.tsx +17 -0
  62. package/src/components/interaction/form/FileInput/FileInput.tsx +99 -0
  63. package/src/components/interaction/form/FileInput/index.ts +1 -0
  64. package/src/components/interaction/form/FormInput/FormInput.module.css +37 -0
  65. package/src/components/interaction/form/FormInput/FormInput.spec.tsx +43 -0
  66. package/src/components/interaction/form/FormInput/FormInput.stories.tsx +17 -0
  67. package/src/components/interaction/form/FormInput/FormInput.tsx +47 -0
  68. package/src/components/interaction/form/FormInput/index.ts +1 -0
  69. package/src/components/interaction/form/NumberInput/NumberInput.module.css +78 -0
  70. package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +49 -0
  71. package/src/components/interaction/form/NumberInput/NumberInput.stories.tsx +17 -0
  72. package/src/components/interaction/form/NumberInput/NumberInput.tsx +106 -0
  73. package/src/components/interaction/form/NumberInput/index.ts +1 -0
  74. package/src/components/interaction/form/Radio/Radio.module.css +62 -0
  75. package/src/components/interaction/form/Radio/Radio.spec.tsx +38 -0
  76. package/src/components/interaction/form/Radio/Radio.stories.tsx +26 -0
  77. package/src/components/interaction/form/Radio/Radio.tsx +39 -0
  78. package/src/components/interaction/form/Radio/index.ts +1 -0
  79. package/src/components/interaction/form/Select/Select.module.css +64 -0
  80. package/src/components/interaction/form/Select/Select.spec.tsx +61 -0
  81. package/src/components/interaction/form/Select/Select.stories.tsx +24 -0
  82. package/src/components/interaction/form/Select/Select.tsx +72 -0
  83. package/src/components/interaction/form/Select/index.ts +1 -0
  84. package/src/components/interaction/form/Slider/Slider.module.css +99 -0
  85. package/src/components/interaction/form/Slider/Slider.spec.tsx +53 -0
  86. package/src/components/interaction/form/Slider/Slider.stories.tsx +18 -0
  87. package/src/components/interaction/form/Slider/Slider.tsx +71 -0
  88. package/src/components/interaction/form/Slider/index.ts +1 -0
  89. package/src/components/interaction/form/Switch/Switch.module.css +114 -0
  90. package/src/components/interaction/form/Switch/Switch.spec.tsx +48 -0
  91. package/src/components/interaction/form/Switch/Switch.stories.tsx +31 -0
  92. package/src/components/interaction/form/Switch/Switch.tsx +54 -0
  93. package/src/components/interaction/form/Switch/index.ts +1 -0
  94. package/src/components/interaction/form/Textarea/Textarea.module.css +44 -0
  95. package/src/components/interaction/form/Textarea/Textarea.spec.tsx +53 -0
  96. package/src/components/interaction/form/Textarea/Textarea.stories.tsx +18 -0
  97. package/src/components/interaction/form/Textarea/Textarea.tsx +44 -0
  98. package/src/components/interaction/form/Textarea/index.ts +1 -0
  99. package/src/components/interaction/form/atoms/InputContainer.module.css +9 -0
  100. package/src/components/interaction/form/atoms/InputContainer.tsx +9 -0
  101. package/src/components/interaction/form/atoms/Label.module.css +10 -0
  102. package/src/components/interaction/form/atoms/Label.tsx +15 -0
  103. package/src/components/interaction/form/atoms/Message.module.css +11 -0
  104. package/src/components/interaction/form/atoms/Message.tsx +17 -0
  105. package/src/components/layout/ButtonGroup/ButtonGroup.module.css +59 -0
  106. package/src/components/layout/ButtonGroup/ButtonGroup.spec.tsx +20 -0
  107. package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +28 -0
  108. package/src/components/layout/ButtonGroup/ButtonGroup.tsx +17 -0
  109. package/src/components/layout/ButtonGroup/index.ts +1 -0
  110. package/src/components/layout/Card/Card.module.css +72 -0
  111. package/src/components/layout/Card/Card.spec.tsx +33 -0
  112. package/src/components/layout/Card/Card.stories.tsx +32 -0
  113. package/src/components/layout/Card/Card.tsx +45 -0
  114. package/src/components/layout/Card/index.ts +1 -0
  115. package/src/components/layout/IconWrapper/IconWrapper.module.css +24 -0
  116. package/src/components/layout/IconWrapper/IconWrapper.spec.tsx +19 -0
  117. package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +22 -0
  118. package/src/components/layout/IconWrapper/IconWrapper.tsx +14 -0
  119. package/src/components/layout/IconWrapper/index.ts +1 -0
  120. package/src/components/layout/SectionHeader/SectionHeader.module.css +75 -0
  121. package/src/components/layout/SectionHeader/SectionHeader.spec.tsx +31 -0
  122. package/src/components/layout/SectionHeader/SectionHeader.stories.tsx +21 -0
  123. package/src/components/layout/SectionHeader/SectionHeader.tsx +32 -0
  124. package/src/components/layout/SectionHeader/index.ts +1 -0
  125. package/src/components/ui/Accordion/Accordion.module.css +87 -0
  126. package/src/components/ui/Accordion/Accordion.spec.tsx +78 -0
  127. package/src/components/ui/Accordion/Accordion.stories.tsx +34 -0
  128. package/src/components/ui/Accordion/Accordion.tsx +82 -0
  129. package/src/components/ui/Accordion/index.ts +1 -0
  130. package/src/components/ui/Alert/Alert.module.css +91 -0
  131. package/src/components/ui/Alert/Alert.spec.tsx +63 -0
  132. package/src/components/ui/Alert/Alert.stories.tsx +53 -0
  133. package/src/components/ui/Alert/Alert.tsx +54 -0
  134. package/src/components/ui/Alert/index.ts +1 -0
  135. package/src/components/ui/Avatar/Avatar.module.css +42 -0
  136. package/src/components/ui/Avatar/Avatar.spec.tsx +49 -0
  137. package/src/components/ui/Avatar/Avatar.stories.tsx +44 -0
  138. package/src/components/ui/Avatar/Avatar.tsx +45 -0
  139. package/src/components/ui/Avatar/index.ts +1 -0
  140. package/src/components/ui/Badge/Badge.module.css +46 -0
  141. package/src/components/ui/Badge/Badge.spec.tsx +19 -0
  142. package/src/components/ui/Badge/Badge.stories.tsx +29 -0
  143. package/src/components/ui/Badge/Badge.tsx +13 -0
  144. package/src/components/ui/Badge/index.ts +1 -0
  145. package/src/components/ui/Breadcrumb/Breadcrumb.module.css +50 -0
  146. package/src/components/ui/Breadcrumb/Breadcrumb.spec.tsx +44 -0
  147. package/src/components/ui/Breadcrumb/Breadcrumb.stories.tsx +48 -0
  148. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +41 -0
  149. package/src/components/ui/Breadcrumb/index.ts +1 -0
  150. package/src/components/ui/Calendar/Calendar.module.css +120 -0
  151. package/src/components/ui/Calendar/Calendar.spec.tsx +64 -0
  152. package/src/components/ui/Calendar/Calendar.stories.tsx +59 -0
  153. package/src/components/ui/Calendar/Calendar.tsx +184 -0
  154. package/src/components/ui/Calendar/index.ts +1 -0
  155. package/src/components/ui/Carousel/Carousel.module.css +66 -0
  156. package/src/components/ui/Carousel/Carousel.spec.tsx +29 -0
  157. package/src/components/ui/Carousel/Carousel.stories.tsx +30 -0
  158. package/src/components/ui/Carousel/Carousel.tsx +64 -0
  159. package/src/components/ui/Carousel/index.ts +1 -0
  160. package/src/components/ui/DescriptionList/DescriptionList.module.css +43 -0
  161. package/src/components/ui/DescriptionList/DescriptionList.spec.tsx +31 -0
  162. package/src/components/ui/DescriptionList/DescriptionList.stories.tsx +21 -0
  163. package/src/components/ui/DescriptionList/DescriptionList.tsx +30 -0
  164. package/src/components/ui/DescriptionList/index.ts +1 -0
  165. package/src/components/ui/Link/Link.module.css +64 -0
  166. package/src/components/ui/Link/Link.spec.tsx +43 -0
  167. package/src/components/ui/Link/Link.stories.tsx +55 -0
  168. package/src/components/ui/Link/Link.tsx +42 -0
  169. package/src/components/ui/Link/index.ts +1 -0
  170. package/src/components/ui/Loading/Loading.module.css +33 -0
  171. package/src/components/ui/Loading/Loading.spec.tsx +19 -0
  172. package/src/components/ui/Loading/Loading.stories.tsx +27 -0
  173. package/src/components/ui/Loading/Loading.tsx +15 -0
  174. package/src/components/ui/Loading/index.ts +1 -0
  175. package/src/components/ui/NotificationBanner/NotificationBanner.module.css +79 -0
  176. package/src/components/ui/NotificationBanner/NotificationBanner.spec.tsx +42 -0
  177. package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +30 -0
  178. package/src/components/ui/NotificationBanner/NotificationBanner.tsx +45 -0
  179. package/src/components/ui/NotificationBanner/index.ts +1 -0
  180. package/src/components/ui/Pagination/Pagination.module.css +78 -0
  181. package/src/components/ui/Pagination/Pagination.spec.tsx +67 -0
  182. package/src/components/ui/Pagination/Pagination.stories.tsx +40 -0
  183. package/src/components/ui/Pagination/Pagination.tsx +87 -0
  184. package/src/components/ui/Pagination/index.ts +1 -0
  185. package/src/components/ui/Progress/Progress.module.css +51 -0
  186. package/src/components/ui/Progress/Progress.spec.tsx +55 -0
  187. package/src/components/ui/Progress/Progress.stories.tsx +30 -0
  188. package/src/components/ui/Progress/Progress.tsx +43 -0
  189. package/src/components/ui/Progress/index.ts +1 -0
  190. package/src/components/ui/ProgressCircle/ProgressCircle.module.css +40 -0
  191. package/src/components/ui/ProgressCircle/ProgressCircle.spec.tsx +34 -0
  192. package/src/components/ui/ProgressCircle/ProgressCircle.stories.tsx +18 -0
  193. package/src/components/ui/ProgressCircle/ProgressCircle.tsx +75 -0
  194. package/src/components/ui/ProgressCircle/index.ts +1 -0
  195. package/src/components/ui/Separator/Separator.module.css +23 -0
  196. package/src/components/ui/Separator/Separator.spec.tsx +30 -0
  197. package/src/components/ui/Separator/Separator.stories.tsx +40 -0
  198. package/src/components/ui/Separator/Separator.tsx +21 -0
  199. package/src/components/ui/Separator/index.ts +1 -0
  200. package/src/components/ui/Skeleton/Skeleton.module.css +24 -0
  201. package/src/components/ui/Skeleton/Skeleton.spec.tsx +19 -0
  202. package/src/components/ui/Skeleton/Skeleton.stories.tsx +25 -0
  203. package/src/components/ui/Skeleton/Skeleton.tsx +12 -0
  204. package/src/components/ui/Skeleton/index.ts +1 -0
  205. package/src/components/ui/SkipLink/SkipLink.module.css +30 -0
  206. package/src/components/ui/SkipLink/SkipLink.spec.tsx +24 -0
  207. package/src/components/ui/SkipLink/SkipLink.stories.tsx +24 -0
  208. package/src/components/ui/SkipLink/SkipLink.tsx +14 -0
  209. package/src/components/ui/SkipLink/index.ts +1 -0
  210. package/src/components/ui/Table/Table.module.css +111 -0
  211. package/src/components/ui/Table/Table.spec.tsx +69 -0
  212. package/src/components/ui/Table/Table.stories.tsx +53 -0
  213. package/src/components/ui/Table/Table.tsx +98 -0
  214. package/src/components/ui/Table/index.ts +1 -0
  215. package/src/components/ui/Tabs/Tabs.module.css +61 -0
  216. package/src/components/ui/Tabs/Tabs.spec.tsx +91 -0
  217. package/src/components/ui/Tabs/Tabs.stories.tsx +59 -0
  218. package/src/components/ui/Tabs/Tabs.tsx +100 -0
  219. package/src/components/ui/Tabs/index.ts +1 -0
  220. package/src/components/ui/Tooltip/Tooltip.module.css +69 -0
  221. package/src/components/ui/Tooltip/Tooltip.spec.tsx +46 -0
  222. package/src/components/ui/Tooltip/Tooltip.stories.tsx +69 -0
  223. package/src/components/ui/Tooltip/Tooltip.tsx +38 -0
  224. package/src/components/ui/Tooltip/index.ts +1 -0
  225. package/src/components/ui/Typography/Typography.module.css +41 -0
  226. package/src/components/ui/Typography/Typography.spec.tsx +39 -0
  227. package/src/components/ui/Typography/Typography.stories.tsx +31 -0
  228. package/src/components/ui/Typography/Typography.tsx +28 -0
  229. package/src/components/ui/Typography/index.ts +1 -0
  230. package/src/css/index.css +55 -0
  231. package/src/index.ts +54 -0
  232. package/src/test/setup.ts +1 -0
  233. package/src/typings.d.ts +4 -0
@@ -0,0 +1,74 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { DropdownMenu } from './DropdownMenu';
4
+
5
+ const items = [
6
+ { id: 'edit', label: 'Edit', onClick: vi.fn() },
7
+ { id: 'duplicate', label: 'Duplicate', onClick: vi.fn() },
8
+ { id: 'delete', label: 'Delete', onClick: vi.fn(), disabled: true },
9
+ ];
10
+
11
+ describe('DropdownMenu', () => {
12
+ it('renders the trigger', () => {
13
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
14
+ expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
15
+ });
16
+
17
+ it('does not show the menu by default', () => {
18
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
19
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
20
+ });
21
+
22
+ it('shows the menu when trigger is clicked', async () => {
23
+ const user = userEvent.setup();
24
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
25
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
26
+ expect(screen.getByRole('menu')).toBeInTheDocument();
27
+ });
28
+
29
+ it('renders all menu items', async () => {
30
+ const user = userEvent.setup();
31
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
32
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
33
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument();
34
+ expect(screen.getByRole('menuitem', { name: 'Duplicate' })).toBeInTheDocument();
35
+ });
36
+
37
+ it('calls onClick and closes the menu when a menu item is clicked', async () => {
38
+ const onClick = vi.fn();
39
+ const user = userEvent.setup();
40
+ render(
41
+ <DropdownMenu
42
+ trigger={<button type="button">Actions</button>}
43
+ items={[{ id: 'edit', label: 'Edit', onClick }]}
44
+ />
45
+ );
46
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
47
+ await user.click(screen.getByRole('menuitem', { name: 'Edit' }));
48
+ expect(onClick).toHaveBeenCalledOnce();
49
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
50
+ });
51
+
52
+ it('disables a menu item when disabled is set', async () => {
53
+ const user = userEvent.setup();
54
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
55
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
56
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeDisabled();
57
+ });
58
+
59
+ it('closes the menu when Escape is pressed', async () => {
60
+ const user = userEvent.setup();
61
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
62
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
63
+ await user.keyboard('{Escape}');
64
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
65
+ });
66
+
67
+ it('navigates items with arrow keys', async () => {
68
+ const user = userEvent.setup();
69
+ render(<DropdownMenu trigger={<button type="button">Actions</button>} items={items} />);
70
+ await user.click(screen.getByRole('button', { name: 'Actions' }));
71
+ await user.keyboard('{ArrowDown}');
72
+ expect(screen.getByRole('menuitem', { name: 'Duplicate' })).toHaveFocus();
73
+ });
74
+ });
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { DropdownMenu } from './DropdownMenu';
3
+
4
+ const meta = {
5
+ title: 'Interaction/DropdownMenu',
6
+ component: DropdownMenu,
7
+ tags: ['autodocs'],
8
+ decorators: [
9
+ Story => (
10
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '2rem' }}>
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ } satisfies Meta<typeof DropdownMenu>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Default: Story = {
21
+ args: {
22
+ items: [
23
+ { id: 'edit', label: 'Edit' },
24
+ { id: 'duplicate', label: 'Duplicate' },
25
+ { id: 'delete', label: 'Delete' },
26
+ ],
27
+ },
28
+ render: args => (
29
+ <DropdownMenu {...args} trigger={<button type="button">Actions ▾</button>} />
30
+ ),
31
+ };
32
+
33
+ export const WithDisabledItem: Story = {
34
+ render: () => (
35
+ <DropdownMenu
36
+ trigger={<button type="button">Options ▾</button>}
37
+ items={[
38
+ { id: 'view', label: 'View' },
39
+ { id: 'edit', label: 'Edit' },
40
+ { id: 'delete', label: 'Delete (unavailable)', disabled: true },
41
+ ]}
42
+ />
43
+ ),
44
+ };
45
+
46
+ export const WithSeparator: Story = {
47
+ render: () => (
48
+ <DropdownMenu
49
+ trigger={<button type="button">File ▾</button>}
50
+ items={[
51
+ { id: 'new', label: 'New' },
52
+ { id: 'open', label: 'Open' },
53
+ { id: 'save', label: 'Save', separator: true },
54
+ { id: 'export', label: 'Export' },
55
+ ]}
56
+ />
57
+ ),
58
+ };
59
+
60
+ export const AlignedEnd: Story = {
61
+ render: () => (
62
+ <DropdownMenu
63
+ trigger={<button type="button">⋮</button>}
64
+ items={[{ id: 'copy', label: 'Copy link' }, { id: 'report', label: 'Report' }]}
65
+ placement="bottom-end"
66
+ />
67
+ ),
68
+ };
@@ -0,0 +1,137 @@
1
+ import {
2
+ KeyboardEvent,
3
+ ReactElement,
4
+ cloneElement,
5
+ isValidElement,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import css from './DropdownMenu.module.css';
12
+ import { cn } from '@boostdev/design-system-foundation';
13
+
14
+ interface DropdownMenuItem {
15
+ id: string;
16
+ label: string;
17
+ onClick?: () => void;
18
+ disabled?: boolean;
19
+ icon?: ReactElement;
20
+ separator?: boolean;
21
+ }
22
+
23
+ interface DropdownMenuProps {
24
+ trigger: ReactElement;
25
+ items: DropdownMenuItem[];
26
+ placement?: 'bottom-start' | 'bottom-end';
27
+ className?: string;
28
+ }
29
+
30
+ export function DropdownMenu({
31
+ trigger,
32
+ items,
33
+ placement = 'bottom-start',
34
+ className,
35
+ }: Readonly<DropdownMenuProps>) {
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const containerRef = useRef<HTMLDivElement>(null);
38
+ const menuId = useId();
39
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
40
+ const open = () => {
41
+ setIsOpen(true);
42
+ };
43
+
44
+ const close = () => setIsOpen(false);
45
+
46
+ useEffect(() => {
47
+ if (isOpen) {
48
+ itemRefs.current[0]?.focus();
49
+ }
50
+ }, [isOpen]);
51
+
52
+ useEffect(() => {
53
+ if (!isOpen) return;
54
+
55
+ const handlePointerDown = (e: PointerEvent) => {
56
+ if (!containerRef.current?.contains(e.target as Node)) close();
57
+ };
58
+ const handleKeyDown = (e: globalThis.KeyboardEvent) => {
59
+ if (e.key === 'Escape') close();
60
+ };
61
+
62
+ document.addEventListener('pointerdown', handlePointerDown);
63
+ document.addEventListener('keydown', handleKeyDown);
64
+ return () => {
65
+ document.removeEventListener('pointerdown', handlePointerDown);
66
+ document.removeEventListener('keydown', handleKeyDown);
67
+ };
68
+ }, [isOpen]);
69
+
70
+ const handleItemKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
71
+ const enabledIndexes = items
72
+ .map((item, i) => ({ item, i }))
73
+ .filter(({ item }) => !item.disabled)
74
+ .map(({ i }) => i);
75
+ const pos = enabledIndexes.indexOf(index);
76
+
77
+ if (e.key === 'ArrowDown') {
78
+ e.preventDefault();
79
+ itemRefs.current[enabledIndexes[(pos + 1) % enabledIndexes.length]]?.focus();
80
+ } else if (e.key === 'ArrowUp') {
81
+ e.preventDefault();
82
+ itemRefs.current[enabledIndexes[(pos - 1 + enabledIndexes.length) % enabledIndexes.length]]?.focus();
83
+ } else if (e.key === 'Home') {
84
+ e.preventDefault();
85
+ itemRefs.current[enabledIndexes[0]]?.focus();
86
+ } else if (e.key === 'End') {
87
+ e.preventDefault();
88
+ itemRefs.current[enabledIndexes[enabledIndexes.length - 1]]?.focus();
89
+ } else if (e.key === 'Tab') {
90
+ close();
91
+ }
92
+ };
93
+
94
+ const triggerEl = isValidElement(trigger)
95
+ ? cloneElement(trigger as ReactElement<Record<string, unknown>>, {
96
+ 'aria-haspopup': 'menu',
97
+ 'aria-expanded': isOpen,
98
+ 'aria-controls': menuId,
99
+ onClick: (e: MouseEvent) => {
100
+ if (isOpen) { close(); } else { open(); }
101
+ const existingOnClick = (trigger.props as Record<string, unknown>).onClick;
102
+ if (typeof existingOnClick === 'function') existingOnClick(e);
103
+ },
104
+ })
105
+ : trigger;
106
+
107
+ return (
108
+ <div ref={containerRef} className={cn(css.wrapper, className)}>
109
+ {triggerEl}
110
+ {isOpen && (
111
+ <ul
112
+ id={menuId}
113
+ role="menu"
114
+ className={cn(css.menu, css[`--placement_${placement}`])}
115
+ >
116
+ {items.map((item, index) => (
117
+ <li key={item.id} role="presentation">
118
+ {item.separator && <hr className={css.separator} role="separator" />}
119
+ <button
120
+ ref={el => { itemRefs.current[index] = el; }}
121
+ type="button"
122
+ role="menuitem"
123
+ disabled={item.disabled}
124
+ className={css.item}
125
+ onClick={() => { item.onClick?.(); close(); }}
126
+ onKeyDown={e => handleItemKeyDown(e, index)}
127
+ >
128
+ {item.icon && <span className={css.icon} aria-hidden="true">{item.icon}</span>}
129
+ {item.label}
130
+ </button>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ )}
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1 @@
1
+ export { DropdownMenu } from './DropdownMenu';
@@ -0,0 +1,39 @@
1
+ @layer component {
2
+ .wrapper {
3
+ position: relative;
4
+ display: inline-flex;
5
+ }
6
+
7
+ .panel {
8
+ position: absolute;
9
+ z-index: var(--z-index_popover);
10
+ min-width: 12rem;
11
+ padding: var(--space_m);
12
+ border-radius: var(--border_radius--s);
13
+ border: 1px solid var(--color_bg--subtle);
14
+ background-color: var(--color_bg);
15
+ box-shadow: var(--shadow_m);
16
+ color: var(--color_on-bg);
17
+ font-size: var(--font_size--body);
18
+ }
19
+
20
+ .--placement_bottom {
21
+ top: calc(100% + var(--space_xs));
22
+ left: 0;
23
+ }
24
+
25
+ .--placement_top {
26
+ bottom: calc(100% + var(--space_xs));
27
+ left: 0;
28
+ }
29
+
30
+ .--placement_right {
31
+ left: calc(100% + var(--space_xs));
32
+ top: 0;
33
+ }
34
+
35
+ .--placement_left {
36
+ right: calc(100% + var(--space_xs));
37
+ top: 0;
38
+ }
39
+ }
@@ -0,0 +1,72 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { Popover } from './Popover';
4
+
5
+ describe('Popover', () => {
6
+ it('renders the trigger', () => {
7
+ render(
8
+ <Popover content={<p>Panel content</p>}>
9
+ <button type="button">Open</button>
10
+ </Popover>
11
+ );
12
+ expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
13
+ });
14
+
15
+ it('does not show the panel by default', () => {
16
+ render(
17
+ <Popover content={<p>Panel content</p>}>
18
+ <button type="button">Open</button>
19
+ </Popover>
20
+ );
21
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
22
+ });
23
+
24
+ it('shows the panel when trigger is clicked', async () => {
25
+ const user = userEvent.setup();
26
+ render(
27
+ <Popover content={<p>Panel content</p>}>
28
+ <button type="button">Open</button>
29
+ </Popover>
30
+ );
31
+ await user.click(screen.getByRole('button', { name: 'Open' }));
32
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
33
+ expect(screen.getByText('Panel content')).toBeInTheDocument();
34
+ });
35
+
36
+ it('closes the panel when trigger is clicked again', async () => {
37
+ const user = userEvent.setup();
38
+ render(
39
+ <Popover content={<p>Panel content</p>}>
40
+ <button type="button">Toggle</button>
41
+ </Popover>
42
+ );
43
+ await user.click(screen.getByRole('button', { name: 'Toggle' }));
44
+ await user.click(screen.getByRole('button', { name: 'Toggle' }));
45
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
46
+ });
47
+
48
+ it('closes the panel when Escape is pressed', async () => {
49
+ const user = userEvent.setup();
50
+ render(
51
+ <Popover content={<p>Panel content</p>}>
52
+ <button type="button">Open</button>
53
+ </Popover>
54
+ );
55
+ await user.click(screen.getByRole('button', { name: 'Open' }));
56
+ await user.keyboard('{Escape}');
57
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
58
+ });
59
+
60
+ it('sets aria-expanded on the trigger when open', async () => {
61
+ const user = userEvent.setup();
62
+ render(
63
+ <Popover content={<p>Panel</p>}>
64
+ <button type="button">Open</button>
65
+ </Popover>
66
+ );
67
+ const trigger = screen.getByRole('button');
68
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
69
+ await user.click(trigger);
70
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
71
+ });
72
+ });
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Popover } from './Popover';
3
+
4
+ const meta = {
5
+ title: 'Interaction/Popover',
6
+ component: Popover,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ placement: { control: 'radio', options: ['top', 'bottom', 'left', 'right'] },
10
+ },
11
+ decorators: [
12
+ Story => (
13
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '6rem' }}>
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ } satisfies Meta<typeof Popover>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const Default: Story = {
24
+ args: { placement: 'bottom', content: <p style={{ margin: 0 }}>This is a popover with rich content.</p> },
25
+ render: args => (
26
+ <Popover {...args}>
27
+ <button type="button">Open popover</button>
28
+ </Popover>
29
+ ),
30
+ };
31
+
32
+ export const WithForm: Story = {
33
+ render: () => (
34
+ <Popover
35
+ placement="bottom"
36
+ content={
37
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
38
+ <strong>Filter options</strong>
39
+ <label><input type="checkbox" /> Show archived</label>
40
+ <label><input type="checkbox" /> Show drafts</label>
41
+ </div>
42
+ }
43
+ >
44
+ <button type="button">Filters</button>
45
+ </Popover>
46
+ ),
47
+ };
@@ -0,0 +1,78 @@
1
+ import {
2
+ ReactElement,
3
+ ReactNode,
4
+ cloneElement,
5
+ isValidElement,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import css from './Popover.module.css';
12
+ import { cn } from '@boostdev/design-system-foundation';
13
+
14
+ interface PopoverProps {
15
+ children: ReactElement;
16
+ content: ReactNode;
17
+ placement?: 'top' | 'bottom' | 'left' | 'right';
18
+ className?: string;
19
+ }
20
+
21
+ export function Popover({
22
+ children,
23
+ content,
24
+ placement = 'bottom',
25
+ className,
26
+ }: Readonly<PopoverProps>) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const containerRef = useRef<HTMLSpanElement>(null);
29
+ const panelId = useId();
30
+
31
+ useEffect(() => {
32
+ if (!isOpen) return;
33
+
34
+ const handlePointerDown = (e: PointerEvent) => {
35
+ if (!containerRef.current?.contains(e.target as Node)) {
36
+ setIsOpen(false);
37
+ }
38
+ };
39
+ const handleKeyDown = (e: KeyboardEvent) => {
40
+ if (e.key === 'Escape') setIsOpen(false);
41
+ };
42
+
43
+ document.addEventListener('pointerdown', handlePointerDown);
44
+ document.addEventListener('keydown', handleKeyDown);
45
+ return () => {
46
+ document.removeEventListener('pointerdown', handlePointerDown);
47
+ document.removeEventListener('keydown', handleKeyDown);
48
+ };
49
+ }, [isOpen]);
50
+
51
+ const trigger = isValidElement(children)
52
+ ? cloneElement(children as ReactElement<Record<string, unknown>>, {
53
+ 'aria-expanded': isOpen,
54
+ 'aria-controls': panelId,
55
+ onClick: (e: MouseEvent) => {
56
+ setIsOpen(prev => !prev);
57
+ const existingOnClick = (children.props as Record<string, unknown>).onClick;
58
+ if (typeof existingOnClick === 'function') existingOnClick(e);
59
+ },
60
+ })
61
+ : children;
62
+
63
+ return (
64
+ <span ref={containerRef} className={cn(css.wrapper, className)}>
65
+ {trigger}
66
+ {isOpen && (
67
+ <div
68
+ id={panelId}
69
+ role="dialog"
70
+ aria-modal="false"
71
+ className={cn(css.panel, css[`--placement_${placement}`])}
72
+ >
73
+ {content}
74
+ </div>
75
+ )}
76
+ </span>
77
+ );
78
+ }
@@ -0,0 +1 @@
1
+ export { Popover } from './Popover';
@@ -0,0 +1,16 @@
1
+ @layer component {
2
+ .rating {
3
+ display: inline-flex;
4
+ gap: var(--space_xxxs);
5
+ color: var(--color_bg);
6
+ }
7
+
8
+ .star {
9
+ width: 1.5em;
10
+ height: 1.5em;
11
+ }
12
+
13
+ .--filled {
14
+ color: var(--color_warning);
15
+ }
16
+ }
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { Rating } from './Rating';
3
+
4
+ describe('Rating', () => {
5
+ it('renders with role img', () => {
6
+ render(<Rating value={3} />);
7
+ expect(screen.getByRole('img')).toBeInTheDocument();
8
+ });
9
+
10
+ it('has an accessible aria-label with value and max', () => {
11
+ render(<Rating value={3} max={5} />);
12
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', '3 out of 5 stars');
13
+ });
14
+
15
+ it('renders max number of stars', () => {
16
+ render(<Rating value={2} max={4} />);
17
+ const container = screen.getByRole('img');
18
+ expect(container.querySelectorAll('svg')).toHaveLength(4);
19
+ });
20
+
21
+ it('uses default max of 5', () => {
22
+ render(<Rating value={0} />);
23
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', '0 out of 5 stars');
24
+ });
25
+
26
+ it('accepts a custom className', () => {
27
+ render(<Rating value={3} className="custom" />);
28
+ expect(screen.getByRole('img')).toHaveClass('custom');
29
+ });
30
+ });
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Rating } from './Rating';
3
+
4
+ const meta = {
5
+ title: 'Interaction/Rating',
6
+ component: Rating,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ value: { control: { type: 'range', min: 0, max: 5, step: 1 } },
10
+ max: { control: { type: 'number' } },
11
+ },
12
+ } satisfies Meta<typeof Rating>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ export const ThreeStars: Story = { args: { value: 3, max: 5 } };
18
+ export const FullStars: Story = { args: { value: 5, max: 5 } };
19
+ export const Empty: Story = { args: { value: 0, max: 5 } };
20
+ export const CustomMax: Story = { args: { value: 3, max: 10 } };
21
+ export const AllRatings: Story = {
22
+ render: () => (
23
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
24
+ {[0, 1, 2, 3, 4, 5].map(v => (
25
+ <Rating key={v} value={v} max={5} />
26
+ ))}
27
+ </div>
28
+ ),
29
+ };
@@ -0,0 +1,30 @@
1
+ import css from './Rating.module.css';
2
+ import { cn } from '@boostdev/design-system-foundation';
3
+
4
+ interface RatingProps {
5
+ value: number;
6
+ max?: number;
7
+ className?: string;
8
+ }
9
+
10
+ export function Rating({ value, max = 5, className }: RatingProps) {
11
+ return (
12
+ <div
13
+ className={cn(css.rating, className)}
14
+ role="img"
15
+ aria-label={`${value} out of ${max} stars`}
16
+ >
17
+ {Array.from({ length: max }).map((_, i) => (
18
+ <svg
19
+ key={i}
20
+ aria-hidden="true"
21
+ className={cn(css.star, i < value && css['--filled'])}
22
+ fill="currentColor"
23
+ viewBox="0 0 24 24"
24
+ >
25
+ <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
26
+ </svg>
27
+ ))}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1 @@
1
+ export { Rating } from './Rating';
@@ -0,0 +1,48 @@
1
+ @layer component {
2
+ .toastContainer {
3
+ position: fixed;
4
+ bottom: var(--space_xl);
5
+ right: var(--space_xl);
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space_m);
9
+ z-index: var(--z-index_toast);
10
+ }
11
+
12
+ .toast {
13
+ padding: var(--space_m) var(--space_l);
14
+ background-color: var(--color_bg);
15
+ color: var(--color_on-bg);
16
+ border-radius: var(--border_radius--s);
17
+ box-shadow: var(--shadow_s);
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--space_m);
21
+ min-width: 300px;
22
+ animation: slideIn var(--animation_duration--fast) var(--animation_easing);
23
+ }
24
+
25
+ .--variant_success {
26
+ border-left: 4px solid var(--color_success);
27
+ }
28
+
29
+ .--variant_error {
30
+ border-left: 4px solid var(--color_error);
31
+ }
32
+
33
+ .--variant_info {
34
+ border-left: 4px solid var(--color_interactive);
35
+ }
36
+ }
37
+
38
+ @keyframes slideIn {
39
+ from {
40
+ transform: translateX(100%);
41
+ opacity: 0;
42
+ }
43
+
44
+ to {
45
+ transform: translateX(0);
46
+ opacity: 1;
47
+ }
48
+ }