@finsweet/webflow-apps-utils 1.0.50 → 1.0.52

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.
@@ -0,0 +1,492 @@
1
+ <script lang="ts">
2
+ import { TimesIcon } from '../../icons';
3
+
4
+ import Loader from '../Loader.svelte';
5
+ import { Tooltip } from '../tooltip';
6
+ import type { TagsInputProps } from './types.js';
7
+
8
+ let {
9
+ value = $bindable([]),
10
+ placeholder = 'Add tag...',
11
+ id = 'tags-input',
12
+ disabled = false,
13
+ loading = false,
14
+ invalid = false,
15
+ readonly = false,
16
+ alert = null,
17
+ maxTags = null,
18
+ minTags = null,
19
+ maxTagLength = null,
20
+ separatorKeys = ['Enter', ','],
21
+ allowDuplicates = false,
22
+ validateTag,
23
+ trimTags = true,
24
+ width = '100%',
25
+ height = 'auto',
26
+ class: className = '',
27
+ onValueChange,
28
+ onTagAdd,
29
+ onTagRemove,
30
+ onInvalidTag,
31
+ onfocus,
32
+ onblur,
33
+ onkeydown,
34
+ children,
35
+ ...restProps
36
+ }: TagsInputProps = $props();
37
+
38
+ // Component state
39
+ let inputElement: HTMLInputElement | undefined = $state();
40
+ let inputValue = $state('');
41
+ let isFocused = $state(false);
42
+
43
+ // Derived states
44
+ let isDisabled = $derived(disabled || loading);
45
+ let canAddMore = $derived(maxTags === null || value.length < maxTags);
46
+ let showPlaceholder = $derived(value.length === 0 && !inputValue);
47
+ let hasAlert = $derived(alert?.message);
48
+
49
+ // Validation state
50
+ let isMinTagsInvalid = $derived.by(() => {
51
+ if (minTags === null) return false;
52
+ return value.length < minTags;
53
+ });
54
+
55
+ // Derived alert state for styling
56
+ let alertType = $derived(alert?.type || null);
57
+ let isErrorAlert = $derived(alertType === 'error' || alertType === 'warning');
58
+ let isSuccessAlert = $derived(alertType === 'success');
59
+
60
+ // CSS classes
61
+ let wrapperClasses = $derived(
62
+ `
63
+ tags-input-wrapper
64
+ ${isDisabled ? 'disabled' : ''}
65
+ ${readonly ? 'readonly' : ''}
66
+ ${invalid || isErrorAlert || isMinTagsInvalid ? 'invalid' : ''}
67
+ ${isSuccessAlert ? 'success' : ''}
68
+ ${isFocused ? 'focused' : ''}
69
+ ${loading ? 'loading' : ''}
70
+ ${className}
71
+ `
72
+ .trim()
73
+ .replace(/\s+/g, ' ')
74
+ );
75
+
76
+ /**
77
+ * Focus the input element
78
+ */
79
+ const focusInput = () => {
80
+ if (inputElement && !readonly && !isDisabled) {
81
+ inputElement.focus();
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Validates a tag before adding
87
+ */
88
+ const validateTagValue = (tag: string): { valid: boolean; reason?: string } => {
89
+ const trimmedTag = trimTags ? tag.trim() : tag;
90
+
91
+ if (!trimmedTag) {
92
+ return { valid: false, reason: 'Tag cannot be empty' };
93
+ }
94
+
95
+ if (maxTagLength !== null && trimmedTag.length > maxTagLength) {
96
+ return { valid: false, reason: `Tag exceeds maximum length of ${maxTagLength}` };
97
+ }
98
+
99
+ if (!allowDuplicates && value.includes(trimmedTag)) {
100
+ return { valid: false, reason: 'Duplicate tag' };
101
+ }
102
+
103
+ if (!canAddMore) {
104
+ return { valid: false, reason: `Maximum of ${maxTags} tags allowed` };
105
+ }
106
+
107
+ if (validateTag) {
108
+ const customValidation = validateTag(trimmedTag);
109
+ if (customValidation === false) {
110
+ return { valid: false, reason: 'Invalid tag' };
111
+ }
112
+ if (typeof customValidation === 'string') {
113
+ return { valid: false, reason: customValidation };
114
+ }
115
+ }
116
+
117
+ return { valid: true };
118
+ };
119
+
120
+ /**
121
+ * Adds a new tag
122
+ */
123
+ const addTag = (rawTag: string) => {
124
+ if (readonly || isDisabled) return;
125
+
126
+ const tag = trimTags ? rawTag.trim() : rawTag;
127
+ const validation = validateTagValue(tag);
128
+
129
+ if (!validation.valid) {
130
+ onInvalidTag?.(tag, validation.reason || 'Invalid tag');
131
+ return false;
132
+ }
133
+
134
+ inputValue = '';
135
+ value = [...value, tag];
136
+
137
+ onTagAdd?.(tag);
138
+ onValueChange?.(value);
139
+
140
+ return true;
141
+ };
142
+
143
+ /**
144
+ * Removes a tag at the specified index
145
+ */
146
+ const removeTag = (index: number) => {
147
+ if (readonly || isDisabled) return;
148
+
149
+ const removedTag = value[index];
150
+ value = value.filter((_, i) => i !== index);
151
+
152
+ onTagRemove?.(removedTag, index);
153
+ onValueChange?.(value);
154
+
155
+ // Focus back to input after removal
156
+ focusInput();
157
+ };
158
+
159
+ /**
160
+ * Handles input changes
161
+ */
162
+ const handleInput = (event: Event) => {
163
+ const target = event.target as HTMLInputElement;
164
+ const newValue = target.value;
165
+
166
+ // Check if a separator key was typed (e.g., comma)
167
+ for (const separator of separatorKeys) {
168
+ if (separator.length === 1 && newValue.includes(separator)) {
169
+ const parts = newValue.split(separator);
170
+ for (let i = 0; i < parts.length - 1; i++) {
171
+ if (parts[i].trim()) {
172
+ addTag(parts[i]);
173
+ }
174
+ }
175
+ inputValue = parts[parts.length - 1];
176
+ return;
177
+ }
178
+ }
179
+
180
+ inputValue = newValue;
181
+ };
182
+
183
+ /**
184
+ * Handles keydown events
185
+ */
186
+ const handleKeydown = (event: KeyboardEvent) => {
187
+ // Check for separator keys (e.g., Enter)
188
+ if (separatorKeys.includes(event.key)) {
189
+ event.preventDefault();
190
+ if (inputValue.trim()) {
191
+ addTag(inputValue);
192
+ }
193
+ return;
194
+ }
195
+
196
+ // Handle Backspace to remove last tag when input is empty
197
+ if (event.key === 'Backspace' && inputValue === '' && value.length > 0) {
198
+ removeTag(value.length - 1);
199
+ return;
200
+ }
201
+
202
+ // Handle Tab to add tag if there's input
203
+ if (event.key === 'Tab' && inputValue.trim()) {
204
+ event.preventDefault();
205
+ addTag(inputValue);
206
+ return;
207
+ }
208
+
209
+ onkeydown?.(event);
210
+ };
211
+
212
+ /**
213
+ * Handles focus events
214
+ */
215
+ const handleFocus = (event: FocusEvent) => {
216
+ isFocused = true;
217
+ onfocus?.(event);
218
+ };
219
+
220
+ /**
221
+ * Handles blur events
222
+ */
223
+ const handleBlur = (event: FocusEvent) => {
224
+ isFocused = false;
225
+
226
+ // Add any remaining input as a tag on blur
227
+ if (inputValue.trim()) {
228
+ addTag(inputValue);
229
+ }
230
+
231
+ onblur?.(event);
232
+ };
233
+
234
+ /**
235
+ * Handles wrapper click to focus input
236
+ */
237
+ const handleWrapperClick = () => {
238
+ focusInput();
239
+ };
240
+
241
+ /**
242
+ * Gets the tooltip background color based on alert type
243
+ */
244
+ const getTooltipColor = (alertType: string) => {
245
+ switch (alertType) {
246
+ case 'error':
247
+ return 'var(--redBackground)';
248
+ case 'warning':
249
+ return 'var(--orangeBackground)';
250
+ case 'success':
251
+ return 'var(--greenBackground)';
252
+ case 'info':
253
+ default:
254
+ return 'var(--actionPrimaryBackground)';
255
+ }
256
+ };
257
+ </script>
258
+
259
+ {#snippet tagsInputContent()}
260
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
261
+ <div
262
+ class={wrapperClasses}
263
+ style="width: {width}; min-height: {height};"
264
+ role="group"
265
+ aria-labelledby="{id}-label"
266
+ onclick={handleWrapperClick}
267
+ onkeydown={(e) => e.key === 'Enter' && handleWrapperClick()}
268
+ >
269
+ <div class="tags-input-content">
270
+ {#each value as tag, index (`${index}-${tag}`)}
271
+ <span class="tag" role="listitem">
272
+ <span class="tag-text">{tag}</span>
273
+ {#if !readonly && !isDisabled}
274
+ <button
275
+ type="button"
276
+ class="tag-remove"
277
+ onclick={(e) => {
278
+ e.stopPropagation();
279
+ removeTag(index);
280
+ }}
281
+ aria-label="Remove tag {tag}"
282
+ tabindex={-1}
283
+ >
284
+ <TimesIcon />
285
+ </button>
286
+ {/if}
287
+ </span>
288
+ {/each}
289
+
290
+ {#if !readonly}
291
+ <input
292
+ bind:this={inputElement}
293
+ {id}
294
+ type="text"
295
+ class="tags-input-field"
296
+ placeholder={showPlaceholder ? placeholder : ''}
297
+ value={inputValue}
298
+ disabled={isDisabled || !canAddMore}
299
+ oninput={handleInput}
300
+ onkeydown={handleKeydown}
301
+ onfocus={handleFocus}
302
+ onblur={handleBlur}
303
+ aria-label="Add new tag"
304
+ {...restProps}
305
+ />
306
+ {/if}
307
+
308
+ {#if loading}
309
+ <div class="tags-input-loader">
310
+ <Loader size={14} color="var(--text2)" />
311
+ </div>
312
+ {/if}
313
+ </div>
314
+
315
+ {#if children}
316
+ {@render children()}
317
+ {/if}
318
+ </div>
319
+ {/snippet}
320
+
321
+ <Tooltip
322
+ message={hasAlert ? alert?.message || '' : ''}
323
+ placement="top"
324
+ listener="hover"
325
+ listenerout="hover"
326
+ showArrow={true}
327
+ hidden={!hasAlert}
328
+ disabled={!hasAlert || !alert?.message}
329
+ fontColor="var(--actionPrimaryText)"
330
+ width="max-content"
331
+ padding="6px"
332
+ bgColor={getTooltipColor(alert?.type || 'info')}
333
+ class="tags-input-tooltip"
334
+ >
335
+ {#snippet target()}
336
+ {@render tagsInputContent()}
337
+ {/snippet}
338
+ </Tooltip>
339
+
340
+ <style>
341
+ :global(.tags-input-tooltip) {
342
+ padding: 0;
343
+ }
344
+
345
+ .tags-input-wrapper {
346
+ position: relative;
347
+ border: 1px solid var(--border3);
348
+ border-radius: var(--border-radius);
349
+ padding: 4px;
350
+ display: flex;
351
+ flex-wrap: wrap;
352
+ align-items: flex-start;
353
+ align-content: flex-start;
354
+ background: var(--background1);
355
+ min-height: 32px;
356
+ box-shadow:
357
+ 0px 16px 16px -16px rgba(0, 0, 0, 0.13) inset,
358
+ 0px 12px 12px -12px rgba(0, 0, 0, 0.13) inset,
359
+ 0px 8px 8px -8px rgba(0, 0, 0, 0.17) inset,
360
+ 0px 4px 4px -4px rgba(0, 0, 0, 0.17) inset,
361
+ 0px 3px 3px -3px rgba(0, 0, 0, 0.17) inset,
362
+ 0px 1px 1px -1px rgba(0, 0, 0, 0.13) inset;
363
+ cursor: text;
364
+ }
365
+
366
+ .tags-input-wrapper.focused {
367
+ border-color: var(--blueBorder);
368
+ }
369
+
370
+ .tags-input-wrapper.invalid {
371
+ border-color: var(--redBorder);
372
+ }
373
+
374
+ .tags-input-wrapper.success {
375
+ border-color: var(--greenBorder);
376
+ }
377
+
378
+ .tags-input-wrapper.disabled,
379
+ .tags-input-wrapper.readonly {
380
+ cursor: not-allowed;
381
+ opacity: 0.7;
382
+ border-color: var(--border1);
383
+ }
384
+
385
+ .tags-input-wrapper.loading {
386
+ cursor: wait;
387
+ }
388
+
389
+ .tags-input-content {
390
+ display: flex;
391
+ flex-wrap: wrap;
392
+ align-items: flex-start;
393
+ align-content: flex-start;
394
+ gap: 4px;
395
+ width: 100%;
396
+ }
397
+
398
+ .tag {
399
+ position: relative;
400
+ display: flex;
401
+ padding: 4px 8px;
402
+ justify-content: center;
403
+ align-items: center;
404
+ border-radius: var(--border-radius);
405
+ background: var(--actionSecondaryBackground);
406
+ color: var(--text1);
407
+ font-size: var(--font-size-small);
408
+ font-weight: var(--font-weight-normal);
409
+ line-height: 16px;
410
+ letter-spacing: -0.115px;
411
+ box-shadow:
412
+ 0 0.5px 1px 0 #000,
413
+ 0 0.5px 0.5px 0 rgba(255, 255, 255, 0.12) inset;
414
+ user-select: none;
415
+ max-width: 100%;
416
+ min-width: 0;
417
+ }
418
+
419
+ .tag-text {
420
+ overflow: hidden;
421
+ text-overflow: ellipsis;
422
+ white-space: nowrap;
423
+ min-width: 0;
424
+ }
425
+
426
+ .tag-remove {
427
+ position: absolute;
428
+ right: 0;
429
+ top: 0;
430
+ bottom: 0;
431
+ display: flex;
432
+ align-items: center;
433
+ justify-content: center;
434
+ width: max-content;
435
+ padding: 4px;
436
+ border: none;
437
+ background: #464646;
438
+ color: var(--text2);
439
+ cursor: pointer;
440
+ border-radius: 0 var(--border-radius) var(--border-radius) 0;
441
+ opacity: 0;
442
+ pointer-events: none;
443
+ }
444
+
445
+ .tags-input-wrapper:not(.disabled) .tag:hover .tag-remove {
446
+ opacity: 1;
447
+ pointer-events: auto;
448
+ }
449
+
450
+ .tag-remove:not(:disabled) {
451
+ color: var(--text1);
452
+ }
453
+
454
+ .tag-remove:disabled {
455
+ cursor: not-allowed;
456
+ opacity: 0.5;
457
+ }
458
+
459
+ .tag-remove :global(svg) {
460
+ width: 10px;
461
+ height: 10px;
462
+ }
463
+
464
+ .tags-input-field {
465
+ flex: 1;
466
+ min-width: 60px;
467
+ padding: 4px 8px;
468
+ border: none;
469
+ background: transparent;
470
+ color: var(--text1);
471
+ font-size: var(--font-size-small);
472
+ font-family: inherit;
473
+ line-height: 16px;
474
+ letter-spacing: -0.115px;
475
+ outline: none;
476
+ }
477
+
478
+ .tags-input-field::placeholder {
479
+ color: var(--text3);
480
+ }
481
+
482
+ .tags-input-field:disabled {
483
+ cursor: not-allowed;
484
+ }
485
+
486
+ .tags-input-loader {
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: center;
490
+ padding: 0 4px;
491
+ }
492
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { TagsInputProps } from './types.js';
2
+ declare const TagsInput: import("svelte").Component<TagsInputProps, {}, "value">;
3
+ type TagsInput = ReturnType<typeof TagsInput>;
4
+ export default TagsInput;
@@ -0,0 +1,2 @@
1
+ export { default as TagsInput } from './TagsInput.svelte';
2
+ export * from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as TagsInput } from './TagsInput.svelte';
2
+ export * from './types.js';
@@ -0,0 +1,123 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { AlertConfig } from '../input/types.js';
3
+ export interface TagsInputProps {
4
+ /**
5
+ * Array of tag values
6
+ */
7
+ value?: string[];
8
+ /**
9
+ * Placeholder text when no tags and input is empty
10
+ */
11
+ placeholder?: string;
12
+ /**
13
+ * Input field id
14
+ */
15
+ id?: string;
16
+ /**
17
+ * If true, the input field will be disabled
18
+ */
19
+ disabled?: boolean;
20
+ /**
21
+ * If true, the component will show loading state
22
+ */
23
+ loading?: boolean;
24
+ /**
25
+ * If true, the input field will be invalid
26
+ */
27
+ invalid?: boolean;
28
+ /**
29
+ * If true, the input field will be readonly (no adding/removing tags)
30
+ */
31
+ readonly?: boolean;
32
+ /**
33
+ * Defines the alert message to show
34
+ */
35
+ alert?: AlertConfig | null;
36
+ /**
37
+ * Maximum number of tags allowed
38
+ */
39
+ maxTags?: number | null;
40
+ /**
41
+ * Minimum number of tags required (for validation display)
42
+ */
43
+ minTags?: number | null;
44
+ /**
45
+ * Maximum length of each tag
46
+ */
47
+ maxTagLength?: number | null;
48
+ /**
49
+ * Separator keys to trigger tag creation (default: ['Enter', ','])
50
+ */
51
+ separatorKeys?: string[];
52
+ /**
53
+ * Whether to allow duplicate tags (default: false)
54
+ */
55
+ allowDuplicates?: boolean;
56
+ /**
57
+ * Custom validation function for tags
58
+ * Return true if valid, false or error message string if invalid
59
+ */
60
+ validateTag?: (tag: string) => boolean | string;
61
+ /**
62
+ * Whether to trim whitespace from tags (default: true)
63
+ */
64
+ trimTags?: boolean;
65
+ /**
66
+ * Custom width for the component
67
+ */
68
+ width?: string;
69
+ /**
70
+ * Custom height for the component
71
+ */
72
+ height?: string;
73
+ /**
74
+ * Additional CSS classes
75
+ */
76
+ class?: string;
77
+ /**
78
+ * Event handler for value changes
79
+ */
80
+ onValueChange?: (tags: string[]) => void;
81
+ /**
82
+ * Event handler for individual tag addition
83
+ */
84
+ onTagAdd?: (tag: string) => void;
85
+ /**
86
+ * Event handler for individual tag removal
87
+ */
88
+ onTagRemove?: (tag: string, index: number) => void;
89
+ /**
90
+ * Event handler for invalid tag attempt
91
+ */
92
+ onInvalidTag?: (tag: string, reason: string) => void;
93
+ /**
94
+ * Event handler for focus events
95
+ */
96
+ onfocus?: (event: FocusEvent) => void;
97
+ /**
98
+ * Event handler for blur events
99
+ */
100
+ onblur?: (event: FocusEvent) => void;
101
+ /**
102
+ * Event handler for keydown events
103
+ */
104
+ onkeydown?: (event: KeyboardEvent) => void;
105
+ /**
106
+ * Children content (if any)
107
+ */
108
+ children?: Snippet;
109
+ }
110
+ export interface TagAddEvent {
111
+ tag: string;
112
+ }
113
+ export interface TagRemoveEvent {
114
+ tag: string;
115
+ index: number;
116
+ }
117
+ export interface InvalidTagEvent {
118
+ tag: string;
119
+ reason: string;
120
+ }
121
+ export interface TagsValueChangeEvent {
122
+ tags: string[];
123
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 12 12" fill="none">
2
2
  <path
3
3
  fill-rule="evenodd"
4
4
  clip-rule="evenodd"
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 14 14" fill="none">
2
2
  <path
3
3
  opacity="0.4"
4
4
  fill-rule="evenodd"
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 16 16" fill="none">
2
2
  <path
3
3
  d="M8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0ZM11.53 10.47L10.47 11.53L8 9.06L5.53 11.53L4.47 10.47L6.94 8L4.47 5.53L5.53 4.47L8 6.94L10.47 4.47L11.53 5.53L9.06 8L11.53 10.47Z"
4
4
  fill="currentColor"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finsweet/webflow-apps-utils",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
4
4
  "description": "Shared utilities for Webflow apps",
5
5
  "homepage": "https://github.com/finsweet/webflow-apps-utils",
6
6
  "repository": {