@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,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 app-level notifications announced through a polite live region, with auto-dismiss, stacking and optional actions",
6
- "designIntent": "Applications need lightweight feedback that does not steal focus. Toasts render into a labelled polite live region so screen readers announce them, stack newest-first, auto-dismiss on a timer, and support one optional action (typically undo).",
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": "Short heading announced with the toast"
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": "Optional supporting detail"
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": "Optional inline action; invoking it dismisses the toast"
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
- "description": "Auto-dismiss delay in ms; 0 keeps the toast until dismissed",
32
- "enum": ["0", "5000"],
33
- "examples": [0, 3000, 5000]
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": "Basic toast with useToast",
39
- "code": "'use client';\n\nimport { ToastProvider, useToast } from '@/components/slipstream/toast';\n\nfunction SaveButton() {\n const { toast } = useToast();\n return (\n <button onClick={() => toast({ title: 'Saved', description: 'Your changes are live.' })}>\n Save\n </button>\n );\n}\n\nexport function App() {\n return (\n <ToastProvider>\n <SaveButton />\n </ToastProvider>\n );\n}",
40
- "description": "Wrap your app in ToastProvider once; call toast() anywhere below it."
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": "Undo action",
44
- "code": "toast({\n title: 'License revoked',\n action: { label: 'Undo', onClick: restore },\n duration: 0,\n});",
45
- "description": "duration: 0 keeps the toast until the user acts or dismisses it."
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": "Screen reader announcements",
57
- "implementation": "Each toast renders inside role=status (implicit aria-live=polite) within a labelled list container."
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": "Keyboard dismiss",
61
- "implementation": "Toast content is regular DOM; buttons inside toasts are reachable and activatable with the keyboard."
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": ["State what happened in past tense", "Keep under ~60 characters"],
68
- "goodExamples": ["Saved", "License revoked"],
69
- "badExamples": ["Oops!"]
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": "Confirmation",
75
- "relationship": "Use Confirmation for destructive actions before they happen; use Toast to report the result after."
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": ["Confirmation", "Button"],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slipstream-ui/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/michaelmcshinsky/slipstream-ui.git",
@@ -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';