@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.
- package/dist/index.d.mts +672 -52
- package/dist/index.d.ts +672 -52
- package/dist/index.js +1860 -657
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1836 -655
- package/dist/index.mjs.map +1 -1
- package/dist/manifests/app-shell.manifest.json +156 -0
- package/dist/manifests/auth-layout.manifest.json +99 -0
- package/dist/manifests/autocomplete.manifest.json +53 -5
- package/dist/manifests/badge.manifest.json +12 -0
- package/dist/manifests/button.manifest.json +6 -2
- package/dist/manifests/card.manifest.json +53 -6
- package/dist/manifests/date-picker.manifest.json +87 -0
- package/dist/manifests/dialog.manifest.json +49 -4
- package/dist/manifests/drawer.manifest.json +18 -4
- package/dist/manifests/empty-state.manifest.json +108 -0
- package/dist/manifests/file-upload.manifest.json +103 -0
- package/dist/manifests/filter-bar.manifest.json +100 -0
- package/dist/manifests/menu.manifest.json +13 -1
- package/dist/manifests/metric-card.manifest.json +105 -0
- package/dist/manifests/page-header.manifest.json +94 -0
- package/dist/manifests/pagination.manifest.json +62 -34
- package/dist/manifests/password-input.manifest.json +75 -0
- package/dist/manifests/select-menu.manifest.json +142 -0
- package/dist/manifests/select.manifest.json +49 -12
- package/dist/manifests/stepper.manifest.json +95 -0
- package/dist/manifests/table-card.manifest.json +123 -0
- package/dist/manifests/time-picker.manifest.json +86 -0
- package/dist/manifests/timeline.manifest.json +69 -0
- package/dist/manifests/toast.manifest.json +101 -25
- package/package.json +1 -1
- package/src/components/app-shell/AppShell.test.tsx +100 -0
- package/src/components/app-shell/AppShell.tsx +162 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/auth-layout/AuthLayout.test.tsx +34 -0
- package/src/components/auth-layout/AuthLayout.tsx +47 -0
- package/src/components/auth-layout/index.ts +1 -0
- package/src/components/autocomplete/Autocomplete.test.tsx +53 -0
- package/src/components/autocomplete/Autocomplete.tsx +160 -41
- package/src/components/badge/Badge.test.tsx +14 -0
- package/src/components/badge/Badge.tsx +10 -4
- package/src/components/button/Button.test.tsx +29 -0
- package/src/components/button/Button.tsx +10 -0
- package/src/components/button-group/ButtonGroup.test.tsx +6 -8
- package/src/components/button-group/ButtonGroup.tsx +17 -9
- package/src/components/card/Card.test.tsx +14 -0
- package/src/components/card/Card.tsx +15 -8
- package/src/components/date-picker/DatePicker.test.tsx +30 -0
- package/src/components/date-picker/DatePicker.tsx +57 -0
- package/src/components/date-picker/index.ts +1 -0
- package/src/components/dialog/Dialog.test.tsx +64 -1
- package/src/components/dialog/Dialog.tsx +113 -23
- package/src/components/dialog/index.ts +1 -2
- package/src/components/drawer/Drawer.test.tsx +50 -0
- package/src/components/drawer/Drawer.tsx +44 -6
- package/src/components/empty-state/EmptyState.test.tsx +30 -0
- package/src/components/empty-state/EmptyState.tsx +48 -0
- package/src/components/empty-state/index.ts +1 -0
- package/src/components/file-upload/FileUpload.test.tsx +43 -0
- package/src/components/file-upload/FileUpload.tsx +143 -0
- package/src/components/file-upload/index.ts +1 -0
- package/src/components/filter-bar/FilterBar.test.tsx +32 -0
- package/src/components/filter-bar/FilterBar.tsx +71 -0
- package/src/components/filter-bar/index.ts +1 -0
- package/src/components/input-group/InputGroup.test.tsx +6 -3
- package/src/components/input-group/InputGroup.tsx +9 -6
- package/src/components/menu/Menu.test.tsx +21 -0
- package/src/components/menu/Menu.tsx +8 -3
- package/src/components/metric-card/MetricCard.test.tsx +28 -0
- package/src/components/metric-card/MetricCard.tsx +54 -0
- package/src/components/metric-card/index.ts +1 -0
- package/src/components/page-header/PageHeader.test.tsx +31 -0
- package/src/components/page-header/PageHeader.tsx +37 -0
- package/src/components/page-header/index.ts +1 -0
- package/src/components/pagination/Pagination.test.tsx +25 -0
- package/src/components/pagination/Pagination.tsx +53 -12
- package/src/components/password-input/PasswordInput.test.tsx +29 -0
- package/src/components/password-input/PasswordInput.tsx +41 -0
- package/src/components/password-input/index.ts +1 -0
- package/src/components/select/Select.test.tsx +40 -1
- package/src/components/select/Select.tsx +52 -14
- package/src/components/select/index.ts +1 -1
- package/src/components/select-menu/SelectMenu.test.tsx +105 -0
- package/src/components/select-menu/SelectMenu.tsx +368 -0
- package/src/components/select-menu/index.ts +1 -0
- package/src/components/stepper/Stepper.test.tsx +35 -0
- package/src/components/stepper/Stepper.tsx +91 -0
- package/src/components/stepper/index.ts +1 -0
- package/src/components/table-card/TableCard.test.tsx +59 -0
- package/src/components/table-card/TableCard.tsx +48 -0
- package/src/components/table-card/index.ts +1 -0
- package/src/components/time-picker/TimePicker.test.tsx +30 -0
- package/src/components/time-picker/TimePicker.tsx +56 -0
- package/src/components/time-picker/index.ts +1 -0
- package/src/components/timeline/Timeline.test.tsx +26 -0
- package/src/components/timeline/Timeline.tsx +53 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/toast/Toast.test.tsx +5 -0
- package/src/components/toast/Toast.tsx +2 -16
- package/src/components/toast/Toaster.test.tsx +84 -0
- package/src/components/toast/Toaster.tsx +116 -0
- package/src/components/toast/index.ts +4 -0
- package/src/components/toast/toast-store.ts +117 -0
- package/src/index.ts +14 -0
- package/src/manifests/app-shell.manifest.json +156 -0
- package/src/manifests/auth-layout.manifest.json +99 -0
- package/src/manifests/autocomplete.manifest.json +53 -5
- package/src/manifests/badge.manifest.json +12 -0
- package/src/manifests/button.manifest.json +6 -2
- package/src/manifests/card.manifest.json +53 -6
- package/src/manifests/date-picker.manifest.json +87 -0
- package/src/manifests/dialog.manifest.json +49 -4
- package/src/manifests/drawer.manifest.json +18 -4
- package/src/manifests/empty-state.manifest.json +108 -0
- package/src/manifests/file-upload.manifest.json +103 -0
- package/src/manifests/filter-bar.manifest.json +100 -0
- package/src/manifests/menu.manifest.json +13 -1
- package/src/manifests/metric-card.manifest.json +105 -0
- package/src/manifests/page-header.manifest.json +94 -0
- package/src/manifests/pagination.manifest.json +62 -34
- package/src/manifests/password-input.manifest.json +75 -0
- package/src/manifests/select-menu.manifest.json +142 -0
- package/src/manifests/select.manifest.json +49 -12
- package/src/manifests/stepper.manifest.json +95 -0
- package/src/manifests/table-card.manifest.json +123 -0
- package/src/manifests/time-picker.manifest.json +86 -0
- package/src/manifests/timeline.manifest.json +69 -0
- 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
|
|
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(
|
|
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
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
49
|
-
left:
|
|
50
|
-
top:
|
|
51
|
-
bottom:
|
|
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
|
+
});
|