sveltekit-admin 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +71 -11
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +42 -3
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/data.d.ts +18 -2
  18. package/dist/server/data.js +39 -8
  19. package/dist/server/errors.d.ts +47 -0
  20. package/dist/server/errors.js +90 -0
  21. package/dist/server/handler.d.ts +92 -26
  22. package/dist/server/handler.js +263 -560
  23. package/dist/server/introspection/parser.d.ts +15 -0
  24. package/dist/server/introspection/parser.js +17 -0
  25. package/dist/server/mutations.d.ts +13 -0
  26. package/dist/server/mutations.js +476 -0
  27. package/dist/server/plugin.d.ts +47 -0
  28. package/dist/server/plugin.js +1 -0
  29. package/dist/server/pluginAccess.d.ts +7 -0
  30. package/dist/server/pluginAccess.js +79 -0
  31. package/dist/server/pluginRegistry.d.ts +12 -0
  32. package/dist/server/pluginRegistry.js +72 -0
  33. package/dist/server/query/listColumns.d.ts +19 -0
  34. package/dist/server/query/listColumns.js +43 -0
  35. package/dist/server/query/listQuery.d.ts +1 -1
  36. package/dist/server/query/pageSize.d.ts +19 -0
  37. package/dist/server/query/pageSize.js +26 -0
  38. package/dist/server/query/sortQuery.d.ts +31 -0
  39. package/dist/server/query/sortQuery.js +33 -0
  40. package/dist/server/query/urls.js +9 -1
  41. package/dist/server/relationLoaders.d.ts +42 -0
  42. package/dist/server/relationLoaders.js +188 -0
  43. package/dist/server/router.d.ts +10 -0
  44. package/dist/server/router.js +42 -19
  45. package/dist/server/runtime.d.ts +51 -0
  46. package/dist/server/runtime.js +263 -0
  47. package/dist/server/search.d.ts +14 -0
  48. package/dist/server/search.js +78 -0
  49. package/dist/server/submitted.d.ts +20 -0
  50. package/dist/server/submitted.js +54 -0
  51. package/dist/server/views/FieldInput.svelte +114 -8
  52. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  53. package/dist/server/views/Form.svelte +96 -5
  54. package/dist/server/views/Form.svelte.d.ts +19 -1
  55. package/dist/server/views/Layout.svelte +32 -4
  56. package/dist/server/views/Layout.svelte.d.ts +2 -0
  57. package/dist/server/views/List.svelte +156 -26
  58. package/dist/server/views/List.svelte.d.ts +7 -2
  59. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  60. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  61. package/dist/server/views/RelationSelect.svelte +14 -3
  62. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  63. package/dist/server/views/html.d.ts +19 -0
  64. package/dist/server/views/html.js +25 -0
  65. package/dist/server/views/pagination.d.ts +9 -0
  66. package/dist/server/views/pagination.js +31 -0
  67. package/dist/server/views/theme.js +147 -3
  68. package/dist/server/views/types.d.ts +15 -0
  69. package/package.json +24 -21
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
- import type { ViewModel } from './types.js';
3
+ import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
4
4
  import type { ListQuery } from '../query/listQuery.js';
5
5
  import type { ResolvedFilterField } from '../query/filterDetection.js';
6
+ import type { SortState } from '../query/sortQuery.js';
6
7
  import { DATETIME_PRESETS } from '../query/filterDetection.js';
7
- import type { FkFilterMeta } from './types.js';
8
- import { getDisplayFields } from '../introspection/parser.js';
8
+ import { resolveListColumns } from '../query/listColumns.js';
9
9
  import { buildListUrl, hiddenParams } from '../query/urls.js';
10
+ import { paginationWindow } from './pagination.js';
10
11
  import { escapeHtml, toLabel, formatValue } from './html.js';
11
12
  import ListFilters from './ListFilters.svelte';
12
13
 
@@ -19,7 +20,10 @@
19
20
  query,
20
21
  currentUrl,
21
22
  listFilters,
22
- fkFilterMeta
23
+ fkFilterMeta,
24
+ sort,
25
+ pageSizes = [],
26
+ recordActions = []
23
27
  }: {
24
28
  model: ViewModel;
25
29
  items: any[];
@@ -34,30 +38,59 @@
34
38
  listFilters?: ResolvedFilterField[];
35
39
  /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
36
40
  fkFilterMeta?: Map<string, FkFilterMeta>;
41
+ /** Tri actif + drapeau « colonne refusée ». Absent = en-têtes non cliquables. */
42
+ sort?: SortState;
43
+ /** Tailles de page proposées. Vide/absent = pas de sélecteur. */
44
+ pageSizes?: number[];
45
+ recordActions?: ListRecordAction[];
37
46
  } = $props();
38
47
 
39
48
  const modelConfig = $derived(config.models?.[model.name] || {});
40
49
  const hidden = $derived(modelConfig.hidden || []);
41
50
  const listFields = $derived(modelConfig.listFields);
42
51
 
43
- const displayFields = $derived.by(() => {
44
- const explicit = new Set(listFields ?? []);
45
- const safeNames = new Set(getDisplayFields(model).map((f) => f.name));
52
+ // Résolu par `resolveListColumns` et non ici : le handler applique la MÊME
53
+ // fonction pour décider quels champs un `?sort=` a le droit de désigner.
54
+ const displayFields = $derived(resolveListColumns(model.fields, { hidden, listFields }));
46
55
 
47
- let fields = model.fields.filter(
48
- (f) =>
49
- (explicit.has(f.name) || safeNames.has(f.name)) &&
50
- !hidden.includes(f.name) &&
51
- !f.relation &&
52
- !['Json', 'Bytes'].includes(f.type)
53
- );
54
-
55
- if (listFields?.length) {
56
- fields = fields.filter((f) => listFields.includes(f.name));
57
- }
56
+ /**
57
+ * En-têtes cliquables seulement quand l'URL courante est connue : sans elle
58
+ * on ne sait pas construire un lien qui préserve recherche et filtres, et un
59
+ * en-tête qui les effacerait silencieusement serait pire que pas de tri.
60
+ * Même convention que la pagination.
61
+ */
62
+ const sortable = $derived(Boolean(currentUrl));
58
63
 
59
- return fields.slice(0, 6);
64
+ /**
65
+ * Compte rendu d'une suppression en masse, posé par la redirection après
66
+ * écriture. Validé comme n'importe quelle entrée d'URL : seul un entier
67
+ * positif est rendu, tout le reste est ignoré plutôt qu'affiché.
68
+ */
69
+ const deletedCount = $derived.by(() => {
70
+ const raw = currentUrl?.searchParams.get('deleted');
71
+ if (raw === null || raw === undefined) return null;
72
+ const parsed = Number(raw);
73
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
60
74
  });
75
+ const activeSort = $derived(sort?.active ?? null);
76
+
77
+ /**
78
+ * Cliquer une colonne déjà triée inverse la direction ; cliquer une autre
79
+ * colonne repart en ascendant. `buildListUrl` retire `page` de lui-même :
80
+ * changer de tri ne garde aucun sens sur la page 3, ce ne sont plus les
81
+ * mêmes lignes.
82
+ */
83
+ const sortHref = (field: string) => {
84
+ const dir = activeSort?.field === field && activeSort.dir === 'asc' ? 'desc' : null;
85
+ return buildListUrl(currentUrl!, { sort: field, dir });
86
+ };
87
+
88
+ const ariaSortFor = (field: string) =>
89
+ activeSort?.field !== field ? 'none' : activeSort.dir === 'asc' ? 'ascending' : 'descending';
90
+
91
+ /** Indicateur visuel, doublé par `aria-sort` côté lecteur d'écran. */
92
+ const sortMarkFor = (field: string) =>
93
+ activeSort?.field !== field ? '' : activeSort.dir === 'asc' ? ' ↑' : ' ↓';
61
94
 
62
95
  const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
63
96
  const totalPages = $derived(Math.ceil(pagination.total / pagination.perPage));
@@ -74,6 +107,14 @@
74
107
  if (!currentUrl) return (n: number) => `?page=${n}`;
75
108
  return (n: number) => buildListUrl(currentUrl, { page: String(n) });
76
109
  });
110
+ const pageWindow = $derived(paginationWindow(pagination.page, totalPages));
111
+ // Rendu en liens plutôt qu'en `<select>` : la page n'est jamais hydratée, un
112
+ // select aurait donc besoin soit d'un bouton « appliquer », soit d'un
113
+ // `onchange` inline. Quatre liens font le même travail sans script.
114
+ // `buildListUrl` retire `page` : changer de taille ne garde aucun sens sur la
115
+ // page 3, ce ne sont plus les mêmes lignes.
116
+ const sizeHref = (size: number) => buildListUrl(currentUrl!, { perPage: String(size) });
117
+ const showSizes = $derived(pageSizes.length > 0 && Boolean(currentUrl));
77
118
  const clearHref = $derived(currentUrl ? currentUrl.pathname : listPath);
78
119
  const searchHiddenParams = $derived(currentUrl ? hiddenParams(currentUrl, ['q']) : []);
79
120
 
@@ -126,6 +167,28 @@
126
167
  * pour un champ sensible que pour un champ inconnu (§0.a, §5.4) — ne
127
168
  * jamais dire "champ interdit", ça confirmerait son existence.
128
169
  */
170
+ // A per-row function (rather than {#each} in the markup) so an empty recordActions
171
+ // keeps this to the smallest possible footprint: Svelte 5's SSR wraps every
172
+ // {#each}/{@html} node in its own hydration-boundary comment regardless of the
173
+ // array's length/content (verified empirically — even {@html ''} still emits
174
+ // `<!--hash--><!---->`), so there is no template-level construct that renders zero
175
+ // bytes for an empty array here. Folding this into the pre-existing delete-form
176
+ // {@html} call (right below) was considered and rejected: recordActions must render
177
+ // *before* Edit (see list.test.ts "rend le lien avant Edit"), but the delete form's
178
+ // pre-existing {@html} — and thus its hydration marker — sits *after* Edit, so
179
+ // reusing it would either reorder Edit/recordActions or move the marker in front of
180
+ // Edit for every row, not just when recordActions is non-empty. Neither is
181
+ // byte-identical to the pre-recordActions baseline (see task-6-report.md fix-round-1
182
+ // notes). `action.label` and `hrefFor`'s return value are both escaped manually
183
+ // since this goes through @html instead of Svelte's auto-escaped text/attributes.
184
+ const recordActionsHtml = (id: string | number) =>
185
+ recordActions
186
+ .map(
187
+ (action) =>
188
+ `<a href="${escapeHtml(action.hrefFor(id))}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
189
+ )
190
+ .join('');
191
+
129
192
  const ignoredMessages = $derived.by(() => {
130
193
  return (query?.ignored ?? []).map((entry) => {
131
194
  // `param` est soit `f.<field>` / `f.<field>__<op>` (nouveau format),
@@ -181,11 +244,22 @@
181
244
  </form>
182
245
  {/if}
183
246
 
184
- {#if ignoredMessages.length > 0}
185
- <div class="ska-alert ska-alert--error">
247
+ {#if deletedCount !== null}
248
+ <div class="ska-alert ska-alert--success" role="status">
249
+ <p>{deletedCount} {deletedCount === 1 ? 'record' : 'records'} deleted</p>
250
+ </div>
251
+ {/if}
252
+
253
+ {#if ignoredMessages.length > 0 || sort?.ignored}
254
+ <div class="ska-alert ska-alert--error" role="alert">
186
255
  {#each ignoredMessages as m (m.key)}
187
256
  <p>{m.text}</p>
188
257
  {/each}
258
+ {#if sort?.ignored}
259
+ <!-- Même neutralité que pour un filtre refusé : ne jamais dire si la
260
+ colonne existe, seulement qu'on ne trie pas dessus. -->
261
+ <p>Ignored sort: this column cannot be sorted</p>
262
+ {/if}
189
263
  </div>
190
264
  {/if}
191
265
 
@@ -195,28 +269,53 @@
195
269
  </p>
196
270
  {/if}
197
271
 
272
+ {#if sortable}
273
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- chaîne littérale, sans donnée interpolée ; Svelte 5 refuse un onsubmit littéral comme attribut d'événement -->
274
+ {@html `<form id="ska-bulk" method="POST" action="${listPath}" class="ska-bulk" onsubmit="return confirm('Delete the selected records?')"><input type="hidden" name="_action" value="bulk-delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete selected</button></form>`}
275
+ {/if}
276
+
198
277
  <div class="ska-card">
199
278
  <div class="ska-table-wrap">
200
279
  <table class="ska-table">
201
280
  <thead>
202
281
  <tr>
203
- {#each displayFields as f (f.name)}<th>{toLabel(f.name)}</th>{/each}
204
- <th>Actions</th>
282
+ {#if sortable}
283
+ <th class="ska-table__select" scope="col">
284
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- chaîne littérale ; le « tout cocher » est le seul endroit de l'admin qui demande du script, et le reste fonctionne sans lui -->
285
+ {@html `<input type="checkbox" class="ska-checkbox" aria-label="Select all on this page" onclick="for (const box of this.closest('table').querySelectorAll('input[name=ids]')) box.checked = this.checked">`}
286
+ </th>
287
+ {/if}
288
+ {#each displayFields as f (f.name)}{#if sortable}<th scope="col" aria-sort={ariaSortFor(f.name)}><a href={sortHref(f.name)} class="ska-th-sort">{toLabel(f.name)}<span aria-hidden="true">{sortMarkFor(f.name)}</span></a></th>{:else}<th scope="col">{toLabel(f.name)}</th>{/if}{/each}
289
+ <th scope="col">Actions</th>
205
290
  </tr>
206
291
  </thead>
207
292
  <tbody>
208
293
  {#if items.length === 0}
209
294
  <tr>
210
- <td colspan={displayFields.length + 1} style="text-align: center; color: #64748b; padding: 2rem;">
295
+ <td colspan={displayFields.length + (sortable ? 2 : 1)} style="text-align: center; color: #64748b; padding: 2rem;">
211
296
  {hasActiveCriteria ? 'No results for these criteria' : 'No records found'}
212
297
  </td>
213
298
  </tr>
214
299
  {:else}
215
300
  {#each items as item (item[model.primaryKey])}
216
301
  <tr>
302
+ {#if sortable}
303
+ <td class="ska-table__select">
304
+ <input
305
+ type="checkbox"
306
+ class="ska-checkbox"
307
+ name="ids"
308
+ value={item[model.primaryKey]}
309
+ form="ska-bulk"
310
+ aria-label="Select record {item[model.primaryKey]}"
311
+ />
312
+ </td>
313
+ {/if}
217
314
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- formatValue already escapes string values itself and returns a literal <span> only for null/undefined -->
218
315
  {#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
219
316
  <td class="ska-table__actions">
317
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and hrefFor's return value via escapeHtml -->
318
+ {@html recordActionsHtml(item[model.primaryKey])}
220
319
  <a href="{listPath}/{item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
221
320
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- Svelte 5 rejects a literal onsubmit string as an event attribute; the PK is escaped manually here since it can't go through Svelte's native attribute escaping; the whole form (not just onsubmit) is rendered as raw HTML because there's no native-Svelte way to attach a plain inline onsubmit="..." string attribute at all in Svelte 5 templates, so the whole element had to be raw text to preserve the exact prior confirm-dialog behavior in a page that's never hydrated by a Svelte runtime -->
222
321
  {@html `<form method="POST" action="${listPath}/${escapeHtml(String(item[model.primaryKey]))}" style="display:inline" onsubmit="return confirm('Delete this item?')"><input type="hidden" name="_action" value="delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button></form>`}
@@ -233,8 +332,39 @@
233
332
  <span class="ska-pagination__info">
234
333
  Showing {(pagination.page - 1) * pagination.perPage + 1} to {Math.min(pagination.page * pagination.perPage, pagination.total)} of {pagination.total}
235
334
  </span>
236
- {#if pagination.page > 1}<a href={pageHref(pagination.page - 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>{/if}
237
- {#if pagination.page < totalPages}<a href={pageHref(pagination.page + 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>{/if}
335
+ <nav class="ska-pagination__pages" aria-label="Pagination">
336
+ {#if pagination.page > 1}
337
+ <a href={pageHref(1)} class="ska-btn ska-btn--secondary ska-btn--sm" aria-label="First page">«</a>
338
+ <a href={pageHref(pagination.page - 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>
339
+ {/if}
340
+ {#each pageWindow as entry, i (i)}
341
+ {#if entry === 'gap'}
342
+ <span class="ska-pagination__gap" aria-hidden="true">…</span>
343
+ {:else if entry === pagination.page}
344
+ <!-- Page courante : repère, pas lien. Un lien vers l'endroit où on
345
+ est déjà est un piège au clavier autant qu'un bruit inutile. -->
346
+ <span class="ska-btn ska-btn--sm ska-pagination__current" aria-current="page">{entry}</span>
347
+ {:else}
348
+ <a href={pageHref(entry)} class="ska-btn ska-btn--secondary ska-btn--sm">{entry}</a>
349
+ {/if}
350
+ {/each}
351
+ {#if pagination.page < totalPages}
352
+ <a href={pageHref(pagination.page + 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>
353
+ <a href={pageHref(totalPages)} class="ska-btn ska-btn--secondary ska-btn--sm" aria-label="Last page">»</a>
354
+ {/if}
355
+ </nav>
356
+ {#if showSizes}
357
+ <div class="ska-pagination__sizes">
358
+ <span class="ska-pagination__info">Rows</span>
359
+ {#each pageSizes as size (size)}
360
+ {#if size === pagination.perPage}
361
+ <span class="ska-btn ska-btn--sm ska-pagination__current" aria-current="true">{size}</span>
362
+ {:else}
363
+ <a href={sizeHref(size)} class="ska-btn ska-btn--secondary ska-btn--sm">{size}</a>
364
+ {/if}
365
+ {/each}
366
+ </div>
367
+ {/if}
238
368
  </div>
239
369
  {/if}
240
370
  </div>
@@ -1,8 +1,8 @@
1
1
  import type { AdminHandlerConfig } from '../handler.js';
2
- import type { ViewModel } from './types.js';
2
+ import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
3
3
  import type { ListQuery } from '../query/listQuery.js';
4
4
  import type { ResolvedFilterField } from '../query/filterDetection.js';
5
- import type { FkFilterMeta } from './types.js';
5
+ import type { SortState } from '../query/sortQuery.js';
6
6
  type $$ComponentProps = {
7
7
  model: ViewModel;
8
8
  items: any[];
@@ -21,6 +21,11 @@ type $$ComponentProps = {
21
21
  listFilters?: ResolvedFilterField[];
22
22
  /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
23
23
  fkFilterMeta?: Map<string, FkFilterMeta>;
24
+ /** Tri actif + drapeau « colonne refusée ». Absent = en-têtes non cliquables. */
25
+ sort?: SortState;
26
+ /** Tailles de page proposées. Vide/absent = pas de sélecteur. */
27
+ pageSizes?: number[];
28
+ recordActions?: ListRecordAction[];
24
29
  };
25
30
  declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
26
31
  type List = ReturnType<typeof List>;
@@ -1,19 +1,33 @@
1
1
  <script lang="ts">
2
2
  import type { RelationEdge } from '../introspection/relations.js';
3
3
  import type { RelationMeta } from './types.js';
4
- import { toLabel } from './html.js';
4
+ import { toLabel, fieldErrorAttrs } from './html.js';
5
5
 
6
6
  let {
7
7
  edge,
8
- meta
8
+ meta,
9
+ submittedIds,
10
+ errorMessage
9
11
  }: {
10
12
  edge: RelationEdge;
11
13
  meta: RelationMeta;
14
+ /**
15
+ * IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
16
+ * dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
17
+ * dit « pas un re-rendu d'erreur ». C'est la même distinction que le
18
+ * sentinelle `__rel_present__` côté écriture.
19
+ */
20
+ submittedIds?: string[];
21
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
22
+ errorMessage?: string;
12
23
  } = $props();
13
24
 
14
25
  const label = $derived(toLabel(edge.field));
15
26
  const inputName = $derived(`__rel__${edge.field}`);
16
- const selected = $derived(new Set((meta.selectedIds ?? []).map(String)));
27
+ const selected = $derived(
28
+ new Set((submittedIds ?? meta.selectedIds ?? []).map(String))
29
+ );
30
+ const err = $derived(fieldErrorAttrs(inputName, errorMessage));
17
31
  </script>
18
32
 
19
33
  <!--
@@ -29,13 +43,18 @@
29
43
  <div class="ska-field">
30
44
  <label class="ska-label" for={inputName}>{label} (IDs séparés par des virgules)</label>
31
45
  <input type="hidden" name="__rel_present__{edge.field}" value="1" />
32
- <input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" />
46
+ <input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy} />
47
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
48
+ {@html err.html}
33
49
  </div>
34
50
  {:else}
35
51
  <div class="ska-field">
36
52
  <label class="ska-label" for={inputName}>{label}</label>
37
53
  <input type="hidden" name="__rel_present__{edge.field}" value="1" />
38
- <fieldset id={inputName} class="ska-checkbox-group">
54
+ <!-- Pas d'`aria-invalid` ici : l'attribut n'est pas permis sur le rôle
55
+ `group` implicite d'un `<fieldset>`. Le rattachement du message se fait
56
+ par `aria-describedby` seul, qui l'est. -->
57
+ <fieldset id={inputName} class="ska-checkbox-group" aria-describedby={err.describedBy}>
39
58
  {#each meta.options as opt (opt.id)}
40
59
  <label class="ska-checkbox-wrap">
41
60
  <input
@@ -49,5 +68,7 @@
49
68
  </label>
50
69
  {/each}
51
70
  </fieldset>
71
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
72
+ {@html err.html}
52
73
  </div>
53
74
  {/if}
@@ -3,6 +3,15 @@ import type { RelationMeta } from './types.js';
3
3
  type $$ComponentProps = {
4
4
  edge: RelationEdge;
5
5
  meta: RelationMeta;
6
+ /**
7
+ * IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
8
+ * dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
9
+ * dit « pas un re-rendu d'erreur ». C'est la même distinction que le
10
+ * sentinelle `__rel_present__` côté écriture.
11
+ */
12
+ submittedIds?: string[];
13
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
14
+ errorMessage?: string;
6
15
  };
7
16
  declare const RelationCheckboxes: import("svelte").Component<$$ComponentProps, {}, "">;
8
17
  type RelationCheckboxes = ReturnType<typeof RelationCheckboxes>;
@@ -2,18 +2,21 @@
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
3
  import type { RelationEdge } from '../introspection/relations.js';
4
4
  import type { RelationMeta } from './types.js';
5
- import { toLabel } from './html.js';
5
+ import { toLabel, fieldErrorAttrs } from './html.js';
6
6
 
7
7
  let {
8
8
  edge,
9
9
  meta,
10
10
  currentValue,
11
- config
11
+ config,
12
+ errorMessage
12
13
  }: {
13
14
  edge: RelationEdge;
14
15
  meta: RelationMeta;
15
16
  currentValue: unknown;
16
17
  config: AdminHandlerConfig;
18
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
19
+ errorMessage?: string;
17
20
  } = $props();
18
21
 
19
22
  const relConfig = $derived(config.models?.[edge.model]?.relations?.[edge.field]);
@@ -23,6 +26,8 @@
23
26
  // Toujours défini pour une arête to-one-owning : c'est ce qui la distingue
24
27
  // des autres kinds dans le graphe (voir buildRelationGraph).
25
28
  const scalarName = $derived(edge.scalarFields[0]);
29
+ // Rattaché au scalaire : c'est lui qui porte l'`id` et le `name` du widget.
30
+ const err = $derived(fieldErrorAttrs(scalarName, errorMessage));
26
31
  </script>
27
32
 
28
33
  {#if meta.tooMany}
@@ -36,12 +41,16 @@
36
41
  value={currentValue ?? ''}
37
42
  class="ska-input"
38
43
  required={required}
44
+ aria-invalid={err.ariaInvalid}
45
+ aria-describedby={err.describedBy}
39
46
  />
47
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
48
+ {@html err.html}
40
49
  </div>
41
50
  {:else}
42
51
  <div class="ska-field">
43
52
  <label class="ska-label" for={scalarName}>{label}{required ? ' *' : ''}</label>
44
- <select id={scalarName} name={scalarName} class="ska-input" required={required}>
53
+ <select id={scalarName} name={scalarName} class="ska-input" required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>
45
54
  {#if !required}
46
55
  <option value="">{nullLabel}</option>
47
56
  {/if}
@@ -49,5 +58,7 @@
49
58
  <option value={opt.id} selected={String(opt.id) === String(currentValue ?? '')}>{opt.label}</option>
50
59
  {/each}
51
60
  </select>
61
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
62
+ {@html err.html}
52
63
  </div>
53
64
  {/if}
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  meta: RelationMeta;
7
7
  currentValue: unknown;
8
8
  config: AdminHandlerConfig;
9
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
10
+ errorMessage?: string;
9
11
  };
10
12
  declare const RelationSelect: import("svelte").Component<$$ComponentProps, {}, "">;
11
13
  type RelationSelect = ReturnType<typeof RelationSelect>;
@@ -3,3 +3,22 @@ export declare function escapeHtml(str: string): string;
3
3
  export declare function toLabel(name: string): string;
4
4
  export declare function adjustColor(hex: string, percent: number): string;
5
5
  export declare function formatValue(value: any, type: string): string;
6
+ /**
7
+ * Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
8
+ *
9
+ * Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
10
+ * Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
11
+ * les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
12
+ * octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
13
+ * pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
14
+ * déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
15
+ * que réparti dans quatre branches de gabarit.
16
+ *
17
+ * `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
18
+ * d'`aria-describedby`.
19
+ */
20
+ export declare function fieldErrorAttrs(inputId: string, message: string | undefined): {
21
+ ariaInvalid: 'true' | undefined;
22
+ describedBy: string | undefined;
23
+ html: string;
24
+ };
@@ -39,3 +39,28 @@ export function formatValue(value, type) {
39
39
  }
40
40
  return escapeHtml(str);
41
41
  }
42
+ /**
43
+ * Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
44
+ *
45
+ * Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
46
+ * Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
47
+ * les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
48
+ * octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
49
+ * pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
50
+ * déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
51
+ * que réparti dans quatre branches de gabarit.
52
+ *
53
+ * `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
54
+ * d'`aria-describedby`.
55
+ */
56
+ export function fieldErrorAttrs(inputId, message) {
57
+ if (message === undefined) {
58
+ return { ariaInvalid: undefined, describedBy: undefined, html: '' };
59
+ }
60
+ const errorId = `${inputId}-error`;
61
+ return {
62
+ ariaInvalid: 'true',
63
+ describedBy: errorId,
64
+ html: `<p class="ska-field__error" id="${escapeHtml(errorId)}">${escapeHtml(message)}</p>`
65
+ };
66
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
3
+ *
4
+ * Pure, et volontairement hors du composant : les cas limites (premiers et
5
+ * derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
6
+ * qu'en fouillant du HTML.
7
+ */
8
+ export type PageEntry = number | 'gap';
9
+ export declare function paginationWindow(page: number, totalPages: number): PageEntry[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
3
+ *
4
+ * Pure, et volontairement hors du composant : les cas limites (premiers et
5
+ * derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
6
+ * qu'en fouillant du HTML.
7
+ */
8
+ /** Nombre de pages affichées de part et d'autre de la page courante. */
9
+ const SPAN = 2;
10
+ export function paginationWindow(page, totalPages) {
11
+ if (totalPages <= 1)
12
+ return [];
13
+ const first = 1;
14
+ const last = totalPages;
15
+ const from = Math.max(first, page - SPAN);
16
+ const to = Math.min(last, page + SPAN);
17
+ const entries = [];
18
+ for (let n = from; n <= to; n++)
19
+ entries.push(n);
20
+ // Un trou qui ne cacherait qu'une seule page ne gagne aucune place et coûte
21
+ // un clic : on montre la page à la place. D'où la comparaison à 1 et non 0.
22
+ if (from - first > 1)
23
+ entries.unshift(first, 'gap');
24
+ else if (from > first)
25
+ entries.unshift(first);
26
+ if (last - to > 1)
27
+ entries.push('gap', last);
28
+ else if (to < last)
29
+ entries.push(last);
30
+ return entries;
31
+ }