@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,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "timeline",
|
|
3
|
+
"name": "Timeline",
|
|
4
|
+
"tier": "molecule",
|
|
5
|
+
"description": "Vertical chronology for experience, activity and audit trails, built on an ordered list.",
|
|
6
|
+
"designIntent": "Chronologies communicate order. Rendering them as an ordered list keeps the sequence structural for assistive tech, while the connector rule and dots provide the visual rhythm.",
|
|
7
|
+
"props": [
|
|
8
|
+
{
|
|
9
|
+
"name": "items",
|
|
10
|
+
"type": "{ title: string; period?: string; description?: string; icon?: ReactNode; muted?: boolean }[]",
|
|
11
|
+
"required": true,
|
|
12
|
+
"description": "Ordered timeline entries."
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"examples": [
|
|
16
|
+
{
|
|
17
|
+
"title": "Experience",
|
|
18
|
+
"code": "<Timeline\n items={[\n { title: 'Senior Engineer', period: '2023 — Present', description: 'Platform team' },\n { title: 'Engineer', period: '2020 — 2023' },\n ]}\n/>",
|
|
19
|
+
"description": "Period label, title and supporting line per entry."
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"antiPatterns": [
|
|
23
|
+
{
|
|
24
|
+
"title": "Don't reverse the DOM order for newest-first",
|
|
25
|
+
"explanation": "Reverse the array instead so reading order matches visual order.",
|
|
26
|
+
"example": "// Avoid: flex-col-reverse\n// Prefer: items={items.slice().reverse()}"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"accessibility": [
|
|
30
|
+
{
|
|
31
|
+
"feature": "Sequence",
|
|
32
|
+
"implementation": "Ordered list semantics",
|
|
33
|
+
"userRequirements": "Keep entries in chronological order in the array"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"contentGuidelines": [
|
|
37
|
+
{
|
|
38
|
+
"contentType": "Periods",
|
|
39
|
+
"guidelines": [
|
|
40
|
+
"Use a consistent format",
|
|
41
|
+
"State Present for ongoing"
|
|
42
|
+
],
|
|
43
|
+
"goodExamples": [
|
|
44
|
+
"2023 — Present",
|
|
45
|
+
"Jan 2024"
|
|
46
|
+
],
|
|
47
|
+
"badExamples": [
|
|
48
|
+
"Some time ago"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"compositionRules": [
|
|
53
|
+
{
|
|
54
|
+
"component": "Card",
|
|
55
|
+
"relationship": "Often embedded in profile/about cards",
|
|
56
|
+
"example": "<Card><CardContent><Timeline items={roles} /></CardContent></Card>"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"relatedComponents": [
|
|
60
|
+
"List",
|
|
61
|
+
"Card",
|
|
62
|
+
"Separator"
|
|
63
|
+
],
|
|
64
|
+
"package": "@slipstream-ui/core",
|
|
65
|
+
"importPath": "@slipstream-ui/core",
|
|
66
|
+
"version": "0.1.0",
|
|
67
|
+
"sourceDir": "timeline",
|
|
68
|
+
"exportName": "Timeline"
|
|
69
|
+
}
|
|
@@ -2,47 +2,99 @@
|
|
|
2
2
|
"id": "toast",
|
|
3
3
|
"name": "Toast",
|
|
4
4
|
"tier": "molecule",
|
|
5
|
-
"description": "Accessible
|
|
6
|
-
"designIntent": "
|
|
5
|
+
"description": "Accessible notifications: tree-scoped provider/hook plus an imperative toast() API rendered by <Toaster />.",
|
|
6
|
+
"designIntent": "Toasts announce status changes without stealing focus. The provider/useToast pair covers tree-scoped usage; the module-level toast() + <Toaster /> covers event handlers and data layers where threading a hook is impossible (e.g. 1000+ call sites). A polite live region and auto-dismiss keep the queue quiet.",
|
|
7
7
|
"props": [
|
|
8
8
|
{
|
|
9
9
|
"name": "title",
|
|
10
10
|
"type": "string",
|
|
11
11
|
"required": true,
|
|
12
|
-
"description": "
|
|
12
|
+
"description": "Toast heading.",
|
|
13
|
+
"examples": [
|
|
14
|
+
"Saved",
|
|
15
|
+
"Upload failed"
|
|
16
|
+
]
|
|
13
17
|
},
|
|
14
18
|
{
|
|
15
19
|
"name": "description",
|
|
16
20
|
"type": "string",
|
|
17
21
|
"required": false,
|
|
18
|
-
"description": "
|
|
22
|
+
"description": "Supporting line under the title."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "variant",
|
|
26
|
+
"type": "'default' | 'success' | 'error' | 'warning' | 'info'",
|
|
27
|
+
"required": false,
|
|
28
|
+
"defaultValue": "default",
|
|
29
|
+
"description": "Status accent (dot + optional rich tint).",
|
|
30
|
+
"enum": [
|
|
31
|
+
"default",
|
|
32
|
+
"success",
|
|
33
|
+
"error",
|
|
34
|
+
"warning",
|
|
35
|
+
"info"
|
|
36
|
+
],
|
|
37
|
+
"examples": [
|
|
38
|
+
"success",
|
|
39
|
+
"error"
|
|
40
|
+
]
|
|
19
41
|
},
|
|
20
42
|
{
|
|
21
43
|
"name": "action",
|
|
22
44
|
"type": "{ label: string; onClick: () => void }",
|
|
23
45
|
"required": false,
|
|
24
|
-
"description": "
|
|
46
|
+
"description": "Single inline action; dismisses the toast after running."
|
|
25
47
|
},
|
|
26
48
|
{
|
|
27
49
|
"name": "duration",
|
|
28
50
|
"type": "number",
|
|
29
|
-
"defaultValue": 5000,
|
|
30
51
|
"required": false,
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
52
|
+
"defaultValue": "5000",
|
|
53
|
+
"description": "Auto-dismiss delay in ms; 0 keeps the toast until dismissed."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "position",
|
|
57
|
+
"type": "'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'",
|
|
58
|
+
"required": false,
|
|
59
|
+
"defaultValue": "bottom-right",
|
|
60
|
+
"description": "Toaster placement (Toaster component).",
|
|
61
|
+
"enum": [
|
|
62
|
+
"top-left",
|
|
63
|
+
"top-center",
|
|
64
|
+
"top-right",
|
|
65
|
+
"bottom-left",
|
|
66
|
+
"bottom-center",
|
|
67
|
+
"bottom-right"
|
|
68
|
+
],
|
|
69
|
+
"examples": [
|
|
70
|
+
"top-right"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "richColors",
|
|
75
|
+
"type": "boolean",
|
|
76
|
+
"required": false,
|
|
77
|
+
"defaultValue": "false",
|
|
78
|
+
"description": "Tint the toast surface with its status color (Toaster component)."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "label",
|
|
82
|
+
"type": "string",
|
|
83
|
+
"required": false,
|
|
84
|
+
"defaultValue": "Notifications",
|
|
85
|
+
"description": "Accessible name of the live region (Toaster component)."
|
|
34
86
|
}
|
|
35
87
|
],
|
|
36
88
|
"examples": [
|
|
37
89
|
{
|
|
38
|
-
"title": "
|
|
39
|
-
"code": "
|
|
40
|
-
"description": "
|
|
90
|
+
"title": "Imperative API",
|
|
91
|
+
"code": "import { Toaster, toast } from '@slipstream-ui/core';\n\n<Toaster position=\"bottom-right\" richColors />\n\ntoast.success('Saved', 'Your changes are live');\ntoast.error('Upload failed', 'Check your connection');\ntoast({ title: 'Heads up', duration: 0 });",
|
|
92
|
+
"description": "Fire from event handlers or data layers — no hook needed."
|
|
41
93
|
},
|
|
42
94
|
{
|
|
43
|
-
"title": "
|
|
44
|
-
"code": "
|
|
45
|
-
"description": "
|
|
95
|
+
"title": "Tree-scoped provider",
|
|
96
|
+
"code": "<ToastProvider>\n <App />\n</ToastProvider>;\n\nconst { toast, dismiss } = useToast();\ntoast({ title: 'Saved' });",
|
|
97
|
+
"description": "Existing provider/hook pair for component-scoped usage."
|
|
46
98
|
}
|
|
47
99
|
],
|
|
48
100
|
"antiPatterns": [
|
|
@@ -53,29 +105,53 @@
|
|
|
53
105
|
],
|
|
54
106
|
"accessibility": [
|
|
55
107
|
{
|
|
56
|
-
"feature": "
|
|
57
|
-
"implementation": "Each toast renders
|
|
108
|
+
"feature": "Polite live region",
|
|
109
|
+
"implementation": "Each toast renders role=status inside a labelled list; announcements never steal focus",
|
|
110
|
+
"userRequirements": "Keep messages short and actionable"
|
|
58
111
|
},
|
|
59
112
|
{
|
|
60
|
-
"feature": "
|
|
61
|
-
"implementation": "
|
|
113
|
+
"feature": "Dismiss control",
|
|
114
|
+
"implementation": "Every toast has a labelled dismiss button (Dismiss <title>)",
|
|
115
|
+
"userRequirements": "Don't remove the dismiss button"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"feature": "Auto-dismiss timing",
|
|
119
|
+
"implementation": "5s default; duration 0 keeps sticky toasts",
|
|
120
|
+
"userRequirements": "Use duration 0 for errors that need a decision"
|
|
62
121
|
}
|
|
63
122
|
],
|
|
64
123
|
"contentGuidelines": [
|
|
65
124
|
{
|
|
66
125
|
"contentType": "toast title",
|
|
67
|
-
"guidelines": [
|
|
68
|
-
|
|
69
|
-
|
|
126
|
+
"guidelines": [
|
|
127
|
+
"State what happened in past tense",
|
|
128
|
+
"Keep under ~60 characters"
|
|
129
|
+
],
|
|
130
|
+
"goodExamples": [
|
|
131
|
+
"Saved",
|
|
132
|
+
"License revoked"
|
|
133
|
+
],
|
|
134
|
+
"badExamples": [
|
|
135
|
+
"Oops!"
|
|
136
|
+
]
|
|
70
137
|
}
|
|
71
138
|
],
|
|
72
139
|
"compositionRules": [
|
|
73
140
|
{
|
|
74
|
-
"component": "
|
|
75
|
-
"relationship": "
|
|
141
|
+
"component": "Toaster",
|
|
142
|
+
"relationship": "Mount once near the app root next to your router; keep it outside transforms so fixed positioning is not affected",
|
|
143
|
+
"example": "<App><Toaster /></App>"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"component": "ToastProvider",
|
|
147
|
+
"relationship": "Use the provider/hook pair when the call site is inside the React tree",
|
|
148
|
+
"example": "<ToastProvider><App /></ToastProvider>"
|
|
76
149
|
}
|
|
77
150
|
],
|
|
78
|
-
"relatedComponents": [
|
|
151
|
+
"relatedComponents": [
|
|
152
|
+
"Confirmation",
|
|
153
|
+
"Button"
|
|
154
|
+
],
|
|
79
155
|
"package": "@slipstream-ui/core",
|
|
80
156
|
"importPath": "@slipstream-ui/core",
|
|
81
157
|
"version": "0.0.1",
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { AppShell, Sidebar, type SidebarSection } from './AppShell';
|
|
6
|
+
|
|
7
|
+
const sections: SidebarSection[] = [
|
|
8
|
+
{
|
|
9
|
+
title: 'Workspace',
|
|
10
|
+
items: [
|
|
11
|
+
{ href: '/dashboard', label: 'Dashboard' },
|
|
12
|
+
{ href: '/classes', label: 'Classes', badge: '4' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
items: [{ href: '/settings', label: 'Settings' }],
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('Sidebar', () => {
|
|
21
|
+
it('renders sections, headings and marks the active route', () => {
|
|
22
|
+
render(<Sidebar sections={sections} activeHref="/classes" />);
|
|
23
|
+
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('Workspace')).toBeInTheDocument();
|
|
25
|
+
const active = screen.getByRole('link', { name: /Classes/ });
|
|
26
|
+
expect(active).toHaveAttribute('aria-current', 'page');
|
|
27
|
+
expect(screen.getByRole('link', { name: 'Dashboard' })).not.toHaveAttribute('aria-current');
|
|
28
|
+
expect(screen.getByText('4')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses a custom router link component', () => {
|
|
32
|
+
function RouterLink({ href, children, ...props }: React.ComponentProps<'a'>) {
|
|
33
|
+
return (
|
|
34
|
+
<a data-router href={href} {...props}>
|
|
35
|
+
{children}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
render(<Sidebar sections={sections} linkComponent={RouterLink} />);
|
|
40
|
+
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('data-router');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('honours an explicit active flag', () => {
|
|
44
|
+
render(
|
|
45
|
+
<Sidebar
|
|
46
|
+
sections={[{ items: [{ href: '/x', label: 'X', active: true }] }]}
|
|
47
|
+
activeHref="/elsewhere"
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
expect(screen.getByRole('link', { name: 'X' })).toHaveAttribute('aria-current', 'page');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('has no accessibility violations', async () => {
|
|
54
|
+
const { container } = render(<Sidebar sections={sections} activeHref="/dashboard" />);
|
|
55
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('AppShell', () => {
|
|
60
|
+
it('renders the rail, sticky header and main content', () => {
|
|
61
|
+
render(
|
|
62
|
+
<AppShell sidebar={<Sidebar sections={sections} />} header={<span>Top bar</span>}>
|
|
63
|
+
<p>Page body</p>
|
|
64
|
+
</AppShell>
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByText('Page body')).toBeInTheDocument();
|
|
67
|
+
expect(screen.getAllByRole('link', { name: 'Dashboard' }).length).toBeGreaterThan(0);
|
|
68
|
+
expect(screen.getByText('Top bar')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('opens the sidebar in a drawer on mobile', async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
render(
|
|
74
|
+
<AppShell sidebar={<Sidebar sections={sections} />}>
|
|
75
|
+
<p>Body</p>
|
|
76
|
+
</AppShell>
|
|
77
|
+
);
|
|
78
|
+
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
|
|
79
|
+
expect(screen.getByRole('dialog', { name: 'Navigation' })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('hides the built-in menu button when requested', () => {
|
|
83
|
+
render(
|
|
84
|
+
<AppShell sidebar={<Sidebar sections={sections} />} hideMobileMenu>
|
|
85
|
+
<p>Body</p>
|
|
86
|
+
</AppShell>
|
|
87
|
+
);
|
|
88
|
+
expect(screen.queryByRole('button', { name: 'Open navigation' })).not.toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('can render without a sidebar', () => {
|
|
92
|
+
render(
|
|
93
|
+
<AppShell header={<span>Only header</span>}>
|
|
94
|
+
<p>Body</p>
|
|
95
|
+
</AppShell>
|
|
96
|
+
);
|
|
97
|
+
expect(screen.queryByRole('button', { name: 'Open navigation' })).not.toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText('Only header')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { Drawer } from '../drawer/Drawer';
|
|
6
|
+
|
|
7
|
+
export type SidebarItem = {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
/** Trailing slot — count, status dot, shortcut. */
|
|
12
|
+
badge?: React.ReactNode;
|
|
13
|
+
/** Force the active state (otherwise activeHref is compared). */
|
|
14
|
+
active?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SidebarSection = {
|
|
18
|
+
title?: string;
|
|
19
|
+
items: SidebarItem[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SidebarProps = Omit<React.HTMLAttributes<HTMLElement>, 'children'> & {
|
|
23
|
+
sections: SidebarSection[];
|
|
24
|
+
/** Brand/mark rendered at the top. */
|
|
25
|
+
heading?: React.ReactNode;
|
|
26
|
+
/** Rendered at the bottom (user menu, upgrade CTA). */
|
|
27
|
+
footer?: React.ReactNode;
|
|
28
|
+
/** Current route — items with a matching href get aria-current="page". */
|
|
29
|
+
activeHref?: string;
|
|
30
|
+
/** Custom link element for routers (react-router `Link`, Next `Link`). */
|
|
31
|
+
linkComponent?: React.ElementType;
|
|
32
|
+
/** Accessible name of the nav landmark. Default 'Primary'. */
|
|
33
|
+
label?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sectioned navigation rail. Purely presentational: you pass the active
|
|
38
|
+
* route and a router link component, so it stays stateless and works with
|
|
39
|
+
* any routing library.
|
|
40
|
+
*/
|
|
41
|
+
export function Sidebar({
|
|
42
|
+
sections,
|
|
43
|
+
heading,
|
|
44
|
+
footer,
|
|
45
|
+
activeHref,
|
|
46
|
+
linkComponent,
|
|
47
|
+
label = 'Primary',
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: SidebarProps) {
|
|
51
|
+
const Link = linkComponent ?? 'a';
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={cn('flex h-full min-h-0 flex-col gap-6 p-4', className)} {...props}>
|
|
55
|
+
{heading && <div className="px-2">{heading}</div>}
|
|
56
|
+
<nav aria-label={label} className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto">
|
|
57
|
+
{sections.map((section, index) => (
|
|
58
|
+
<div key={section.title ?? `section-${index}`}>
|
|
59
|
+
{section.title && (
|
|
60
|
+
<p className="mb-1.5 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
61
|
+
{section.title}
|
|
62
|
+
</p>
|
|
63
|
+
)}
|
|
64
|
+
<ul className="space-y-0.5">
|
|
65
|
+
{section.items.map((item) => {
|
|
66
|
+
const active = item.active ?? (activeHref !== undefined && item.href === activeHref);
|
|
67
|
+
return (
|
|
68
|
+
<li key={item.href}>
|
|
69
|
+
<Link
|
|
70
|
+
href={item.href}
|
|
71
|
+
aria-current={active ? 'page' : undefined}
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
|
74
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
75
|
+
active
|
|
76
|
+
? 'bg-primary/10 font-medium text-primary'
|
|
77
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{item.icon && <span className="shrink-0 [&_svg]:size-4">{item.icon}</span>}
|
|
81
|
+
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
82
|
+
{item.badge && <span className="shrink-0 text-xs">{item.badge}</span>}
|
|
83
|
+
</Link>
|
|
84
|
+
</li>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</ul>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</nav>
|
|
91
|
+
{footer && <div className="border-t border-border pt-4">{footer}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type AppShellProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
97
|
+
/** Desktop rail; also rendered inside a mobile drawer. */
|
|
98
|
+
sidebar?: React.ReactNode;
|
|
99
|
+
/** Sticky top bar content. */
|
|
100
|
+
header?: React.ReactNode;
|
|
101
|
+
/** Hide the built-in mobile menu button (e.g. when the header owns one). */
|
|
102
|
+
hideMobileMenu?: boolean;
|
|
103
|
+
/** Tailwind width class for the desktop rail. Default 'w-64'. */
|
|
104
|
+
sidebarWidth?: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Application frame: fixed-height sidebar, sticky header, scrolling main.
|
|
109
|
+
* On small screens the sidebar becomes a left Drawer opened by a burger
|
|
110
|
+
* button, so pages never need their own responsive nav wiring.
|
|
111
|
+
*/
|
|
112
|
+
export function AppShell({
|
|
113
|
+
sidebar,
|
|
114
|
+
header,
|
|
115
|
+
hideMobileMenu = false,
|
|
116
|
+
sidebarWidth = 'w-64',
|
|
117
|
+
className,
|
|
118
|
+
children,
|
|
119
|
+
...props
|
|
120
|
+
}: AppShellProps) {
|
|
121
|
+
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className={cn('flex min-h-screen bg-background text-foreground', className)} {...props}>
|
|
125
|
+
{sidebar && (
|
|
126
|
+
<aside className={cn('hidden shrink-0 border-r border-border md:block', sidebarWidth)}>
|
|
127
|
+
{sidebar}
|
|
128
|
+
</aside>
|
|
129
|
+
)}
|
|
130
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
131
|
+
{(header || (sidebar && !hideMobileMenu)) && (
|
|
132
|
+
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
133
|
+
{sidebar && !hideMobileMenu && (
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => setMobileOpen(true)}
|
|
137
|
+
aria-label="Open navigation"
|
|
138
|
+
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-muted md:hidden"
|
|
139
|
+
>
|
|
140
|
+
<span aria-hidden="true">☰</span>
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
<div className="min-w-0 flex-1">{header}</div>
|
|
144
|
+
</header>
|
|
145
|
+
)}
|
|
146
|
+
<main className="min-w-0 flex-1">{children}</main>
|
|
147
|
+
</div>
|
|
148
|
+
{sidebar && (
|
|
149
|
+
<Drawer
|
|
150
|
+
open={mobileOpen}
|
|
151
|
+
onOpenChange={setMobileOpen}
|
|
152
|
+
title="Navigation"
|
|
153
|
+
side="left"
|
|
154
|
+
size="sm"
|
|
155
|
+
animate
|
|
156
|
+
>
|
|
157
|
+
{sidebar}
|
|
158
|
+
</Drawer>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AppShell';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { axe } from 'jest-axe';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { AuthLayout } from './AuthLayout';
|
|
5
|
+
|
|
6
|
+
describe('AuthLayout', () => {
|
|
7
|
+
it('renders brand, heading, description and footer', () => {
|
|
8
|
+
render(
|
|
9
|
+
<AuthLayout
|
|
10
|
+
title="Sign in"
|
|
11
|
+
description="Welcome back."
|
|
12
|
+
brand={<span>Acme</span>}
|
|
13
|
+
footer={<a href="/signup">Create account</a>}
|
|
14
|
+
>
|
|
15
|
+
<form aria-label="Sign in form" />
|
|
16
|
+
</AuthLayout>
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Acme')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText('Welcome back.')).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole('form', { name: 'Sign in form' })).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByRole('link', { name: 'Create account' })).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('supports size presets', () => {
|
|
26
|
+
const { container } = render(<AuthLayout size="lg" title="Reset"><span /></AuthLayout>);
|
|
27
|
+
expect(container.querySelector('.max-w-lg')).not.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has no accessibility violations', async () => {
|
|
31
|
+
const { container } = render(<AuthLayout title="Sign up"><input aria-label="Email" /></AuthLayout>);
|
|
32
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
|
|
4
|
+
export type AuthLayoutProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Brand/mark above the card. */
|
|
8
|
+
brand?: React.ReactNode;
|
|
9
|
+
/** Links or legal copy under the card. */
|
|
10
|
+
footer?: React.ReactNode;
|
|
11
|
+
/** Rendered inside the card under the heading. */
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
/** Card width preset (default 'md'). */
|
|
14
|
+
size?: 'sm' | 'md' | 'lg';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const sizeClasses: Record<NonNullable<AuthLayoutProps['size']>, string> = {
|
|
18
|
+
sm: 'max-w-sm',
|
|
19
|
+
md: 'max-w-md',
|
|
20
|
+
lg: 'max-w-lg',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Centered auth surface: brand mark, title/description, the form card and a
|
|
25
|
+
* footer slot. Keeps sign-in, sign-up, reset and verify screens visually
|
|
26
|
+
* identical without each page re-deriving the layout.
|
|
27
|
+
*/
|
|
28
|
+
export const AuthLayout = React.forwardRef<HTMLDivElement, AuthLayoutProps>(function AuthLayout(
|
|
29
|
+
{ title, description, brand, footer, children, size = 'md', className, ...props },
|
|
30
|
+
ref
|
|
31
|
+
) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
ref={ref}
|
|
35
|
+
className={cn('flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12', className)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{brand && <div className="mb-6">{brand}</div>}
|
|
39
|
+
<div className={cn('w-full rounded-xl border border-border bg-card p-6 shadow-sm sm:p-8', sizeClasses[size])}>
|
|
40
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
41
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
42
|
+
<div className="mt-6">{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
{footer && <div className="mt-6 text-center text-sm text-muted-foreground">{footer}</div>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AuthLayout';
|