@actuate-media/cms-admin 0.23.0 → 0.24.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 (34) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  5. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  7. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  8. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  11. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/forms/EntriesTable.d.ts +28 -0
  14. package/dist/components/forms/EntriesTable.d.ts.map +1 -0
  15. package/dist/components/forms/EntriesTable.js +26 -0
  16. package/dist/components/forms/EntriesTable.js.map +1 -0
  17. package/dist/layout/Sidebar.d.ts.map +1 -1
  18. package/dist/layout/Sidebar.js +44 -21
  19. package/dist/layout/Sidebar.js.map +1 -1
  20. package/dist/views/FormEntries.d.ts +5 -0
  21. package/dist/views/FormEntries.d.ts.map +1 -0
  22. package/dist/views/FormEntries.js +211 -0
  23. package/dist/views/FormEntries.js.map +1 -0
  24. package/dist/views/FormSubmissions.d.ts.map +1 -1
  25. package/dist/views/FormSubmissions.js +4 -24
  26. package/dist/views/FormSubmissions.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/AdminRoot.tsx +4 -0
  29. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  30. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  31. package/src/components/forms/EntriesTable.tsx +197 -0
  32. package/src/layout/Sidebar.tsx +75 -17
  33. package/src/views/FormEntries.tsx +449 -0
  34. package/src/views/FormSubmissions.tsx +4 -149
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
package/src/AdminRoot.tsx CHANGED
@@ -13,6 +13,7 @@ import { Settings } from './views/Settings.js'
13
13
  import { Forms } from './views/Forms.js'
14
14
  import { FormEditor } from './views/FormEditor.js'
15
15
  import { FormSubmissions } from './views/FormSubmissions.js'
16
+ import { FormEntries } from './views/FormEntries.js'
16
17
  import { SEO } from './views/SEO.js'
17
18
  import { ScriptTags } from './views/ScriptTags.js'
18
19
  import { ScriptTagEditor } from './views/ScriptTagEditor.js'
@@ -365,6 +366,9 @@ function AdminShell({
365
366
  if (matchRoute('/forms/new')) {
366
367
  return <FormEditor onNavigate={navigate} />
367
368
  }
369
+ if (matchRoute('/forms/entries')) {
370
+ return <FormEntries onNavigate={navigate} />
371
+ }
368
372
  const formEdit = matchRoute('/forms/:id/edit')
369
373
  if (formEdit?.id) {
370
374
  return <FormEditor formId={formEdit.id} onNavigate={navigate} />
@@ -0,0 +1,38 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // The Forms nav item expands into a submenu: an "All Entries" child plus one
6
+ // child per active form carrying its own unread badge. Mock the sidebar-counts
7
+ // fetcher so the children are deterministic.
8
+ // Distinct per-form counts so a child badge can't collide with the parent
9
+ // total badge (8 + 5 = 13).
10
+ const fetchFormsSidebarCounts = vi.fn(async () => [
11
+ { formId: 'f1', name: 'Contact Us', slug: 'contact-us', status: 'active', unread: 8 },
12
+ { formId: 'f2', name: 'Project Inquiry', slug: 'project-inquiry', status: 'active', unread: 5 },
13
+ ])
14
+
15
+ vi.mock('../../lib/forms-service.js', () => ({
16
+ fetchFormsSidebarCounts: () => fetchFormsSidebarCounts(),
17
+ }))
18
+
19
+ const { Sidebar } = await import('../../layout/Sidebar.js')
20
+
21
+ describe('Sidebar Forms submenu', () => {
22
+ it('renders All Entries plus a child per active form', async () => {
23
+ render(
24
+ <Sidebar
25
+ collapsed={false}
26
+ onToggleCollapse={() => {}}
27
+ currentPath="/forms"
28
+ onNavigate={() => {}}
29
+ />,
30
+ )
31
+
32
+ await waitFor(() => expect(screen.getByText('All Entries')).toBeTruthy())
33
+ // Per-form children (the Contact Us label also appears; scope to nav links).
34
+ expect(screen.getByText('Project Inquiry')).toBeTruthy()
35
+ // The form with unread submissions shows its own badge.
36
+ expect(screen.getByLabelText('8 unread')).toBeTruthy()
37
+ })
38
+ })
@@ -0,0 +1,133 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Drive the global All Entries inbox through a mocked forms-service so the test
6
+ // owns the data and can assert the cross-form table (Form column + form
7
+ // filter), status-pill refetches, and opening the detail drawer.
8
+ function makeEntry(over: Record<string, unknown> = {}) {
9
+ return {
10
+ id: 'e1',
11
+ entryNumber: 1,
12
+ formId: 'f1',
13
+ formName: 'Contact Us',
14
+ formSlug: 'contact-us',
15
+ data: { name: 'Jordan Avery', email: 'jordan@example.com', message: 'Hello there' },
16
+ senderName: 'Jordan Avery',
17
+ senderEmail: 'jordan@example.com',
18
+ preview: 'Hello there',
19
+ unread: true,
20
+ starred: false,
21
+ archived: false,
22
+ spamStatus: 'clean',
23
+ submittedAt: new Date().toISOString(),
24
+ createdAt: new Date().toISOString(),
25
+ updatedAt: new Date().toISOString(),
26
+ ...over,
27
+ }
28
+ }
29
+
30
+ const ENTRIES = [
31
+ makeEntry(),
32
+ makeEntry({
33
+ id: 'e2',
34
+ entryNumber: 2,
35
+ formId: 'f2',
36
+ formName: 'Project Inquiry',
37
+ formSlug: 'project-inquiry',
38
+ senderName: 'Priya Nair',
39
+ senderEmail: 'priya@example.com',
40
+ preview: 'A question about pricing',
41
+ unread: false,
42
+ }),
43
+ ]
44
+
45
+ const fetchForms = vi.fn(async () => [
46
+ { id: 'f1', name: 'Contact Us', slug: 'contact-us', status: 'active', fields: [] },
47
+ { id: 'f2', name: 'Project Inquiry', slug: 'project-inquiry', status: 'active', fields: [] },
48
+ ])
49
+ const fetchEntries = vi.fn(async (_p?: unknown) => ({
50
+ entries: ENTRIES,
51
+ total: ENTRIES.length,
52
+ page: 1,
53
+ pageSize: 25,
54
+ }))
55
+ const fetchEntryCounts = vi.fn(async (_p?: unknown) => ({
56
+ total: 2,
57
+ unread: 1,
58
+ starred: 0,
59
+ thisWeek: 2,
60
+ byForm: { f1: 1 },
61
+ }))
62
+ const fetchEntry = vi.fn(async (_id?: string) => ({ entry: ENTRIES[0], fields: [] }))
63
+ const updateEntry = vi.fn(async (_id?: string, _patch?: unknown) => ({ data: ENTRIES[0] }))
64
+
65
+ vi.mock('../../lib/forms-service.js', () => ({
66
+ fetchForms: () => fetchForms(),
67
+ fetchEntries: (p?: unknown) => fetchEntries(p),
68
+ fetchEntryCounts: (p?: unknown) => fetchEntryCounts(p),
69
+ fetchEntry: (id?: string) => fetchEntry(id),
70
+ updateEntry: (id?: string, patch?: unknown) => updateEntry(id, patch),
71
+ deleteEntry: vi.fn(async () => ({})),
72
+ bulkUpdateEntries: vi.fn(async () => ({ data: { updated: 1 } })),
73
+ }))
74
+
75
+ vi.mock('../../lib/forms-events.js', () => ({
76
+ onFormsChanged: () => () => {},
77
+ emitFormsChanged: vi.fn(),
78
+ }))
79
+
80
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
81
+
82
+ const { FormEntries } = await import('../../views/FormEntries.js')
83
+
84
+ beforeEach(() => {
85
+ fetchForms.mockClear()
86
+ fetchEntries.mockClear()
87
+ fetchEntryCounts.mockClear()
88
+ fetchEntry.mockClear()
89
+ })
90
+
91
+ describe('FormEntries (All Entries)', () => {
92
+ it('renders cross-form entries with a Form column and filter', async () => {
93
+ render(<FormEntries onNavigate={() => {}} />)
94
+
95
+ expect(await screen.findByText('Jordan Avery')).toBeTruthy()
96
+ expect(screen.getByText('Priya Nair')).toBeTruthy()
97
+ expect(screen.getByRole('table', { name: 'All entries' })).toBeTruthy()
98
+ // Form names appear (column values) and the filter is populated from fetchForms.
99
+ expect(screen.getByLabelText('Filter by form')).toBeTruthy()
100
+ await waitFor(() => expect(fetchForms).toHaveBeenCalled())
101
+ })
102
+
103
+ it('refetches scoped to a form when the form filter changes', async () => {
104
+ render(<FormEntries onNavigate={() => {}} />)
105
+ await screen.findByText('Jordan Avery')
106
+
107
+ fireEvent.change(screen.getByLabelText('Filter by form'), { target: { value: 'f2' } })
108
+
109
+ await waitFor(() =>
110
+ expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ formId: 'f2' })),
111
+ )
112
+ })
113
+
114
+ it('refetches with the unread filter when the pill is clicked', async () => {
115
+ render(<FormEntries onNavigate={() => {}} />)
116
+ await screen.findByText('Jordan Avery')
117
+
118
+ fireEvent.click(screen.getByRole('button', { name: 'Unread', pressed: false }))
119
+
120
+ await waitFor(() =>
121
+ expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ unread: true })),
122
+ )
123
+ })
124
+
125
+ it('opens the detail drawer when a row is clicked', async () => {
126
+ render(<FormEntries onNavigate={() => {}} />)
127
+ const row = await screen.findByText('Jordan Avery')
128
+
129
+ fireEvent.click(row)
130
+
131
+ await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
132
+ })
133
+ })
@@ -0,0 +1,197 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shared, token-only submissions table used by both the per-form Submissions
5
+ * view and the global All Entries inbox. Presentational only: selection, star,
6
+ * and row-open are delegated to the parent; an optional Form column links each
7
+ * row back to its form when entries span multiple forms.
8
+ */
9
+ import type { ReactNode } from 'react'
10
+ import { Star } from 'lucide-react'
11
+ import type { FormSubmission } from '../../lib/forms-service.js'
12
+ import { btnGhostIcon } from './primitives.js'
13
+
14
+ export function formatEntryDateTime(iso: string | null | undefined): string {
15
+ if (!iso) return '—'
16
+ const d = new Date(iso)
17
+ if (Number.isNaN(d.getTime())) return '—'
18
+ return d.toLocaleString(undefined, {
19
+ month: 'short',
20
+ day: 'numeric',
21
+ year: 'numeric',
22
+ hour: 'numeric',
23
+ minute: '2-digit',
24
+ })
25
+ }
26
+
27
+ export interface EntriesTableProps {
28
+ entries: FormSubmission[]
29
+ selected: Set<string>
30
+ allSelected: boolean
31
+ onToggleSelectAll: () => void
32
+ onToggleSelect: (id: string) => void
33
+ onOpen: (id: string) => void
34
+ onToggleStar: (entry: FormSubmission) => void
35
+ /** Show a Form column (used by the cross-form All Entries inbox). */
36
+ showForm?: boolean
37
+ /** Navigate to a form's submissions when its name is clicked (All Entries). */
38
+ onOpenForm?: (formId: string) => void
39
+ /** Accessible label for the table element. */
40
+ ariaLabel?: string
41
+ /** Footer row (e.g. pagination) rendered inside the bordered card. */
42
+ footer?: ReactNode
43
+ }
44
+
45
+ export function EntriesTable({
46
+ entries,
47
+ selected,
48
+ allSelected,
49
+ onToggleSelectAll,
50
+ onToggleSelect,
51
+ onOpen,
52
+ onToggleStar,
53
+ showForm = false,
54
+ onOpenForm,
55
+ ariaLabel = 'Submissions',
56
+ footer,
57
+ }: EntriesTableProps) {
58
+ return (
59
+ <div className="border-border bg-card rounded-lg border">
60
+ <div className="overflow-x-auto">
61
+ <table
62
+ className={`w-full ${showForm ? 'min-w-[860px]' : 'min-w-[720px]'} text-sm`}
63
+ aria-label={ariaLabel}
64
+ >
65
+ <thead>
66
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
67
+ <th scope="col" className="w-10 px-4 py-2.5">
68
+ <input
69
+ type="checkbox"
70
+ checked={allSelected}
71
+ onChange={onToggleSelectAll}
72
+ aria-label="Select all submissions"
73
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
74
+ />
75
+ </th>
76
+ <th scope="col" className="px-4 py-2.5 font-medium">
77
+ Sender
78
+ </th>
79
+ {showForm && (
80
+ <th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
81
+ Form
82
+ </th>
83
+ )}
84
+ <th scope="col" className="px-4 py-2.5 font-medium">
85
+ Submission
86
+ </th>
87
+ <th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
88
+ Submitted
89
+ </th>
90
+ <th scope="col" className="w-10 px-4 py-2.5 text-center font-medium">
91
+ <span className="sr-only">Starred</span>
92
+ </th>
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ {entries.map((entry) => {
97
+ const isSelected = selected.has(entry.id)
98
+ const name = entry.senderName || 'Anonymous'
99
+ return (
100
+ <tr
101
+ key={entry.id}
102
+ className={`border-border hover:bg-accent/40 group cursor-pointer border-b transition-colors last:border-b-0 ${
103
+ isSelected ? 'bg-accent/30' : ''
104
+ }`}
105
+ onClick={() => onOpen(entry.id)}
106
+ >
107
+ <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
108
+ <input
109
+ type="checkbox"
110
+ checked={isSelected}
111
+ onChange={() => onToggleSelect(entry.id)}
112
+ aria-label={`Select submission from ${name}`}
113
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
114
+ />
115
+ </td>
116
+ <td className="px-4 py-3">
117
+ <div className="flex items-center gap-3">
118
+ <span
119
+ className="bg-muted text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
120
+ aria-hidden
121
+ >
122
+ {name.charAt(0).toUpperCase()}
123
+ </span>
124
+ <div className="min-w-0">
125
+ <div className="flex items-center gap-2">
126
+ {entry.unread && (
127
+ <span
128
+ className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full"
129
+ aria-label="Unread"
130
+ />
131
+ )}
132
+ <span
133
+ className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
134
+ >
135
+ {name}
136
+ </span>
137
+ </div>
138
+ {entry.senderEmail && (
139
+ <span className="text-muted-foreground block truncate text-xs">
140
+ {entry.senderEmail}
141
+ </span>
142
+ )}
143
+ </div>
144
+ </div>
145
+ </td>
146
+ {showForm && (
147
+ <td
148
+ className="px-4 py-3 whitespace-nowrap"
149
+ onClick={(e) => e.stopPropagation()}
150
+ >
151
+ {onOpenForm ? (
152
+ <button
153
+ type="button"
154
+ onClick={() => onOpenForm(entry.formId)}
155
+ className="text-muted-foreground hover:text-primary focus-visible:ring-ring rounded text-sm focus-visible:ring-2 focus-visible:outline-none"
156
+ >
157
+ {entry.formName || '—'}
158
+ </button>
159
+ ) : (
160
+ <span className="text-muted-foreground text-sm">
161
+ {entry.formName || '—'}
162
+ </span>
163
+ )}
164
+ </td>
165
+ )}
166
+ <td className="px-4 py-3">
167
+ <span className="text-muted-foreground line-clamp-1 max-w-md">
168
+ {entry.preview || '—'}
169
+ </span>
170
+ </td>
171
+ <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
172
+ {formatEntryDateTime(entry.submittedAt)}
173
+ </td>
174
+ <td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
175
+ <button
176
+ type="button"
177
+ className={btnGhostIcon}
178
+ aria-pressed={entry.starred}
179
+ aria-label={entry.starred ? 'Unstar submission' : 'Star submission'}
180
+ onClick={() => onToggleStar(entry)}
181
+ >
182
+ <Star
183
+ className={`h-4 w-4 ${entry.starred ? 'fill-warning text-warning' : ''}`}
184
+ aria-hidden
185
+ />
186
+ </button>
187
+ </td>
188
+ </tr>
189
+ )
190
+ })}
191
+ </tbody>
192
+ </table>
193
+ </div>
194
+ {footer}
195
+ </div>
196
+ )
197
+ }
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useRef, useState, type ReactNode } from 'react'
4
4
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
5
  import { useTheme } from '../components/ThemeProvider.js'
6
- import { fetchFormsSidebarCounts } from '../lib/forms-service.js'
6
+ import { fetchFormsSidebarCounts, type FormsSidebarCount } from '../lib/forms-service.js'
7
7
  import { onFormsChanged } from '../lib/forms-events.js'
8
8
  import {
9
9
  LayoutDashboard,
@@ -14,6 +14,7 @@ import {
14
14
  ChevronLeft,
15
15
  ChevronDown,
16
16
  ClipboardList,
17
+ Inbox,
17
18
  Search as SearchIcon,
18
19
  Briefcase,
19
20
  FolderOpen,
@@ -177,8 +178,8 @@ export function Sidebar({
177
178
  onNavigate,
178
179
  config,
179
180
  }: SidebarProps) {
180
- const formsUnread = useFormsUnread()
181
- const navItems = applyFormsBadge(buildNavItems(config), formsUnread)
181
+ const formsSidebar = useFormsSidebar()
182
+ const navItems = applyFormsNav(buildNavItems(config), formsSidebar)
182
183
 
183
184
  return (
184
185
  <aside
@@ -228,21 +229,33 @@ export function Sidebar({
228
229
 
229
230
  // ─── Dynamic Forms unread badge ───────────────────────────────────────────
230
231
 
232
+ interface FormsSidebarState {
233
+ /** Total unread submissions across active forms (parent badge). */
234
+ totalUnread: number
235
+ /** Per-form unread counts, used to build the Forms submenu children. */
236
+ forms: FormsSidebarCount[]
237
+ }
238
+
231
239
  /**
232
- * Live total of unread submissions across active forms, surfaced on the Forms
233
- * nav item. Refreshes on mount and whenever a forms/entries mutation fires the
234
- * shared event (mark read, archive, notify toggle, …). Best-effort: a failed
235
- * fetch simply leaves the badge unset rather than surfacing an error in nav.
240
+ * Live unread counts for the Forms submenu: a total surfaced on the Forms nav
241
+ * item plus per-form counts that drive the "All Entries" + per-form children.
242
+ * Refreshes on mount and whenever a forms/entries mutation fires the shared
243
+ * event (mark read, archive, notify toggle, …). Best-effort: a failed fetch
244
+ * keeps the last known value rather than surfacing an error in nav.
236
245
  */
237
- function useFormsUnread(): number {
238
- const [unread, setUnread] = useState(0)
246
+ function useFormsSidebar(): FormsSidebarState {
247
+ const [state, setState] = useState<FormsSidebarState>({ totalUnread: 0, forms: [] })
239
248
 
240
249
  useEffect(() => {
241
250
  let active = true
242
251
  const load = () => {
243
252
  fetchFormsSidebarCounts()
244
253
  .then((counts) => {
245
- if (active) setUnread(counts.reduce((sum, c) => sum + (c.unread ?? 0), 0))
254
+ if (!active) return
255
+ setState({
256
+ totalUnread: counts.reduce((sum, c) => sum + (c.unread ?? 0), 0),
257
+ forms: counts,
258
+ })
246
259
  })
247
260
  .catch(() => {
248
261
  /* keep the last known value; nav must never break on a fetch error */
@@ -256,13 +269,32 @@ function useFormsUnread(): number {
256
269
  }
257
270
  }, [])
258
271
 
259
- return unread
272
+ return state
260
273
  }
261
274
 
262
- /** Return a copy of the nav tree with the live unread count on the Forms item. */
263
- function applyFormsBadge(items: NavItem[], unread: number): NavItem[] {
264
- if (unread <= 0) return items
265
- return items.map((item) => (item.path === '/forms' ? { ...item, badge: unread } : item))
275
+ /**
276
+ * Return a copy of the nav tree with the Forms item expanded into a submenu:
277
+ * a total-unread badge on the parent, an "All Entries" child, and one child
278
+ * per active form carrying its own unread badge.
279
+ */
280
+ function applyFormsNav(items: NavItem[], state: FormsSidebarState): NavItem[] {
281
+ return items.map((item) => {
282
+ if (item.path !== '/forms') return item
283
+ const children: NavItem[] = [
284
+ { path: '/forms/entries', label: 'All Entries', icon: Inbox },
285
+ ...state.forms.map((f) => ({
286
+ path: `/forms/${f.formId}/submissions`,
287
+ label: f.name,
288
+ icon: ClipboardList,
289
+ badge: f.unread,
290
+ })),
291
+ ]
292
+ return {
293
+ ...item,
294
+ badge: state.totalUnread > 0 ? state.totalUnread : undefined,
295
+ children,
296
+ }
297
+ })
266
298
  }
267
299
 
268
300
  // ─── Rendering ──────────────────────────────────────────────────────────────
@@ -377,6 +409,14 @@ function NavSection({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
377
409
  <Icon className="h-5 w-5 shrink-0" aria-hidden />
378
410
  <span className="truncate text-sm font-medium">{item.label}</span>
379
411
  </button>
412
+ {!!item.badge && item.badge > 0 && (
413
+ <span
414
+ className="bg-destructive text-destructive-foreground mr-1 inline-flex min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
415
+ aria-label={`${item.badge} unread`}
416
+ >
417
+ {item.badge > 99 ? '99+' : item.badge}
418
+ </span>
419
+ )}
380
420
  <button
381
421
  onClick={() => setOpen((v) => !v)}
382
422
  className="text-sidebar-foreground/55 hover:text-sidebar-foreground mr-1 flex shrink-0 items-center rounded-md p-1.5 transition-colors"
@@ -547,11 +587,21 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
547
587
  ? 'bg-sidebar-accent text-sidebar-primary'
548
588
  : 'text-sidebar-foreground hover:bg-sidebar-accent'
549
589
  }`}
550
- aria-label={item.label}
590
+ aria-label={
591
+ item.badge && item.badge > 0 ? `${item.label} (${item.badge} unread)` : item.label
592
+ }
551
593
  title={item.label}
552
594
  aria-current={isActive ? 'page' : undefined}
553
595
  >
554
- <Icon className="h-5 w-5 shrink-0" aria-hidden />
596
+ <span className="relative flex shrink-0 items-center">
597
+ <Icon className="h-5 w-5 shrink-0" aria-hidden />
598
+ {!!item.badge && item.badge > 0 && (
599
+ <span
600
+ className="bg-destructive absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full"
601
+ aria-hidden
602
+ />
603
+ )}
604
+ </span>
555
605
  </button>
556
606
  </DropdownMenu.Trigger>
557
607
  </div>
@@ -593,6 +643,14 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
593
643
  >
594
644
  <ChildIcon className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
595
645
  <span className="truncate">{child.label}</span>
646
+ {!!child.badge && child.badge > 0 && (
647
+ <span
648
+ className="bg-destructive text-destructive-foreground ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
649
+ aria-label={`${child.badge} unread`}
650
+ >
651
+ {child.badge > 99 ? '99+' : child.badge}
652
+ </span>
653
+ )}
596
654
  </DropdownMenu.Item>
597
655
  )
598
656
  })}