@happyvertical/smrt-content 0.43.4 → 0.43.6

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 (34) hide show
  1. package/AGENTS.md +9 -86
  2. package/agents/content-list.md +869 -0
  3. package/dist/content-query.d.ts +310 -0
  4. package/dist/content-query.d.ts.map +1 -0
  5. package/dist/contents.d.ts +22 -0
  6. package/dist/contents.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +672 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/manifest.json +22 -2
  12. package/dist/smrt-knowledge.json +38 -5
  13. package/dist/svelte/components/ContentList.svelte +941 -30
  14. package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +98 -1
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  18. package/dist/svelte/content-list-controller.js +290 -19
  19. package/dist/svelte/content-list-query.d.ts +498 -0
  20. package/dist/svelte/content-list-query.d.ts.map +1 -0
  21. package/dist/svelte/content-list-query.js +1294 -0
  22. package/dist/svelte/content-list-saved-views.d.ts +172 -0
  23. package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
  24. package/dist/svelte/content-list-saved-views.js +298 -0
  25. package/dist/svelte/content-list-url-state.d.ts +211 -0
  26. package/dist/svelte/content-list-url-state.d.ts.map +1 -0
  27. package/dist/svelte/content-list-url-state.js +856 -0
  28. package/dist/svelte/i18n.contribution.d.ts +24 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +26 -0
  31. package/dist/svelte/index.d.ts +5 -1
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +8 -1
  34. package/package.json +16 -15
@@ -1,5 +1,41 @@
1
+ <script module lang="ts">
2
+ import type { DataTableViewState as ContentListDataTableViewState } from '@happyvertical/smrt-ui/data';
3
+ import type { ContentListUrlStateOptions } from '../content-list-url-state.js';
4
+
5
+ /**
6
+ * Optional, router-agnostic URL state (#2452).
7
+ *
8
+ * `ContentList` never imports a router: it reads `params` once during
9
+ * initialization and hands the merged parameters back through `onChange`, so a
10
+ * SvelteKit host calls `replaceState`, a hash router rewrites the fragment, and
11
+ * a test passes a plain `URLSearchParams`.
12
+ */
13
+ export interface ContentListUrlStateBinding {
14
+ /**
15
+ * Query parameters to restore the view from. Read once, at initialization —
16
+ * later navigation is the host's to drive (re-key the component to re-read).
17
+ */
18
+ params?: URLSearchParams | string | null;
19
+ /**
20
+ * Receives the full merged parameter set — every foreign parameter
21
+ * preserved — whenever the query-affecting state changes. Never called for
22
+ * the initial restore.
23
+ */
24
+ onChange?: (
25
+ params: URLSearchParams,
26
+ state: ContentListDataTableViewState,
27
+ ) => void;
28
+ /** Prefix and default page size, forwarded to the URL-state module. */
29
+ options?: ContentListUrlStateOptions;
30
+ }
31
+ </script>
32
+
1
33
  <script lang="ts">
2
- import { DataTable, type DataTableColumn } from '@happyvertical/smrt-ui/data';
34
+ import {
35
+ DataTable,
36
+ type DataTableColumn,
37
+ type DataTableViewState,
38
+ } from '@happyvertical/smrt-ui/data';
3
39
  import { ConfirmDialog } from '@happyvertical/smrt-ui/feedback';
4
40
  import { Checkbox, Input, Select } from '@happyvertical/smrt-ui/forms';
5
41
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
@@ -23,23 +59,65 @@ import {
23
59
  contentStateVariant,
24
60
  contentStatusVariant,
25
61
  createContentListController,
62
+ CONTENT_LIST_STATUS_OPTIONS,
63
+ CONTENT_LIST_TYPE_OPTIONS,
64
+ CONTENT_LIST_UNREPRESENTABLE_OPTION,
65
+ type ContentListSelectFilterState,
26
66
  isContentListFilterExactly,
27
- normalizeContentType,
67
+ normalizeContentListTypeLock,
28
68
  paginateContentListRows,
29
- readContentListFilter,
69
+ readContentListSelectFilter,
30
70
  resolveContentHref,
31
71
  selectableContentListRowIds,
32
72
  selectContentListRows,
33
73
  toContentListRows,
34
74
  } from '../content-list-controller.js';
75
+ import {
76
+ CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
77
+ type ContentListQueryDrop,
78
+ type ContentListQueryDropReason,
79
+ type ContentListQueryNotices,
80
+ type ContentListQueryRequestOptions,
81
+ type ContentListQuerySource,
82
+ contentListQueryErrorMessage,
83
+ contentListQueryExactTotal,
84
+ contentListQueryRowsToContents,
85
+ contentListQueryTotalValue,
86
+ CONTENT_LIST_QUERY_MAX_OFFSET,
87
+ contentListViewStateToDataQueryRequest,
88
+ readContentListQueryNotices,
89
+ resolveContentListMaxPageSize,
90
+ } from '../content-list-query.js';
91
+ import {
92
+ type ContentListSavedView,
93
+ type ContentListSavedViewStore,
94
+ restoreContentListSavedView,
95
+ toContentListSavedViewInput,
96
+ } from '../content-list-saved-views.js';
97
+ import {
98
+ applyContentListViewState,
99
+ type ContentListStateDrop,
100
+ type ContentListStateDropReason,
101
+ type ContentListStateValidationOptions,
102
+ mergeContentListViewStateIntoSearchParams,
103
+ readContentListViewStateFromSearchParams,
104
+ } from '../content-list-url-state.js';
35
105
  import { M } from '../i18n.contribution.js';
36
106
  import ImageThumbnail from './ImageThumbnail.svelte';
37
107
 
38
108
  const { t } = useI18n();
39
109
 
110
+ /** One reported refusal, from a restore or from the query translation. */
111
+ type ContentListDropNotice = ContentListStateDrop | ContentListQueryDrop;
112
+
40
113
  interface Props {
41
114
  apiBaseUrl?: string;
42
- contents: ContentData[];
115
+ /**
116
+ * Client-side rows. Ignored when `query` is supplied — the server then owns
117
+ * filtering, sorting, and paging, and these rows would be a second, disagreeing
118
+ * source of truth.
119
+ */
120
+ contents?: ContentData[];
43
121
  type?: string;
44
122
  defaultViewMode?: ContentListViewMode;
45
123
  onEdit: (content: ContentData) => void;
@@ -55,11 +133,22 @@ interface Props {
55
133
  onRetry?: () => void;
56
134
  /** Opt-in agent addressability. Non-table presentations land with #2456. */
57
135
  dataSurface?: ContentListDataSurface;
136
+ /**
137
+ * Opt-in server-backed rows (#2452). `bind()` is called exactly once, during
138
+ * initialization, so a `remoteQuery(...)` binding is disposed with this
139
+ * component. Supplying it switches the list into server mode: `contents` is
140
+ * ignored and the local select/paginate transform never runs.
141
+ */
142
+ query?: ContentListQuerySource;
143
+ /** Opt-in shareable URL state. The host owns navigation. */
144
+ urlState?: ContentListUrlStateBinding;
145
+ /** Opt-in saved views. `createContentListSavedViewStore()` is the default store. */
146
+ savedViews?: ContentListSavedViewStore;
58
147
  }
59
148
 
60
149
  let {
61
150
  apiBaseUrl = '/api/v1',
62
- contents,
151
+ contents = [],
63
152
  type = undefined,
64
153
  defaultViewMode = 'grid',
65
154
  onEdit,
@@ -71,19 +160,175 @@ let {
71
160
  error = null,
72
161
  onRetry = undefined,
73
162
  dataSurface = undefined,
163
+ query = undefined,
164
+ urlState = undefined,
165
+ savedViews = undefined,
74
166
  }: Props = $props();
75
167
 
168
+ const initialQuery = untrack(() => query);
169
+
76
170
  // One controller owns search, filters, sorting, paging, and selection for
77
171
  // every presentation. The view mode lives beside it, so switching presentation
78
172
  // never touches query or selection state.
79
173
  // The seed is intentionally the initial `type`; the effect below keeps the
80
174
  // locked filter in sync afterwards.
175
+ const initialUrlState = untrack(() => urlState);
176
+
177
+ /**
178
+ * THE page-size ceiling. Resolved once, from every configured limit, and used
179
+ * by the controller seed, the URL sanitizer, the saved-view sanitizer and the
180
+ * translator — so the size the UI pages by and the size the server applies are
181
+ * the same number by construction rather than by coincidence.
182
+ *
183
+ * Every candidate narrows (`Math.min`, inside `resolveContentListMaxPageSize`):
184
+ * a host that sets `query.request.maxPageSize` as a server row budget must not
185
+ * have it discarded because a looser `urlState.options.maxPageSize` also exists.
186
+ */
187
+ const maxPageSize = resolveContentListMaxPageSize(
188
+ initialUrlState?.options?.maxPageSize,
189
+ initialQuery?.request?.maxPageSize,
190
+ );
191
+
192
+ /**
193
+ * The page size a server-backed list runs at, clamped to that ceiling.
194
+ *
195
+ * `null` (unpaginated) is only expressible locally: the query endpoint always
196
+ * applies a limit, so an unpaginated server list would render one silent page
197
+ * with no controls. This value is the seed, the URL layer's notion of the
198
+ * default (so a link without `size` restores it rather than wiping it), and the
199
+ * translator's fallback — one number in all three places. Clamping the seed
200
+ * matters on its own: a `defaultPageSize` above the ceiling would seed a page
201
+ * size the request then silently reduces, and `totalPages` would compute 1.
202
+ */
203
+ const serverPageSize = initialQuery
204
+ ? Math.min(
205
+ Math.max(
206
+ 1,
207
+ Math.floor(
208
+ initialQuery.request?.defaultPageSize ??
209
+ CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
210
+ ),
211
+ ),
212
+ maxPageSize,
213
+ )
214
+ : null;
215
+
216
+ /** Validation options shared by the URL and saved-view restore paths. */
217
+ const restoreOptions: ContentListStateValidationOptions = { maxPageSize };
218
+
219
+ /**
220
+ * URL options with the server page size filled in as the default, so a link
221
+ * that omits `size` restores the seed instead of the local `null`, and a link
222
+ * this list writes omits `size` while the list is at its default.
223
+ */
224
+ const urlStateOptions: ContentListUrlStateOptions = {
225
+ ...initialUrlState?.options,
226
+ maxPageSize,
227
+ ...(serverPageSize !== null &&
228
+ initialUrlState?.options?.defaultPageSize === undefined
229
+ ? { defaultPageSize: serverPageSize }
230
+ : {}),
231
+ };
232
+
81
233
  const controller = createContentListController({
82
234
  type: untrack(() => type),
235
+ // Local mode keeps the historical unpaginated list.
236
+ ...(serverPageSize === null ? {} : { pageSize: serverPageSize }),
237
+ });
238
+
239
+ /** Everything a restore or a translation refused, surfaced as one notice. */
240
+ let restoreDrops = $state<ContentListDropNotice[]>([]);
241
+ let queryDrops = $state<ContentListQueryDrop[]>([]);
242
+ /**
243
+ * A capped-offset redirect, held apart from `queryDrops` so it survives the
244
+ * corrective re-translation that immediately follows it.
245
+ */
246
+ let pageCapDrop = $state<ContentListQueryDrop | null>(null);
247
+ /** Which page the last offset cap redirected to. Named in the notice. */
248
+ let pageCapCorrectedTo = $state<number | undefined>(undefined);
249
+ let dismissedDropKey = $state('');
250
+ let resultNotices = $state<ContentListQueryNotices>({
251
+ truncated: false,
252
+ warnings: [],
83
253
  });
254
+ /**
255
+ * The query signature the binding's rows and total currently describe.
256
+ *
257
+ * A total is only authoritative for the query that produced it. `remoteQuery`
258
+ * keeps serving the PREVIOUS query's total while a new request is in flight, so
259
+ * a saved view or a programmatic patch that changes the query AND restores a
260
+ * page past the old query's last one would have that page clamped away before
261
+ * its own total ever arrived.
262
+ */
263
+ let settledSignature = $state<string | undefined>(undefined);
264
+
265
+ function toSearchParams(
266
+ input: URLSearchParams | string | null | undefined,
267
+ ): URLSearchParams {
268
+ if (!input) return new URLSearchParams();
269
+ return new URLSearchParams(input);
270
+ }
271
+
272
+ // The URL restore runs before the first snapshot is taken, so the initial
273
+ // render is already the restored view rather than a flash of the default one.
274
+ // `applyContentListViewState` merges over current state instead of dispatching
275
+ // `setSearch`/`setFilters`, which would reset the restored page.
276
+ // An empty query string is a valid binding, not an absent one: the reader
277
+ // still applies the configured defaults, so a page opened at a bare path must
278
+ // restore the same way one opened with parameters does.
279
+ if (initialUrlState?.params !== undefined && initialUrlState?.params !== null) {
280
+ const reading = readContentListViewStateFromSearchParams(
281
+ toSearchParams(initialUrlState.params),
282
+ urlStateOptions,
283
+ );
284
+ // A restore REPLACES the filter set, including the type filter the controller
285
+ // was seeded with. On a locked list the lock effect would then re-apply it
286
+ // through `setFilters`, which resets paging — silently discarding a `?page=`
287
+ // the same link just restored. Folding the lock into the restored patch keeps
288
+ // it to one `replaceState`, and leaves the effect's first run a no-op.
289
+ const initialLockedType = normalizeContentListTypeLock(untrack(() => type));
290
+ const patch =
291
+ initialLockedType === null
292
+ ? reading.state
293
+ : {
294
+ ...reading.state,
295
+ filters: [
296
+ ...(reading.state.filters ?? []).filter(
297
+ (filter) => filter.columnId !== CONTENT_LIST_TYPE_FILTER_ID,
298
+ ),
299
+ {
300
+ columnId: CONTENT_LIST_TYPE_FILTER_ID,
301
+ operator: 'equals' as const,
302
+ value: initialLockedType,
303
+ },
304
+ ],
305
+ };
306
+ applyContentListViewState(controller, patch, restoreOptions);
307
+ restoreDrops = reading.dropped;
308
+ }
309
+
310
+ /**
311
+ * The server-query binding, created once. `bind()` runs inside this
312
+ * component's initialization, so a binding that registers an `$effect` teardown
313
+ * (as `remoteQuery` does) is disposed when this component is.
314
+ */
315
+ const queryBinding = initialQuery?.bind();
316
+ const queryRequestOptions: ContentListQueryRequestOptions | undefined =
317
+ initialQuery
318
+ ? {
319
+ ...initialQuery.request,
320
+ maxPageSize,
321
+ ...(serverPageSize === null ? {} : { defaultPageSize: serverPageSize }),
322
+ }
323
+ : undefined;
324
+ const serverBacked = queryBinding !== undefined;
325
+
84
326
  let snapshot = $state(controller.snapshot());
85
327
  let viewMode: ContentListViewMode = $state(untrack(() => defaultViewMode));
86
328
  let pendingDelete = $state<ContentListRow | null>(null);
329
+ let savedViewList = $state<ContentListSavedView[]>([]);
330
+ let selectedSavedViewId = $state('');
331
+ let savedViewName = $state('');
87
332
 
88
333
  $effect(() =>
89
334
  controller.subscribe((transition) => {
@@ -93,23 +338,70 @@ $effect(() =>
93
338
 
94
339
  const tableState = $derived(snapshot.state);
95
340
 
341
+ // In server mode the controller's page size must be a number the request can
342
+ // actually carry, or `totalPages` and `showPagination` describe a page the
343
+ // server never returned and rows are stranded behind a plausible-looking single
344
+ // page. Two ways in, both enforced against LIVE state because a saved view, a
345
+ // link, and a data-surface `set-page-size` all arrive after mount:
346
+ //
347
+ // null → unpaginated, which the endpoint cannot express;
348
+ // > maxPageSize → the translator clamps the request, so leaving the
349
+ // controller above the ceiling makes the two disagree.
350
+ //
351
+ // `setPageSize` also resets the page, which is correct: a different page size
352
+ // means the old page number addresses different rows.
353
+ $effect(() => {
354
+ if (serverPageSize === null) return;
355
+ const current = tableState.pageSize;
356
+ if (current !== null && current <= maxPageSize) return;
357
+ const next = current === null ? serverPageSize : maxPageSize;
358
+ const reason: ContentListQueryDropReason =
359
+ current === null ? 'unpaginated-unsupported' : 'out-of-range';
360
+ untrack(() => {
361
+ controller.dispatch({ type: 'setPageSize', pageSize: next });
362
+ restoreDrops = [
363
+ ...restoreDrops.filter(
364
+ (drop) =>
365
+ drop.scope !== 'pageSize' ||
366
+ (drop.reason !== 'unpaginated-unsupported' &&
367
+ drop.reason !== 'out-of-range'),
368
+ ),
369
+ {
370
+ scope: 'pageSize',
371
+ reason,
372
+ ...(current === null ? {} : { detail: String(current) }),
373
+ },
374
+ ];
375
+ });
376
+ });
377
+
96
378
  /** The normalized type the `type` prop locks the list to, if any. */
97
- const lockedType = $derived(type?.trim() ? normalizeContentType(type) : null);
379
+ const lockedType = $derived(normalizeContentListTypeLock(type));
98
380
 
99
381
  // A `type` prop locks the type filter, exactly as the legacy select did. The
100
382
  // lock is enforced against the live state, not only against the prop, because a
101
383
  // data-surface `set-filters` or `reset` command can otherwise replace or clear
102
384
  // it. The equality guard keeps the effect from dispatching in a loop.
385
+ // Tracks the previous prop so an unlocked list can tell "the lock was just
386
+ // removed" from "there was never a lock". A plain binding, so writing it here
387
+ // cannot re-trigger the effect.
388
+ let previousLockedType: string | null = untrack(() => lockedType);
389
+
103
390
  $effect(() => {
104
391
  const locked = lockedType;
105
392
  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.
393
+ const lockWasRemoved = previousLockedType !== null;
394
+ previousLockedType = null;
395
+ // Unlocked: the toolbar select owns the filter. Clear it only when the prop
396
+ // actually went away, because clearing on every run would also discard a
397
+ // type filter restored from a link or a saved view.
398
+ if (!lockWasRemoved) return;
108
399
  untrack(() =>
109
400
  applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, null),
110
401
  );
111
402
  return;
112
403
  }
404
+ previousLockedType = locked;
113
405
  if (
114
406
  isContentListFilterExactly(tableState, CONTENT_LIST_TYPE_FILTER_ID, locked)
115
407
  )
@@ -131,31 +423,256 @@ const columnLabels = $derived({
131
423
  });
132
424
 
133
425
  const queryColumns = $derived(buildContentListColumns(columnLabels));
134
- const rows = $derived(toContentListRows(contents));
426
+ const sourceContents = $derived(
427
+ queryBinding ? contentListQueryRowsToContents(queryBinding.rows) : contents,
428
+ );
429
+ const rows = $derived(toContentListRows(sourceContents));
430
+ // In server mode the returned rows ARE the answer: the server already applied
431
+ // the search, the filters, the sort, and the page. Running the local transform
432
+ // over them again would re-filter with subtly different semantics (untrimmed
433
+ // search, case-insensitive comparison, a `site` predicate the server never saw)
434
+ // and could hide rows the server deliberately returned.
135
435
  const queryRows = $derived(
136
- selectContentListRows(rows, tableState, queryColumns),
436
+ serverBacked ? rows : selectContentListRows(rows, tableState, queryColumns),
437
+ );
438
+ const pageRows = $derived(
439
+ serverBacked ? rows : paginateContentListRows(queryRows, tableState),
440
+ );
441
+ /** Server row count for the whole query, not just the rendered page. */
442
+ const serverTotal = $derived(
443
+ queryBinding ? contentListQueryTotalValue(queryBinding.total) : undefined,
444
+ );
445
+ const totalRowCount = $derived(
446
+ serverBacked ? (serverTotal ?? rows.length) : queryRows.length,
447
+ );
448
+
449
+ /**
450
+ * The row count the current page may be clamped against, or `undefined` when
451
+ * no authoritative count exists.
452
+ *
453
+ * Clamping MOVES the operator, so every input has to be judged on whether it is
454
+ * exactly right — twice now a clamp has acted on a number that was not the
455
+ * total. The complete set:
456
+ *
457
+ * | Input | Authoritative? |
458
+ * |---|---|
459
+ * | local mode row count | yes — the supplied array IS the whole result set |
460
+ * | server total, `exact` | yes |
461
+ * | server total, `estimated` | NO — clamping on an approximation can hide a page that really exists |
462
+ * | server total, `unavailable` | NO — the count is unknown, and `rows.length` is the page, not the total |
463
+ * | no response yet for this query | NO — a page restored from a link must survive until its own count arrives |
464
+ * | a settled response for a DIFFERENT query | NO — the binding still holds the previous query's total while a new request is in flight |
465
+ * | a page-size change | n/a — `setPageSize` resets the page itself, so it can never strand an out-of-range one |
466
+ *
467
+ * `pageableRowCount` deliberately keeps using the looser
468
+ * `contentListQueryTotalValue`: an estimate is fine for SHOWING a pager, and
469
+ * over-offering a page is visible and self-correcting where hiding one is not.
470
+ */
471
+ const clampableRowCount = $derived(
472
+ serverBacked
473
+ ? contentListQueryExactTotal(queryBinding?.total)
474
+ : queryRows.length,
137
475
  );
138
- const pageRows = $derived(paginateContentListRows(queryRows, tableState));
139
476
 
140
477
  // 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.
478
+ // to be clamped against the result count rather than DataTable's. In server
479
+ // mode that count is the server's total — clamping against the page length
480
+ // would collapse every list to a single page.
142
481
  $effect(() => {
143
- const totalRows = queryRows.length;
482
+ const totalRows = clampableRowCount;
483
+ // Both signatures are read as dependencies on purpose: the effect must re-run
484
+ // when the SETTLED query changes even if the new query's count happens to
485
+ // equal the old one's, or a stale page survives on a coincidence.
486
+ const settled = settledSignature;
487
+ const signature = querySignature;
488
+ if (totalRows === undefined) return;
489
+ if (serverBacked && settled !== signature) return;
490
+ // Deliberately the TRUE total, not `pageableRowCount`: clamping to the
491
+ // reachable ceiling here would silently move a crafted `?page=9000` before
492
+ // the query effect ever sees it, and the operator would never be told why
493
+ // they landed somewhere else.
144
494
  untrack(() => controller.clampPage(totalRows));
145
495
  });
146
496
 
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.
497
+ /**
498
+ * The last page a server query can actually fetch.
499
+ *
500
+ * Offset paging stops at `CONTENT_LIST_QUERY_MAX_OFFSET`, so on a very large
501
+ * list the arithmetic total implies pages the endpoint will never return.
502
+ * Advertising them is worse than not offering them: every click past the
503
+ * boundary silently lands back on the same page.
504
+ */
505
+ const maxReachablePage = $derived(
506
+ serverBacked && tableState.pageSize
507
+ ? Math.floor(CONTENT_LIST_QUERY_MAX_OFFSET / tableState.pageSize) + 1
508
+ : Number.POSITIVE_INFINITY,
509
+ );
510
+ /**
511
+ * The row count the pagers may page over. DataTable derives its own page count
512
+ * from `totalRows` and takes no ceiling, so the ceiling has to be applied to
513
+ * the number handed to it. `clampPage` deliberately keeps using the true total
514
+ * (see below).
515
+ */
516
+ const pageableRowCount = $derived(
517
+ tableState.pageSize
518
+ ? Math.min(totalRowCount, maxReachablePage * tableState.pageSize)
519
+ : totalRowCount,
520
+ );
521
+ // EVERY presentation renders its own page controls, compact included, and
522
+ // `totalRows` is deliberately never handed to DataTable.
523
+ //
524
+ // DataTable runs its own `clampPage(totalRows)` effect against the SAME
525
+ // controller, with no authority rule and no notion of which query a total
526
+ // belongs to. One prop cannot serve both purposes: it drives that clamp AND
527
+ // DataTable's pager, so any total authoritative enough to clamp against is also
528
+ // the only total the pager can show, and vice versa. Passing an
529
+ // authoritative-only total silences the clamp correctly but leaves compact with
530
+ // no pager on an `estimated` total while the card modes still show one — the
531
+ // two modes would then disagree about which pages exist, which is a worse bug
532
+ // than the one being fixed.
533
+ //
534
+ // So ContentList owns paging outright: one clamp (the effect above, with the
535
+ // authority rule) and one pager (below, driven by `pageableRowCount`, which
536
+ // accepts an estimate because SHOWING a page is a different question from
537
+ // MOVING the operator). The same reasoning already made the selection column
538
+ // content-owned in compact mode.
151
539
  const totalPages = $derived(
152
540
  tableState.pageSize
153
- ? Math.max(1, Math.ceil(queryRows.length / tableState.pageSize))
541
+ ? Math.max(1, Math.ceil(pageableRowCount / tableState.pageSize))
154
542
  : 1,
155
543
  );
156
544
  const showPagination = $derived(Boolean(tableState.pageSize) && totalPages > 1);
545
+ const queryErrorMessage = $derived(
546
+ queryBinding ? contentListQueryErrorMessage(queryBinding.error) : null,
547
+ );
548
+ /** A host-supplied error still wins: it describes the surrounding page load. */
549
+ const activeError = $derived(error ?? queryErrorMessage);
550
+ const isLoading = $derived(loading || (queryBinding?.loading ?? false));
157
551
  /** Rows are already rendered, so a load is a refresh rather than a first fill. */
158
- const refreshing = $derived(loading && pageRows.length > 0);
552
+ const refreshing = $derived(
553
+ (queryBinding?.refreshing ?? false) || (isLoading && pageRows.length > 0),
554
+ );
555
+ /**
556
+ * A retry re-reads the same query, so its answer replaces the rendered rows —
557
+ * and therefore has to replace the completeness flags that describe them too.
558
+ * Discarding the envelope here is what left a "rows are missing" notice
559
+ * standing over a page that came back complete (and the inverse after a
560
+ * transient error). The signature does not change on a retry, so the query
561
+ * effect never re-runs and this is the only place that can refresh them.
562
+ */
563
+ function retryQuery(): void {
564
+ if (!queryBinding) return;
565
+ const signature = executedSignature;
566
+ void queryBinding
567
+ .retry()
568
+ .then((result) => {
569
+ // `retry()` resolves undefined when there is no request to repeat, and a
570
+ // newer query may have superseded this one while it was in flight.
571
+ if (result === undefined || executedSignature !== signature) return;
572
+ resultNotices = readContentListQueryNotices(result);
573
+ })
574
+ .catch(() => undefined);
575
+ }
576
+
577
+ const retryHandler = $derived(
578
+ onRetry ?? (queryBinding ? retryQuery : undefined),
579
+ );
580
+
581
+ /**
582
+ * A signature of exactly the query-affecting state. Selection and expansion are
583
+ * excluded on purpose: checking a row must not re-run the server query. The
584
+ * value is a primitive, so an unrelated transition that leaves the query
585
+ * unchanged does not propagate.
586
+ */
587
+ const querySignature = $derived(
588
+ JSON.stringify([
589
+ tableState.search,
590
+ tableState.filters,
591
+ tableState.sorting,
592
+ tableState.page,
593
+ tableState.pageSize,
594
+ ]),
595
+ );
596
+
597
+ let executedSignature: string | undefined;
598
+ let publishedUrlSignature: string | undefined;
599
+
600
+
601
+ $effect(() => {
602
+ const signature = querySignature;
603
+ untrack(() => {
604
+ const state = controller.getState();
605
+ if (queryBinding && signature !== executedSignature) {
606
+ executedSignature = signature;
607
+ const translated = contentListViewStateToDataQueryRequest(
608
+ state,
609
+ queryRequestOptions,
610
+ );
611
+ // The page-cap drop is held separately because it must OUTLIVE this
612
+ // translation: the corrective dispatch below re-enters this effect, and
613
+ // the second translation caps nothing, so a drop stored here would be
614
+ // overwritten in the same flush and the redirect would be silent.
615
+ queryDrops = translated.dropped.filter((drop) => drop.scope !== 'page');
616
+ if (translated.effectivePage !== state.page) {
617
+ // The offset had to be capped. Move the controller's page marker to the
618
+ // page the request actually reads, or the UI labels this answer with a
619
+ // page number the server never saw. The dispatch re-enters this effect
620
+ // with the corrected signature, which then executes.
621
+ pageCapDrop = {
622
+ scope: 'page',
623
+ reason: 'out-of-range',
624
+ detail: String(state.page),
625
+ };
626
+ pageCapCorrectedTo = translated.effectivePage;
627
+ controller.dispatch({
628
+ type: 'setPage',
629
+ page: translated.effectivePage,
630
+ });
631
+ return;
632
+ }
633
+ // The redirect notice stands until the operator moves off the page they
634
+ // were redirected to; clearing it on the corrective re-run would be the
635
+ // same silence by a different route.
636
+ if (pageCapDrop !== null && state.page !== pageCapCorrectedTo) {
637
+ pageCapDrop = null;
638
+ pageCapCorrectedTo = undefined;
639
+ }
640
+ // The binding owns cancellation of a superseded run and already reflects
641
+ // a failure in its error state, so a rejection here is not also an
642
+ // unhandled one. The resolved envelope carries the server's completeness
643
+ // flags, which the binding itself does not expose.
644
+ void queryBinding
645
+ .execute(translated.request)
646
+ .then((result) => {
647
+ // A newer query may have superseded this one while it was in flight.
648
+ if (executedSignature !== signature) return;
649
+ // The binding's rows and total now describe THIS query, so the page
650
+ // may be clamped against them.
651
+ settledSignature = signature;
652
+ resultNotices = readContentListQueryNotices(result);
653
+ })
654
+ .catch(() => undefined);
655
+ }
656
+ if (publishedUrlSignature === undefined) {
657
+ // The first pass adopts whatever the restore produced without pushing it
658
+ // back at the host as a navigation.
659
+ publishedUrlSignature = signature;
660
+ return;
661
+ }
662
+ if (signature === publishedUrlSignature) return;
663
+ publishedUrlSignature = signature;
664
+ const binding = urlState;
665
+ if (!binding?.onChange) return;
666
+ binding.onChange(
667
+ mergeContentListViewStateIntoSearchParams(
668
+ toSearchParams(binding.params),
669
+ state,
670
+ urlStateOptions,
671
+ ),
672
+ state as DataTableViewState,
673
+ );
674
+ });
675
+ });
159
676
 
160
677
  const selectedRowKeys = $derived(
161
678
  new Set(tableState.selectedRowIds.map((rowId) => String(rowId))),
@@ -175,13 +692,27 @@ const somePageSelected = $derived(
175
692
  );
176
693
  const selectedCount = $derived(tableState.selectedRowIds.length);
177
694
 
695
+ /** Synthetic ids the adapter minted for rows that carry no durable identity. */
696
+ const unidentifiedRowKeys = $derived(
697
+ new Set(
698
+ rows.filter((row) => !row.identified).map((row) => String(row.id)),
699
+ ),
700
+ );
701
+
178
702
  // DataTable's own selection column and data-surface commands can both introduce
179
703
  // ids for rows that carry no durable identity. Normalizing here covers every
180
704
  // path at once; re-dispatching only on a real difference keeps it settling.
705
+ //
706
+ // In server mode `rows` is only the current page, so membership cannot be the
707
+ // test: it would silently clear the whole selection on every page change. Only
708
+ // the synthetic ids are stripped there, which keeps a selection addressable
709
+ // across pages while still refusing an unaddressable row.
181
710
  $effect(() => {
182
711
  const selected = tableState.selectedRowIds;
183
712
  const durable = selected.filter((rowId) =>
184
- identifiedRowKeys.has(String(rowId)),
713
+ serverBacked
714
+ ? !unidentifiedRowKeys.has(String(rowId))
715
+ : identifiedRowKeys.has(String(rowId)),
185
716
  );
186
717
  if (durable.length === selected.length) return;
187
718
  untrack(() =>
@@ -189,13 +720,105 @@ $effect(() => {
189
720
  );
190
721
  });
191
722
 
723
+ /**
724
+ * What each toolbar select may display, and whether it can display the live
725
+ * predicate at all.
726
+ *
727
+ * INVARIANT: the select's displayed state either matches the live predicate
728
+ * exactly, or the operator is told it does not. Three states, and all three are
729
+ * reachable from a shared link:
730
+ *
731
+ * - representable and inside the vocabulary — the select shows it, silently;
732
+ * - representable but outside the vocabulary (`?status=embargoed`, or a typo)
733
+ * — the value is rendered as an extra option AND reported, so an empty list
734
+ * always has an explanation;
735
+ * - not representable at all (`?status.in=draft,review`, `?status.isNull=1`,
736
+ * `?status.notEquals=draft`) — the select shows a disabled summary of the
737
+ * real predicate instead of a value it is not applying, and reports it.
738
+ * Choosing any real option replaces every filter on that column, so the
739
+ * operator is never stuck.
740
+ */
741
+ const typeFilterState = $derived(
742
+ readContentListSelectFilter(tableState, CONTENT_LIST_TYPE_FILTER_ID),
743
+ );
744
+ const statusFilterState = $derived(
745
+ readContentListSelectFilter(tableState, CONTENT_LIST_STATUS_FILTER_ID),
746
+ );
192
747
  const selectedType = $derived(
193
- readContentListFilter(tableState, CONTENT_LIST_TYPE_FILTER_ID) ?? '',
748
+ typeFilterState.representable
749
+ ? typeFilterState.value
750
+ : CONTENT_LIST_UNREPRESENTABLE_OPTION,
194
751
  );
195
752
  const selectedStatus = $derived(
196
- readContentListFilter(tableState, CONTENT_LIST_STATUS_FILTER_ID) ?? '',
753
+ statusFilterState.representable
754
+ ? statusFilterState.value
755
+ : CONTENT_LIST_UNREPRESENTABLE_OPTION,
756
+ );
757
+
758
+ /** A live, representable value the select has no option for. */
759
+ const unlistedType = $derived(
760
+ typeFilterState.representable &&
761
+ typeFilterState.value &&
762
+ !(CONTENT_LIST_TYPE_OPTIONS as readonly string[]).includes(
763
+ typeFilterState.value,
764
+ )
765
+ ? typeFilterState.value
766
+ : null,
767
+ );
768
+ const unlistedStatus = $derived(
769
+ statusFilterState.representable &&
770
+ statusFilterState.value &&
771
+ !(CONTENT_LIST_STATUS_OPTIONS as readonly string[]).includes(
772
+ statusFilterState.value,
773
+ )
774
+ ? statusFilterState.value
775
+ : null,
197
776
  );
198
777
 
778
+ function columnFilterDrops(
779
+ columnId: string,
780
+ unlisted: string | null,
781
+ filterState: ContentListSelectFilterState,
782
+ ): ContentListQueryDrop[] {
783
+ if (unlisted !== null) {
784
+ return [
785
+ {
786
+ scope: 'filter',
787
+ reason: 'unlisted-value',
788
+ columnId,
789
+ detail: unlisted,
790
+ },
791
+ ];
792
+ }
793
+ if (!filterState.representable) {
794
+ return [
795
+ {
796
+ scope: 'filter',
797
+ reason: 'unrepresentable-filter',
798
+ columnId,
799
+ detail: filterState.detail ?? '',
800
+ },
801
+ ];
802
+ }
803
+ return [];
804
+ }
805
+
806
+ const unlistedValueDrops = $derived<ContentListQueryDrop[]>([
807
+ // A locked list renders no type select, so there is nothing to disagree with.
808
+ ...(lockedType !== null
809
+ ? []
810
+ : columnFilterDrops(
811
+ CONTENT_LIST_TYPE_FILTER_ID,
812
+ unlistedType,
813
+ typeFilterState,
814
+ )),
815
+ ...columnFilterDrops(
816
+ CONTENT_LIST_STATUS_FILTER_ID,
817
+ unlistedStatus,
818
+ statusFilterState,
819
+ ),
820
+ ]);
821
+
199
822
  const surfaceOptions = $derived(
200
823
  dataSurface
201
824
  ? {
@@ -207,6 +830,169 @@ const surfaceOptions = $derived(
207
830
  : undefined,
208
831
  );
209
832
 
833
+ // ---------------------------------------------------------------------------
834
+ // Restore reporting
835
+ // ---------------------------------------------------------------------------
836
+
837
+ const dropNotices = $derived<ContentListDropNotice[]>([
838
+ ...restoreDrops,
839
+ ...queryDrops,
840
+ ...unlistedValueDrops,
841
+ ...(pageCapDrop === null ? [] : [pageCapDrop]),
842
+ ]);
843
+ /**
844
+ * What the server said about the completeness of the answer.
845
+ *
846
+ * A binding may expose the flags directly; `remoteQuery` does not, so the
847
+ * component falls back to the envelope its own `execute` resolved. Either way
848
+ * the operator has to be told: the server drops trailing rows to fit its byte
849
+ * budget, and the next page is computed from `page * limit`, so those rows are
850
+ * skipped on the following page too.
851
+ */
852
+ const queryTruncated = $derived(
853
+ queryBinding?.truncated ?? resultNotices.truncated,
854
+ );
855
+ const queryWarnings = $derived<ReadonlyArray<string>>(
856
+ queryBinding?.warnings ?? resultNotices.warnings,
857
+ );
858
+ /** Identity of the current set of refusals, so a dismissal is not permanent. */
859
+ const dropNoticeKey = $derived(
860
+ dropNotices.length > 0 || queryTruncated || queryWarnings.length > 0
861
+ ? JSON.stringify([dropNotices, queryTruncated, queryWarnings])
862
+ : '',
863
+ );
864
+ const showDropNotice = $derived(
865
+ dropNoticeKey !== '' && dropNoticeKey !== dismissedDropKey,
866
+ );
867
+
868
+ const DROP_REASON_MESSAGES: Record<
869
+ ContentListStateDropReason | ContentListQueryDropReason,
870
+ string
871
+ > = {
872
+ 'unknown-column': M['content.content_list.drop_unknown_column'],
873
+ 'hidden-column': M['content.content_list.drop_hidden_column'],
874
+ 'structural-column': M['content.content_list.drop_structural_column'],
875
+ 'no-server-field': M['content.content_list.drop_no_server_field'],
876
+ 'unsupported-operator': M['content.content_list.drop_unsupported_operator'],
877
+ 'unsupported-value': M['content.content_list.drop_unsupported_value'],
878
+ malformed: M['content.content_list.drop_malformed'],
879
+ 'out-of-range': M['content.content_list.drop_out_of_range'],
880
+ 'filter-widened': M['content.content_list.drop_filter_widened'],
881
+ 'unlisted-value': M['content.content_list.drop_unlisted_value'],
882
+ 'unrepresentable-filter':
883
+ M['content.content_list.drop_unrepresentable_filter'],
884
+ 'unpaginated-unsupported': M['content.content_list.drop_unpaginated'],
885
+ };
886
+
887
+ function dropNoticeText(drop: ContentListDropNotice): string {
888
+ // A capped offset is a redirect, not merely a refused value: the operator
889
+ // needs to know which page they asked for and which one they are looking at.
890
+ if (drop.reason === 'unlisted-value') {
891
+ return t(M['content.content_list.drop_unlisted_value'], {
892
+ value: drop.detail ?? '',
893
+ });
894
+ }
895
+ if (drop.reason === 'unrepresentable-filter') {
896
+ return t(M['content.content_list.drop_unrepresentable_filter'], {
897
+ target: drop.columnId ?? drop.scope,
898
+ value: drop.detail ?? '',
899
+ });
900
+ }
901
+ if (drop.scope === 'page' && drop.reason === 'out-of-range') {
902
+ return t(M['content.content_list.drop_page_unreachable'], {
903
+ requested: drop.detail ?? '',
904
+ landed: String(pageCapCorrectedTo ?? ''),
905
+ });
906
+ }
907
+ return t(M['content.content_list.dropped_item'], {
908
+ target: drop.columnId ?? drop.scope,
909
+ reason: t(DROP_REASON_MESSAGES[drop.reason]),
910
+ });
911
+ }
912
+
913
+ function dismissDropNotice() {
914
+ dismissedDropKey = dropNoticeKey;
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Saved views
919
+ // ---------------------------------------------------------------------------
920
+
921
+ $effect(() => {
922
+ const store = savedViews;
923
+ if (!store) {
924
+ savedViewList = [];
925
+ return;
926
+ }
927
+ let cancelled = false;
928
+ void store
929
+ .list()
930
+ .then((views) => {
931
+ if (!cancelled) savedViewList = views;
932
+ })
933
+ .catch(() => {
934
+ // An unreadable store is an empty store; the list still opens.
935
+ if (!cancelled) savedViewList = [];
936
+ });
937
+ return () => {
938
+ cancelled = true;
939
+ };
940
+ });
941
+
942
+ async function reloadSavedViews() {
943
+ if (!savedViews) return;
944
+ try {
945
+ savedViewList = await savedViews.list();
946
+ } catch {
947
+ savedViewList = [];
948
+ }
949
+ }
950
+
951
+ function applySavedView(id: string) {
952
+ selectedSavedViewId = id;
953
+ const view = savedViewList.find((entry) => entry.id === id);
954
+ if (!view) return;
955
+ try {
956
+ // The same validator, AND the same limits, the URL path uses: a stored view
957
+ // must not be a way around a `maxPageSize` the host configured for links. A
958
+ // stale view restores its valid remainder rather than refusing to open.
959
+ const restoration = restoreContentListSavedView(view, restoreOptions);
960
+ applyContentListViewState(controller, restoration.state, restoreOptions);
961
+ restoreDrops = restoration.dropped;
962
+ } catch {
963
+ restoreDrops = [{ scope: 'state', reason: 'malformed' }];
964
+ }
965
+ }
966
+
967
+ async function saveCurrentView() {
968
+ const store = savedViews;
969
+ const name = savedViewName.trim();
970
+ if (!store || !name) return;
971
+ try {
972
+ const saved = await store.save(
973
+ toContentListSavedViewInput(name, controller.snapshot()),
974
+ );
975
+ savedViewName = '';
976
+ selectedSavedViewId = saved.id;
977
+ await reloadSavedViews();
978
+ } catch {
979
+ // Keep the operator's text so the save can be retried.
980
+ }
981
+ }
982
+
983
+ async function deleteSelectedView() {
984
+ const store = savedViews;
985
+ const id = selectedSavedViewId;
986
+ if (!store || !id) return;
987
+ try {
988
+ await store.delete(id);
989
+ selectedSavedViewId = '';
990
+ await reloadSavedViews();
991
+ } catch {
992
+ // Nothing to undo; the list is reloaded on the next mount.
993
+ }
994
+ }
995
+
210
996
  function isSelected(row: ContentListRow): boolean {
211
997
  return selectedRowKeys.has(String(row.id));
212
998
  }
@@ -443,6 +1229,17 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
443
1229
  <option value="article">{t(M['content.content_list.type_articles'])}</option>
444
1230
  <option value="document">{t(M['content.content_list.type_documents'])}</option>
445
1231
  <option value="mirror">{t(M['content.content_list.type_mirrors'])}</option>
1232
+ {#if unlistedType}
1233
+ <!-- A live filter the vocabulary does not cover; showing it is what
1234
+ keeps the toolbar honest about why the list may be empty. -->
1235
+ <option value={unlistedType}>{unlistedType}</option>
1236
+ {:else if !typeFilterState.representable}
1237
+ <!-- A live predicate no single option can express. Show the
1238
+ predicate rather than a value the query is not applying. -->
1239
+ <option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
1240
+ {typeFilterState.detail}
1241
+ </option>
1242
+ {/if}
446
1243
  </Select>
447
1244
  {/if}
448
1245
 
@@ -458,9 +1255,61 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
458
1255
  <option value="">{t(M['content.content_list.all_statuses'])}</option>
459
1256
  <option value="published">{t(M['content.content_list.status_published'])}</option>
460
1257
  <option value="draft">{t(M['content.content_list.status_draft'])}</option>
1258
+ <option value="review">{t(M['content.content_list.status_review'])}</option>
461
1259
  <option value="archived">{t(M['content.content_list.status_archived'])}</option>
1260
+ {#if unlistedStatus}
1261
+ <option value={unlistedStatus}>{unlistedStatus}</option>
1262
+ {:else if !statusFilterState.representable}
1263
+ <option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
1264
+ {statusFilterState.detail}
1265
+ </option>
1266
+ {/if}
462
1267
  </Select>
463
1268
 
1269
+ {#if savedViews}
1270
+ <div class="saved-views" role="group" aria-label={t(M['content.content_list.saved_views'])}>
1271
+ <Select
1272
+ aria-label={t(M['content.content_list.saved_views'])}
1273
+ value={selectedSavedViewId}
1274
+ onchange={(event: Event) =>
1275
+ applySavedView((event.currentTarget as HTMLSelectElement).value)}
1276
+ >
1277
+ <option value="">{t(M['content.content_list.saved_view_none'])}</option>
1278
+ {#each savedViewList as view (view.id)}
1279
+ <option value={view.id}>{view.name}</option>
1280
+ {/each}
1281
+ </Select>
1282
+ <Input
1283
+ type="text"
1284
+ aria-label={t(M['content.content_list.saved_view_name'])}
1285
+ placeholder={t(M['content.content_list.saved_view_name_placeholder'])}
1286
+ value={savedViewName}
1287
+ oninput={(event: Event) => {
1288
+ savedViewName = (event.currentTarget as HTMLInputElement).value;
1289
+ }}
1290
+ />
1291
+ <Button
1292
+ variant="ghost"
1293
+ size="sm"
1294
+ type="button"
1295
+ disabled={savedViewName.trim().length === 0}
1296
+ onclick={() => void saveCurrentView()}
1297
+ >
1298
+ {t(M['content.content_list.saved_view_save'])}
1299
+ </Button>
1300
+ {#if selectedSavedViewId}
1301
+ <Button
1302
+ variant="ghost"
1303
+ size="sm"
1304
+ type="button"
1305
+ onclick={() => void deleteSelectedView()}
1306
+ >
1307
+ {t(M['content.content_list.saved_view_delete'])}
1308
+ </Button>
1309
+ {/if}
1310
+ </div>
1311
+ {/if}
1312
+
464
1313
  {#if controls}
465
1314
  {@render controls()}
466
1315
  {/if}
@@ -532,12 +1381,38 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
532
1381
  </div>
533
1382
  </div>
534
1383
 
535
- {#if error}
1384
+ {#if showDropNotice}
1385
+ <div class="state-notice" role="status">
1386
+ <p class="state-notice__title">{t(M['content.content_list.dropped_title'])}</p>
1387
+ <ul class="state-notice__list">
1388
+ {#each dropNotices as drop, index (index)}
1389
+ <li>{dropNoticeText(drop)}</li>
1390
+ {/each}
1391
+ {#if queryTruncated}
1392
+ <li>{t(M['content.content_list.result_truncated'])}</li>
1393
+ {/if}
1394
+ {#each queryWarnings as warning, index (index)}
1395
+ <li>{warning}</li>
1396
+ {/each}
1397
+ </ul>
1398
+ <Button
1399
+ variant="ghost"
1400
+ size="sm"
1401
+ type="button"
1402
+ class="state-notice__dismiss"
1403
+ onclick={dismissDropNotice}
1404
+ >
1405
+ {t(M['content.content_list.dropped_dismiss'])}
1406
+ </Button>
1407
+ </div>
1408
+ {/if}
1409
+
1410
+ {#if activeError}
536
1411
  <div class="state-panel state-panel--error" role="alert">
537
1412
  <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?.()}>
1413
+ <p class="state-panel__detail">{activeError}</p>
1414
+ {#if retryHandler}
1415
+ <Button variant="ghost" type="button" class="retry-button" onclick={() => retryHandler?.()}>
541
1416
  {t(M['content.content_list.retry'])}
542
1417
  </Button>
543
1418
  {/if}
@@ -580,20 +1455,19 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
580
1455
  <div class="content-table-wrapper">
581
1456
  <DataTable
582
1457
  data={pageRows}
583
- totalRows={queryRows.length}
584
1458
  columns={tableColumns}
585
1459
  rowKey={CONTENT_LIST_ROW_KEY}
586
1460
  {controller}
587
1461
  sortable
588
1462
  agentAddressable
589
- {loading}
1463
+ loading={isLoading}
590
1464
  caption={t(M['content.content_list.table_caption'])}
591
1465
  rowLabel={(row: ContentListRow) => row.title}
592
1466
  dataSurface={surfaceOptions}
593
1467
  empty={tableEmptyState}
594
1468
  />
595
1469
  </div>
596
- {:else if loading && pageRows.length === 0}
1470
+ {:else if isLoading && pageRows.length === 0}
597
1471
  <div class="state-panel" role="status">
598
1472
  {t(M['content.content_list.loading'])}
599
1473
  </div>
@@ -751,7 +1625,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
751
1625
  </div>
752
1626
  {/if}
753
1627
 
754
- {#if showPagination && viewMode !== 'compact'}
1628
+ {#if showPagination}
755
1629
  <div class="content-pagination">
756
1630
  <Pagination
757
1631
  currentPage={tableState.page}
@@ -1253,6 +2127,43 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1253
2127
  background: var(--smrt-color-error-container);
1254
2128
  }
1255
2129
 
2130
+ /* Saved views live beside the search and filters they name. */
2131
+ .saved-views {
2132
+ display: flex;
2133
+ gap: 0.5rem;
2134
+ align-items: center;
2135
+ flex-wrap: wrap;
2136
+ }
2137
+
2138
+ /* Reports what a restored link, saved view, or server query discarded. */
2139
+ .state-notice {
2140
+ margin-bottom: 1rem;
2141
+ padding: 0.75rem 1rem;
2142
+ border-radius: 0.5rem;
2143
+ border: 1px solid var(--smrt-color-outline-variant);
2144
+ background: var(--smrt-color-surface-container-low);
2145
+ color: var(--smrt-color-on-surface-variant);
2146
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
2147
+ }
2148
+
2149
+ .state-notice__title {
2150
+ margin: 0 0 0.35rem;
2151
+ font-weight: var(--smrt-typography-weight-semibold, 600);
2152
+ color: var(--smrt-color-on-surface);
2153
+ }
2154
+
2155
+ .state-notice__list {
2156
+ margin: 0 0 0.5rem;
2157
+ padding-left: 1.25rem;
2158
+ }
2159
+
2160
+ .state-notice :global(.state-notice__dismiss) {
2161
+ border: 1px solid var(--smrt-color-outline-variant);
2162
+ border-radius: 0.375rem;
2163
+ padding: 0.25rem 0.75rem;
2164
+ cursor: pointer;
2165
+ }
2166
+
1256
2167
  /* Shared empty, loading, and error presentation. */
1257
2168
  .state-panel {
1258
2169
  background: var(--smrt-color-surface);