@talelk/newui-templates 1.0.0 → 1.1.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@talelk/newui-templates",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The three page templates: AppShell, PaneLayout and AuthLayout.",
|
|
6
6
|
"exports": {
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"skills/newui-templates"
|
|
15
16
|
],
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@talelk/newui-atoms": "1.
|
|
18
|
-
"@talelk/newui-molecules": "1.0
|
|
19
|
-
"@talelk/newui-organisms": "1.0
|
|
18
|
+
"@talelk/newui-atoms": "1.1.0",
|
|
19
|
+
"@talelk/newui-molecules": "2.1.0",
|
|
20
|
+
"@talelk/newui-organisms": "2.1.0"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
|
22
23
|
"solid-js": "^1.9.0"
|
|
@@ -34,6 +35,9 @@
|
|
|
34
35
|
"url": "git+https://github.com/talelk/newui.git",
|
|
35
36
|
"directory": "packages/templates"
|
|
36
37
|
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"tanstack-intent"
|
|
40
|
+
],
|
|
37
41
|
"scripts": {
|
|
38
42
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
39
43
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: newui-templates
|
|
3
|
+
description: >
|
|
4
|
+
Use when composing application frames with @talelk/newui-templates — AppShell
|
|
5
|
+
(responsive sidebar + drawer, brand/tenant/navigation/settings/ account slots,
|
|
6
|
+
collapse), PaneLayout (resizable split panes), AuthLayout (sign-in/sign-up
|
|
7
|
+
canvas). Use when the user asks for an app shell, a split layout or an auth
|
|
8
|
+
page in newui, or mentions @talelk/newui-templates.
|
|
9
|
+
metadata:
|
|
10
|
+
type: core
|
|
11
|
+
domain: ui
|
|
12
|
+
library: '@talelk/newui-templates'
|
|
13
|
+
library_version: '1.1.1'
|
|
14
|
+
sources:
|
|
15
|
+
- talelk/newui:packages/templates/src/templates/AppShell.tsx
|
|
16
|
+
- talelk/newui:packages/templates/src/templates/PaneLayout/PaneLayout.tsx
|
|
17
|
+
- talelk/newui:packages/templates/src/templates/AuthLayout.tsx
|
|
18
|
+
- talelk/newui:spec/app-shell.md
|
|
19
|
+
- talelk/newui:spec/pane-layout.md
|
|
20
|
+
- talelk/newui:spec/auth-layout.md
|
|
21
|
+
- talelk/newui:spec/adr/0013-atomic-packages-and-app-shell.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# newui templates
|
|
25
|
+
|
|
26
|
+
Exactly three application frames. Read the `newui` skill for the vocabulary;
|
|
27
|
+
the organisms skill covers `AppSidebar`, which AppShell renders for you.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```tsx no-check
|
|
32
|
+
import { AppShell } from '@talelk/newui-templates'
|
|
33
|
+
import { SidebarItem } from '@talelk/newui-organisms'
|
|
34
|
+
|
|
35
|
+
export function Frame() {
|
|
36
|
+
return (
|
|
37
|
+
<AppShell
|
|
38
|
+
title="Projects"
|
|
39
|
+
brand={<span>Acme</span>}
|
|
40
|
+
navigation={[
|
|
41
|
+
{
|
|
42
|
+
label: 'Workspace',
|
|
43
|
+
items: [
|
|
44
|
+
{ label: 'Projects', icon: 'lucide-folders', route: '/', active: true },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
]}
|
|
48
|
+
settings={<SidebarItem label="Settings" icon="lucide-settings" />}
|
|
49
|
+
account={<SidebarItem label="Rhea Kapoor" icon="lucide-user-round" />}
|
|
50
|
+
>
|
|
51
|
+
<p>Page content — the template owns the h1 from `title`.</p>
|
|
52
|
+
</AppShell>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Core patterns
|
|
58
|
+
|
|
59
|
+
**AppShell owns the responsive sidebar.** Desktop docks it; narrow viewports
|
|
60
|
+
open the same navigation as a modal drawer. There is no separate mobile shell
|
|
61
|
+
and no rail — never mount `Sidebar` or `MobileNav` inside it.
|
|
62
|
+
|
|
63
|
+
**Collapse is controllable.** Pass `collapsed` + `onCollapsedChange` when the
|
|
64
|
+
app owns persistence (P2); without them the shell owns the state. The fold
|
|
65
|
+
lives in the sidebar's identity row (`⌘B`/`Ctrl+B` also toggles).
|
|
66
|
+
|
|
67
|
+
**Split content with PaneLayout inside AppShell.** Mail-style records need
|
|
68
|
+
`PaneLayout`'s resizable start/end panes; `active` collapses to one pane on
|
|
69
|
+
compact widths. Split panes do not replace the shell.
|
|
70
|
+
|
|
71
|
+
**AuthLayout is a canvas, not an auth backend.** It centers the flow and
|
|
72
|
+
offers the desktop-only `aside` context panel; the card and session logic
|
|
73
|
+
stay app-owned (`AuthCard` is an organism).
|
|
74
|
+
|
|
75
|
+
## Common mistakes
|
|
76
|
+
|
|
77
|
+
**One `h1` per page.** AppShell renders it from `title`; do not add a second
|
|
78
|
+
heading for the page name. `mobileTitle` changes only the compact bar (a
|
|
79
|
+
`span`, not a heading).
|
|
80
|
+
|
|
81
|
+
**Don't pass a rail or second navigation.** The old `SidebarRail`/
|
|
82
|
+
`DesktopShell` APIs were removed; their props fail typecheck.
|
|
83
|
+
|
|
84
|
+
**Don't put page content outside the children.** `footer` renders inside the
|
|
85
|
+
page scroller below the body — a fixed footer is app chrome, not this
|
|
86
|
+
template's slot.
|
|
87
|
+
|
|
88
|
+
## Completion
|
|
89
|
+
|
|
90
|
+
The starter app (`apps/starter`) composes all three templates against the
|
|
91
|
+
published packages; its e2e suite (`e2e/starter.spec.ts`) is the reference
|
|
92
|
+
for expected behavior.
|
|
@@ -1,51 +1,80 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
2
|
import { SidebarItem } from '@talelk/newui-organisms'
|
|
3
3
|
import userEvent from '@testing-library/user-event'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { AppShell } from './AppShell'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* jsdom's matchMedia stub always answers `false`, which puts every shell in
|
|
9
|
+
* the mobile layout. The desktop contract (one docked sidebar, fold) needs the
|
|
10
|
+
* wide answer, so these tests stub the query for the duration of a render.
|
|
11
|
+
*/
|
|
12
|
+
function withDesktop(run: () => void | Promise<void>): Promise<void> {
|
|
13
|
+
const original = window.matchMedia
|
|
14
|
+
window.matchMedia = ((query: string) => ({
|
|
15
|
+
matches: query.includes('min-width'),
|
|
16
|
+
media: query,
|
|
17
|
+
onchange: null,
|
|
18
|
+
addEventListener() {},
|
|
19
|
+
removeEventListener() {},
|
|
20
|
+
addListener() {},
|
|
21
|
+
removeListener() {},
|
|
22
|
+
dispatchEvent: () => false,
|
|
23
|
+
})) as unknown as typeof window.matchMedia
|
|
24
|
+
return Promise.resolve()
|
|
25
|
+
.then(run)
|
|
26
|
+
.finally(() => {
|
|
27
|
+
window.matchMedia = original
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAVIGATION = [
|
|
32
|
+
{
|
|
33
|
+
label: 'Workspace',
|
|
34
|
+
items: [
|
|
35
|
+
{ label: 'Projects', icon: 'lucide-folders', active: true },
|
|
36
|
+
{ label: 'People', icon: 'lucide-users' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'Reports',
|
|
41
|
+
items: [{ label: 'Revenue', icon: 'lucide-chart-no-axes-combined' }],
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
function renderShell(overrides?: Partial<Parameters<typeof AppShell>[0]>) {
|
|
46
|
+
return render(() => (
|
|
47
|
+
<AppShell
|
|
48
|
+
title="Projects"
|
|
49
|
+
brand={<span>Acme</span>}
|
|
50
|
+
brandCollapsed={<span>AC</span>}
|
|
51
|
+
tenant={<button type="button">Acme · Production</button>}
|
|
52
|
+
tenantCollapsed={
|
|
53
|
+
<button type="button" aria-label="Choose tenant">
|
|
54
|
+
AC
|
|
55
|
+
</button>
|
|
56
|
+
}
|
|
57
|
+
navigation={NAVIGATION}
|
|
58
|
+
settings={
|
|
59
|
+
<SidebarItem label="Settings" icon="lucide-settings">
|
|
60
|
+
Settings
|
|
61
|
+
</SidebarItem>
|
|
62
|
+
}
|
|
63
|
+
account={
|
|
64
|
+
<SidebarItem label="Rhea Kapoor" icon="lucide-user-round">
|
|
65
|
+
Rhea Kapoor
|
|
66
|
+
</SidebarItem>
|
|
67
|
+
}
|
|
68
|
+
{...overrides}
|
|
69
|
+
>
|
|
70
|
+
<p>Content</p>
|
|
71
|
+
</AppShell>
|
|
72
|
+
))
|
|
73
|
+
}
|
|
74
|
+
|
|
7
75
|
describe('AppShell', () => {
|
|
8
76
|
it('uses one sidebar model for the mobile drawer, including tenant and bottom actions', async () => {
|
|
9
|
-
|
|
10
|
-
<AppShell
|
|
11
|
-
title="Projects"
|
|
12
|
-
brand={<span>Acme</span>}
|
|
13
|
-
brandCollapsed={<span>AC</span>}
|
|
14
|
-
tenant={<button type="button">Acme · Production</button>}
|
|
15
|
-
tenantCollapsed={
|
|
16
|
-
<button type="button" aria-label="Choose tenant">
|
|
17
|
-
AC
|
|
18
|
-
</button>
|
|
19
|
-
}
|
|
20
|
-
navigation={[
|
|
21
|
-
{
|
|
22
|
-
label: 'Workspace',
|
|
23
|
-
items: [
|
|
24
|
-
{ label: 'Projects', icon: 'lucide-folders', active: true },
|
|
25
|
-
{ label: 'People', icon: 'lucide-users' },
|
|
26
|
-
],
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
label: 'Reports',
|
|
30
|
-
items: [
|
|
31
|
-
{ label: 'Revenue', icon: 'lucide-chart-no-axes-combined' },
|
|
32
|
-
],
|
|
33
|
-
},
|
|
34
|
-
]}
|
|
35
|
-
settings={
|
|
36
|
-
<SidebarItem label="Settings" icon="lucide-settings">
|
|
37
|
-
Settings
|
|
38
|
-
</SidebarItem>
|
|
39
|
-
}
|
|
40
|
-
account={
|
|
41
|
-
<SidebarItem label="Rhea Kapoor" icon="lucide-user-round">
|
|
42
|
-
Rhea Kapoor
|
|
43
|
-
</SidebarItem>
|
|
44
|
-
}
|
|
45
|
-
>
|
|
46
|
-
<p>Content</p>
|
|
47
|
-
</AppShell>
|
|
48
|
-
))
|
|
77
|
+
renderShell()
|
|
49
78
|
|
|
50
79
|
expect(
|
|
51
80
|
screen.getByRole('heading', { name: 'Projects', level: 1 }),
|
|
@@ -71,4 +100,90 @@ describe('AppShell', () => {
|
|
|
71
100
|
await userEvent.click(screen.getByRole('button', { name: 'People' }))
|
|
72
101
|
await waitFor(() => expect(drawer).not.toBeVisible())
|
|
73
102
|
})
|
|
103
|
+
|
|
104
|
+
it('closes the drawer from its own close control', async () => {
|
|
105
|
+
renderShell()
|
|
106
|
+
await userEvent.click(
|
|
107
|
+
screen.getByRole('button', { name: 'Open application navigation' }),
|
|
108
|
+
)
|
|
109
|
+
// Focus returns to the trigger through the native `close` event, which the
|
|
110
|
+
// engine owns — the browser harness asserts that half.
|
|
111
|
+
await userEvent.click(
|
|
112
|
+
screen.getByRole('button', { name: 'Close navigation' }),
|
|
113
|
+
)
|
|
114
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders exactly one h1 and keeps the mobile bar title a span', () => {
|
|
118
|
+
renderShell({ mobileTitle: <span>Projects</span> })
|
|
119
|
+
const headings = document.querySelectorAll('h1')
|
|
120
|
+
expect(headings.length).toBe(1)
|
|
121
|
+
expect(headings[0]?.textContent).toBe('Projects')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('renders the page footer after the content inside the scroller', () => {
|
|
125
|
+
renderShell({ footer: <p>Quarterly summary</p> })
|
|
126
|
+
const content = screen.getByText('Content')
|
|
127
|
+
const footer = screen.getByText('Quarterly summary')
|
|
128
|
+
expect(
|
|
129
|
+
footer.compareDocumentPosition(content) &
|
|
130
|
+
Node.DOCUMENT_POSITION_PRECEDING,
|
|
131
|
+
).toBeTruthy()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('desktop', () => {
|
|
135
|
+
it('docks the sidebar and folds it through the one fold control', async () => {
|
|
136
|
+
await withDesktop(async () => {
|
|
137
|
+
renderShell()
|
|
138
|
+
const dock = document.querySelector(
|
|
139
|
+
'[data-slot="app-shell-sidebar"]',
|
|
140
|
+
) as HTMLElement
|
|
141
|
+
expect(dock.className).toContain('w-64')
|
|
142
|
+
|
|
143
|
+
// The Sidebar keeps inactive slot copies mounted and hides them with
|
|
144
|
+
// CSS, so the fold control's name and the dock width are the jsdom-
|
|
145
|
+
// visible contract; the compact-slot swap is the browser harness's.
|
|
146
|
+
userEvent.click(
|
|
147
|
+
screen.getByRole('button', { name: 'Collapse sidebar' }),
|
|
148
|
+
)
|
|
149
|
+
await waitFor(() => expect(dock.className).toContain('w-14'))
|
|
150
|
+
expect(
|
|
151
|
+
screen.queryByRole('button', { name: 'Collapse sidebar' }),
|
|
152
|
+
).toBeNull()
|
|
153
|
+
|
|
154
|
+
userEvent.click(screen.getByRole('button', { name: 'Expand sidebar' }))
|
|
155
|
+
await waitFor(() => expect(dock.className).toContain('w-64'))
|
|
156
|
+
expect(
|
|
157
|
+
screen.queryByRole('button', { name: 'Expand sidebar' }),
|
|
158
|
+
).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('stays owned by the app when `collapsed` is controlled', async () => {
|
|
163
|
+
await withDesktop(async () => {
|
|
164
|
+
const onCollapsedChange = vi.fn()
|
|
165
|
+
renderShell({ collapsed: false, onCollapsedChange })
|
|
166
|
+
const dock = document.querySelector(
|
|
167
|
+
'[data-slot="app-shell-sidebar"]',
|
|
168
|
+
) as HTMLElement
|
|
169
|
+
userEvent.click(
|
|
170
|
+
screen.getByRole('button', { name: 'Collapse sidebar' }),
|
|
171
|
+
)
|
|
172
|
+
await waitFor(() =>
|
|
173
|
+
expect(onCollapsedChange).toHaveBeenCalledWith(true),
|
|
174
|
+
)
|
|
175
|
+
// Controlled: the shell does not move until the app moves it.
|
|
176
|
+
expect(dock.className).toContain('w-64')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('renders header actions in the shell header', async () => {
|
|
181
|
+
await withDesktop(async () => {
|
|
182
|
+
renderShell({
|
|
183
|
+
headerActions: <button type="button">Invite</button>,
|
|
184
|
+
})
|
|
185
|
+
expect(screen.getByRole('button', { name: 'Invite' })).toBeTruthy()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|
|
74
189
|
})
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Button,
|
|
3
|
-
createMediaQuery,
|
|
4
|
-
UIProvider,
|
|
5
|
-
useUI,
|
|
6
|
-
} from '@talelk/newui-atoms'
|
|
1
|
+
import { createMediaQuery, UIProvider, useUI } from '@talelk/newui-atoms'
|
|
7
2
|
import { closeDialog, showDialog } from '@talelk/newui-atoms/internal'
|
|
8
3
|
import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
|
|
9
4
|
import { AppSidebar, type AppSidebarGroup } from '@talelk/newui-organisms'
|
|
@@ -41,6 +36,10 @@ export interface AppShellProps {
|
|
|
41
36
|
navigation?: AppSidebarGroup[]
|
|
42
37
|
/** Advanced navigation content, rendered inside the same sidebar. */
|
|
43
38
|
navigationContent?: JSX.Element | (() => JSX.Element)
|
|
39
|
+
/** Global utilities pinned above Settings while the sidebar is expanded. */
|
|
40
|
+
utilities?: JSX.Element
|
|
41
|
+
/** Compact icon form of `utilities`, shown while the sidebar is collapsed. */
|
|
42
|
+
utilitiesCollapsed?: JSX.Element
|
|
44
43
|
/** Settings action pinned to the bottom of the sidebar. */
|
|
45
44
|
settings?: JSX.Element
|
|
46
45
|
/** User/account control pinned below settings. */
|
|
@@ -116,6 +115,8 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
116
115
|
search: props.sidebarSearch,
|
|
117
116
|
groups: props.navigation,
|
|
118
117
|
children: props.navigationContent,
|
|
118
|
+
utilities: props.utilities,
|
|
119
|
+
utilitiesCollapsed: props.utilitiesCollapsed,
|
|
119
120
|
settings: props.settings,
|
|
120
121
|
account: props.account,
|
|
121
122
|
accountCollapsed: props.accountCollapsed,
|
|
@@ -159,7 +160,7 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
159
160
|
aria-haspopup="dialog"
|
|
160
161
|
aria-expanded={drawerOpen()}
|
|
161
162
|
onClick={openDrawer}
|
|
162
|
-
class="grid size-
|
|
163
|
+
class="grid size-10 shrink-0 place-items-center rounded-3 hover:bg-surface-gray-2 focus-visible:focus-ring"
|
|
163
164
|
>
|
|
164
165
|
<span aria-hidden="true" class="lucide-menu size-4" />
|
|
165
166
|
</button>
|
|
@@ -180,24 +181,26 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
180
181
|
<a href="#app-shell-content" class="sr-only focus:not-sr-only">
|
|
181
182
|
Skip to workspace
|
|
182
183
|
</a>
|
|
183
|
-
<div class="mx-auto flex w-full max-w-[1440px] flex-col gap-
|
|
184
|
-
<div class="flex flex-
|
|
185
|
-
<
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
184
|
+
<div class="mx-auto flex w-full max-w-[1440px] flex-col gap-4 p-page-gutter-compact pb-8 md:p-page-gutter">
|
|
185
|
+
<div class="flex flex-col gap-2">
|
|
186
|
+
<Show when={props.breadcrumbs}>
|
|
187
|
+
{(items) => <Breadcrumbs items={items()} />}
|
|
188
|
+
</Show>
|
|
189
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
190
|
+
<div class="min-w-0">
|
|
191
|
+
<h1 class="mt-1 text-heading">{props.title}</h1>
|
|
192
|
+
<Show when={props.description}>
|
|
193
|
+
<p class="mt-1 max-w-2xl text-ink-gray-7">
|
|
194
|
+
{props.description}
|
|
195
|
+
</p>
|
|
196
|
+
</Show>
|
|
197
|
+
</div>
|
|
198
|
+
<Show when={props.actions}>
|
|
199
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
200
|
+
{props.actions}
|
|
201
|
+
</div>
|
|
194
202
|
</Show>
|
|
195
203
|
</div>
|
|
196
|
-
<Show when={props.actions}>
|
|
197
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
198
|
-
{props.actions}
|
|
199
|
-
</div>
|
|
200
|
-
</Show>
|
|
201
204
|
</div>
|
|
202
205
|
{props.children}
|
|
203
206
|
<Show when={props.footer}>{props.footer}</Show>
|
|
@@ -219,21 +222,29 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
219
222
|
const control = target.closest('a, button, [data-navigates]')
|
|
220
223
|
if (control && !control.closest('[data-keep-drawer]')) closeDrawer()
|
|
221
224
|
}}
|
|
222
|
-
class="open:flex m-0 h-dvh max-h-none w-72 max-w-[calc(100vw-2rem)] flex-col overflow-
|
|
225
|
+
class="open:flex m-0 h-dvh max-h-none w-72 max-w-[calc(100vw-2rem)] flex-col overflow-hidden border-e border-outline-gray-1 bg-surface-gray-1 p-0 text-ink-gray-9 shadow-xl backdrop:bg-black-overlay-200"
|
|
223
226
|
>
|
|
224
|
-
<div class="flex shrink-0 justify-
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
<div class="flex h-10 shrink-0 items-center justify-between border-b border-outline-gray-1 ps-3 pe-2">
|
|
228
|
+
<span class="text-xs font-medium text-ink-gray-7">Navigation</span>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
aria-label="Close navigation"
|
|
232
|
+
onClick={closeDrawer}
|
|
233
|
+
class="grid size-10 shrink-0 place-items-center rounded-control text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
|
|
234
|
+
>
|
|
235
|
+
<span aria-hidden="true" class="lucide-x size-4" />
|
|
236
|
+
</button>
|
|
228
237
|
</div>
|
|
229
238
|
<UIProvider dir={ui?.dir()} portalTarget={drawerPortal}>
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
240
|
+
<AppSidebar
|
|
241
|
+
{...sidebarProps()}
|
|
242
|
+
width="100%"
|
|
243
|
+
collapsedWidth="100%"
|
|
244
|
+
collapsed={false}
|
|
245
|
+
collapsible={false}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
237
248
|
</UIProvider>
|
|
238
249
|
</dialog>
|
|
239
250
|
</div>
|
|
@@ -2,19 +2,66 @@ import { render, screen } from '@solidjs/testing-library'
|
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
3
|
import { AuthLayout } from './AuthLayout'
|
|
4
4
|
|
|
5
|
+
function renderAuth(overrides?: Partial<Parameters<typeof AuthLayout>[0]>) {
|
|
6
|
+
return render(() => (
|
|
7
|
+
<AuthLayout brand={<span>Acme</span>} {...overrides}>
|
|
8
|
+
<form aria-label="Sign in">
|
|
9
|
+
<button type="submit">Continue</button>
|
|
10
|
+
</form>
|
|
11
|
+
</AuthLayout>
|
|
12
|
+
))
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
describe('AuthLayout', () => {
|
|
6
16
|
it('centers public access content and allows an optional context panel', () => {
|
|
7
|
-
const { container } =
|
|
8
|
-
<AuthLayout aside={<p>Welcome to Acme</p>} brand={<span>Acme</span>}>
|
|
9
|
-
<form aria-label="Sign in">
|
|
10
|
-
<button type="submit">Continue</button>
|
|
11
|
-
</form>
|
|
12
|
-
</AuthLayout>
|
|
13
|
-
))
|
|
17
|
+
const { container } = renderAuth({ aside: <p>Welcome to Acme</p> })
|
|
14
18
|
|
|
15
19
|
expect(screen.getByText('Welcome to Acme')).toBeVisible()
|
|
16
20
|
expect(screen.getByText('Acme')).toBeVisible()
|
|
17
21
|
expect(screen.getByRole('form', { name: 'Sign in' })).toBeVisible()
|
|
18
22
|
expect(container.querySelectorAll('main')).toHaveLength(1)
|
|
19
23
|
})
|
|
24
|
+
|
|
25
|
+
it('renders the context panel as an aside and splits the canvas in two', () => {
|
|
26
|
+
const { container } = renderAuth({ aside: <p>Welcome to Acme</p> })
|
|
27
|
+
const aside = container.querySelector('aside')
|
|
28
|
+
expect(aside).not.toBeNull()
|
|
29
|
+
expect(aside?.textContent).toBe('Welcome to Acme')
|
|
30
|
+
expect((container.firstElementChild as HTMLElement).className).toContain(
|
|
31
|
+
'lg:grid-cols-2',
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('omits the panel and the split without `aside`', () => {
|
|
36
|
+
const { container } = renderAuth()
|
|
37
|
+
expect(container.querySelector('aside')).toBeNull()
|
|
38
|
+
expect(
|
|
39
|
+
(container.firstElementChild as HTMLElement).className,
|
|
40
|
+
).not.toContain('lg:grid-cols-2')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders the header row only when `header` is given', () => {
|
|
44
|
+
const { container, unmount } = renderAuth()
|
|
45
|
+
expect(container.querySelector('[class*="absolute inset-x-0"]')).toBeNull()
|
|
46
|
+
unmount()
|
|
47
|
+
|
|
48
|
+
renderAuth({ header: <a href="/">Back to home</a> })
|
|
49
|
+
expect(screen.getByRole('link', { name: 'Back to home' })).toBeVisible()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('stacks the brand above the flow inside the centered column', () => {
|
|
53
|
+
const { container } = renderAuth()
|
|
54
|
+
const column = container.querySelector('main')?.firstElementChild
|
|
55
|
+
const brand = column?.children[0]
|
|
56
|
+
const form = column?.children[1]
|
|
57
|
+
expect(brand?.textContent).toBe('Acme')
|
|
58
|
+
expect(form?.querySelector('button')?.textContent).toBe('Continue')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('marks the root with the auth-layout slot', () => {
|
|
62
|
+
const { container } = renderAuth()
|
|
63
|
+
expect(
|
|
64
|
+
(container.firstElementChild as HTMLElement).getAttribute('data-slot'),
|
|
65
|
+
).toBe('auth-layout')
|
|
66
|
+
})
|
|
20
67
|
})
|