@finsweet/webflow-apps-utils 1.0.51 → 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.
@@ -108,3 +108,4 @@ export declare const InvalidState: Story;
108
108
  export declare const InvalidWithAlert: Story;
109
109
  export declare const ValidationStates: Story;
110
110
  export declare const FormValidationExample: Story;
111
+ export declare const WithFooter: Story;
@@ -1,5 +1,6 @@
1
1
  import { CheckIcon, UndoIcon } from '../../icons';
2
2
  import Select from './Select.svelte';
3
+ import SelectWithFooterStory from './SelectWithFooterStory.svelte';
3
4
  // Mock options for stories
4
5
  const basicOptions = [
5
6
  { label: 'Option 1', value: 'option1' },
@@ -567,3 +568,37 @@ export const FormValidationExample = {
567
568
  }
568
569
  }
569
570
  };
571
+ // Footer snippet example
572
+ const providerOptions = [
573
+ { label: 'Facebook', value: 'facebook' },
574
+ { label: 'Google', value: 'google' },
575
+ { label: 'Cloudflare', value: 'cloudflare' },
576
+ { label: 'Youtube', value: 'youtube' },
577
+ { label: 'Swiper', value: 'swiper' },
578
+ { label: 'GSAP', value: 'gsap' }
579
+ ];
580
+ export const WithFooter = {
581
+ render: () => ({
582
+ Component: SelectWithFooterStory,
583
+ props: {
584
+ options: providerOptions,
585
+ defaultText: 'Providers',
586
+ dropdownWidth: '250px',
587
+ dropdownHeight: '200px',
588
+ selected: 'facebook'
589
+ }
590
+ }),
591
+ args: {
592
+ options: providerOptions,
593
+ defaultText: 'Providers',
594
+ dropdownWidth: '250px',
595
+ dropdownHeight: '200px'
596
+ },
597
+ parameters: {
598
+ docs: {
599
+ description: {
600
+ story: 'Select with a sticky footer action. The footer stays visible while scrolling through options. Click the footer to trigger a custom action and close the dropdown.'
601
+ }
602
+ }
603
+ }
604
+ };
@@ -14,7 +14,12 @@
14
14
 
15
15
  import { Tooltip } from '..';
16
16
  import { Text } from '../text';
17
- import type { DropdownInstance, SelectInstanceManager, SelectProps } from './types.js';
17
+ import type {
18
+ DropdownInstance,
19
+ SelectFooterProps,
20
+ SelectInstanceManager,
21
+ SelectProps
22
+ } from './types.js';
18
23
 
19
24
  let {
20
25
  id = uuidv4(),
@@ -38,7 +43,8 @@
38
43
  invalid = false,
39
44
  className = '',
40
45
  onchange,
41
- children
46
+ children,
47
+ footer
42
48
  }: SelectProps = $props();
43
49
 
44
50
  // State variables
@@ -274,6 +280,17 @@
274
280
  lastHoveredItem = target;
275
281
  };
276
282
 
283
+ /**
284
+ * Clears the hover state when mouse leaves the items area.
285
+ */
286
+ const clearHoverState = (): void => {
287
+ if (lastHoveredItem) {
288
+ lastHoveredItem.classList.remove('hover-state');
289
+ lastHoveredItem.setAttribute('tabindex', '-1');
290
+ lastHoveredItem = null;
291
+ }
292
+ };
293
+
277
294
  type EventOption = [string, () => void];
278
295
 
279
296
  /**
@@ -403,14 +420,14 @@
403
420
  const getTooltipColor = (alertType: string) => {
404
421
  switch (alertType) {
405
422
  case 'error':
406
- return 'var(--redBackground, #ff4d4d)';
423
+ return 'var(--redBackground)';
407
424
  case 'warning':
408
- return 'var(--orangeBackground, #ff9933)';
425
+ return 'var(--orangeBackground)';
409
426
  case 'success':
410
- return 'var(--greenBackground, #00cc66)';
427
+ return 'var(--greenBackground)';
411
428
  case 'info':
412
429
  default:
413
- return 'var(--blueBackground, #4d9fff)';
430
+ return 'var(--actionPrimaryBackground)';
414
431
  }
415
432
  };
416
433
 
@@ -458,8 +475,9 @@
458
475
  <div
459
476
  tabindex={disabled || isOpen ? -1 : 0}
460
477
  class="dropdown-list"
478
+ class:has-footer={footer}
461
479
  role="listbox"
462
- style="width:{dropdownWidth}; max-height:{dropdownHeight};"
480
+ style="width:{dropdownWidth};"
463
481
  onkeydown={(e) => {
464
482
  e.stopPropagation();
465
483
  e.preventDefault();
@@ -467,85 +485,98 @@
467
485
  }}
468
486
  bind:this={dropdownItems}
469
487
  >
470
- {#if selectedLabel}
471
- <div class="selected">
472
- <div class="label">
473
- <Text label={selectedLabel} fontSize="normal" fontColor="var(--text1)" />
488
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
489
+ <div
490
+ class="dropdown-items-scroll"
491
+ style="max-height:{dropdownHeight};"
492
+ onmouseleave={clearHoverState}
493
+ >
494
+ {#if selectedLabel}
495
+ <div class="selected">
496
+ <div class="label">
497
+ <Text label={selectedLabel} fontSize="normal" fontColor="var(--text1)" />
498
+ </div>
474
499
  </div>
475
- </div>
476
- {/if}
477
-
478
- {#if enableSearch}
479
- <div class="search-container">
480
- <input
481
- type="text"
482
- placeholder="Search"
483
- oninput={(e) => {
500
+ {/if}
501
+
502
+ {#if enableSearch}
503
+ <div class="search-container">
504
+ <input
505
+ type="text"
506
+ placeholder="Search"
507
+ oninput={(e) => {
508
+ e.stopPropagation();
509
+ e.preventDefault();
510
+ handleSearch(e);
511
+ }}
512
+ onkeydown={(e) => e.stopPropagation()}
513
+ />
514
+ </div>
515
+ {/if}
516
+
517
+ {#each optionsStore?.length > 0 ? optionsStore : options as { label, value, className = null, description = null, labelIcon = null, descriptionTitle = null, isDisabled = false }, index (index)}
518
+ {@const indexId = index + 1}
519
+ {@const itemId = ref ? ref.replace(' ', '-') : 'dropdown'}
520
+ <button
521
+ aria-posinset={indexId}
522
+ aria-selected={value === selected && selected?.trim() !== '' ? 'true' : 'false'}
523
+ id={`${itemId}-list-${indexId}-${id}`}
524
+ data-value={value}
525
+ class="dropdown-item {isDisabled ? 'disabled' : ''} {className}"
526
+ role="option"
527
+ onclick={(e) => {
528
+ e.stopPropagation();
529
+ if (isDisabled) return;
530
+ handleSelect(value, label, e.currentTarget);
531
+ }}
532
+ onkeydown={(e) => {
484
533
  e.stopPropagation();
485
534
  e.preventDefault();
486
- handleSearch(e);
487
535
  }}
488
- onkeydown={(e) => e.stopPropagation()}
489
- />
536
+ onmouseenter={handleMouseEnter}
537
+ aria-hidden={!isOpen}
538
+ tabindex={value === selected ? 0 : -1}
539
+ style={description ? 'align-items:start;' : ''}
540
+ >
541
+ <div class="icon" aria-label={label}>
542
+ {#if value === selected && selected?.trim() !== ''}
543
+ <CheckIcon />
544
+ {/if}
545
+ </div>
546
+ <div class="label">
547
+ {#if description || descriptionTitle || labelIcon}
548
+ <div class="label-content">
549
+ <div class="label-name">
550
+ <Text {label} />
551
+ {#if labelIcon}
552
+ {@const IconComponent = labelIcon}
553
+ <IconComponent />
554
+ {/if}
555
+ </div>
556
+ <div class="label-description-title">
557
+ <Text
558
+ label={descriptionTitle || ''}
559
+ fontColor="var(--greenText)"
560
+ fontSize="10px"
561
+ />
562
+ </div>
563
+ <div class="label-description">
564
+ <Text label={description || ''} fontColor="var(--text2)" fontSize="10px" />
565
+ </div>
566
+ </div>
567
+ {:else}
568
+ <Text {label} fontSize="normal" />
569
+ {/if}
570
+ </div>
571
+ </button>
572
+ {/each}
573
+ </div>
574
+
575
+ {#if footer}
576
+ <div class="dropdown-footer">
577
+ {@render footer({ close: closeDropdown })}
490
578
  </div>
491
579
  {/if}
492
-
493
- {#each optionsStore?.length > 0 ? optionsStore : options as { label, value, className = null, description = null, labelIcon = null, descriptionTitle = null, isDisabled = false }, index (index)}
494
- {@const indexId = index + 1}
495
- {@const itemId = ref ? ref.replace(' ', '-') : 'dropdown'}
496
- <button
497
- aria-posinset={indexId}
498
- aria-selected={value === selected && selected?.trim() !== '' ? 'true' : 'false'}
499
- id={`${itemId}-list-${indexId}-${id}`}
500
- data-value={value}
501
- class="dropdown-item {isDisabled ? 'disabled' : ''} {className}"
502
- role="option"
503
- onclick={(e) => {
504
- e.stopPropagation();
505
- if (isDisabled) return;
506
- handleSelect(value, label, e.currentTarget);
507
- }}
508
- onkeydown={(e) => {
509
- e.stopPropagation();
510
- e.preventDefault();
511
- }}
512
- onmouseenter={handleMouseEnter}
513
- aria-hidden={!isOpen}
514
- tabindex={value === selected ? 0 : -1}
515
- style={description ? 'align-items:start;' : ''}
516
- >
517
- <div class="icon" aria-label={label}>
518
- {#if value === selected && selected?.trim() !== ''}
519
- <CheckIcon />
520
- {/if}
521
- </div>
522
- <div class="label">
523
- {#if description || descriptionTitle || labelIcon}
524
- <div class="label-content">
525
- <div class="label-name">
526
- <Text {label} />
527
- {#if labelIcon}
528
- {@const IconComponent = labelIcon}
529
- <IconComponent />
530
- {/if}
531
- </div>
532
- <div class="label-description-title">
533
- <Text
534
- label={descriptionTitle || ''}
535
- fontColor="var(--greenText)"
536
- fontSize="10px"
537
- />
538
- </div>
539
- <div class="label-description">
540
- <Text label={description || ''} fontColor="var(--text2)" fontSize="10px" />
541
- </div>
542
- </div>
543
- {:else}
544
- <Text {label} fontSize="normal" />
545
- {/if}
546
- </div>
547
- </button>
548
- {/each}
549
580
  </div>
550
581
  </div>
551
582
  </div>
@@ -675,14 +706,30 @@
675
706
  position: absolute;
676
707
  flex-direction: column;
677
708
  align-items: flex-start;
678
- gap: 4px;
709
+ gap: 0;
679
710
  border-radius: 4px;
680
711
  border: 1px solid var(--border1);
681
712
  background: var(--background3);
682
713
  box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.15);
714
+ z-index: 99999;
715
+ }
683
716
 
717
+ .dropdown-items-scroll {
718
+ display: flex;
719
+ flex-direction: column;
720
+ align-items: flex-start;
721
+ gap: 4px;
722
+ width: 100%;
684
723
  overflow-y: auto;
685
- z-index: 99999;
724
+ }
725
+
726
+ .dropdown-footer {
727
+ display: flex;
728
+ align-items: center;
729
+ width: 100%;
730
+ border-top: 1px solid var(--border1);
731
+ background: var(--background3);
732
+ flex-shrink: 0;
686
733
  }
687
734
  .dropdown-list .selected {
688
735
  display: flex;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import Select from './Select.svelte';
3
+ import type { SelectOption } from './types.js';
4
+
5
+ interface Props {
6
+ options: SelectOption[];
7
+ defaultText?: string;
8
+ dropdownWidth?: string;
9
+ dropdownHeight?: string;
10
+ selected?: string | null;
11
+ }
12
+
13
+ let {
14
+ options,
15
+ defaultText = 'Select',
16
+ dropdownWidth = '200px',
17
+ dropdownHeight = '200px',
18
+ selected = $bindable(null)
19
+ }: Props = $props();
20
+
21
+ const handleFooterClick = (close: () => void) => {
22
+ console.log('Footer action clicked - adding a new provider manually');
23
+ close();
24
+ };
25
+ </script>
26
+
27
+ <Select {options} {defaultText} {dropdownWidth} {dropdownHeight} bind:selected>
28
+ {#snippet footer({ close })}
29
+ <button type="button" class="footer-action" onclick={() => handleFooterClick(close)}>
30
+ + Add manually a provider
31
+ </button>
32
+ {/snippet}
33
+ </Select>
34
+
35
+ <style>
36
+ .footer-action {
37
+ all: unset;
38
+ display: flex;
39
+ align-items: center;
40
+ padding: 8px;
41
+ width: 100%;
42
+ color: var(--blueText);
43
+ font-size: 11.5px;
44
+ font-weight: 400;
45
+ line-height: 16px;
46
+ letter-spacing: -0.115px;
47
+ cursor: pointer;
48
+ box-sizing: border-box;
49
+ }
50
+
51
+ .footer-action:hover {
52
+ background: var(--background5);
53
+ }
54
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { SelectOption } from './types.js';
2
+ interface Props {
3
+ options: SelectOption[];
4
+ defaultText?: string;
5
+ dropdownWidth?: string;
6
+ dropdownHeight?: string;
7
+ selected?: string | null;
8
+ }
9
+ declare const SelectWithFooterStory: import("svelte").Component<Props, {}, "selected">;
10
+ type SelectWithFooterStory = ReturnType<typeof SelectWithFooterStory>;
11
+ export default SelectWithFooterStory;
@@ -1,2 +1,2 @@
1
1
  export { default as Select } from './Select.svelte';
2
- export type { SelectOption, SelectProps, SelectChangeEvent, SelectChangeHandler, SelectState, DropdownInstance, DropdownConfig, SelectStyles, NavigationKey, KeyboardNavigationEvent, SearchConfig, FilterFunction, SelectElementRefs, SelectInstanceManager } from './types.js';
2
+ export type { SelectOption, SelectProps, SelectFooterProps, SelectChangeEvent, SelectChangeHandler, SelectState, DropdownInstance, DropdownConfig, SelectStyles, NavigationKey, KeyboardNavigationEvent, SearchConfig, FilterFunction, SelectElementRefs, SelectInstanceManager } from './types.js';
@@ -24,6 +24,9 @@ export interface SelectChangeEvent {
24
24
  value: string | null;
25
25
  }
26
26
  export type SelectChangeHandler = (event: SelectChangeEvent) => void;
27
+ export interface SelectFooterProps {
28
+ close: () => void;
29
+ }
27
30
  export interface SelectProps {
28
31
  id?: string;
29
32
  defaultText?: string;
@@ -53,6 +56,11 @@ export interface SelectProps {
53
56
  className?: string;
54
57
  onchange?: SelectChangeHandler;
55
58
  children?: Snippet;
59
+ /**
60
+ * Footer snippet for custom actions at the bottom of the dropdown.
61
+ * Receives { close } function to close the dropdown.
62
+ */
63
+ footer?: Snippet<[SelectFooterProps]>;
56
64
  }
57
65
  export interface SelectState {
58
66
  isOpen: boolean;
@@ -52,12 +52,19 @@
52
52
  return value.length < minTags;
53
53
  });
54
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
+
55
60
  // CSS classes
56
61
  let wrapperClasses = $derived(
57
62
  `
58
63
  tags-input-wrapper
59
64
  ${isDisabled ? 'disabled' : ''}
60
- ${invalid || hasAlert || isMinTagsInvalid ? 'invalid' : ''}
65
+ ${readonly ? 'readonly' : ''}
66
+ ${invalid || isErrorAlert || isMinTagsInvalid ? 'invalid' : ''}
67
+ ${isSuccessAlert ? 'success' : ''}
61
68
  ${isFocused ? 'focused' : ''}
62
69
  ${loading ? 'loading' : ''}
63
70
  ${className}
@@ -237,14 +244,14 @@
237
244
  const getTooltipColor = (alertType: string) => {
238
245
  switch (alertType) {
239
246
  case 'error':
240
- return 'var(--redBackground, #cf313b)';
247
+ return 'var(--redBackground)';
241
248
  case 'warning':
242
- return 'var(--orangeBackground, #bf4704)';
249
+ return 'var(--orangeBackground)';
243
250
  case 'success':
244
- return 'var(--greenBackground, #007a41)';
251
+ return 'var(--greenBackground)';
245
252
  case 'info':
246
253
  default:
247
- return 'var(--actionPrimaryBackground, #006acc)';
254
+ return 'var(--actionPrimaryBackground)';
248
255
  }
249
256
  };
250
257
  </script>
@@ -260,7 +267,7 @@
260
267
  onkeydown={(e) => e.key === 'Enter' && handleWrapperClick()}
261
268
  >
262
269
  <div class="tags-input-content">
263
- {#each value as tag, index (tag)}
270
+ {#each value as tag, index (`${index}-${tag}`)}
264
271
  <span class="tag" role="listitem">
265
272
  <span class="tag-text">{tag}</span>
266
273
  {#if !readonly && !isDisabled}
@@ -337,14 +344,14 @@
337
344
 
338
345
  .tags-input-wrapper {
339
346
  position: relative;
340
- border: 1px solid var(--border3, rgba(255, 255, 255, 0.19));
341
- border-radius: var(--border-radius, 4px);
347
+ border: 1px solid var(--border3);
348
+ border-radius: var(--border-radius);
342
349
  padding: 4px;
343
350
  display: flex;
344
351
  flex-wrap: wrap;
345
352
  align-items: flex-start;
346
353
  align-content: flex-start;
347
- background: var(--background1, #1e1e1e);
354
+ background: var(--background1);
348
355
  min-height: 32px;
349
356
  box-shadow:
350
357
  0px 16px 16px -16px rgba(0, 0, 0, 0.13) inset,
@@ -357,17 +364,22 @@
357
364
  }
358
365
 
359
366
  .tags-input-wrapper.focused {
360
- border-color: var(--blueBorder, #007df0);
367
+ border-color: var(--blueBorder);
361
368
  }
362
369
 
363
370
  .tags-input-wrapper.invalid {
364
- border-color: var(--redBorder, #e42f3a);
371
+ border-color: var(--redBorder);
372
+ }
373
+
374
+ .tags-input-wrapper.success {
375
+ border-color: var(--greenBorder);
365
376
  }
366
377
 
367
- .tags-input-wrapper.disabled {
378
+ .tags-input-wrapper.disabled,
379
+ .tags-input-wrapper.readonly {
368
380
  cursor: not-allowed;
369
- opacity: 0.5;
370
- border-color: var(--border1, rgba(255, 255, 255, 0.1));
381
+ opacity: 0.7;
382
+ border-color: var(--border1);
371
383
  }
372
384
 
373
385
  .tags-input-wrapper.loading {
@@ -389,24 +401,26 @@
389
401
  padding: 4px 8px;
390
402
  justify-content: center;
391
403
  align-items: center;
392
- border-radius: var(--border-radius, 4px);
404
+ border-radius: var(--border-radius);
393
405
  background: var(--actionSecondaryBackground);
394
- color: var(--text1, #ebebeb);
395
- font-size: var(--font-size-small, 11.5px);
396
- font-weight: var(--font-weight-normal, 400);
406
+ color: var(--text1);
407
+ font-size: var(--font-size-small);
408
+ font-weight: var(--font-weight-normal);
397
409
  line-height: 16px;
398
410
  letter-spacing: -0.115px;
399
411
  box-shadow:
400
412
  0 0.5px 1px 0 #000,
401
413
  0 0.5px 0.5px 0 rgba(255, 255, 255, 0.12) inset;
402
414
  user-select: none;
415
+ max-width: 100%;
416
+ min-width: 0;
403
417
  }
404
418
 
405
419
  .tag-text {
406
- max-width: 150px;
407
420
  overflow: hidden;
408
421
  text-overflow: ellipsis;
409
422
  white-space: nowrap;
423
+ min-width: 0;
410
424
  }
411
425
 
412
426
  .tag-remove {
@@ -423,7 +437,7 @@
423
437
  background: #464646;
424
438
  color: var(--text2);
425
439
  cursor: pointer;
426
- border-radius: 0 var(--border-radius, 4px) var(--border-radius, 4px) 0;
440
+ border-radius: 0 var(--border-radius) var(--border-radius) 0;
427
441
  opacity: 0;
428
442
  pointer-events: none;
429
443
  }
@@ -434,7 +448,7 @@
434
448
  }
435
449
 
436
450
  .tag-remove:not(:disabled) {
437
- color: var(--text1, #ebebeb);
451
+ color: var(--text1);
438
452
  }
439
453
 
440
454
  .tag-remove:disabled {
@@ -453,8 +467,8 @@
453
467
  padding: 4px 8px;
454
468
  border: none;
455
469
  background: transparent;
456
- color: var(--text1, #ebebeb);
457
- font-size: var(--font-size-small, 11.5px);
470
+ color: var(--text1);
471
+ font-size: var(--font-size-small);
458
472
  font-family: inherit;
459
473
  line-height: 16px;
460
474
  letter-spacing: -0.115px;
@@ -462,7 +476,7 @@
462
476
  }
463
477
 
464
478
  .tags-input-field::placeholder {
465
- color: var(--text3, #a3a3a3);
479
+ color: var(--text3);
466
480
  }
467
481
 
468
482
  .tags-input-field:disabled {
@@ -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.51",
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": {