@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.0.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.0",
18
- "@talelk/newui-molecules": "1.0.0",
19
- "@talelk/newui-organisms": "1.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
  })
@@ -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-9 shrink-0 place-items-center rounded-3 hover:bg-surface-gray-2 focus-visible:focus-ring"
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-section-gap p-page-gutter-compact pb-8 md:p-page-gutter">
184
- <div class="flex flex-wrap items-end justify-between gap-4">
185
- <div class="min-w-0">
186
- <Show when={props.breadcrumbs}>
187
- {(items) => <Breadcrumbs items={items()} />}
188
- </Show>
189
- <h1 class="mt-1 text-heading">{props.title}</h1>
190
- <Show when={props.description}>
191
- <p class="mt-2 max-w-2xl text-ink-gray-7">
192
- {props.description}
193
- </p>
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-y-auto border-e border-outline-gray-1 bg-surface-sidebar p-0 text-ink-gray-9 backdrop:bg-black-overlay-200"
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-end border-b border-outline-gray-1 p-2">
225
- <Button variant="ghost" size="sm" onClick={closeDrawer}>
226
- Close navigation
227
- </Button>
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
- <AppSidebar
231
- {...sidebarProps()}
232
- width="100%"
233
- collapsedWidth="100%"
234
- collapsed={false}
235
- collapsible={false}
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 } = 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
  })