includio-cms 0.5.3 → 0.5.7

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 (109) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/ROADMAP.md +33 -0
  3. package/dist/admin/api/rest/handler.d.ts +7 -0
  4. package/dist/admin/api/rest/handler.js +116 -0
  5. package/dist/admin/api/rest/middleware/apiKey.d.ts +6 -0
  6. package/dist/admin/api/rest/middleware/apiKey.js +45 -0
  7. package/dist/admin/api/rest/routes/collections.d.ts +5 -0
  8. package/dist/admin/api/rest/routes/collections.js +104 -0
  9. package/dist/admin/api/rest/routes/entries.d.ts +2 -0
  10. package/dist/admin/api/rest/routes/entries.js +37 -0
  11. package/dist/admin/api/rest/routes/languages.d.ts +1 -0
  12. package/dist/admin/api/rest/routes/languages.js +5 -0
  13. package/dist/admin/api/rest/routes/schema.d.ts +2 -0
  14. package/dist/admin/api/rest/routes/schema.js +78 -0
  15. package/dist/admin/api/rest/routes/singletons.d.ts +3 -0
  16. package/dist/admin/api/rest/routes/singletons.js +60 -0
  17. package/dist/admin/auth-client.d.ts +48 -48
  18. package/dist/admin/client/admin/admin-after-login-layout.svelte +5 -3
  19. package/dist/admin/client/admin/admin-layout.svelte +0 -2
  20. package/dist/admin/client/collection/bulk-actions-bar.svelte +3 -1
  21. package/dist/admin/client/collection/bulk-actions-bar.svelte.d.ts +1 -1
  22. package/dist/admin/client/collection/collection-entries.svelte +334 -166
  23. package/dist/admin/client/collection/collection-page.svelte +6 -3
  24. package/dist/admin/client/collection/data-table.svelte +127 -18
  25. package/dist/admin/client/collection/data-table.svelte.d.ts +2 -0
  26. package/dist/admin/client/collection/row-actions.svelte +3 -1
  27. package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -1
  28. package/dist/admin/client/entry/entry-page.svelte +5 -4
  29. package/dist/admin/client/form/form-config-wrapper.svelte +5 -3
  30. package/dist/admin/client/form/form-page.svelte +5 -5
  31. package/dist/admin/client/form/form-submission/form-submission-page.svelte +30 -32
  32. package/dist/admin/components/accessibility/accessibility-overview.svelte +7 -5
  33. package/dist/admin/components/accessibility/accessibility-overview.svelte.d.ts +2 -17
  34. package/dist/admin/components/dashboard/a11y-gauge.svelte +7 -4
  35. package/dist/admin/components/dashboard/a11y-gauge.svelte.d.ts +2 -17
  36. package/dist/admin/components/dashboard/accessibility-hub.svelte +7 -4
  37. package/dist/admin/components/dashboard/accessibility-hub.svelte.d.ts +2 -17
  38. package/dist/admin/components/dashboard/form-submissions-widget.svelte +8 -3
  39. package/dist/admin/components/dashboard/form-submissions-widget.svelte.d.ts +2 -17
  40. package/dist/admin/components/dashboard/recent-activity.svelte +8 -3
  41. package/dist/admin/components/dashboard/recent-activity.svelte.d.ts +2 -17
  42. package/dist/admin/components/dashboard/recent-entries.svelte +8 -3
  43. package/dist/admin/components/dashboard/recent-entries.svelte.d.ts +2 -17
  44. package/dist/admin/components/fields/image-field.svelte +21 -20
  45. package/dist/admin/components/fields/media-field.svelte +45 -44
  46. package/dist/admin/components/fields/relation-field.svelte +428 -149
  47. package/dist/admin/components/fields/relation-picker-dialog.svelte +112 -0
  48. package/dist/admin/components/fields/relation-picker-dialog.svelte.d.ts +22 -0
  49. package/dist/admin/components/fields/seo-field.svelte +66 -10
  50. package/dist/admin/components/icons/icon-map.d.ts +4 -0
  51. package/dist/admin/components/icons/icon-map.js +143 -0
  52. package/dist/admin/components/layout/header-actions.svelte +0 -10
  53. package/dist/admin/components/layout/nav-collections.svelte +55 -57
  54. package/dist/admin/components/layout/nav-collections.svelte.d.ts +2 -17
  55. package/dist/admin/components/layout/nav-forms.svelte +49 -49
  56. package/dist/admin/components/layout/nav-forms.svelte.d.ts +2 -17
  57. package/dist/admin/components/layout/nav-search.svelte +15 -22
  58. package/dist/admin/components/layout/nav-singletons.svelte +35 -38
  59. package/dist/admin/components/layout/nav-singletons.svelte.d.ts +2 -17
  60. package/dist/admin/components/media/file/file-preview.svelte +92 -91
  61. package/dist/admin/components/media/file-preview.svelte +17 -18
  62. package/dist/admin/components/media/media-library.svelte +39 -41
  63. package/dist/admin/components/media/media-selector.svelte +8 -8
  64. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +6 -8
  65. package/dist/admin/components/tiptap/content-editor.svelte +2 -2
  66. package/dist/admin/components/tiptap/editor-toolbar.svelte +1 -1
  67. package/dist/admin/components/tiptap/inline-block-node.d.ts +1 -0
  68. package/dist/admin/components/tiptap/inline-block-node.js +12 -1
  69. package/dist/admin/components/tiptap/tiptap-editor.svelte +1 -1
  70. package/dist/admin/remote/entry.remote.d.ts +20 -1
  71. package/dist/admin/remote/entry.remote.js +24 -3
  72. package/dist/admin/remote/invite.d.ts +2 -2
  73. package/dist/cli/scaffold/admin.js +8 -0
  74. package/dist/cms/runtime/remote.d.ts +8 -0
  75. package/dist/cms/runtime/remote.js +7 -0
  76. package/dist/cms/runtime/types.d.ts +1 -1
  77. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  78. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  79. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  80. package/dist/components/ui/sonner/sonner.svelte +1 -2
  81. package/dist/core/cms.d.ts +2 -1
  82. package/dist/core/cms.js +2 -0
  83. package/dist/core/server/entries/operations/get.d.ts +11 -0
  84. package/dist/core/server/entries/operations/get.js +63 -1
  85. package/dist/core/server/entries/operations/update.d.ts +1 -0
  86. package/dist/core/server/entries/operations/update.js +5 -1
  87. package/dist/core/server/fields/resolveUrlFields.js +49 -18
  88. package/dist/db-postgres/schema/entry.d.ts +17 -0
  89. package/dist/db-postgres/schema/entry.js +4 -2
  90. package/dist/server/auth.d.ts +4 -4
  91. package/dist/sveltekit/components/structured-content.svelte +18 -5
  92. package/dist/sveltekit/server/handle.js +1 -0
  93. package/dist/types/cms.d.ts +7 -0
  94. package/dist/types/collections.d.ts +2 -0
  95. package/dist/types/config.d.ts +2 -2
  96. package/dist/types/entries.d.ts +7 -1
  97. package/dist/types/forms.d.ts +2 -0
  98. package/dist/types/index.d.ts +2 -1
  99. package/dist/types/index.js +1 -0
  100. package/dist/updates/0.5.4/index.d.ts +2 -0
  101. package/dist/updates/0.5.4/index.js +15 -0
  102. package/dist/updates/0.5.5/index.d.ts +2 -0
  103. package/dist/updates/0.5.5/index.js +20 -0
  104. package/dist/updates/0.5.6/index.d.ts +2 -0
  105. package/dist/updates/0.5.6/index.js +17 -0
  106. package/dist/updates/0.5.7/index.d.ts +2 -0
  107. package/dist/updates/0.5.7/index.js +14 -0
  108. package/dist/updates/index.js +5 -1
  109. package/package.json +6 -2
@@ -1,19 +1,19 @@
1
1
  <script lang="ts">
2
2
  import type { RelationFieldData, RelationField } from '../../../types/fields.js';
3
- import type { CollectionConfig } from '../../../types/collections.js';
4
3
  import { getRemotes } from '../../../sveltekit/index.js';
5
4
  import { buttonVariants } from '../../../components/ui/button/button.svelte';
6
- import { getCollectionEntryLabel } from '../../utils/entryLabel.js';
7
5
  import { onMount, tick, untrack } from 'svelte';
8
6
  import { useId } from 'bits-ui';
9
7
  import * as Popover from '../../../components/ui/popover/index.js';
10
8
  import * as Command from '../../../components/ui/command/index.js';
11
9
  import { cn } from '../../../utils.js';
12
- import Selector from '@tabler/icons-svelte/icons/selector';
13
10
  import Check from '@tabler/icons-svelte/icons/check';
14
11
  import X from '@tabler/icons-svelte/icons/x';
12
+ import Plus from '@tabler/icons-svelte/icons/plus';
15
13
  import GripVertical from '@tabler/icons-svelte/icons/grip-vertical';
16
- import { getContentLanguage } from '../../state/content-language.svelte.js';
14
+ import ChevronUp from '@tabler/icons-svelte/icons/chevron-up';
15
+ import ChevronDown from '@tabler/icons-svelte/icons/chevron-down';
16
+ import Spinner from '../../../components/ui/spinner/spinner.svelte';
17
17
  import type { InterfaceLanguage } from '../../../types/languages.js';
18
18
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
19
19
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
@@ -21,14 +21,62 @@
21
21
  import { arrayMove } from '../../utils/arrayMove.js';
22
22
  import { flip } from 'svelte/animate';
23
23
  import { fade } from 'svelte/transition';
24
+ import RelationPickerDialog from './relation-picker-dialog.svelte';
24
25
 
25
- const lang: Record<InterfaceLanguage, { select: string; search: string; selected: string }> = {
26
- en: { select: 'Select', search: 'Search', selected: 'selected' },
27
- pl: { select: 'Wybierz', search: 'Szukaj', selected: 'wybranych' }
26
+ const lang: Record<
27
+ InterfaceLanguage,
28
+ {
29
+ select: string;
30
+ search: string;
31
+ selected: string;
32
+ add: string;
33
+ noResults: string;
34
+ clear: string;
35
+ remove: string;
36
+ reorder: string;
37
+ moveUp: string;
38
+ moveDown: string;
39
+ movedTo: string;
40
+ loading: string;
41
+ dialogTitle: string;
42
+ closeDialog: string;
43
+ }
44
+ > = {
45
+ en: {
46
+ select: 'Select',
47
+ search: 'Search',
48
+ selected: 'selected',
49
+ add: 'Add',
50
+ noResults: 'No results found',
51
+ clear: 'Clear selection',
52
+ remove: 'Remove',
53
+ reorder: 'Reorder',
54
+ moveUp: 'Move up',
55
+ moveDown: 'Move down',
56
+ movedTo: 'Moved to position',
57
+ loading: 'Loading...',
58
+ dialogTitle: 'Select',
59
+ closeDialog: 'Close'
60
+ },
61
+ pl: {
62
+ select: 'Wybierz',
63
+ search: 'Szukaj',
64
+ selected: 'wybranych',
65
+ add: 'Dodaj',
66
+ noResults: 'Brak wyników',
67
+ clear: 'Wyczyść wybór',
68
+ remove: 'Usuń',
69
+ reorder: 'Zmień kolejność',
70
+ moveUp: 'Przenieś wyżej',
71
+ moveDown: 'Przenieś niżej',
72
+ movedTo: 'Przeniesiono na pozycję',
73
+ loading: 'Ładowanie...',
74
+ dialogTitle: 'Wybierz',
75
+ closeDialog: 'Zamknij'
76
+ }
28
77
  };
29
78
 
30
79
  const remotes = getRemotes();
31
- const contentLanguage = getContentLanguage();
32
80
  const interfaceLanguage = useInterfaceLanguage();
33
81
 
34
82
  type Props = {
@@ -39,6 +87,7 @@
39
87
  let { field, value = $bindable(), ...props }: Props = $props();
40
88
 
41
89
  const multiple = $derived(field.multiple === true);
90
+ const t = $derived(lang[interfaceLanguage.current]);
42
91
 
43
92
  // Ensure value is array when multiple
44
93
  onMount(() => {
@@ -47,7 +96,7 @@
47
96
  }
48
97
  });
49
98
 
50
- // Helper to get current array value safely
99
+ // --- Value helpers ---
51
100
  function getArrayValue(): string[] {
52
101
  if (Array.isArray(value)) return value;
53
102
  return value ? [value] : [];
@@ -58,11 +107,16 @@
58
107
  }
59
108
 
60
109
  function toggleItem(id: string) {
61
- const arr = getArrayValue();
62
- if (arr.includes(id)) {
63
- value = arr.filter((v) => v !== id);
110
+ if (multiple) {
111
+ const arr = getArrayValue();
112
+ if (arr.includes(id)) {
113
+ value = arr.filter((v) => v !== id);
114
+ } else {
115
+ value = [...arr, id];
116
+ }
64
117
  } else {
65
- value = [...arr, id];
118
+ value = id;
119
+ pickerOpen = false;
66
120
  }
67
121
  }
68
122
 
@@ -70,195 +124,420 @@
70
124
  value = getArrayValue().filter((v) => v !== id);
71
125
  }
72
126
 
73
- type RelationOption = { label: string; value: string };
74
- type RelationData = { collectionConfig: CollectionConfig; options: RelationOption[] };
127
+ function clearValue() {
128
+ value = multiple ? [] : '';
129
+ }
130
+
131
+ // --- Labels cache (for displaying selected items) ---
132
+ type LabelEntry = { id: string; label: string };
133
+ let labelCache = $state<Map<string, string>>(new Map());
75
134
 
76
- let relationData = $state<RelationData | null>(null);
77
- let loading = $state(true);
135
+ // Resolve labels for selected IDs not yet in cache
136
+ async function resolveLabels(ids: string[]) {
137
+ const missing = ids.filter((id) => !labelCache.has(id));
138
+ if (missing.length === 0) return;
78
139
 
140
+ const results = await remotes.getEntryLabels({
141
+ slug: field.collection,
142
+ ids: missing
143
+ });
144
+ const newCache = new Map(labelCache);
145
+ for (const r of results) {
146
+ newCache.set(r.id, r.label);
147
+ }
148
+ labelCache = newCache;
149
+ }
150
+
151
+ // Resolve labels whenever value changes
79
152
  $effect(() => {
80
- const collection = field.collection;
81
- const currentLang = contentLanguage.current;
153
+ const ids = getArrayValue();
154
+ if (ids.length > 0) {
155
+ untrack(() => resolveLabels(ids));
156
+ }
157
+ });
158
+
159
+ function getLabel(id: string): string {
160
+ return labelCache.get(id) ?? id;
161
+ }
162
+
163
+ // --- Picker state ---
164
+ let pickerOpen = $state(false);
165
+ let searchValue = $state('');
166
+ let pickerOptions = $state<LabelEntry[]>([]);
167
+ let pickerLoading = $state(false);
168
+ let totalCount = $state(0);
169
+ let initialLoading = $state(true);
170
+
171
+ // Auto-detect mode: <=30 published → inline popover, >30 → dialog
172
+ let useDialog = $state(false);
173
+
174
+ // Debounced search
175
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null;
176
+
177
+ function debouncedSearch(query: string) {
178
+ if (searchTimeout) clearTimeout(searchTimeout);
179
+ searchTimeout = setTimeout(() => fetchOptions(query), 300);
180
+ }
181
+
182
+ async function fetchOptions(search?: string) {
183
+ pickerLoading = true;
184
+ try {
185
+ const results = await remotes.getEntryLabels({
186
+ slug: field.collection,
187
+ search: search || undefined,
188
+ status: 'published',
189
+ limit: 100
190
+ });
191
+ pickerOptions = results.map((r) => ({ id: r.id, label: r.label }));
192
+ if (results.length > 0) {
193
+ totalCount = results[0].total;
194
+ } else if (!search) {
195
+ totalCount = 0;
196
+ }
197
+ } finally {
198
+ pickerLoading = false;
199
+ }
200
+ }
82
201
 
202
+ // Initial load to determine mode + count
203
+ onMount(async () => {
204
+ try {
205
+ const results = await remotes.getEntryLabels({
206
+ slug: field.collection,
207
+ status: 'published',
208
+ limit: 100
209
+ });
210
+ pickerOptions = results.map((r) => ({ id: r.id, label: r.label }));
211
+ totalCount = results.length > 0 ? results[0].total : 0;
212
+ useDialog = totalCount > 30;
213
+
214
+ // Populate label cache from initial load
215
+ const newCache = new Map(labelCache);
216
+ for (const r of results) {
217
+ newCache.set(r.id, r.label);
218
+ }
219
+ labelCache = newCache;
220
+ } finally {
221
+ initialLoading = false;
222
+ }
223
+ });
224
+
225
+ function handleSearchChange(val: string) {
226
+ searchValue = val;
227
+ debouncedSearch(val);
228
+ }
229
+
230
+ function openPicker() {
231
+ searchValue = '';
232
+ fetchOptions();
233
+ pickerOpen = true;
234
+ }
235
+
236
+ function closePicker() {
237
+ pickerOpen = false;
238
+ searchValue = '';
239
+ }
240
+
241
+ // --- Collection config ---
242
+ let collectionLabel = $state('');
243
+ let singularLabel = $state('');
244
+
245
+ $effect(() => {
246
+ const slug = field.collection;
83
247
  untrack(() => {
84
- loading = true;
85
- Promise.all([
86
- remotes.getCollection(collection),
87
- remotes.getEntries({ slug: collection, language: currentLang })
88
- ]).then(([collectionConfig, entries]) => {
89
- const options = entries.map((entry) => ({
90
- label: getCollectionEntryLabel(entry, collectionConfig),
91
- value: entry.id
92
- }));
93
- relationData = { collectionConfig, options };
94
- loading = false;
248
+ remotes.getCollection(slug).then((config) => {
249
+ collectionLabel =
250
+ getLocalizedLabel(config.labels?.plural, interfaceLanguage.current) || config.slug;
251
+ singularLabel =
252
+ getLocalizedLabel(config.labels?.singular, interfaceLanguage.current) || config.slug;
95
253
  });
96
254
  });
97
255
  });
98
256
 
99
- let open = $state(false);
257
+ // Popover state (inline mode)
258
+ let popoverOpen = $state(false);
259
+ const triggerId = useId();
100
260
 
101
- function closeAndFocusTrigger(triggerId: string) {
102
- open = false;
261
+ function closePopoverAndFocus() {
262
+ popoverOpen = false;
103
263
  tick().then(() => {
104
264
  document.getElementById(triggerId)?.focus();
105
265
  });
106
266
  }
107
- const triggerId = useId();
108
267
 
109
- function getOptionLabel(id: string): string {
110
- return relationData?.options.find((o) => o.value === id)?.label ?? id;
268
+ // --- Keyboard reorder ---
269
+ let liveMessage = $state('');
270
+
271
+ function moveItem(fromIndex: number, toIndex: number) {
272
+ const arr = getArrayValue();
273
+ if (toIndex < 0 || toIndex >= arr.length) return;
274
+ value = [...arrayMove(arr, fromIndex, toIndex)];
275
+ const label = getLabel(arr[fromIndex]);
276
+ liveMessage = `${label}: ${t.movedTo} ${toIndex + 1}`;
111
277
  }
112
278
 
113
- // Selected items for multi-select display
279
+ // Selected items for multi-select chip display
114
280
  let selectedItems = $derived(
115
- multiple ? getArrayValue().map((id) => ({ id, label: getOptionLabel(id) })) : []
281
+ multiple ? getArrayValue().map((id) => ({ id, label: getLabel(id) })) : []
116
282
  );
283
+
284
+ // Single-select display
285
+ let singleLabel = $derived(!multiple && value ? getLabel(value as string) : '');
117
286
  </script>
118
287
 
119
- {#if loading}
120
- <div class="h-10 w-full animate-pulse rounded-md bg-accent"></div>
121
- {:else if relationData && relationData.options.length > 0}
122
- {@const { collectionConfig, options } = relationData}
288
+ {#if initialLoading}
289
+ <div class="flex h-10 w-full items-center justify-center rounded-md border bg-muted/30">
290
+ <Spinner class="size-4" />
291
+ </div>
292
+ {:else if multiple}
293
+ <!-- Multi-select mode -->
294
+
295
+ <!-- Selected items list with drag reorder -->
296
+ {#if selectedItems.length > 0}
297
+ <div class="flex flex-col gap-1.5">
298
+ {#each selectedItems as item, index (item.id)}
299
+ <div
300
+ use:droppable={{
301
+ container: index.toString(),
302
+ callbacks: {
303
+ onDrop: (state) => {
304
+ const dragIndex = parseInt(state.sourceContainer ?? '');
305
+ const dropIndex = parseInt(state.targetContainer ?? '');
306
+
307
+ if (!isNaN(dragIndex) && !isNaN(dropIndex)) {
308
+ value = [...arrayMove(getArrayValue(), dragIndex, dropIndex)];
309
+ }
310
+ }
311
+ }
312
+ }}
313
+ animate:flip={{ duration: 200 }}
314
+ in:fade={{ duration: 150 }}
315
+ out:fade={{ duration: 150 }}
316
+ >
317
+ <div
318
+ class="flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 text-sm"
319
+ >
320
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
321
+ <div
322
+ use:draggable={{ container: index.toString(), dragData: { id: item.id } }}
323
+ class="cursor-grab text-muted-foreground hover:text-foreground"
324
+ aria-label="{t.reorder} {item.label}"
325
+ ondragstart={(e) => e.stopPropagation()}
326
+ onpointerdown={(e) => e.stopPropagation()}
327
+ >
328
+ <GripVertical class="h-3.5 w-3.5" />
329
+ </div>
330
+ {#if index > 0}
331
+ <button
332
+ type="button"
333
+ class="shrink-0 text-muted-foreground hover:text-foreground"
334
+ onclick={() => moveItem(index, index - 1)}
335
+ aria-label="{t.moveUp} {item.label}"
336
+ >
337
+ <ChevronUp class="h-3.5 w-3.5" />
338
+ </button>
339
+ {/if}
340
+ {#if index < selectedItems.length - 1}
341
+ <button
342
+ type="button"
343
+ class="shrink-0 text-muted-foreground hover:text-foreground"
344
+ onclick={() => moveItem(index, index + 1)}
345
+ aria-label="{t.moveDown} {item.label}"
346
+ >
347
+ <ChevronDown class="h-3.5 w-3.5" />
348
+ </button>
349
+ {/if}
350
+ <span class="flex-1 truncate">{item.label}</span>
351
+ <button
352
+ type="button"
353
+ class="shrink-0 text-muted-foreground hover:text-destructive"
354
+ onclick={() => removeItem(item.id)}
355
+ aria-label="{t.remove} {item.label}"
356
+ >
357
+ <X class="h-3.5 w-3.5" />
358
+ </button>
359
+ </div>
360
+ </div>
361
+ {/each}
362
+ </div>
363
+ {/if}
364
+
365
+ <!-- Add trigger -->
366
+ {#if useDialog}
367
+ <button
368
+ type="button"
369
+ class="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
370
+ onclick={openPicker}
371
+ aria-haspopup="dialog"
372
+ >
373
+ <Plus class="h-4 w-4" />
374
+ {t.add} {singularLabel}
375
+ </button>
123
376
 
124
- {#if multiple}
125
- <!-- Multi-select mode -->
126
- <Popover.Root bind:open>
377
+ <RelationPickerDialog
378
+ bind:open={pickerOpen}
379
+ title="{t.dialogTitle} {collectionLabel}"
380
+ searchPlaceholder="{t.search} {collectionLabel}..."
381
+ noResults={t.noResults}
382
+ closeLabel={t.closeDialog}
383
+ {multiple}
384
+ selected={getArrayValue()}
385
+ options={pickerOptions}
386
+ loading={pickerLoading}
387
+ bind:searchValue
388
+ onSearchChange={handleSearchChange}
389
+ onSelect={toggleItem}
390
+ onClose={closePicker}
391
+ />
392
+ {:else}
393
+ <Popover.Root bind:open={popoverOpen}>
127
394
  <Popover.Trigger
128
395
  id={triggerId}
129
- class={cn(
130
- buttonVariants({ variant: 'outline' }),
131
- 'justify-between',
132
- 'text-muted-foreground'
133
- )}
396
+ class="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
134
397
  role="combobox"
135
- {...props}
398
+ aria-expanded={popoverOpen}
136
399
  >
137
- {#if getArrayValue().length > 0}
138
- {getArrayValue().length} {lang[interfaceLanguage.current].selected}
139
- {:else}
140
- {lang[interfaceLanguage.current].select} {getLocalizedLabel(collectionConfig.labels?.singular, interfaceLanguage.current) || collectionConfig.slug}
141
- {/if}
142
- <Selector class="opacity-50" />
400
+ <Plus class="h-4 w-4" />
401
+ {t.add} {singularLabel}
143
402
  </Popover.Trigger>
144
403
 
145
- <Popover.Content class="w-[200px] p-0">
404
+ <Popover.Content class="min-w-[280px] p-0">
146
405
  <Command.Root>
147
406
  <Command.Input
148
407
  autofocus
149
- placeholder="{lang[interfaceLanguage.current].search} {getLocalizedLabel(
150
- collectionConfig.labels?.plural,
151
- interfaceLanguage.current
152
- ) || collectionConfig.slug}..."
408
+ placeholder="{t.search} {collectionLabel}..."
153
409
  class="h-9"
154
410
  />
155
- <Command.Empty>No {collectionConfig.labels?.plural} found.</Command.Empty>
156
- <Command.Group value="options">
157
- {#each options as option (option.value)}
158
- <Command.Item
159
- value={option.label}
160
- onSelect={() => {
161
- toggleItem(option.value);
162
- }}
163
- >
164
- {option.label}
165
- <Check
166
- class={cn('ml-auto', !isSelected(option.value) && 'text-transparent')}
167
- />
168
- </Command.Item>
169
- {/each}
170
- </Command.Group>
411
+ <Command.List>
412
+ <Command.Empty>{t.noResults}</Command.Empty>
413
+ <Command.Group>
414
+ {#each pickerOptions as option (option.id)}
415
+ <Command.Item
416
+ value={option.label}
417
+ onSelect={() => toggleItem(option.id)}
418
+ class="flex items-center gap-2"
419
+ >
420
+ <div
421
+ class={cn(
422
+ 'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
423
+ isSelected(option.id)
424
+ ? 'border-primary bg-primary text-primary-foreground'
425
+ : 'border-muted-foreground/30'
426
+ )}
427
+ >
428
+ {#if isSelected(option.id)}
429
+ <Check class="h-3 w-3" />
430
+ {/if}
431
+ </div>
432
+ <span class="flex-1 truncate">{option.label}</span>
433
+ </Command.Item>
434
+ {/each}
435
+ </Command.Group>
436
+ </Command.List>
171
437
  </Command.Root>
172
438
  </Popover.Content>
173
439
  </Popover.Root>
440
+ {/if}
174
441
 
175
- <!-- Selected items list with drag reorder -->
176
- {#if selectedItems.length > 0}
177
- <div class="mt-2 flex flex-col gap-1.5">
178
- {#each selectedItems as item, index (item.id)}
179
- <div
180
- use:droppable={{
181
- container: index.toString(),
182
- callbacks: {
183
- onDrop: (state) => {
184
- const dragIndex = parseInt(state.sourceContainer ?? '');
185
- const dropIndex = parseInt(state.targetContainer ?? '');
186
-
187
- if (!isNaN(dragIndex) && !isNaN(dropIndex)) {
188
- value = [...arrayMove(getArrayValue(), dragIndex, dropIndex)];
189
- }
190
- }
191
- }
192
- }}
193
- animate:flip={{ duration: 200 }}
194
- in:fade={{ duration: 150 }}
195
- out:fade={{ duration: 150 }}
196
- >
197
- <div
198
- class="flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm"
199
- >
200
- <div
201
- use:draggable={{ container: index.toString(), dragData: { id: item.id } }}
202
- class="cursor-grab text-muted-foreground hover:text-foreground"
203
- >
204
- <GripVertical class="h-3.5 w-3.5" />
205
- </div>
206
- <span class="flex-1 truncate">{item.label}</span>
207
- <button
208
- type="button"
209
- class="shrink-0 text-muted-foreground hover:text-destructive"
210
- onclick={() => removeItem(item.id)}
211
- >
212
- <X class="h-3.5 w-3.5" />
213
- </button>
214
- </div>
215
- </div>
216
- {/each}
217
- </div>
218
- {/if}
442
+ <!-- Live region for screen readers -->
443
+ <div class="sr-only" aria-live="polite" role="status">
444
+ {liveMessage || `${getArrayValue().length} ${t.selected}`}
445
+ </div>
446
+ {:else}
447
+ <!-- Single-select mode -->
448
+ {#if value}
449
+ <!-- Show selected chip -->
450
+ <div class="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
451
+ <span class="flex-1 truncate">{singleLabel}</span>
452
+ <button
453
+ type="button"
454
+ class="shrink-0 text-muted-foreground hover:text-destructive"
455
+ onclick={clearValue}
456
+ aria-label={t.clear}
457
+ >
458
+ <X class="h-3.5 w-3.5" />
459
+ </button>
460
+ </div>
461
+ {/if}
462
+
463
+ {#if useDialog}
464
+ <button
465
+ type="button"
466
+ class={cn(
467
+ buttonVariants({ variant: 'outline' }),
468
+ 'w-full justify-between',
469
+ !value && 'text-muted-foreground',
470
+ value && 'mt-1.5'
471
+ )}
472
+ onclick={openPicker}
473
+ role="combobox"
474
+ aria-expanded={pickerOpen}
475
+ aria-haspopup="dialog"
476
+ {...props}
477
+ >
478
+ {t.select} {singularLabel}
479
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
480
+ </button>
481
+
482
+ <RelationPickerDialog
483
+ bind:open={pickerOpen}
484
+ title="{t.dialogTitle} {singularLabel}"
485
+ searchPlaceholder="{t.search} {collectionLabel}..."
486
+ noResults={t.noResults}
487
+ closeLabel={t.closeDialog}
488
+ {multiple}
489
+ selected={value ? [value as string] : []}
490
+ options={pickerOptions}
491
+ loading={pickerLoading}
492
+ bind:searchValue
493
+ onSearchChange={handleSearchChange}
494
+ onSelect={toggleItem}
495
+ onClose={closePicker}
496
+ />
219
497
  {:else}
220
- <!-- Single-select mode -->
221
- <Popover.Root bind:open>
498
+ <Popover.Root bind:open={popoverOpen}>
222
499
  <Popover.Trigger
223
500
  id={triggerId}
224
501
  class={cn(
225
502
  buttonVariants({ variant: 'outline' }),
226
- 'justify-between',
227
- !value && 'text-muted-foreground'
503
+ 'w-full justify-between',
504
+ !value && 'text-muted-foreground',
505
+ value && 'mt-1.5'
228
506
  )}
229
507
  role="combobox"
508
+ aria-expanded={popoverOpen}
230
509
  {...props}
231
510
  >
232
- {options.find((f) => f.value === value)?.label ??
233
- `${lang[interfaceLanguage.current].select} ${getLocalizedLabel(collectionConfig.labels?.singular, interfaceLanguage.current) || collectionConfig.slug}`}
234
- <Selector class="opacity-50" />
511
+ {t.select} {singularLabel}
512
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
235
513
  </Popover.Trigger>
236
514
 
237
- <Popover.Content class="w-[200px] p-0">
515
+ <Popover.Content class="min-w-[280px] p-0">
238
516
  <Command.Root>
239
517
  <Command.Input
240
518
  autofocus
241
- placeholder="{lang[interfaceLanguage.current].search} {getLocalizedLabel(
242
- collectionConfig.labels?.plural,
243
- interfaceLanguage.current
244
- ) || collectionConfig.slug}..."
519
+ placeholder="{t.search} {collectionLabel}..."
245
520
  class="h-9"
246
521
  />
247
- <Command.Empty>No {collectionConfig.labels?.plural} found.</Command.Empty>
248
- <Command.Group value="options">
249
- {#each options as option (option.value)}
250
- <Command.Item
251
- value={option.label}
252
- onSelect={() => {
253
- value = option.value;
254
- closeAndFocusTrigger(triggerId);
255
- }}
256
- >
257
- {option.label}
258
- <Check class={cn('ml-auto', option.value !== value && 'text-transparent')} />
259
- </Command.Item>
260
- {/each}
261
- </Command.Group>
522
+ <Command.List>
523
+ <Command.Empty>{t.noResults}</Command.Empty>
524
+ <Command.Group>
525
+ {#each pickerOptions as option (option.id)}
526
+ <Command.Item
527
+ value={option.label}
528
+ onSelect={() => {
529
+ value = option.id;
530
+ closePopoverAndFocus();
531
+ }}
532
+ >
533
+ {option.label}
534
+ <Check
535
+ class={cn('ml-auto', option.id !== value && 'text-transparent')}
536
+ />
537
+ </Command.Item>
538
+ {/each}
539
+ </Command.Group>
540
+ </Command.List>
262
541
  </Command.Root>
263
542
  </Popover.Content>
264
543
  </Popover.Root>