@happyvertical/smrt-content 0.43.2 → 0.43.4

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.
@@ -1,11 +1,37 @@
1
1
  <script lang="ts">
2
+ import { DataTable, type DataTableColumn } from '@happyvertical/smrt-ui/data';
2
3
  import { ConfirmDialog } from '@happyvertical/smrt-ui/feedback';
3
- import { Input, Select } from '@happyvertical/smrt-ui/forms';
4
+ import { Checkbox, Input, Select } from '@happyvertical/smrt-ui/forms';
4
5
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
5
- import { Button } from '@happyvertical/smrt-ui/ui';
6
+ import { Button, Pagination } from '@happyvertical/smrt-ui/ui';
6
7
  import type { Snippet } from 'svelte';
7
8
  import { untrack } from 'svelte';
8
9
  import type { ContentData } from '../../mock-smrt-client.js';
10
+ import {
11
+ applyContentListFilter,
12
+ buildContentListColumns,
13
+ buildContentListSurfaceDescriptor,
14
+ CONTENT_LIST_ACTIONS_COLUMN_ID,
15
+ CONTENT_LIST_ROW_KEY,
16
+ CONTENT_LIST_SELECTION_COLUMN_ID,
17
+ CONTENT_LIST_STATUS_FILTER_ID,
18
+ CONTENT_LIST_TYPE_FILTER_ID,
19
+ type ContentListDataSurface,
20
+ type ContentListRow,
21
+ type ContentListViewMode,
22
+ contentListRowActions,
23
+ contentStateVariant,
24
+ contentStatusVariant,
25
+ createContentListController,
26
+ isContentListFilterExactly,
27
+ normalizeContentType,
28
+ paginateContentListRows,
29
+ readContentListFilter,
30
+ resolveContentHref,
31
+ selectableContentListRowIds,
32
+ selectContentListRows,
33
+ toContentListRows,
34
+ } from '../content-list-controller.js';
9
35
  import { M } from '../i18n.contribution.js';
10
36
  import ImageThumbnail from './ImageThumbnail.svelte';
11
37
 
@@ -15,12 +41,20 @@ interface Props {
15
41
  apiBaseUrl?: string;
16
42
  contents: ContentData[];
17
43
  type?: string;
18
- defaultViewMode?: 'grid' | 'detailed' | 'compact';
44
+ defaultViewMode?: ContentListViewMode;
19
45
  onEdit: (content: ContentData) => void;
20
46
  onDelete: (content: ContentData) => void;
21
47
  onAdd: () => void;
22
48
  controls?: Snippet;
23
49
  getViewHref?: (content: ContentData) => string | null;
50
+ /** Announced uniformly by every presentation; #2455 extends it. */
51
+ loading?: boolean;
52
+ /** Load failure announced instead of the list. */
53
+ error?: string | null;
54
+ /** Retry affordance rendered with an error. */
55
+ onRetry?: () => void;
56
+ /** Opt-in agent addressability. Non-table presentations land with #2456. */
57
+ dataSurface?: ContentListDataSurface;
24
58
  }
25
59
 
26
60
  let {
@@ -33,154 +67,407 @@ let {
33
67
  onAdd,
34
68
  controls,
35
69
  getViewHref = undefined,
70
+ loading = false,
71
+ error = null,
72
+ onRetry = undefined,
73
+ dataSurface = undefined,
36
74
  }: Props = $props();
37
75
 
38
- let searchTerm = $state('');
39
- let selectedType = $state('All Types');
40
- let selectedStatus = $state('All Statuses');
41
- let viewMode: 'grid' | 'detailed' | 'compact' = $state(
42
- untrack(() => defaultViewMode),
76
+ // One controller owns search, filters, sorting, paging, and selection for
77
+ // every presentation. The view mode lives beside it, so switching presentation
78
+ // never touches query or selection state.
79
+ // The seed is intentionally the initial `type`; the effect below keeps the
80
+ // locked filter in sync afterwards.
81
+ const controller = createContentListController({
82
+ type: untrack(() => type),
83
+ });
84
+ let snapshot = $state(controller.snapshot());
85
+ let viewMode: ContentListViewMode = $state(untrack(() => defaultViewMode));
86
+ let pendingDelete = $state<ContentListRow | null>(null);
87
+
88
+ $effect(() =>
89
+ controller.subscribe((transition) => {
90
+ snapshot = transition.next;
91
+ }),
92
+ );
93
+
94
+ const tableState = $derived(snapshot.state);
95
+
96
+ /** The normalized type the `type` prop locks the list to, if any. */
97
+ const lockedType = $derived(type?.trim() ? normalizeContentType(type) : null);
98
+
99
+ // A `type` prop locks the type filter, exactly as the legacy select did. The
100
+ // lock is enforced against the live state, not only against the prop, because a
101
+ // data-surface `set-filters` or `reset` command can otherwise replace or clear
102
+ // it. The equality guard keeps the effect from dispatching in a loop.
103
+ $effect(() => {
104
+ const locked = lockedType;
105
+ if (locked === null) {
106
+ // Unlocked: the toolbar select owns the filter. Only reading `type` here
107
+ // keeps the legacy behaviour of clearing it when the prop is removed.
108
+ untrack(() =>
109
+ applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, null),
110
+ );
111
+ return;
112
+ }
113
+ if (
114
+ isContentListFilterExactly(tableState, CONTENT_LIST_TYPE_FILTER_ID, locked)
115
+ )
116
+ return;
117
+ untrack(() =>
118
+ applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, locked),
119
+ );
120
+ });
121
+
122
+ const columnLabels = $derived({
123
+ type: t(M['content.content_list.column_type']),
124
+ title: t(M['content.content_list.column_title']),
125
+ author: t(M['content.content_list.column_author']),
126
+ status: t(M['content.content_list.column_status']),
127
+ state: t(M['content.content_list.column_state']),
128
+ publish: t(M['content.content_list.column_publish']),
129
+ updated: t(M['content.content_list.column_updated']),
130
+ site: t(M['content.content_list.column_site']),
131
+ });
132
+
133
+ const queryColumns = $derived(buildContentListColumns(columnLabels));
134
+ const rows = $derived(toContentListRows(contents));
135
+ const queryRows = $derived(
136
+ selectContentListRows(rows, tableState, queryColumns),
43
137
  );
138
+ const pageRows = $derived(paginateContentListRows(queryRows, tableState));
44
139
 
140
+ // The adapter owns filtering, sorting, and paging, so the controller's page has
141
+ // to be clamped against the adapter's result count rather than DataTable's.
45
142
  $effect(() => {
46
- selectedType = type || 'All Types';
143
+ const totalRows = queryRows.length;
144
+ untrack(() => controller.clampPage(totalRows));
47
145
  });
48
146
 
49
- function getTextValue(value: unknown): string {
50
- return typeof value === 'string' ? value : '';
147
+ // The card presentations have to render their own page controls: DataTable
148
+ // and with it the page navigation is only mounted in compact mode, so a page
149
+ // size set from a saved view or a surface command would otherwise strand the
150
+ // operator on page one.
151
+ const totalPages = $derived(
152
+ tableState.pageSize
153
+ ? Math.max(1, Math.ceil(queryRows.length / tableState.pageSize))
154
+ : 1,
155
+ );
156
+ const showPagination = $derived(Boolean(tableState.pageSize) && totalPages > 1);
157
+ /** Rows are already rendered, so a load is a refresh rather than a first fill. */
158
+ const refreshing = $derived(loading && pageRows.length > 0);
159
+
160
+ const selectedRowKeys = $derived(
161
+ new Set(tableState.selectedRowIds.map((rowId) => String(rowId))),
162
+ );
163
+ // Only durable rows may be addressed by a selection.
164
+ const identifiedRowKeys = $derived(
165
+ new Set(selectableContentListRowIds(rows).map((rowId) => String(rowId))),
166
+ );
167
+ const selectablePageRowIds = $derived(selectableContentListRowIds(pageRows));
168
+ const allPageSelected = $derived(
169
+ selectablePageRowIds.length > 0 &&
170
+ selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId))),
171
+ );
172
+ const somePageSelected = $derived(
173
+ !allPageSelected &&
174
+ selectablePageRowIds.some((rowId) => selectedRowKeys.has(String(rowId))),
175
+ );
176
+ const selectedCount = $derived(tableState.selectedRowIds.length);
177
+
178
+ // DataTable's own selection column and data-surface commands can both introduce
179
+ // ids for rows that carry no durable identity. Normalizing here covers every
180
+ // path at once; re-dispatching only on a real difference keeps it settling.
181
+ $effect(() => {
182
+ const selected = tableState.selectedRowIds;
183
+ const durable = selected.filter((rowId) =>
184
+ identifiedRowKeys.has(String(rowId)),
185
+ );
186
+ if (durable.length === selected.length) return;
187
+ untrack(() =>
188
+ controller.dispatch({ type: 'setSelectedRows', rowIds: durable }),
189
+ );
190
+ });
191
+
192
+ const selectedType = $derived(
193
+ readContentListFilter(tableState, CONTENT_LIST_TYPE_FILTER_ID) ?? '',
194
+ );
195
+ const selectedStatus = $derived(
196
+ readContentListFilter(tableState, CONTENT_LIST_STATUS_FILTER_ID) ?? '',
197
+ );
198
+
199
+ const surfaceOptions = $derived(
200
+ dataSurface
201
+ ? {
202
+ registry: dataSurface.registry,
203
+ descriptor:
204
+ dataSurface.descriptor ??
205
+ buildContentListSurfaceDescriptor({ columnLabels }),
206
+ }
207
+ : undefined,
208
+ );
209
+
210
+ function isSelected(row: ContentListRow): boolean {
211
+ return selectedRowKeys.has(String(row.id));
51
212
  }
52
213
 
53
- function getDisplayTitle(content: ContentData): string {
54
- return getTextValue(content.title) || 'Untitled content';
214
+ function toggleRow(row: ContentListRow) {
215
+ if (!row.identified) return;
216
+ controller.dispatch({ type: 'toggleRowSelection', rowId: row.id });
55
217
  }
56
218
 
57
- function getDisplayDescription(content: ContentData): string {
58
- return getTextValue(content.description);
219
+ function togglePageSelection() {
220
+ const remaining = tableState.selectedRowIds.filter(
221
+ (rowId) =>
222
+ !selectablePageRowIds.some(
223
+ (pageRowId) => String(pageRowId) === String(rowId),
224
+ ),
225
+ );
226
+ controller.dispatch({
227
+ type: 'setSelectedRows',
228
+ rowIds: allPageSelected
229
+ ? remaining
230
+ : [...remaining, ...selectablePageRowIds],
231
+ });
59
232
  }
60
233
 
61
- function getDisplayAuthor(content: ContentData): string {
62
- return getTextValue(content.author);
234
+ function clearSelection() {
235
+ controller.dispatch({ type: 'setSelectedRows', rowIds: [] });
63
236
  }
64
237
 
65
- function getNormalizedType(value: unknown): string {
66
- return getTextValue(value).toLowerCase() || 'content';
238
+ function handlePageChange(page: number) {
239
+ controller.dispatch({ type: 'setPage', page });
67
240
  }
68
241
 
69
- const filteredContents = $derived(
70
- contents.filter((content: ContentData) => {
71
- const title = getDisplayTitle(content);
72
- const description = getDisplayDescription(content);
73
- const author = getDisplayAuthor(content);
74
-
75
- const matchesSearch =
76
- searchTerm === '' ||
77
- title.toLowerCase().includes(searchTerm.toLowerCase()) ||
78
- description.toLowerCase().includes(searchTerm.toLowerCase()) ||
79
- author.toLowerCase().includes(searchTerm.toLowerCase());
80
-
81
- const isLockedType = !!type;
82
- const matchesType = isLockedType
83
- ? content.type === type
84
- : selectedType === 'All Types' ||
85
- (selectedType === 'Articles' && content.type === 'article') ||
86
- (selectedType === 'Documents' && content.type === 'document') ||
87
- (selectedType === 'Mirrors' && content.type === 'mirror');
88
-
89
- const matchesStatus =
90
- selectedStatus === 'All Statuses' ||
91
- content.status.toLowerCase() === selectedStatus.toLowerCase();
92
-
93
- return matchesSearch && matchesType && matchesStatus;
94
- }),
95
- );
242
+ function handleSearch(value: string) {
243
+ controller.dispatch({ type: 'setSearch', search: value });
244
+ }
96
245
 
97
- function getTypeLabel(value: unknown) {
98
- switch (getNormalizedType(value)) {
99
- case 'article':
100
- return 'Article';
101
- case 'mirror':
102
- return 'Mirror';
103
- case 'document':
104
- return 'Document';
105
- default:
106
- return 'Content';
107
- }
246
+ function handleFilter(columnId: string, value: string) {
247
+ applyContentListFilter(controller, columnId, value || null);
108
248
  }
109
249
 
110
- function getStatusBadge(value: unknown) {
111
- switch (getTextValue(value).toLowerCase()) {
112
- case 'published':
113
- return 'published';
114
- case 'draft':
115
- return 'draft';
116
- case 'archived':
117
- return 'archived';
118
- default:
119
- return 'unknown';
120
- }
250
+ function rowActions(row: ContentListRow) {
251
+ return contentListRowActions(row, { getViewHref });
121
252
  }
122
253
 
123
- function getStateBadge(value: unknown) {
124
- switch (getTextValue(value).toLowerCase()) {
125
- case 'highlighted':
126
- return 'highlighted';
127
- case 'active':
128
- return 'active';
129
- case 'deprecated':
130
- return 'deprecated';
131
- default:
132
- return 'unknown';
133
- }
254
+ function viewHref(row: ContentListRow): string | null {
255
+ return resolveContentHref(row.content, getViewHref);
134
256
  }
135
257
 
136
- let pendingDelete = $state<ContentData | null>(null);
258
+ function selectRowLabel(row: ContentListRow): string {
259
+ return isSelected(row)
260
+ ? t(M['content.content_list.deselect_row'], { title: row.title })
261
+ : t(M['content.content_list.select_row'], { title: row.title });
262
+ }
137
263
 
138
- function handleDeleteContent(content: ContentData) {
139
- pendingDelete = content;
264
+ function handleDeleteContent(row: ContentListRow) {
265
+ pendingDelete = row;
140
266
  }
141
267
 
142
268
  function confirmDelete() {
143
269
  const target = pendingDelete;
144
270
  pendingDelete = null;
145
271
  if (target) {
146
- onDelete(target);
272
+ onDelete(target.content);
147
273
  }
148
274
  }
149
275
 
150
276
  function cancelDelete() {
151
277
  pendingDelete = null;
152
278
  }
279
+
280
+ /**
281
+ * Compact mode renders the shared columns with content-specific cells.
282
+ *
283
+ * Selection is a content-owned column rather than DataTable's built-in one:
284
+ * DataTable has no per-row selection predicate, so its header select-all would
285
+ * address the synthetic id of an unidentified row, which the normalization
286
+ * effect then strips — leaving the header permanently indeterminate. Owning the
287
+ * column keeps compact select-all identical to the card presentations.
288
+ */
289
+ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
290
+ {
291
+ id: CONTENT_LIST_SELECTION_COLUMN_ID,
292
+ label: t(M['content.content_list.select_all']),
293
+ role: 'action',
294
+ align: 'center',
295
+ width: '3rem',
296
+ sortable: false,
297
+ searchable: false,
298
+ filterable: false,
299
+ header: selectHeader,
300
+ cell: selectCell,
301
+ },
302
+ ...queryColumns.map((column) => {
303
+ if (column.id === 'type') return { ...column, cell: typeCell };
304
+ if (column.id === 'title') return { ...column, cell: titleCell };
305
+ if (column.id === 'status') return { ...column, cell: statusCell };
306
+ if (column.id === 'state') return { ...column, cell: stateCell };
307
+ if (column.id === 'publish') return { ...column, cell: publishCell };
308
+ if (column.id === 'updated') return { ...column, cell: updatedCell };
309
+ return column;
310
+ }),
311
+ {
312
+ id: CONTENT_LIST_ACTIONS_COLUMN_ID,
313
+ label: t(M['content.content_list.actions_column']),
314
+ role: 'action',
315
+ align: 'right',
316
+ sortable: false,
317
+ searchable: false,
318
+ filterable: false,
319
+ cell: actionsCell,
320
+ },
321
+ ]);
153
322
  </script>
154
323
 
324
+ {#snippet tableEmptyState()}
325
+ <p class="table-empty-state">{t(M['content.content_list.empty'])}</p>
326
+ {/snippet}
327
+
328
+ {#snippet selectHeader()}
329
+ <Checkbox
330
+ checked={allPageSelected}
331
+ indeterminate={somePageSelected}
332
+ aria-label={t(M['content.content_list.select_all'])}
333
+ onchange={togglePageSelection}
334
+ />
335
+ {/snippet}
336
+
337
+ {#snippet selectCell({ row }: { row: ContentListRow })}
338
+ <Checkbox
339
+ checked={isSelected(row)}
340
+ disabled={!row.identified}
341
+ aria-label={selectRowLabel(row)}
342
+ title={row.identified
343
+ ? undefined
344
+ : t(M['content.content_list.row_not_selectable'])}
345
+ onchange={() => toggleRow(row)}
346
+ />
347
+ {/snippet}
348
+
349
+ {#snippet typeCell({ row }: { row: ContentListRow })}
350
+ <span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
351
+ {/snippet}
352
+
353
+ {#snippet titleCell({ row }: { row: ContentListRow })}
354
+ {#if viewHref(row)}
355
+ <a class="title-link" href={viewHref(row)}>{row.title}</a>
356
+ {:else}
357
+ <strong>{row.title}</strong>
358
+ {/if}
359
+ {/snippet}
360
+
361
+ {#snippet statusCell({ row }: { row: ContentListRow })}
362
+ <span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
363
+ {/snippet}
364
+
365
+ {#snippet stateCell({ row }: { row: ContentListRow })}
366
+ <span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
367
+ {/snippet}
368
+
369
+ {#snippet publishCell({ row }: { row: ContentListRow })}
370
+ {row.publishLabel || '-'}
371
+ {/snippet}
372
+
373
+ {#snippet updatedCell({ row }: { row: ContentListRow })}
374
+ {row.updatedLabel || '-'}
375
+ {/snippet}
376
+
377
+ {#snippet actionsCell({ row }: { row: ContentListRow })}
378
+ {@const actions = rowActions(row)}
379
+ <div class="actions-cell">
380
+ {#if actions.includes('view')}
381
+ <a
382
+ class="icon-btn"
383
+ href={viewHref(row)}
384
+ title={t(M['content.content_list.view_published_article'])}
385
+ aria-label={t(M['content.content_list.view_published_article'])}
386
+ >
387
+ <span aria-hidden="true">🔎</span>
388
+ </a>
389
+ {/if}
390
+ {#if actions.includes('edit')}
391
+ <Button
392
+ variant="ghost"
393
+ size="sm"
394
+ class="icon-btn"
395
+ type="button"
396
+ onclick={() => onEdit(row.content)}
397
+ title={t(M['content.content_list.edit'])}
398
+ aria-label={t(M['content.content_list.edit'])}
399
+ >
400
+ <span aria-hidden="true">✏️</span>
401
+ </Button>
402
+ {/if}
403
+ {#if actions.includes('delete')}
404
+ <Button
405
+ variant="ghost"
406
+ size="sm"
407
+ class="icon-btn delete-icon"
408
+ type="button"
409
+ onclick={() => handleDeleteContent(row)}
410
+ title={t(M['content.content_list.delete'])}
411
+ aria-label={t(M['content.content_list.delete'])}
412
+ >
413
+ <span aria-hidden="true">🗑️</span>
414
+ </Button>
415
+ {/if}
416
+ </div>
417
+ {/snippet}
418
+
155
419
  <div class="content-list-wrapper">
156
-
420
+
157
421
  <div class="content-controls">
158
422
  <div class="search-filters">
159
- <Input type="text" placeholder={t(M['content.content_list.search_placeholder'])} bind:value={searchTerm} />
160
-
161
- {#if !type}
162
- <Select bind:value={selectedType}>
163
- <option value="All Types">{t(M['content.content_list.all_types'])}</option>
164
- <option value="Articles">Articles</option>
165
- <option value="Documents">Documents</option>
166
- <option value="Mirrors">Mirrors</option>
423
+ <Input
424
+ type="text"
425
+ placeholder={t(M['content.content_list.search_placeholder'])}
426
+ aria-label={t(M['content.content_list.search_label'])}
427
+ value={tableState.search}
428
+ oninput={(event: Event) =>
429
+ handleSearch((event.currentTarget as HTMLInputElement).value)}
430
+ />
431
+
432
+ {#if !lockedType}
433
+ <Select
434
+ aria-label={t(M['content.content_list.filter_type'])}
435
+ value={selectedType}
436
+ onchange={(event: Event) =>
437
+ handleFilter(
438
+ CONTENT_LIST_TYPE_FILTER_ID,
439
+ (event.currentTarget as HTMLSelectElement).value,
440
+ )}
441
+ >
442
+ <option value="">{t(M['content.content_list.all_types'])}</option>
443
+ <option value="article">{t(M['content.content_list.type_articles'])}</option>
444
+ <option value="document">{t(M['content.content_list.type_documents'])}</option>
445
+ <option value="mirror">{t(M['content.content_list.type_mirrors'])}</option>
167
446
  </Select>
168
447
  {/if}
169
448
 
170
- <Select bind:value={selectedStatus}>
171
- <option value="All Statuses">{t(M['content.content_list.all_statuses'])}</option>
172
- <option value="Published">Published</option>
173
- <option value="Draft">Draft</option>
174
- <option value="Archived">Archived</option>
449
+ <Select
450
+ aria-label={t(M['content.content_list.filter_status'])}
451
+ value={selectedStatus}
452
+ onchange={(event: Event) =>
453
+ handleFilter(
454
+ CONTENT_LIST_STATUS_FILTER_ID,
455
+ (event.currentTarget as HTMLSelectElement).value,
456
+ )}
457
+ >
458
+ <option value="">{t(M['content.content_list.all_statuses'])}</option>
459
+ <option value="published">{t(M['content.content_list.status_published'])}</option>
460
+ <option value="draft">{t(M['content.content_list.status_draft'])}</option>
461
+ <option value="archived">{t(M['content.content_list.status_archived'])}</option>
175
462
  </Select>
176
-
463
+
177
464
  {#if controls}
178
465
  {@render controls()}
179
466
  {/if}
180
467
  </div>
181
-
468
+
182
469
  <div class="actions-group">
183
- <div class="view-toggles">
470
+ <div class="view-toggles" role="group" aria-label={t(M['content.content_list.view_mode'])}>
184
471
  <Button
185
472
  variant="ghost"
186
473
  size="sm"
@@ -191,7 +478,7 @@ function cancelDelete() {
191
478
  aria-label={t(M['content.content_list.grid_view'])}
192
479
  title={t(M['content.content_list.grid_view'])}
193
480
  >
194
- <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
481
+ <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
195
482
  <rect x="3" y="3" width="7" height="7"></rect>
196
483
  <rect x="14" y="3" width="7" height="7"></rect>
197
484
  <rect x="14" y="14" width="7" height="7"></rect>
@@ -208,7 +495,7 @@ function cancelDelete() {
208
495
  aria-label={t(M['content.content_list.detailed_list'])}
209
496
  title={t(M['content.content_list.detailed_list'])}
210
497
  >
211
- <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
498
+ <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
212
499
  <line x1="8" y1="6" x2="21" y2="6"></line>
213
500
  <line x1="8" y1="12" x2="21" y2="12"></line>
214
501
  <line x1="8" y1="18" x2="21" y2="18"></line>
@@ -227,7 +514,7 @@ function cancelDelete() {
227
514
  aria-label={t(M['content.content_list.compact_list'])}
228
515
  title={t(M['content.content_list.compact_list'])}
229
516
  >
230
- <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
517
+ <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
231
518
  <line x1="3" y1="6" x2="21" y2="6"></line>
232
519
  <line x1="3" y1="12" x2="21" y2="12"></line>
233
520
  <line x1="3" y1="18" x2="21" y2="18"></line>
@@ -236,7 +523,7 @@ function cancelDelete() {
236
523
  </div>
237
524
 
238
525
  <Button variant="ghost" class="add-button" type="button" onclick={() => onAdd()}>
239
- <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
526
+ <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
240
527
  <line x1="12" y1="5" x2="12" y2="19"></line>
241
528
  <line x1="5" y1="12" x2="19" y2="12"></line>
242
529
  </svg>
@@ -245,160 +532,235 @@ function cancelDelete() {
245
532
  </div>
246
533
  </div>
247
534
 
248
- {#if filteredContents.length === 0}
249
- <div class="empty-state">
250
- {t(M['content.content_list.empty'])}
535
+ {#if error}
536
+ <div class="state-panel state-panel--error" role="alert">
537
+ <p class="state-panel__title">{t(M['content.content_list.error_title'])}</p>
538
+ <p class="state-panel__detail">{error}</p>
539
+ {#if onRetry}
540
+ <Button variant="ghost" type="button" class="retry-button" onclick={() => onRetry?.()}>
541
+ {t(M['content.content_list.retry'])}
542
+ </Button>
543
+ {/if}
251
544
  </div>
252
- {:else if viewMode === 'compact'}
253
- <div class="content-table-wrapper">
254
- <table class="content-table">
255
- <thead>
256
- <tr>
257
- <th>Type</th>
258
- <th>Title</th>
259
- <th>Author</th>
260
- <th>Status</th>
261
- <th>State</th>
262
- <th class="actions-col">Actions</th>
263
- </tr>
264
- </thead>
265
- <tbody>
266
- {#each filteredContents as content (content.id)}
267
- <tr>
268
- <td class="type-cell">
269
- <span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
270
- {getTypeLabel(content.type)}
271
- </span>
272
- </td>
273
- <td class="title-cell"><strong>{getDisplayTitle(content)}</strong></td>
274
- <td>{getDisplayAuthor(content) || '-'}</td>
275
- <td><span class="badge status-{getStatusBadge(content.status)}">{content.status}</span></td>
276
- <td><span class="badge state-{getStateBadge(content.state)}">{content.state}</span></td>
277
- <td class="actions-cell">
278
- {#if getViewHref?.(content)}
279
- <a class="icon-btn" href={getViewHref(content) || '#'} title={t(M['content.content_list.view_published_article'])} aria-label={t(M['content.content_list.view_published_article'])}>🔎</a>
545
+ {:else}
546
+ {#if pageRows.length > 0 || selectedCount > 0}
547
+ <div class="content-selection">
548
+ {#if viewMode !== 'compact'}
549
+ <Checkbox
550
+ checked={allPageSelected}
551
+ indeterminate={somePageSelected}
552
+ aria-label={t(M['content.content_list.select_all'])}
553
+ onchange={togglePageSelection}
554
+ />
555
+ {/if}
556
+ <span class="content-selection__count" aria-live="polite">
557
+ {t(M['content.content_list.selection_count'], { count: selectedCount })}
558
+ </span>
559
+ {#if selectedCount > 0}
560
+ <Button variant="ghost" size="sm" type="button" class="clear-selection" onclick={clearSelection}>
561
+ {t(M['content.content_list.clear_selection'])}
562
+ </Button>
563
+ {/if}
564
+ </div>
565
+ {/if}
566
+
567
+ {#if refreshing && viewMode !== 'compact'}
568
+ <!-- DataTable announces its own refresh; the card views need their own. -->
569
+ <p class="content-refreshing" role="status" aria-live="polite">
570
+ {t(M['content.content_list.refreshing'])}
571
+ </p>
572
+ {/if}
573
+
574
+ {#if viewMode === 'compact'}
575
+ <!--
576
+ The compact table stays mounted for empty and loading results: it owns
577
+ the mounted data surface, so unmounting it on a zero-row query would
578
+ unregister the surface and leave an agent unable to undo its own search.
579
+ -->
580
+ <div class="content-table-wrapper">
581
+ <DataTable
582
+ data={pageRows}
583
+ totalRows={queryRows.length}
584
+ columns={tableColumns}
585
+ rowKey={CONTENT_LIST_ROW_KEY}
586
+ {controller}
587
+ sortable
588
+ agentAddressable
589
+ {loading}
590
+ caption={t(M['content.content_list.table_caption'])}
591
+ rowLabel={(row: ContentListRow) => row.title}
592
+ dataSurface={surfaceOptions}
593
+ empty={tableEmptyState}
594
+ />
595
+ </div>
596
+ {:else if loading && pageRows.length === 0}
597
+ <div class="state-panel" role="status">
598
+ {t(M['content.content_list.loading'])}
599
+ </div>
600
+ {:else if pageRows.length === 0}
601
+ <div class="state-panel empty-state">
602
+ {t(M['content.content_list.empty'])}
603
+ </div>
604
+ {:else if viewMode === 'detailed'}
605
+ <div class="content-detailed">
606
+ {#each pageRows as row (row.id)}
607
+ {@const content = row.content}
608
+ {@const actions = rowActions(row)}
609
+ <article class="content-row">
610
+ <div class="content-row__select">
611
+ <Checkbox
612
+ checked={isSelected(row)}
613
+ disabled={!row.identified}
614
+ aria-label={selectRowLabel(row)}
615
+ title={row.identified
616
+ ? undefined
617
+ : t(M['content.content_list.row_not_selectable'])}
618
+ onchange={() => toggleRow(row)}
619
+ />
620
+ </div>
621
+
622
+ <div class="content-row__main">
623
+ <div class="content-row__eyebrow">
624
+ <span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
625
+ {#if row.author}
626
+ <span class="content-row__author">By {row.author}</span>
280
627
  {/if}
281
- <Button variant="ghost" size="sm" class="icon-btn" type="button" onclick={() => onEdit(content)} title={t(M['content.content_list.edit'])} aria-label={t(M['content.content_list.edit'])}>✏️</Button>
282
- <Button variant="ghost" size="sm" class="icon-btn delete-icon" type="button" onclick={() => handleDeleteContent(content)} title={t(M['content.content_list.delete'])} aria-label={t(M['content.content_list.delete'])}>🗑️</Button>
283
- </td>
284
- </tr>
285
- {/each}
286
- </tbody>
287
- </table>
288
- </div>
289
- {:else if viewMode === 'detailed'}
290
- <div class="content-detailed">
291
- {#each filteredContents as content (content.id)}
292
- <article class="content-row">
293
- <div class="content-row__main">
294
- <div class="content-row__eyebrow">
295
- <span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
296
- {getTypeLabel(content.type)}
297
- </span>
298
- {#if getDisplayAuthor(content)}
299
- <span class="content-row__author">By {getDisplayAuthor(content)}</span>
628
+ </div>
629
+
630
+ <h3>{row.title}</h3>
631
+
632
+ {#if row.description}
633
+ <p class="content-row__description">{row.description}</p>
634
+ {/if}
635
+
636
+ {#if content.url || content.fileKey}
637
+ <div class="content-row__links">
638
+ {#if content.url}
639
+ <a href={content.url} target="_blank" rel="noreferrer">
640
+ {t(M['content.content_list.source_material'])}
641
+ </a>
642
+ {/if}
643
+ {#if content.fileKey}
644
+ <span>{content.fileKey}</span>
645
+ {/if}
646
+ </div>
300
647
  {/if}
301
648
  </div>
302
649
 
303
- <h3>{getDisplayTitle(content)}</h3>
650
+ <div class="content-row__meta">
651
+ <span class="meta-label">{t(M['content.content_list.column_status'])}</span>
652
+ <span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
653
+ <span class="meta-label">{t(M['content.content_list.column_state'])}</span>
654
+ <span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
655
+ </div>
304
656
 
305
- {#if getDisplayDescription(content)}
306
- <p class="content-row__description">{getDisplayDescription(content)}</p>
657
+ <div class="content-row__actions">
658
+ {#if actions.includes('view')}
659
+ <a href={viewHref(row)} class="quiet-action">{t(M['content.content_list.view_article'])}</a>
660
+ {/if}
661
+ {#if actions.includes('edit')}
662
+ <Button variant="ghost" type="button" class="quiet-action" onclick={() => onEdit(content)}>
663
+ {t(M['content.content_list.edit'])}
664
+ </Button>
665
+ {/if}
666
+ {#if actions.includes('delete')}
667
+ <Button
668
+ variant="ghost"
669
+ type="button"
670
+ class="quiet-action quiet-action--danger"
671
+ onclick={() => handleDeleteContent(row)}
672
+ >
673
+ {t(M['content.content_list.delete'])}
674
+ </Button>
675
+ {/if}
676
+ </div>
677
+ </article>
678
+ {/each}
679
+ </div>
680
+ {:else}
681
+ <div class="content-grid">
682
+ {#each pageRows as row (row.id)}
683
+ {@const content = row.content}
684
+ {@const actions = rowActions(row)}
685
+ <div class="content-card">
686
+ {#if content.thumbnailAssetId}
687
+ <div class="card-thumbnail">
688
+ <ImageThumbnail
689
+ apiBaseUrl={apiBaseUrl}
690
+ assetId={content.thumbnailAssetId}
691
+ />
692
+ </div>
307
693
  {/if}
694
+ <div class="content-header">
695
+ <div class="content-header__eyebrow">
696
+ <Checkbox
697
+ checked={isSelected(row)}
698
+ disabled={!row.identified}
699
+ aria-label={selectRowLabel(row)}
700
+ title={row.identified
701
+ ? undefined
702
+ : t(M['content.content_list.row_not_selectable'])}
703
+ onchange={() => toggleRow(row)}
704
+ />
705
+ <span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
706
+ {#if row.author}
707
+ <div class="author">{row.author}</div>
708
+ {/if}
709
+ </div>
710
+ <h3>{row.title}</h3>
711
+ </div>
308
712
 
309
- {#if content.url || content.fileKey}
310
- <div class="content-row__links">
713
+ <div class="content-meta">
714
+ <div>{row.typeLabel}</div>
715
+ <div class="badges">
716
+ <span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
717
+ <span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
718
+ </div>
719
+ </div>
720
+
721
+ <p class="content-description">{row.description}</p>
722
+
723
+ <div class="content-footer">
724
+ <div class="meta-links">
311
725
  {#if content.url}
312
- <a href={content.url} target="_blank" rel="noreferrer">
313
- {t(M['content.content_list.source_material'])}
314
- </a>
726
+ <div class="source">Source: <a href={content.url} target="_blank" rel="noreferrer">{content.url}</a></div>
315
727
  {/if}
316
728
  {#if content.fileKey}
317
- <span>{content.fileKey}</span>
729
+ <div class="file">File: {content.fileKey}</div>
318
730
  {/if}
319
731
  </div>
320
- {/if}
321
- </div>
322
-
323
- <div class="content-row__meta">
324
- <span class="meta-label">Status</span>
325
- <span class="badge status-{getStatusBadge(content.status)}">{content.status}</span>
326
- <span class="meta-label">State</span>
327
- <span class="badge state-{getStateBadge(content.state)}">{content.state}</span>
328
- </div>
329
732
 
330
- <div class="content-row__actions">
331
- {#if getViewHref?.(content)}
332
- <a href={getViewHref(content) || '#'} class="quiet-action">{t(M['content.content_list.view_article'])}</a>
333
- {/if}
334
- <Button variant="ghost" type="button" class="quiet-action" onclick={() => onEdit(content)}>{t(M['content.content_list.edit'])}</Button>
335
- <Button
336
- variant="ghost"
337
- type="button"
338
- class="quiet-action quiet-action--danger"
339
- onclick={() => handleDeleteContent(content)}
340
- >
341
- {t(M['content.content_list.delete'])}
342
- </Button>
343
- </div>
344
- </article>
345
- {/each}
346
- </div>
347
- {:else}
348
- <div class="content-{viewMode}">
349
- {#each filteredContents as content (content.id)}
350
- <div class="content-card">
351
- {#if content.thumbnailAssetId}
352
- <div class="card-thumbnail">
353
- <ImageThumbnail
354
- apiBaseUrl={apiBaseUrl}
355
- assetId={content.thumbnailAssetId}
356
- />
357
- </div>
358
- {/if}
359
- <div class="content-header">
360
- <div class="content-header__eyebrow">
361
- <span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
362
- {getTypeLabel(content.type)}
363
- </span>
364
- {#if getDisplayAuthor(content)}
365
- <div class="author">{getDisplayAuthor(content)}</div>
366
- {/if}
367
- </div>
368
- <h3>{getDisplayTitle(content)}</h3>
369
- </div>
370
-
371
- <div class="content-meta">
372
- <div>{getTypeLabel(content.type)}</div>
373
- <div class="badges">
374
- <span class="badge status-{getStatusBadge(content.status)}">{content.status}</span>
375
- <span class="badge state-{getStateBadge(content.state)}">{content.state}</span>
376
- </div>
377
- </div>
378
-
379
- <p class="content-description">{getDisplayDescription(content)}</p>
380
-
381
- <div class="content-footer">
382
- <div class="meta-links">
383
- {#if content.url}
384
- <div class="source">Source: <a href={content.url} target="_blank">{content.url}</a></div>
385
- {/if}
386
- {#if content.fileKey}
387
- <div class="file">File: {content.fileKey}</div>
388
- {/if}
389
- </div>
390
-
391
- <div class="content-actions">
392
- {#if getViewHref?.(content)}
393
- <a href={getViewHref(content) || '#'} class="view-btn">{t(M['content.content_list.view_article_button'])}</a>
394
- {/if}
395
- <Button variant="ghost" type="button" class="content-action-btn" onclick={() => onEdit(content)}>{t(M['content.content_list.edit'])}</Button>
396
- <Button variant="ghost" type="button" class="content-action-btn delete-btn" onclick={() => handleDeleteContent(content)}>{t(M['content.content_list.delete'])}</Button>
733
+ <div class="content-actions">
734
+ {#if actions.includes('view')}
735
+ <a href={viewHref(row)} class="view-btn">{t(M['content.content_list.view_article_button'])}</a>
736
+ {/if}
737
+ {#if actions.includes('edit')}
738
+ <Button variant="ghost" type="button" class="content-action-btn" onclick={() => onEdit(content)}>
739
+ {t(M['content.content_list.edit'])}
740
+ </Button>
741
+ {/if}
742
+ {#if actions.includes('delete')}
743
+ <Button variant="ghost" type="button" class="content-action-btn delete-btn" onclick={() => handleDeleteContent(row)}>
744
+ {t(M['content.content_list.delete'])}
745
+ </Button>
746
+ {/if}
747
+ </div>
397
748
  </div>
398
749
  </div>
399
- </div>
400
- {/each}
401
- </div>
750
+ {/each}
751
+ </div>
752
+ {/if}
753
+
754
+ {#if showPagination && viewMode !== 'compact'}
755
+ <div class="content-pagination">
756
+ <Pagination
757
+ currentPage={tableState.page}
758
+ {totalPages}
759
+ onPageChange={handlePageChange}
760
+ aria-label={t(M['content.content_list.pagination'])}
761
+ />
762
+ </div>
763
+ {/if}
402
764
  {/if}
403
765
 
404
766
  </div>
@@ -407,7 +769,7 @@ function cancelDelete() {
407
769
  open={pendingDelete !== null}
408
770
  title={t(M['content.content_list.delete_confirm_title'])}
409
771
  message={t(M['content.content_list.delete_confirm_message'], {
410
- title: pendingDelete ? getDisplayTitle(pendingDelete) : '',
772
+ title: pendingDelete ? pendingDelete.title : '',
411
773
  })}
412
774
  confirmLabel={t(M['content.content_list.delete'])}
413
775
  cancelLabel={t(M['content.content_list.cancel'])}
@@ -516,6 +878,20 @@ function cancelDelete() {
516
878
  box-shadow: 0 4px 6px -1px color-mix(in srgb, var(--smrt-color-primary) 50%, transparent);
517
879
  }
518
880
 
881
+ /* Selection summary shared by every presentation. */
882
+ .content-selection {
883
+ display: flex;
884
+ align-items: center;
885
+ gap: 0.75rem;
886
+ padding: 0.35rem 0.1rem 0.75rem;
887
+ color: var(--smrt-color-on-surface-variant);
888
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
889
+ }
890
+
891
+ .content-selection :global(input[type='checkbox']) {
892
+ cursor: pointer;
893
+ }
894
+
519
895
  .content-header__eyebrow,
520
896
  .content-row__eyebrow {
521
897
  display: flex;
@@ -658,7 +1034,7 @@ function cancelDelete() {
658
1034
  color: var(--smrt-color-primary);
659
1035
  text-decoration: none;
660
1036
  }
661
-
1037
+
662
1038
  .source a:hover {
663
1039
  text-decoration: underline;
664
1040
  }
@@ -723,13 +1099,17 @@ function cancelDelete() {
723
1099
 
724
1100
  .content-row {
725
1101
  display: grid;
726
- grid-template-columns: minmax(0, 1.8fr) auto auto;
1102
+ grid-template-columns: auto minmax(0, 1.8fr) auto auto;
727
1103
  gap: 1.25rem;
728
1104
  align-items: start;
729
1105
  padding: 1.1rem 0;
730
1106
  border-bottom: 1px solid var(--smrt-color-outline-variant);
731
1107
  }
732
1108
 
1109
+ .content-row__select {
1110
+ padding-top: 0.25rem;
1111
+ }
1112
+
733
1113
  .content-row h3 {
734
1114
  margin: 0;
735
1115
  font-size: var(--smrt-typography-title-medium-size, 1.1rem);
@@ -816,53 +1196,39 @@ function cancelDelete() {
816
1196
  box-shadow: var(--smrt-elevation-1, 0 1px 3px rgba(0,0,0,0.05));
817
1197
  }
818
1198
 
819
- .content-table {
820
- width: 100%;
821
- border-collapse: collapse;
822
- text-align: left;
823
- }
824
-
825
- .content-table th {
826
- background: var(--smrt-color-surface-container-low);
827
- padding: 1rem;
828
- font-size: var(--smrt-typography-title-small-size, 0.875rem);
829
- font-weight: var(--smrt-typography-weight-semibold, 600);
1199
+ .content-refreshing {
1200
+ margin: 0 0 0.75rem;
830
1201
  color: var(--smrt-color-on-surface-variant);
831
- border-bottom: 1px solid var(--smrt-color-outline-variant);
832
- }
833
-
834
- .content-table td {
835
- padding: 1rem;
836
- border-bottom: 1px solid var(--smrt-color-outline-variant);
837
- color: var(--smrt-color-on-surface);
838
1202
  font-size: var(--smrt-typography-body-medium-size, 0.875rem);
839
- vertical-align: middle;
840
1203
  }
841
1204
 
842
- .content-table tr:last-child td {
843
- border-bottom: none;
844
- }
845
-
846
- .content-table tr:hover {
847
- background: var(--smrt-color-surface-container-low);
1205
+ .content-pagination {
1206
+ display: flex;
1207
+ justify-content: center;
1208
+ margin-top: 1.25rem;
848
1209
  }
849
1210
 
850
- .type-cell {
851
- white-space: nowrap;
1211
+ .table-empty-state {
1212
+ margin: 0;
1213
+ padding: 1.5rem 0;
1214
+ text-align: center;
1215
+ color: var(--smrt-color-on-surface-variant);
852
1216
  }
853
1217
 
854
- .title-cell strong {
855
- color: var(--smrt-color-on-surface);
1218
+ .title-link {
1219
+ color: var(--smrt-color-primary);
856
1220
  font-weight: var(--smrt-typography-weight-semibold, 600);
1221
+ text-decoration: none;
857
1222
  }
858
1223
 
859
- .actions-col {
860
- width: 100px;
861
- text-align: right;
1224
+ .title-link:hover {
1225
+ text-decoration: underline;
862
1226
  }
863
1227
 
864
1228
  .actions-cell {
865
- text-align: right;
1229
+ display: flex;
1230
+ justify-content: flex-end;
1231
+ gap: 0.15rem;
866
1232
  white-space: nowrap;
867
1233
  }
868
1234
 
@@ -875,6 +1241,7 @@ function cancelDelete() {
875
1241
  border-radius: 0.25rem;
876
1242
  transition: background 0.2s;
877
1243
  opacity: 0.7;
1244
+ text-decoration: none;
878
1245
  }
879
1246
 
880
1247
  .actions-cell :global(.icon-btn:hover) {
@@ -886,7 +1253,8 @@ function cancelDelete() {
886
1253
  background: var(--smrt-color-error-container);
887
1254
  }
888
1255
 
889
- .empty-state {
1256
+ /* Shared empty, loading, and error presentation. */
1257
+ .state-panel {
890
1258
  background: var(--smrt-color-surface);
891
1259
  padding: 4rem;
892
1260
  text-align: center;
@@ -896,13 +1264,33 @@ function cancelDelete() {
896
1264
  font-size: var(--smrt-typography-body-large-size, 1.1rem);
897
1265
  }
898
1266
 
899
- .source, .file {
1267
+ .state-panel--error {
1268
+ border-style: solid;
1269
+ border-color: var(--smrt-color-error);
1270
+ color: var(--smrt-color-on-surface);
1271
+ }
1272
+
1273
+ .state-panel__title {
1274
+ margin: 0 0 0.5rem;
1275
+ font-weight: var(--smrt-typography-weight-semibold, 600);
1276
+ }
1277
+
1278
+ .state-panel__detail {
1279
+ margin: 0 0 1rem;
900
1280
  color: var(--smrt-color-on-surface-variant);
1281
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
1282
+ }
1283
+
1284
+ .state-panel :global(.retry-button) {
1285
+ border: 1px solid var(--smrt-color-outline);
1286
+ border-radius: 0.5rem;
1287
+ padding: 0.5rem 1rem;
1288
+ cursor: pointer;
901
1289
  }
902
1290
 
903
1291
  @media (max-width: 960px) {
904
1292
  .content-row {
905
- grid-template-columns: minmax(0, 1fr);
1293
+ grid-template-columns: auto minmax(0, 1fr);
906
1294
  gap: 0.9rem;
907
1295
  }
908
1296