@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
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ // Chrome (flex: 0 0 auto) that appears when ≥1 row is selected, shrinking the one scroll region rather
3
+ // than overlaying it. Publish/Unpublish are schema-driven (only when the collection has a status).
4
+ // NOT a live region: the selection count is announced through the list's permanent .list__sr-status
5
+ // region; a v-if-toggled role="status" here would double up and stay silent on first show.
6
+ defineProps<{ count: number; hasStatus: boolean; busy: boolean }>()
7
+ const emit = defineEmits<{ setStatus: [status: 'published' | 'draft']; delete: []; clear: [] }>()
8
+
9
+ const { t } = useT()
10
+ </script>
11
+
12
+ <template>
13
+ <div class="list__bulkbar">
14
+ <span class="list__bulk-count">{{ t('list.selected', { n: count }) }}</span>
15
+ <div class="list__bulk-actions">
16
+ <template v-if="hasStatus">
17
+ <KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'published')">{{ t('list.bulkPublish') }}</KestrelUiButton>
18
+ <KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'draft')">{{ t('list.bulkUnpublish') }}</KestrelUiButton>
19
+ </template>
20
+ <KestrelUiButton type="button" size="sm" variant="danger" :disabled="busy" @click="emit('delete')">{{ t('list.bulkDelete') }}</KestrelUiButton>
21
+ <KestrelUiButton type="button" size="sm" variant="ghost" @click="emit('clear')">{{ t('list.clearSelection') }}</KestrelUiButton>
22
+ </div>
23
+ </div>
24
+ </template>
25
+
26
+ <style lang="scss">
27
+ .list {
28
+ &__bulkbar {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: var(--space-3);
32
+ padding: var(--space-2) var(--space-3);
33
+ border: 1px solid var(--color-border);
34
+ border-radius: var(--radius-md);
35
+ background: var(--color-surface);
36
+ }
37
+ &__bulk-count {
38
+ font-size: var(--text-sm);
39
+ font-weight: var(--weight-medium);
40
+ }
41
+ &__bulk-actions {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: var(--space-2);
45
+ margin-inline-start: auto;
46
+ }
47
+ }
48
+ </style>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { columnLabel } from '../utils/list-cell'
3
+ import type { ListColumn } from '../utils/list-columns'
4
+
5
+ defineProps<{ columns: ListColumn[]; visibleKeys: string[]; isDefault: boolean }>()
6
+ const emit = defineEmits<{ toggle: [key: string]; reset: [] }>()
7
+
8
+ const { t } = useT()
9
+ const colLabel = (col: ListColumn) => columnLabel(col, t)
10
+ </script>
11
+
12
+ <template>
13
+ <div id="list-columns-panel" class="list__panel">
14
+ <!-- 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 -->
15
+ <label v-for="c in columns" :key="c.key" class="list__col-row">
16
+ <KestrelUiCheckbox
17
+ class="list__col-check"
18
+ :data-col="c.key"
19
+ :model-value="visibleKeys.includes(c.key)"
20
+ :disabled="visibleKeys.length === 1 && visibleKeys.includes(c.key)"
21
+ @update:model-value="() => emit('toggle', c.key)"
22
+ />
23
+ <span>{{ colLabel(c) }}</span>
24
+ </label>
25
+ <button v-if="!isDefault" type="button" class="list__panel-action" @click="emit('reset')">{{ t('list.columnsReset') }}</button>
26
+ </div>
27
+ </template>
28
+
29
+ <style lang="scss">
30
+ .list__col-row {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: var(--space-2);
34
+ font-size: var(--text-sm);
35
+ cursor: pointer;
36
+ }
37
+ </style>
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import type { FilterOp } from '../../../core/app/utils/filter-ops'
3
+ import type { ListFilterDraft } from '../composables/useListFilterDraft'
4
+ import { columnLabel } from '../utils/list-cell'
5
+ import type { ListColumn } from '../utils/list-columns'
6
+
7
+ // The whole draft controller arrives as ONE prop: its ten members are a single cohesive thing, and the
8
+ // panel must share the parent's draft state (the chip row renders off the same clauses).
9
+ const props = defineProps<{ columns: ListColumn[]; filter: ListFilterDraft; hasActive: boolean }>()
10
+
11
+ const { t } = useT()
12
+ const colLabel = (col: ListColumn) => columnLabel(col, t)
13
+ const draft = computed(() => props.filter.draft)
14
+ </script>
15
+
16
+ <template>
17
+ <div id="list-filter-panel" class="list__panel list__panel--filter">
18
+ <p v-if="!columns.length" class="list__panel-empty">{{ t('list.noFilters') }}</p>
19
+ <div v-for="c in columns" :key="c.key" class="list__filter-row">
20
+ <span class="list__filter-label">{{ colLabel(c) }}</span>
21
+ <div class="list__filter-controls">
22
+ <select
23
+ v-if="filter.opsFor(c).length > 1"
24
+ class="list__filter-op"
25
+ :data-filter-op="c.key"
26
+ :aria-label="`${t('filter.operator')} — ${colLabel(c)}`"
27
+ :value="draft[c.key]?.op"
28
+ @change="filter.setOp(c.key, ($event.target as HTMLSelectElement).value as FilterOp)"
29
+ >
30
+ <option v-for="op in filter.opsFor(c)" :key="op" :value="op">{{ filter.opLabel(c.filterKind ?? 'text', op) }}</option>
31
+ </select>
32
+
33
+ <input
34
+ v-if="c.filterKind === 'datetime'"
35
+ type="date"
36
+ class="list__filter"
37
+ :data-filter="c.key"
38
+ :aria-label="colLabel(c)"
39
+ :value="draft[c.key]?.value ?? ''"
40
+ @input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
41
+ >
42
+ <input
43
+ v-else-if="c.filterKind === 'number' || c.filterKind === 'ref' || c.filterKind === 'idSet'"
44
+ type="number"
45
+ class="list__filter"
46
+ :data-filter="c.key"
47
+ :aria-label="colLabel(c)"
48
+ :value="draft[c.key]?.value ?? ''"
49
+ @input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
50
+ >
51
+ <select
52
+ v-else-if="c.filterKind === 'boolean'"
53
+ class="list__filter"
54
+ :data-filter="c.key"
55
+ :aria-label="colLabel(c)"
56
+ :value="draft[c.key]?.value ?? ''"
57
+ @change="filter.setValue(c.key, ($event.target as HTMLSelectElement).value)"
58
+ >
59
+ <option value="">—</option>
60
+ <option value="true">{{ t('filter.bool.true') }}</option>
61
+ <option value="false">{{ t('filter.bool.false') }}</option>
62
+ </select>
63
+ <!-- Single choice (`enum`) and multi choice (`stringSet`) both pick a stored VALUE from the
64
+ field's choices; a free-text box would let the user type the visible LABEL and match nothing. -->
65
+ <select
66
+ v-else-if="c.filterKind === 'enum' || c.filterKind === 'stringSet'"
67
+ class="list__filter"
68
+ :data-filter="c.key"
69
+ :aria-label="colLabel(c)"
70
+ :value="draft[c.key]?.value ?? ''"
71
+ @change="filter.setValue(c.key, ($event.target as HTMLSelectElement).value)"
72
+ >
73
+ <option value="">—</option>
74
+ <option v-for="o in filter.enumOptions(c)" :key="o.value" :value="o.value">{{ o.label }}</option>
75
+ </select>
76
+ <input
77
+ v-else
78
+ type="text"
79
+ class="list__filter"
80
+ :data-filter="c.key"
81
+ :aria-label="colLabel(c)"
82
+ :value="draft[c.key]?.value ?? ''"
83
+ :placeholder="t('list.filterPlaceholder', { name: colLabel(c) })"
84
+ @input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
85
+ >
86
+ </div>
87
+ </div>
88
+ <button v-if="hasActive" type="button" class="list__panel-action" @click="filter.clearAll">{{ t('list.filterClearAll') }}</button>
89
+ </div>
90
+ </template>
91
+
92
+ <style lang="scss">
93
+ .list {
94
+ // The inline filter rows need room for name + operator + value; the columns panel stays compact.
95
+ &__panel--filter {
96
+ min-width: 30rem;
97
+ }
98
+ // Name, operator and value on ONE line: a stacked label doubled the panel's height for no information.
99
+ &__filter-row {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: var(--space-2);
103
+ font-size: var(--text-sm);
104
+ }
105
+ // Fixed basis so the names form an aligned column and the controls all start at the same x.
106
+ &__filter-label {
107
+ flex: 0 0 6.5rem;
108
+ min-width: 0;
109
+ overflow: hidden;
110
+ text-overflow: ellipsis;
111
+ white-space: nowrap;
112
+ color: var(--color-text-muted);
113
+ }
114
+ // Operator select + value control; the value control takes the remaining width.
115
+ &__filter-controls {
116
+ flex: 1 1 auto;
117
+ min-width: 0;
118
+ display: flex;
119
+ gap: var(--space-1);
120
+ align-items: center;
121
+ }
122
+ &__filter-op {
123
+ flex: 0 0 auto;
124
+ max-width: 45%;
125
+ padding: var(--space-1) var(--space-2);
126
+ border: 1px solid var(--color-border);
127
+ border-radius: var(--radius-sm);
128
+ background: var(--color-surface);
129
+ color: var(--color-text-muted);
130
+ font: inherit;
131
+ font-size: var(--text-sm);
132
+ }
133
+ &__filter {
134
+ width: 100%;
135
+ padding: var(--space-1) var(--space-2);
136
+ border: 1px solid var(--color-border);
137
+ border-radius: var(--radius-sm);
138
+ font: inherit;
139
+ }
140
+ &__filter-controls .list__filter {
141
+ flex: 1 1 auto;
142
+ min-width: 0;
143
+ }
144
+ }
145
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
3
+
4
+ const props = defineProps<{ page: number; totalPages: number; total: number; perPage: number }>()
5
+ const emit = defineEmits<{ 'update:page': [page: number]; 'update:perPage': [perPage: number] }>()
6
+
7
+ const { t } = useT()
8
+ // The per-page `<select>` is hand-rolled here (no UiField wrapper), so the label↔control association needs
9
+ // an explicit id pair for static a11y analysis to see it.
10
+ const perPageId = useId()
11
+
12
+ function prev() {
13
+ if (props.page > 1) emit('update:page', props.page - 1)
14
+ }
15
+ function next() {
16
+ if (props.page < props.totalPages) emit('update:page', props.page + 1)
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <div class="list__pager">
22
+ <KestrelUiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</KestrelUiButton>
23
+ <span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
24
+ <KestrelUiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</KestrelUiButton>
25
+ <label class="list__perpage" :for="perPageId">
26
+ <span class="list__perpage-label">{{ t('list.perPage') }}</span>
27
+ <select
28
+ :id="perPageId"
29
+ class="list__perpage-select"
30
+ :value="perPage"
31
+ :aria-label="t('list.perPage')"
32
+ @change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
33
+ >
34
+ <option v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n">{{ n }}</option>
35
+ </select>
36
+ </label>
37
+ </div>
38
+ </template>
39
+
40
+ <style lang="scss">
41
+ .list {
42
+ &__pager {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: var(--space-3);
46
+ }
47
+ &__page {
48
+ font-size: var(--text-sm);
49
+ color: var(--color-text-muted);
50
+ }
51
+ // Per-page size selector sits at the far end of the pager.
52
+ &__perpage {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: var(--space-2);
56
+ margin-inline-start: auto;
57
+ font-size: var(--text-sm);
58
+ color: var(--color-text-muted);
59
+ }
60
+ &__perpage-select {
61
+ padding: var(--space-1) var(--space-2);
62
+ border: 1px solid var(--color-border);
63
+ border-radius: var(--radius-sm);
64
+ background: var(--color-surface);
65
+ color: var(--color-text);
66
+ font: inherit;
67
+ font-size: var(--text-sm);
68
+ }
69
+ }
70
+ </style>
@@ -0,0 +1,308 @@
1
+ <script setup lang="ts">
2
+ import { sortDirection } from '../utils/list-query'
3
+ import { cellDisplay, columnLabel, rowLabel as labelForRow } from '../utils/list-cell'
4
+ import type { ListColumn } from '../utils/list-columns'
5
+
6
+ const props = defineProps<{
7
+ rows: Record<string, unknown>[]
8
+ columns: ListColumn[]
9
+ collection: string
10
+ localeQuery: string
11
+ sort: string
12
+ busy: boolean
13
+ selected: Set<number>
14
+ allSelected: boolean
15
+ headerIndeterminate: boolean
16
+ }>()
17
+ const emit = defineEmits<{
18
+ sort: [key: string]
19
+ toggleRow: [id: number, on: boolean]
20
+ toggleAll: [on: boolean]
21
+ duplicate: [id: number]
22
+ delete: [ids: number[]]
23
+ }>()
24
+
25
+ const { t } = useT()
26
+ const { locales } = useContentLocales()
27
+
28
+ const colLabel = (col: ListColumn) => columnLabel(col, t)
29
+ const rowLabel = (row: Record<string, unknown>) => labelForRow(props.columns, row)
30
+
31
+ function arrow(field: string) {
32
+ const dir = sortDirection(props.sort, field)
33
+ return dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : ''
34
+ }
35
+ // Programmatic sort state for AT (the visual arrow is aria-hidden) — WCAG 1.3.1 / 4.1.2.
36
+ function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
37
+ const dir = sortDirection(props.sort, field)
38
+ return dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'
39
+ }
40
+
41
+ // Per-row translation status (the list query attaches `$translations`: locale → sibling id, or null).
42
+ // Present locales link to their sibling; missing ones offer create-and-link, carrying the translation group.
43
+ function rowTranslations(row: Record<string, unknown>) {
44
+ const map = (row.$translations ?? {}) as Record<string, number | null>
45
+ const group = row.translationGroup as string | undefined
46
+ return locales.map((loc) => {
47
+ const id = map[loc]
48
+ return id != null
49
+ ? { loc, present: true, to: `/admin/${props.collection}/${id}?locale=${loc}` }
50
+ : { loc, present: false, to: `/admin/${props.collection}/new?locale=${loc}${group ? `&group=${group}` : ''}` }
51
+ })
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <table class="list__table">
57
+ <thead>
58
+ <tr>
59
+ <th class="list__select-th" scope="col">
60
+ <KestrelUiCheckbox
61
+ :model-value="allSelected"
62
+ :indeterminate="headerIndeterminate"
63
+ :aria-label="t('list.selectAll')"
64
+ @update:model-value="(v) => emit('toggleAll', v)"
65
+ />
66
+ </th>
67
+ <!-- Row actions: an always-visible, left-frozen chrome column placed right after Select. Its
68
+ accessible name is real visually-hidden text, not aria-label on a static <th> (which several
69
+ screen-reader / browser pairs ignore on non-interactive table cells). -->
70
+ <th class="list__actions-th" scope="col">
71
+ <span class="list__vh">{{ t('a11y.rowActions') }}</span>
72
+ </th>
73
+ <th
74
+ v-for="c in columns"
75
+ :key="c.key"
76
+ scope="col"
77
+ :aria-sort="c.sortable ? ariaSort(c.key) : undefined"
78
+ :class="{ 'list__th-narrow': c.type === 'translations' || c.type === 'deadRefs' }"
79
+ >
80
+ <button v-if="c.sortable" type="button" class="list__sort" @click="emit('sort', c.key)">{{ colLabel(c) }}<span aria-hidden="true">{{ arrow(c.key) }}</span></button>
81
+ <span v-else>{{ colLabel(c) }}</span>
82
+ </th>
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ <tr v-for="row in rows" :key="String(row.id)" class="list__row">
87
+ <td class="list__select-cell">
88
+ <KestrelUiCheckbox
89
+ :model-value="selected.has(Number(row.id))"
90
+ :aria-label="t('list.selectRow', { name: rowLabel(row) })"
91
+ @update:model-value="(v) => emit('toggleRow', Number(row.id), v)"
92
+ />
93
+ </td>
94
+ <td class="list__actions-cell">
95
+ <div class="list__row-actions">
96
+ <NuxtLink :to="`/admin/${collection}/${row.id}${localeQuery}`" class="list__action-btn" :aria-label="t('list.rowEdit', { name: rowLabel(row) })">
97
+ <KestrelUiIcon name="pencil" :size="15" />
98
+ </NuxtLink>
99
+ <button type="button" class="list__action-btn" :disabled="busy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="emit('duplicate', Number(row.id))">
100
+ <KestrelUiIcon name="copy" :size="15" />
101
+ </button>
102
+ <button type="button" class="list__action-btn list__action-btn--danger" :disabled="busy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="emit('delete', [Number(row.id)])">
103
+ <KestrelUiIcon name="trash" :size="15" />
104
+ </button>
105
+ </div>
106
+ </td>
107
+ <td v-for="c in columns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
108
+ <span v-if="c.type === 'deadRefs'" class="list__deadrefs">
109
+ <KestrelUiIcon
110
+ v-if="row.$hasDeadRefs"
111
+ name="triangle-alert"
112
+ :size="15"
113
+ class="list__deadref-icon"
114
+ role="img"
115
+ :aria-label="t('deadRefs.listWarning')"
116
+ :title="t('deadRefs.listWarning')"
117
+ />
118
+ </span>
119
+ <span v-else-if="c.type === 'translations'" class="list__transl">
120
+ <NuxtLink
121
+ v-for="tr in rowTranslations(row)"
122
+ :key="tr.loc"
123
+ :to="tr.to"
124
+ :data-loc="tr.loc"
125
+ class="list__badge"
126
+ :class="tr.present ? 'list__badge--present' : 'list__badge--missing'"
127
+ :title="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
128
+ :aria-label="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
129
+ >{{ tr.loc.toUpperCase() }}</NuxtLink>
130
+ </span>
131
+ <template v-else>{{ cellDisplay(c, row) }}</template>
132
+ </td>
133
+ </tr>
134
+ </tbody>
135
+ </table>
136
+ </template>
137
+
138
+ <style lang="scss">
139
+ // Visually-hidden text for a static cell that must be announced but not shown (the Actions column header).
140
+ // Chosen over aria-label, which is unreliably announced on non-interactive table cells.
141
+ .list__vh {
142
+ position: absolute;
143
+ width: 1px;
144
+ height: 1px;
145
+ padding: 0;
146
+ margin: -1px;
147
+ overflow: hidden;
148
+ clip: rect(0, 0, 0, 0);
149
+ white-space: nowrap;
150
+ border: 0;
151
+ }
152
+
153
+ .list {
154
+ &__table {
155
+ width: 100%;
156
+ border-collapse: collapse;
157
+ font-size: var(--text-sm);
158
+
159
+ th,
160
+ td {
161
+ text-align: start;
162
+ padding: var(--space-2) var(--space-3);
163
+ border-bottom: 1px solid var(--color-border);
164
+ vertical-align: top;
165
+ }
166
+ // Keep the column headers visible while the rows scroll inside .list__scroll. A solid background is
167
+ // required so scrolling rows don't show through. Part of the frozen-column z-index stack (see the
168
+ // &__select-* / &__actions-* rules): scrolling body td 0 < frozen body td 1 < scrolling header th 2 <
169
+ // frozen corner th 3, so nothing bleeds through on either scroll axis.
170
+ thead th {
171
+ position: sticky;
172
+ top: 0;
173
+ z-index: 2; // scrolling header: above scrolling body cells (0) and the frozen body columns (1)
174
+ background: var(--color-bg);
175
+ }
176
+ // Frozen corner: the select + actions headers are sticky on BOTH axes, so they must sit above every
177
+ // scrolling header AND every frozen body column where the two frozen edges cross.
178
+ thead th.list__select-th,
179
+ thead th.list__actions-th {
180
+ z-index: 3;
181
+ }
182
+ }
183
+ &__sort {
184
+ background: none;
185
+ border: 0;
186
+ padding: 0;
187
+ font: inherit;
188
+ font-weight: var(--weight-medium);
189
+ cursor: pointer;
190
+ color: var(--color-text);
191
+ }
192
+ // Compact, de-emphasised info columns (translations · dead-reference warning).
193
+ &__th-narrow,
194
+ &__narrow-cell {
195
+ width: 1%;
196
+ white-space: nowrap;
197
+ }
198
+
199
+ // Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
200
+ &__deadrefs {
201
+ display: inline-flex;
202
+ }
203
+ &__deadref-icon {
204
+ color: var(--color-warning);
205
+ }
206
+ &__transl {
207
+ display: inline-flex;
208
+ gap: 2px;
209
+ flex-wrap: wrap;
210
+ }
211
+ &__badge {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ min-width: 1.4rem;
216
+ padding: 0 3px;
217
+ border: 1px solid transparent;
218
+ border-radius: var(--radius-sm);
219
+ font-size: 0.625rem;
220
+ font-weight: var(--weight-medium);
221
+ line-height: 1.5;
222
+ letter-spacing: 0.02em;
223
+ text-decoration: none;
224
+
225
+ &--present {
226
+ background: var(--color-primary-soft);
227
+ color: var(--color-primary);
228
+ }
229
+ &--missing {
230
+ color: var(--color-text-muted);
231
+ border-color: var(--color-border);
232
+ border-style: dashed;
233
+ }
234
+ &:focus-visible {
235
+ outline: 2px solid var(--color-focus);
236
+ outline-offset: -2px;
237
+ }
238
+ }
239
+
240
+ // Selection column — the first frozen chrome column, pinned to the left with `position: sticky` inside
241
+ // the existing .list__scroll (no new scroll container). Opaque `var(--color-bg)` so horizontally-scrolled
242
+ // data can't bleed through; z-index 1 lifts the frozen body cell above the scrolling body cells.
243
+ &__select-th,
244
+ &__select-cell {
245
+ position: sticky;
246
+ left: 0;
247
+ z-index: 1;
248
+ width: var(--list-col-select);
249
+ white-space: nowrap;
250
+ vertical-align: middle;
251
+ background: var(--color-bg);
252
+ }
253
+ // Row-actions column — the second frozen column, sitting immediately after Select (its `left` offset is
254
+ // exactly the select column's width). Always visible; snug `width: 1%` so the three icon buttons never
255
+ // steal width from the Title column.
256
+ &__actions-th,
257
+ &__actions-cell {
258
+ position: sticky;
259
+ left: var(--list-col-select);
260
+ z-index: 1;
261
+ width: 1%;
262
+ white-space: nowrap;
263
+ vertical-align: middle;
264
+ background: var(--color-bg);
265
+ }
266
+ // A plain, always-visible inline row of icon buttons (Edit → Duplicate → Delete; destructive last, never
267
+ // default focus). BlockTree.vue deliberately keeps its own absolute overlay: a tree row is a single flex
268
+ // row with no column grid — there is no cell to place actions in.
269
+ &__row-actions {
270
+ display: inline-flex;
271
+ align-items: center;
272
+ gap: 1px;
273
+ }
274
+ &__action-btn {
275
+ display: flex;
276
+ align-items: center;
277
+ justify-content: center;
278
+ width: 1.5rem;
279
+ height: 1.5rem;
280
+ border: 0;
281
+ border-radius: var(--radius-sm);
282
+ background: transparent;
283
+ color: var(--color-text-muted); /* meaningful icon control at rest → ≥3:1 (SC 1.4.11) */
284
+ text-decoration: none;
285
+ cursor: pointer;
286
+ transition:
287
+ background-color var(--motion-fast) var(--ease-standard),
288
+ color var(--motion-fast) var(--ease-standard);
289
+
290
+ &:hover {
291
+ background: var(--color-hover);
292
+ color: var(--color-text);
293
+ }
294
+ &:focus-visible {
295
+ outline: 2px solid var(--color-focus);
296
+ outline-offset: -2px;
297
+ }
298
+ &--danger:hover {
299
+ color: var(--color-danger);
300
+ }
301
+ &:disabled {
302
+ opacity: 0.5;
303
+ cursor: default;
304
+ pointer-events: none;
305
+ }
306
+ }
307
+ }
308
+ </style>
@@ -92,7 +92,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
92
92
  Visually hidden; not a tooltip. -->
93
93
  <span class="editor-status-sr" role="status" aria-live="polite">{{ save.detail }}</span>
94
94
 
95
- <UiTooltip side="bottom">
95
+ <KestrelUiTooltip side="bottom">
96
96
  <!-- A focusable status indicator, NOT a <button>: it has no action, and a button whose accessible
97
97
  name contains "Saved"/"Unsaved" would collide with `getByRole('button', {name:'Save'})`. aria-label
98
98
  leads with the visible word so it stays part of the accessible name, then adds the detail for AT. -->
@@ -105,9 +105,9 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
105
105
  <strong class="editor-status-tip__detail">{{ save.detail }}</strong>
106
106
  </span>
107
107
  </template>
108
- </UiTooltip>
108
+ </KestrelUiTooltip>
109
109
 
110
- <UiTooltip v-if="live" side="bottom">
110
+ <KestrelUiTooltip v-if="live" side="bottom">
111
111
  <span class="editor-status-live" tabindex="0" :data-tone="live.tone" :aria-label="`${live.word} — ${live.detail}`">
112
112
  <span class="editor-status-live__dot" aria-hidden="true" />
113
113
  <span class="editor-status-live__word">{{ live.word }}</span>
@@ -119,7 +119,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
119
119
  <span v-if="live.tone === 'red' && live.error" class="editor-status-tip__err">{{ live.error }}</span>
120
120
  </span>
121
121
  </template>
122
- </UiTooltip>
122
+ </KestrelUiTooltip>
123
123
  </span>
124
124
  </template>
125
125
 
@@ -6,7 +6,7 @@ const ctx = useEditorFormContext()
6
6
 
7
7
  <template>
8
8
  <div class="editor__flat">
9
- <PageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
9
+ <KestrelPageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
10
10
  </div>
11
11
  </template>
12
12
 
@@ -38,11 +38,11 @@ const { t } = useT()
38
38
  class="locale-bar__item locale-bar__btn"
39
39
  :to="`/admin/${collection}?locale=${loc}`"
40
40
  :aria-label="t('localeBar.editLocale', { loc: up(loc) })"
41
- >{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
41
+ >{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
42
42
 
43
43
  <span v-else-if="existingId(loc) !== null" class="locale-bar__item">
44
- <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
45
- <button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><UiIcon name="copy" :size="14" /></button>
44
+ <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
45
+ <button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><KestrelUiIcon name="copy" :size="14" /></button>
46
46
  </span>
47
47
 
48
48
  <NuxtLink
@@ -50,7 +50,7 @@ const { t } = useT()
50
50
  class="locale-bar__item locale-bar__btn locale-bar__btn--add"
51
51
  :to="createLink(loc)"
52
52
  :aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
53
- >{{ up(loc) }}<UiIcon name="plus" :size="14" /></NuxtLink>
53
+ >{{ up(loc) }}<KestrelUiIcon name="plus" :size="14" /></NuxtLink>
54
54
  </template>
55
55
  </div>
56
56
  </template>