@actuate-media/cms-admin 0.23.0 → 0.25.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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +112 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/EntriesTable.d.ts +28 -0
- package/dist/components/forms/EntriesTable.d.ts.map +1 -0
- package/dist/components/forms/EntriesTable.js +26 -0
- package/dist/components/forms/EntriesTable.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +44 -21
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/FormEntries.d.ts +5 -0
- package/dist/views/FormEntries.d.ts.map +1 -0
- package/dist/views/FormEntries.js +211 -0
- package/dist/views/FormEntries.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +4 -24
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
- package/dist/views/page-builder/BlockEditor.js +2 -1
- package/dist/views/page-builder/BlockEditor.js.map +1 -1
- package/dist/views/page-builder/RelationshipField.d.ts +15 -0
- package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
- package/dist/views/page-builder/RelationshipField.js +65 -0
- package/dist/views/page-builder/RelationshipField.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
- package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
- package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
- package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
- package/src/__tests__/views/form-entries.render.test.tsx +133 -0
- package/src/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
- package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/layout/Sidebar.tsx +75 -17
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +4 -149
- package/src/views/page-builder/BlockEditor.tsx +7 -10
- package/src/views/page-builder/RelationshipField.tsx +113 -0
- package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
- package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
import type { FieldDefinition } from '@actuate-media/cms-core'
|
|
5
|
+
|
|
6
|
+
const cmsApi = vi.fn()
|
|
7
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
|
|
8
|
+
|
|
9
|
+
const { RelationshipField } = await import('../../views/page-builder/RelationshipField.js')
|
|
10
|
+
|
|
11
|
+
const formField: FieldDefinition = {
|
|
12
|
+
type: 'relationship',
|
|
13
|
+
label: 'Form',
|
|
14
|
+
relationTo: 'forms',
|
|
15
|
+
} as FieldDefinition
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cmsApi.mockReset()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('RelationshipField (form block picker)', () => {
|
|
22
|
+
it('loads forms and lets the editor pick one by id', async () => {
|
|
23
|
+
cmsApi.mockResolvedValue({
|
|
24
|
+
data: [
|
|
25
|
+
{ id: 'f1', name: 'Contact Us', slug: 'contact-us' },
|
|
26
|
+
{ id: 'f2', name: 'Event RSVP', slug: 'event-rsvp' },
|
|
27
|
+
],
|
|
28
|
+
status: 200,
|
|
29
|
+
})
|
|
30
|
+
const onChange = vi.fn()
|
|
31
|
+
render(<RelationshipField definition={formField} value="" onChange={onChange} label="Form" />)
|
|
32
|
+
|
|
33
|
+
await waitFor(() => expect(screen.getByText('Contact Us')).toBeTruthy())
|
|
34
|
+
expect(cmsApi).toHaveBeenCalledWith('/forms')
|
|
35
|
+
expect(screen.getByText('Event RSVP')).toBeTruthy()
|
|
36
|
+
|
|
37
|
+
fireEvent.change(screen.getByLabelText('Form'), { target: { value: 'f2' } })
|
|
38
|
+
expect(onChange).toHaveBeenCalledWith('f2')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('shows an empty hint when the collection has no items', async () => {
|
|
42
|
+
cmsApi.mockResolvedValue({ data: [], status: 200 })
|
|
43
|
+
render(<RelationshipField definition={formField} value="" onChange={vi.fn()} label="Form" />)
|
|
44
|
+
await waitFor(() => expect(screen.getByText('No forms found.')).toBeTruthy())
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('degrades to a plain id input when the collection cannot be loaded', async () => {
|
|
48
|
+
cmsApi.mockResolvedValue({ error: 'boom', status: 500 })
|
|
49
|
+
render(<RelationshipField definition={formField} value="f9" onChange={vi.fn()} label="Form" />)
|
|
50
|
+
await waitFor(() =>
|
|
51
|
+
expect((screen.getByPlaceholderText('Enter forms id…') as HTMLInputElement).value).toBe('f9'),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -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
|
+
}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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
|
|
181
|
-
const navItems =
|
|
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
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
|
238
|
-
const [
|
|
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)
|
|
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
|
|
272
|
+
return state
|
|
260
273
|
}
|
|
261
274
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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={
|
|
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
|
-
<
|
|
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
|
})}
|