@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
|
|
6
|
+
export type FileUploadProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> & {
|
|
7
|
+
/** Controlled selection. */
|
|
8
|
+
files?: File[];
|
|
9
|
+
/** Initial selection when uncontrolled. */
|
|
10
|
+
defaultFiles?: File[];
|
|
11
|
+
/** Fired whenever the list changes (add, remove, drop, clear). */
|
|
12
|
+
onFilesChange?: (files: File[]) => void;
|
|
13
|
+
/** Drag-and-drop area copy. */
|
|
14
|
+
label?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
/** Per-file progress (0-100) keyed by file name, rendered as a bar. */
|
|
17
|
+
progress?: Record<string, number>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatSize(bytes: number): string {
|
|
21
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
22
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
23
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drag-and-drop file picker with a removable list. The native input drives
|
|
28
|
+
* selection (keyboard + mobile camera/file dialogs); the drop zone is a
|
|
29
|
+
* progressive enhancement. Files are plain `File` objects, so you own the
|
|
30
|
+
* upload transport and can render progress per file.
|
|
31
|
+
*/
|
|
32
|
+
export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(function FileUpload(
|
|
33
|
+
{
|
|
34
|
+
files,
|
|
35
|
+
defaultFiles,
|
|
36
|
+
onFilesChange,
|
|
37
|
+
label = 'Drop files here or click to browse',
|
|
38
|
+
hint,
|
|
39
|
+
progress,
|
|
40
|
+
multiple = true,
|
|
41
|
+
accept,
|
|
42
|
+
disabled,
|
|
43
|
+
className,
|
|
44
|
+
id,
|
|
45
|
+
...props
|
|
46
|
+
},
|
|
47
|
+
ref
|
|
48
|
+
) {
|
|
49
|
+
const [internal, setInternal] = React.useState<File[]>(defaultFiles ?? []);
|
|
50
|
+
const [dragging, setDragging] = React.useState(false);
|
|
51
|
+
const current = files ?? internal;
|
|
52
|
+
const generatedId = React.useId();
|
|
53
|
+
const inputId = id ?? generatedId;
|
|
54
|
+
|
|
55
|
+
const commit = (next: File[]) => {
|
|
56
|
+
if (files === undefined) setInternal(next);
|
|
57
|
+
onFilesChange?.(next);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const add = (incoming: FileList | null) => {
|
|
61
|
+
if (!incoming || incoming.length === 0) return;
|
|
62
|
+
const list = Array.from(incoming);
|
|
63
|
+
commit(multiple ? [...current, ...list] : list.slice(0, 1));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
68
|
+
<div
|
|
69
|
+
onDragOver={(event) => {
|
|
70
|
+
if (disabled) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
setDragging(true);
|
|
73
|
+
}}
|
|
74
|
+
onDragLeave={() => setDragging(false)}
|
|
75
|
+
onDrop={(event) => {
|
|
76
|
+
if (disabled) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
setDragging(false);
|
|
79
|
+
add(event.dataTransfer.files);
|
|
80
|
+
}}
|
|
81
|
+
className={cn(
|
|
82
|
+
'rounded-lg border border-dashed border-border p-6 text-center transition-colors',
|
|
83
|
+
dragging && 'border-primary bg-primary/5',
|
|
84
|
+
disabled && 'opacity-50'
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<label htmlFor={inputId} className={cn('block text-sm', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
|
88
|
+
{label}
|
|
89
|
+
<input
|
|
90
|
+
ref={ref}
|
|
91
|
+
id={inputId}
|
|
92
|
+
type="file"
|
|
93
|
+
multiple={multiple}
|
|
94
|
+
accept={accept}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
className="sr-only"
|
|
97
|
+
onChange={(event) => {
|
|
98
|
+
add(event.target.files);
|
|
99
|
+
event.target.value = '';
|
|
100
|
+
}}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
</label>
|
|
104
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
105
|
+
{hint ?? (accept ? `Accepted: ${accept}` : 'Any file type')}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{current.length > 0 && (
|
|
110
|
+
<ul aria-label="Selected files" className="space-y-2">
|
|
111
|
+
{current.map((file) => (
|
|
112
|
+
<li key={file.name} className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
|
|
113
|
+
<div className="min-w-0">
|
|
114
|
+
<p className="truncate text-sm">{file.name}</p>
|
|
115
|
+
<p className="text-xs text-muted-foreground">{formatSize(file.size)}</p>
|
|
116
|
+
{progress?.[file.name] !== undefined && (
|
|
117
|
+
<div
|
|
118
|
+
role="progressbar"
|
|
119
|
+
aria-valuenow={progress[file.name]}
|
|
120
|
+
aria-valuemin={0}
|
|
121
|
+
aria-valuemax={100}
|
|
122
|
+
aria-label={`Uploading ${file.name}`}
|
|
123
|
+
className="mt-1 h-1.5 w-40 overflow-hidden rounded-full bg-muted"
|
|
124
|
+
>
|
|
125
|
+
<div className="h-full bg-primary" style={{ width: `${progress[file.name]}%` }} />
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => commit(current.filter((entry) => entry.name !== file.name))}
|
|
132
|
+
aria-label={`Remove ${file.name}`}
|
|
133
|
+
className="rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
134
|
+
>
|
|
135
|
+
✕
|
|
136
|
+
</button>
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FileUpload';
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { FilterBar } from './FilterBar';
|
|
6
|
+
|
|
7
|
+
describe('FilterBar', () => {
|
|
8
|
+
it('reports search input changes', async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
const onSearchChange = vi.fn();
|
|
11
|
+
render(<FilterBar searchValue="" onSearchChange={onSearchChange} searchPlaceholder="Search classes" />);
|
|
12
|
+
await user.type(screen.getByRole('searchbox', { name: 'Search classes' }), 'ab');
|
|
13
|
+
expect(onSearchChange).toHaveBeenCalled();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('shows a reset button with the active count and fires it', async () => {
|
|
17
|
+
const user = userEvent.setup();
|
|
18
|
+
const onReset = vi.fn();
|
|
19
|
+
render(<FilterBar onReset={onReset} activeCount={2}><span>filters</span></FilterBar>);
|
|
20
|
+
await user.click(screen.getByRole('button', { name: 'Reset (2)' }));
|
|
21
|
+
expect(onReset).toHaveBeenCalledOnce();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('has no accessibility violations', async () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<FilterBar searchValue="" onSearchChange={() => undefined} onReset={() => undefined}>
|
|
27
|
+
<select aria-label="Status"><option>All</option></select>
|
|
28
|
+
</FilterBar>
|
|
29
|
+
);
|
|
30
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { Button } from '../button/Button';
|
|
6
|
+
import { Input } from '../input/Input';
|
|
7
|
+
|
|
8
|
+
export type FilterBarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
9
|
+
/** Controlled search text. */
|
|
10
|
+
searchValue?: string;
|
|
11
|
+
onSearchChange?: (value: string) => void;
|
|
12
|
+
/** Search placeholder; the search box is hidden when omitted. */
|
|
13
|
+
searchPlaceholder?: string;
|
|
14
|
+
/** Rendered after the search box — selects, toggles, date pickers. */
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
/** Shows a reset button when provided (pass the active filter count for the label). */
|
|
17
|
+
onReset?: () => void;
|
|
18
|
+
activeCount?: number;
|
|
19
|
+
/** Rendered at the far right (bulk actions, column settings). */
|
|
20
|
+
trailing?: React.ReactNode;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Horizontal filter strip for list pages: search field, filter controls and
|
|
25
|
+
* a reset affordance. Scrolls horizontally on narrow screens instead of
|
|
26
|
+
* wrapping into a tall stack.
|
|
27
|
+
*/
|
|
28
|
+
export const FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(function FilterBar(
|
|
29
|
+
{
|
|
30
|
+
searchValue,
|
|
31
|
+
onSearchChange,
|
|
32
|
+
searchPlaceholder = 'Search…',
|
|
33
|
+
onReset,
|
|
34
|
+
activeCount = 0,
|
|
35
|
+
trailing,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
ref
|
|
41
|
+
) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
ref={ref}
|
|
45
|
+
role="search"
|
|
46
|
+
className={cn(
|
|
47
|
+
'flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card p-2',
|
|
48
|
+
className
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{onSearchChange !== undefined && (
|
|
53
|
+
<Input
|
|
54
|
+
type="search"
|
|
55
|
+
value={searchValue}
|
|
56
|
+
onChange={(event) => onSearchChange(event.target.value)}
|
|
57
|
+
placeholder={searchPlaceholder}
|
|
58
|
+
aria-label={searchPlaceholder}
|
|
59
|
+
className="h-9 w-full min-w-40 flex-1 sm:w-auto"
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-wrap items-center gap-2">{children}</div>
|
|
63
|
+
{onReset && (
|
|
64
|
+
<Button type="button" variant="ghost" size="sm" onClick={onReset}>
|
|
65
|
+
Reset{activeCount > 0 ? ` (${activeCount})` : ''}
|
|
66
|
+
</Button>
|
|
67
|
+
)}
|
|
68
|
+
{trailing && <div className="ml-auto flex items-center gap-2">{trailing}</div>}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FilterBar';
|
|
@@ -15,9 +15,12 @@ describe('InputGroup', () => {
|
|
|
15
15
|
);
|
|
16
16
|
const group = container.querySelector('[data-slot="input-group"]')!;
|
|
17
17
|
expect(group).toHaveClass('border', 'rounded-md', 'focus-within:ring-2');
|
|
18
|
-
// Chrome-stripping rides on descendant selectors in the
|
|
19
|
-
|
|
20
|
-
expect(group.className).toContain('[&_input]
|
|
18
|
+
// Chrome-stripping rides on important descendant selectors in the
|
|
19
|
+
// group's class so no inner border/outline can show at addon seams.
|
|
20
|
+
expect(group.className).toContain('[&_input]:!border-0');
|
|
21
|
+
expect(group.className).toContain('[&_input]:!bg-transparent');
|
|
22
|
+
expect(group.className).toContain('[&_input]:!ring-0');
|
|
23
|
+
expect(group.className).toContain('[&_input]:!outline-none');
|
|
21
24
|
expect(group.className).toContain('[&_input]:flex-1');
|
|
22
25
|
expect(group.querySelector('input')).toHaveAttribute('placeholder', 'Search');
|
|
23
26
|
});
|
|
@@ -56,12 +56,15 @@ const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
|
|
56
56
|
className={cn(
|
|
57
57
|
'flex h-10 w-full items-center overflow-hidden rounded-md border border-input bg-background',
|
|
58
58
|
'ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
|
59
|
-
// The group is the
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'[&
|
|
59
|
+
// The group's outline is the only outline: force-strip every
|
|
60
|
+
// nested field's border, radius, ring, shadow and UA focus outline.
|
|
61
|
+
// Important flags defeat both the component's own utilities
|
|
62
|
+
// (Input ships `border` + `focus-visible:ring-2`) and browser
|
|
63
|
+
// defaults — no seams appear where an addon sits flush.
|
|
64
|
+
'[&_input]:h-full [&_input]:min-w-0 [&_input]:flex-1 [&_input]:rounded-none [&_input]:px-3 [&_input]:py-2',
|
|
65
|
+
'[&_input]:!border-0 [&_input]:!bg-transparent [&_input]:!shadow-none [&_input]:!outline-none [&_input]:!ring-0 [&_input]:!ring-offset-0',
|
|
66
|
+
'[&_textarea]:!border-0 [&_textarea]:!rounded-none [&_textarea]:!bg-transparent [&_textarea]:!shadow-none [&_textarea]:!outline-none [&_textarea]:!ring-0 [&_textarea]:!ring-offset-0',
|
|
67
|
+
'[&_select]:!border-0 [&_select]:!rounded-none [&_select]:!bg-transparent [&_select]:!shadow-none [&_select]:!outline-none [&_select]:!ring-0 [&_select]:!ring-offset-0',
|
|
65
68
|
'[&_input:disabled]:cursor-not-allowed',
|
|
66
69
|
className
|
|
67
70
|
)}
|
|
@@ -88,3 +88,24 @@ describe('Menu', () => {
|
|
|
88
88
|
expect(await axe(container)).toHaveNoViolations();
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
describe('MenuItem asChild', () => {
|
|
94
|
+
it('renders a link with menuitem semantics', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
render(
|
|
97
|
+
<Menu>
|
|
98
|
+
<MenuTrigger>Options</MenuTrigger>
|
|
99
|
+
<MenuContent>
|
|
100
|
+
<MenuItem asChild>
|
|
101
|
+
<a href="/settings">Settings</a>
|
|
102
|
+
</MenuItem>
|
|
103
|
+
</MenuContent>
|
|
104
|
+
</Menu>
|
|
105
|
+
);
|
|
106
|
+
await user.click(screen.getByRole('button', { name: 'Options' }));
|
|
107
|
+
const item = screen.getByRole('menuitem', { name: 'Settings' });
|
|
108
|
+
expect(item.tagName).toBe('A');
|
|
109
|
+
expect(item).toHaveAttribute('href', '/settings');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
5
6
|
import { cn } from '../../utils/cn';
|
|
6
7
|
|
|
7
8
|
type MenuContextValue = {
|
|
@@ -214,16 +215,20 @@ MenuContent.displayName = 'MenuContent';
|
|
|
214
215
|
export type MenuItemProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
215
216
|
/** Renders the item in a destructive style. */
|
|
216
217
|
destructive?: boolean;
|
|
218
|
+
/** Render the child element (e.g. an <a>) as the menu item. */
|
|
219
|
+
asChild?: boolean;
|
|
217
220
|
};
|
|
218
221
|
|
|
219
222
|
/** A single action item in the menu. */
|
|
220
223
|
const MenuItem = React.forwardRef<HTMLButtonElement, MenuItemProps>(
|
|
221
|
-
({ className, destructive, onClick, ...props }, ref) => {
|
|
224
|
+
({ className, destructive, asChild = false, onClick, ...props }, ref) => {
|
|
222
225
|
const { setOpen } = React.useContext(MenuContext);
|
|
226
|
+
const Comp = asChild ? Slot : 'button';
|
|
227
|
+
const typeProp = asChild ? {} : { type: 'button' as const };
|
|
223
228
|
return (
|
|
224
|
-
<
|
|
229
|
+
<Comp
|
|
225
230
|
ref={ref}
|
|
226
|
-
|
|
231
|
+
{...typeProp}
|
|
227
232
|
role="menuitem"
|
|
228
233
|
onClick={(event) => {
|
|
229
234
|
onClick?.(event);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { axe } from 'jest-axe';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { MetricCard } from './MetricCard';
|
|
5
|
+
|
|
6
|
+
describe('MetricCard', () => {
|
|
7
|
+
it('renders label, value, delta and hint with a direction glyph', () => {
|
|
8
|
+
render(<MetricCard label="Revenue" value="$12,480" delta="+12.4%" trend="up" hint="vs last month" />);
|
|
9
|
+
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText('$12,480')).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText(/12.4%/)).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText('vs last month')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('colors trends but never relies on color alone', () => {
|
|
16
|
+
const { rerender } = render(<MetricCard label="Churn" value="2.1%" delta="-0.4" trend="down" />);
|
|
17
|
+
const delta = screen.getByText(/-0.4/);
|
|
18
|
+
expect(delta).toHaveClass('text-destructive');
|
|
19
|
+
expect(delta.textContent).toContain('↓');
|
|
20
|
+
rerender(<MetricCard label="Churn" value="2.1%" delta="0" trend="flat" />);
|
|
21
|
+
expect(screen.getByText(/0/).textContent).toContain('→');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('has no accessibility violations', async () => {
|
|
25
|
+
const { container } = render(<MetricCard label="Users" value={1234} delta="+3%" trend="up" />);
|
|
26
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
|
|
4
|
+
export type MetricTrend = 'up' | 'down' | 'flat';
|
|
5
|
+
|
|
6
|
+
export type MetricCardProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> & {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string | number;
|
|
9
|
+
/** Secondary line under the value. */
|
|
10
|
+
hint?: string;
|
|
11
|
+
/** Signed change, e.g. "+12.4%" or "-3". */
|
|
12
|
+
delta?: string;
|
|
13
|
+
/** Direction colors the delta (green up / red down / muted flat). */
|
|
14
|
+
trend?: MetricTrend;
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const trendClasses: Record<MetricTrend, string> = {
|
|
19
|
+
up: 'text-success',
|
|
20
|
+
down: 'text-destructive',
|
|
21
|
+
flat: 'text-muted-foreground',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const trendGlyph: Record<MetricTrend, string> = { up: '↑', down: '↓', flat: '→' };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compact KPI tile: label, value, optional delta with direction color and a
|
|
28
|
+
* supporting hint. Deltas always carry an arrow glyph so the direction is
|
|
29
|
+
* never communicated by color alone.
|
|
30
|
+
*/
|
|
31
|
+
export const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(function MetricCard(
|
|
32
|
+
{ label, value, hint, delta, trend = 'flat', icon, className, ...props },
|
|
33
|
+
ref
|
|
34
|
+
) {
|
|
35
|
+
return (
|
|
36
|
+
<div ref={ref} className={cn('rounded-xl border border-border bg-card p-5', className)} {...props}>
|
|
37
|
+
<div className="flex items-start justify-between gap-3">
|
|
38
|
+
<p className="text-sm text-muted-foreground">{label}</p>
|
|
39
|
+
{icon && <span className="text-muted-foreground [&_svg]:size-4">{icon}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
|
|
42
|
+
{(delta || hint) && (
|
|
43
|
+
<p className="mt-1 flex items-center gap-2 text-sm">
|
|
44
|
+
{delta && (
|
|
45
|
+
<span className={cn('font-medium tabular-nums', trendClasses[trend])}>
|
|
46
|
+
<span aria-hidden="true">{trendGlyph[trend]}</span> {delta}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
{hint && <span className="text-muted-foreground">{hint}</span>}
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './MetricCard';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { axe } from 'jest-axe';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { PageHeader } from './PageHeader';
|
|
5
|
+
|
|
6
|
+
describe('PageHeader', () => {
|
|
7
|
+
it('renders a level-1 heading with description and actions', () => {
|
|
8
|
+
render(
|
|
9
|
+
<PageHeader
|
|
10
|
+
title="Classes"
|
|
11
|
+
description="Manage your weekly schedule."
|
|
12
|
+
actions={<button type="button">New</button>}
|
|
13
|
+
breadcrumbs={<a href="/">Home</a>}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Classes' })).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('Manage your weekly schedule.')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders children under the title row', () => {
|
|
23
|
+
render(<PageHeader title="Reports"><span>tabs</span></PageHeader>);
|
|
24
|
+
expect(screen.getByText('tabs')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('has no accessibility violations', async () => {
|
|
28
|
+
const { container } = render(<PageHeader title="Dashboard" description="Overview" />);
|
|
29
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
|
|
4
|
+
export type PageHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Breadcrumbs/eyebrow above the title. */
|
|
8
|
+
breadcrumbs?: React.ReactNode;
|
|
9
|
+
/** Right-aligned actions (buttons, menus). */
|
|
10
|
+
actions?: React.ReactNode;
|
|
11
|
+
/** Small slot under the title row (tabs, filters). */
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Standard page heading block: optional breadcrumbs, title, description and
|
|
17
|
+
* a right-aligned action cluster. Pages stop inventing their own spacing
|
|
18
|
+
* and heading hierarchy.
|
|
19
|
+
*/
|
|
20
|
+
export const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(function PageHeader(
|
|
21
|
+
{ title, description, breadcrumbs, actions, className, children, ...props },
|
|
22
|
+
ref
|
|
23
|
+
) {
|
|
24
|
+
return (
|
|
25
|
+
<div ref={ref} className={cn('flex flex-col gap-2', className)} {...props}>
|
|
26
|
+
{breadcrumbs && <div className="text-sm text-muted-foreground">{breadcrumbs}</div>}
|
|
27
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
28
|
+
<div className="min-w-0">
|
|
29
|
+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
30
|
+
{description && <p className="mt-1 max-w-2xl text-sm text-muted-foreground">{description}</p>}
|
|
31
|
+
</div>
|
|
32
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
33
|
+
</div>
|
|
34
|
+
{children && <div className="mt-2">{children}</div>}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './PageHeader';
|
|
@@ -88,3 +88,28 @@ describe('Pagination boundaries and icons', () => {
|
|
|
88
88
|
expect(screen.getByRole('button', { name: 'Last page' })).toHaveTextContent('⏭');
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
describe('Pagination contracts and summary', () => {
|
|
94
|
+
it('accepts currentPage/totalPages aliases', () => {
|
|
95
|
+
render(<Pagination currentPage={3} totalPages={5} />);
|
|
96
|
+
expect(screen.getByRole('button', { name: 'Page 3' })).toHaveAttribute('aria-current', 'page');
|
|
97
|
+
expect(screen.getByRole('button', { name: 'Page 5' })).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('derives the page count and renders a summary from total/pageSize', () => {
|
|
101
|
+
render(<Pagination page={2} pageSize={25} total={312} itemLabel="students" />);
|
|
102
|
+
expect(screen.getByText('Showing 26–50 of 312 students')).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByRole('button', { name: 'Page 13' })).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('can hide the summary explicitly', () => {
|
|
107
|
+
render(<Pagination page={1} pageSize={10} total={40} showSummary={false} />);
|
|
108
|
+
expect(screen.queryByText(/Showing/)).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('falls back to a page summary when totals are absent', () => {
|
|
112
|
+
render(<Pagination page={2} currentPage={2} pageSize={10} total={40} itemLabel="rows" />);
|
|
113
|
+
expect(screen.getByText('Showing 11–20 of 40 rows')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -15,10 +15,22 @@ export type PaginationIcons = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export type PaginationProps = React.HTMLAttributes<HTMLElement> & {
|
|
18
|
-
/** Current page number (1-based). */
|
|
19
|
-
page
|
|
20
|
-
/** Total number of pages. */
|
|
21
|
-
pageCount
|
|
18
|
+
/** Current page number (1-based). Alias: currentPage. */
|
|
19
|
+
page?: number;
|
|
20
|
+
/** Total number of pages. Alias: totalPages (or derived from total/pageSize). */
|
|
21
|
+
pageCount?: number;
|
|
22
|
+
/** Alias for page (migration-friendly). */
|
|
23
|
+
currentPage?: number;
|
|
24
|
+
/** Alias for pageCount. */
|
|
25
|
+
totalPages?: number;
|
|
26
|
+
/** Rows per page — with `total`, enables the item-count summary. */
|
|
27
|
+
pageSize?: number;
|
|
28
|
+
/** Total item count — with `pageSize`, enables the summary and derives pageCount. */
|
|
29
|
+
total?: number;
|
|
30
|
+
/** Noun used in the summary ("Showing 26–50 of 312 classes"). Default "items". */
|
|
31
|
+
itemLabel?: string;
|
|
32
|
+
/** Force the summary on/off; defaults to on when total and pageSize are set. */
|
|
33
|
+
showSummary?: boolean;
|
|
22
34
|
/** Called with the new page number. */
|
|
23
35
|
onPageChange?: (page: number) => void;
|
|
24
36
|
/** How many page buttons to show around the current page (default 1). */
|
|
@@ -38,16 +50,22 @@ const controlClass =
|
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
52
|
* Numeric pagination with prev/next and optional boundary jumps. Renders
|
|
41
|
-
* as a nav landmark with aria-current="page" on the active button.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
53
|
+
* as a nav landmark with aria-current="page" on the active button. Accepts
|
|
54
|
+
* either page/pageCount or the currentPage/totalPages aliases, and can
|
|
55
|
+
* derive the page count (plus a "Showing X–Y of Z" summary) from
|
|
56
|
+
* total/pageSize.
|
|
45
57
|
*/
|
|
46
58
|
const Pagination = React.forwardRef<HTMLElement, PaginationProps>(
|
|
47
59
|
(
|
|
48
60
|
{
|
|
49
|
-
page,
|
|
50
|
-
pageCount,
|
|
61
|
+
page: pageProp,
|
|
62
|
+
pageCount: pageCountProp,
|
|
63
|
+
currentPage,
|
|
64
|
+
totalPages,
|
|
65
|
+
pageSize,
|
|
66
|
+
total,
|
|
67
|
+
itemLabel = 'items',
|
|
68
|
+
showSummary,
|
|
51
69
|
onPageChange,
|
|
52
70
|
siblings = 1,
|
|
53
71
|
showBoundaries = false,
|
|
@@ -59,6 +77,11 @@ const Pagination = React.forwardRef<HTMLElement, PaginationProps>(
|
|
|
59
77
|
},
|
|
60
78
|
ref
|
|
61
79
|
) => {
|
|
80
|
+
const page = pageProp ?? currentPage ?? 1;
|
|
81
|
+
const derivedPageCount =
|
|
82
|
+
total !== undefined && pageSize !== undefined && pageSize > 0 ? Math.ceil(total / pageSize) : undefined;
|
|
83
|
+
const pageCount = Math.max(1, pageCountProp ?? totalPages ?? derivedPageCount ?? 1);
|
|
84
|
+
|
|
62
85
|
// Build the page number list with ellipsis markers.
|
|
63
86
|
const pages: (number | 'ellipsis')[] = [];
|
|
64
87
|
const start = Math.max(2, page - siblings);
|
|
@@ -73,11 +96,16 @@ const Pagination = React.forwardRef<HTMLElement, PaginationProps>(
|
|
|
73
96
|
const withFirst = showFirst ?? showBoundaries;
|
|
74
97
|
const withLast = showLast ?? showBoundaries;
|
|
75
98
|
|
|
76
|
-
|
|
99
|
+
const summaryEnabled = showSummary ?? (total !== undefined && pageSize !== undefined);
|
|
100
|
+
const rangeStart = pageSize !== undefined ? (page - 1) * pageSize + 1 : undefined;
|
|
101
|
+
const rangeEnd =
|
|
102
|
+
pageSize !== undefined && total !== undefined ? Math.min(total, page * pageSize) : undefined;
|
|
103
|
+
|
|
104
|
+
const nav = (
|
|
77
105
|
<nav
|
|
78
106
|
ref={ref}
|
|
79
107
|
aria-label="Pagination"
|
|
80
|
-
className={cn('flex items-center justify-center gap-1', className)}
|
|
108
|
+
className={cn('flex items-center justify-center gap-1', summaryEnabled ? undefined : className)}
|
|
81
109
|
{...props}
|
|
82
110
|
>
|
|
83
111
|
{withFirst && (
|
|
@@ -150,6 +178,19 @@ const Pagination = React.forwardRef<HTMLElement, PaginationProps>(
|
|
|
150
178
|
)}
|
|
151
179
|
</nav>
|
|
152
180
|
);
|
|
181
|
+
|
|
182
|
+
if (!summaryEnabled) return nav;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
|
|
186
|
+
<p className="text-sm text-muted-foreground">
|
|
187
|
+
{rangeStart !== undefined && rangeEnd !== undefined && total !== undefined
|
|
188
|
+
? `Showing ${rangeStart}–${rangeEnd} of ${total} ${itemLabel}`
|
|
189
|
+
: `Page ${page} of ${pageCount}`}
|
|
190
|
+
</p>
|
|
191
|
+
{nav}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
153
194
|
}
|
|
154
195
|
);
|
|
155
196
|
Pagination.displayName = 'Pagination';
|