@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
@@ -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 group's class.
19
- expect(group.className).toContain('[&_input]:border-0');
20
- expect(group.className).toContain('[&_input]:bg-transparent');
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 control: strip chrome from nested fields so
60
- // only the outer border shows. Literal variants survive purging.
61
- '[&_input]:min-w-0 [&_input]:flex-1 [&_input]:border-0 [&_input]:bg-transparent [&_input]:px-3 [&_input]:py-2 [&_input]:shadow-none',
62
- '[&_input]:focus-visible:ring-0 [&_input]:focus-visible:ring-offset-0',
63
- '[&_textarea]:border-0 [&_textarea]:bg-transparent [&_textarea]:shadow-none [&_textarea]:focus-visible:ring-0',
64
- '[&_select]:border-0 [&_select]:bg-transparent [&_select]:shadow-none [&_select]:focus-visible:ring-0',
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
- <button
229
+ <Comp
225
230
  ref={ref}
226
- type="button"
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: number;
20
- /** Total number of pages. */
21
- pageCount: number;
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. Each
42
- * control is a real <button> with an accessible name; glyphs are
43
- * swappable and each boundary button can be shown or hidden independently
44
- * (e.g. hide — instead of disabling — once you reach the list edge).
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
- return (
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';