@finsweet/webflow-apps-utils 1.0.52 → 1.0.54

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.
@@ -76,6 +76,14 @@ declare const meta: {
76
76
  control: string;
77
77
  description: string;
78
78
  };
79
+ closeOnEscape: {
80
+ control: string;
81
+ description: string;
82
+ };
83
+ closeOnClickOutside: {
84
+ control: string;
85
+ description: string;
86
+ };
79
87
  };
80
88
  };
81
89
  export default meta;
@@ -90,6 +98,9 @@ export declare const CustomDimensions: Story;
90
98
  export declare const CompactSelect: Story;
91
99
  export declare const WideSelect: Story;
92
100
  export declare const PreventDeselection: Story;
101
+ export declare const DisableEscapeClose: Story;
102
+ export declare const DisableClickOutsideClose: Story;
103
+ export declare const DisableAllCloseBehaviors: Story;
93
104
  export declare const MixedOptions: Story;
94
105
  export declare const TopPlacement: Story;
95
106
  export declare const LeftPlacement: Story;
@@ -123,6 +123,14 @@ const meta = {
123
123
  invalid: {
124
124
  control: 'boolean',
125
125
  description: 'Whether the select is in an invalid state'
126
+ },
127
+ closeOnEscape: {
128
+ control: 'boolean',
129
+ description: 'Whether the dropdown closes when pressing the Escape key'
130
+ },
131
+ closeOnClickOutside: {
132
+ control: 'boolean',
133
+ description: 'Whether the dropdown closes when clicking outside the component'
126
134
  }
127
135
  }
128
136
  };
@@ -241,6 +249,49 @@ export const PreventDeselection = {
241
249
  }
242
250
  }
243
251
  };
252
+ export const DisableEscapeClose = {
253
+ args: {
254
+ options: basicOptions,
255
+ defaultText: 'Press Escape (disabled)',
256
+ closeOnEscape: false
257
+ },
258
+ parameters: {
259
+ docs: {
260
+ description: {
261
+ story: 'Prevents the dropdown from closing when the Escape key is pressed. Users must select an option or click the button again to close.'
262
+ }
263
+ }
264
+ }
265
+ };
266
+ export const DisableClickOutsideClose = {
267
+ args: {
268
+ options: basicOptions,
269
+ defaultText: 'Click outside (disabled)',
270
+ closeOnClickOutside: false
271
+ },
272
+ parameters: {
273
+ docs: {
274
+ description: {
275
+ story: 'Prevents the dropdown from closing when clicking outside the component. Users must select an option or click the button again to close.'
276
+ }
277
+ }
278
+ }
279
+ };
280
+ export const DisableAllCloseBehaviors = {
281
+ args: {
282
+ options: basicOptions,
283
+ defaultText: 'Must select to close',
284
+ closeOnEscape: false,
285
+ closeOnClickOutside: false
286
+ },
287
+ parameters: {
288
+ docs: {
289
+ description: {
290
+ story: 'Disables both Escape key and click outside behaviors. The dropdown can only be closed by selecting an option or clicking the select button. Useful for modal-like contexts where you want to force a selection.'
291
+ }
292
+ }
293
+ }
294
+ };
244
295
  export const MixedOptions = {
245
296
  args: {
246
297
  options: mixedOptions,
@@ -42,6 +42,8 @@
42
42
  alert = null,
43
43
  invalid = false,
44
44
  className = '',
45
+ closeOnEscape = true,
46
+ closeOnClickOutside = true,
45
47
  onchange,
46
48
  children,
47
49
  footer
@@ -94,6 +96,28 @@
94
96
  }
95
97
  });
96
98
 
99
+ // Handle global Escape key when dropdown is open
100
+ $effect(() => {
101
+ const handleGlobalKeyDown = (event: KeyboardEvent) => {
102
+ if (event.key === 'Escape' && closeOnEscape && isOpen) {
103
+ event.preventDefault();
104
+ closeDropdown();
105
+ // Remove focus to prevent focus ring after closing
106
+ if (document.activeElement instanceof HTMLElement) {
107
+ document.activeElement.blur();
108
+ }
109
+ }
110
+ };
111
+
112
+ if (isOpen) {
113
+ document?.addEventListener('keydown', handleGlobalKeyDown);
114
+ }
115
+
116
+ return () => {
117
+ document?.removeEventListener('keydown', handleGlobalKeyDown);
118
+ };
119
+ });
120
+
97
121
  // Computed states
98
122
  let hasAlert = $derived(alert?.message);
99
123
 
@@ -159,6 +183,8 @@
159
183
  * Dismiss dropdown when clicking outside of it.
160
184
  */
161
185
  const dismissTooltip = (event: Event): void => {
186
+ if (!closeOnClickOutside) return;
187
+
162
188
  const isClickInside = dropdownWrapper?.contains(event.target as Node);
163
189
 
164
190
  if (!isClickInside) {
@@ -247,7 +273,13 @@
247
273
  break;
248
274
  }
249
275
  case 'Escape':
250
- closeDropdown();
276
+ if (closeOnEscape) {
277
+ closeDropdown();
278
+ // Remove focus to prevent focus ring after closing
279
+ if (document.activeElement instanceof HTMLElement) {
280
+ document.activeElement.blur();
281
+ }
282
+ }
251
283
  break;
252
284
  }
253
285
 
@@ -301,7 +333,9 @@
301
333
 
302
334
  if (!dropdownItems || !target) return instances;
303
335
 
304
- document?.addEventListener('click', dismissTooltip);
336
+ if (closeOnClickOutside) {
337
+ document?.addEventListener('click', dismissTooltip);
338
+ }
305
339
  instances.push(setupDropdown(target, dropdownItems));
306
340
 
307
341
  return instances;
@@ -312,6 +346,9 @@
312
346
  */
313
347
  const cleanupDropdownInstances = (instances: DropdownInstance[]) => {
314
348
  instances.forEach((instance) => instance.cleanup());
349
+ if (closeOnClickOutside) {
350
+ document?.removeEventListener('click', dismissTooltip);
351
+ }
315
352
  };
316
353
 
317
354
  /**
@@ -679,6 +716,10 @@
679
716
  display: flex;
680
717
  align-items: center;
681
718
  gap: 4px;
719
+ outline: none; /* Remove default focus outline since we have custom focus styling */
720
+ }
721
+ .dropdown:focus-visible {
722
+ outline: none; /* Prevent browser's default focus ring */
682
723
  }
683
724
  .dropdown.disabled {
684
725
  cursor: not-allowed !important;
@@ -712,6 +753,10 @@
712
753
  background: var(--background3);
713
754
  box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.15);
714
755
  z-index: 99999;
756
+ outline: none; /* Remove focus outline */
757
+ }
758
+ .dropdown-list:focus-visible {
759
+ outline: none; /* Prevent browser's default focus ring */
715
760
  }
716
761
 
717
762
  .dropdown-items-scroll {
@@ -778,6 +823,10 @@
778
823
  font-weight: 400;
779
824
  line-height: 16px;
780
825
  border: 1px solid transparent;
826
+ outline: none; /* Remove focus outline */
827
+ }
828
+ .dropdown-item:focus-visible {
829
+ outline: none; /* Prevent browser's default focus ring */
781
830
  }
782
831
  .dropdown-item .icon,
783
832
  .dropdown-list .selected .icon {
@@ -49,6 +49,16 @@ export interface SelectProps {
49
49
  * Alert configuration for showing validation messages
50
50
  */
51
51
  alert?: AlertConfig | null;
52
+ /**
53
+ * If true, the dropdown will close when the Escape key is pressed
54
+ * @default true
55
+ */
56
+ closeOnEscape?: boolean;
57
+ /**
58
+ * If true, the dropdown will close when clicking outside the component
59
+ * @default true
60
+ */
61
+ closeOnClickOutside?: boolean;
52
62
  /**
53
63
  * If true, the select will be invalid
54
64
  */
@@ -60,6 +60,14 @@ declare const meta: {
60
60
  control: "boolean";
61
61
  description: string;
62
62
  };
63
+ showRemoveIcon: {
64
+ control: "boolean";
65
+ description: string;
66
+ };
67
+ expandOnClick: {
68
+ control: "boolean";
69
+ description: string;
70
+ };
63
71
  width: {
64
72
  control: "text";
65
73
  description: string;
@@ -99,5 +107,8 @@ export declare const KeywordsTags: Story;
99
107
  export declare const FormIntegration: Story;
100
108
  export declare const ManyTags: Story;
101
109
  export declare const LongTags: Story;
110
+ export declare const ShowRemoveIcon: Story;
111
+ export declare const ExpandOnClick: Story;
112
+ export declare const ShowRemoveIconAndExpandOnClick: Story;
102
113
  export declare const SpecialCharacters: Story;
103
114
  export declare const UnicodeSupport: Story;
@@ -60,6 +60,14 @@ const meta = {
60
60
  control: 'boolean',
61
61
  description: 'Whether to trim whitespace from tags'
62
62
  },
63
+ showRemoveIcon: {
64
+ control: 'boolean',
65
+ description: 'Whether to always show the remove icon on tags'
66
+ },
67
+ expandOnClick: {
68
+ control: 'boolean',
69
+ description: 'Whether clicking a tag expands it to show full content'
70
+ },
63
71
  width: {
64
72
  control: 'text',
65
73
  description: 'Custom width for the component'
@@ -407,6 +415,53 @@ export const LongTags = {
407
415
  }
408
416
  }
409
417
  };
418
+ export const ShowRemoveIcon = {
419
+ args: {
420
+ value: ['JavaScript', 'TypeScript', 'Svelte'],
421
+ showRemoveIcon: true,
422
+ placeholder: 'Remove icon always visible...'
423
+ },
424
+ parameters: {
425
+ docs: {
426
+ description: {
427
+ story: 'When showRemoveIcon is true, the X button is always visible inline with 4px gap.'
428
+ }
429
+ }
430
+ }
431
+ };
432
+ export const ExpandOnClick = {
433
+ args: {
434
+ value: [
435
+ 'This is a very long tag that will be truncated',
436
+ 'Another long tag content here',
437
+ 'Short'
438
+ ],
439
+ expandOnClick: true,
440
+ placeholder: 'Click tags to expand...'
441
+ },
442
+ parameters: {
443
+ docs: {
444
+ description: {
445
+ story: 'When expandOnClick is true, clicking a tag expands it to show full content.'
446
+ }
447
+ }
448
+ }
449
+ };
450
+ export const ShowRemoveIconAndExpandOnClick = {
451
+ args: {
452
+ value: ['This is a very long tag name', 'TypeScript', 'Click me to expand'],
453
+ showRemoveIcon: true,
454
+ expandOnClick: true,
455
+ placeholder: 'Both features enabled...'
456
+ },
457
+ parameters: {
458
+ docs: {
459
+ description: {
460
+ story: 'Both showRemoveIcon and expandOnClick enabled together.'
461
+ }
462
+ }
463
+ }
464
+ };
410
465
  export const SpecialCharacters = {
411
466
  args: {
412
467
  value: ['C++', 'C#', '.NET', '@angular', '#svelte'],
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+
2
4
  import { TimesIcon } from '../../icons';
3
5
 
4
6
  import Loader from '../Loader.svelte';
@@ -21,6 +23,8 @@
21
23
  allowDuplicates = false,
22
24
  validateTag,
23
25
  trimTags = true,
26
+ showRemoveIcon = false,
27
+ expandOnClick = false,
24
28
  width = '100%',
25
29
  height = 'auto',
26
30
  class: className = '',
@@ -39,6 +43,7 @@
39
43
  let inputElement: HTMLInputElement | undefined = $state();
40
44
  let inputValue = $state('');
41
45
  let isFocused = $state(false);
46
+ let expandedTags = new SvelteSet<number>();
42
47
 
43
48
  // Derived states
44
49
  let isDisabled = $derived(disabled || loading);
@@ -67,12 +72,40 @@
67
72
  ${isSuccessAlert ? 'success' : ''}
68
73
  ${isFocused ? 'focused' : ''}
69
74
  ${loading ? 'loading' : ''}
75
+ ${showRemoveIcon ? 'show-remove-icon' : ''}
70
76
  ${className}
71
77
  `
72
78
  .trim()
73
79
  .replace(/\s+/g, ' ')
74
80
  );
75
81
 
82
+ /**
83
+ * Collapses all expanded tags
84
+ */
85
+ const collapseAllTags = () => {
86
+ expandedTags.clear();
87
+ };
88
+
89
+ /**
90
+ * Toggles the expanded state of a tag
91
+ */
92
+ const toggleTagExpand = (index: number) => {
93
+ if (!expandOnClick) return;
94
+
95
+ if (expandedTags.has(index)) {
96
+ expandedTags.delete(index);
97
+ } else {
98
+ expandedTags.add(index);
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Checks if a tag is expanded
104
+ */
105
+ const isTagExpanded = (index: number): boolean => {
106
+ return expandedTags.has(index);
107
+ };
108
+
76
109
  /**
77
110
  * Focus the input element
78
111
  */
@@ -232,9 +265,13 @@
232
265
  };
233
266
 
234
267
  /**
235
- * Handles wrapper click to focus input
268
+ * Handles wrapper click to focus input and collapse expanded tags
236
269
  */
237
270
  const handleWrapperClick = () => {
271
+ // Collapse all expanded tags when clicking on the wrapper area
272
+ if (expandOnClick && expandedTags.size > 0) {
273
+ collapseAllTags();
274
+ }
238
275
  focusInput();
239
276
  };
240
277
 
@@ -268,7 +305,24 @@
268
305
  >
269
306
  <div class="tags-input-content">
270
307
  {#each value as tag, index (`${index}-${tag}`)}
271
- <span class="tag" role="listitem">
308
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
309
+ <span
310
+ class="tag {expandOnClick ? 'expandable' : ''} {isTagExpanded(index) ? 'expanded' : ''}"
311
+ role="listitem"
312
+ onclick={(e) => {
313
+ if (expandOnClick) {
314
+ e.stopPropagation();
315
+ toggleTagExpand(index);
316
+ }
317
+ }}
318
+ onkeydown={(e) => {
319
+ if (expandOnClick && e.key === 'Enter') {
320
+ e.stopPropagation();
321
+ toggleTagExpand(index);
322
+ }
323
+ }}
324
+ tabindex={expandOnClick ? 0 : undefined}
325
+ >
272
326
  <span class="tag-text">{tag}</span>
273
327
  {#if !readonly && !isDisabled}
274
328
  <button
@@ -391,10 +445,14 @@
391
445
  flex-wrap: wrap;
392
446
  align-items: flex-start;
393
447
  align-content: flex-start;
394
- gap: 4px;
448
+ gap: 0;
395
449
  width: 100%;
396
450
  }
397
451
 
452
+ .tags-input-wrapper.show-remove-icon .tags-input-content {
453
+ gap: 4px;
454
+ }
455
+
398
456
  .tag {
399
457
  position: relative;
400
458
  display: flex;
@@ -414,6 +472,22 @@
414
472
  user-select: none;
415
473
  max-width: 100%;
416
474
  min-width: 0;
475
+ margin: 2px;
476
+ }
477
+
478
+ .tags-input-wrapper.show-remove-icon .tag {
479
+ margin: 0;
480
+ }
481
+
482
+ .tag.expandable {
483
+ cursor: pointer;
484
+ }
485
+
486
+ .tag.expanded .tag-text {
487
+ overflow: visible;
488
+ text-overflow: unset;
489
+ white-space: normal;
490
+ word-break: break-word;
417
491
  }
418
492
 
419
493
  .tag-text {
@@ -447,6 +521,20 @@
447
521
  pointer-events: auto;
448
522
  }
449
523
 
524
+ /* Show remove icon mode - inline button */
525
+ .tags-input-wrapper.show-remove-icon .tag-remove {
526
+ position: relative;
527
+ top: auto;
528
+ right: auto;
529
+ bottom: auto;
530
+ padding: 0;
531
+ margin-left: 4px;
532
+ background: transparent;
533
+ opacity: 1;
534
+ pointer-events: auto;
535
+ border-radius: 0;
536
+ }
537
+
450
538
  .tag-remove:not(:disabled) {
451
539
  color: var(--text1);
452
540
  }
@@ -62,6 +62,18 @@ export interface TagsInputProps {
62
62
  * Whether to trim whitespace from tags (default: true)
63
63
  */
64
64
  trimTags?: boolean;
65
+ /**
66
+ * Whether to always show the remove icon on tags (default: false)
67
+ * When true: remove button is inline, 4px gap, no padding on close button
68
+ * When false: remove button is absolute positioned, appears on hover
69
+ */
70
+ showRemoveIcon?: boolean;
71
+ /**
72
+ * Whether clicking a tag expands it to show full content (default: false)
73
+ * When true: clicking a tag removes ellipsis and shows full text
74
+ * When false: long tags are always truncated with ellipsis
75
+ */
76
+ expandOnClick?: boolean;
65
77
  /**
66
78
  * Custom width for the component
67
79
  */
@@ -42,7 +42,3 @@
42
42
  </script>
43
43
 
44
44
  {#if children}{@render children()}{/if}
45
-
46
- <style>
47
- /* Global provider styles - currently using display: contents */
48
- </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finsweet/webflow-apps-utils",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
4
4
  "description": "Shared utilities for Webflow apps",
5
5
  "homepage": "https://github.com/finsweet/webflow-apps-utils",
6
6
  "repository": {