@talelk/newui-templates 1.1.0 → 1.1.2
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.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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.0
|
|
18
|
-
"@talelk/newui-molecules": "2.
|
|
19
|
-
"@talelk/newui-organisms": "2.
|
|
18
|
+
"@talelk/newui-atoms": "1.2.0",
|
|
19
|
+
"@talelk/newui-molecules": "2.2.0",
|
|
20
|
+
"@talelk/newui-organisms": "2.1.1"
|
|
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.2'
|
|
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
|
})
|
|
@@ -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
|
})
|
|
@@ -22,7 +22,9 @@ export function AuthLayout(props: AuthLayoutProps): JSX.Element {
|
|
|
22
22
|
classList={{ 'lg:grid-cols-2': Boolean(props.aside) }}
|
|
23
23
|
>
|
|
24
24
|
<Show when={props.aside}>
|
|
25
|
-
|
|
25
|
+
{/* Cool Paper, not a saturated brand field: the One Signal Rule keeps
|
|
26
|
+
blue for the interactive moment, which the app's aside content owns. */}
|
|
27
|
+
<aside class="hidden flex-col justify-between border-s border-outline-gray-1 bg-surface-gray-2 p-6 text-ink-gray-9 lg:flex">
|
|
26
28
|
{props.aside}
|
|
27
29
|
</aside>
|
|
28
30
|
</Show>
|