create-top-secret-starter 0.14.0 → 0.15.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 (31) hide show
  1. package/features.json +0 -1
  2. package/index.js +1 -2
  3. package/package.json +1 -1
  4. package/templates/template-router/.oxlintrc.json +8 -18
  5. package/templates/template-router/e2e/keyboard.spec.ts +3 -3
  6. package/templates/template-router/src/components/app-shell.plain.tsx +5 -1
  7. package/templates/template-router/src/components/app-shell.tsx +5 -1
  8. package/templates/template-router/src/components/orders/orders-table.tsx +18 -16
  9. package/templates/template-router/src/components/skip-link.tsx +11 -0
  10. package/templates/template-router/src/components/theme-toggle.tsx +1 -1
  11. package/templates/template-router/src/components/ui/table.tsx +17 -2
  12. package/templates/template-router/src/components/ui/toast.tsx +24 -2
  13. package/templates/template-router/src/index.css +27 -3
  14. package/templates/template-router/src/routes/_authenticated/index.tsx +1 -1
  15. package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +2 -1
  16. package/templates/template-router/src/routes/_authenticated/notes/index.tsx +41 -3
  17. package/templates/template-router/src/routes/_authenticated/orders/index.tsx +34 -1
  18. package/templates/template-start/.oxlintrc.json +8 -18
  19. package/templates/template-start/e2e/keyboard.spec.ts +3 -3
  20. package/templates/template-start/src/components/app-shell.plain.tsx +5 -1
  21. package/templates/template-start/src/components/app-shell.tsx +5 -1
  22. package/templates/template-start/src/components/orders/orders-table.tsx +18 -16
  23. package/templates/template-start/src/components/skip-link.tsx +11 -0
  24. package/templates/template-start/src/components/theme-toggle.tsx +1 -1
  25. package/templates/template-start/src/components/ui/table.tsx +17 -2
  26. package/templates/template-start/src/components/ui/toast.tsx +24 -2
  27. package/templates/template-start/src/index.css +27 -3
  28. package/templates/template-start/src/routes/_authenticated/index.tsx +1 -1
  29. package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +2 -1
  30. package/templates/template-start/src/routes/_authenticated/notes/index.tsx +41 -3
  31. package/templates/template-start/src/routes/_authenticated/orders/index.tsx +34 -1
package/features.json CHANGED
@@ -27,7 +27,6 @@
27
27
  "src/components/orders",
28
28
  "src/mocks/orders.ts",
29
29
  "src/components/ui/table.tsx",
30
- "src/components/ui/dialog.tsx",
31
30
  "src/components/ui/dropdown-menu.tsx",
32
31
  "src/components/ui/select.tsx",
33
32
  "e2e/orders.spec.ts"
package/index.js CHANGED
@@ -248,11 +248,10 @@ const substitute = (file, pairs) =>
248
248
  return text.replaceAll(from, to)
249
249
  }, source)
250
250
  )
251
- // the project name lands in the document title/description and the demo home page
251
+ // the project name lands in the document title/description and the sidebar brand
252
252
  const nameTargets = base => [
253
253
  path.join(base, 'index.html'),
254
254
  path.join(base, 'src', 'site.ts'),
255
- path.join(base, 'src', 'routes', '_authenticated', 'index.tsx'),
256
255
  path.join(base, 'src', 'components', 'app-sidebar.tsx')
257
256
  ]
258
257
  const applyName = files => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-top-secret-starter",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
5
5
  "keywords": [
6
6
  "create",
@@ -60,11 +60,11 @@
60
60
  "error",
61
61
  {
62
62
  "name": "fetch",
63
- "message": "Use the ky instances from src/api/index.ts."
63
+ "message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
64
64
  },
65
65
  {
66
66
  "name": "localStorage",
67
- "message": "Use createStorageStore from src/lib/storage-store.ts."
67
+ "message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
68
68
  }
69
69
  ],
70
70
  "no-restricted-imports": [
@@ -73,12 +73,12 @@
73
73
  "paths": [
74
74
  {
75
75
  "name": "zod",
76
- "message": "Import from zod/mini. For forms use zodResolver; it works with mini."
76
+ "message": "Import from 'zod/mini'. Mixing the two ships both builds (+15.8 kB gzip). For forms use zodResolver (typed against zod/v4/core, works with mini)."
77
77
  },
78
78
  {
79
79
  "name": "react",
80
80
  "importNames": ["useMemo", "useCallback"],
81
- "message": "React Compiler is enabled; avoid manual memoization."
81
+ "message": "React Compiler is on: manual memoization is noise."
82
82
  }
83
83
  ]
84
84
  }
@@ -92,7 +92,7 @@
92
92
  "error",
93
93
  {
94
94
  "name": "localStorage",
95
- "message": "Use createStorageStore from src/lib/storage-store.ts."
95
+ "message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
96
96
  }
97
97
  ]
98
98
  }
@@ -104,13 +104,13 @@
104
104
  "error",
105
105
  {
106
106
  "name": "fetch",
107
- "message": "Use the ky instances from src/api/index.ts."
107
+ "message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
108
108
  }
109
109
  ]
110
110
  }
111
111
  },
112
112
  {
113
- "files": ["src/api/auth/auth.test.ts", "src/api/refresh.test.ts"],
113
+ "files": ["src/api/**/*.test.ts"],
114
114
  "rules": {
115
115
  "no-restricted-globals": "off"
116
116
  }
@@ -118,17 +118,7 @@
118
118
  {
119
119
  "files": ["src/components/ui/**"],
120
120
  "rules": {
121
- "no-restricted-imports": [
122
- "error",
123
- {
124
- "paths": [
125
- {
126
- "name": "zod",
127
- "message": "Import from zod/mini. For forms use zodResolver; it works with mini."
128
- }
129
- ]
130
- }
131
- ]
121
+ "no-restricted-imports": "off"
132
122
  }
133
123
  }
134
124
  ]
@@ -55,7 +55,7 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
55
55
  )
56
56
  .map(el => el.textContent.trim() || (el.getAttribute('aria-label') ?? ''))
57
57
  )
58
- const order = ['Home', 'Notes', 'Theme', 'Sign out'].map(name =>
58
+ const order = ['Home', 'Notes', 'Switch to', 'Sign out'].map(name =>
59
59
  tabbables.findIndex(text => text.startsWith(name))
60
60
  )
61
61
  expect(order.every(index => index >= 0)).toBe(true)
@@ -73,12 +73,12 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
73
73
 
74
74
  test('a button activates with Space', async ({ page }) => {
75
75
  await signIn(page)
76
- const toggle = page.getByRole('button', { name: 'Theme: system' })
76
+ const toggle = page.getByRole('button', { name: 'Switch to light theme' })
77
77
 
78
78
  await toggle.focus()
79
79
  await page.keyboard.press('Space')
80
80
 
81
- await expect(page.getByRole('button', { name: 'Theme: light' })).toBeFocused()
81
+ await expect(page.getByRole('button', { name: 'Switch to dark theme' })).toBeFocused()
82
82
  })
83
83
 
84
84
  test('the back-home link activates with Enter', async ({ page }) => {
@@ -3,11 +3,13 @@ import type { PropsWithChildren } from 'react'
3
3
 
4
4
  import { HeaderActions } from '@/components/header-actions'
5
5
  import { NAV } from '@/components/nav'
6
+ import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
6
7
 
7
8
  // header-only shell: `create-cli` renames this over `app-shell.tsx` when the sidebar is off
8
9
  export const AppShell = ({ children }: PropsWithChildren) => {
9
10
  return (
10
11
  <>
12
+ <SkipLink />
11
13
  <header className='flex h-14 items-center gap-4 border-b px-6'>
12
14
  <nav aria-label='Main' className='flex items-center gap-4 text-sm'>
13
15
  {NAV.map(item => (
@@ -23,7 +25,9 @@ export const AppShell = ({ children }: PropsWithChildren) => {
23
25
  </nav>
24
26
  <HeaderActions />
25
27
  </header>
26
- <div className='flex flex-1 flex-col p-4 sm:p-6'>{children}</div>
28
+ <div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
29
+ {children}
30
+ </div>
27
31
  </>
28
32
  )
29
33
  }
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'
2
2
 
3
3
  import { AppSidebar } from '@/components/app-sidebar'
4
4
  import { HeaderActions } from '@/components/header-actions'
5
+ import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
5
6
  import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
6
7
  import { sidebarStore, useSidebarOpen } from '@/lib/sidebar-store'
7
8
 
@@ -11,13 +12,16 @@ export const AppShell = ({ children }: PropsWithChildren) => {
11
12
 
12
13
  return (
13
14
  <SidebarProvider open={open} onOpenChange={sidebarStore.set}>
15
+ <SkipLink />
14
16
  <AppSidebar />
15
17
  <SidebarInset className='min-w-0'>
16
18
  <header className='flex h-14 items-center gap-2 border-b px-4'>
17
19
  <SidebarTrigger />
18
20
  <HeaderActions />
19
21
  </header>
20
- <div className='flex flex-1 flex-col p-4 sm:p-6'>{children}</div>
22
+ <div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
23
+ {children}
24
+ </div>
21
25
  </SidebarInset>
22
26
  </SidebarProvider>
23
27
  )
@@ -53,7 +53,6 @@ import {
53
53
  DropdownMenuSeparator,
54
54
  DropdownMenuTrigger
55
55
  } from '@/components/ui/dropdown-menu'
56
- import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
57
56
  import {
58
57
  Table,
59
58
  TableBody,
@@ -111,7 +110,11 @@ const columns = helper.columns([
111
110
  header: 'Amount',
112
111
  size: 130,
113
112
  meta: { align: 'end' },
114
- cell: ({ row }) => formatMoney(row.original.amountCents, row.original.currency)
113
+ cell: ({ row }) => (
114
+ <span className='tabular-nums'>
115
+ {formatMoney(row.original.amountCents, row.original.currency)}
116
+ </span>
117
+ )
115
118
  }),
116
119
  helper.accessor('createdAt', {
117
120
  header: 'Date',
@@ -181,10 +184,12 @@ type Props = {
181
184
  sorting: OrdersSorting
182
185
  onSortingChange: (next: OrdersSorting) => void
183
186
  toolbar?: React.ReactNode
187
+ /** shown in place of the rows; the caller knows which filters produced the emptiness */
188
+ empty: React.ReactNode
184
189
  busy?: boolean
185
190
  }
186
191
 
187
- export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }: Props) => {
192
+ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, empty, busy }: Props) => {
188
193
  const layout = useOrdersTableLayout()
189
194
  const [dragging, setDragging] = useState<OrderColumn | null>(null)
190
195
  const [dropTarget, setDropTarget] = useState<string | null>(null)
@@ -280,11 +285,13 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
280
285
  </DropdownMenu>
281
286
  </div>
282
287
 
283
- <div
284
- className='overflow-x-auto rounded-lg border transition-opacity aria-busy:opacity-60'
285
- aria-busy={busy ? true : undefined}
286
- >
287
- <Table className='table-fixed' style={{ minWidth: table.getTotalSize() }}>
288
+ <div className='transition-opacity aria-busy:opacity-60' aria-busy={busy ? true : undefined}>
289
+ <Table
290
+ label='Orders'
291
+ containerClassName='rounded-lg border'
292
+ className='table-fixed'
293
+ style={{ minWidth: table.getTotalSize() }}
294
+ >
288
295
  <TableHeader>
289
296
  {table.getHeaderGroups().map(group => (
290
297
  <TableRow key={group.id}>
@@ -332,7 +339,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
332
339
  <span
333
340
  draggable
334
341
  aria-hidden='true'
335
- className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing'
342
+ className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing pointer-coarse:hidden'
336
343
  onDragStart={event => {
337
344
  event.dataTransfer.setData(DRAG_TYPE, column.id)
338
345
  event.dataTransfer.effectAllowed = 'move'
@@ -367,7 +374,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
367
374
  variant='ghost'
368
375
  size='icon-xs'
369
376
  className={cn(
370
- 'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 data-open:opacity-100',
377
+ 'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 data-open:opacity-100',
371
378
  align === 'end' ? 'mr-auto' : 'ml-auto'
372
379
  )}
373
380
  aria-label={`${labelOf(column)} column options`}
@@ -445,12 +452,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
445
452
  {table.getRowModel().rows.length === 0 ? (
446
453
  <TableRow>
447
454
  <TableCell colSpan={table.getVisibleLeafColumns().length} className='p-0'>
448
- <Empty className='border-none'>
449
- <EmptyHeader>
450
- <EmptyTitle>No orders match</EmptyTitle>
451
- <EmptyDescription>Clear the search or pick another status.</EmptyDescription>
452
- </EmptyHeader>
453
- </Empty>
455
+ {empty}
454
456
  </TableCell>
455
457
  </TableRow>
456
458
  ) : (
@@ -0,0 +1,11 @@
1
+ export const MAIN_CONTENT_ID = 'main-content'
2
+
3
+ // the first focusable element on the page: the shell's nav sits between the top and the content
4
+ export const SkipLink = () => (
5
+ <a
6
+ href={`#${MAIN_CONTENT_ID}`}
7
+ className='sr-only z-50 rounded-lg bg-background px-3 py-2 text-sm font-medium ring-1 ring-border focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:ring-3 focus:ring-ring/50'
8
+ >
9
+ Skip to content
10
+ </a>
11
+ )
@@ -17,7 +17,7 @@ export const ThemeToggle = () => {
17
17
  <Button
18
18
  variant='ghost'
19
19
  size='icon'
20
- aria-label={`Theme: ${theme}`}
20
+ aria-label={`Switch to ${next} theme`}
21
21
  onClick={() => setTheme(next)}
22
22
  >
23
23
  <Icon />
@@ -1,9 +1,24 @@
1
1
  import * as React from 'react'
2
2
  import { cn } from 'cn'
3
3
 
4
- function Table({ className, ...props }: React.ComponentProps<'table'>) {
4
+ type TableProps = React.ComponentProps<'table'> & {
5
+ /** names the scroll region; a keyboard user lands on it before the columns it hides */
6
+ label: string
7
+ containerClassName?: string
8
+ }
9
+
10
+ function Table({ className, label, containerClassName, ...props }: TableProps) {
5
11
  return (
6
- <div data-slot='table-container' className='relative w-full overflow-x-auto'>
12
+ <div
13
+ data-slot='table-container'
14
+ role='region'
15
+ aria-label={label}
16
+ tabIndex={0}
17
+ className={cn(
18
+ 'relative w-full overflow-x-auto scroll-x-hint focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
19
+ containerClassName
20
+ )}
21
+ >
7
22
  <table
8
23
  data-slot='table'
9
24
  className={cn('w-full caption-bottom text-sm', className)}
@@ -12,7 +12,29 @@ import {
12
12
  Loader2Icon
13
13
  } from 'lucide-react'
14
14
 
15
- const toast = ToastPrimitive.createToastManager()
15
+ const manager = ToastPrimitive.createToastManager()
16
+
17
+ // an error, or an action like Undo, is the way back: it must not expire before it is read
18
+ const persist = <T,>(value: T) =>
19
+ typeof value === 'string' ? { title: value, timeout: 0 } : { ...value, timeout: 0 }
20
+
21
+ const toast: typeof manager = {
22
+ ...manager,
23
+ add: options =>
24
+ manager.add({
25
+ timeout: options.type === 'error' || options.actionProps ? 0 : undefined,
26
+ ...options
27
+ }),
28
+ // `promise` builds its own toasts, so its error branch never reaches `add`
29
+ promise: (value, options) => {
30
+ const { error } = options
31
+ return manager.promise(value, {
32
+ ...options,
33
+ error:
34
+ typeof error === 'function' ? (reason: unknown) => persist(error(reason)) : persist(error)
35
+ })
36
+ }
37
+ }
16
38
 
17
39
  function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
18
40
  return <ToastPrimitive.Provider {...props} />
@@ -152,7 +174,7 @@ function ToastIcon({ type }: { type: string | undefined }) {
152
174
  }
153
175
 
154
176
  if (type === 'loading') {
155
- icon = <Loader2Icon className='animate-spin' aria-hidden='true' />
177
+ icon = <Loader2Icon className='motion-safe:animate-spin' aria-hidden='true' />
156
178
  }
157
179
 
158
180
  if (!icon) {
@@ -66,7 +66,7 @@
66
66
  --destructive: oklch(0.577 0.245 27.325);
67
67
  --border: oklch(0.922 0 0);
68
68
  --input: oklch(0.922 0 0);
69
- --ring: oklch(0.708 0 0);
69
+ --ring: oklch(0.62 0 0);
70
70
  --chart-1: oklch(0.87 0 0);
71
71
  --chart-2: oklch(0.556 0 0);
72
72
  --chart-3: oklch(0.439 0 0);
@@ -80,7 +80,7 @@
80
80
  --sidebar-accent: oklch(0.97 0 0);
81
81
  --sidebar-accent-foreground: oklch(0.205 0 0);
82
82
  --sidebar-border: oklch(0.922 0 0);
83
- --sidebar-ring: oklch(0.708 0 0);
83
+ --sidebar-ring: oklch(0.62 0 0);
84
84
  }
85
85
 
86
86
  .dark {
@@ -131,7 +131,7 @@
131
131
  color-scheme: dark;
132
132
  }
133
133
  body {
134
- @apply bg-background text-foreground tabular-nums antialiased;
134
+ @apply bg-background text-foreground antialiased;
135
135
  }
136
136
  h1,
137
137
  h2,
@@ -151,4 +151,28 @@
151
151
  appearance: none;
152
152
  margin: 0;
153
153
  }
154
+ /* covers the component library's enter/exit keyframes too, which no `motion-safe:` reaches */
155
+ @media (prefers-reduced-motion: reduce) {
156
+ *,
157
+ *::before,
158
+ *::after {
159
+ animation-duration: 0.01ms !important;
160
+ animation-iteration-count: 1 !important;
161
+ transition-duration: 0.01ms !important;
162
+ scroll-behavior: auto !important;
163
+ }
164
+ }
165
+ }
166
+
167
+ /* edge shadows that appear only on the side with more to scroll: `local` gradients cover them at the ends */
168
+ @utility scroll-x-hint {
169
+ --shadow-edge: color-mix(in oklch, var(--foreground) 18%, transparent);
170
+ background:
171
+ linear-gradient(to right, var(--background), transparent) 0 0 / 24px 100% no-repeat local,
172
+ linear-gradient(to left, var(--background), transparent) 100% 0 / 24px 100% no-repeat local,
173
+ radial-gradient(farthest-side at 0 50%, var(--shadow-edge), transparent) 0 0 / 12px 100%
174
+ no-repeat scroll,
175
+ radial-gradient(farthest-side at 100% 50%, var(--shadow-edge), transparent) 100% 0 / 12px 100%
176
+ no-repeat scroll;
177
+ background-color: var(--background);
154
178
  }
@@ -26,7 +26,7 @@ function HomePage() {
26
26
  return (
27
27
  <main className='flex flex-1 flex-col gap-6'>
28
28
  <div className='flex flex-col gap-1'>
29
- <h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
29
+ <h1 className='text-2xl font-semibold'>Home</h1>
30
30
  <p className='text-sm text-muted-foreground'>Signed in as {user.email}</p>
31
31
  </div>
32
32
  {/* realtime:start */}
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react'
4
4
  import { isNotFound } from '@/api'
5
5
  import { noteQuery, useNote } from '@/api/notes'
6
6
  import { buttonVariants } from '@/components/ui/button'
7
+ import { formatDate } from '@/lib/format'
7
8
 
8
9
  export const Route = createFileRoute('/_authenticated/notes/$noteId')({
9
10
  loader: async ({ context, params }) => {
@@ -31,7 +32,7 @@ function NotePage() {
31
32
  <header className='space-y-1'>
32
33
  <h1 className='text-2xl font-semibold'>{note.title}</h1>
33
34
  <p className='text-xs text-muted-foreground'>
34
- <time dateTime={note.createdAt}>{new Date(note.createdAt).toLocaleDateString()}</time>
35
+ <time dateTime={note.createdAt}>{formatDate(note.createdAt)}</time>
35
36
  </p>
36
37
  </header>
37
38
  {note.body ? (
@@ -1,11 +1,20 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute } from '@tanstack/react-router'
3
3
  import { Trash2 } from 'lucide-react'
4
+ import { useState } from 'react'
4
5
  import { useForm } from 'react-hook-form'
5
6
 
6
7
  import { notesQuery, useCreateNote, useDeleteNote, useDeletingNoteIds, useNotes } from '@/api/notes'
7
- import { CreateNoteSchema } from '@/api/notes/schema'
8
+ import { CreateNoteSchema, type Note } from '@/api/notes/schema'
8
9
  import { Button } from '@/components/ui/button'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle
17
+ } from '@/components/ui/dialog'
9
18
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
10
19
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
11
20
  import { Input } from '@/components/ui/input'
@@ -22,6 +31,9 @@ function NotesPage() {
22
31
  const createNote = useCreateNote()
23
32
  const deleteNote = useDeleteNote()
24
33
  const deleting = new Set(useDeletingNoteIds())
34
+ // deleting a note is final, so it is confirmed rather than undone.
35
+ // the note outlives `open`: the dialog is still on screen while it animates out
36
+ const [confirm, setConfirm] = useState<{ note: Note; open: boolean } | null>(null)
25
37
  const {
26
38
  register,
27
39
  handleSubmit,
@@ -33,6 +45,7 @@ function NotesPage() {
33
45
  })
34
46
 
35
47
  const onSubmit = handleSubmit(values => createNote.mutate(values, { onSuccess: () => reset() }))
48
+ const close = () => setConfirm(current => current && { ...current, open: false })
36
49
 
37
50
  return (
38
51
  <main className='mx-auto w-full max-w-md space-y-6'>
@@ -90,11 +103,11 @@ function NotesPage() {
90
103
  {note.title}
91
104
  </Link>
92
105
  <Button
93
- variant='ghost'
106
+ variant='destructive'
94
107
  size='icon-sm'
95
108
  aria-label={`Delete ${note.title}`}
96
109
  disabled={deleting.has(note.id)}
97
- onClick={() => deleteNote.mutate(note.id)}
110
+ onClick={() => setConfirm({ note, open: true })}
98
111
  >
99
112
  <Trash2 />
100
113
  </Button>
@@ -102,6 +115,31 @@ function NotesPage() {
102
115
  ))}
103
116
  </ul>
104
117
  )}
118
+
119
+ <Dialog open={confirm?.open === true} onOpenChange={next => !next && close()}>
120
+ <DialogContent>
121
+ <DialogHeader>
122
+ <DialogTitle>Delete this note?</DialogTitle>
123
+ <DialogDescription>
124
+ “{confirm?.note.title}” will be removed for good. This cannot be undone.
125
+ </DialogDescription>
126
+ </DialogHeader>
127
+ <DialogFooter>
128
+ <Button variant='outline' onClick={close}>
129
+ Cancel
130
+ </Button>
131
+ <Button
132
+ variant='destructive'
133
+ onClick={() => {
134
+ if (confirm) deleteNote.mutate(confirm.note.id)
135
+ close()
136
+ }}
137
+ >
138
+ Delete note
139
+ </Button>
140
+ </DialogFooter>
141
+ </DialogContent>
142
+ </Dialog>
105
143
  </main>
106
144
  )
107
145
  }
@@ -18,7 +18,14 @@ import {
18
18
  import { CreateOrderDialog } from '@/components/orders/create-order-dialog'
19
19
  import { statusLabel } from '@/components/orders/order-status'
20
20
  import { OrdersTable, type OrdersSorting } from '@/components/orders/orders-table'
21
- import { buttonVariants } from '@/components/ui/button'
21
+ import { Button, buttonVariants } from '@/components/ui/button'
22
+ import {
23
+ Empty,
24
+ EmptyContent,
25
+ EmptyDescription,
26
+ EmptyHeader,
27
+ EmptyTitle
28
+ } from '@/components/ui/empty'
22
29
  import { Input } from '@/components/ui/input'
23
30
  import {
24
31
  Select,
@@ -84,11 +91,36 @@ function OrdersPage() {
84
91
  })
85
92
  const onSorting = ({ sort, order }: OrdersSorting) =>
86
93
  void navigate({ search: prev => ({ ...prev, sort, order, page: 1 }) })
94
+ const onClearFilters = () =>
95
+ void navigate({ search: prev => ({ ...prev, q: undefined, status: undefined, page: 1 }) })
87
96
  const onPageSize = (value: string | null) =>
88
97
  void navigate({
89
98
  search: prev => ({ ...prev, pageSize: Number(value) as (typeof PAGE_SIZES)[number], page: 1 })
90
99
  })
91
100
 
101
+ const filtered = Boolean(search.q || search.status)
102
+ const empty = (
103
+ <Empty className='border-none'>
104
+ <EmptyHeader>
105
+ <EmptyTitle>
106
+ {search.q ? `No orders match \u201C${search.q}\u201D` : 'No orders to show'}
107
+ </EmptyTitle>
108
+ <EmptyDescription>
109
+ {filtered ? 'Try another search or status.' : 'Create the first one to see it here.'}
110
+ </EmptyDescription>
111
+ </EmptyHeader>
112
+ <EmptyContent>
113
+ {filtered ? (
114
+ <Button variant='outline' onClick={onClearFilters}>
115
+ Clear filters
116
+ </Button>
117
+ ) : (
118
+ <CreateOrderDialog />
119
+ )}
120
+ </EmptyContent>
121
+ </Empty>
122
+ )
123
+
92
124
  return (
93
125
  <main className='flex flex-1 flex-col gap-4'>
94
126
  <div className='flex flex-wrap items-center justify-between gap-2'>
@@ -100,6 +132,7 @@ function OrdersPage() {
100
132
  orders={data.items}
101
133
  sorting={search}
102
134
  onSortingChange={onSorting}
135
+ empty={empty}
103
136
  busy={busy}
104
137
  toolbar={
105
138
  <>
@@ -60,11 +60,11 @@
60
60
  "error",
61
61
  {
62
62
  "name": "fetch",
63
- "message": "Use the ky instances from src/api/index.ts."
63
+ "message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
64
64
  },
65
65
  {
66
66
  "name": "localStorage",
67
- "message": "Use createStorageStore from src/lib/storage-store.ts."
67
+ "message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
68
68
  }
69
69
  ],
70
70
  "no-restricted-imports": [
@@ -73,12 +73,12 @@
73
73
  "paths": [
74
74
  {
75
75
  "name": "zod",
76
- "message": "Import from zod/mini. For forms use zodResolver; it works with mini."
76
+ "message": "Import from 'zod/mini'. Mixing the two ships both builds (+15.8 kB gzip). For forms use zodResolver (typed against zod/v4/core, works with mini)."
77
77
  },
78
78
  {
79
79
  "name": "react",
80
80
  "importNames": ["useMemo", "useCallback"],
81
- "message": "React Compiler is enabled; avoid manual memoization."
81
+ "message": "React Compiler is on: manual memoization is noise."
82
82
  }
83
83
  ]
84
84
  }
@@ -92,7 +92,7 @@
92
92
  "error",
93
93
  {
94
94
  "name": "localStorage",
95
- "message": "Use createStorageStore from src/lib/storage-store.ts."
95
+ "message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
96
96
  }
97
97
  ]
98
98
  }
@@ -104,13 +104,13 @@
104
104
  "error",
105
105
  {
106
106
  "name": "fetch",
107
- "message": "Use the ky instances from src/api/index.ts."
107
+ "message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
108
108
  }
109
109
  ]
110
110
  }
111
111
  },
112
112
  {
113
- "files": ["src/api/auth/auth.test.ts", "src/api/refresh.test.ts"],
113
+ "files": ["src/api/**/*.test.ts"],
114
114
  "rules": {
115
115
  "no-restricted-globals": "off"
116
116
  }
@@ -118,17 +118,7 @@
118
118
  {
119
119
  "files": ["src/components/ui/**"],
120
120
  "rules": {
121
- "no-restricted-imports": [
122
- "error",
123
- {
124
- "paths": [
125
- {
126
- "name": "zod",
127
- "message": "Import from zod/mini. For forms use zodResolver; it works with mini."
128
- }
129
- ]
130
- }
131
- ]
121
+ "no-restricted-imports": "off"
132
122
  }
133
123
  }
134
124
  ]
@@ -55,7 +55,7 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
55
55
  )
56
56
  .map(el => el.textContent.trim() || (el.getAttribute('aria-label') ?? ''))
57
57
  )
58
- const order = ['Home', 'Notes', 'Theme', 'Sign out'].map(name =>
58
+ const order = ['Home', 'Notes', 'Switch to', 'Sign out'].map(name =>
59
59
  tabbables.findIndex(text => text.startsWith(name))
60
60
  )
61
61
  expect(order.every(index => index >= 0)).toBe(true)
@@ -73,12 +73,12 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
73
73
 
74
74
  test('a button activates with Space', async ({ page }) => {
75
75
  await signIn(page)
76
- const toggle = page.getByRole('button', { name: 'Theme: system' })
76
+ const toggle = page.getByRole('button', { name: 'Switch to light theme' })
77
77
 
78
78
  await toggle.focus()
79
79
  await page.keyboard.press('Space')
80
80
 
81
- await expect(page.getByRole('button', { name: 'Theme: light' })).toBeFocused()
81
+ await expect(page.getByRole('button', { name: 'Switch to dark theme' })).toBeFocused()
82
82
  })
83
83
 
84
84
  test('the back-home link activates with Enter', async ({ page }) => {
@@ -3,11 +3,13 @@ import type { PropsWithChildren } from 'react'
3
3
 
4
4
  import { HeaderActions } from '@/components/header-actions'
5
5
  import { NAV } from '@/components/nav'
6
+ import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
6
7
 
7
8
  // header-only shell: `create-cli` renames this over `app-shell.tsx` when the sidebar is off
8
9
  export const AppShell = ({ children }: PropsWithChildren) => {
9
10
  return (
10
11
  <>
12
+ <SkipLink />
11
13
  <header className='flex h-14 items-center gap-4 border-b px-6'>
12
14
  <nav aria-label='Main' className='flex items-center gap-4 text-sm'>
13
15
  {NAV.map(item => (
@@ -23,7 +25,9 @@ export const AppShell = ({ children }: PropsWithChildren) => {
23
25
  </nav>
24
26
  <HeaderActions />
25
27
  </header>
26
- <div className='flex flex-1 flex-col p-4 sm:p-6'>{children}</div>
28
+ <div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
29
+ {children}
30
+ </div>
27
31
  </>
28
32
  )
29
33
  }
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'
2
2
 
3
3
  import { AppSidebar } from '@/components/app-sidebar'
4
4
  import { HeaderActions } from '@/components/header-actions'
5
+ import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
5
6
  import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
6
7
  import { sidebarStore, useSidebarOpen } from '@/lib/sidebar-store'
7
8
 
@@ -11,13 +12,16 @@ export const AppShell = ({ children }: PropsWithChildren) => {
11
12
 
12
13
  return (
13
14
  <SidebarProvider open={open} onOpenChange={sidebarStore.set}>
15
+ <SkipLink />
14
16
  <AppSidebar />
15
17
  <SidebarInset className='min-w-0'>
16
18
  <header className='flex h-14 items-center gap-2 border-b px-4'>
17
19
  <SidebarTrigger />
18
20
  <HeaderActions />
19
21
  </header>
20
- <div className='flex flex-1 flex-col p-4 sm:p-6'>{children}</div>
22
+ <div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
23
+ {children}
24
+ </div>
21
25
  </SidebarInset>
22
26
  </SidebarProvider>
23
27
  )
@@ -53,7 +53,6 @@ import {
53
53
  DropdownMenuSeparator,
54
54
  DropdownMenuTrigger
55
55
  } from '@/components/ui/dropdown-menu'
56
- import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
57
56
  import {
58
57
  Table,
59
58
  TableBody,
@@ -111,7 +110,11 @@ const columns = helper.columns([
111
110
  header: 'Amount',
112
111
  size: 130,
113
112
  meta: { align: 'end' },
114
- cell: ({ row }) => formatMoney(row.original.amountCents, row.original.currency)
113
+ cell: ({ row }) => (
114
+ <span className='tabular-nums'>
115
+ {formatMoney(row.original.amountCents, row.original.currency)}
116
+ </span>
117
+ )
115
118
  }),
116
119
  helper.accessor('createdAt', {
117
120
  header: 'Date',
@@ -181,10 +184,12 @@ type Props = {
181
184
  sorting: OrdersSorting
182
185
  onSortingChange: (next: OrdersSorting) => void
183
186
  toolbar?: React.ReactNode
187
+ /** shown in place of the rows; the caller knows which filters produced the emptiness */
188
+ empty: React.ReactNode
184
189
  busy?: boolean
185
190
  }
186
191
 
187
- export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }: Props) => {
192
+ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, empty, busy }: Props) => {
188
193
  const layout = useOrdersTableLayout()
189
194
  const [dragging, setDragging] = useState<OrderColumn | null>(null)
190
195
  const [dropTarget, setDropTarget] = useState<string | null>(null)
@@ -280,11 +285,13 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
280
285
  </DropdownMenu>
281
286
  </div>
282
287
 
283
- <div
284
- className='overflow-x-auto rounded-lg border transition-opacity aria-busy:opacity-60'
285
- aria-busy={busy ? true : undefined}
286
- >
287
- <Table className='table-fixed' style={{ minWidth: table.getTotalSize() }}>
288
+ <div className='transition-opacity aria-busy:opacity-60' aria-busy={busy ? true : undefined}>
289
+ <Table
290
+ label='Orders'
291
+ containerClassName='rounded-lg border'
292
+ className='table-fixed'
293
+ style={{ minWidth: table.getTotalSize() }}
294
+ >
288
295
  <TableHeader>
289
296
  {table.getHeaderGroups().map(group => (
290
297
  <TableRow key={group.id}>
@@ -332,7 +339,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
332
339
  <span
333
340
  draggable
334
341
  aria-hidden='true'
335
- className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing'
342
+ className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing pointer-coarse:hidden'
336
343
  onDragStart={event => {
337
344
  event.dataTransfer.setData(DRAG_TYPE, column.id)
338
345
  event.dataTransfer.effectAllowed = 'move'
@@ -367,7 +374,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
367
374
  variant='ghost'
368
375
  size='icon-xs'
369
376
  className={cn(
370
- 'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 data-open:opacity-100',
377
+ 'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 data-open:opacity-100',
371
378
  align === 'end' ? 'mr-auto' : 'ml-auto'
372
379
  )}
373
380
  aria-label={`${labelOf(column)} column options`}
@@ -445,12 +452,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
445
452
  {table.getRowModel().rows.length === 0 ? (
446
453
  <TableRow>
447
454
  <TableCell colSpan={table.getVisibleLeafColumns().length} className='p-0'>
448
- <Empty className='border-none'>
449
- <EmptyHeader>
450
- <EmptyTitle>No orders match</EmptyTitle>
451
- <EmptyDescription>Clear the search or pick another status.</EmptyDescription>
452
- </EmptyHeader>
453
- </Empty>
455
+ {empty}
454
456
  </TableCell>
455
457
  </TableRow>
456
458
  ) : (
@@ -0,0 +1,11 @@
1
+ export const MAIN_CONTENT_ID = 'main-content'
2
+
3
+ // the first focusable element on the page: the shell's nav sits between the top and the content
4
+ export const SkipLink = () => (
5
+ <a
6
+ href={`#${MAIN_CONTENT_ID}`}
7
+ className='sr-only z-50 rounded-lg bg-background px-3 py-2 text-sm font-medium ring-1 ring-border focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:ring-3 focus:ring-ring/50'
8
+ >
9
+ Skip to content
10
+ </a>
11
+ )
@@ -17,7 +17,7 @@ export const ThemeToggle = () => {
17
17
  <Button
18
18
  variant='ghost'
19
19
  size='icon'
20
- aria-label={`Theme: ${theme}`}
20
+ aria-label={`Switch to ${next} theme`}
21
21
  onClick={() => setTheme(next)}
22
22
  >
23
23
  <Icon />
@@ -1,9 +1,24 @@
1
1
  import * as React from 'react'
2
2
  import { cn } from 'cn'
3
3
 
4
- function Table({ className, ...props }: React.ComponentProps<'table'>) {
4
+ type TableProps = React.ComponentProps<'table'> & {
5
+ /** names the scroll region; a keyboard user lands on it before the columns it hides */
6
+ label: string
7
+ containerClassName?: string
8
+ }
9
+
10
+ function Table({ className, label, containerClassName, ...props }: TableProps) {
5
11
  return (
6
- <div data-slot='table-container' className='relative w-full overflow-x-auto'>
12
+ <div
13
+ data-slot='table-container'
14
+ role='region'
15
+ aria-label={label}
16
+ tabIndex={0}
17
+ className={cn(
18
+ 'relative w-full overflow-x-auto scroll-x-hint focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
19
+ containerClassName
20
+ )}
21
+ >
7
22
  <table
8
23
  data-slot='table'
9
24
  className={cn('w-full caption-bottom text-sm', className)}
@@ -12,7 +12,29 @@ import {
12
12
  Loader2Icon
13
13
  } from 'lucide-react'
14
14
 
15
- const toast = ToastPrimitive.createToastManager()
15
+ const manager = ToastPrimitive.createToastManager()
16
+
17
+ // an error, or an action like Undo, is the way back: it must not expire before it is read
18
+ const persist = <T,>(value: T) =>
19
+ typeof value === 'string' ? { title: value, timeout: 0 } : { ...value, timeout: 0 }
20
+
21
+ const toast: typeof manager = {
22
+ ...manager,
23
+ add: options =>
24
+ manager.add({
25
+ timeout: options.type === 'error' || options.actionProps ? 0 : undefined,
26
+ ...options
27
+ }),
28
+ // `promise` builds its own toasts, so its error branch never reaches `add`
29
+ promise: (value, options) => {
30
+ const { error } = options
31
+ return manager.promise(value, {
32
+ ...options,
33
+ error:
34
+ typeof error === 'function' ? (reason: unknown) => persist(error(reason)) : persist(error)
35
+ })
36
+ }
37
+ }
16
38
 
17
39
  function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
18
40
  return <ToastPrimitive.Provider {...props} />
@@ -152,7 +174,7 @@ function ToastIcon({ type }: { type: string | undefined }) {
152
174
  }
153
175
 
154
176
  if (type === 'loading') {
155
- icon = <Loader2Icon className='animate-spin' aria-hidden='true' />
177
+ icon = <Loader2Icon className='motion-safe:animate-spin' aria-hidden='true' />
156
178
  }
157
179
 
158
180
  if (!icon) {
@@ -66,7 +66,7 @@
66
66
  --destructive: oklch(0.577 0.245 27.325);
67
67
  --border: oklch(0.922 0 0);
68
68
  --input: oklch(0.922 0 0);
69
- --ring: oklch(0.708 0 0);
69
+ --ring: oklch(0.62 0 0);
70
70
  --chart-1: oklch(0.87 0 0);
71
71
  --chart-2: oklch(0.556 0 0);
72
72
  --chart-3: oklch(0.439 0 0);
@@ -80,7 +80,7 @@
80
80
  --sidebar-accent: oklch(0.97 0 0);
81
81
  --sidebar-accent-foreground: oklch(0.205 0 0);
82
82
  --sidebar-border: oklch(0.922 0 0);
83
- --sidebar-ring: oklch(0.708 0 0);
83
+ --sidebar-ring: oklch(0.62 0 0);
84
84
  }
85
85
 
86
86
  .dark {
@@ -131,7 +131,7 @@
131
131
  color-scheme: dark;
132
132
  }
133
133
  body {
134
- @apply bg-background text-foreground tabular-nums antialiased;
134
+ @apply bg-background text-foreground antialiased;
135
135
  }
136
136
  h1,
137
137
  h2,
@@ -151,4 +151,28 @@
151
151
  appearance: none;
152
152
  margin: 0;
153
153
  }
154
+ /* covers the component library's enter/exit keyframes too, which no `motion-safe:` reaches */
155
+ @media (prefers-reduced-motion: reduce) {
156
+ *,
157
+ *::before,
158
+ *::after {
159
+ animation-duration: 0.01ms !important;
160
+ animation-iteration-count: 1 !important;
161
+ transition-duration: 0.01ms !important;
162
+ scroll-behavior: auto !important;
163
+ }
164
+ }
165
+ }
166
+
167
+ /* edge shadows that appear only on the side with more to scroll: `local` gradients cover them at the ends */
168
+ @utility scroll-x-hint {
169
+ --shadow-edge: color-mix(in oklch, var(--foreground) 18%, transparent);
170
+ background:
171
+ linear-gradient(to right, var(--background), transparent) 0 0 / 24px 100% no-repeat local,
172
+ linear-gradient(to left, var(--background), transparent) 100% 0 / 24px 100% no-repeat local,
173
+ radial-gradient(farthest-side at 0 50%, var(--shadow-edge), transparent) 0 0 / 12px 100%
174
+ no-repeat scroll,
175
+ radial-gradient(farthest-side at 100% 50%, var(--shadow-edge), transparent) 100% 0 / 12px 100%
176
+ no-repeat scroll;
177
+ background-color: var(--background);
154
178
  }
@@ -26,7 +26,7 @@ function HomePage() {
26
26
  return (
27
27
  <main className='flex flex-1 flex-col gap-6'>
28
28
  <div className='flex flex-col gap-1'>
29
- <h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
29
+ <h1 className='text-2xl font-semibold'>Home</h1>
30
30
  <p className='text-sm text-muted-foreground'>Signed in as {user.email}</p>
31
31
  </div>
32
32
  {/* realtime:start */}
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react'
4
4
  import { isNotFound } from '@/api'
5
5
  import { noteQuery, useNote } from '@/api/notes'
6
6
  import { buttonVariants } from '@/components/ui/button'
7
+ import { formatDate } from '@/lib/format'
7
8
 
8
9
  export const Route = createFileRoute('/_authenticated/notes/$noteId')({
9
10
  loader: async ({ context, params }) => {
@@ -31,7 +32,7 @@ function NotePage() {
31
32
  <header className='space-y-1'>
32
33
  <h1 className='text-2xl font-semibold'>{note.title}</h1>
33
34
  <p className='text-xs text-muted-foreground'>
34
- <time dateTime={note.createdAt}>{new Date(note.createdAt).toLocaleDateString()}</time>
35
+ <time dateTime={note.createdAt}>{formatDate(note.createdAt)}</time>
35
36
  </p>
36
37
  </header>
37
38
  {note.body ? (
@@ -1,11 +1,20 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute } from '@tanstack/react-router'
3
3
  import { Trash2 } from 'lucide-react'
4
+ import { useState } from 'react'
4
5
  import { useForm } from 'react-hook-form'
5
6
 
6
7
  import { notesQuery, useCreateNote, useDeleteNote, useDeletingNoteIds, useNotes } from '@/api/notes'
7
- import { CreateNoteSchema } from '@/api/notes/schema'
8
+ import { CreateNoteSchema, type Note } from '@/api/notes/schema'
8
9
  import { Button } from '@/components/ui/button'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle
17
+ } from '@/components/ui/dialog'
9
18
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
10
19
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
11
20
  import { Input } from '@/components/ui/input'
@@ -22,6 +31,9 @@ function NotesPage() {
22
31
  const createNote = useCreateNote()
23
32
  const deleteNote = useDeleteNote()
24
33
  const deleting = new Set(useDeletingNoteIds())
34
+ // deleting a note is final, so it is confirmed rather than undone.
35
+ // the note outlives `open`: the dialog is still on screen while it animates out
36
+ const [confirm, setConfirm] = useState<{ note: Note; open: boolean } | null>(null)
25
37
  const {
26
38
  register,
27
39
  handleSubmit,
@@ -33,6 +45,7 @@ function NotesPage() {
33
45
  })
34
46
 
35
47
  const onSubmit = handleSubmit(values => createNote.mutate(values, { onSuccess: () => reset() }))
48
+ const close = () => setConfirm(current => current && { ...current, open: false })
36
49
 
37
50
  return (
38
51
  <main className='mx-auto w-full max-w-md space-y-6'>
@@ -90,11 +103,11 @@ function NotesPage() {
90
103
  {note.title}
91
104
  </Link>
92
105
  <Button
93
- variant='ghost'
106
+ variant='destructive'
94
107
  size='icon-sm'
95
108
  aria-label={`Delete ${note.title}`}
96
109
  disabled={deleting.has(note.id)}
97
- onClick={() => deleteNote.mutate(note.id)}
110
+ onClick={() => setConfirm({ note, open: true })}
98
111
  >
99
112
  <Trash2 />
100
113
  </Button>
@@ -102,6 +115,31 @@ function NotesPage() {
102
115
  ))}
103
116
  </ul>
104
117
  )}
118
+
119
+ <Dialog open={confirm?.open === true} onOpenChange={next => !next && close()}>
120
+ <DialogContent>
121
+ <DialogHeader>
122
+ <DialogTitle>Delete this note?</DialogTitle>
123
+ <DialogDescription>
124
+ “{confirm?.note.title}” will be removed for good. This cannot be undone.
125
+ </DialogDescription>
126
+ </DialogHeader>
127
+ <DialogFooter>
128
+ <Button variant='outline' onClick={close}>
129
+ Cancel
130
+ </Button>
131
+ <Button
132
+ variant='destructive'
133
+ onClick={() => {
134
+ if (confirm) deleteNote.mutate(confirm.note.id)
135
+ close()
136
+ }}
137
+ >
138
+ Delete note
139
+ </Button>
140
+ </DialogFooter>
141
+ </DialogContent>
142
+ </Dialog>
105
143
  </main>
106
144
  )
107
145
  }
@@ -18,7 +18,14 @@ import {
18
18
  import { CreateOrderDialog } from '@/components/orders/create-order-dialog'
19
19
  import { statusLabel } from '@/components/orders/order-status'
20
20
  import { OrdersTable, type OrdersSorting } from '@/components/orders/orders-table'
21
- import { buttonVariants } from '@/components/ui/button'
21
+ import { Button, buttonVariants } from '@/components/ui/button'
22
+ import {
23
+ Empty,
24
+ EmptyContent,
25
+ EmptyDescription,
26
+ EmptyHeader,
27
+ EmptyTitle
28
+ } from '@/components/ui/empty'
22
29
  import { Input } from '@/components/ui/input'
23
30
  import {
24
31
  Select,
@@ -84,11 +91,36 @@ function OrdersPage() {
84
91
  })
85
92
  const onSorting = ({ sort, order }: OrdersSorting) =>
86
93
  void navigate({ search: prev => ({ ...prev, sort, order, page: 1 }) })
94
+ const onClearFilters = () =>
95
+ void navigate({ search: prev => ({ ...prev, q: undefined, status: undefined, page: 1 }) })
87
96
  const onPageSize = (value: string | null) =>
88
97
  void navigate({
89
98
  search: prev => ({ ...prev, pageSize: Number(value) as (typeof PAGE_SIZES)[number], page: 1 })
90
99
  })
91
100
 
101
+ const filtered = Boolean(search.q || search.status)
102
+ const empty = (
103
+ <Empty className='border-none'>
104
+ <EmptyHeader>
105
+ <EmptyTitle>
106
+ {search.q ? `No orders match \u201C${search.q}\u201D` : 'No orders to show'}
107
+ </EmptyTitle>
108
+ <EmptyDescription>
109
+ {filtered ? 'Try another search or status.' : 'Create the first one to see it here.'}
110
+ </EmptyDescription>
111
+ </EmptyHeader>
112
+ <EmptyContent>
113
+ {filtered ? (
114
+ <Button variant='outline' onClick={onClearFilters}>
115
+ Clear filters
116
+ </Button>
117
+ ) : (
118
+ <CreateOrderDialog />
119
+ )}
120
+ </EmptyContent>
121
+ </Empty>
122
+ )
123
+
92
124
  return (
93
125
  <main className='flex flex-1 flex-col gap-4'>
94
126
  <div className='flex flex-wrap items-center justify-between gap-2'>
@@ -100,6 +132,7 @@ function OrdersPage() {
100
132
  orders={data.items}
101
133
  sorting={search}
102
134
  onSortingChange={onSorting}
135
+ empty={empty}
103
136
  busy={busy}
104
137
  toolbar={
105
138
  <>