@astronox/core 0.2.0 → 0.2.2

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 (81) hide show
  1. package/README.md +9 -30
  2. package/package.json +7 -1
  3. package/src/admin/contracts.ts +28 -0
  4. package/src/admin/index.ts +2 -0
  5. package/src/admin/module.ts +28 -0
  6. package/src/auth/contracts.ts +53 -0
  7. package/src/auth/firebase/client.ts +53 -0
  8. package/src/auth/firebase/contracts.ts +8 -0
  9. package/src/auth/firebase/index.ts +1 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/auth/module.ts +99 -0
  12. package/src/client/index.ts +3 -0
  13. package/src/client/preferences.ts +400 -0
  14. package/src/components/actions/Button/Button.astro +42 -6
  15. package/src/components/actions/Button/Button.css +19 -0
  16. package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +35 -0
  17. package/src/components/actions/MediaTrigger/MediaTrigger.astro +12 -4
  18. package/src/components/actions/MediaTrigger/MediaTrigger.css +1 -0
  19. package/src/components/content/CardBody/CardBody.astro +3 -0
  20. package/src/components/content/CardBody/CardBody.css +10 -0
  21. package/src/components/content/MarkdownContent/MarkdownContent.css +0 -1
  22. package/src/components/content/MarkdownContent/MarkdownNode.astro +1 -0
  23. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +549 -0
  24. package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +403 -0
  25. package/src/components/forms/Checkbox/Checkbox.astro +66 -0
  26. package/src/components/forms/Checkbox/Checkbox.css +126 -0
  27. package/src/components/forms/ChoiceGroup/ChoiceGroup.astro +57 -0
  28. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +108 -0
  29. package/src/components/forms/ChoiceGroup/contracts.ts +9 -0
  30. package/src/components/forms/DateTimeInput/DateTimeInput.astro +14 -0
  31. package/src/components/forms/FieldGroup/FieldGroup.astro +34 -0
  32. package/src/components/forms/FieldGroup/FieldGroup.css +30 -0
  33. package/src/components/forms/TextInput/TextInput.astro +7 -1
  34. package/src/components/forms/Textarea/Textarea.astro +32 -0
  35. package/src/components/forms/Textarea/Textarea.css +54 -0
  36. package/src/components/icons/BackIcon.astro +26 -0
  37. package/src/components/icons/CheckIcon.astro +22 -0
  38. package/src/components/icons/DeleteIcon.astro +22 -0
  39. package/src/components/icons/StatusIndicatorIcon.astro +16 -0
  40. package/src/components/index.ts +12 -0
  41. package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -17
  42. package/src/components/media/EmbedFrame/EmbedFrame.astro +1 -19
  43. package/src/components/media/EmbedFrame/EmbedFrame.css +0 -2
  44. package/src/components/navigation/SubNavigation/SubNavigation.css +0 -5
  45. package/src/composition/Grid/Grid.astro +9 -0
  46. package/src/composition/Grid/Grid.css +52 -0
  47. package/src/composition/GridItem/GridItem.astro +9 -0
  48. package/src/composition/GridItem/GridItem.css +12 -0
  49. package/src/composition/SurfaceGroup/SurfaceGroup.css +21 -7
  50. package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +3 -0
  51. package/src/data/firestore/client/contracts.ts +55 -0
  52. package/src/data/firestore/client/firebase.ts +12 -0
  53. package/src/data/firestore/client/index.ts +3 -0
  54. package/src/data/firestore/client/repository.ts +111 -0
  55. package/src/display/contracts.ts +41 -0
  56. package/src/display/index.ts +1 -0
  57. package/src/display/tableProgressive.ts +62 -0
  58. package/src/patterns/Combobox/Combobox.astro +23 -0
  59. package/src/patterns/Combobox/comboboxController.ts +72 -0
  60. package/src/patterns/Fullscreen/FullscreenControl.astro +4 -14
  61. package/src/patterns/Fullscreen/FullscreenControl.css +0 -26
  62. package/src/patterns/Modal/Modal.astro +1 -1
  63. package/src/patterns/Modal/Modal.css +38 -1
  64. package/src/patterns/Table/Table.astro +122 -7
  65. package/src/patterns/Table/Table.css +2 -1
  66. package/src/patterns/Table/TableBody.astro +11 -1
  67. package/src/patterns/Table/TableBody.css +6 -0
  68. package/src/patterns/Table/TableCell.css +6 -0
  69. package/src/patterns/Table/TableCellValue.astro +31 -0
  70. package/src/patterns/Table/TableFooter.astro +3 -1
  71. package/src/patterns/Table/TableHeader.astro +1 -0
  72. package/src/patterns/Table/TablePagination.astro +63 -47
  73. package/src/patterns/Table/TableSkeleton.astro +18 -22
  74. package/src/patterns/Table/TableSkeleton.css +30 -5
  75. package/src/patterns/Table/tableClientController.ts +253 -0
  76. package/src/patterns/Table/tableFilterController.ts +83 -33
  77. package/src/patterns/Table/tableLayout.ts +10 -3
  78. package/src/patterns/Table/tableProgressiveController.ts +615 -227
  79. package/src/patterns/Table/tableSortController.ts +25 -7
  80. package/src/styles/interaction.css +16 -0
  81. package/src/styles/style.css +4 -0
@@ -1,319 +1,707 @@
1
+ import type {
2
+ TableProgressiveEntry,
3
+ TableProgressiveModel,
4
+ } from '../../display/contracts';
5
+ import { tableProgressiveBatchSize } from '../../display/tableProgressive';
6
+
1
7
  const initializedAttribute = 'data-table-progressive-initialized';
2
8
 
3
- type ProgressiveState = 'idle' | 'loading' | 'error' | 'complete';
9
+ type PaginationState = 'idle' | 'loading' | 'error' | 'complete';
10
+ type TableLoadingState =
11
+ | 'pending'
12
+ | 'ready'
13
+ | 'refreshing'
14
+ | 'appending'
15
+ | 'empty'
16
+ | 'error';
17
+ type SortDirection = 'ascending' | 'descending';
18
+ type SortType = 'number' | 'text';
19
+ type SortValue = string | number | null;
4
20
 
5
21
  export type TableRowsAppendedDetail = {
6
22
  rows: readonly HTMLElement[];
7
23
  };
8
24
 
25
+ export type TableProgressiveFilterState = {
26
+ search: string;
27
+ selections: Readonly<Record<string, string>>;
28
+ };
29
+
30
+ export type TableProgressiveSortState = {
31
+ id: string;
32
+ type: SortType;
33
+ direction: SortDirection;
34
+ tieBreaker?: string;
35
+ } | null;
36
+
37
+ export type TableProgressiveEntryUpdate = {
38
+ sortValues?: Readonly<Record<string, SortValue>>;
39
+ exclusion?: {
40
+ key: string;
41
+ excluded: boolean;
42
+ };
43
+ };
44
+
45
+ type IndexedEntry = TableProgressiveEntry & {
46
+ index: number;
47
+ exclusions: Set<string>;
48
+ normalizedSearch: readonly string[];
49
+ sortValues: Readonly<Record<string, SortValue>>;
50
+ };
51
+
52
+ type ProgressiveContext = {
53
+ destroy(): void;
54
+ progressive: true;
55
+ refresh(): void;
56
+ setFilters(state: TableProgressiveFilterState): void;
57
+ setSort(state: TableProgressiveSortState): void;
58
+ updateEntry(key: string, update: TableProgressiveEntryUpdate): void;
59
+ };
60
+
61
+ const contexts = new WeakMap<HTMLElement, ProgressiveContext>();
62
+
9
63
  export function initTableProgressive(root: HTMLElement): () => void {
10
64
  if (root.getAttribute(initializedAttribute) === 'true') {
11
65
  return () => undefined;
12
66
  }
13
67
 
14
- const tableKey = root.dataset.tableKey;
68
+ const manifest = readManifest(root);
15
69
  const rowsRoot = root.querySelector<HTMLElement>('[data-table-rows]');
16
- const pagination = root.querySelector<HTMLElement>('[data-table-pagination]');
17
- const link = pagination?.querySelector<HTMLAnchorElement>(
18
- '[data-table-pagination-link]',
70
+ const skeletonTemplate = root.querySelector<HTMLTemplateElement>(
71
+ ':scope > [data-table-skeleton-template]',
19
72
  );
73
+
74
+ if (!rowsRoot || !skeletonTemplate) {
75
+ return () => undefined;
76
+ }
77
+
78
+ if (!manifest) {
79
+ if (root.dataset.tablePreparation !== 'controlled') {
80
+ revealServerRows(root, rowsRoot);
81
+ }
82
+ return () => undefined;
83
+ }
84
+
85
+ const pagination = root.querySelector<HTMLElement>('[data-table-pagination]');
20
86
  const sentinel = pagination?.querySelector<HTMLElement>(
21
87
  '[data-table-pagination-sentinel]',
22
88
  );
23
89
  const status = pagination?.querySelector<HTMLElement>(
24
90
  '[data-table-pagination-status]',
25
91
  );
26
- const skeletonTemplate = pagination?.querySelector<HTMLTemplateElement>(
27
- '[data-table-skeleton-template]',
28
- );
92
+ if (!pagination || !sentinel || !status) {
93
+ return () => undefined;
94
+ }
29
95
 
30
- if (
31
- !tableKey ||
32
- !rowsRoot ||
33
- !pagination ||
34
- !link ||
35
- !sentinel ||
36
- !status ||
37
- !skeletonTemplate ||
38
- !('IntersectionObserver' in window)
39
- ) {
96
+ const fallbackLink = pagination.querySelector<HTMLAnchorElement>(
97
+ '[data-table-pagination-link]',
98
+ );
99
+ if (!('IntersectionObserver' in window)) {
100
+ if (root.dataset.tablePreparation !== 'controlled') {
101
+ revealServerRows(root, rowsRoot);
102
+ }
103
+ showFallback(pagination, fallbackLink);
40
104
  return () => undefined;
41
105
  }
42
106
 
43
107
  root.setAttribute(initializedAttribute, 'true');
44
- const activePagination = pagination;
45
- const activeLink = link;
46
- const activeStatus = status;
47
- activePagination.setAttribute('data-table-progressive-enhanced', 'true');
48
- activePagination.removeAttribute('data-surface');
49
- setState(activePagination, activeStatus, 'idle');
50
-
51
- const loadedUrls = new Set<string>();
108
+ pagination.hidden = false;
109
+ pagination.setAttribute('data-table-progressive-enhanced', 'true');
110
+ pagination.removeAttribute('data-surface');
111
+
112
+ const locale = document.documentElement.lang || undefined;
113
+ const normalize = (value: string): string =>
114
+ value
115
+ .normalize('NFD')
116
+ .replace(/\p{Diacritic}/gu, '')
117
+ .toLocaleLowerCase(locale);
118
+ const entries = manifest.entries.map(
119
+ (entry, index): IndexedEntry => ({
120
+ ...entry,
121
+ index,
122
+ exclusions: new Set(),
123
+ normalizedSearch: (entry.filters?.search ?? []).map(normalize),
124
+ sortValues: entry.sortValues ?? {},
125
+ }),
126
+ );
127
+ const entriesByKey = new Map(entries.map((entry) => [entry.key, entry]));
128
+ const rowsByKey = new Map<string, HTMLElement>();
129
+ const loadedSources = new Set<string>();
130
+ const batchSize = Math.max(
131
+ 1,
132
+ Math.floor(manifest.batchSize ?? tableProgressiveBatchSize),
133
+ );
134
+ const initialOffset = Math.min(
135
+ Math.max(0, Math.floor(manifest.initialOffset ?? 0)),
136
+ entries.length,
137
+ );
138
+ const empty = root.querySelector<HTMLElement>('[data-table-filter-empty]');
139
+ const collator = new Intl.Collator(locale, {
140
+ numeric: true,
141
+ sensitivity: 'base',
142
+ });
143
+ let filterState: TableProgressiveFilterState = {
144
+ search: '',
145
+ selections: {},
146
+ };
147
+ let sortState: TableProgressiveSortState = null;
148
+ let results = entries;
149
+ let cursor = 0;
52
150
  let request: AbortController | null = null;
53
- let skeletonRows: HTMLElement[] = [];
54
- let batchedRows: HTMLElement[] = [];
151
+ let skeletonRows = getSkeletonRows(rowsRoot);
55
152
  let disposed = false;
56
- let completed = false;
57
- let loadAllRequested = false;
153
+ let generation = 0;
154
+ let prepared = false;
155
+
156
+ registerRows(getRows(rowsRoot));
58
157
 
59
158
  const observer = new IntersectionObserver(
60
- (entries) => {
61
- if (entries.some((entry) => entry.isIntersecting)) {
62
- void loadNextPage();
159
+ (observedEntries) => {
160
+ if (observedEntries.some((entry) => entry.isIntersecting)) {
161
+ void renderNextBatch();
63
162
  }
64
163
  },
65
164
  { rootMargin: '600px 0px' },
66
165
  );
67
166
 
68
- const removeSkeleton = (): void => {
167
+ function registerRows(rows: readonly HTMLElement[]): void {
168
+ for (const row of rows) {
169
+ const key = row.dataset.tableRowKey;
170
+ if (key) rowsByKey.set(key, row);
171
+ }
172
+ }
173
+
174
+ function removeSkeleton(): void {
69
175
  skeletonRows.forEach((row) => row.remove());
70
176
  skeletonRows = [];
71
- };
72
-
73
- const addSkeleton = (): void => {
74
- if (skeletonRows.length > 0) return;
177
+ }
75
178
 
76
- const templateRows = skeletonTemplate.content.querySelectorAll<HTMLElement>(
77
- '[data-table-skeleton-row]',
78
- );
179
+ function addSkeleton(count: number): void {
180
+ removeSkeleton();
181
+ const candidates = Array.from(
182
+ skeletonTemplate.content.querySelectorAll<HTMLElement>(
183
+ '[data-table-skeleton-row]',
184
+ ),
185
+ ).slice(0, count);
186
+ skeletonRows = candidates.map((row) => document.importNode(row, true));
187
+ insertBeforeEmpty(rowsRoot, empty, skeletonRows);
188
+ }
79
189
 
80
- skeletonRows = Array.from(templateRows, (row) =>
81
- document.importNode(row, true),
82
- );
83
- rowsRoot.append(...skeletonRows);
84
- };
190
+ function detachRenderedRows(): HTMLElement[] {
191
+ const rendered = getRows(rowsRoot);
192
+ rendered.forEach((row) => row.remove());
193
+ return rendered;
194
+ }
85
195
 
86
- const appendRows = (rows: readonly HTMLElement[]): void => {
196
+ function appendRows(rows: readonly HTMLElement[]): void {
87
197
  if (rows.length === 0) return;
88
-
89
- const fragment = document.createDocumentFragment();
90
- for (const row of rows) fragment.append(row);
91
- rowsRoot.append(fragment);
198
+ for (const row of rows) row.hidden = false;
199
+ insertBeforeEmpty(rowsRoot, empty, rows);
92
200
  root.dispatchEvent(
93
201
  new CustomEvent<TableRowsAppendedDetail>('table:rows-appended', {
94
202
  bubbles: true,
95
203
  detail: { rows },
96
204
  }),
97
205
  );
98
- };
99
-
100
- const flushBatchedRows = (): void => {
101
- if (batchedRows.length === 0) {
102
- removeSkeleton();
103
- return;
104
- }
206
+ }
105
207
 
106
- const rows = batchedRows;
107
- batchedRows = [];
108
- removeSkeleton();
109
- appendRows(rows);
110
- };
208
+ function updateEmptyState(): void {
209
+ if (empty) empty.hidden = results.length > 0;
210
+ }
111
211
 
112
- const complete = (): void => {
113
- completed = true;
114
- flushBatchedRows();
115
- loadAllRequested = false;
212
+ function updatePagination(): void {
116
213
  observer.disconnect();
117
- activePagination.removeAttribute('data-surface');
118
- activePagination.hidden = true;
119
- setState(activePagination, activeStatus, 'complete');
120
- root.removeAttribute('data-table-progressive-loading-all');
121
- root.dispatchEvent(
122
- new CustomEvent('table:progressive-complete', { bubbles: true }),
214
+ const complete = cursor >= results.length;
215
+ pagination.hidden = complete;
216
+ setPaginationState(pagination, status, complete ? 'complete' : 'idle');
217
+ if (!complete) observer.observe(sentinel);
218
+ }
219
+
220
+ function updateTableState(state: TableLoadingState): void {
221
+ root.dataset.tableLoadingState = state;
222
+ if (
223
+ state === 'pending' ||
224
+ state === 'refreshing' ||
225
+ state === 'appending'
226
+ ) {
227
+ root.setAttribute('aria-busy', 'true');
228
+ } else {
229
+ root.removeAttribute('aria-busy');
230
+ }
231
+ }
232
+
233
+ function applyQuery(): void {
234
+ const query = normalize(filterState.search.trim());
235
+ const selections = Object.entries(filterState.selections).filter(
236
+ ([, value]) => value.length > 0,
123
237
  );
124
- };
125
238
 
126
- const showFallback = (): void => {
127
- activePagination.removeAttribute('data-table-progressive-enhanced');
128
- activePagination.setAttribute('data-surface', '');
129
- };
239
+ results = entries.filter((entry) => {
240
+ if (entry.exclusions.size > 0) return false;
241
+ if (
242
+ query.length > 0 &&
243
+ !entry.normalizedSearch.some((value) => value.includes(query))
244
+ ) {
245
+ return false;
246
+ }
247
+ return selections.every(([field, value]) =>
248
+ (entry.filters?.[field] ?? []).includes(value),
249
+ );
250
+ });
251
+
252
+ if (sortState) {
253
+ const state = sortState;
254
+ results = [...results].sort((left, right) => {
255
+ const comparison = compareValues(
256
+ left.sortValues[state.id],
257
+ right.sortValues[state.id],
258
+ state.type,
259
+ state.direction,
260
+ collator,
261
+ );
262
+ if (comparison !== 0) return comparison;
263
+
264
+ if (state.tieBreaker) {
265
+ const id = state.tieBreaker;
266
+ const tieComparison = compareValues(
267
+ left.sortValues[id],
268
+ right.sortValues[id],
269
+ inferSortType(left.sortValues[id], right.sortValues[id]),
270
+ 'ascending',
271
+ collator,
272
+ );
273
+ if (tieComparison !== 0) return tieComparison;
274
+ }
275
+
276
+ return left.index - right.index;
277
+ });
278
+ }
279
+ }
130
280
 
131
- async function loadNextPage(): Promise<void> {
132
- if (request || disposed || completed) {
281
+ async function renderNextBatch(
282
+ loadingState: Extract<
283
+ TableLoadingState,
284
+ 'pending' | 'refreshing' | 'appending'
285
+ > = 'appending',
286
+ ): Promise<void> {
287
+ if (request || disposed || cursor >= results.length) return;
288
+
289
+ const target = results.slice(cursor, cursor + batchSize);
290
+ if (target.length === 0) {
291
+ removeSkeleton();
292
+ updatePagination();
293
+ updateTableState(results.length === 0 ? 'empty' : 'ready');
133
294
  return;
134
295
  }
135
296
 
136
- const nextUrl = new URL(activeLink.href, window.location.href);
137
- if (nextUrl.origin !== window.location.origin) {
138
- setState(activePagination, activeStatus, 'error');
139
- showFallback();
297
+ const missing = target.filter((entry) => !rowsByKey.has(entry.key));
298
+ const activeGeneration = generation;
299
+
300
+ if (missing.length > 0) {
301
+ const activeRequest = new AbortController();
302
+ request = activeRequest;
303
+ setPaginationState(pagination, status, 'loading');
304
+ updateTableState(loadingState);
305
+ addSkeleton(target.length);
306
+
307
+ try {
308
+ await loadSources(missing, activeRequest.signal);
309
+ } catch (error) {
310
+ if (error instanceof DOMException && error.name === 'AbortError') return;
311
+ removeSkeleton();
312
+ setPaginationState(pagination, status, 'error');
313
+ updateTableState('error');
314
+ showFallback(pagination, fallbackLink);
315
+ observer.disconnect();
316
+ return;
317
+ } finally {
318
+ if (request === activeRequest) {
319
+ request = null;
320
+ }
321
+ }
322
+ }
323
+
324
+ if (disposed || activeGeneration !== generation) return;
325
+ const rows = target.map((entry) => rowsByKey.get(entry.key));
326
+ if (rows.some((row) => !row)) {
327
+ removeSkeleton();
328
+ setPaginationState(pagination, status, 'error');
329
+ updateTableState('error');
330
+ showFallback(pagination, fallbackLink);
140
331
  observer.disconnect();
141
332
  return;
142
333
  }
143
334
 
144
- const requestKey = nextUrl.href;
145
- if (loadedUrls.has(requestKey)) {
146
- return;
335
+ removeSkeleton();
336
+ appendRows(rows as HTMLElement[]);
337
+ cursor += target.length;
338
+ updatePagination();
339
+ updateTableState('ready');
340
+ }
341
+
342
+ async function loadSources(
343
+ missingEntries: readonly IndexedEntry[],
344
+ signal: AbortSignal,
345
+ ): Promise<void> {
346
+ const sourceIds = new Set(
347
+ missingEntries.map((entry) => entry.source).filter(isString),
348
+ );
349
+ if (sourceIds.size === 0 || !manifest.sources) {
350
+ throw new Error('Missing progressive table row source');
147
351
  }
148
352
 
149
- request = new AbortController();
150
- setState(activePagination, activeStatus, 'loading');
151
- root.setAttribute('aria-busy', 'true');
353
+ await Promise.all(
354
+ [...sourceIds].map(async (sourceId) => {
355
+ if (loadedSources.has(sourceId)) return;
356
+ const href = manifest.sources?.[sourceId];
357
+ if (!href) throw new Error(`Missing table source: ${sourceId}`);
358
+
359
+ const url = new URL(href, window.location.href);
360
+ if (url.origin !== window.location.origin) {
361
+ throw new Error('Table sources must use the current origin');
362
+ }
363
+ const response = await fetch(url, {
364
+ credentials: 'same-origin',
365
+ headers: { Accept: 'text/html' },
366
+ signal,
367
+ });
368
+ if (!response.ok) {
369
+ throw new Error(`Unable to load table source: ${response.status}`);
370
+ }
371
+
372
+ const documentNode = new DOMParser().parseFromString(
373
+ await response.text(),
374
+ 'text/html',
375
+ );
376
+ const tableKey = root.dataset.tableKey;
377
+ const sourceTable = tableKey
378
+ ? Array.from(
379
+ documentNode.querySelectorAll<HTMLElement>('[data-table-key]'),
380
+ ).find((table) => table.dataset.tableKey === tableKey)
381
+ : null;
382
+ const sourceRowsRoot = sourceTable?.querySelector<HTMLElement>(
383
+ '[data-table-rows]',
384
+ );
385
+ const sourceRows = sourceRowsRoot ? getRows(sourceRowsRoot) : [];
386
+ if (sourceRows.length === 0) {
387
+ throw new Error(`Missing progressive table: ${tableKey ?? 'unknown'}`);
388
+ }
389
+
390
+ registerRows(sourceRows.map((row) => document.importNode(row, true)));
391
+ loadedSources.add(sourceId);
392
+ }),
393
+ );
394
+ }
152
395
 
153
- let continueLoading = false;
396
+ function prepareWindow(
397
+ offset: number,
398
+ state: Extract<TableLoadingState, 'pending' | 'refreshing'>,
399
+ ): void {
400
+ generation += 1;
401
+ request?.abort();
402
+ request = null;
403
+ updateTableState(state);
404
+ addSkeleton(batchSize);
405
+ detachRenderedRows();
406
+ applyQuery();
407
+ cursor = Math.min(offset, results.length);
408
+ updateEmptyState();
409
+ if (results.length === 0) {
410
+ removeSkeleton();
411
+ updatePagination();
412
+ updateTableState('empty');
413
+ return;
414
+ }
415
+ void renderNextBatch(state);
416
+ }
154
417
 
155
- try {
156
- addSkeleton();
157
- const response = await fetch(nextUrl, {
158
- credentials: 'same-origin',
159
- headers: { Accept: 'text/html' },
160
- signal: request.signal,
161
- });
418
+ function refresh(): void {
419
+ if (!prepared) {
420
+ prepared = true;
421
+ prepareWindow(initialOffset, 'pending');
422
+ return;
423
+ }
424
+ prepareWindow(0, 'refreshing');
425
+ }
162
426
 
163
- if (!response.ok) {
164
- throw new Error(`Unable to load table page: ${response.status}`);
427
+ const context: ProgressiveContext = {
428
+ destroy() {
429
+ disposed = true;
430
+ request?.abort();
431
+ observer.disconnect();
432
+ removeSkeleton();
433
+ pagination.hidden = !fallbackLink;
434
+ pagination.removeAttribute('data-table-progressive-enhanced');
435
+ pagination.removeAttribute('data-table-progressive-state');
436
+ if (!pagination.hidden) pagination.setAttribute('data-surface', '');
437
+ status.hidden = true;
438
+ status.textContent = '';
439
+ root.removeAttribute('aria-busy');
440
+ root.removeAttribute('data-table-loading-state');
441
+ root.removeAttribute(initializedAttribute);
442
+ contexts.delete(root);
443
+ },
444
+ progressive: true,
445
+ refresh,
446
+ setFilters(state) {
447
+ if (areFilterStatesEqual(filterState, state)) return;
448
+ filterState = state;
449
+ if (prepared) prepareWindow(0, 'refreshing');
450
+ },
451
+ setSort(state) {
452
+ if (areSortStatesEqual(sortState, state)) return;
453
+ sortState = state;
454
+ if (prepared) prepareWindow(0, 'refreshing');
455
+ },
456
+ updateEntry(key, update) {
457
+ const entry = entriesByKey.get(key);
458
+ if (!entry) return;
459
+ if (update.sortValues) {
460
+ entry.sortValues = { ...entry.sortValues, ...update.sortValues };
165
461
  }
166
-
167
- const nextDocument = new DOMParser().parseFromString(
168
- await response.text(),
169
- 'text/html',
170
- );
171
- const nextTable = Array.from(
172
- nextDocument.querySelectorAll<HTMLElement>('[data-table-key]'),
173
- ).find((table) => table.dataset.tableKey === tableKey);
174
- const nextRowsRoot =
175
- nextTable?.querySelector<HTMLElement>('[data-table-rows]');
176
-
177
- if (!nextTable || !nextRowsRoot) {
178
- throw new Error(`Missing paginated table: ${tableKey}`);
462
+ if (update.exclusion) {
463
+ if (update.exclusion.excluded) {
464
+ entry.exclusions.add(update.exclusion.key);
465
+ } else {
466
+ entry.exclusions.delete(update.exclusion.key);
467
+ }
179
468
  }
469
+ },
470
+ };
180
471
 
181
- const nextRows = Array.from(
182
- nextRowsRoot.querySelectorAll<HTMLElement>(':scope > [data-table-row]'),
183
- );
184
- if (nextRows.length === 0) {
185
- throw new Error(`Missing paginated table rows: ${tableKey}`);
186
- }
472
+ contexts.set(root, context);
473
+ if (root.dataset.tablePreparation !== 'controlled') refresh();
187
474
 
188
- const nextPagination = nextTable.querySelector<HTMLElement>(
189
- '[data-table-pagination]',
190
- );
191
- const nextLink = nextPagination?.querySelector<HTMLAnchorElement>(
192
- '[data-table-pagination-link]',
193
- );
475
+ return context.destroy;
476
+ }
194
477
 
195
- const importedRows = nextRows.map((row) =>
196
- document.importNode(row, true),
197
- );
198
- if (loadAllRequested) {
199
- batchedRows.push(...importedRows);
200
- } else {
201
- removeSkeleton();
202
- appendRows(importedRows);
203
- }
204
- loadedUrls.add(requestKey);
478
+ export function setTableProgressiveFilters(
479
+ root: HTMLElement,
480
+ state: TableProgressiveFilterState,
481
+ ): boolean {
482
+ const context = getProgressiveContext(root);
483
+ if (!context) return false;
484
+ context.setFilters(state);
485
+ return true;
486
+ }
205
487
 
206
- if (!nextPagination || !nextLink) {
207
- complete();
208
- return;
209
- }
488
+ export function setTableProgressiveSort(
489
+ root: HTMLElement,
490
+ state: TableProgressiveSortState,
491
+ ): boolean {
492
+ const context = getProgressiveContext(root);
493
+ if (!context) return false;
494
+ context.setSort(state);
495
+ return true;
496
+ }
210
497
 
211
- const nextHref = nextLink.getAttribute('href');
212
- if (!nextHref) {
213
- complete();
214
- return;
215
- }
498
+ export function updateTableProgressiveEntry(
499
+ root: HTMLElement,
500
+ key: string,
501
+ update: TableProgressiveEntryUpdate,
502
+ ): boolean {
503
+ const context = getProgressiveContext(root);
504
+ if (!context) return false;
505
+ context.updateEntry(key, update);
506
+ return true;
507
+ }
216
508
 
217
- activeLink.href = nextHref;
218
- copyDatasetValue(activePagination, nextPagination, 'tablePage');
219
- copyDatasetValue(activePagination, nextPagination, 'tableTotalPages');
220
- continueLoading = loadAllRequested;
221
- if (!continueLoading) setState(activePagination, activeStatus, 'idle');
222
- } catch (error) {
223
- if (error instanceof DOMException && error.name === 'AbortError') {
224
- return;
225
- }
226
- flushBatchedRows();
227
- loadAllRequested = false;
228
- root.removeAttribute('data-table-progressive-loading-all');
229
- setState(activePagination, activeStatus, 'error');
230
- showFallback();
231
- observer.disconnect();
232
- } finally {
233
- request = null;
234
- if (!loadAllRequested) root.removeAttribute('aria-busy');
235
- if (continueLoading && !disposed) void loadNextPage();
236
- }
509
+ export function refreshTableProgressive(root: HTMLElement): boolean {
510
+ const context = getProgressiveContext(root);
511
+ if (!context) return false;
512
+ context.refresh();
513
+ return true;
514
+ }
515
+
516
+ export function hasTableProgressive(root: HTMLElement): boolean {
517
+ return getProgressiveContext(root)?.progressive === true;
518
+ }
519
+
520
+ export function resolveTablePreparation(root: HTMLElement): void {
521
+ const context = getProgressiveContext(root);
522
+ if (context) {
523
+ context.refresh();
524
+ return;
237
525
  }
238
526
 
239
- const handleLoadAll = (): void => {
240
- if (completed) return;
241
- loadAllRequested = true;
242
- root.setAttribute('data-table-progressive-loading-all', 'true');
243
- void loadNextPage();
244
- };
527
+ const rowsRoot = root.querySelector<HTMLElement>('[data-table-rows]');
528
+ if (rowsRoot) revealServerRows(root, rowsRoot);
529
+ }
245
530
 
246
- const handleClick = (event: MouseEvent): void => {
247
- if (
248
- event.defaultPrevented ||
249
- event.button !== 0 ||
250
- event.metaKey ||
251
- event.ctrlKey ||
252
- event.shiftKey ||
253
- event.altKey
254
- ) {
255
- return;
256
- }
531
+ function getProgressiveContext(
532
+ root: HTMLElement,
533
+ ): ProgressiveContext | undefined {
534
+ const existing = contexts.get(root);
535
+ if (existing) return existing;
536
+ if (
537
+ !root.querySelector(':scope > [data-table-progressive-manifest]')
538
+ ) {
539
+ return undefined;
540
+ }
541
+ initTableProgressive(root);
542
+ return contexts.get(root);
543
+ }
257
544
 
258
- if (activePagination.dataset.tableProgressiveState === 'error') {
259
- return;
260
- }
545
+ function readManifest(root: HTMLElement): TableProgressiveModel | null {
546
+ const node = root.querySelector<HTMLScriptElement>(
547
+ ':scope > [data-table-progressive-manifest]',
548
+ );
549
+ if (!node?.textContent) return null;
550
+
551
+ try {
552
+ const value: unknown = JSON.parse(node.textContent);
553
+ if (!isRecord(value) || !Array.isArray(value.entries)) return null;
554
+ const entries = value.entries.filter(isProgressiveEntry);
555
+ if (entries.length !== value.entries.length) return null;
556
+ return {
557
+ entries,
558
+ sources: isStringRecord(value.sources) ? value.sources : undefined,
559
+ batchSize: isFiniteNumber(value.batchSize) ? value.batchSize : undefined,
560
+ initialOffset: isFiniteNumber(value.initialOffset)
561
+ ? value.initialOffset
562
+ : undefined,
563
+ };
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
261
568
 
262
- event.preventDefault();
263
- observer.observe(sentinel);
264
- void loadNextPage();
265
- };
569
+ function getRows(root: ParentNode): HTMLElement[] {
570
+ return Array.from(
571
+ root.querySelectorAll<HTMLElement>(':scope > [data-table-row]'),
572
+ );
573
+ }
266
574
 
267
- activeLink.addEventListener('click', handleClick);
268
- root.addEventListener('table:load-all', handleLoadAll);
269
- observer.observe(sentinel);
575
+ function getSkeletonRows(root: ParentNode): HTMLElement[] {
576
+ return Array.from(
577
+ root.querySelectorAll<HTMLElement>(
578
+ ':scope > [data-table-skeleton-row]',
579
+ ),
580
+ );
581
+ }
270
582
 
271
- return () => {
272
- disposed = true;
273
- request?.abort();
274
- observer.disconnect();
275
- activeLink.removeEventListener('click', handleClick);
276
- root.removeEventListener('table:load-all', handleLoadAll);
277
- flushBatchedRows();
278
- activePagination.hidden = false;
279
- activePagination.setAttribute('data-surface', '');
280
- activePagination.removeAttribute('data-table-progressive-enhanced');
281
- activePagination.removeAttribute('data-table-progressive-state');
282
- activeStatus.hidden = true;
283
- activeStatus.textContent = '';
284
- root.removeAttribute('data-table-progressive-loading-all');
285
- root.removeAttribute('aria-busy');
286
- root.removeAttribute(initializedAttribute);
287
- };
583
+ function revealServerRows(root: HTMLElement, rowsRoot: HTMLElement): void {
584
+ getSkeletonRows(rowsRoot).forEach((row) => row.remove());
585
+ root.dataset.tableLoadingState = getRows(rowsRoot).some(
586
+ (row) => !row.hidden,
587
+ )
588
+ ? 'ready'
589
+ : 'empty';
590
+ root.removeAttribute('aria-busy');
288
591
  }
289
592
 
290
- function setState(
593
+ function insertBeforeEmpty(
594
+ rowsRoot: HTMLElement,
595
+ empty: HTMLElement | null,
596
+ rows: readonly HTMLElement[],
597
+ ): void {
598
+ const fragment = document.createDocumentFragment();
599
+ rows.forEach((row) => fragment.append(row));
600
+ rowsRoot.insertBefore(fragment, empty);
601
+ }
602
+
603
+ function setPaginationState(
291
604
  pagination: HTMLElement,
292
605
  status: HTMLElement,
293
- state: ProgressiveState,
606
+ state: PaginationState,
294
607
  ): void {
295
608
  pagination.dataset.tableProgressiveState = state;
296
609
  status.hidden = state !== 'loading' && state !== 'error';
297
-
298
- if (state === 'loading') {
299
- status.textContent = pagination.dataset.tableLoadingLabel ?? '';
300
- } else if (state === 'error') {
301
- status.textContent = pagination.dataset.tableErrorLabel ?? '';
302
- } else {
303
- status.textContent = '';
304
- }
610
+ status.textContent =
611
+ state === 'loading'
612
+ ? (pagination.dataset.tableLoadingLabel ?? '')
613
+ : state === 'error'
614
+ ? (pagination.dataset.tableErrorLabel ?? '')
615
+ : '';
305
616
  }
306
617
 
307
- function copyDatasetValue(
308
- target: HTMLElement,
309
- source: HTMLElement,
310
- key: 'tablePage' | 'tableTotalPages',
618
+ function showFallback(
619
+ pagination: HTMLElement,
620
+ link: HTMLAnchorElement | null,
311
621
  ): void {
312
- const value = source.dataset[key];
622
+ if (!link) return;
623
+ pagination.hidden = false;
624
+ pagination.removeAttribute('data-table-progressive-enhanced');
625
+ pagination.setAttribute('data-surface', '');
626
+ }
313
627
 
314
- if (value === undefined) {
315
- delete target.dataset[key];
316
- } else {
317
- target.dataset[key] = value;
628
+ function compareValues(
629
+ left: SortValue | undefined,
630
+ right: SortValue | undefined,
631
+ type: SortType,
632
+ direction: SortDirection,
633
+ collator: Intl.Collator,
634
+ ): number {
635
+ const leftMissing = left === null || left === undefined;
636
+ const rightMissing = right === null || right === undefined;
637
+ if (leftMissing || rightMissing) {
638
+ if (leftMissing && rightMissing) return 0;
639
+ return leftMissing ? 1 : -1;
318
640
  }
641
+
642
+ const comparison =
643
+ type === 'number'
644
+ ? Number(left) - Number(right)
645
+ : collator.compare(String(left), String(right));
646
+ return direction === 'ascending' ? comparison : -comparison;
647
+ }
648
+
649
+ function areFilterStatesEqual(
650
+ left: TableProgressiveFilterState,
651
+ right: TableProgressiveFilterState,
652
+ ): boolean {
653
+ if (left.search !== right.search) return false;
654
+ const leftEntries = Object.entries(left.selections);
655
+ const rightEntries = Object.entries(right.selections);
656
+ return (
657
+ leftEntries.length === rightEntries.length &&
658
+ leftEntries.every(([key, value]) => right.selections[key] === value)
659
+ );
660
+ }
661
+
662
+ function areSortStatesEqual(
663
+ left: TableProgressiveSortState,
664
+ right: TableProgressiveSortState,
665
+ ): boolean {
666
+ if (left === right) return true;
667
+ return Boolean(
668
+ left &&
669
+ right &&
670
+ left.id === right.id &&
671
+ left.type === right.type &&
672
+ left.direction === right.direction &&
673
+ left.tieBreaker === right.tieBreaker,
674
+ );
675
+ }
676
+
677
+ function inferSortType(
678
+ left: SortValue | undefined,
679
+ right: SortValue | undefined,
680
+ ): SortType {
681
+ return typeof left === 'number' || typeof right === 'number'
682
+ ? 'number'
683
+ : 'text';
684
+ }
685
+
686
+ function isProgressiveEntry(value: unknown): value is TableProgressiveEntry {
687
+ return isRecord(value) && typeof value.key === 'string';
688
+ }
689
+
690
+ function isRecord(value: unknown): value is Record<string, unknown> {
691
+ return typeof value === 'object' && value !== null;
692
+ }
693
+
694
+ function isString(value: string | undefined): value is string {
695
+ return typeof value === 'string';
696
+ }
697
+
698
+ function isStringRecord(value: unknown): value is Record<string, string> {
699
+ return (
700
+ isRecord(value) &&
701
+ Object.values(value).every((entry) => typeof entry === 'string')
702
+ );
703
+ }
704
+
705
+ function isFiniteNumber(value: unknown): value is number {
706
+ return typeof value === 'number' && Number.isFinite(value);
319
707
  }