@michaelthielemann/kestrel 2.0.0 → 3.0.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 (117) hide show
  1. package/README.md +16 -8
  2. package/layers/admin/app/components/AdminAccount.vue +3 -3
  3. package/layers/admin/app/components/AdminNav.vue +1 -1
  4. package/layers/admin/app/components/BlockFields.vue +2 -2
  5. package/layers/admin/app/components/BlockPreview.vue +8 -8
  6. package/layers/admin/app/components/BlockTree.vue +10 -10
  7. package/layers/admin/app/components/BlocksBody.vue +4 -4
  8. package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
  9. package/layers/admin/app/components/CollectionEditor.vue +3 -3
  10. package/layers/admin/app/components/CollectionList.vue +91 -788
  11. package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
  12. package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
  14. package/layers/admin/app/components/CollectionListPager.vue +70 -0
  15. package/layers/admin/app/components/CollectionListTable.vue +308 -0
  16. package/layers/admin/app/components/EditorStatus.vue +4 -4
  17. package/layers/admin/app/components/FieldsBody.vue +1 -1
  18. package/layers/admin/app/components/LocaleBar.vue +4 -4
  19. package/layers/admin/app/components/PageFields.vue +17 -12
  20. package/layers/admin/app/components/PageFieldsPane.vue +2 -2
  21. package/layers/admin/app/components/SeoFields.vue +52 -10
  22. package/layers/admin/app/components/SingletonEditor.vue +7 -7
  23. package/layers/admin/app/composables/useEditForm.ts +9 -3
  24. package/layers/admin/app/composables/useListBatchActions.ts +61 -0
  25. package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
  26. package/layers/admin/app/composables/useListRows.ts +70 -0
  27. package/layers/admin/app/composables/useListSelection.ts +28 -0
  28. package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
  29. package/layers/admin/app/layouts/admin.vue +7 -7
  30. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
  31. package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
  32. package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
  33. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
  34. package/layers/admin/app/pages/admin/index.vue +3 -3
  35. package/layers/admin/app/pages/admin/login.vue +6 -6
  36. package/layers/admin/app/pages/admin/media.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +3 -3
  38. package/layers/admin/app/utils/edit-form.ts +9 -2
  39. package/layers/admin/app/utils/list-cell.ts +46 -0
  40. package/layers/admin/nuxt.config.ts +3 -0
  41. package/layers/core/modules/component-namespace/index.ts +20 -0
  42. package/layers/core/modules/component-namespace/shared.ts +24 -0
  43. package/layers/core/modules/kestrel/index.ts +11 -0
  44. package/layers/core/nuxt.config.ts +1 -0
  45. package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
  46. package/layers/core/server/api/[collection]/index.put.ts +18 -1
  47. package/layers/core/server/utils/collection-types.ts +4 -3
  48. package/layers/core/server/utils/defineCollection.ts +8 -2
  49. package/layers/core/server/utils/kestrel-config.ts +35 -1
  50. package/layers/core/server/utils/seo.ts +18 -0
  51. package/layers/core/server/utils/serialize-collection.ts +0 -2
  52. package/layers/core/server/utils/write-effects.ts +40 -0
  53. package/layers/fields/app/utils/field-factories.ts +1 -1
  54. package/layers/fields/server/utils/buildCollection.ts +9 -7
  55. package/layers/media/app/components/KestrelImg.vue +29 -0
  56. package/layers/media/app/components/MediaConflictDialog.vue +7 -7
  57. package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
  58. package/layers/media/app/components/MediaGrid.vue +3 -3
  59. package/layers/media/app/components/MediaLibrary.vue +26 -24
  60. package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
  61. package/layers/media/app/components/MediaPathBar.vue +1 -1
  62. package/layers/media/app/components/MediaPicker.vue +3 -3
  63. package/layers/media/app/components/MediaRenameDialog.vue +8 -8
  64. package/layers/media/app/components/MediaTable.vue +2 -2
  65. package/layers/media/app/components/MediaToolbar.vue +4 -4
  66. package/layers/media/app/components/MediaUploadDialog.vue +9 -9
  67. package/layers/media/app/components/MediaViewer.vue +64 -15
  68. package/layers/media/app/components/field/Media.vue +9 -9
  69. package/layers/media/app/plugins/field-media.client.ts +3 -3
  70. package/layers/media/app/utils/ai-disclosure.ts +19 -0
  71. package/layers/media/app/utils/library.ts +2 -0
  72. package/layers/media/nuxt.config.ts +5 -1
  73. package/layers/media/server/api/media/[id].patch.test.ts +66 -0
  74. package/layers/media/server/api/media/[id].patch.ts +25 -1
  75. package/layers/media/server/api/media/index.post.ts +19 -3
  76. package/layers/media/server/collections/media.ts +14 -0
  77. package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
  78. package/layers/media/server/utils/ai-signal-detect.ts +155 -0
  79. package/layers/media/server/utils/library.ts +2 -1
  80. package/layers/media/server/utils/record.ts +8 -0
  81. package/layers/media/server/utils/resolve.ts +12 -0
  82. package/layers/public/app/pages/[...slug].vue +32 -3
  83. package/layers/public/app/pages/__kestrel/preview.vue +1 -1
  84. package/layers/public/app/utils/json-ld.ts +139 -0
  85. package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
  86. package/layers/public/modules/prerender-routes/index.ts +5 -2
  87. package/layers/public/nuxt.config.ts +3 -0
  88. package/layers/public/server/api/route.get.ts +9 -1
  89. package/layers/public/server/collections/redirects.ts +75 -0
  90. package/layers/public/server/plugins/03.redirects.ts +37 -0
  91. package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
  92. package/layers/public/server/routes/llms.txt.get.ts +1 -12
  93. package/layers/public/server/routes/redirects.json.get.ts +58 -0
  94. package/layers/public/server/routes/robots.txt.get.ts +1 -0
  95. package/layers/public/server/utils/llms-full.ts +125 -0
  96. package/layers/public/server/utils/llms.ts +13 -0
  97. package/layers/public/server/utils/page-resolve.ts +112 -4
  98. package/layers/public/server/utils/publish/invalidation.ts +48 -3
  99. package/layers/public/server/utils/publish/publisher.ts +11 -5
  100. package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
  101. package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
  102. package/layers/public/server/utils/richtext-markdown.ts +260 -0
  103. package/layers/public/server/utils/site-url.ts +8 -0
  104. package/layers/public/server/utils/sitemap.ts +5 -3
  105. package/layers/ui/app/components/field/Choice.vue +6 -1
  106. package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
  107. package/layers/ui/app/components/field/Slug.vue +2 -2
  108. package/layers/ui/app/components/ui/Button.vue +1 -1
  109. package/layers/ui/app/components/ui/EmptyState.vue +1 -1
  110. package/layers/ui/app/components/ui/TextInput.vue +1 -1
  111. package/layers/ui/app/components/ui/Toasts.vue +1 -1
  112. package/layers/ui/app/composables/useToast.ts +1 -1
  113. package/layers/ui/app/i18n/de.ts +13 -0
  114. package/layers/ui/app/i18n/en.ts +13 -0
  115. package/layers/ui/nuxt.config.ts +5 -1
  116. package/package.json +2 -1
  117. package/templates/starter/nuxt.config.ts +3 -0
@@ -1,12 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
3
- import { sortDirection, type FilterCell } from '../utils/list-query'
4
- import type { ListColumn } from '../utils/list-columns'
5
- import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
6
- import type { BatchDeleteReport } from '../utils/collection-ops'
7
- import { OPS_BY_KIND, DEFAULT_OP, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
8
3
  import { resolveLocalized } from '../../../ui/app/utils/localized'
9
- import { humanizeFieldName } from '../../../ui/app/utils/humanize'
10
4
 
11
5
  // `locale` is set only for a translatable collection: it filters the list to that content locale and
12
6
  // shows a locale switcher. New records / row links carry it so they open in the same locale.
@@ -27,10 +21,6 @@ const { available, visibleColumns, visibleKeys, toggle: toggleColumn, reset: res
27
21
  useListColumns(toRef(props, 'schema'))
28
22
  const filterableColumns = computed(() => available.value.filter((c) => c.filterable))
29
23
 
30
- function colLabel(col: ListColumn): string {
31
- return col.type === 'field' ? humanizeFieldName(col.name ?? col.key) : t(col.labelKey ?? col.key)
32
- }
33
-
34
24
  // The URL is the single source of truth for committed list state. `sort`/`page`/`perPage`/`filter` are
35
25
  // derived (read-only) computeds off `route.query`; `effectiveQuery` is the sanitized `$fetch` query; the
36
26
  // setters navigate (page → push so Back walks pages, the rest → replace). The composable also owns the
@@ -38,228 +28,34 @@ function colLabel(col: ListColumn): string {
38
28
  const { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter } =
39
29
  useListUrlState(available)
40
30
 
41
- // `filter` (above, from the URL) = the committed clauses that drive the query. `draft` = the panel's pending
42
- // edits, kept local and seeded per filterable column below (keyed by column key { op, value }).
43
- const draft = reactive<Record<string, FilterCell>>({})
44
-
45
- // Overwrite each draft cell FROM the committed `filter` so deep-link / Back-Forward / chip-removal reflect
46
- // back into the open panel; drop drafts for columns this collection no longer has. We key the watch on the
47
- // committed filter's CONTENT (its JSON), not its object identity: `filter` recomputes to a fresh-but-EQUAL
48
- // object on every `route.query` change (page/sort/perPage navigations too), and resyncing on those identity
49
- // flips would wipe an in-progress panel edit. Clobber-safe on a real filter commit: a commit sends ALL
50
- // drafts at once, so post-commit `filter` equals `draft` (a no-op overwrite) — the content only actually
51
- // changes on external navigation (deep-link/Back-Forward/chip-removal), which is when the panel should resync.
52
- watch([filterableColumns, () => JSON.stringify(filter.value)], () => {
53
- const cols = filterableColumns.value
54
- for (const c of cols) {
55
- const cm = filter.value[c.key]
56
- draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
57
- }
58
- for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
59
- }, { immediate: true })
60
-
61
- function opsFor(c: ListColumn): readonly FilterOp[] {
62
- return OPS_BY_KIND[c.filterKind ?? 'text']
63
- }
64
- // Operator labels are kind-aware: a datetime comparison reads "before / on or before / …" instead of the
65
- // generic "less than / less or equal / …".
66
- function opLabel(kind: FilterKind, op: FilterOp): string {
67
- if (kind === 'datetime' && (op === 'lt' || op === 'lte' || op === 'gt' || op === 'gte')) return t(`filter.opDate.${op}`)
68
- return t(`filter.op.${op}`)
69
- }
70
- // Choices for an `enum` / `stringSet` value control: a fixed draft/published set for `status`, else the
71
- // choice field's own (single- and multi-choice fields both carry `options.choices`).
72
- function enumOptions(c: ListColumn): { label: string; value: string }[] {
73
- if (c.key === 'status') return [
74
- { value: 'draft', label: t('pageSettings.statusDraft') },
75
- { value: 'published', label: t('pageSettings.statusPublished') },
76
- ]
77
- return ((c.field?.options?.choices ?? []) as { label: string; value: string }[])
78
- }
79
- function displayValue(kind: FilterKind, value: string): string {
80
- if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
81
- return value
82
- }
83
-
84
- const rows = ref<Record<string, unknown>[]>([])
85
- const total = ref(0)
86
- // Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
87
- // error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
88
- const error = ref<string | null>(null)
89
-
90
- const toast = useToast()
91
-
92
- // ── Page-scoped row selection ──────────────────────────────────────────────────────────────────────
93
- // A Set of selected row ids, valid only for the rows currently on screen (cleared on every fetch). The
94
- // header checkbox is select-all-on-this-page with a tri-state (some-but-not-all) indeterminate marker.
95
- // A reactive Set (Vue tracks add/delete/has/size), so a single-row toggle is O(1) — no full-Set copy or
96
- // whole-table re-diff on each click, which mattered at the 500-row page cap.
97
- const selected = reactive(new Set<number>())
98
- const pageIds = computed(() => rows.value.map((r) => Number(r.id)))
99
- const allSelected = computed(() => pageIds.value.length > 0 && pageIds.value.every((id) => selected.has(id)))
100
- const headerIndeterminate = computed(() => pageIds.value.some((id) => selected.has(id)) && !allSelected.value)
101
- function toggleRow(id: number, on: boolean) {
102
- if (on) selected.add(id)
103
- else selected.delete(id)
104
- }
105
- function toggleAll(on: boolean) {
106
- selected.clear()
107
- if (on) for (const id of pageIds.value) selected.add(id)
108
- }
109
- function clearSelection() {
110
- selected.clear()
111
- }
112
- // A row's human label: the first visible non-sidecar cell that has a value, else the bare id — used for
113
- // the per-row checkbox / action aria-labels so each control names its row.
114
- function rowLabel(row: Record<string, unknown>): string {
115
- for (const c of visibleColumns.value) {
116
- if (c.type === 'translations' || c.type === 'deadRefs') continue
117
- const d = cellDisplay(c, row)
118
- if (d) return d
119
- }
120
- return `#${row.id}`
121
- }
122
-
123
- // ── Batch operations (row action = 1 id, bulk bar = N ids; the server does the batching) ─────────────
124
- const { busy: opsBusy, error: opsError, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus } =
125
- useCollectionOps(collection, () => fetchRows())
126
-
127
- const deleteOpen = ref(false)
128
- const deleteReport = ref<BatchDeleteReport | null>(null)
129
- const deleteIds = ref<number[]>([])
130
-
131
- // Destructive → confirm dialog. The referrer preview is best-effort: if it fails we still open the dialog
132
- // with a bare summary (never block a delete on the warning lookup).
133
- async function askDelete(ids: number[]) {
134
- if (!ids.length) return
135
- deleteIds.value = ids
136
- deleteReport.value = null
137
- deleteOpen.value = true
138
- try {
139
- deleteReport.value = await previewDelete(ids)
140
- } catch {
141
- // The referrer lookup FAILED — do not fabricate a "no inbound links" report (that would look exactly
142
- // like a verified-safe delete). Flag `checked: false` so the dialog cautions the check couldn't run.
143
- deleteReport.value = { count: ids.length, referencedCount: 0, referenced: [], checked: false }
144
- opsError.value = null
145
- }
146
- }
147
- async function confirmDelete() {
148
- try {
149
- await runDelete(deleteIds.value)
150
- toast.success(t('toast.deleted'))
151
- deleteOpen.value = false
152
- } catch {
153
- // error stays surfaced in the dialog via opsError
154
- }
155
- }
156
- // Non-destructive / reversible → no dialog: run, toast, refetch (the composable's onChanged).
157
- async function onDuplicate(id: number) {
158
- try {
159
- await runDuplicate([id])
160
- toast.success(t('toast.duplicated'))
161
- } catch {
162
- toast.error(opsError.value ?? t('list.opFailed'))
163
- }
164
- }
165
- async function onBulkStatus(status: 'published' | 'draft') {
166
- try {
167
- await runSetStatus([...selected], status)
168
- toast.success(status === 'published' ? t('toast.published') : t('toast.unpublished'))
169
- } catch {
170
- toast.error(opsError.value ?? t('list.opFailed'))
171
- }
172
- }
31
+ // `filter` (above, from the URL) = the committed clauses that drive the query; the panel's pending edits
32
+ // live in `draft` inside the composable, which also owns the debounce and the chip model.
33
+ const filterDraft = useListFilterDraft({ available, filterableColumns, filter, setFilter })
34
+ const activeFilters = filterDraft.activeFilters
35
+
36
+ const { rows, total, error, totalPages, fetchRows } = useListRows({
37
+ collection,
38
+ effectiveQuery,
39
+ locale: () => props.locale,
40
+ page,
41
+ perPage,
42
+ clampPage,
43
+ // Selection is PAGE-SCOPED: any (re)fetch — page, filter, sort, per-page — replaces the visible rows, so
44
+ // the previous selection no longer maps to what's on screen. (A locale switch remounts the whole
45
+ // component, so that case clears too.)
46
+ onLoaded: () => clearSelection(),
47
+ })
173
48
 
174
- // Monotonic request id: a slow earlier response must not overwrite a newer one.
175
- let seq = 0
176
- async function fetchRows() {
177
- const mine = ++seq
178
- try {
179
- const res = await $fetch<{ data: Record<string, unknown>[]; total: number; page: number; perPage: number }>(
180
- `/api/${collection.value}`,
181
- { query: { ...effectiveQuery.value, ...(props.locale ? { locale: props.locale } : {}) } },
182
- )
183
- if (mine !== seq) return
184
- rows.value = res.data
185
- total.value = res.total
186
- error.value = null
187
- // A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
188
- // would otherwise strand the user on an out-of-range page showing the "create your first" empty
189
- // state. Clamp back to the last page that still has rows (REPLACE, so the dead page isn't left as a
190
- // history entry that Back would bounce off; the query watch refetches).
191
- if (res.data.length === 0 && res.total > 0 && page.value > 1) {
192
- clampPage(Math.max(1, Math.ceil(res.total / perPage.value)))
193
- }
194
- // Selection is PAGE-SCOPED: any (re)fetch — page, filter, sort, per-page — replaces the visible rows,
195
- // so the previous selection no longer maps to what's on screen. Clear it. (A locale switch remounts
196
- // the whole component, so that case clears too.)
197
- selected.clear()
198
- } catch (e) {
199
- if (mine !== seq) return
200
- // Keep any previously-rendered rows, but surface that this fetch failed (and don't show the
201
- // "create your first" empty state — that would imply the collection is empty when it isn't).
202
- error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.loadError')
203
- }
204
- }
49
+ const { selected, allSelected, headerIndeterminate, toggleRow, toggleAll, clear: clearSelection } =
50
+ useListSelection(rows)
205
51
 
206
- const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
207
- // The per-page `<select>` is hand-rolled directly in this template (no UiField/UiCheckbox wrapper), so the
208
- // label↔control association needs an explicit pair for static a11y analysis to see it.
209
- const perPageId = useId()
52
+ // Row action = 1 id, bulk bar = N ids; the server does the batching.
53
+ const { busy: opsBusy, error: opsError, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate: onDuplicate, setStatus } =
54
+ useListBatchActions(collection, fetchRows)
210
55
 
211
- // Filters: edited in the Filter panel (a value change debounces; an operator change commits at once), shown
212
- // as removable chips in the bar. `commitDrafts` builds a fresh clause map from the non-empty draft cells and
213
- // navigates via `setFilter` (which also resets page → 1).
214
- let filterTimer: ReturnType<typeof setTimeout> | undefined
215
- function commitDrafts() {
216
- const next: Record<string, FilterCell> = {}
217
- for (const [field, cell] of Object.entries(draft)) {
218
- if (cell.value !== '' && cell.value != null) next[field] = { op: cell.op, value: cell.value }
219
- }
220
- setFilter(next)
221
- }
222
- function setValue(field: string, value: string) {
223
- if (draft[field]) draft[field].value = value
224
- clearTimeout(filterTimer)
225
- filterTimer = setTimeout(commitDrafts, 250)
226
- }
227
- function setOp(field: string, op: FilterOp) {
228
- if (draft[field]) draft[field].op = op
229
- // An operator change is deliberate — re-query immediately (no debounce) if the field has a value.
230
- clearTimeout(filterTimer)
231
- commitDrafts()
232
- }
233
- function clearFilter(field: string) {
234
- if (draft[field]) draft[field].value = ''
235
- commitDrafts() // re-commits the drafts with this one now empty → the clause is dropped (and page → 1)
236
- }
237
- function clearAllFilters() {
238
- for (const cell of Object.values(draft)) cell.value = ''
239
- setFilter({}) // clear the committed clauses (and page → 1); the draft-resync watch clears the panel
240
- }
241
- const activeFilters = computed(() => Object.entries(filter.value).map(([key, cell]) => {
242
- const col = available.value.find((c) => c.key === key)
243
- const kind = col?.filterKind ?? 'text'
244
- return { key, op: cell.op, opLabel: opLabel(kind, cell.op), label: col ? colLabel(col) : key, display: displayValue(kind, cell.value) }
245
- }))
246
- onBeforeUnmount(() => clearTimeout(filterTimer))
56
+ // Toolbar popovers (filter / columns), including the outside-click and Escape-restores-focus behaviour.
57
+ const { container: toolsRef, open: openPanel, toggle: togglePanel } = useToolbarPanel<'filter' | 'columns'>()
247
58
 
248
- function prev() {
249
- if (page.value > 1) setPage(page.value - 1)
250
- }
251
- function next() {
252
- if (page.value < totalPages.value) setPage(page.value + 1)
253
- }
254
- function arrow(field: string) {
255
- const dir = sortDirection(sort.value, field)
256
- return dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : ''
257
- }
258
- // Programmatic sort state for AT (the visual arrow is aria-hidden) — WCAG 1.3.1 / 4.1.2.
259
- function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
260
- const dir = sortDirection(sort.value, field)
261
- return dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'
262
- }
263
59
  // Polite status announced after every (re)fetch so filter/sort/page changes report their result count
264
60
  // to screen readers — WCAG 4.1.3 Status Messages.
265
61
  const resultsLabel = computed(() => t(total.value === 1 ? 'list.result' : 'list.results', { total: total.value }))
@@ -269,74 +65,6 @@ const resultsLabel = computed(() => t(total.value === 1 ? 'list.result' : 'list.
269
65
  // which would silence the first selection. Selection takes precedence; with none, it reports the count.
270
66
  const srStatus = computed(() => (selected.size ? t('list.selected', { n: selected.size }) : resultsLabel.value))
271
67
 
272
- const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
273
- // A date-only value ('YYYY-MM-DD', from a datetime field with precision 'date') parses as UTC midnight;
274
- // formatting it in the local zone would shift the calendar day, so pin it to UTC. Full ISO timestamps
275
- // (createdAt/updatedAt carry a 'Z') and local datetimes keep the local-zone formatter.
276
- const dateOnlyFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })
277
- function isDateColumn(col: ListColumn): boolean {
278
- return col.key === 'createdAt' || col.key === 'updatedAt' || col.field?.type === 'datetime'
279
- }
280
- function cellDisplay(col: ListColumn, row: Record<string, unknown>): string {
281
- const value = row[col.key]
282
- if (isDateColumn(col) && value != null) {
283
- const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
284
- const d = new Date(value as string)
285
- if (!Number.isNaN(d.getTime())) return (dateOnly ? dateOnlyFmt : dateFmt).format(d)
286
- }
287
- return cellText(value)
288
- }
289
- function cellText(value: unknown): string {
290
- if (value == null) return ''
291
- if (typeof value === 'string') return value.replace(/<[^>]*>/g, '').slice(0, 80)
292
- if (typeof value === 'object') return JSON.stringify(value).slice(0, 80)
293
- return String(value)
294
- }
295
-
296
- // Per-row translation status (A4 attaches `$translations`: locale → sibling id, or null). Present
297
- // locales link to their sibling; missing ones offer create-and-link, carrying the translation group.
298
- function rowTranslations(row: Record<string, unknown>) {
299
- const map = (row.$translations ?? {}) as Record<string, number | null>
300
- const group = row.translationGroup as string | undefined
301
- return locales.map((loc) => {
302
- const id = map[loc]
303
- return id != null
304
- ? { loc, present: true, to: `/admin/${collection.value}/${id}?locale=${loc}` }
305
- : { loc, present: false, to: `/admin/${collection.value}/new?locale=${loc}${group ? `&group=${group}` : ''}` }
306
- })
307
- }
308
-
309
- // Toolbar popovers (filter / columns). Plain inline panels (not teleported) so they stay testable and
310
- // keep their scoped styles; a document listener closes them on an outside click.
311
- const toolsRef = ref<HTMLElement | null>(null)
312
- const openPanel = ref<'filter' | 'columns' | null>(null)
313
- // The element that opened the current panel — Escape restores focus to it (otherwise closing the panel
314
- // from inside drops keyboard focus onto <body>, stranding the keyboard user at the top of the document).
315
- let panelTrigger: HTMLElement | null = null
316
- function togglePanel(which: 'filter' | 'columns', e: MouseEvent) {
317
- if (openPanel.value === which) { openPanel.value = null; return }
318
- panelTrigger = e.currentTarget as HTMLElement
319
- openPanel.value = which
320
- }
321
- function onDocPointer(e: PointerEvent) {
322
- // Outside click: close but do NOT steal focus — it belongs wherever the user just clicked.
323
- if (toolsRef.value && !toolsRef.value.contains(e.target as Node)) openPanel.value = null
324
- }
325
- function onDocKeydown(e: KeyboardEvent) {
326
- if (e.key === 'Escape' && openPanel.value) {
327
- openPanel.value = null
328
- panelTrigger?.focus() // return focus to the trigger, per the disclosure/APG pattern
329
- }
330
- }
331
- onMounted(() => {
332
- document.addEventListener('pointerdown', onDocPointer)
333
- document.addEventListener('keydown', onDocKeydown)
334
- })
335
- onBeforeUnmount(() => {
336
- document.removeEventListener('pointerdown', onDocPointer)
337
- document.removeEventListener('keydown', onDocKeydown)
338
- })
339
-
340
68
  // Exactly ONE refetch per navigation: the effective (sanitized) query is stringified so an identical or
341
69
  // junk-only URL change never refetches, and a single change fires a single fetch. Every URL-driven refetch
342
70
  // clears the page-scoped selection inside `fetchRows`.
@@ -365,7 +93,7 @@ await fetchRows()
365
93
 
366
94
  <div ref="toolsRef" class="list__tools">
367
95
  <div class="list__tool">
368
- <UiButton
96
+ <KestrelUiButton
369
97
  type="button"
370
98
  size="sm"
371
99
  icon="filter"
@@ -375,84 +103,17 @@ await fetchRows()
375
103
  @click="togglePanel('filter', $event)"
376
104
  >
377
105
  {{ t('list.filter') }}<span v-if="activeFilters.length" class="list__tool-count">{{ activeFilters.length }}</span>
378
- </UiButton>
379
- <div v-if="openPanel === 'filter'" id="list-filter-panel" class="list__panel list__panel--filter">
380
- <p v-if="!filterableColumns.length" class="list__panel-empty">{{ t('list.noFilters') }}</p>
381
- <div v-for="c in filterableColumns" :key="c.key" class="list__filter-row">
382
- <span class="list__filter-label">{{ colLabel(c) }}</span>
383
- <div class="list__filter-controls">
384
- <select
385
- v-if="opsFor(c).length > 1"
386
- class="list__filter-op"
387
- :data-filter-op="c.key"
388
- :aria-label="`${t('filter.operator')} — ${colLabel(c)}`"
389
- :value="draft[c.key]?.op"
390
- @change="setOp(c.key, ($event.target as HTMLSelectElement).value as FilterOp)"
391
- >
392
- <option v-for="op in opsFor(c)" :key="op" :value="op">{{ opLabel(c.filterKind ?? 'text', op) }}</option>
393
- </select>
394
-
395
- <input
396
- v-if="c.filterKind === 'datetime'"
397
- type="date"
398
- class="list__filter"
399
- :data-filter="c.key"
400
- :aria-label="colLabel(c)"
401
- :value="draft[c.key]?.value ?? ''"
402
- @input="setValue(c.key, ($event.target as HTMLInputElement).value)"
403
- >
404
- <input
405
- v-else-if="c.filterKind === 'number' || c.filterKind === 'ref' || c.filterKind === 'idSet'"
406
- type="number"
407
- class="list__filter"
408
- :data-filter="c.key"
409
- :aria-label="colLabel(c)"
410
- :value="draft[c.key]?.value ?? ''"
411
- @input="setValue(c.key, ($event.target as HTMLInputElement).value)"
412
- >
413
- <select
414
- v-else-if="c.filterKind === 'boolean'"
415
- class="list__filter"
416
- :data-filter="c.key"
417
- :aria-label="colLabel(c)"
418
- :value="draft[c.key]?.value ?? ''"
419
- @change="setValue(c.key, ($event.target as HTMLSelectElement).value)"
420
- >
421
- <option value="">—</option>
422
- <option value="true">{{ t('filter.bool.true') }}</option>
423
- <option value="false">{{ t('filter.bool.false') }}</option>
424
- </select>
425
- <!-- Single choice (`enum`) and multi choice (`stringSet`) both pick a stored VALUE from the
426
- field's choices; a free-text box would let the user type the visible LABEL and match nothing. -->
427
- <select
428
- v-else-if="c.filterKind === 'enum' || c.filterKind === 'stringSet'"
429
- class="list__filter"
430
- :data-filter="c.key"
431
- :aria-label="colLabel(c)"
432
- :value="draft[c.key]?.value ?? ''"
433
- @change="setValue(c.key, ($event.target as HTMLSelectElement).value)"
434
- >
435
- <option value="">—</option>
436
- <option v-for="o in enumOptions(c)" :key="o.value" :value="o.value">{{ o.label }}</option>
437
- </select>
438
- <input
439
- v-else
440
- type="text"
441
- class="list__filter"
442
- :data-filter="c.key"
443
- :aria-label="colLabel(c)"
444
- :value="draft[c.key]?.value ?? ''"
445
- :placeholder="t('list.filterPlaceholder', { name: colLabel(c) })"
446
- @input="setValue(c.key, ($event.target as HTMLInputElement).value)"
447
- >
448
- </div>
449
- </div>
450
- <button v-if="activeFilters.length" type="button" class="list__panel-action" @click="clearAllFilters">{{ t('list.filterClearAll') }}</button>
451
- </div>
106
+ </KestrelUiButton>
107
+ <KestrelCollectionListFilterPanel
108
+ v-if="openPanel === 'filter'"
109
+ :columns="filterableColumns"
110
+ :filter="filterDraft"
111
+ :has-active="activeFilters.length > 0"
112
+ />
452
113
  </div>
453
114
 
454
115
  <div class="list__tool">
455
- <UiButton
116
+ <KestrelUiButton
456
117
  type="button"
457
118
  size="sm"
458
119
  icon="columns"
@@ -460,152 +121,76 @@ await fetchRows()
460
121
  :aria-expanded="openPanel === 'columns'"
461
122
  class="list__tool-btn"
462
123
  @click="togglePanel('columns', $event)"
463
- >{{ t('list.columns') }}</UiButton>
464
- <div v-if="openPanel === 'columns'" id="list-columns-panel" class="list__panel">
465
- <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
466
- <label v-for="c in available" :key="c.key" class="list__col-row">
467
- <UiCheckbox
468
- class="list__col-check"
469
- :data-col="c.key"
470
- :model-value="visibleKeys.includes(c.key)"
471
- :disabled="visibleKeys.length === 1 && visibleKeys.includes(c.key)"
472
- @update:model-value="() => toggleColumn(c.key)"
473
- />
474
- <span>{{ colLabel(c) }}</span>
475
- </label>
476
- <button v-if="!columnsAreDefault" type="button" class="list__panel-action" @click="resetColumns">{{ t('list.columnsReset') }}</button>
477
- </div>
124
+ >{{ t('list.columns') }}</KestrelUiButton>
125
+ <KestrelCollectionListColumnsPanel
126
+ v-if="openPanel === 'columns'"
127
+ :columns="available"
128
+ :visible-keys="visibleKeys"
129
+ :is-default="columnsAreDefault"
130
+ @toggle="toggleColumn"
131
+ @reset="resetColumns"
132
+ />
478
133
  </div>
479
134
  </div>
480
135
 
481
136
  <NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
482
137
  </div>
483
138
 
484
- <!-- Bulk action bar: chrome (flex:0 0 auto) that appears when ≥1 row is selected, shrinking the one
485
- scroll region rather than overlaying it. Publish/Unpublish are schema-driven (only when status).
486
- NOT a live region: the selection count is announced through the permanent .list__sr-status region
487
- (see `srStatus`); a v-if-toggled role="status" here would double up and stay silent on first show. -->
488
- <div v-if="selected.size" class="list__bulkbar">
489
- <span class="list__bulk-count">{{ t('list.selected', { n: selected.size }) }}</span>
490
- <div class="list__bulk-actions">
491
- <template v-if="schema.status">
492
- <UiButton type="button" size="sm" variant="secondary" :disabled="opsBusy" @click="onBulkStatus('published')">{{ t('list.bulkPublish') }}</UiButton>
493
- <UiButton type="button" size="sm" variant="secondary" :disabled="opsBusy" @click="onBulkStatus('draft')">{{ t('list.bulkUnpublish') }}</UiButton>
494
- </template>
495
- <UiButton type="button" size="sm" variant="danger" :disabled="opsBusy" @click="askDelete([...selected])">{{ t('list.bulkDelete') }}</UiButton>
496
- <UiButton type="button" size="sm" variant="ghost" @click="clearSelection">{{ t('list.clearSelection') }}</UiButton>
497
- </div>
498
- </div>
139
+ <KestrelCollectionListBulkBar
140
+ v-if="selected.size"
141
+ :count="selected.size"
142
+ :has-status="!!schema.status"
143
+ :busy="opsBusy"
144
+ @set-status="(status) => setStatus([...selected], status)"
145
+ @delete="askDelete([...selected])"
146
+ @clear="clearSelection"
147
+ />
499
148
 
500
149
  <div v-if="activeFilters.length" class="list__chips">
501
150
  <span v-for="f in activeFilters" :key="f.key" class="list__chip">
502
151
  <span class="list__chip-label">{{ f.label }} {{ f.opLabel }}:</span> {{ f.display }}
503
- <button type="button" class="list__chip-x" :aria-label="t('list.removeFilter', { name: f.label })" @click="clearFilter(f.key)">
504
- <UiIcon name="x" :size="12" />
152
+ <button type="button" class="list__chip-x" :aria-label="t('list.removeFilter', { name: f.label })" @click="filterDraft.clearFilter(f.key)">
153
+ <KestrelUiIcon name="x" :size="12" />
505
154
  </button>
506
155
  </span>
507
156
  </div>
508
157
 
509
- <UiAlert v-if="error" variant="error" class="list__error">
158
+ <KestrelUiAlert v-if="error" variant="error" class="list__error">
510
159
  {{ error }}
511
- <UiButton type="button" variant="secondary" size="sm" class="list__retry" @click="fetchRows">{{ t('common.retry') }}</UiButton>
512
- </UiAlert>
160
+ <KestrelUiButton type="button" variant="secondary" size="sm" class="list__retry" @click="fetchRows">{{ t('common.retry') }}</KestrelUiButton>
161
+ </KestrelUiAlert>
513
162
 
514
163
  <!-- The one scroll region: the table + its alternate empty-states scroll here while the bar, chips,
515
164
  error and pager stay fixed (see the admin layout's scroll-ownership convention). -->
516
165
  <div class="list__scroll">
517
- <table class="list__table">
518
- <thead>
519
- <tr>
520
- <th class="list__select-th" scope="col">
521
- <UiCheckbox
522
- :model-value="allSelected"
523
- :indeterminate="headerIndeterminate"
524
- :aria-label="t('list.selectAll')"
525
- @update:model-value="toggleAll"
526
- />
527
- </th>
528
- <!-- Row actions: an always-visible, left-frozen chrome column placed right after Select. Its
529
- accessible name is real visually-hidden text, not aria-label on a static <th> (which several
530
- screen-reader / browser pairs ignore on non-interactive table cells). -->
531
- <th class="list__actions-th" scope="col">
532
- <span class="list__vh">{{ t('a11y.rowActions') }}</span>
533
- </th>
534
- <th
535
- v-for="c in visibleColumns"
536
- :key="c.key"
537
- scope="col"
538
- :aria-sort="c.sortable ? ariaSort(c.key) : undefined"
539
- :class="{ 'list__th-narrow': c.type === 'translations' || c.type === 'deadRefs' }"
540
- >
541
- <button v-if="c.sortable" type="button" class="list__sort" @click="setSort(c.key)">{{ colLabel(c) }}<span aria-hidden="true">{{ arrow(c.key) }}</span></button>
542
- <span v-else>{{ colLabel(c) }}</span>
543
- </th>
544
- </tr>
545
- </thead>
546
- <tbody>
547
- <tr v-for="row in rows" :key="String(row.id)" class="list__row">
548
- <td class="list__select-cell">
549
- <UiCheckbox
550
- :model-value="selected.has(Number(row.id))"
551
- :aria-label="t('list.selectRow', { name: rowLabel(row) })"
552
- @update:model-value="(v) => toggleRow(Number(row.id), v)"
553
- />
554
- </td>
555
- <td class="list__actions-cell">
556
- <div class="list__row-actions">
557
- <NuxtLink :to="`/admin/${collection}/${row.id}${localeQuery}`" class="list__action-btn" :aria-label="t('list.rowEdit', { name: rowLabel(row) })">
558
- <UiIcon name="pencil" :size="15" />
559
- </NuxtLink>
560
- <button type="button" class="list__action-btn" :disabled="opsBusy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="onDuplicate(Number(row.id))">
561
- <UiIcon name="copy" :size="15" />
562
- </button>
563
- <button type="button" class="list__action-btn list__action-btn--danger" :disabled="opsBusy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="askDelete([Number(row.id)])">
564
- <UiIcon name="trash" :size="15" />
565
- </button>
566
- </div>
567
- </td>
568
- <td v-for="c in visibleColumns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
569
- <span v-if="c.type === 'deadRefs'" class="list__deadrefs">
570
- <UiIcon
571
- v-if="row.$hasDeadRefs"
572
- name="triangle-alert"
573
- :size="15"
574
- class="list__deadref-icon"
575
- role="img"
576
- :aria-label="t('deadRefs.listWarning')"
577
- :title="t('deadRefs.listWarning')"
578
- />
579
- </span>
580
- <span v-else-if="c.type === 'translations'" class="list__transl">
581
- <NuxtLink
582
- v-for="tr in rowTranslations(row)"
583
- :key="tr.loc"
584
- :to="tr.to"
585
- :data-loc="tr.loc"
586
- class="list__badge"
587
- :class="tr.present ? 'list__badge--present' : 'list__badge--missing'"
588
- :title="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
589
- :aria-label="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
590
- >{{ tr.loc.toUpperCase() }}</NuxtLink>
591
- </span>
592
- <template v-else>{{ cellDisplay(c, row) }}</template>
593
- </td>
594
- </tr>
595
- </tbody>
596
- </table>
597
-
598
- <UiEmptyState
166
+ <KestrelCollectionListTable
167
+ :rows="rows"
168
+ :columns="visibleColumns"
169
+ :collection="collection"
170
+ :locale-query="localeQuery"
171
+ :sort="sort"
172
+ :busy="opsBusy"
173
+ :selected="selected"
174
+ :all-selected="allSelected"
175
+ :header-indeterminate="headerIndeterminate"
176
+ @sort="setSort"
177
+ @toggle-row="toggleRow"
178
+ @toggle-all="toggleAll"
179
+ @duplicate="onDuplicate"
180
+ @delete="askDelete"
181
+ />
182
+
183
+ <KestrelUiEmptyState
599
184
  v-if="!rows.length && !error && activeFilters.length"
600
185
  icon="filter"
601
186
  :title="t('list.noMatch.title')"
602
187
  :description="t('list.noMatch.desc', { name: label })"
603
188
  >
604
189
  <template #action>
605
- <button type="button" class="list__new" @click="clearAllFilters">{{ t('list.filterClearAll') }}</button>
190
+ <button type="button" class="list__new" @click="filterDraft.clearAll">{{ t('list.filterClearAll') }}</button>
606
191
  </template>
607
- </UiEmptyState>
608
- <UiEmptyState
192
+ </KestrelUiEmptyState>
193
+ <KestrelUiEmptyState
609
194
  v-else-if="!rows.length && !error"
610
195
  icon="file-text"
611
196
  :title="t('list.empty.title')"
@@ -614,30 +199,21 @@ await fetchRows()
614
199
  <template #action>
615
200
  <NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
616
201
  </template>
617
- </UiEmptyState>
202
+ </KestrelUiEmptyState>
618
203
  </div>
619
204
 
620
205
  <p class="list__sr-status" role="status" aria-live="polite">{{ srStatus }}</p>
621
206
 
622
- <div class="list__pager">
623
- <UiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</UiButton>
624
- <span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
625
- <UiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</UiButton>
626
- <label class="list__perpage" :for="perPageId">
627
- <span class="list__perpage-label">{{ t('list.perPage') }}</span>
628
- <select
629
- :id="perPageId"
630
- class="list__perpage-select"
631
- :value="perPage"
632
- :aria-label="t('list.perPage')"
633
- @change="setPerPage(Number(($event.target as HTMLSelectElement).value))"
634
- >
635
- <option v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n">{{ n }}</option>
636
- </select>
637
- </label>
638
- </div>
207
+ <KestrelCollectionListPager
208
+ :page="page"
209
+ :total-pages="totalPages"
210
+ :total="total"
211
+ :per-page="perPage"
212
+ @update:page="setPage"
213
+ @update:per-page="setPerPage"
214
+ />
639
215
 
640
- <CollectionDeleteDialog
216
+ <KestrelCollectionDeleteDialog
641
217
  :open="deleteOpen"
642
218
  :report="deleteReport"
643
219
  :busy="opsBusy"
@@ -666,26 +242,12 @@ await fetchRows()
666
242
  border: 0;
667
243
  }
668
244
 
669
- // Visually-hidden text for a static cell that must be announced but not shown (the Actions column header).
670
- // Same technique as .list__sr-status above; chosen over aria-label, which is unreliably announced on
671
- // non-interactive table cells.
672
- .list__vh {
673
- position: absolute;
674
- width: 1px;
675
- height: 1px;
676
- padding: 0;
677
- margin: -1px;
678
- overflow: hidden;
679
- clip: rect(0, 0, 0, 0);
680
- white-space: nowrap;
681
- border: 0;
682
- }
683
-
684
245
  // Fills the height handed down and stays fixed itself; scroll is delegated to .list__scroll. Overflow is
685
246
  // left VISIBLE (not a scroll container) so the inline Filter/Columns popovers are never clipped.
686
247
  .list {
687
248
  // Frozen-column token: the actions column's sticky `left` must equal the select column's width
688
- // (1.25rem checkbox + 2×var(--space-3) cell padding). Keep in sync if the checkbox size or padding change.
249
+ // (1.25rem checkbox + 2×var(--space-3) cell padding). Read by CollectionListTable's frozen columns
250
+ // keep in sync if the checkbox size or padding change.
689
251
  --list-col-select: 2.75rem;
690
252
  display: flex;
691
253
  flex-direction: column;
@@ -733,7 +295,8 @@ await fetchRows()
733
295
  }
734
296
  }
735
297
 
736
- // Filter / Columns toolbar
298
+ // Filter / Columns toolbar. The panels themselves live in CollectionListFilterPanel /
299
+ // CollectionListColumnsPanel; the shared popover chrome below styles both.
737
300
  &__tools {
738
301
  display: flex;
739
302
  align-items: center;
@@ -784,56 +347,6 @@ await fetchRows()
784
347
  font-size: var(--text-sm);
785
348
  cursor: pointer;
786
349
  }
787
- // The inline filter rows need room for name + operator + value; the columns panel stays compact.
788
- &__panel--filter {
789
- min-width: 30rem;
790
- }
791
- // Name, operator and value on ONE line: a stacked label doubled the panel's height for no information.
792
- &__filter-row {
793
- display: flex;
794
- align-items: center;
795
- gap: var(--space-2);
796
- font-size: var(--text-sm);
797
- }
798
- // Fixed basis so the names form an aligned column and the controls all start at the same x.
799
- &__filter-label {
800
- flex: 0 0 6.5rem;
801
- min-width: 0;
802
- overflow: hidden;
803
- text-overflow: ellipsis;
804
- white-space: nowrap;
805
- color: var(--color-text-muted);
806
- }
807
- // Operator select + value control; the value control takes the remaining width.
808
- &__filter-controls {
809
- flex: 1 1 auto;
810
- min-width: 0;
811
- display: flex;
812
- gap: var(--space-1);
813
- align-items: center;
814
- }
815
- &__filter-op {
816
- flex: 0 0 auto;
817
- max-width: 45%;
818
- padding: var(--space-1) var(--space-2);
819
- border: 1px solid var(--color-border);
820
- border-radius: var(--radius-sm);
821
- background: var(--color-surface);
822
- color: var(--color-text-muted);
823
- font: inherit;
824
- font-size: var(--text-sm);
825
- }
826
- &__filter-controls .list__filter {
827
- flex: 1 1 auto;
828
- min-width: 0;
829
- }
830
- &__col-row {
831
- display: flex;
832
- align-items: center;
833
- gap: var(--space-2);
834
- font-size: var(--text-sm);
835
- cursor: pointer;
836
- }
837
350
 
838
351
  &__new {
839
352
  margin-left: auto;
@@ -881,215 +394,5 @@ await fetchRows()
881
394
  color: var(--color-text);
882
395
  }
883
396
  }
884
-
885
- &__table {
886
- width: 100%;
887
- border-collapse: collapse;
888
- font-size: var(--text-sm);
889
-
890
- th,
891
- td {
892
- text-align: start;
893
- padding: var(--space-2) var(--space-3);
894
- border-bottom: 1px solid var(--color-border);
895
- vertical-align: top;
896
- }
897
- // Keep the column headers visible while the rows scroll inside .list__scroll. A solid background is
898
- // required so scrolling rows don't show through. Part of the frozen-column z-index stack (see the
899
- // &__select-* / &__actions-* rules): scrolling body td 0 < frozen body td 1 < scrolling header th 2 <
900
- // frozen corner th 3, so nothing bleeds through on either scroll axis.
901
- thead th {
902
- position: sticky;
903
- top: 0;
904
- z-index: 2; // scrolling header: above scrolling body cells (0) and the frozen body columns (1)
905
- background: var(--color-bg);
906
- }
907
- // Frozen corner: the select + actions headers are sticky on BOTH axes, so they must sit above every
908
- // scrolling header AND every frozen body column where the two frozen edges cross.
909
- thead th.list__select-th,
910
- thead th.list__actions-th {
911
- z-index: 3;
912
- }
913
- }
914
- &__sort {
915
- background: none;
916
- border: 0;
917
- padding: 0;
918
- font: inherit;
919
- font-weight: var(--weight-medium);
920
- cursor: pointer;
921
- color: var(--color-text);
922
- }
923
- &__filter {
924
- width: 100%;
925
- padding: var(--space-1) var(--space-2);
926
- border: 1px solid var(--color-border);
927
- border-radius: var(--radius-sm);
928
- font: inherit;
929
- }
930
- // Compact, de-emphasised info columns (translations · dead-reference warning).
931
- &__th-narrow,
932
- &__narrow-cell {
933
- width: 1%;
934
- white-space: nowrap;
935
- }
936
-
937
- // Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
938
- &__deadrefs {
939
- display: inline-flex;
940
- }
941
- &__deadref-icon {
942
- color: var(--color-warning);
943
- }
944
- &__transl {
945
- display: inline-flex;
946
- gap: 2px;
947
- flex-wrap: wrap;
948
- }
949
- &__badge {
950
- display: inline-flex;
951
- align-items: center;
952
- justify-content: center;
953
- min-width: 1.4rem;
954
- padding: 0 3px;
955
- border: 1px solid transparent;
956
- border-radius: var(--radius-sm);
957
- font-size: 0.625rem;
958
- font-weight: var(--weight-medium);
959
- line-height: 1.5;
960
- letter-spacing: 0.02em;
961
- text-decoration: none;
962
-
963
- &--present {
964
- background: var(--color-primary-soft);
965
- color: var(--color-primary);
966
- }
967
- &--missing {
968
- color: var(--color-text-muted);
969
- border-color: var(--color-border);
970
- border-style: dashed;
971
- }
972
- &:focus-visible {
973
- outline: 2px solid var(--color-focus);
974
- outline-offset: -2px;
975
- }
976
- }
977
-
978
- // Selection column — the first frozen chrome column, pinned to the left with `position: sticky` inside
979
- // the existing .list__scroll (no new scroll container). Opaque `var(--color-bg)` so horizontally-scrolled
980
- // data can't bleed through; z-index 1 lifts the frozen body cell above the scrolling body cells.
981
- &__select-th,
982
- &__select-cell {
983
- position: sticky;
984
- left: 0;
985
- z-index: 1;
986
- width: var(--list-col-select);
987
- white-space: nowrap;
988
- vertical-align: middle;
989
- background: var(--color-bg);
990
- }
991
- // Row-actions column — the second frozen column, sitting immediately after Select (its `left` offset is
992
- // exactly the select column's width). Always visible; snug `width: 1%` so the three icon buttons never
993
- // steal width from the Title column.
994
- &__actions-th,
995
- &__actions-cell {
996
- position: sticky;
997
- left: var(--list-col-select);
998
- z-index: 1;
999
- width: 1%;
1000
- white-space: nowrap;
1001
- vertical-align: middle;
1002
- background: var(--color-bg);
1003
- }
1004
- // A plain, always-visible inline row of icon buttons (Edit → Duplicate → Delete; destructive last, never
1005
- // default focus). BlockTree.vue deliberately keeps its own absolute overlay: a tree row is a single flex
1006
- // row with no column grid — there is no cell to place actions in.
1007
- &__row-actions {
1008
- display: inline-flex;
1009
- align-items: center;
1010
- gap: 1px;
1011
- }
1012
- &__action-btn {
1013
- display: flex;
1014
- align-items: center;
1015
- justify-content: center;
1016
- width: 1.5rem;
1017
- height: 1.5rem;
1018
- border: 0;
1019
- border-radius: var(--radius-sm);
1020
- background: transparent;
1021
- color: var(--color-text-muted); /* meaningful icon control at rest → ≥3:1 (SC 1.4.11) */
1022
- text-decoration: none;
1023
- cursor: pointer;
1024
- transition:
1025
- background-color var(--motion-fast) var(--ease-standard),
1026
- color var(--motion-fast) var(--ease-standard);
1027
-
1028
- &:hover {
1029
- background: var(--color-hover);
1030
- color: var(--color-text);
1031
- }
1032
- &:focus-visible {
1033
- outline: 2px solid var(--color-focus);
1034
- outline-offset: -2px;
1035
- }
1036
- &--danger:hover {
1037
- color: var(--color-danger);
1038
- }
1039
- &:disabled {
1040
- opacity: 0.5;
1041
- cursor: default;
1042
- pointer-events: none;
1043
- }
1044
- }
1045
-
1046
- // Bulk action bar — appears above the scroll region when ≥1 row is selected.
1047
- &__bulkbar {
1048
- display: flex;
1049
- align-items: center;
1050
- gap: var(--space-3);
1051
- padding: var(--space-2) var(--space-3);
1052
- border: 1px solid var(--color-border);
1053
- border-radius: var(--radius-md);
1054
- background: var(--color-surface);
1055
- }
1056
- &__bulk-count {
1057
- font-size: var(--text-sm);
1058
- font-weight: var(--weight-medium);
1059
- }
1060
- &__bulk-actions {
1061
- display: flex;
1062
- align-items: center;
1063
- gap: var(--space-2);
1064
- margin-inline-start: auto;
1065
- }
1066
-
1067
- &__pager {
1068
- display: flex;
1069
- align-items: center;
1070
- gap: var(--space-3);
1071
- }
1072
- &__page {
1073
- font-size: var(--text-sm);
1074
- color: var(--color-text-muted);
1075
- }
1076
- // Per-page size selector sits at the far end of the pager.
1077
- &__perpage {
1078
- display: inline-flex;
1079
- align-items: center;
1080
- gap: var(--space-2);
1081
- margin-inline-start: auto;
1082
- font-size: var(--text-sm);
1083
- color: var(--color-text-muted);
1084
- }
1085
- &__perpage-select {
1086
- padding: var(--space-1) var(--space-2);
1087
- border: 1px solid var(--color-border);
1088
- border-radius: var(--radius-sm);
1089
- background: var(--color-surface);
1090
- color: var(--color-text);
1091
- font: inherit;
1092
- font-size: var(--text-sm);
1093
- }
1094
397
  }
1095
398
  </style>