@placeholderco/placeholder-ui 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +26 -26
  2. package/README.md +179 -179
  3. package/dist/display/Alert.svelte +179 -179
  4. package/dist/display/Avatar.svelte +166 -166
  5. package/dist/display/LinkCollection.svelte +161 -161
  6. package/dist/display/Paper.svelte +118 -118
  7. package/dist/form/Autocomplete.svelte +223 -191
  8. package/dist/form/Autocomplete.svelte.d.ts +3 -1
  9. package/dist/form/AutocompleteMulti.svelte +356 -0
  10. package/dist/form/AutocompleteMulti.svelte.d.ts +28 -0
  11. package/dist/form/Checkbox.svelte +201 -201
  12. package/dist/form/Chips.svelte +128 -128
  13. package/dist/form/ComboBox.svelte +158 -158
  14. package/dist/form/ComboBox.svelte.d.ts +1 -1
  15. package/dist/form/ComboBoxItemBuilder.svelte +460 -460
  16. package/dist/form/ComboBoxMulti.svelte +197 -197
  17. package/dist/form/ComboBoxMulti.svelte.d.ts +1 -1
  18. package/dist/form/CronBuilder.svelte +693 -693
  19. package/dist/form/DatePicker.svelte +672 -672
  20. package/dist/form/DateTimePicker.svelte +712 -712
  21. package/dist/form/FileInput.svelte +235 -235
  22. package/dist/form/FormGroup.svelte +68 -68
  23. package/dist/form/Number.svelte +238 -238
  24. package/dist/form/PasswordInput.svelte +252 -252
  25. package/dist/form/RadioGroup.svelte +210 -210
  26. package/dist/form/Rating.svelte +235 -235
  27. package/dist/form/SegmentedControl.svelte +149 -149
  28. package/dist/form/Select.svelte +590 -590
  29. package/dist/form/Select.svelte.d.ts +1 -1
  30. package/dist/form/SelectMulti.svelte +613 -613
  31. package/dist/form/SelectMulti.svelte.d.ts +1 -1
  32. package/dist/form/Slider.svelte +358 -358
  33. package/dist/form/Switch.svelte +147 -147
  34. package/dist/form/TextArea.svelte +148 -148
  35. package/dist/form/Textbox.svelte +228 -228
  36. package/dist/form/TimePicker.svelte +267 -267
  37. package/dist/icon/Icon.svelte +52 -52
  38. package/dist/icon/alert-octagon.svg +5 -5
  39. package/dist/icon/alert-triangle.svg +5 -5
  40. package/dist/icon/archive.svg +1 -1
  41. package/dist/icon/arrow-down.svg +1 -1
  42. package/dist/icon/arrow-left.svg +1 -1
  43. package/dist/icon/arrow-right.svg +1 -1
  44. package/dist/icon/arrow-up.svg +1 -1
  45. package/dist/icon/at.svg +1 -1
  46. package/dist/icon/bell.svg +1 -1
  47. package/dist/icon/bookmark.svg +1 -1
  48. package/dist/icon/calendar.svg +1 -1
  49. package/dist/icon/camera.svg +1 -1
  50. package/dist/icon/chart-bar.svg +1 -1
  51. package/dist/icon/chart-line.svg +1 -1
  52. package/dist/icon/chart-pie.svg +1 -1
  53. package/dist/icon/checkbox.svg +1 -1
  54. package/dist/icon/checklist.svg +1 -1
  55. package/dist/icon/circle-check.svg +1 -1
  56. package/dist/icon/circle-x.svg +1 -1
  57. package/dist/icon/clock.svg +1 -1
  58. package/dist/icon/credit-card.svg +1 -1
  59. package/dist/icon/dots-vertical.svg +1 -1
  60. package/dist/icon/dots.svg +1 -1
  61. package/dist/icon/external-link.svg +1 -1
  62. package/dist/icon/eye-off.svg +1 -1
  63. package/dist/icon/eye.svg +1 -1
  64. package/dist/icon/filter.svg +1 -1
  65. package/dist/icon/fingerprint.svg +1 -1
  66. package/dist/icon/flag.svg +1 -1
  67. package/dist/icon/heart.svg +1 -1
  68. package/dist/icon/home.svg +1 -1
  69. package/dist/icon/key.svg +1 -1
  70. package/dist/icon/list-check.svg +1 -1
  71. package/dist/icon/login.svg +1 -1
  72. package/dist/icon/logout.svg +1 -1
  73. package/dist/icon/map-pin.svg +1 -1
  74. package/dist/icon/maximize.svg +1 -1
  75. package/dist/icon/microphone.svg +1 -1
  76. package/dist/icon/minimize.svg +1 -1
  77. package/dist/icon/note.svg +1 -1
  78. package/dist/icon/player-pause.svg +1 -1
  79. package/dist/icon/printer.svg +1 -1
  80. package/dist/icon/qrcode.svg +1 -1
  81. package/dist/icon/send.svg +1 -1
  82. package/dist/icon/settings.svg +1 -1
  83. package/dist/icon/share.svg +1 -1
  84. package/dist/icon/shopping-cart.svg +1 -1
  85. package/dist/icon/sort-ascending.svg +1 -1
  86. package/dist/icon/sort-descending.svg +1 -1
  87. package/dist/icon/star.svg +1 -1
  88. package/dist/icon/tag.svg +1 -1
  89. package/dist/icon/trending-down.svg +1 -1
  90. package/dist/icon/trending-up.svg +1 -1
  91. package/dist/icon/upload.svg +1 -1
  92. package/dist/icon/volume-off.svg +1 -1
  93. package/dist/icon/volume.svg +1 -1
  94. package/dist/icon/world.svg +1 -1
  95. package/dist/icon/zoom-in.svg +1 -1
  96. package/dist/icon/zoom-out.svg +1 -1
  97. package/dist/index.d.ts +1 -0
  98. package/dist/index.js +1 -0
  99. package/dist/layout/AppShell.svelte +169 -169
  100. package/dist/layout/CustomNavbar.svelte +61 -61
  101. package/dist/layout/Navbar.svelte +206 -206
  102. package/dist/layout/NavbarItemDisplay.svelte +29 -29
  103. package/dist/layout/Sidenav.svelte +712 -712
  104. package/dist/styles/components.css +199 -199
  105. package/dist/styles/dark.css +146 -146
  106. package/dist/styles/index.css +116 -116
  107. package/dist/styles/reset.css +110 -110
  108. package/dist/styles/semantic.css +86 -86
  109. package/dist/styles/tokens.css +203 -197
  110. package/dist/styles/utilities.css +523 -523
  111. package/dist/ui/Accordion.svelte +289 -289
  112. package/dist/ui/ActionIcon.svelte +76 -76
  113. package/dist/ui/Badge.svelte +329 -279
  114. package/dist/ui/Breadcrumbs.svelte +131 -131
  115. package/dist/ui/Button.svelte +432 -370
  116. package/dist/ui/ButtonVariant.d.ts +1 -1
  117. package/dist/ui/Dialog.svelte +307 -307
  118. package/dist/ui/Drawer.svelte +524 -524
  119. package/dist/ui/Dropdown.svelte +97 -97
  120. package/dist/ui/Dropzone.svelte +122 -122
  121. package/dist/ui/Link.svelte +32 -32
  122. package/dist/ui/Loader.svelte +70 -70
  123. package/dist/ui/LoadingOverlay.svelte +53 -53
  124. package/dist/ui/Pagination.svelte +135 -135
  125. package/dist/ui/Popover.svelte +225 -225
  126. package/dist/ui/Progress.svelte +191 -191
  127. package/dist/ui/RingProgress.svelte +141 -141
  128. package/dist/ui/Skeleton.svelte +85 -85
  129. package/dist/ui/Stepper.svelte +355 -355
  130. package/dist/ui/Table.svelte +345 -345
  131. package/dist/ui/Tabs.svelte +146 -146
  132. package/dist/ui/ThemeSwitcher.svelte +39 -39
  133. package/dist/ui/Timeline.svelte +225 -225
  134. package/dist/ui/Toaster.svelte +6 -6
  135. package/dist/ui/Tooltip.svelte +434 -434
  136. package/package.json +14 -14
@@ -1,460 +1,460 @@
1
- <script lang="ts">
2
- import type { ComboBoxItem } from '../models/ComboBoxItem.js';
3
- import ActionIcon from '../ui/ActionIcon.svelte';
4
- import Button from '../ui/Button.svelte';
5
- import Checkbox from './Checkbox.svelte';
6
- import { iconPlus, iconTrash } from '../icon/index.js';
7
- import FormGroup from './FormGroup.svelte';
8
-
9
- interface Props {
10
- label?: string;
11
- items?: ComboBoxItem[];
12
- defaultValues?: string[];
13
- labelPlaceholder?: string;
14
- valuePlaceholder?: string;
15
- class?: string;
16
- onchange?: (items: ComboBoxItem[], defaultValues: string[]) => void;
17
- }
18
-
19
- let {
20
- label = '',
21
- items = $bindable([]),
22
- defaultValues = $bindable([]),
23
- labelPlaceholder = 'Label',
24
- valuePlaceholder = 'Value',
25
- class: classes = '',
26
- onchange
27
- }: Props = $props();
28
-
29
- let newLabel = $state('');
30
- let newValue = $state('');
31
- let valueManuallyEdited = $state(false);
32
- let labelInputEl: HTMLInputElement | undefined = $state(undefined);
33
-
34
- // Copy label exactly for default value
35
- function labelToValue(label: string): string {
36
- return label.trim();
37
- }
38
-
39
- // Handle label input - auto-sync to value if not manually edited
40
- function handleLabelInput(value: string) {
41
- newLabel = value;
42
- if (!valueManuallyEdited) {
43
- newValue = labelToValue(value);
44
- }
45
- }
46
-
47
- // Handle value input - mark as manually edited
48
- function handleValueInput(value: string) {
49
- newValue = value;
50
- if (value !== labelToValue(newLabel)) {
51
- valueManuallyEdited = true;
52
- }
53
- }
54
-
55
- // Check if a value already exists (excluding a specific index for editing)
56
- function valueExists(value: string, excludeIndex?: number): boolean {
57
- return items.some((item, i) =>
58
- item.value === value && i !== excludeIndex
59
- );
60
- }
61
-
62
- // Check if a label already exists (excluding a specific index for editing)
63
- function labelExists(label: string, excludeIndex?: number): boolean {
64
- return items.some((item, i) =>
65
- item.label === label && i !== excludeIndex
66
- );
67
- }
68
-
69
- // Check if new value/label would be a duplicate
70
- let isDuplicateNewValue = $derived(
71
- newValue.trim() !== '' && valueExists(newValue.trim())
72
- );
73
-
74
- let isDuplicateNewLabel = $derived(
75
- newLabel.trim() !== '' && labelExists(newLabel.trim())
76
- );
77
-
78
- function addItem() {
79
- if (!newLabel.trim() || !newValue.trim()) return;
80
- if (isDuplicateNewValue || isDuplicateNewLabel) return;
81
-
82
- const newItem: ComboBoxItem = {
83
- label: newLabel.trim(),
84
- value: newValue.trim()
85
- };
86
-
87
- items = [...items, newItem];
88
- newLabel = '';
89
- newValue = '';
90
- valueManuallyEdited = false;
91
-
92
- notifyChange();
93
-
94
- // Focus back on label input for quick entry of next item
95
- labelInputEl?.focus();
96
- }
97
-
98
- function removeItem(index: number) {
99
- const removedItem = items[index];
100
- items = items.filter((_, i) => i !== index);
101
-
102
- // Remove from defaults if it was selected
103
- if (defaultValues.includes(removedItem.value)) {
104
- defaultValues = defaultValues.filter(v => v !== removedItem.value);
105
- }
106
-
107
- notifyChange();
108
- }
109
-
110
- function toggleDefault(value: string, checked: boolean) {
111
- if (checked) {
112
- defaultValues = [...defaultValues, value];
113
- } else {
114
- defaultValues = defaultValues.filter(v => v !== value);
115
- }
116
- notifyChange();
117
- }
118
-
119
- function updateItemLabel(index: number, label: string) {
120
- // Don't allow duplicate labels
121
- if (labelExists(label, index)) return;
122
-
123
- const currentItem = items[index];
124
-
125
- // Check if label and value were in sync (exact case-sensitive match)
126
- const wasInSync = currentItem.label === currentItem.value;
127
-
128
- // If they were in sync, also update the value to maintain exact match (if it wouldn't create a duplicate)
129
- if (wasInSync && !valueExists(label, index)) {
130
- const oldValue = currentItem.value;
131
- items = items.map((item, i) =>
132
- i === index ? { ...item, label, value: label } : item
133
- );
134
-
135
- // Update defaults if the value was changed
136
- if (defaultValues.includes(oldValue)) {
137
- defaultValues = defaultValues.map(v => v === oldValue ? label : v);
138
- }
139
- } else {
140
- items = items.map((item, i) =>
141
- i === index ? { ...item, label } : item
142
- );
143
- }
144
-
145
- notifyChange();
146
- }
147
-
148
- function updateItemValue(index: number, newVal: string) {
149
- // Don't allow duplicate values
150
- if (valueExists(newVal, index)) return;
151
-
152
- const oldValue = items[index].value;
153
- items = items.map((item, i) =>
154
- i === index ? { ...item, value: newVal } : item
155
- );
156
-
157
- // Update defaults if the value was changed
158
- if (defaultValues.includes(oldValue)) {
159
- defaultValues = defaultValues.map(v => v === oldValue ? newVal : v);
160
- }
161
-
162
- notifyChange();
163
- }
164
-
165
- function notifyChange() {
166
- onchange?.(items, defaultValues);
167
- }
168
-
169
- function handleKeyDown(e: KeyboardEvent) {
170
- if (e.key === 'Enter') {
171
- e.preventDefault();
172
- addItem();
173
- }
174
- }
175
-
176
- // Computed output for display/copying
177
- let outputItems = $derived(
178
- items.map(item => ({
179
- ...item,
180
- selected: defaultValues.includes(item.value)
181
- }))
182
- );
183
-
184
- // Get labels for selected defaults
185
- let defaultLabels = $derived(
186
- defaultValues
187
- .map(v => items.find(i => i.value === v)?.label ?? v)
188
- .join(', ')
189
- );
190
- </script>
191
-
192
- <div class="combobox-builder {classes}">
193
- {#if label}
194
- <FormGroup {label}>
195
- <div></div>
196
- </FormGroup>
197
- {/if}
198
-
199
- <div class="builder-content">
200
- <!-- Header row -->
201
- <div class="header-row">
202
- <div class="col-default">Selected</div>
203
- <div class="col-label">Label</div>
204
- <div class="col-value">Value</div>
205
- <div class="col-actions"></div>
206
- </div>
207
-
208
- <!-- Existing items -->
209
- {#each items as item, index (index)}
210
- <div class="item-row">
211
- <div class="col-default">
212
- <Checkbox
213
- checked={defaultValues.includes(item.value)}
214
- onchange={(checked) => toggleDefault(item.value, checked)}
215
- />
216
- </div>
217
- <div class="col-label">
218
- <input
219
- type="text"
220
- class="item-input"
221
- value={item.label}
222
- oninput={(e) => updateItemLabel(index, e.currentTarget.value)}
223
- placeholder={labelPlaceholder}
224
- />
225
- </div>
226
- <div class="col-value">
227
- <input
228
- type="text"
229
- class="item-input"
230
- value={item.value}
231
- oninput={(e) => updateItemValue(index, e.currentTarget.value)}
232
- placeholder={valuePlaceholder}
233
- />
234
- </div>
235
- <div class="col-actions">
236
- <ActionIcon
237
- variant="danger-subtle"
238
- svg={iconTrash}
239
- size="1rem"
240
- onclick={() => removeItem(index)}
241
- tooltip="Remove item"
242
- />
243
- </div>
244
- </div>
245
- {/each}
246
-
247
- <!-- Add new item row -->
248
- <div class="add-row">
249
- <div class="col-default"></div>
250
- <div class="col-label">
251
- <input
252
- bind:this={labelInputEl}
253
- type="text"
254
- class="item-input new-input"
255
- class:error={isDuplicateNewLabel}
256
- value={newLabel}
257
- oninput={(e) => handleLabelInput(e.currentTarget.value)}
258
- placeholder={labelPlaceholder}
259
- onkeydown={handleKeyDown}
260
- />
261
- </div>
262
- <div class="col-value">
263
- <input
264
- type="text"
265
- class="item-input new-input"
266
- class:error={isDuplicateNewValue}
267
- value={newValue}
268
- oninput={(e) => handleValueInput(e.currentTarget.value)}
269
- placeholder={valuePlaceholder}
270
- onkeydown={handleKeyDown}
271
- />
272
- </div>
273
- <div class="col-actions">
274
- <ActionIcon
275
- variant="auto-subtle"
276
- svg={iconPlus}
277
- size="1rem"
278
- onclick={addItem}
279
- disabled={!newLabel.trim() || !newValue.trim() || isDuplicateNewValue || isDuplicateNewLabel}
280
- tooltip="Add item"
281
- />
282
- </div>
283
- </div>
284
-
285
- {#if isDuplicateNewLabel || isDuplicateNewValue}
286
- <div class="error-message">
287
- {#if isDuplicateNewLabel && isDuplicateNewValue}
288
- Label "{newLabel}" and value "{newValue}" already exist
289
- {:else if isDuplicateNewLabel}
290
- Label "{newLabel}" already exists
291
- {:else}
292
- Value "{newValue}" already exists
293
- {/if}
294
- </div>
295
- {/if}
296
-
297
- <!-- Quick add button for mobile/convenience -->
298
- {#if newLabel.trim() && newValue.trim() && !isDuplicateNewValue && !isDuplicateNewLabel}
299
- <div class="quick-add">
300
- <Button variant="auto-subtle" onclick={addItem}>
301
- Add "{newLabel}"
302
- </Button>
303
- </div>
304
- {/if}
305
- </div>
306
-
307
- <!-- Summary -->
308
- {#if items.length > 0}
309
- <div class="summary">
310
- <span class="summary-count">{items.length} item{items.length !== 1 ? 's' : ''}</span>
311
- {#if defaultValues.length > 0}
312
- <span class="summary-default">
313
- Selected: {defaultLabels}
314
- </span>
315
- {/if}
316
- </div>
317
- {/if}
318
- </div>
319
-
320
- <style>
321
- .combobox-builder {
322
- width: 100%;
323
- }
324
-
325
- .builder-content {
326
- border: 1px solid var(--pui-border-default);
327
- border-radius: var(--pui-radius-md);
328
- overflow: hidden;
329
- }
330
-
331
- .header-row {
332
- display: grid;
333
- grid-template-columns: 3.5rem 1fr 1fr 2.5rem;
334
- gap: var(--pui-spacing-2);
335
- padding: var(--pui-spacing-2) var(--pui-spacing-3);
336
- background-color: var(--pui-input-bg);
337
- border-bottom: 1px solid var(--pui-border-default);
338
- font-size: var(--pui-font-size-xs);
339
- font-weight: var(--pui-font-weight-semibold);
340
- color: var(--pui-text-muted);
341
- text-transform: uppercase;
342
- letter-spacing: 0.025em;
343
- }
344
-
345
- .item-row,
346
- .add-row {
347
- display: grid;
348
- grid-template-columns: 3.5rem 1fr 1fr 2.5rem;
349
- gap: var(--pui-spacing-2);
350
- padding: var(--pui-spacing-2) var(--pui-spacing-3);
351
- align-items: center;
352
- border-bottom: 1px solid var(--pui-border-default);
353
- }
354
-
355
- .item-row:last-of-type {
356
- border-bottom: 1px solid var(--pui-border-default);
357
- }
358
-
359
- .add-row {
360
- background-color: var(--pui-bg-subtle);
361
- border-bottom: none;
362
- }
363
-
364
- .col-default {
365
- display: flex;
366
- justify-content: center;
367
- align-items: center;
368
- }
369
-
370
- .col-actions {
371
- display: flex;
372
- justify-content: center;
373
- align-items: center;
374
- }
375
-
376
- .item-input {
377
- width: 100%;
378
- padding: var(--pui-spacing-1_5) var(--pui-spacing-2);
379
- border: 1px solid var(--pui-border-default);
380
- border-radius: var(--pui-radius-sm);
381
- background-color: var(--pui-input-bg);
382
- color: var(--pui-text-primary);
383
- font-size: var(--pui-font-size-sm);
384
- transition: border-color var(--pui-transition-fast) var(--pui-ease-in-out);
385
- }
386
-
387
- .item-input:focus {
388
- outline: none;
389
- border-color: var(--pui-input-border-focus);
390
- }
391
-
392
- .item-input::placeholder {
393
- color: var(--pui-text-placeholder);
394
- }
395
-
396
- .new-input {
397
- background-color: transparent;
398
- }
399
-
400
- .item-input.error {
401
- border-color: var(--pui-text-danger);
402
- }
403
-
404
- .item-input.error:focus {
405
- border-color: var(--pui-text-danger);
406
- }
407
-
408
- .error-message {
409
- padding: var(--pui-spacing-1_5) var(--pui-spacing-3);
410
- font-size: var(--pui-font-size-xs);
411
- color: var(--pui-text-danger);
412
- background-color: var(--pui-bg-subtle);
413
- }
414
-
415
- .quick-add {
416
- padding: var(--pui-spacing-2) var(--pui-spacing-3);
417
- background-color: var(--pui-bg-subtle);
418
- display: flex;
419
- justify-content: flex-end;
420
- }
421
-
422
- .summary {
423
- display: flex;
424
- gap: var(--pui-spacing-4);
425
- padding: var(--pui-spacing-2) 0;
426
- font-size: var(--pui-font-size-sm);
427
- color: var(--pui-text-muted);
428
- margin-top: var(--pui-spacing-2);
429
- }
430
-
431
- .summary-count {
432
- font-weight: var(--pui-font-weight-medium);
433
- }
434
-
435
- .summary-default {
436
- color: var(--pui-accent-color);
437
- }
438
-
439
- :global(.dark) .add-row,
440
- :global(.dark) .quick-add,
441
- :global(.dark) .error-message {
442
- background-color: var(--pui-bg-subtle);
443
- }
444
-
445
- /* Responsive adjustments */
446
- @media (max-width: 480px) {
447
- .header-row,
448
- .item-row,
449
- .add-row {
450
- grid-template-columns: 2.5rem 1fr 1fr 2rem;
451
- gap: var(--pui-spacing-1);
452
- padding: var(--pui-spacing-1_5) var(--pui-spacing-2);
453
- }
454
-
455
- .item-input {
456
- padding: var(--pui-spacing-1) var(--pui-spacing-1_5);
457
- font-size: var(--pui-font-size-sm);
458
- }
459
- }
460
- </style>
1
+ <script lang="ts">
2
+ import type { ComboBoxItem } from '../models/ComboBoxItem.js';
3
+ import ActionIcon from '../ui/ActionIcon.svelte';
4
+ import Button from '../ui/Button.svelte';
5
+ import Checkbox from './Checkbox.svelte';
6
+ import { iconPlus, iconTrash } from '../icon/index.js';
7
+ import FormGroup from './FormGroup.svelte';
8
+
9
+ interface Props {
10
+ label?: string;
11
+ items?: ComboBoxItem[];
12
+ defaultValues?: string[];
13
+ labelPlaceholder?: string;
14
+ valuePlaceholder?: string;
15
+ class?: string;
16
+ onchange?: (items: ComboBoxItem[], defaultValues: string[]) => void;
17
+ }
18
+
19
+ let {
20
+ label = '',
21
+ items = $bindable([]),
22
+ defaultValues = $bindable([]),
23
+ labelPlaceholder = 'Label',
24
+ valuePlaceholder = 'Value',
25
+ class: classes = '',
26
+ onchange
27
+ }: Props = $props();
28
+
29
+ let newLabel = $state('');
30
+ let newValue = $state('');
31
+ let valueManuallyEdited = $state(false);
32
+ let labelInputEl: HTMLInputElement | undefined = $state(undefined);
33
+
34
+ // Copy label exactly for default value
35
+ function labelToValue(label: string): string {
36
+ return label.trim();
37
+ }
38
+
39
+ // Handle label input - auto-sync to value if not manually edited
40
+ function handleLabelInput(value: string) {
41
+ newLabel = value;
42
+ if (!valueManuallyEdited) {
43
+ newValue = labelToValue(value);
44
+ }
45
+ }
46
+
47
+ // Handle value input - mark as manually edited
48
+ function handleValueInput(value: string) {
49
+ newValue = value;
50
+ if (value !== labelToValue(newLabel)) {
51
+ valueManuallyEdited = true;
52
+ }
53
+ }
54
+
55
+ // Check if a value already exists (excluding a specific index for editing)
56
+ function valueExists(value: string, excludeIndex?: number): boolean {
57
+ return items.some((item, i) =>
58
+ item.value === value && i !== excludeIndex
59
+ );
60
+ }
61
+
62
+ // Check if a label already exists (excluding a specific index for editing)
63
+ function labelExists(label: string, excludeIndex?: number): boolean {
64
+ return items.some((item, i) =>
65
+ item.label === label && i !== excludeIndex
66
+ );
67
+ }
68
+
69
+ // Check if new value/label would be a duplicate
70
+ let isDuplicateNewValue = $derived(
71
+ newValue.trim() !== '' && valueExists(newValue.trim())
72
+ );
73
+
74
+ let isDuplicateNewLabel = $derived(
75
+ newLabel.trim() !== '' && labelExists(newLabel.trim())
76
+ );
77
+
78
+ function addItem() {
79
+ if (!newLabel.trim() || !newValue.trim()) return;
80
+ if (isDuplicateNewValue || isDuplicateNewLabel) return;
81
+
82
+ const newItem: ComboBoxItem = {
83
+ label: newLabel.trim(),
84
+ value: newValue.trim()
85
+ };
86
+
87
+ items = [...items, newItem];
88
+ newLabel = '';
89
+ newValue = '';
90
+ valueManuallyEdited = false;
91
+
92
+ notifyChange();
93
+
94
+ // Focus back on label input for quick entry of next item
95
+ labelInputEl?.focus();
96
+ }
97
+
98
+ function removeItem(index: number) {
99
+ const removedItem = items[index];
100
+ items = items.filter((_, i) => i !== index);
101
+
102
+ // Remove from defaults if it was selected
103
+ if (defaultValues.includes(removedItem.value)) {
104
+ defaultValues = defaultValues.filter(v => v !== removedItem.value);
105
+ }
106
+
107
+ notifyChange();
108
+ }
109
+
110
+ function toggleDefault(value: string, checked: boolean) {
111
+ if (checked) {
112
+ defaultValues = [...defaultValues, value];
113
+ } else {
114
+ defaultValues = defaultValues.filter(v => v !== value);
115
+ }
116
+ notifyChange();
117
+ }
118
+
119
+ function updateItemLabel(index: number, label: string) {
120
+ // Don't allow duplicate labels
121
+ if (labelExists(label, index)) return;
122
+
123
+ const currentItem = items[index];
124
+
125
+ // Check if label and value were in sync (exact case-sensitive match)
126
+ const wasInSync = currentItem.label === currentItem.value;
127
+
128
+ // If they were in sync, also update the value to maintain exact match (if it wouldn't create a duplicate)
129
+ if (wasInSync && !valueExists(label, index)) {
130
+ const oldValue = currentItem.value;
131
+ items = items.map((item, i) =>
132
+ i === index ? { ...item, label, value: label } : item
133
+ );
134
+
135
+ // Update defaults if the value was changed
136
+ if (defaultValues.includes(oldValue)) {
137
+ defaultValues = defaultValues.map(v => v === oldValue ? label : v);
138
+ }
139
+ } else {
140
+ items = items.map((item, i) =>
141
+ i === index ? { ...item, label } : item
142
+ );
143
+ }
144
+
145
+ notifyChange();
146
+ }
147
+
148
+ function updateItemValue(index: number, newVal: string) {
149
+ // Don't allow duplicate values
150
+ if (valueExists(newVal, index)) return;
151
+
152
+ const oldValue = items[index].value;
153
+ items = items.map((item, i) =>
154
+ i === index ? { ...item, value: newVal } : item
155
+ );
156
+
157
+ // Update defaults if the value was changed
158
+ if (defaultValues.includes(oldValue)) {
159
+ defaultValues = defaultValues.map(v => v === oldValue ? newVal : v);
160
+ }
161
+
162
+ notifyChange();
163
+ }
164
+
165
+ function notifyChange() {
166
+ onchange?.(items, defaultValues);
167
+ }
168
+
169
+ function handleKeyDown(e: KeyboardEvent) {
170
+ if (e.key === 'Enter') {
171
+ e.preventDefault();
172
+ addItem();
173
+ }
174
+ }
175
+
176
+ // Computed output for display/copying
177
+ let outputItems = $derived(
178
+ items.map(item => ({
179
+ ...item,
180
+ selected: defaultValues.includes(item.value)
181
+ }))
182
+ );
183
+
184
+ // Get labels for selected defaults
185
+ let defaultLabels = $derived(
186
+ defaultValues
187
+ .map(v => items.find(i => i.value === v)?.label ?? v)
188
+ .join(', ')
189
+ );
190
+ </script>
191
+
192
+ <div class="combobox-builder {classes}">
193
+ {#if label}
194
+ <FormGroup {label}>
195
+ <div></div>
196
+ </FormGroup>
197
+ {/if}
198
+
199
+ <div class="builder-content">
200
+ <!-- Header row -->
201
+ <div class="header-row">
202
+ <div class="col-default">Selected</div>
203
+ <div class="col-label">Label</div>
204
+ <div class="col-value">Value</div>
205
+ <div class="col-actions"></div>
206
+ </div>
207
+
208
+ <!-- Existing items -->
209
+ {#each items as item, index (index)}
210
+ <div class="item-row">
211
+ <div class="col-default">
212
+ <Checkbox
213
+ checked={defaultValues.includes(item.value)}
214
+ onchange={(checked) => toggleDefault(item.value, checked)}
215
+ />
216
+ </div>
217
+ <div class="col-label">
218
+ <input
219
+ type="text"
220
+ class="item-input"
221
+ value={item.label}
222
+ oninput={(e) => updateItemLabel(index, e.currentTarget.value)}
223
+ placeholder={labelPlaceholder}
224
+ />
225
+ </div>
226
+ <div class="col-value">
227
+ <input
228
+ type="text"
229
+ class="item-input"
230
+ value={item.value}
231
+ oninput={(e) => updateItemValue(index, e.currentTarget.value)}
232
+ placeholder={valuePlaceholder}
233
+ />
234
+ </div>
235
+ <div class="col-actions">
236
+ <ActionIcon
237
+ variant="danger-subtle"
238
+ svg={iconTrash}
239
+ size="1rem"
240
+ onclick={() => removeItem(index)}
241
+ tooltip="Remove item"
242
+ />
243
+ </div>
244
+ </div>
245
+ {/each}
246
+
247
+ <!-- Add new item row -->
248
+ <div class="add-row">
249
+ <div class="col-default"></div>
250
+ <div class="col-label">
251
+ <input
252
+ bind:this={labelInputEl}
253
+ type="text"
254
+ class="item-input new-input"
255
+ class:error={isDuplicateNewLabel}
256
+ value={newLabel}
257
+ oninput={(e) => handleLabelInput(e.currentTarget.value)}
258
+ placeholder={labelPlaceholder}
259
+ onkeydown={handleKeyDown}
260
+ />
261
+ </div>
262
+ <div class="col-value">
263
+ <input
264
+ type="text"
265
+ class="item-input new-input"
266
+ class:error={isDuplicateNewValue}
267
+ value={newValue}
268
+ oninput={(e) => handleValueInput(e.currentTarget.value)}
269
+ placeholder={valuePlaceholder}
270
+ onkeydown={handleKeyDown}
271
+ />
272
+ </div>
273
+ <div class="col-actions">
274
+ <ActionIcon
275
+ variant="auto-subtle"
276
+ svg={iconPlus}
277
+ size="1rem"
278
+ onclick={addItem}
279
+ disabled={!newLabel.trim() || !newValue.trim() || isDuplicateNewValue || isDuplicateNewLabel}
280
+ tooltip="Add item"
281
+ />
282
+ </div>
283
+ </div>
284
+
285
+ {#if isDuplicateNewLabel || isDuplicateNewValue}
286
+ <div class="error-message">
287
+ {#if isDuplicateNewLabel && isDuplicateNewValue}
288
+ Label "{newLabel}" and value "{newValue}" already exist
289
+ {:else if isDuplicateNewLabel}
290
+ Label "{newLabel}" already exists
291
+ {:else}
292
+ Value "{newValue}" already exists
293
+ {/if}
294
+ </div>
295
+ {/if}
296
+
297
+ <!-- Quick add button for mobile/convenience -->
298
+ {#if newLabel.trim() && newValue.trim() && !isDuplicateNewValue && !isDuplicateNewLabel}
299
+ <div class="quick-add">
300
+ <Button variant="auto-subtle" onclick={addItem}>
301
+ Add "{newLabel}"
302
+ </Button>
303
+ </div>
304
+ {/if}
305
+ </div>
306
+
307
+ <!-- Summary -->
308
+ {#if items.length > 0}
309
+ <div class="summary">
310
+ <span class="summary-count">{items.length} item{items.length !== 1 ? 's' : ''}</span>
311
+ {#if defaultValues.length > 0}
312
+ <span class="summary-default">
313
+ Selected: {defaultLabels}
314
+ </span>
315
+ {/if}
316
+ </div>
317
+ {/if}
318
+ </div>
319
+
320
+ <style>
321
+ .combobox-builder {
322
+ width: 100%;
323
+ }
324
+
325
+ .builder-content {
326
+ border: 1px solid var(--pui-border-default);
327
+ border-radius: var(--pui-radius-md);
328
+ overflow: hidden;
329
+ }
330
+
331
+ .header-row {
332
+ display: grid;
333
+ grid-template-columns: 3.5rem 1fr 1fr 2.5rem;
334
+ gap: var(--pui-spacing-2);
335
+ padding: var(--pui-spacing-2) var(--pui-spacing-3);
336
+ background-color: var(--pui-input-bg);
337
+ border-bottom: 1px solid var(--pui-border-default);
338
+ font-size: var(--pui-font-size-xs);
339
+ font-weight: var(--pui-font-weight-semibold);
340
+ color: var(--pui-text-muted);
341
+ text-transform: uppercase;
342
+ letter-spacing: 0.025em;
343
+ }
344
+
345
+ .item-row,
346
+ .add-row {
347
+ display: grid;
348
+ grid-template-columns: 3.5rem 1fr 1fr 2.5rem;
349
+ gap: var(--pui-spacing-2);
350
+ padding: var(--pui-spacing-2) var(--pui-spacing-3);
351
+ align-items: center;
352
+ border-bottom: 1px solid var(--pui-border-default);
353
+ }
354
+
355
+ .item-row:last-of-type {
356
+ border-bottom: 1px solid var(--pui-border-default);
357
+ }
358
+
359
+ .add-row {
360
+ background-color: var(--pui-bg-subtle);
361
+ border-bottom: none;
362
+ }
363
+
364
+ .col-default {
365
+ display: flex;
366
+ justify-content: center;
367
+ align-items: center;
368
+ }
369
+
370
+ .col-actions {
371
+ display: flex;
372
+ justify-content: center;
373
+ align-items: center;
374
+ }
375
+
376
+ .item-input {
377
+ width: 100%;
378
+ padding: var(--pui-spacing-1_5) var(--pui-spacing-2);
379
+ border: 1px solid var(--pui-border-default);
380
+ border-radius: var(--pui-radius-sm);
381
+ background-color: var(--pui-input-bg);
382
+ color: var(--pui-text-primary);
383
+ font-size: var(--pui-font-size-sm);
384
+ transition: border-color var(--pui-transition-fast) var(--pui-ease-in-out);
385
+ }
386
+
387
+ .item-input:focus {
388
+ outline: none;
389
+ border-color: var(--pui-input-border-focus);
390
+ }
391
+
392
+ .item-input::placeholder {
393
+ color: var(--pui-text-placeholder);
394
+ }
395
+
396
+ .new-input {
397
+ background-color: transparent;
398
+ }
399
+
400
+ .item-input.error {
401
+ border-color: var(--pui-text-danger);
402
+ }
403
+
404
+ .item-input.error:focus {
405
+ border-color: var(--pui-text-danger);
406
+ }
407
+
408
+ .error-message {
409
+ padding: var(--pui-spacing-1_5) var(--pui-spacing-3);
410
+ font-size: var(--pui-font-size-xs);
411
+ color: var(--pui-text-danger);
412
+ background-color: var(--pui-bg-subtle);
413
+ }
414
+
415
+ .quick-add {
416
+ padding: var(--pui-spacing-2) var(--pui-spacing-3);
417
+ background-color: var(--pui-bg-subtle);
418
+ display: flex;
419
+ justify-content: flex-end;
420
+ }
421
+
422
+ .summary {
423
+ display: flex;
424
+ gap: var(--pui-spacing-4);
425
+ padding: var(--pui-spacing-2) 0;
426
+ font-size: var(--pui-font-size-sm);
427
+ color: var(--pui-text-muted);
428
+ margin-top: var(--pui-spacing-2);
429
+ }
430
+
431
+ .summary-count {
432
+ font-weight: var(--pui-font-weight-medium);
433
+ }
434
+
435
+ .summary-default {
436
+ color: var(--pui-accent-color);
437
+ }
438
+
439
+ :global(.dark) .add-row,
440
+ :global(.dark) .quick-add,
441
+ :global(.dark) .error-message {
442
+ background-color: var(--pui-bg-subtle);
443
+ }
444
+
445
+ /* Responsive adjustments */
446
+ @media (max-width: 480px) {
447
+ .header-row,
448
+ .item-row,
449
+ .add-row {
450
+ grid-template-columns: 2.5rem 1fr 1fr 2rem;
451
+ gap: var(--pui-spacing-1);
452
+ padding: var(--pui-spacing-1_5) var(--pui-spacing-2);
453
+ }
454
+
455
+ .item-input {
456
+ padding: var(--pui-spacing-1) var(--pui-spacing-1_5);
457
+ font-size: var(--pui-font-size-sm);
458
+ }
459
+ }
460
+ </style>