sveltacular 1.0.32 → 1.0.33

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.
@@ -10,6 +10,7 @@ export { default as NewOrExistingCombo } from './combo/new-or-existing-combo.sve
10
10
  export { default as NumberBox } from './number-box/number-box.svelte';
11
11
  export { default as NumberRangeBox } from './number-range-box/number-range-box.svelte';
12
12
  export { default as PhoneBox } from './phone-box/phone-box.svelte';
13
+ export { default as ReferenceBox } from './reference-box/reference-box.svelte';
13
14
  export { default as Slider } from './slider/slider.svelte';
14
15
  export { default as TagBox } from './tag-box/tag-box.svelte';
15
16
  export { default as TextArea } from './text-area/text-area.svelte';
@@ -20,6 +21,7 @@ export * from './check-box/index.js';
20
21
  export * from './list-box/index.js';
21
22
  export * from './phone-box/index.js';
22
23
  export * from './radio-group/index.js';
24
+ export * from './reference-box/index.js';
23
25
  export { default as Form } from './form.svelte';
24
26
  export { default as FormActions } from './form-actions/form-actions.svelte';
25
27
  export { default as FormField } from './form-field/form-field.svelte';
@@ -11,6 +11,7 @@ export { default as NewOrExistingCombo } from './combo/new-or-existing-combo.sve
11
11
  export { default as NumberBox } from './number-box/number-box.svelte';
12
12
  export { default as NumberRangeBox } from './number-range-box/number-range-box.svelte';
13
13
  export { default as PhoneBox } from './phone-box/phone-box.svelte';
14
+ export { default as ReferenceBox } from './reference-box/reference-box.svelte';
14
15
  export { default as Slider } from './slider/slider.svelte';
15
16
  export { default as TagBox } from './tag-box/tag-box.svelte';
16
17
  export { default as TextArea } from './text-area/text-area.svelte';
@@ -22,6 +23,7 @@ export * from './check-box/index.js';
22
23
  export * from './list-box/index.js';
23
24
  export * from './phone-box/index.js';
24
25
  export * from './radio-group/index.js';
26
+ export * from './reference-box/index.js';
25
27
  // Form structure components
26
28
  export { default as Form } from './form.svelte';
27
29
  export { default as FormActions } from './form-actions/form-actions.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ReferenceBox } from './reference-box.svelte';
2
+ export type { ReferenceItem, SearchFunction, CreateNewFunction } from './reference-box.js';
@@ -0,0 +1 @@
1
+ export { default as ReferenceBox } from './reference-box.svelte';
@@ -0,0 +1,8 @@
1
+ export type ReferenceItem = {
2
+ id: string | number;
3
+ name: string;
4
+ description?: string;
5
+ };
6
+ export type SearchFunction = (text: string) => Promise<ReferenceItem[]>;
7
+ export type CreateNewFunction = (inputName: string) => Promise<ReferenceItem | null>;
8
+ export type LinkBuilderFunction = (item: ReferenceItem) => string | undefined;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,709 @@
1
+ <script lang="ts">
2
+ import { uniqueId } from '../../helpers/unique-id.js';
3
+ import FormField, { type FormFieldFeedback } from '../form-field/form-field.svelte';
4
+ import Chip from '../../generic/chip/chip.svelte';
5
+ import Menu from '../../generic/menu/menu.svelte';
6
+ import type { FormFieldSizeOptions, MenuOption } from '../../types/form.js';
7
+ import { onMount } from 'svelte';
8
+ import { browser } from '$app/environment';
9
+ import debounce from '../../helpers/debounce.js';
10
+ import type {
11
+ ReferenceItem,
12
+ SearchFunction,
13
+ CreateNewFunction,
14
+ LinkBuilderFunction
15
+ } from './reference-box.js';
16
+ import Prompt from '../../modals/prompt.svelte';
17
+ import { ucfirst } from '../../helpers/ucfirst.js';
18
+ import Icon from '../../icons/icon.svelte';
19
+
20
+ const id = uniqueId();
21
+ const listboxId = `${id}-listbox`;
22
+
23
+ let {
24
+ value = $bindable([] as ReferenceItem[]),
25
+ items = [] as ReferenceItem[],
26
+ search = undefined as SearchFunction | undefined,
27
+ createNew = undefined as CreateNewFunction | undefined,
28
+ linkBuilder = undefined as LinkBuilderFunction | undefined,
29
+ resourceName = undefined as string | undefined,
30
+ placeholder = 'Search and add items...',
31
+ required = false,
32
+ disabled = false,
33
+ size = 'full' as FormFieldSizeOptions,
34
+ label = undefined as string | undefined,
35
+ helperText = undefined as string | undefined,
36
+ feedback = undefined as FormFieldFeedback | undefined,
37
+ maxItems = undefined as number | undefined,
38
+ onChange = undefined as ((value: ReferenceItem[]) => void) | undefined
39
+ }: {
40
+ value?: ReferenceItem[];
41
+ items?: ReferenceItem[];
42
+ search?: SearchFunction | undefined;
43
+ createNew?: CreateNewFunction | undefined;
44
+ linkBuilder?: LinkBuilderFunction | undefined;
45
+ resourceName?: string | undefined;
46
+ placeholder?: string;
47
+ required?: boolean;
48
+ disabled?: boolean;
49
+ size?: FormFieldSizeOptions;
50
+ label?: string;
51
+ helperText?: string;
52
+ feedback?: FormFieldFeedback | undefined;
53
+ maxItems?: number | undefined;
54
+ onChange?: ((value: ReferenceItem[]) => void) | undefined;
55
+ } = $props();
56
+
57
+ let searchText = $state('');
58
+ let isMenuOpen = $state(false);
59
+ let highlightIndex = $state(-1);
60
+ let inputElement: HTMLInputElement | null = $state(null);
61
+ let containerElement: HTMLDivElement | null = $state(null);
62
+ let invalidAttempt = $state(false);
63
+ let localItems = $state<ReferenceItem[]>([]);
64
+ let isLoading = $state(false);
65
+ let showPrompt = $state(false);
66
+ let isCreating = $state(false);
67
+ let createError = $state<string | null>(null);
68
+ let promptKey = $state(0);
69
+
70
+ // Use local items when search function is provided, otherwise use static items
71
+ let currentItems = $derived(search ? localItems : items);
72
+
73
+ // Convert ReferenceItem[] to MenuOption[] for Menu component
74
+ let menuOptions = $derived.by(() => {
75
+ return currentItems
76
+ .filter((item) => {
77
+ // Don't show already selected items
78
+ return !value.some((v) => v.id === item.id);
79
+ })
80
+ .map((item, index) => ({
81
+ value: String(item.id),
82
+ name: item.name,
83
+ index
84
+ }));
85
+ });
86
+
87
+ // Filter suggestions based on current input
88
+ let filteredSuggestions = $derived.by(() => {
89
+ if (!searchText.trim()) {
90
+ return menuOptions;
91
+ }
92
+
93
+ const searchLower = searchText.trim().toLowerCase();
94
+ return menuOptions.filter((option) => {
95
+ const optionText = option.name.toLowerCase();
96
+ return optionText.includes(searchLower);
97
+ });
98
+ });
99
+
100
+ // Check if an item already exists (by id)
101
+ function itemExists(item: ReferenceItem): boolean {
102
+ return value.some((v) => v.id === item.id);
103
+ }
104
+
105
+ // Check if max items reached
106
+ function isMaxItemsReached(): boolean {
107
+ return maxItems !== undefined && value.length >= maxItems;
108
+ }
109
+
110
+ function addItem(itemToAdd: ReferenceItem) {
111
+ // Check max items
112
+ if (isMaxItemsReached()) {
113
+ showInvalidFeedback();
114
+ return;
115
+ }
116
+
117
+ // Check duplicate
118
+ if (itemExists(itemToAdd)) {
119
+ showInvalidFeedback();
120
+ return;
121
+ }
122
+
123
+ // Add the item
124
+ value = [...value, itemToAdd];
125
+ searchText = '';
126
+ invalidAttempt = false;
127
+ isMenuOpen = false;
128
+ highlightIndex = -1;
129
+ onChange?.(value);
130
+
131
+ // Focus back on input
132
+ if (browser && inputElement) {
133
+ inputElement.focus();
134
+ }
135
+ }
136
+
137
+ function showInvalidFeedback() {
138
+ invalidAttempt = true;
139
+ setTimeout(() => {
140
+ invalidAttempt = false;
141
+ }, 500);
142
+ }
143
+
144
+ function removeItem(itemToRemove: ReferenceItem) {
145
+ value = value.filter((item) => item.id !== itemToRemove.id);
146
+ onChange?.(value);
147
+ }
148
+
149
+ function handleKeydown(event: KeyboardEvent) {
150
+ if (disabled) return;
151
+
152
+ // Escape - close dropdown
153
+ if (event.key === 'Escape') {
154
+ event.preventDefault();
155
+ closeDropdown();
156
+ return;
157
+ }
158
+
159
+ // Enter - add item or select from dropdown
160
+ if (event.key === 'Enter') {
161
+ event.preventDefault();
162
+ if (isMenuOpen && highlightIndex >= 0 && filteredSuggestions[highlightIndex]) {
163
+ const selectedOption = filteredSuggestions[highlightIndex];
164
+ const selectedItem = currentItems.find((item) => String(item.id) === selectedOption.value);
165
+ if (selectedItem) {
166
+ addItem(selectedItem);
167
+ }
168
+ } else if (isMenuOpen && createNew && highlightIndex === filteredSuggestions.length) {
169
+ // "Create new..." is highlighted
170
+ openCreatePrompt();
171
+ } else if (isMenuOpen && filteredSuggestions.length > 0) {
172
+ // Auto-select first item if available
173
+ const firstItem = currentItems.find(
174
+ (item) => String(item.id) === filteredSuggestions[0].value
175
+ );
176
+ if (firstItem) {
177
+ addItem(firstItem);
178
+ }
179
+ }
180
+ return;
181
+ }
182
+
183
+ // Tab - select highlighted suggestion or add current text
184
+ if (event.key === 'Tab') {
185
+ if (isMenuOpen && highlightIndex >= 0 && filteredSuggestions[highlightIndex]) {
186
+ event.preventDefault();
187
+ const selectedOption = filteredSuggestions[highlightIndex];
188
+ const selectedItem = currentItems.find((item) => String(item.id) === selectedOption.value);
189
+ if (selectedItem) {
190
+ addItem(selectedItem);
191
+ }
192
+ } else if (isMenuOpen && createNew && highlightIndex === filteredSuggestions.length) {
193
+ // "Create new..." is highlighted
194
+ event.preventDefault();
195
+ openCreatePrompt();
196
+ } else if (isMenuOpen && filteredSuggestions.length > 0) {
197
+ event.preventDefault();
198
+ const firstItem = currentItems.find(
199
+ (item) => String(item.id) === filteredSuggestions[0].value
200
+ );
201
+ if (firstItem) {
202
+ addItem(firstItem);
203
+ }
204
+ }
205
+ return;
206
+ }
207
+
208
+ // Arrow Down - navigate suggestions
209
+ if (event.key === 'ArrowDown') {
210
+ event.preventDefault();
211
+ if (!isMenuOpen && (filteredSuggestions.length > 0 || createNew)) {
212
+ openDropdown();
213
+ }
214
+ // Allow highlighting "Create new..." option if it exists
215
+ const maxIndex = createNew ? filteredSuggestions.length : filteredSuggestions.length - 1;
216
+ if (maxIndex >= 0) {
217
+ highlightIndex = Math.min(highlightIndex + 1, maxIndex);
218
+ }
219
+ return;
220
+ }
221
+
222
+ // Arrow Up - navigate suggestions
223
+ if (event.key === 'ArrowUp') {
224
+ event.preventDefault();
225
+ if (isMenuOpen) {
226
+ // Allow highlighting "Create new..." option if it exists
227
+ const maxIndex = createNew ? filteredSuggestions.length : filteredSuggestions.length - 1;
228
+ if (maxIndex >= 0) {
229
+ highlightIndex = Math.max(highlightIndex - 1, 0);
230
+ }
231
+ }
232
+ return;
233
+ }
234
+ }
235
+
236
+ function handleInput(event: Event) {
237
+ const input = event.target as HTMLInputElement;
238
+ const inputValue = input.value;
239
+ searchText = inputValue;
240
+
241
+ // Clear invalid feedback when user types
242
+ invalidAttempt = false;
243
+
244
+ // Open dropdown when typing if there are items or search function
245
+ if (inputValue.trim() && (currentItems.length > 0 || search)) {
246
+ openDropdown();
247
+ // Auto-highlight first item
248
+ highlightIndex = 0;
249
+ } else {
250
+ closeDropdown();
251
+ }
252
+
253
+ // Trigger search if search function is provided
254
+ if (search && inputValue.trim()) {
255
+ triggerSearch();
256
+ }
257
+ }
258
+
259
+ function openDropdown() {
260
+ if (!disabled && (filteredSuggestions.length > 0 || createNew)) {
261
+ isMenuOpen = true;
262
+ }
263
+ }
264
+
265
+ function closeDropdown() {
266
+ isMenuOpen = false;
267
+ highlightIndex = -1;
268
+ }
269
+
270
+ function onSelectFromMenu(item: MenuOption) {
271
+ const selectedItem = currentItems.find((i) => String(i.id) === item.value);
272
+ if (selectedItem) {
273
+ addItem(selectedItem);
274
+ }
275
+ }
276
+
277
+ // Get ARIA active descendant
278
+ let activeDescendant = $derived(
279
+ highlightIndex >= 0 && filteredSuggestions[highlightIndex]
280
+ ? `${listboxId}-option-${highlightIndex}`
281
+ : highlightIndex === filteredSuggestions.length && createNew
282
+ ? `${listboxId}-option-create`
283
+ : undefined
284
+ );
285
+
286
+ // Check if we should show "Create new..." option
287
+ let showCreateNew = $derived(!!createNew && isMenuOpen);
288
+
289
+ // Check if there are no results when searching
290
+ let hasNoResults = $derived(
291
+ searchText.trim() && filteredSuggestions.length === 0 && !isLoading && !createNew
292
+ );
293
+
294
+ // Debounced search function
295
+ const triggerSearch = debounce(async () => {
296
+ if (search && searchText.trim()) {
297
+ isLoading = true;
298
+ try {
299
+ localItems = await search(searchText);
300
+ } finally {
301
+ isLoading = false;
302
+ }
303
+ }
304
+ }, 300);
305
+
306
+ // Handle creating new item
307
+ const handleCreateNew = async (name: string) => {
308
+ if (!createNew) return;
309
+
310
+ isCreating = true;
311
+ createError = null;
312
+
313
+ try {
314
+ const result = await createNew(name);
315
+
316
+ if (result) {
317
+ // Add to local items if using search (for display in dropdown)
318
+ if (search) {
319
+ localItems = [...localItems, result];
320
+ }
321
+ // Note: For static items, the parent should update the items prop
322
+ // The newly created item will be added to value, which is what matters
323
+
324
+ // Add the newly created item
325
+ addItem(result);
326
+ showPrompt = false;
327
+ } else {
328
+ // Handle error - show message to user
329
+ createError = 'Failed to create new item';
330
+ // Keep prompt open so user can try again
331
+ }
332
+ } catch (error) {
333
+ createError = error instanceof Error ? error.message : 'An error occurred';
334
+ // Keep prompt open so user can try again
335
+ } finally {
336
+ isCreating = false;
337
+ }
338
+ };
339
+
340
+ const openCreatePrompt = () => {
341
+ createError = null;
342
+ // Increment key to force Prompt to remount and reset its value
343
+ promptKey++;
344
+ showPrompt = true;
345
+ // Close dropdown when opening prompt
346
+ isMenuOpen = false;
347
+ };
348
+
349
+ // Reset error when prompt closes
350
+ $effect(() => {
351
+ if (!showPrompt) {
352
+ createError = null;
353
+ }
354
+ });
355
+
356
+ // Close dropdown when clicking outside
357
+ onMount(() => {
358
+ const handleClickOutside = (e: MouseEvent) => {
359
+ if (containerElement && !containerElement.contains(e.target as Node)) {
360
+ closeDropdown();
361
+ }
362
+ };
363
+
364
+ if (browser) {
365
+ document.addEventListener('mousedown', handleClickOutside);
366
+ return () => {
367
+ document.removeEventListener('mousedown', handleClickOutside);
368
+ };
369
+ }
370
+ });
371
+ </script>
372
+
373
+ <FormField {size} {label} {id} {required} {disabled} {helperText} {feedback}>
374
+ <!-- ARIA live region for screen reader announcements -->
375
+ <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
376
+ {#if isMenuOpen && isLoading}
377
+ Searching...
378
+ {:else if isMenuOpen && filteredSuggestions.length > 0}
379
+ {filteredSuggestions.length}
380
+ {filteredSuggestions.length === 1 ? 'result' : 'results'} available
381
+ {:else if invalidAttempt}
382
+ {#if isMaxItemsReached()}
383
+ Maximum {maxItems} items reached
384
+ {:else}
385
+ Item already exists
386
+ {/if}
387
+ {/if}
388
+ </div>
389
+
390
+ <div class="reference-box" bind:this={containerElement}>
391
+ <div class="input-container">
392
+ <div
393
+ class="input {disabled ? 'disabled' : 'enabled'} {invalidAttempt
394
+ ? 'invalid'
395
+ : ''} {isMenuOpen ? 'open' : ''}"
396
+ >
397
+ <input
398
+ {id}
399
+ type="text"
400
+ bind:value={searchText}
401
+ bind:this={inputElement}
402
+ {placeholder}
403
+ onkeydown={handleKeydown}
404
+ oninput={handleInput}
405
+ {disabled}
406
+ {required}
407
+ role="combobox"
408
+ aria-expanded={isMenuOpen}
409
+ aria-controls={listboxId}
410
+ aria-autocomplete="list"
411
+ aria-activedescendant={activeDescendant}
412
+ aria-haspopup="listbox"
413
+ aria-label="Reference input"
414
+ aria-busy={isLoading}
415
+ />
416
+ {#if isLoading}
417
+ <div class="loading-indicator" aria-hidden="true">
418
+ <div class="spinner"></div>
419
+ </div>
420
+ {/if}
421
+ </div>
422
+ </div>
423
+
424
+ <!-- Autocomplete dropdown -->
425
+ {#if isMenuOpen}
426
+ <div class="dropdown">
427
+ {#if hasNoResults}
428
+ <div class="no-results" role="status">No results found</div>
429
+ {:else}
430
+ <Menu
431
+ items={filteredSuggestions}
432
+ open={isMenuOpen}
433
+ closeAfterSelect={true}
434
+ {searchText}
435
+ onSelect={onSelectFromMenu}
436
+ size="full"
437
+ bind:highlightIndex
438
+ {listboxId}
439
+ />
440
+ {/if}
441
+ {#if showCreateNew}
442
+ <button
443
+ type="button"
444
+ class="create-new"
445
+ class:selected={highlightIndex === filteredSuggestions.length}
446
+ onclick={openCreatePrompt}
447
+ role="option"
448
+ id={listboxId ? `${listboxId}-option-create` : undefined}
449
+ aria-selected={highlightIndex === filteredSuggestions.length}
450
+ >
451
+ <Icon type="plus" size="sm" />
452
+ <span>
453
+ {#if resourceName}
454
+ Create new {resourceName}...
455
+ {:else}
456
+ Create new...
457
+ {/if}
458
+ </span>
459
+ </button>
460
+ {/if}
461
+ </div>
462
+ {/if}
463
+
464
+ {#if value.length > 0}
465
+ <div class="items">
466
+ {#each value as item}
467
+ {@const linkUrl = linkBuilder ? linkBuilder(item) : undefined}
468
+ <span class="item">
469
+ <Chip
470
+ label={item.name}
471
+ removable={!disabled}
472
+ onRemove={() => removeItem(item)}
473
+ link={linkUrl ? { url: linkUrl, target: '_blank' } : undefined}
474
+ tooltip={item.description}
475
+ >
476
+ {item.description}
477
+ </Chip>
478
+ </span>
479
+ {/each}
480
+ </div>
481
+ {/if}
482
+ </div>
483
+ </FormField>
484
+
485
+ {#key promptKey}
486
+ <Prompt
487
+ bind:open={showPrompt}
488
+ title={resourceName ? `Create New ${ucfirst(resourceName)}` : 'Create New'}
489
+ placeholder="Enter name"
490
+ required={true}
491
+ okText={resourceName ? `Create ${ucfirst(resourceName)}` : 'Create'}
492
+ cancelText="Cancel"
493
+ onOk={handleCreateNew}
494
+ onCancel={() => {
495
+ createError = null;
496
+ }}
497
+ >
498
+ {#if createError}
499
+ <div class="create-error" role="alert">
500
+ {createError}
501
+ </div>
502
+ {/if}
503
+ {#if isCreating}
504
+ <div class="creating-indicator" aria-live="polite">Creating...</div>
505
+ {/if}
506
+ </Prompt>
507
+ {/key}
508
+
509
+ <style>.reference-box {
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: var(--spacing-sm);
513
+ width: 100%;
514
+ position: relative;
515
+ }
516
+ .reference-box .input-container {
517
+ display: flex;
518
+ gap: var(--spacing-sm);
519
+ align-items: stretch;
520
+ }
521
+ .reference-box .input-container .input {
522
+ display: flex;
523
+ align-items: center;
524
+ justify-content: flex-start;
525
+ position: relative;
526
+ width: 100%;
527
+ min-height: 2.125rem;
528
+ border-radius: var(--radius-md);
529
+ border: var(--border-thin) solid var(--form-input-border);
530
+ background-color: var(--form-input-bg);
531
+ color: var(--form-input-fg);
532
+ font-size: var(--font-md);
533
+ font-weight: 500;
534
+ line-height: 2rem;
535
+ padding: 0;
536
+ transition: background-color var(--transition-base) var(--ease-in-out), border-color var(--transition-base) var(--ease-in-out), color var(--transition-base) var(--ease-in-out), fill var(--transition-base) var(--ease-in-out), stroke var(--transition-base) var(--ease-in-out), box-shadow var(--transition-base) var(--ease-in-out);
537
+ user-select: none;
538
+ white-space: nowrap;
539
+ flex: 1;
540
+ }
541
+ .reference-box .input-container .input.disabled {
542
+ opacity: 0.5;
543
+ cursor: not-allowed;
544
+ }
545
+ .reference-box .input-container .input.open {
546
+ border-color: var(--focus-ring, #007bff);
547
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
548
+ }
549
+ .reference-box .input-container .input.invalid {
550
+ border-color: var(--danger, #dc3545);
551
+ animation: shake 0.3s ease-in-out;
552
+ box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
553
+ }
554
+ .reference-box .input-container .input input {
555
+ background-color: transparent;
556
+ border: none;
557
+ line-height: 2rem;
558
+ height: 2rem;
559
+ font-size: var(--font-md);
560
+ width: 100%;
561
+ flex-grow: 1;
562
+ padding: 0 var(--spacing-base);
563
+ margin: 0;
564
+ }
565
+ .reference-box .input-container .input input:focus {
566
+ outline: none;
567
+ }
568
+ .reference-box .input-container .input input:disabled {
569
+ cursor: not-allowed;
570
+ }
571
+ .reference-box .input-container .input input::placeholder {
572
+ color: var(--form-input-placeholder);
573
+ }
574
+ .reference-box .input-container .input .loading-indicator {
575
+ position: absolute;
576
+ right: var(--spacing-base);
577
+ width: 1.25rem;
578
+ height: 1.25rem;
579
+ display: flex;
580
+ align-items: center;
581
+ justify-content: center;
582
+ z-index: 2;
583
+ }
584
+ .reference-box .input-container .input .loading-indicator .spinner {
585
+ width: 1rem;
586
+ height: 1rem;
587
+ border: 2px solid var(--form-input-border);
588
+ border-top-color: var(--form-input-fg);
589
+ border-radius: 50%;
590
+ animation: spin 0.8s linear infinite;
591
+ }
592
+ .reference-box .dropdown {
593
+ position: absolute;
594
+ top: calc(100% - var(--spacing-sm));
595
+ left: 0;
596
+ width: 100%;
597
+ z-index: 1000;
598
+ margin-top: 0.25rem;
599
+ }
600
+ .reference-box .dropdown .no-results {
601
+ padding: 1rem;
602
+ text-align: center;
603
+ color: var(--text-muted, #6c757d);
604
+ font-size: var(--font-sm, 0.875rem);
605
+ background-color: var(--form-input-bg);
606
+ border: var(--border-thin) solid var(--form-input-border);
607
+ border-radius: var(--radius-md);
608
+ }
609
+ .reference-box .dropdown .create-new {
610
+ width: 100%;
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 0.5rem;
614
+ padding: 0.5rem 1rem;
615
+ border: none;
616
+ background-color: var(--form-input-bg);
617
+ color: var(--form-input-fg);
618
+ font-size: var(--font-sm, 0.875rem);
619
+ cursor: pointer;
620
+ border-top: var(--border-thin) solid var(--form-input-border);
621
+ transition: background-color var(--transition-base) var(--ease-in-out), color var(--transition-base) var(--ease-in-out);
622
+ border: var(--border-thin) solid var(--form-input-border);
623
+ border-top: none;
624
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
625
+ }
626
+ .reference-box .dropdown .create-new:hover, .reference-box .dropdown .create-new.selected {
627
+ background: var(--form-input-selected-bg, #003c75);
628
+ color: var(--form-input-selected-fg, white);
629
+ }
630
+ .reference-box .dropdown .create-new:focus {
631
+ outline: none;
632
+ }
633
+ .reference-box .dropdown .create-new:focus-visible {
634
+ outline: 2px solid var(--focus-ring, #007bff);
635
+ outline-offset: -2px;
636
+ }
637
+ .reference-box .dropdown .create-new span {
638
+ flex: 1;
639
+ }
640
+ .reference-box .dropdown :global(.menu) {
641
+ font-size: var(--font-sm, 0.875rem);
642
+ border-radius: var(--radius-md);
643
+ }
644
+ .reference-box .dropdown :global(.menu) :global(li) :global(div) {
645
+ padding: 0.25rem 0.5rem;
646
+ line-height: 1.25;
647
+ font-size: var(--font-sm, 0.875rem);
648
+ }
649
+
650
+ .sr-only {
651
+ position: absolute;
652
+ width: 1px;
653
+ height: 1px;
654
+ padding: 0;
655
+ margin: -1px;
656
+ overflow: hidden;
657
+ clip: rect(0, 0, 0, 0);
658
+ white-space: nowrap;
659
+ border-width: 0;
660
+ }
661
+
662
+ @keyframes shake {
663
+ 0%, 100% {
664
+ transform: translateX(0);
665
+ }
666
+ 10%, 30%, 50%, 70%, 90% {
667
+ transform: translateX(-4px);
668
+ }
669
+ 20%, 40%, 60%, 80% {
670
+ transform: translateX(4px);
671
+ }
672
+ }
673
+ @keyframes spin {
674
+ from {
675
+ transform: rotate(0deg);
676
+ }
677
+ to {
678
+ transform: rotate(360deg);
679
+ }
680
+ }
681
+ .create-error {
682
+ color: var(--color-danger, #dc3545);
683
+ font-size: var(--font-sm, 0.875rem);
684
+ margin-top: 0.5rem;
685
+ padding: 0.5rem;
686
+ background-color: var(--color-danger-bg, #f8d7da);
687
+ border-radius: var(--radius-sm, 0.25rem);
688
+ }
689
+
690
+ .creating-indicator {
691
+ color: var(--text-muted, #6c757d);
692
+ font-size: var(--font-sm, 0.875rem);
693
+ margin-top: 0.5rem;
694
+ font-style: italic;
695
+ }
696
+
697
+ .item {
698
+ display: inline-block;
699
+ vertical-align: middle;
700
+ line-height: 1;
701
+ margin-right: var(--spacing-xs);
702
+ margin-bottom: var(--spacing-xs);
703
+ padding: 0;
704
+ border: none;
705
+ background: none;
706
+ font: inherit;
707
+ color: inherit;
708
+ text-align: left;
709
+ }</style>
@@ -0,0 +1,23 @@
1
+ import { type FormFieldFeedback } from '../form-field/form-field.svelte';
2
+ import type { FormFieldSizeOptions } from '../../types/form.js';
3
+ import type { ReferenceItem, SearchFunction, CreateNewFunction, LinkBuilderFunction } from './reference-box.js';
4
+ type $$ComponentProps = {
5
+ value?: ReferenceItem[];
6
+ items?: ReferenceItem[];
7
+ search?: SearchFunction | undefined;
8
+ createNew?: CreateNewFunction | undefined;
9
+ linkBuilder?: LinkBuilderFunction | undefined;
10
+ resourceName?: string | undefined;
11
+ placeholder?: string;
12
+ required?: boolean;
13
+ disabled?: boolean;
14
+ size?: FormFieldSizeOptions;
15
+ label?: string;
16
+ helperText?: string;
17
+ feedback?: FormFieldFeedback | undefined;
18
+ maxItems?: number | undefined;
19
+ onChange?: ((value: ReferenceItem[]) => void) | undefined;
20
+ };
21
+ declare const ReferenceBox: import("svelte").Component<$$ComponentProps, {}, "value">;
22
+ type ReferenceBox = ReturnType<typeof ReferenceBox>;
23
+ export default ReferenceBox;
@@ -25,7 +25,8 @@
25
25
  strict = false,
26
26
  caseInsensitive = true,
27
27
  maxTags = undefined as number | undefined,
28
- onChange = undefined as ((value: string[]) => void) | undefined
28
+ onChange = undefined as ((value: string[]) => void) | undefined,
29
+ deleteOnBackspace = false
29
30
  }: {
30
31
  value?: string[];
31
32
  placeholder?: string;
@@ -42,6 +43,7 @@
42
43
  caseInsensitive?: boolean;
43
44
  maxTags?: number | undefined;
44
45
  onChange?: ((value: string[]) => void) | undefined;
46
+ deleteOnBackspace?: boolean;
45
47
  } = $props();
46
48
 
47
49
  let newTag = $state('');
@@ -118,7 +120,7 @@
118
120
 
119
121
  function addTag(tagToAdd?: string) {
120
122
  const tag = (tagToAdd || newTag).trim();
121
-
123
+
122
124
  // Prevent empty tags
123
125
  if (!tag) {
124
126
  newTag = '';
@@ -151,7 +153,7 @@
151
153
  isMenuOpen = false;
152
154
  highlightIndex = -1;
153
155
  onChange?.(value);
154
-
156
+
155
157
  // Focus back on input
156
158
  if (browser && inputElement) {
157
159
  inputElement.focus();
@@ -208,7 +210,9 @@
208
210
 
209
211
  // Backspace - remove last tag if input is empty
210
212
  if (event.key === 'Backspace' && newTag === '' && value.length > 0) {
211
- removeTag(value[value.length - 1]);
213
+ if (deleteOnBackspace) {
214
+ removeTag(value[value.length - 1]);
215
+ }
212
216
  return;
213
217
  }
214
218
 
@@ -312,7 +316,8 @@
312
316
  <!-- ARIA live region for screen reader announcements -->
313
317
  <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
314
318
  {#if isMenuOpen && filteredSuggestions.length > 0}
315
- {filteredSuggestions.length} {filteredSuggestions.length === 1 ? 'suggestion' : 'suggestions'} available
319
+ {filteredSuggestions.length}
320
+ {filteredSuggestions.length === 1 ? 'suggestion' : 'suggestions'} available
316
321
  {:else if invalidTagAttempt}
317
322
  {#if isMaxTagsReached()}
318
323
  Maximum {maxTags} tags reached
@@ -326,7 +331,11 @@
326
331
 
327
332
  <div class="tag-box" bind:this={containerElement}>
328
333
  <div class="input-container">
329
- <div class="input {disabled ? 'disabled' : 'enabled'} {invalidTagAttempt ? 'invalid' : ''} {isMenuOpen ? 'open' : ''}">
334
+ <div
335
+ class="input {disabled ? 'disabled' : 'enabled'} {invalidTagAttempt
336
+ ? 'invalid'
337
+ : ''} {isMenuOpen ? 'open' : ''}"
338
+ >
330
339
  <input
331
340
  {id}
332
341
  type="text"
@@ -16,6 +16,7 @@ type $$ComponentProps = {
16
16
  caseInsensitive?: boolean;
17
17
  maxTags?: number | undefined;
18
18
  onChange?: ((value: string[]) => void) | undefined;
19
+ deleteOnBackspace?: boolean;
19
20
  };
20
21
  declare const TagBox: import("svelte").Component<$$ComponentProps, {}, "value">;
21
22
  type TagBox = ReturnType<typeof TagBox>;
@@ -1,23 +1,43 @@
1
1
  <script lang="ts">
2
+ import Icon from '../../icons/icon.svelte';
2
3
  import type { ComponentSize } from '../../types/size.js';
4
+ import type { Snippet } from 'svelte';
3
5
 
4
6
  let {
5
7
  label,
8
+ tooltip = undefined,
6
9
  removable = false,
7
10
  size = 'md' as ComponentSize,
8
11
  variant = 'standard' as 'standard' | 'positive' | 'negative',
9
- onRemove = undefined
12
+ onRemove = undefined,
13
+ link = undefined,
14
+ children = undefined
10
15
  }: {
11
16
  label: string;
17
+ tooltip?: string;
18
+ link?: { url: string; target?: string };
12
19
  removable?: boolean;
13
20
  size?: ComponentSize;
14
21
  variant?: 'standard' | 'positive' | 'negative';
15
22
  onRemove?: (() => void) | undefined;
23
+ children?: Snippet;
16
24
  } = $props();
17
25
  </script>
18
26
 
19
- <div class="chip {size} {variant}">
27
+ <div class="chip {size} {variant}" title={tooltip}>
20
28
  <span class="label">{label}</span>
29
+ {#if children}
30
+ <span class="children">
31
+ {@render children?.()}
32
+ </span>
33
+ {/if}
34
+
35
+ {#if link}
36
+ <a class="link" href={link.url} target={link.target || '_blank'} rel="noopener noreferrer">
37
+ <Icon type="external-link" size="xs" />
38
+ </a>
39
+ {/if}
40
+
21
41
  {#if removable}
22
42
  <button type="button" class="remove" onclick={onRemove} aria-label="Remove {label}"> × </button>
23
43
  {/if}
@@ -32,10 +52,24 @@
32
52
  background-color: var(--chip-bg, #e0e0e0);
33
53
  color: var(--chip-fg, #000);
34
54
  font-size: 0.875rem;
35
- font-weight: 500;
36
55
  }
37
56
  .chip .label {
38
57
  line-height: 1.5;
58
+ font-weight: 500;
59
+ }
60
+ .chip .children {
61
+ font-size: 80%;
62
+ font-style: italic;
63
+ opacity: 0.5;
64
+ }
65
+ .chip .link {
66
+ display: inline-block;
67
+ vertical-align: middle;
68
+ line-height: 1;
69
+ margin-left: 0.25rem;
70
+ margin-right: 0.25rem;
71
+ padding: 0;
72
+ border: none;
39
73
  }
40
74
  .chip .remove {
41
75
  background: none;
@@ -1,10 +1,17 @@
1
1
  import type { ComponentSize } from '../../types/size.js';
2
+ import type { Snippet } from 'svelte';
2
3
  type $$ComponentProps = {
3
4
  label: string;
5
+ tooltip?: string;
6
+ link?: {
7
+ url: string;
8
+ target?: string;
9
+ };
4
10
  removable?: boolean;
5
11
  size?: ComponentSize;
6
12
  variant?: 'standard' | 'positive' | 'negative';
7
13
  onRemove?: (() => void) | undefined;
14
+ children?: Snippet;
8
15
  };
9
16
  declare const Chip: import("svelte").Component<$$ComponentProps, {}, "">;
10
17
  type Chip = ReturnType<typeof Chip>;
@@ -229,6 +229,15 @@ export const iconRegistry = {
229
229
  }
230
230
  ]
231
231
  },
232
+ 'external-link': {
233
+ viewBox: '0 0 122.6 122.88',
234
+ fill: 'currentColor',
235
+ paths: [
236
+ {
237
+ d: 'M110.6,72.58c0-3.19,2.59-5.78,5.78-5.78c3.19,0,5.78,2.59,5.78,5.78v33.19c0,4.71-1.92,8.99-5.02,12.09 c-3.1,3.1-7.38,5.02-12.09,5.02H17.11c-4.71,0-8.99-1.92-12.09-5.02c-3.1-3.1-5.02-7.38-5.02-12.09V17.19 C0,12.48,1.92,8.2,5.02,5.1C8.12,2,12.4,0.08,17.11,0.08h32.98c3.19,0,5.78,2.59,5.78,5.78c0,3.19-2.59,5.78-5.78,5.78H17.11 c-1.52,0-2.9,0.63-3.91,1.63c-1.01,1.01-1.63,2.39-1.63,3.91v88.58c0,1.52,0.63,2.9,1.63,3.91c1.01,1.01,2.39,1.63,3.91,1.63h87.95 c1.52,0,2.9-0.63,3.91-1.63s1.63-2.39,1.63-3.91V72.58L110.6,72.58z M112.42,17.46L54.01,76.6c-2.23,2.27-5.89,2.3-8.16,0.07 c-2.27-2.23-2.3-5.89-0.07-8.16l56.16-56.87H78.56c-3.19,0-5.78-2.59-5.78-5.78c0-3.19,2.59-5.78,5.78-5.78h26.5 c5.12,0,11.72-0.87,15.65,3.1c2.48,2.51,1.93,22.52,1.61,34.11c-0.08,3-0.15,5.29-0.15,6.93c0,3.19-2.59,5.78-5.78,5.78 c-3.19,0-5.78-2.59-5.78-5.78c0-0.31,0.08-3.32,0.19-7.24C110.96,30.94,111.93,22.94,112.42,17.46L112.42,17.46z'
238
+ }
239
+ ]
240
+ },
232
241
  eye: {
233
242
  viewBox: '0 0 20 14',
234
243
  fill: 'none',
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Type-safe string union of all available icon types
3
3
  */
4
- export type IconType = 'angle-right' | 'angle-up' | 'angle-left' | 'angle-down' | 'arrow-left' | 'arrow-right' | 'arrow-up' | 'arrow-down' | 'check' | 'clipboard' | 'close' | 'copy' | 'download' | 'edit' | 'envelope' | 'envelope-full' | 'export' | 'eye' | 'folder-open' | 'hamburger' | 'heart' | 'heart-full' | 'home' | 'home-full' | 'import' | 'info' | 'link' | 'minus' | 'mobile-phone' | 'phone' | 'plus' | 'print' | 'search' | 'settings' | 'sortable' | 'star' | 'star-full' | 'trash' | 'triangle-up' | 'triangle-down' | 'triangle-left' | 'triangle-right' | 'triangle-up-down' | 'upload' | 'user' | 'warning';
4
+ export type IconType = 'angle-right' | 'angle-up' | 'angle-left' | 'angle-down' | 'arrow-left' | 'arrow-right' | 'arrow-up' | 'arrow-down' | 'check' | 'clipboard' | 'close' | 'copy' | 'download' | 'edit' | 'envelope' | 'envelope-full' | 'export' | 'external-link' | 'eye' | 'folder-open' | 'hamburger' | 'heart' | 'heart-full' | 'home' | 'home-full' | 'import' | 'info' | 'link' | 'minus' | 'mobile-phone' | 'phone' | 'plus' | 'print' | 'search' | 'settings' | 'sortable' | 'star' | 'star-full' | 'trash' | 'triangle-up' | 'triangle-down' | 'triangle-left' | 'triangle-right' | 'triangle-up-down' | 'upload' | 'user' | 'warning';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltacular",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "A Svelte component library",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",