@talelk/newui-templates 1.1.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.1.0",
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.0.1",
18
- "@talelk/newui-molecules": "2.0.0",
19
- "@talelk/newui-organisms": "2.0.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
- render(() => (
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 } = render(() => (
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
  })