@slipstream-ui/core 0.3.0 → 0.4.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 (128) hide show
  1. package/dist/index.d.mts +672 -52
  2. package/dist/index.d.ts +672 -52
  3. package/dist/index.js +1860 -657
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1836 -655
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/manifests/app-shell.manifest.json +156 -0
  8. package/dist/manifests/auth-layout.manifest.json +99 -0
  9. package/dist/manifests/autocomplete.manifest.json +53 -5
  10. package/dist/manifests/badge.manifest.json +12 -0
  11. package/dist/manifests/button.manifest.json +6 -2
  12. package/dist/manifests/card.manifest.json +53 -6
  13. package/dist/manifests/date-picker.manifest.json +87 -0
  14. package/dist/manifests/dialog.manifest.json +49 -4
  15. package/dist/manifests/drawer.manifest.json +18 -4
  16. package/dist/manifests/empty-state.manifest.json +108 -0
  17. package/dist/manifests/file-upload.manifest.json +103 -0
  18. package/dist/manifests/filter-bar.manifest.json +100 -0
  19. package/dist/manifests/menu.manifest.json +13 -1
  20. package/dist/manifests/metric-card.manifest.json +105 -0
  21. package/dist/manifests/page-header.manifest.json +94 -0
  22. package/dist/manifests/pagination.manifest.json +62 -34
  23. package/dist/manifests/password-input.manifest.json +75 -0
  24. package/dist/manifests/select-menu.manifest.json +142 -0
  25. package/dist/manifests/select.manifest.json +49 -12
  26. package/dist/manifests/stepper.manifest.json +95 -0
  27. package/dist/manifests/table-card.manifest.json +123 -0
  28. package/dist/manifests/time-picker.manifest.json +86 -0
  29. package/dist/manifests/timeline.manifest.json +69 -0
  30. package/dist/manifests/toast.manifest.json +101 -25
  31. package/package.json +1 -1
  32. package/src/components/app-shell/AppShell.test.tsx +100 -0
  33. package/src/components/app-shell/AppShell.tsx +162 -0
  34. package/src/components/app-shell/index.ts +1 -0
  35. package/src/components/auth-layout/AuthLayout.test.tsx +34 -0
  36. package/src/components/auth-layout/AuthLayout.tsx +47 -0
  37. package/src/components/auth-layout/index.ts +1 -0
  38. package/src/components/autocomplete/Autocomplete.test.tsx +53 -0
  39. package/src/components/autocomplete/Autocomplete.tsx +160 -41
  40. package/src/components/badge/Badge.test.tsx +14 -0
  41. package/src/components/badge/Badge.tsx +10 -4
  42. package/src/components/button/Button.test.tsx +29 -0
  43. package/src/components/button/Button.tsx +10 -0
  44. package/src/components/button-group/ButtonGroup.test.tsx +6 -8
  45. package/src/components/button-group/ButtonGroup.tsx +17 -9
  46. package/src/components/card/Card.test.tsx +14 -0
  47. package/src/components/card/Card.tsx +15 -8
  48. package/src/components/date-picker/DatePicker.test.tsx +30 -0
  49. package/src/components/date-picker/DatePicker.tsx +57 -0
  50. package/src/components/date-picker/index.ts +1 -0
  51. package/src/components/dialog/Dialog.test.tsx +64 -1
  52. package/src/components/dialog/Dialog.tsx +113 -23
  53. package/src/components/dialog/index.ts +1 -2
  54. package/src/components/drawer/Drawer.test.tsx +50 -0
  55. package/src/components/drawer/Drawer.tsx +44 -6
  56. package/src/components/empty-state/EmptyState.test.tsx +30 -0
  57. package/src/components/empty-state/EmptyState.tsx +48 -0
  58. package/src/components/empty-state/index.ts +1 -0
  59. package/src/components/file-upload/FileUpload.test.tsx +43 -0
  60. package/src/components/file-upload/FileUpload.tsx +143 -0
  61. package/src/components/file-upload/index.ts +1 -0
  62. package/src/components/filter-bar/FilterBar.test.tsx +32 -0
  63. package/src/components/filter-bar/FilterBar.tsx +71 -0
  64. package/src/components/filter-bar/index.ts +1 -0
  65. package/src/components/input-group/InputGroup.test.tsx +6 -3
  66. package/src/components/input-group/InputGroup.tsx +9 -6
  67. package/src/components/menu/Menu.test.tsx +21 -0
  68. package/src/components/menu/Menu.tsx +8 -3
  69. package/src/components/metric-card/MetricCard.test.tsx +28 -0
  70. package/src/components/metric-card/MetricCard.tsx +54 -0
  71. package/src/components/metric-card/index.ts +1 -0
  72. package/src/components/page-header/PageHeader.test.tsx +31 -0
  73. package/src/components/page-header/PageHeader.tsx +37 -0
  74. package/src/components/page-header/index.ts +1 -0
  75. package/src/components/pagination/Pagination.test.tsx +25 -0
  76. package/src/components/pagination/Pagination.tsx +53 -12
  77. package/src/components/password-input/PasswordInput.test.tsx +29 -0
  78. package/src/components/password-input/PasswordInput.tsx +41 -0
  79. package/src/components/password-input/index.ts +1 -0
  80. package/src/components/select/Select.test.tsx +40 -1
  81. package/src/components/select/Select.tsx +52 -14
  82. package/src/components/select/index.ts +1 -1
  83. package/src/components/select-menu/SelectMenu.test.tsx +105 -0
  84. package/src/components/select-menu/SelectMenu.tsx +368 -0
  85. package/src/components/select-menu/index.ts +1 -0
  86. package/src/components/stepper/Stepper.test.tsx +35 -0
  87. package/src/components/stepper/Stepper.tsx +91 -0
  88. package/src/components/stepper/index.ts +1 -0
  89. package/src/components/table-card/TableCard.test.tsx +59 -0
  90. package/src/components/table-card/TableCard.tsx +48 -0
  91. package/src/components/table-card/index.ts +1 -0
  92. package/src/components/time-picker/TimePicker.test.tsx +30 -0
  93. package/src/components/time-picker/TimePicker.tsx +56 -0
  94. package/src/components/time-picker/index.ts +1 -0
  95. package/src/components/timeline/Timeline.test.tsx +26 -0
  96. package/src/components/timeline/Timeline.tsx +53 -0
  97. package/src/components/timeline/index.ts +1 -0
  98. package/src/components/toast/Toast.test.tsx +5 -0
  99. package/src/components/toast/Toast.tsx +2 -16
  100. package/src/components/toast/Toaster.test.tsx +84 -0
  101. package/src/components/toast/Toaster.tsx +116 -0
  102. package/src/components/toast/index.ts +4 -0
  103. package/src/components/toast/toast-store.ts +117 -0
  104. package/src/index.ts +14 -0
  105. package/src/manifests/app-shell.manifest.json +156 -0
  106. package/src/manifests/auth-layout.manifest.json +99 -0
  107. package/src/manifests/autocomplete.manifest.json +53 -5
  108. package/src/manifests/badge.manifest.json +12 -0
  109. package/src/manifests/button.manifest.json +6 -2
  110. package/src/manifests/card.manifest.json +53 -6
  111. package/src/manifests/date-picker.manifest.json +87 -0
  112. package/src/manifests/dialog.manifest.json +49 -4
  113. package/src/manifests/drawer.manifest.json +18 -4
  114. package/src/manifests/empty-state.manifest.json +108 -0
  115. package/src/manifests/file-upload.manifest.json +103 -0
  116. package/src/manifests/filter-bar.manifest.json +100 -0
  117. package/src/manifests/menu.manifest.json +13 -1
  118. package/src/manifests/metric-card.manifest.json +105 -0
  119. package/src/manifests/page-header.manifest.json +94 -0
  120. package/src/manifests/pagination.manifest.json +62 -34
  121. package/src/manifests/password-input.manifest.json +75 -0
  122. package/src/manifests/select-menu.manifest.json +142 -0
  123. package/src/manifests/select.manifest.json +49 -12
  124. package/src/manifests/stepper.manifest.json +95 -0
  125. package/src/manifests/table-card.manifest.json +123 -0
  126. package/src/manifests/time-picker.manifest.json +86 -0
  127. package/src/manifests/timeline.manifest.json +69 -0
  128. package/src/manifests/toast.manifest.json +101 -25
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { axe } from 'jest-axe';
5
5
  import { describe, expect, it, vi } from 'vitest';
6
- import { Dialog } from './Dialog';
6
+ import { Dialog, DialogBody, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './Dialog';
7
7
 
8
8
  function Harness(props: { onOpenChange?: (open: boolean) => void; closeOnBackdrop?: boolean }) {
9
9
  const [open, setOpen] = useState(true);
@@ -184,3 +184,66 @@ describe('Dialog animate (optional fade)', () => {
184
184
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
185
185
  });
186
186
  });
187
+
188
+
189
+ describe('Dialog sizes, slots and compound parts', () => {
190
+ it('applies size presets', () => {
191
+ const { rerender } = render(
192
+ <Dialog open onOpenChange={vi.fn()} title="Sized" size="2xl">
193
+ body
194
+ </Dialog>
195
+ );
196
+ expect(screen.getByRole('dialog')).toHaveClass('max-w-2xl');
197
+ rerender(
198
+ <Dialog open onOpenChange={vi.fn()} title="Sized" size="sm">
199
+ body
200
+ </Dialog>
201
+ );
202
+ expect(screen.getByRole('dialog')).toHaveClass('max-w-sm');
203
+ });
204
+
205
+ it('wires a composed header through DialogTitle/DialogDescription', () => {
206
+ render(
207
+ <Dialog
208
+ open
209
+ onOpenChange={vi.fn()}
210
+ header={
211
+ <DialogHeader>
212
+ <DialogTitle>Publish release</DialogTitle>
213
+ </DialogHeader>
214
+ }
215
+ >
216
+ <DialogDescription>Tag and publish the workspace.</DialogDescription>
217
+ <DialogBody>Body content</DialogBody>
218
+ </Dialog>
219
+ );
220
+ const dialog = screen.getByRole('dialog', { name: 'Publish release' });
221
+ expect(dialog).toBeInTheDocument();
222
+ expect(screen.getByText('Body content')).toBeInTheDocument();
223
+ });
224
+
225
+ it('renders the footer slot inside the panel', () => {
226
+ render(
227
+ <Dialog
228
+ open
229
+ onOpenChange={vi.fn()}
230
+ title="Confirm"
231
+ footer={<DialogFooter><button type="button">Save</button></DialogFooter>}
232
+ >
233
+ body
234
+ </Dialog>
235
+ );
236
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
237
+ });
238
+
239
+ it('caps and scrolls the body when scrollable', () => {
240
+ render(
241
+ <Dialog open onOpenChange={vi.fn()} title="Long" scrollable>
242
+ <p>scrollable body</p>
243
+ </Dialog>
244
+ );
245
+ const body = screen.getByText('scrollable body').parentElement!;
246
+ expect(body.className).toContain('max-h-[60vh]');
247
+ expect(body.className).toContain('overflow-y-auto');
248
+ });
249
+ });
@@ -5,17 +5,34 @@ import { createPortal } from 'react-dom';
5
5
  import { cn } from '../../utils/cn';
6
6
  import { usePresence } from '../../utils/presence';
7
7
 
8
+ export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
9
+
8
10
  export type DialogProps = {
9
11
  open: boolean;
10
12
  onOpenChange: (open: boolean) => void;
11
- title: string;
13
+ /** Accessible name + default header title. Optional when composing a custom header. */
14
+ title?: string;
12
15
  description?: string;
13
16
  children?: React.ReactNode;
17
+ /** Panel max-width preset (default 'md'). */
18
+ size?: DialogSize;
19
+ /**
20
+ * Replaces the default header row. Compose with `<DialogHeader>` /
21
+ * `<DialogTitle>` / `<DialogDescription>` — those parts pick up the
22
+ * dialog's aria ids automatically.
23
+ */
24
+ header?: React.ReactNode;
25
+ /** Action row pinned under the body. */
26
+ footer?: React.ReactNode;
27
+ /** Cap the body height and scroll inside the panel. */
28
+ scrollable?: boolean;
29
+ bodyClassName?: string;
14
30
  closeOnBackdrop?: boolean;
15
31
  /** Hide the close (✕) button in the header; Escape and backdrop still close. */
16
32
  showClose?: boolean;
17
33
  /** Focused when the dialog opens; defaults to the first focusable element. */
18
34
  initialFocusRef?: React.RefObject<HTMLElement | null>;
35
+ /** Accessible name when `title` is omitted (required with a custom header). */
19
36
  ariaLabel?: string;
20
37
  /**
21
38
  * Fade the backdrop and panel in on open and out on close (requires
@@ -28,10 +45,61 @@ export type DialogProps = {
28
45
  const FOCUSABLE =
29
46
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
30
47
 
48
+ const sizeClasses: Record<DialogSize, string> = {
49
+ sm: 'max-w-sm',
50
+ md: 'max-w-md',
51
+ lg: 'max-w-lg',
52
+ xl: 'max-w-xl',
53
+ '2xl': 'max-w-2xl',
54
+ };
55
+
56
+ const DialogIdsContext = React.createContext<{ titleId: string; descriptionId: string } | null>(null);
57
+
58
+ export type DialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
59
+
60
+ /** Header row for composed dialogs. */
61
+ function DialogHeader({ className, ...props }: DialogHeaderProps) {
62
+ return <div className={cn('flex items-start justify-between gap-4', className)} {...props} />;
63
+ }
64
+
65
+ export type DialogTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
66
+
67
+ /** Dialog heading; automatically wired to aria-labelledby. */
68
+ function DialogTitle({ className, ...props }: DialogTitleProps) {
69
+ const ids = React.useContext(DialogIdsContext);
70
+ return <h2 id={ids?.titleId} className={cn('text-lg font-semibold', className)} {...props} />;
71
+ }
72
+
73
+ export type DialogDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
74
+
75
+ /** Supporting line; automatically wired to aria-describedby. */
76
+ function DialogDescription({ className, ...props }: DialogDescriptionProps) {
77
+ const ids = React.useContext(DialogIdsContext);
78
+ return <p id={ids?.descriptionId} className={cn('mt-2 text-sm text-muted-foreground', className)} {...props} />;
79
+ }
80
+
81
+ export type DialogBodyProps = React.HTMLAttributes<HTMLDivElement>;
82
+
83
+ /** Body wrapper for composed dialogs. */
84
+ function DialogBody({ className, ...props }: DialogBodyProps) {
85
+ return <div className={cn('mt-4', className)} {...props} />;
86
+ }
87
+
88
+ export type DialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
89
+
90
+ /** Action row pinned to the bottom of the panel. */
91
+ function DialogFooter({ className, ...props }: DialogFooterProps) {
92
+ return <div className={cn('mt-6 flex items-center justify-end gap-2', className)} {...props} />;
93
+ }
94
+
31
95
  /**
32
96
  * Accessible modal dialog: portal, aria-modal + labelled/describedby wiring,
33
97
  * Escape and backdrop dismissal, initial focus on open and focus restoration
34
98
  * on close, plus a Tab/Shift+Tab focus trap.
99
+ *
100
+ * Use `title`/`description` for the default header, or pass `header` with
101
+ * `<DialogHeader>`/`<DialogTitle>`/`<DialogDescription>` (and an
102
+ * `ariaLabel` fallback) for full control.
35
103
  */
36
104
  export function Dialog({
37
105
  open,
@@ -39,9 +107,15 @@ export function Dialog({
39
107
  title,
40
108
  description,
41
109
  children,
110
+ size = 'md',
111
+ header,
112
+ footer,
113
+ scrollable = false,
114
+ bodyClassName,
42
115
  closeOnBackdrop = true,
43
116
  showClose = true,
44
117
  initialFocusRef,
118
+ ariaLabel,
45
119
  animate = false,
46
120
  }: DialogProps) {
47
121
  const dialogRef = React.useRef<HTMLDivElement | null>(null);
@@ -119,34 +193,50 @@ export function Dialog({
119
193
  ref={dialogRef}
120
194
  role="dialog"
121
195
  aria-modal="true"
122
- aria-labelledby={titleId}
196
+ aria-labelledby={ariaLabel ? undefined : titleId}
197
+ aria-label={ariaLabel}
123
198
  aria-describedby={description ? descriptionId : undefined}
124
199
  tabIndex={-1}
125
- className={cn('w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl', motion)}
200
+ className={cn(
201
+ 'flex w-full flex-col rounded-xl border border-border bg-card p-6 shadow-xl',
202
+ sizeClasses[size],
203
+ motion,
204
+ )}
126
205
  >
127
- <div className="flex items-start justify-between gap-4">
128
- <h2 id={titleId} className="text-lg font-semibold">
129
- {title}
130
- </h2>
131
- {showClose && (
132
- <button
133
- type="button"
134
- onClick={() => onOpenChange(false)}
135
- aria-label="Close dialog"
136
- className="rounded p-1 text-muted-foreground hover:text-foreground"
137
- >
138
- ✕
139
- </button>
206
+ <DialogIdsContext.Provider value={{ titleId, descriptionId }}>
207
+ {header ?? (
208
+ <DialogHeader>
209
+ <div className="min-w-0">
210
+ {title && <DialogTitle>{title}</DialogTitle>}
211
+ {description && <DialogDescription>{description}</DialogDescription>}
212
+ </div>
213
+ {showClose && (
214
+ <button
215
+ type="button"
216
+ onClick={() => onOpenChange(false)}
217
+ aria-label="Close dialog"
218
+ className="rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
219
+ >
220
+ ✕
221
+ </button>
222
+ )}
223
+ </DialogHeader>
140
224
  )}
141
- </div>
142
- {description && (
143
- <p id={descriptionId} className="mt-2 text-sm text-muted-foreground">
144
- {description}
145
- </p>
146
- )}
147
- {children && <div className="mt-4">{children}</div>}
225
+ <div
226
+ className={cn(
227
+ 'mt-4 min-h-0',
228
+ scrollable && 'max-h-[60vh] overflow-y-auto pr-1',
229
+ bodyClassName,
230
+ )}
231
+ >
232
+ {children}
233
+ </div>
234
+ {footer && <DialogFooter>{footer}</DialogFooter>}
235
+ </DialogIdsContext.Provider>
148
236
  </div>
149
237
  </div>,
150
238
  document.body,
151
239
  );
152
240
  }
241
+
242
+ export { DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter };
@@ -1,2 +1 @@
1
- export { Dialog } from './Dialog';
2
- export type { DialogProps } from './Dialog';
1
+ export * from './Dialog';
@@ -122,3 +122,53 @@ describe('Drawer animate (optional slide)', () => {
122
122
  });
123
123
  });
124
124
  });
125
+
126
+ describe('Drawer sizes and custom extents', () => {
127
+ it('supports xl and 2xl presets on each axis', () => {
128
+ const { rerender } = render(
129
+ <Drawer open onOpenChange={vi.fn()} title="Wide" side="right" size="xl">
130
+ body
131
+ </Drawer>
132
+ );
133
+ expect(screen.getByRole('dialog')).toHaveClass('w-[40rem]');
134
+ rerender(
135
+ <Drawer open onOpenChange={vi.fn()} title="Wide" side="right" size="2xl">
136
+ body
137
+ </Drawer>
138
+ );
139
+ expect(screen.getByRole('dialog')).toHaveClass('w-[48rem]');
140
+ rerender(
141
+ <Drawer open onOpenChange={vi.fn()} title="Tall" side="bottom" size="2xl">
142
+ body
143
+ </Drawer>
144
+ );
145
+ expect(screen.getByRole('dialog')).toHaveClass('h-[48rem]');
146
+ });
147
+
148
+ it('accepts a pixel width and overrides the preset', () => {
149
+ render(
150
+ <Drawer open onOpenChange={vi.fn()} title="Custom" side="right" width={560}>
151
+ body
152
+ </Drawer>
153
+ );
154
+ expect(screen.getByRole('dialog')).toHaveStyle({ width: '560px' });
155
+ });
156
+
157
+ it('accepts a CSS length height for top/bottom drawers', () => {
158
+ render(
159
+ <Drawer open onOpenChange={vi.fn()} title="Custom" side="top" height="60vh">
160
+ body
161
+ </Drawer>
162
+ );
163
+ expect(screen.getByRole('dialog')).toHaveStyle({ height: '60vh' });
164
+ });
165
+
166
+ it('ignores width/height that do not match the side axis', () => {
167
+ render(
168
+ <Drawer open onOpenChange={vi.fn()} title="Mismatch" side="bottom" width={400}>
169
+ body
170
+ </Drawer>
171
+ );
172
+ expect(screen.getByRole('dialog')).not.toHaveAttribute('style');
173
+ });
174
+ });
@@ -6,7 +6,7 @@ import { cn } from '../../utils/cn';
6
6
  import { usePresence } from '../../utils/presence';
7
7
 
8
8
  export type DrawerSide = 'left' | 'right' | 'top' | 'bottom';
9
- export type DrawerSize = 'sm' | 'md' | 'lg';
9
+ export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
10
10
 
11
11
  export type DrawerProps = {
12
12
  open: boolean;
@@ -16,8 +16,18 @@ export type DrawerProps = {
16
16
  children?: React.ReactNode;
17
17
  /** Edge the drawer slides from (default 'right'). */
18
18
  side?: DrawerSide;
19
- /** Extent along the side axis (width for left/right, height for top/bottom). */
19
+ /** Preset extent along the side axis (width for left/right, height for top/bottom). */
20
20
  size?: DrawerSize;
21
+ /**
22
+ * Custom extent for left/right drawers: pixels (480) or any CSS length
23
+ * ("70vw"). Overrides `size`.
24
+ */
25
+ width?: number | string;
26
+ /**
27
+ * Custom extent for top/bottom drawers: pixels (320) or any CSS length
28
+ * ("60vh"). Overrides `size`.
29
+ */
30
+ height?: number | string;
21
31
  /** Clicking the backdrop closes (default true). */
22
32
  closeOnBackdrop?: boolean;
23
33
  /** Hide the close (✕) button in the header (default true). */
@@ -44,13 +54,35 @@ const sideClasses: Record<DrawerSide, string> = {
44
54
  bottom: 'inset-x-0 bottom-0 border-t',
45
55
  };
46
56
 
57
+ const sideWidths: Record<DrawerSize, string> = {
58
+ sm: 'w-72',
59
+ md: 'w-96',
60
+ lg: 'w-[32rem]',
61
+ xl: 'w-[40rem]',
62
+ '2xl': 'w-[48rem]',
63
+ };
64
+
65
+ const sideHeights: Record<DrawerSize, string> = {
66
+ sm: 'h-48',
67
+ md: 'h-80',
68
+ lg: 'h-[32rem]',
69
+ xl: 'h-[40rem]',
70
+ '2xl': 'h-[48rem]',
71
+ };
72
+
47
73
  const sizeClasses: Record<DrawerSide, Record<DrawerSize, string>> = {
48
- right: { sm: 'w-72', md: 'w-96', lg: 'w-[32rem]' },
49
- left: { sm: 'w-72', md: 'w-96', lg: 'w-[32rem]' },
50
- top: { sm: 'h-48', md: 'h-80', lg: 'h-[32rem]' },
51
- bottom: { sm: 'h-48', md: 'h-80', lg: 'h-[32rem]' },
74
+ right: sideWidths,
75
+ left: sideWidths,
76
+ top: sideHeights,
77
+ bottom: sideHeights,
52
78
  };
53
79
 
80
+ /** Custom extent wins over the preset; numbers are pixels. */
81
+ function extentStyle(value: number | string | undefined): string | undefined {
82
+ if (value === undefined) return undefined;
83
+ return typeof value === 'number' ? `${value}px` : value;
84
+ }
85
+
54
86
  // Literal class maps so Tailwind's scanner keeps every slide utility.
55
87
  const slideIn: Record<DrawerSide, string> = {
56
88
  right: 'slide-in-from-right',
@@ -80,6 +112,8 @@ export function Drawer({
80
112
  children,
81
113
  side = 'right',
82
114
  size = 'md',
115
+ width,
116
+ height,
83
117
  closeOnBackdrop = true,
84
118
  showClose = true,
85
119
  initialFocusRef,
@@ -146,6 +180,9 @@ export function Drawer({
146
180
 
147
181
  if (!present || typeof document === 'undefined') return null;
148
182
 
183
+ const isVerticalSide = side === 'top' || side === 'bottom';
184
+ const customExtent = isVerticalSide ? extentStyle(height) : extentStyle(width);
185
+
149
186
  const motion = animate
150
187
  ? cn(
151
188
  'duration-200 motion-reduce:animate-none',
@@ -174,6 +211,7 @@ export function Drawer({
174
211
  sizeClasses[side][size],
175
212
  motion
176
213
  )}
214
+ style={customExtent ? { [isVerticalSide ? 'height' : 'width']: customExtent } : undefined}
177
215
  >
178
216
  <div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
179
217
  <div>
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { axe } from 'jest-axe';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { EmptyState } from './EmptyState';
5
+
6
+ describe('EmptyState', () => {
7
+ it('renders title, description and actions', () => {
8
+ render(
9
+ <EmptyState
10
+ title="No classes yet"
11
+ description="Create your first class to get started."
12
+ action={<button type="button">New class</button>}
13
+ secondaryAction={<a href="/docs">Learn more</a>}
14
+ />
15
+ );
16
+ expect(screen.getByText('No classes yet')).toBeInTheDocument();
17
+ expect(screen.getByRole('button', { name: 'New class' })).toBeInTheDocument();
18
+ expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument();
19
+ });
20
+
21
+ it('supports a compact size', () => {
22
+ const { container } = render(<EmptyState size="sm" title="Nothing here" />);
23
+ expect(container.firstChild).toHaveClass('py-6');
24
+ });
25
+
26
+ it('has no accessibility violations', async () => {
27
+ const { container } = render(<EmptyState title="Empty" description="Nothing to show" />);
28
+ expect(await axe(container)).toHaveNoViolations();
29
+ });
30
+ });
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export type EmptyStateProps = React.HTMLAttributes<HTMLDivElement> & {
5
+ /** Icon or illustration shown above the title. */
6
+ icon?: React.ReactNode;
7
+ title: string;
8
+ description?: string;
9
+ /** Primary action (button/link). */
10
+ action?: React.ReactNode;
11
+ /** Secondary action shown beside the primary one. */
12
+ secondaryAction?: React.ReactNode;
13
+ /** Compact padding for use inside panels and table bodies. */
14
+ size?: 'sm' | 'md';
15
+ };
16
+
17
+ /**
18
+ * Placeholder for empty collections, zero-result searches and unconfigured
19
+ * features. Keeps the title/description/action hierarchy consistent across
20
+ * the product.
21
+ */
22
+ export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
23
+ { icon, title, description, action, secondaryAction, size = 'md', className, children, ...props },
24
+ ref
25
+ ) {
26
+ return (
27
+ <div
28
+ ref={ref}
29
+ className={cn(
30
+ 'flex flex-col items-center justify-center rounded-lg border border-dashed border-border text-center',
31
+ size === 'md' ? 'gap-3 px-6 py-12' : 'gap-2 px-4 py-6',
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {icon && <div className="text-muted-foreground [&_svg]:size-8">{icon}</div>}
37
+ <p className={cn('font-semibold', size === 'md' ? 'text-base' : 'text-sm')}>{title}</p>
38
+ {description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
39
+ {children}
40
+ {(action || secondaryAction) && (
41
+ <div className="mt-1 flex flex-wrap items-center justify-center gap-2">
42
+ {action}
43
+ {secondaryAction}
44
+ </div>
45
+ )}
46
+ </div>
47
+ );
48
+ });
@@ -0,0 +1 @@
1
+ export * from './EmptyState';
@@ -0,0 +1,43 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { axe } from 'jest-axe';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { FileUpload } from './FileUpload';
6
+
7
+ function file(name: string, size = 4) {
8
+ return new File([new Uint8Array(size)], name, { type: 'text/plain' });
9
+ }
10
+
11
+ describe('FileUpload', () => {
12
+ it('lists selected files and reports changes', async () => {
13
+ const user = userEvent.setup();
14
+ const onFilesChange = vi.fn();
15
+ render(<FileUpload aria-label="Attachments" onFilesChange={onFilesChange} />);
16
+ await user.upload(screen.getByLabelText(/Drop files here/), [file('a.txt'), file('b.txt')]);
17
+ expect(onFilesChange).toHaveBeenCalledTimes(1);
18
+ expect(screen.getByText('a.txt')).toBeInTheDocument();
19
+ expect(screen.getByText('b.txt')).toBeInTheDocument();
20
+ });
21
+
22
+ it('removes a file and shows progress', async () => {
23
+ const user = userEvent.setup();
24
+ const onFilesChange = vi.fn();
25
+ render(
26
+ <FileUpload
27
+ aria-label="Uploads"
28
+ defaultFiles={[file('report.pdf')]}
29
+ progress={{ 'report.pdf': 42 }}
30
+ onFilesChange={onFilesChange}
31
+ />
32
+ );
33
+ expect(screen.getByRole('progressbar', { name: 'Uploading report.pdf' })).toHaveAttribute('aria-valuenow', '42');
34
+ await user.click(screen.getByRole('button', { name: 'Remove report.pdf' }));
35
+ expect(onFilesChange).toHaveBeenCalledWith([]);
36
+ expect(screen.queryByText('report.pdf')).not.toBeInTheDocument();
37
+ });
38
+
39
+ it('has no accessibility violations', async () => {
40
+ const { container } = render(<FileUpload aria-label="Files" />);
41
+ expect(await axe(container)).toHaveNoViolations();
42
+ });
43
+ });