@actuate-media/cms-admin 0.22.0 → 0.23.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 (38) hide show
  1. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  2. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  4. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  5. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  8. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  11. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  12. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  13. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  14. package/dist/components/forms/NotificationsPanel.js +1 -1
  15. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  16. package/dist/components/forms/primitives.d.ts +15 -0
  17. package/dist/components/forms/primitives.d.ts.map +1 -1
  18. package/dist/components/forms/primitives.js +36 -1
  19. package/dist/components/forms/primitives.js.map +1 -1
  20. package/dist/lib/forms-service.d.ts +36 -2
  21. package/dist/lib/forms-service.d.ts.map +1 -1
  22. package/dist/lib/forms-service.js +66 -0
  23. package/dist/lib/forms-service.js.map +1 -1
  24. package/dist/views/FormSubmissions.d.ts.map +1 -1
  25. package/dist/views/FormSubmissions.js +224 -77
  26. package/dist/views/FormSubmissions.js.map +1 -1
  27. package/dist/views/Forms.d.ts.map +1 -1
  28. package/dist/views/Forms.js +26 -10
  29. package/dist/views/Forms.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  32. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  33. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  34. package/src/components/forms/NotificationsPanel.tsx +1 -1
  35. package/src/components/forms/primitives.tsx +101 -1
  36. package/src/lib/forms-service.ts +99 -0
  37. package/src/views/FormSubmissions.tsx +529 -394
  38. package/src/views/Forms.tsx +145 -107
@@ -0,0 +1,49 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import { FormsPagination } from '../../components/forms/primitives.js'
5
+
6
+ describe('FormsPagination', () => {
7
+ it('renders nothing when results fit on a single page', () => {
8
+ const { container } = render(
9
+ <FormsPagination page={1} pageSize={25} total={10} onPageChange={() => {}} />,
10
+ )
11
+ expect(container.firstChild).toBeNull()
12
+ })
13
+
14
+ it('shows the result range and page controls across multiple pages', () => {
15
+ render(
16
+ <FormsPagination
17
+ page={1}
18
+ pageSize={10}
19
+ total={42}
20
+ onPageChange={() => {}}
21
+ noun="submission"
22
+ />,
23
+ )
24
+ expect(screen.getByText('1–10 of 42 submissions')).toBeTruthy()
25
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeTruthy()
26
+ expect(screen.getByLabelText('Previous page')).toHaveProperty('disabled', true)
27
+ })
28
+
29
+ it('advances and clamps page navigation', () => {
30
+ const onPageChange = vi.fn()
31
+ render(
32
+ <FormsPagination page={2} pageSize={10} total={42} onPageChange={onPageChange} noun="form" />,
33
+ )
34
+ // Range reflects the current page.
35
+ expect(screen.getByText('11–20 of 42 forms')).toBeTruthy()
36
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
37
+ expect(onPageChange).toHaveBeenCalledWith(3)
38
+ fireEvent.click(screen.getByLabelText('Previous page'))
39
+ expect(onPageChange).toHaveBeenCalledWith(1)
40
+ })
41
+
42
+ it('caps the last page so the final partial page shows the true total', () => {
43
+ render(
44
+ <FormsPagination page={5} pageSize={10} total={42} onPageChange={() => {}} noun="form" />,
45
+ )
46
+ expect(screen.getByText('41–42 of 42 forms')).toBeTruthy()
47
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', true)
48
+ })
49
+ })
@@ -0,0 +1,129 @@
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 per-form submissions table through a mocked forms-service so the
6
+ // test owns the data and can assert rendering, status-pill refetches, bulk
7
+ // selection, and opening the detail drawer without a real backend.
8
+ function makeEntry(over: Record<string, unknown> = {}) {
9
+ return {
10
+ id: 'e1',
11
+ entryNumber: 1,
12
+ formId: 'f1',
13
+ data: { name: 'Jordan Avery', email: 'jordan@example.com', message: 'Hello there' },
14
+ senderName: 'Jordan Avery',
15
+ senderEmail: 'jordan@example.com',
16
+ preview: 'Hello there',
17
+ unread: true,
18
+ starred: false,
19
+ archived: false,
20
+ spamStatus: 'clean',
21
+ submittedAt: new Date().toISOString(),
22
+ createdAt: new Date().toISOString(),
23
+ updatedAt: new Date().toISOString(),
24
+ ...over,
25
+ }
26
+ }
27
+
28
+ const ENTRIES = [
29
+ makeEntry(),
30
+ makeEntry({
31
+ id: 'e2',
32
+ entryNumber: 2,
33
+ senderName: 'Priya Nair',
34
+ senderEmail: 'priya@example.com',
35
+ preview: 'A question about pricing',
36
+ unread: false,
37
+ }),
38
+ ]
39
+
40
+ const fetchFormById = vi.fn(async (_id?: string) => ({
41
+ id: 'f1',
42
+ name: 'Contact Us',
43
+ slug: 'contact-us',
44
+ status: 'active',
45
+ fields: [],
46
+ }))
47
+ const fetchEntries = vi.fn(async (_p?: unknown) => ({
48
+ entries: ENTRIES,
49
+ total: ENTRIES.length,
50
+ page: 1,
51
+ pageSize: 100,
52
+ }))
53
+ const fetchEntryCounts = vi.fn(async (_p?: unknown) => ({
54
+ total: 2,
55
+ unread: 1,
56
+ starred: 0,
57
+ thisWeek: 2,
58
+ byForm: { f1: 1 },
59
+ }))
60
+ const fetchEntry = vi.fn(async (_id?: string) => ({ entry: ENTRIES[0], fields: [] }))
61
+ const updateEntry = vi.fn(async (_id?: string, _patch?: unknown) => ({ data: ENTRIES[0] }))
62
+
63
+ vi.mock('../../lib/forms-service.js', () => ({
64
+ fetchFormById: (id?: string) => fetchFormById(id),
65
+ fetchEntries: (p?: unknown) => fetchEntries(p),
66
+ fetchEntryCounts: (p?: unknown) => fetchEntryCounts(p),
67
+ fetchEntry: (id?: string) => fetchEntry(id),
68
+ updateEntry: (id?: string, patch?: unknown) => updateEntry(id, patch),
69
+ deleteEntry: vi.fn(async () => ({})),
70
+ bulkUpdateEntries: vi.fn(async () => ({ data: { updated: 1 } })),
71
+ }))
72
+
73
+ vi.mock('../../lib/forms-events.js', () => ({
74
+ onFormsChanged: () => () => {},
75
+ emitFormsChanged: vi.fn(),
76
+ }))
77
+
78
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
79
+
80
+ const { FormSubmissions } = await import('../../views/FormSubmissions.js')
81
+
82
+ beforeEach(() => {
83
+ fetchEntries.mockClear()
84
+ fetchEntryCounts.mockClear()
85
+ fetchEntry.mockClear()
86
+ })
87
+
88
+ describe('FormSubmissions table', () => {
89
+ it('renders submissions as table rows with chips', async () => {
90
+ render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
91
+
92
+ expect(await screen.findByText('Jordan Avery')).toBeTruthy()
93
+ expect(screen.getByText('Priya Nair')).toBeTruthy()
94
+ // It's a table, not cards.
95
+ expect(screen.getByRole('table', { name: 'Submissions' })).toBeTruthy()
96
+ // Summary chips reflect counts.
97
+ expect(screen.getByText('this week')).toBeTruthy()
98
+ })
99
+
100
+ it('refetches with the unread filter when the pill is clicked', async () => {
101
+ render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
102
+ await screen.findByText('Jordan Avery')
103
+
104
+ fireEvent.click(screen.getByRole('button', { name: 'Unread', pressed: false }))
105
+
106
+ await waitFor(() =>
107
+ expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ unread: true })),
108
+ )
109
+ })
110
+
111
+ it('shows the bulk action bar when a row is selected', async () => {
112
+ render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
113
+ await screen.findByText('Jordan Avery')
114
+
115
+ fireEvent.click(screen.getByLabelText('Select submission from Jordan Avery'))
116
+
117
+ expect(await screen.findByText('1 selected')).toBeTruthy()
118
+ expect(screen.getByRole('button', { name: 'Mark read' })).toBeTruthy()
119
+ })
120
+
121
+ it('opens the detail drawer when a row is clicked', async () => {
122
+ render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
123
+ const row = await screen.findByText('Jordan Avery')
124
+
125
+ fireEvent.click(row)
126
+
127
+ await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
128
+ })
129
+ })
@@ -0,0 +1,312 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Entry detail drawer — shows a single submission rendered against the schema
5
+ * version it was captured with, plus sender, attribution, and per-entry
6
+ * actions (star, archive, mark unread, delete). Opening an unread entry marks
7
+ * it read. Token-only styling; built on the shared `Drawer`.
8
+ */
9
+ import { useCallback, useEffect, useState } from 'react'
10
+ import { Archive, ArchiveRestore, Loader2, Mail, Star, Trash2 } from 'lucide-react'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ deleteEntry,
14
+ fetchEntry,
15
+ updateEntry,
16
+ type EntryDetail,
17
+ type FormField,
18
+ type FormSubmission,
19
+ } from '../../lib/forms-service.js'
20
+ import { emitFormsChanged } from '../../lib/forms-events.js'
21
+ import { ConfirmDialog } from '../ui/ConfirmDialog.js'
22
+ import { Drawer } from './Drawer.js'
23
+ import { FormsErrorState, btnGhostIcon } from './primitives.js'
24
+
25
+ function formatDateTime(iso: string | null | undefined): string {
26
+ if (!iso) return '—'
27
+ const d = new Date(iso)
28
+ if (Number.isNaN(d.getTime())) return '—'
29
+ return d.toLocaleString(undefined, {
30
+ month: 'short',
31
+ day: 'numeric',
32
+ year: 'numeric',
33
+ hour: 'numeric',
34
+ minute: '2-digit',
35
+ })
36
+ }
37
+
38
+ /** Render a stored value for display, coping with arrays/objects/booleans. */
39
+ function displayValue(value: unknown): string {
40
+ if (value == null || value === '') return '—'
41
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
42
+ if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')
43
+ if (typeof value === 'object') return JSON.stringify(value)
44
+ return String(value)
45
+ }
46
+
47
+ interface AttrRow {
48
+ label: string
49
+ value: string | null | undefined
50
+ }
51
+
52
+ function attributionRows(entry: FormSubmission): AttrRow[] {
53
+ return [
54
+ { label: 'Source', value: entry.utmSource },
55
+ { label: 'Medium', value: entry.utmMedium },
56
+ { label: 'Campaign', value: entry.utmCampaign },
57
+ { label: 'Page', value: entry.pageUrl },
58
+ { label: 'Referrer', value: entry.referrer },
59
+ ].filter((r) => r.value)
60
+ }
61
+
62
+ export interface EntryDetailDrawerProps {
63
+ entryId: string | null
64
+ open: boolean
65
+ onOpenChange: (open: boolean) => void
66
+ /** Called after a mutation (star/archive/read/delete) so the list refreshes. */
67
+ onChanged?: () => void
68
+ /** Called specifically after a delete so the caller can drop the row/selection. */
69
+ onDeleted?: (id: string) => void
70
+ }
71
+
72
+ export function EntryDetailDrawer({
73
+ entryId,
74
+ open,
75
+ onOpenChange,
76
+ onChanged,
77
+ onDeleted,
78
+ }: EntryDetailDrawerProps) {
79
+ const [detail, setDetail] = useState<EntryDetail | null>(null)
80
+ const [loading, setLoading] = useState(false)
81
+ const [error, setError] = useState<string | null>(null)
82
+ const [busy, setBusy] = useState(false)
83
+ const [confirmDelete, setConfirmDelete] = useState(false)
84
+
85
+ const notifyChanged = useCallback(() => {
86
+ emitFormsChanged()
87
+ onChanged?.()
88
+ }, [onChanged])
89
+
90
+ // Load the entry (and its schema) when the drawer opens, marking it read.
91
+ useEffect(() => {
92
+ if (!open || !entryId) return
93
+ let cancelled = false
94
+ setLoading(true)
95
+ setError(null)
96
+ setDetail(null)
97
+ void (async () => {
98
+ try {
99
+ const data = await fetchEntry(entryId)
100
+ if (cancelled) return
101
+ if (!data) {
102
+ setError('Entry not found')
103
+ return
104
+ }
105
+ setDetail(data)
106
+ // Mark read on open (fire-and-forget; refresh the list counts).
107
+ if (data.entry.unread) {
108
+ const res = await updateEntry(entryId, { unread: false })
109
+ if (!cancelled && !res.error) notifyChanged()
110
+ }
111
+ } catch (e) {
112
+ if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load entry')
113
+ } finally {
114
+ if (!cancelled) setLoading(false)
115
+ }
116
+ })()
117
+ return () => {
118
+ cancelled = true
119
+ }
120
+ }, [open, entryId, notifyChanged])
121
+
122
+ const mutate = useCallback(
123
+ async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }) => {
124
+ if (!entryId) return
125
+ setBusy(true)
126
+ const res = await updateEntry(entryId, patch)
127
+ setBusy(false)
128
+ if (res.error) {
129
+ toast.error(res.error)
130
+ return
131
+ }
132
+ setDetail((cur) => (cur ? { ...cur, entry: { ...cur.entry, ...patch } } : cur))
133
+ notifyChanged()
134
+ },
135
+ [entryId, notifyChanged],
136
+ )
137
+
138
+ const handleDelete = useCallback(async () => {
139
+ if (!entryId) return
140
+ setBusy(true)
141
+ const res = await deleteEntry(entryId)
142
+ setBusy(false)
143
+ if (res.error) {
144
+ toast.error(res.error)
145
+ return
146
+ }
147
+ toast.success('Submission deleted')
148
+ notifyChanged()
149
+ onDeleted?.(entryId)
150
+ onOpenChange(false)
151
+ }, [entryId, notifyChanged, onDeleted, onOpenChange])
152
+
153
+ const entry = detail?.entry
154
+ const senderName = entry?.senderName || 'Anonymous'
155
+ const attrRows = entry ? attributionRows(entry) : []
156
+
157
+ return (
158
+ <>
159
+ <Drawer
160
+ open={open}
161
+ onOpenChange={onOpenChange}
162
+ title={entry ? `Entry${entry.entryNumber ? ` #${entry.entryNumber}` : ''}` : 'Entry'}
163
+ description={entry ? formatDateTime(entry.submittedAt) : undefined}
164
+ headerExtra={
165
+ entry ? (
166
+ <>
167
+ <button
168
+ type="button"
169
+ className={btnGhostIcon}
170
+ aria-pressed={entry.starred}
171
+ aria-label={entry.starred ? 'Unstar entry' : 'Star entry'}
172
+ disabled={busy}
173
+ onClick={() => void mutate({ starred: !entry.starred })}
174
+ >
175
+ <Star
176
+ size={18}
177
+ aria-hidden
178
+ className={entry.starred ? 'fill-warning text-warning' : ''}
179
+ />
180
+ </button>
181
+ <button
182
+ type="button"
183
+ className={btnGhostIcon}
184
+ aria-pressed={entry.archived}
185
+ aria-label={entry.archived ? 'Unarchive entry' : 'Archive entry'}
186
+ disabled={busy}
187
+ onClick={() => void mutate({ archived: !entry.archived })}
188
+ >
189
+ {entry.archived ? (
190
+ <ArchiveRestore size={18} aria-hidden />
191
+ ) : (
192
+ <Archive size={18} aria-hidden />
193
+ )}
194
+ </button>
195
+ </>
196
+ ) : undefined
197
+ }
198
+ footer={
199
+ entry ? (
200
+ <>
201
+ <button
202
+ type="button"
203
+ className="text-destructive hover:bg-destructive/10 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
204
+ disabled={busy}
205
+ onClick={() => setConfirmDelete(true)}
206
+ >
207
+ <Trash2 size={16} aria-hidden />
208
+ Delete
209
+ </button>
210
+ <button
211
+ type="button"
212
+ className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
213
+ disabled={busy}
214
+ onClick={() => void mutate({ unread: !entry.unread })}
215
+ >
216
+ <Mail size={16} aria-hidden />
217
+ Mark {entry.unread ? 'read' : 'unread'}
218
+ </button>
219
+ </>
220
+ ) : undefined
221
+ }
222
+ >
223
+ {loading ? (
224
+ <div className="flex h-40 items-center justify-center" role="status" aria-live="polite">
225
+ <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" aria-hidden />
226
+ <span className="sr-only">Loading entry…</span>
227
+ </div>
228
+ ) : error ? (
229
+ <FormsErrorState message={error} />
230
+ ) : entry ? (
231
+ <div className="space-y-6">
232
+ <section className="space-y-1">
233
+ <h3 className="text-foreground text-sm font-medium">{senderName}</h3>
234
+ {entry.senderEmail && (
235
+ <a
236
+ href={`mailto:${entry.senderEmail}`}
237
+ className="text-primary block text-sm hover:underline"
238
+ >
239
+ {entry.senderEmail}
240
+ </a>
241
+ )}
242
+ {entry.senderPhone && (
243
+ <span className="text-muted-foreground block text-sm">{entry.senderPhone}</span>
244
+ )}
245
+ </section>
246
+
247
+ <FieldValues entry={entry} fields={detail.fields} />
248
+
249
+ {attrRows.length > 0 && (
250
+ <section>
251
+ <h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
252
+ Attribution
253
+ </h4>
254
+ <dl className="divide-border divide-y text-sm">
255
+ {attrRows.map((row) => (
256
+ <div key={row.label} className="flex justify-between gap-4 py-1.5">
257
+ <dt className="text-muted-foreground shrink-0">{row.label}</dt>
258
+ <dd className="text-foreground min-w-0 truncate text-right">{row.value}</dd>
259
+ </div>
260
+ ))}
261
+ </dl>
262
+ </section>
263
+ )}
264
+ </div>
265
+ ) : null}
266
+ </Drawer>
267
+
268
+ <ConfirmDialog
269
+ open={confirmDelete}
270
+ onClose={() => setConfirmDelete(false)}
271
+ onConfirm={() => void handleDelete()}
272
+ title="Delete submission?"
273
+ description="This permanently removes the submission and any attached files. This cannot be undone."
274
+ confirmLabel="Delete"
275
+ destructive
276
+ />
277
+ </>
278
+ )
279
+ }
280
+
281
+ /** Render the captured field values against the entry's schema. */
282
+ function FieldValues({ entry, fields }: { entry: FormSubmission; fields: FormField[] }) {
283
+ // Prefer the schema order; fall back to whatever keys exist on the data.
284
+ const inputFields = fields.filter(
285
+ (f) => f.type !== 'divider' && f.type !== 'richtext' && f.type !== 'honeypot',
286
+ )
287
+ const rows: { key: string; label: string }[] = inputFields.length
288
+ ? inputFields.map((f) => ({ key: f.key, label: f.label }))
289
+ : Object.keys(entry.data ?? {}).map((k) => ({ key: k, label: k }))
290
+
291
+ if (rows.length === 0) {
292
+ return <p className="text-muted-foreground text-sm">No field data captured.</p>
293
+ }
294
+
295
+ return (
296
+ <section>
297
+ <h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
298
+ Submission
299
+ </h4>
300
+ <dl className="space-y-3">
301
+ {rows.map((row) => (
302
+ <div key={row.key}>
303
+ <dt className="text-muted-foreground text-xs">{row.label}</dt>
304
+ <dd className="text-foreground mt-0.5 text-sm wrap-break-word whitespace-pre-wrap">
305
+ {displayValue(entry.data?.[row.key])}
306
+ </dd>
307
+ </div>
308
+ ))}
309
+ </dl>
310
+ </section>
311
+ )
312
+ }
@@ -213,7 +213,7 @@ function ToggleRow({
213
213
  checked={checked}
214
214
  onCheckedChange={onChange}
215
215
  aria-label={label}
216
- className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
216
+ className="bg-destructive data-[state=checked]:bg-success focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
217
217
  >
218
218
  <Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
219
219
  </Switch.Root>
@@ -8,7 +8,7 @@
8
8
  * track light/dark mode and brand overrides automatically.
9
9
  */
10
10
  import type { ReactNode } from 'react'
11
- import { AlertTriangle, Loader2 } from 'lucide-react'
11
+ import { AlertTriangle, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
12
12
  import type { FormFieldType, FormStatus } from '../../lib/forms-service.js'
13
13
 
14
14
  // ─── Field-type badges ─────────────────────────────────────────────────────
@@ -188,6 +188,106 @@ export function FormsErrorState({ message, onRetry }: { message: string; onRetry
188
188
  )
189
189
  }
190
190
 
191
+ // ─── Pagination ────────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Return up to 7 page-control entries: a sliding window around the current
195
+ * page with ellipsis markers, so the footer stays narrow on large datasets.
196
+ */
197
+ function pageWindow(current: number, total: number): Array<number | 'gap'> {
198
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
199
+ const out: Array<number | 'gap'> = [1]
200
+ const start = Math.max(2, current - 1)
201
+ const end = Math.min(total - 1, current + 1)
202
+ if (start > 2) out.push('gap')
203
+ for (let i = start; i <= end; i++) out.push(i)
204
+ if (end < total - 1) out.push('gap')
205
+ out.push(total)
206
+ return out
207
+ }
208
+
209
+ /**
210
+ * Token-only pagination footer matching the Posts/Pages list styling. Renders
211
+ * a "start–end of total" summary plus prev / numbered / next controls. Hidden
212
+ * when there is a single page of results.
213
+ */
214
+ export function FormsPagination({
215
+ page,
216
+ pageSize,
217
+ total,
218
+ onPageChange,
219
+ noun = 'result',
220
+ label = 'Pagination',
221
+ }: {
222
+ page: number
223
+ pageSize: number
224
+ total: number
225
+ onPageChange: (page: number) => void
226
+ /** Singular noun for the summary (pluralised automatically). */
227
+ noun?: string
228
+ /** Accessible label for the nav landmark. */
229
+ label?: string
230
+ }) {
231
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
232
+ if (totalPages <= 1) return null
233
+ const start = total === 0 ? 0 : (page - 1) * pageSize + 1
234
+ const end = Math.min(total, page * pageSize)
235
+
236
+ return (
237
+ <div className="border-border flex items-center justify-between border-t px-4 py-3 text-sm">
238
+ <span className="text-muted-foreground tabular-nums">
239
+ {start}–{end} of {total} {noun}
240
+ {total === 1 ? '' : 's'}
241
+ </span>
242
+ <nav className="flex items-center gap-1" aria-label={label}>
243
+ <button
244
+ type="button"
245
+ onClick={() => onPageChange(Math.max(1, page - 1))}
246
+ disabled={page === 1}
247
+ aria-label="Previous page"
248
+ className="border-border bg-background text-foreground hover:bg-accent focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
249
+ >
250
+ <ChevronLeft className="h-4 w-4" aria-hidden />
251
+ </button>
252
+ {pageWindow(page, totalPages).map((p, idx) =>
253
+ p === 'gap' ? (
254
+ <span
255
+ key={`gap-${idx}`}
256
+ aria-hidden
257
+ className="text-muted-foreground inline-flex h-8 w-8 items-center justify-center"
258
+ >
259
+
260
+ </span>
261
+ ) : (
262
+ <button
263
+ key={p}
264
+ type="button"
265
+ onClick={() => onPageChange(p)}
266
+ aria-current={p === page ? 'page' : undefined}
267
+ className={`focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none ${
268
+ p === page
269
+ ? 'bg-primary text-primary-foreground'
270
+ : 'border-border bg-background text-foreground hover:bg-accent border'
271
+ }`}
272
+ >
273
+ {p}
274
+ </button>
275
+ ),
276
+ )}
277
+ <button
278
+ type="button"
279
+ onClick={() => onPageChange(Math.min(totalPages, page + 1))}
280
+ disabled={page === totalPages}
281
+ aria-label="Next page"
282
+ className="border-border bg-background text-foreground hover:bg-accent focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
283
+ >
284
+ <ChevronRight className="h-4 w-4" aria-hidden />
285
+ </button>
286
+ </nav>
287
+ </div>
288
+ )
289
+ }
290
+
191
291
  // ─── Button helpers (token-only) ─────────────────────────────────────────
192
292
 
193
293
  export const btnPrimary =