tharaday 0.8.2 → 0.8.3

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 (170) hide show
  1. package/dist/{src/components → components}/Accordion/Accordion.d.ts +0 -1
  2. package/dist/{src/components → components}/Avatar/Avatar.d.ts +0 -1
  3. package/dist/{src/components → components}/Badge/Badge.d.ts +0 -1
  4. package/dist/{src/components → components}/Box/Box.d.ts +0 -1
  5. package/dist/{src/components → components}/Box/Box.types.d.ts +11 -11
  6. package/dist/{src/components → components}/Box/helpers/getSpacingStyles.d.ts +2 -2
  7. package/dist/{src/components → components}/Breadcrumbs/Breadcrumbs.d.ts +0 -1
  8. package/dist/{src/components → components}/Button/Button.d.ts +0 -1
  9. package/dist/{src/components → components}/Card/Card.d.ts +0 -1
  10. package/dist/{src/components → components}/Checkbox/Checkbox.d.ts +0 -1
  11. package/dist/components/DatePicker/DatePicker.d.ts +1 -0
  12. package/dist/{src/components → components}/Divider/Divider.d.ts +0 -1
  13. package/dist/components/Drawer/Drawer.d.ts +1 -0
  14. package/dist/{src/components → components}/Dropdown/Dropdown.d.ts +0 -1
  15. package/dist/components/EmptyState/EmptyState.d.ts +1 -0
  16. package/dist/{src/components → components}/Header/Header.d.ts +0 -1
  17. package/dist/{src/components → components}/Input/Input.d.ts +0 -1
  18. package/dist/{src/components → components}/List/List.d.ts +2 -2
  19. package/dist/{src/components → components}/List/List.types.d.ts +3 -3
  20. package/dist/{src/components → components}/List/ListItem.d.ts +1 -1
  21. package/dist/{src/components → components}/List/ListItem.types.d.ts +1 -1
  22. package/dist/{src/components → components}/Loader/Loader.d.ts +0 -1
  23. package/dist/{src/components → components}/Modal/Modal.d.ts +1 -2
  24. package/dist/{src/components → components}/NavBar/NavBar.d.ts +0 -1
  25. package/dist/{src/components → components}/Notification/Notification.d.ts +0 -1
  26. package/dist/{src/components → components}/Pagination/Pagination.d.ts +0 -1
  27. package/dist/components/Popover/Popover.d.ts +1 -0
  28. package/dist/{src/components → components}/ProgressBar/ProgressBar.d.ts +0 -1
  29. package/dist/{src/components → components}/RadioButton/RadioButton.d.ts +0 -1
  30. package/dist/{src/components → components}/Select/Select.d.ts +0 -1
  31. package/dist/{src/components → components}/Skeleton/Skeleton.d.ts +0 -1
  32. package/dist/{src/components → components}/Slider/Slider.d.ts +0 -1
  33. package/dist/{src/components → components}/Stepper/Step.d.ts +0 -1
  34. package/dist/{src/components → components}/Stepper/Stepper.d.ts +0 -1
  35. package/dist/{src/components → components}/Stepper/stepper.utils.d.ts +2 -2
  36. package/dist/{src/components → components}/Switch/Switch.d.ts +0 -1
  37. package/dist/{src/components → components}/Table/Table.d.ts +0 -1
  38. package/dist/{src/components → components}/Tabs/Tabs.d.ts +0 -1
  39. package/dist/components/Tag/Tag.d.ts +1 -0
  40. package/dist/{src/components → components}/Text/Text.d.ts +0 -1
  41. package/dist/{src/components → components}/Textarea/Textarea.d.ts +0 -1
  42. package/dist/{src/components → components}/Tooltip/Tooltip.d.ts +0 -1
  43. package/dist/{src/components → components}/Tree/Tree.d.ts +2 -2
  44. package/dist/{src/components → components}/Tree/Tree.types.d.ts +1 -1
  45. package/dist/{src/components → components}/Tree/TreeItem.d.ts +1 -1
  46. package/dist/{src/components → components}/Tree/TreeItem.types.d.ts +1 -1
  47. package/dist/ds.css +1 -1
  48. package/dist/ds.js +1294 -1149
  49. package/dist/ds.umd.cjs +1 -1
  50. package/dist/hooks/useClickOutside.d.ts +6 -0
  51. package/dist/{src/hooks → hooks}/useComponentId.d.ts +1 -1
  52. package/dist/hooks/useFocusTrap.d.ts +17 -0
  53. package/dist/{src/index.d.ts → index.d.ts} +10 -0
  54. package/dist/{src/layouts → layouts}/AppLayout/AppLayout.d.ts +0 -1
  55. package/dist/{src/layouts → layouts}/AuthLayout/AuthLayout.d.ts +0 -1
  56. package/dist/{src/layouts → layouts}/DashboardLayout/DashboardLayout.d.ts +0 -1
  57. package/dist/{src/layouts → layouts}/SettingsLayout/SettingsLayout.d.ts +0 -1
  58. package/package.json +11 -10
  59. package/src/components/Box/Box.module.css +0 -557
  60. package/src/components/Box/Box.test.tsx +4 -4
  61. package/src/components/Box/Box.tsx +8 -16
  62. package/src/components/Box/helpers/getSpacingStyles.ts +23 -17
  63. package/src/components/DatePicker/DatePicker.module.css +212 -0
  64. package/src/components/DatePicker/DatePicker.stories.tsx +53 -0
  65. package/src/components/DatePicker/DatePicker.test.tsx +61 -0
  66. package/src/components/DatePicker/DatePicker.tsx +269 -0
  67. package/src/components/DatePicker/DatePicker.types.ts +11 -0
  68. package/src/components/Drawer/Drawer.module.css +126 -0
  69. package/src/components/Drawer/Drawer.stories.tsx +70 -0
  70. package/src/components/Drawer/Drawer.test.tsx +49 -0
  71. package/src/components/Drawer/Drawer.tsx +77 -0
  72. package/src/components/Drawer/Drawer.types.ts +17 -0
  73. package/src/components/EmptyState/EmptyState.module.css +73 -0
  74. package/src/components/EmptyState/EmptyState.stories.tsx +65 -0
  75. package/src/components/EmptyState/EmptyState.test.tsx +30 -0
  76. package/src/components/EmptyState/EmptyState.tsx +29 -0
  77. package/src/components/EmptyState/EmptyState.types.ts +12 -0
  78. package/src/components/Header/Header.test.tsx +5 -5
  79. package/src/components/Modal/Modal.tsx +2 -62
  80. package/src/components/Popover/Popover.module.css +52 -0
  81. package/src/components/Popover/Popover.stories.tsx +67 -0
  82. package/src/components/Popover/Popover.test.tsx +40 -0
  83. package/src/components/Popover/Popover.tsx +78 -0
  84. package/src/components/Popover/Popover.types.ts +13 -0
  85. package/src/components/Tag/Tag.module.css +115 -0
  86. package/src/components/Tag/Tag.stories.tsx +61 -0
  87. package/src/components/Tag/Tag.test.tsx +42 -0
  88. package/src/components/Tag/Tag.tsx +74 -0
  89. package/src/components/Tag/Tag.types.ts +15 -0
  90. package/src/components/Text/Text.module.css +0 -521
  91. package/src/components/Text/Text.test.tsx +4 -4
  92. package/src/components/Text/Text.tsx +0 -14
  93. package/src/components/Tooltip/Tooltip.module.css +1 -1
  94. package/src/components/Tooltip/Tooltip.test.tsx +5 -5
  95. package/src/components/Tooltip/Tooltip.tsx +2 -6
  96. package/src/hooks/useClickOutside.test.tsx +68 -0
  97. package/src/hooks/useClickOutside.ts +35 -0
  98. package/src/hooks/useFocusTrap.test.tsx +95 -0
  99. package/src/hooks/useFocusTrap.ts +88 -0
  100. package/src/index.ts +10 -0
  101. package/src/styles/themes.browser.test.ts +75 -0
  102. package/vite.config.ts +1 -1
  103. package/dist/src/components/Accordion/Accordion.stories.d.ts +0 -14
  104. package/dist/src/components/Accordion/Accordion.types.d.ts +0 -18
  105. package/dist/src/components/Avatar/Avatar.stories.d.ts +0 -14
  106. package/dist/src/components/Avatar/Avatar.types.d.ts +0 -10
  107. package/dist/src/components/Badge/Badge.stories.d.ts +0 -33
  108. package/dist/src/components/Badge/Badge.types.d.ts +0 -10
  109. package/dist/src/components/Box/Box.stories.d.ts +0 -38
  110. package/dist/src/components/Breadcrumbs/Breadcrumbs.stories.d.ts +0 -13
  111. package/dist/src/components/Breadcrumbs/Breadcrumbs.types.d.ts +0 -11
  112. package/dist/src/components/Button/Button.stories.d.ts +0 -22
  113. package/dist/src/components/Button/Button.types.d.ts +0 -12
  114. package/dist/src/components/Card/Card.stories.d.ts +0 -27
  115. package/dist/src/components/Card/Card.types.d.ts +0 -16
  116. package/dist/src/components/Checkbox/Checkbox.stories.d.ts +0 -17
  117. package/dist/src/components/Checkbox/Checkbox.types.d.ts +0 -7
  118. package/dist/src/components/Divider/Divider.stories.d.ts +0 -15
  119. package/dist/src/components/Divider/Divider.types.d.ts +0 -10
  120. package/dist/src/components/Dropdown/Dropdown.stories.d.ts +0 -12
  121. package/dist/src/components/Dropdown/Dropdown.types.d.ts +0 -24
  122. package/dist/src/components/Header/Header.stories.d.ts +0 -20
  123. package/dist/src/components/Header/Header.types.d.ts +0 -16
  124. package/dist/src/components/Input/Input.stories.d.ts +0 -32
  125. package/dist/src/components/Input/Input.types.d.ts +0 -10
  126. package/dist/src/components/List/List.stories.d.ts +0 -25
  127. package/dist/src/components/Loader/Loader.stories.d.ts +0 -25
  128. package/dist/src/components/Loader/Loader.types.d.ts +0 -8
  129. package/dist/src/components/Modal/Modal.stories.d.ts +0 -28
  130. package/dist/src/components/Modal/Modal.types.d.ts +0 -12
  131. package/dist/src/components/NavBar/NavBar.stories.d.ts +0 -8
  132. package/dist/src/components/NavBar/NavBar.types.d.ts +0 -38
  133. package/dist/src/components/Notification/Notification.stories.d.ts +0 -26
  134. package/dist/src/components/Notification/Notification.types.d.ts +0 -9
  135. package/dist/src/components/Pagination/Pagination.stories.d.ts +0 -21
  136. package/dist/src/components/Pagination/Pagination.types.d.ts +0 -34
  137. package/dist/src/components/ProgressBar/ProgressBar.stories.d.ts +0 -32
  138. package/dist/src/components/ProgressBar/ProgressBar.types.d.ts +0 -12
  139. package/dist/src/components/RadioButton/RadioButton.stories.d.ts +0 -30
  140. package/dist/src/components/RadioButton/RadioButton.types.d.ts +0 -9
  141. package/dist/src/components/Select/Select.stories.d.ts +0 -32
  142. package/dist/src/components/Select/Select.types.d.ts +0 -23
  143. package/dist/src/components/Skeleton/Skeleton.stories.d.ts +0 -15
  144. package/dist/src/components/Skeleton/Skeleton.types.d.ts +0 -9
  145. package/dist/src/components/Slider/Slider.stories.d.ts +0 -23
  146. package/dist/src/components/Slider/Slider.types.d.ts +0 -15
  147. package/dist/src/components/Stepper/Step.types.d.ts +0 -18
  148. package/dist/src/components/Stepper/Stepper.stories.d.ts +0 -15
  149. package/dist/src/components/Stepper/Stepper.types.d.ts +0 -14
  150. package/dist/src/components/Switch/Switch.stories.d.ts +0 -16
  151. package/dist/src/components/Switch/Switch.types.d.ts +0 -6
  152. package/dist/src/components/Table/Table.stories.d.ts +0 -27
  153. package/dist/src/components/Table/Table.types.d.ts +0 -19
  154. package/dist/src/components/Tabs/Tabs.stories.d.ts +0 -19
  155. package/dist/src/components/Tabs/Tabs.types.d.ts +0 -16
  156. package/dist/src/components/Text/Text.stories.d.ts +0 -78
  157. package/dist/src/components/Text/Text.types.d.ts +0 -52
  158. package/dist/src/components/Textarea/Textarea.stories.d.ts +0 -32
  159. package/dist/src/components/Textarea/Textarea.types.d.ts +0 -11
  160. package/dist/src/components/Tooltip/Tooltip.stories.d.ts +0 -10
  161. package/dist/src/components/Tooltip/Tooltip.types.d.ts +0 -12
  162. package/dist/src/components/Tree/Tree.stories.d.ts +0 -27
  163. package/dist/src/layouts/AppLayout/AppLayout.stories.d.ts +0 -13
  164. package/dist/src/layouts/AppLayout/AppLayout.types.d.ts +0 -13
  165. package/dist/src/layouts/AuthLayout/AuthLayout.stories.d.ts +0 -12
  166. package/dist/src/layouts/AuthLayout/AuthLayout.types.d.ts +0 -8
  167. package/dist/src/layouts/DashboardLayout/DashboardLayout.stories.d.ts +0 -11
  168. package/dist/src/layouts/DashboardLayout/DashboardLayout.types.d.ts +0 -10
  169. package/dist/src/layouts/SettingsLayout/SettingsLayout.stories.d.ts +0 -11
  170. package/dist/src/layouts/SettingsLayout/SettingsLayout.types.d.ts +0 -9
@@ -0,0 +1,126 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: var(--ds-overlay);
5
+ z-index: 1000;
6
+ display: flex;
7
+ }
8
+
9
+ .drawer {
10
+ background-color: var(--ds-surface-0);
11
+ display: flex;
12
+ flex-direction: column;
13
+ box-shadow: var(--ds-shadow-lg);
14
+ overflow: hidden;
15
+ font-family: var(--ds-font-family-base);
16
+ }
17
+
18
+ /* Placement */
19
+ .right {
20
+ margin-left: auto;
21
+ height: 100%;
22
+ }
23
+ .left {
24
+ margin-right: auto;
25
+ height: 100%;
26
+ }
27
+ .top {
28
+ width: 100%;
29
+ margin-bottom: auto;
30
+ }
31
+ .bottom {
32
+ width: 100%;
33
+ margin-top: auto;
34
+ }
35
+
36
+ /* Sizes — width for left/right */
37
+ .left.sm,
38
+ .right.sm {
39
+ width: var(--ds-content-width-sm);
40
+ }
41
+ .left.md,
42
+ .right.md {
43
+ width: var(--ds-content-width-md);
44
+ }
45
+ .left.lg,
46
+ .right.lg {
47
+ width: var(--ds-content-width-lg);
48
+ }
49
+ .left.xl,
50
+ .right.xl {
51
+ width: var(--ds-content-width-xl);
52
+ }
53
+ .left.full,
54
+ .right.full {
55
+ width: 100%;
56
+ }
57
+
58
+ /* Sizes — height for top/bottom */
59
+ .top.sm,
60
+ .bottom.sm {
61
+ height: 20vh;
62
+ }
63
+ .top.md,
64
+ .bottom.md {
65
+ height: 40vh;
66
+ }
67
+ .top.lg,
68
+ .bottom.lg {
69
+ height: 60vh;
70
+ }
71
+ .top.xl,
72
+ .bottom.xl {
73
+ height: 80vh;
74
+ }
75
+ .top.full,
76
+ .bottom.full {
77
+ height: 100%;
78
+ }
79
+
80
+ .header {
81
+ padding: var(--ds-space-4);
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: space-between;
85
+ border-bottom: 1px solid var(--ds-border-2);
86
+ flex-shrink: 0;
87
+ }
88
+
89
+ .title {
90
+ margin: 0;
91
+ font-size: var(--ds-font-size-lg);
92
+ font-weight: var(--ds-font-weight-bold);
93
+ color: var(--ds-text-1);
94
+ }
95
+
96
+ /* Double class for specificity over Button size rules */
97
+ .closeButton.closeButton {
98
+ padding: 0;
99
+ width: var(--ds-space-8);
100
+ height: var(--ds-space-8);
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ }
105
+
106
+ .content {
107
+ padding: var(--ds-space-6);
108
+ overflow-y: auto;
109
+ flex: 1;
110
+ }
111
+
112
+ .footer {
113
+ padding: var(--ds-space-4);
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: flex-end;
117
+ gap: var(--ds-space-3);
118
+ border-top: 1px solid var(--ds-border-2);
119
+ background-color: var(--ds-surface-1);
120
+ flex-shrink: 0;
121
+ }
122
+
123
+ .loading {
124
+ opacity: 0.7;
125
+ pointer-events: none;
126
+ }
@@ -0,0 +1,70 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+
4
+ import { Button } from '../Button/Button.tsx';
5
+ import { Drawer } from './Drawer.tsx';
6
+
7
+ const meta = {
8
+ title: 'Components/Drawer',
9
+ component: Drawer,
10
+ tags: ['autodocs'],
11
+ parameters: { layout: 'centered' },
12
+ argTypes: {
13
+ placement: { control: 'select', options: ['left', 'right', 'top', 'bottom'] },
14
+ size: { control: 'select', options: ['sm', 'md', 'lg', 'xl', 'full'] },
15
+ },
16
+ args: {
17
+ isOpen: false,
18
+ onClose: () => {},
19
+ },
20
+ } satisfies Meta<typeof Drawer>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const DrawerDemo = (args: React.ComponentProps<typeof Drawer>) => {
26
+ const [isOpen, setIsOpen] = useState(false);
27
+ return (
28
+ <>
29
+ <Button onClick={() => setIsOpen(true)}>Open drawer</Button>
30
+ <Drawer {...args} isOpen={isOpen} onClose={() => setIsOpen(false)} />
31
+ </>
32
+ );
33
+ };
34
+
35
+ export const Right: Story = {
36
+ render: (args) => <DrawerDemo {...args} />,
37
+ args: {
38
+ title: 'Drawer title',
39
+ placement: 'right',
40
+ children: <p style={{ margin: 0 }}>Drawer content goes here.</p>,
41
+ },
42
+ };
43
+
44
+ export const Left: Story = {
45
+ render: (args) => <DrawerDemo {...args} />,
46
+ args: {
47
+ title: 'Left drawer',
48
+ placement: 'left',
49
+ children: <p style={{ margin: 0 }}>Drawer content goes here.</p>,
50
+ },
51
+ };
52
+
53
+ export const WithFooter: Story = {
54
+ render: (args) => <DrawerDemo {...args} />,
55
+ args: {
56
+ title: 'Confirm action',
57
+ children: <p style={{ margin: 0 }}>Are you sure you want to proceed?</p>,
58
+ footer: (
59
+ <>
60
+ <Button variant="subtle">Cancel</Button>
61
+ <Button intent="danger">Delete</Button>
62
+ </>
63
+ ),
64
+ },
65
+ };
66
+
67
+ export const Small: Story = {
68
+ render: (args) => <DrawerDemo {...args} />,
69
+ args: { title: 'Small drawer', size: 'sm', children: <p style={{ margin: 0 }}>Content.</p> },
70
+ };
@@ -0,0 +1,49 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+
4
+ import { Drawer } from './Drawer.tsx';
5
+
6
+ describe('Drawer', () => {
7
+ it('renders nothing when closed', () => {
8
+ render(<Drawer isOpen={false} onClose={vi.fn()} title="Test" />);
9
+ expect(screen.queryByRole('dialog')).toBeNull();
10
+ });
11
+
12
+ it('renders when open', () => {
13
+ render(<Drawer isOpen onClose={vi.fn()} title="Test drawer" />);
14
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
15
+ expect(screen.getByText('Test drawer')).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders children', () => {
19
+ render(
20
+ <Drawer isOpen onClose={vi.fn()}>
21
+ <p>Drawer content</p>
22
+ </Drawer>
23
+ );
24
+ expect(screen.getByText('Drawer content')).toBeInTheDocument();
25
+ });
26
+
27
+ it('renders footer when provided', () => {
28
+ render(
29
+ <Drawer isOpen onClose={vi.fn()} footer={<button>Confirm</button>}>
30
+ Content
31
+ </Drawer>
32
+ );
33
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
34
+ });
35
+
36
+ it('calls onClose when close button is clicked', async () => {
37
+ const onClose = vi.fn();
38
+ render(<Drawer isOpen onClose={onClose} title="Test" />);
39
+ await userEvent.click(screen.getByRole('button', { name: 'Close drawer' }));
40
+ expect(onClose).toHaveBeenCalledOnce();
41
+ });
42
+
43
+ it('calls onClose when Escape is pressed', async () => {
44
+ const onClose = vi.fn();
45
+ render(<Drawer isOpen onClose={onClose} title="Test" />);
46
+ await userEvent.keyboard('{Escape}');
47
+ expect(onClose).toHaveBeenCalledOnce();
48
+ });
49
+ });
@@ -0,0 +1,77 @@
1
+ import classnames from 'classnames';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ import { useComponentId } from '../../hooks/useComponentId.ts';
5
+ import { useFocusTrap } from '../../hooks/useFocusTrap.ts';
6
+ import { Button } from '../Button/Button.tsx';
7
+ import styles from './Drawer.module.css';
8
+ import type { DrawerProps } from './Drawer.types.ts';
9
+
10
+ export const Drawer = ({
11
+ isOpen,
12
+ onClose,
13
+ title,
14
+ children,
15
+ footer,
16
+ placement = 'right',
17
+ size = 'md',
18
+ isLoading = false,
19
+ className,
20
+ id,
21
+ }: DrawerProps) => {
22
+ const drawerRef = useFocusTrap<HTMLDivElement>({ isOpen, onClose, isLoading });
23
+ const componentId = useComponentId('drawer', id);
24
+ const titleId = `${componentId}-title`;
25
+
26
+ if (!isOpen) {
27
+ return null;
28
+ }
29
+
30
+ return createPortal(
31
+ <div className={styles.overlay} onClick={() => !isLoading && onClose()}>
32
+ <div
33
+ id={componentId}
34
+ ref={drawerRef}
35
+ className={classnames(
36
+ styles.drawer,
37
+ styles[placement],
38
+ styles[size],
39
+ isLoading && styles.loading,
40
+ className
41
+ )}
42
+ onClick={(e) => e.stopPropagation()}
43
+ role="dialog"
44
+ aria-modal="true"
45
+ aria-labelledby={title ? titleId : undefined}
46
+ >
47
+ <div className={styles.header}>
48
+ {title && (
49
+ <h2 id={titleId} className={styles.title}>
50
+ {title}
51
+ </h2>
52
+ )}
53
+ <Button
54
+ variant="subtle"
55
+ size="sm"
56
+ onClick={onClose}
57
+ className={styles.closeButton}
58
+ aria-label="Close drawer"
59
+ disabled={isLoading}
60
+ >
61
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
62
+ <path
63
+ d="M1 1L9 9M9 1L1 9"
64
+ stroke="currentColor"
65
+ strokeWidth="1.5"
66
+ strokeLinecap="round"
67
+ />
68
+ </svg>
69
+ </Button>
70
+ </div>
71
+ <div className={styles.content}>{children}</div>
72
+ {footer && <div className={styles.footer}>{footer}</div>}
73
+ </div>
74
+ </div>,
75
+ document.body
76
+ );
77
+ };
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom';
4
+ export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
5
+
6
+ export interface DrawerProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ title?: string;
10
+ children?: ReactNode;
11
+ footer?: ReactNode;
12
+ placement?: DrawerPlacement;
13
+ size?: DrawerSize;
14
+ isLoading?: boolean;
15
+ className?: string;
16
+ id?: string;
17
+ }
@@ -0,0 +1,73 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ text-align: center;
6
+ font-family: var(--ds-font-family-base);
7
+ }
8
+
9
+ .sm {
10
+ gap: var(--ds-space-3);
11
+ padding: var(--ds-space-6);
12
+ }
13
+ .md {
14
+ gap: var(--ds-space-4);
15
+ padding: var(--ds-space-10);
16
+ }
17
+ .lg {
18
+ gap: var(--ds-space-6);
19
+ padding: var(--ds-space-14);
20
+ }
21
+
22
+ .icon {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ color: var(--ds-text-disabled);
27
+ }
28
+ .sm .icon {
29
+ width: var(--ds-space-8);
30
+ height: var(--ds-space-8);
31
+ }
32
+ .md .icon {
33
+ width: var(--ds-space-10);
34
+ height: var(--ds-space-10);
35
+ }
36
+ .lg .icon {
37
+ width: var(--ds-space-14);
38
+ height: var(--ds-space-14);
39
+ }
40
+
41
+ .content {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: var(--ds-space-2);
45
+ }
46
+
47
+ .title {
48
+ margin: 0;
49
+ font-weight: var(--ds-font-weight-medium);
50
+ color: var(--ds-text-1);
51
+ }
52
+ .sm .title {
53
+ font-size: var(--ds-font-size-sm);
54
+ }
55
+ .md .title {
56
+ font-size: var(--ds-font-size-base);
57
+ }
58
+ .lg .title {
59
+ font-size: var(--ds-font-size-lg);
60
+ }
61
+
62
+ .description {
63
+ margin: 0;
64
+ font-size: var(--ds-font-size-sm);
65
+ color: var(--ds-text-2);
66
+ }
67
+ .lg .description {
68
+ font-size: var(--ds-font-size-base);
69
+ }
70
+
71
+ .action {
72
+ margin-top: var(--ds-space-2);
73
+ }
@@ -0,0 +1,65 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { Button } from '../Button/Button.tsx';
4
+ import { EmptyState } from './EmptyState.tsx';
5
+
6
+ const SearchIcon = () => (
7
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
8
+ <circle cx="11" cy="11" r="7" />
9
+ <path d="M16.5 16.5L21 21" strokeLinecap="round" />
10
+ </svg>
11
+ );
12
+
13
+ const meta = {
14
+ title: 'Components/EmptyState',
15
+ component: EmptyState,
16
+ tags: ['autodocs'],
17
+ parameters: { layout: 'centered' },
18
+ argTypes: {
19
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
20
+ },
21
+ } satisfies Meta<typeof EmptyState>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ export const Default: Story = {
27
+ args: {
28
+ title: 'No results found',
29
+ description: 'Try adjusting your search or filters.',
30
+ },
31
+ };
32
+
33
+ export const WithIcon: Story = {
34
+ args: {
35
+ title: 'No results found',
36
+ description: 'Try adjusting your search or filters.',
37
+ icon: <SearchIcon />,
38
+ },
39
+ };
40
+
41
+ export const WithAction: Story = {
42
+ args: {
43
+ title: 'No items yet',
44
+ description: 'Get started by creating your first item.',
45
+ icon: <SearchIcon />,
46
+ action: <Button size="sm">Create item</Button>,
47
+ },
48
+ };
49
+
50
+ export const Small: Story = {
51
+ args: {
52
+ title: 'Nothing here',
53
+ size: 'sm',
54
+ },
55
+ };
56
+
57
+ export const Large: Story = {
58
+ args: {
59
+ title: 'No results found',
60
+ description: "Try adjusting your search or filters to find what you're looking for.",
61
+ icon: <SearchIcon />,
62
+ action: <Button>Reset filters</Button>,
63
+ size: 'lg',
64
+ },
65
+ };
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+
3
+ import { EmptyState } from './EmptyState.tsx';
4
+
5
+ describe('EmptyState', () => {
6
+ it('renders title', () => {
7
+ render(<EmptyState title="No results" />);
8
+ expect(screen.getByText('No results')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders description when provided', () => {
12
+ render(<EmptyState title="No results" description="Try a different search." />);
13
+ expect(screen.getByText('Try a different search.')).toBeInTheDocument();
14
+ });
15
+
16
+ it('does not render description when omitted', () => {
17
+ render(<EmptyState title="No results" />);
18
+ expect(screen.queryByText('Try a different search.')).toBeNull();
19
+ });
20
+
21
+ it('renders action when provided', () => {
22
+ render(<EmptyState title="No results" action={<button>Reset</button>} />);
23
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
24
+ });
25
+
26
+ it('forwards extra props', () => {
27
+ render(<EmptyState title="No results" data-testid="empty" />);
28
+ expect(screen.getByTestId('empty')).toBeInTheDocument();
29
+ });
30
+ });
@@ -0,0 +1,29 @@
1
+ import classnames from 'classnames';
2
+
3
+ import styles from './EmptyState.module.css';
4
+ import type { EmptyStateProps } from './EmptyState.types.ts';
5
+
6
+ export const EmptyState = ({
7
+ title,
8
+ description,
9
+ icon,
10
+ action,
11
+ size = 'md',
12
+ className,
13
+ ...props
14
+ }: EmptyStateProps) => {
15
+ return (
16
+ <div className={classnames(styles.root, styles[size], className)} {...props}>
17
+ {icon && (
18
+ <div className={styles.icon} aria-hidden="true">
19
+ {icon}
20
+ </div>
21
+ )}
22
+ <div className={styles.content}>
23
+ <p className={styles.title}>{title}</p>
24
+ {description && <p className={styles.description}>{description}</p>}
25
+ </div>
26
+ {action && <div className={styles.action}>{action}</div>}
27
+ </div>
28
+ );
29
+ };
@@ -0,0 +1,12 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export type EmptyStateSize = 'sm' | 'md' | 'lg';
4
+
5
+ export interface EmptyStateProps extends HTMLAttributes<HTMLDivElement> {
6
+ title: string;
7
+ description?: string;
8
+ icon?: ReactNode;
9
+ action?: ReactNode;
10
+ size?: EmptyStateSize;
11
+ className?: string;
12
+ }
@@ -20,12 +20,12 @@ describe('Header', () => {
20
20
  });
21
21
 
22
22
  it('renders Log in button when onLogin is provided and no user', () => {
23
- render(<Header onLogin={() => {}} />);
23
+ render(<Header onLogin={vi.fn()} />);
24
24
  expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
25
25
  });
26
26
 
27
27
  it('renders Sign up button when onCreateAccount is provided and no user', () => {
28
- render(<Header onCreateAccount={() => {}} />);
28
+ render(<Header onCreateAccount={vi.fn()} />);
29
29
  expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
30
30
  });
31
31
 
@@ -44,13 +44,13 @@ describe('Header', () => {
44
44
  });
45
45
 
46
46
  it('renders welcome message with user name when user is provided', () => {
47
- render(<Header user={{ name: 'Alice' }} onLogout={() => {}} />);
47
+ render(<Header user={{ name: 'Alice' }} onLogout={vi.fn()} />);
48
48
  expect(screen.getByText(/Welcome/)).toBeInTheDocument();
49
49
  expect(screen.getByText('Alice')).toBeInTheDocument();
50
50
  });
51
51
 
52
52
  it('renders Log out button when user is provided', () => {
53
- render(<Header user={{ name: 'Alice' }} onLogout={() => {}} />);
53
+ render(<Header user={{ name: 'Alice' }} onLogout={vi.fn()} />);
54
54
  expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument();
55
55
  });
56
56
 
@@ -62,7 +62,7 @@ describe('Header', () => {
62
62
  });
63
63
 
64
64
  it('does not render Log in or Sign up when user is present', () => {
65
- render(<Header user={{ name: 'Alice' }} onLogin={() => {}} onCreateAccount={() => {}} />);
65
+ render(<Header user={{ name: 'Alice' }} onLogin={vi.fn()} onCreateAccount={vi.fn()} />);
66
66
  expect(screen.queryByRole('button', { name: 'Log in' })).not.toBeInTheDocument();
67
67
  expect(screen.queryByRole('button', { name: 'Sign up' })).not.toBeInTheDocument();
68
68
  });
@@ -1,7 +1,7 @@
1
1
  import classnames from 'classnames';
2
- import { useEffect, useRef } from 'react';
3
2
 
4
3
  import { useComponentId } from '../../hooks/useComponentId.ts';
4
+ import { useFocusTrap } from '../../hooks/useFocusTrap.ts';
5
5
  import { createPortal } from 'react-dom';
6
6
 
7
7
  import styles from './Modal.module.css';
@@ -19,70 +19,10 @@ export const Modal = ({
19
19
  className,
20
20
  id,
21
21
  }: ModalProps) => {
22
- const modalRef = useRef<HTMLDivElement>(null);
23
- const previousFocus = useRef<HTMLElement | null>(null);
24
- const onCloseRef = useRef(onClose);
22
+ const modalRef = useFocusTrap<HTMLDivElement>({ isOpen, onClose, isLoading });
25
23
  const componentId = useComponentId('modal', id);
26
24
  const titleId = `${componentId}-title`;
27
25
 
28
- useEffect(() => {
29
- onCloseRef.current = onClose;
30
- }, [onClose]);
31
-
32
- useEffect(() => {
33
- const handleKeyDown = (event: KeyboardEvent) => {
34
- if (event.key === 'Escape' && !isLoading) {
35
- onCloseRef.current();
36
- return;
37
- }
38
-
39
- if (event.key === 'Tab' && modalRef.current) {
40
- const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
41
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
42
- );
43
- const firstElement = focusableElements[0];
44
- const lastElement = focusableElements[focusableElements.length - 1];
45
-
46
- if (!firstElement || !lastElement) {
47
- return;
48
- }
49
-
50
- if (event.shiftKey) {
51
- if (document.activeElement === firstElement) {
52
- lastElement.focus();
53
- event.preventDefault();
54
- }
55
- } else {
56
- if (document.activeElement === lastElement) {
57
- firstElement.focus();
58
- event.preventDefault();
59
- }
60
- }
61
- }
62
- };
63
-
64
- if (isOpen) {
65
- previousFocus.current = document.activeElement as HTMLElement;
66
- document.body.style.overflow = 'hidden';
67
- window.addEventListener('keydown', handleKeyDown);
68
-
69
- requestAnimationFrame(() => {
70
- const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
71
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
72
- );
73
- if (focusableElements && focusableElements.length > 0) {
74
- focusableElements[0].focus();
75
- }
76
- });
77
- }
78
-
79
- return () => {
80
- document.body.style.overflow = 'unset';
81
- window.removeEventListener('keydown', handleKeyDown);
82
- previousFocus.current?.focus();
83
- };
84
- }, [isOpen, isLoading]);
85
-
86
26
  if (!isOpen) {
87
27
  return null;
88
28
  }