@finsweet/webflow-apps-utils 1.0.30 → 1.0.32

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.
@@ -11,6 +11,7 @@ export * from './input';
11
11
  export * from './layout';
12
12
  export * from './modal';
13
13
  export * from './notification';
14
+ export * from './progress-bar';
14
15
  export * from './regions';
15
16
  export * from './section';
16
17
  export * from './select';
@@ -11,6 +11,7 @@ export * from './input';
11
11
  export * from './layout';
12
12
  export * from './modal';
13
13
  export * from './notification';
14
+ export * from './progress-bar';
14
15
  export * from './regions';
15
16
  export * from './section';
16
17
  export * from './select';
@@ -0,0 +1,185 @@
1
+ <script module>
2
+ import { defineMeta } from '@storybook/addon-svelte-csf';
3
+ import { fn } from 'storybook/test';
4
+
5
+ import ProgressBar from './ProgressBar.svelte';
6
+
7
+ // More on how to set up stories at: https://storybook.js.org/docs/writing-stories
8
+ const { Story } = defineMeta({
9
+ title: 'UI/ProgressBar',
10
+ component: ProgressBar,
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ value: {
14
+ control: { type: 'range', min: 0, max: 100, step: 1 }
15
+ },
16
+ max: {
17
+ control: { type: 'number' }
18
+ },
19
+ variant: {
20
+ control: { type: 'select' },
21
+ options: ['default', 'success', 'warning', 'error']
22
+ },
23
+ easing: {
24
+ control: { type: 'select' },
25
+ options: ['linear', 'cubicIn', 'cubicOut', 'cubicInOut', 'quartOut']
26
+ },
27
+ animated: {
28
+ control: { type: 'boolean' }
29
+ },
30
+ duration: {
31
+ control: { type: 'range', min: 100, max: 2000, step: 100 }
32
+ },
33
+ showPercentage: {
34
+ control: { type: 'boolean' }
35
+ },
36
+ showStatus: {
37
+ control: { type: 'boolean' }
38
+ },
39
+ showSpinner: {
40
+ control: { type: 'boolean' }
41
+ },
42
+ completed: {
43
+ control: { type: 'boolean' }
44
+ },
45
+ height: {
46
+ control: { type: 'range', min: 2, max: 20, step: 1 }
47
+ },
48
+ statusText: {
49
+ control: { type: 'text' }
50
+ },
51
+ onComplete: { action: 'completed' }
52
+ },
53
+ args: {
54
+ onComplete: fn()
55
+ }
56
+ });
57
+ </script>
58
+
59
+ <Story
60
+ name="Default"
61
+ args={{
62
+ value: 45,
63
+ showPercentage: true
64
+ }}
65
+ />
66
+
67
+ <Story
68
+ name="With Status"
69
+ args={{
70
+ value: 60,
71
+ showStatus: true,
72
+ showPercentage: true,
73
+ statusText: 'Processing files...'
74
+ }}
75
+ />
76
+
77
+ <Story
78
+ name="With Spinner"
79
+ args={{
80
+ value: 35,
81
+ showStatus: true,
82
+ showPercentage: true,
83
+ showSpinner: true,
84
+ statusText: 'Uploading data...'
85
+ }}
86
+ />
87
+
88
+ <Story
89
+ name="Completed State"
90
+ args={{
91
+ value: 100,
92
+ completed: true,
93
+ showStatus: true,
94
+ showPercentage: true,
95
+ statusText: 'Processing completed'
96
+ }}
97
+ />
98
+
99
+ <Story
100
+ name="Success Variant"
101
+ args={{
102
+ value: 80,
103
+ variant: 'success',
104
+ showStatus: true,
105
+ showPercentage: true,
106
+ statusText: 'Successfully processing...'
107
+ }}
108
+ />
109
+
110
+ <Story
111
+ name="Warning Variant"
112
+ args={{
113
+ value: 65,
114
+ variant: 'warning',
115
+ showStatus: true,
116
+ showPercentage: true,
117
+ statusText: 'Warning: Slow connection detected'
118
+ }}
119
+ />
120
+
121
+ <Story
122
+ name="Error Variant"
123
+ args={{
124
+ value: 25,
125
+ variant: 'error',
126
+ showStatus: true,
127
+ showPercentage: true,
128
+ statusText: 'Error: Connection failed'
129
+ }}
130
+ />
131
+
132
+ <Story
133
+ name="No Animation"
134
+ args={{
135
+ value: 70,
136
+ animated: false,
137
+ showPercentage: true,
138
+ showStatus: true,
139
+ statusText: 'Instant progress'
140
+ }}
141
+ />
142
+
143
+ <Story
144
+ name="Slow Animation"
145
+ args={{
146
+ value: 85,
147
+ duration: 2000,
148
+ easing: 'quartOut',
149
+ showPercentage: true,
150
+ showStatus: true,
151
+ statusText: 'Slow, smooth progress...'
152
+ }}
153
+ />
154
+
155
+ <Story
156
+ name="Custom Height"
157
+ args={{
158
+ value: 55,
159
+ height: 8,
160
+ showPercentage: true,
161
+ showStatus: true,
162
+ statusText: 'Thick progress bar'
163
+ }}
164
+ />
165
+
166
+ <Story
167
+ name="Minimal"
168
+ args={{
169
+ value: 40,
170
+ showPercentage: false,
171
+ showStatus: false
172
+ }}
173
+ />
174
+
175
+ <Story
176
+ name="Scan Progress Simulation"
177
+ args={{
178
+ value: 73,
179
+ showStatus: true,
180
+ showPercentage: true,
181
+ showSpinner: true,
182
+ statusText: 'Scanning: 73/100 pages',
183
+ height: 4
184
+ }}
185
+ />
@@ -0,0 +1,27 @@
1
+ export default ProgressBar;
2
+ type ProgressBar = SvelteComponent<{
3
+ [x: string]: never;
4
+ }, {
5
+ [evt: string]: CustomEvent<any>;
6
+ }, {}> & {
7
+ $$bindings?: string | undefined;
8
+ };
9
+ declare const ProgressBar: $$__sveltets_2_IsomorphicComponent<{
10
+ [x: string]: never;
11
+ }, {
12
+ [evt: string]: CustomEvent<any>;
13
+ }, {}, {}, string>;
14
+ import ProgressBar from './ProgressBar.svelte';
15
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
16
+ new (options: import("svelte").ComponentConstructorOptions<Props>): import("svelte").SvelteComponent<Props, Events, Slots> & {
17
+ $$bindings?: Bindings;
18
+ } & Exports;
19
+ (internal: unknown, props: {
20
+ $$events?: Events;
21
+ $$slots?: Slots;
22
+ }): Exports & {
23
+ $set?: any;
24
+ $on?: any;
25
+ };
26
+ z_$$bindings?: Bindings;
27
+ }
@@ -0,0 +1,290 @@
1
+ <script lang="ts">
2
+ import { cubicIn, cubicInOut, cubicOut, linear, quartOut } from 'svelte/easing';
3
+ import { tweened } from 'svelte/motion';
4
+
5
+ import { CheckCircleOutlinedIcon, WarningTriangleOutlineIcon } from '../../icons';
6
+ import Loader from '../Loader.svelte';
7
+ import { Text } from '../text';
8
+ import type { ProgressBarEasing, ProgressBarProps, ProgressBarVariant } from './types.js';
9
+
10
+ let {
11
+ value = 0,
12
+ max = 100,
13
+ animated = true,
14
+ duration = 400,
15
+ easing = 'cubicOut',
16
+ showPercentage = true,
17
+ showStatus = false,
18
+ statusText = '',
19
+ showSpinner = false,
20
+ variant = 'default',
21
+ height = 4,
22
+ completed = false,
23
+ class: className = '',
24
+ onComplete,
25
+ ...restProps
26
+ }: ProgressBarProps = $props();
27
+
28
+ const easingFunctions = {
29
+ linear,
30
+ cubicIn,
31
+ cubicOut,
32
+ cubicInOut,
33
+ quartOut
34
+ };
35
+
36
+ let currentProgress = $state(0);
37
+ let isCompleted = $state(false);
38
+ let completedValue = $state<number | null>(null);
39
+
40
+ let percentage = $derived(
41
+ Math.min(100, Math.round((Math.max(0, value) / Math.max(1, max)) * 100))
42
+ );
43
+
44
+ let tweenedProgress = $state(0);
45
+ const progress = tweened(0, {
46
+ duration,
47
+ easing: easingFunctions[easing]
48
+ });
49
+
50
+ $effect(() => {
51
+ const unsubscribe = progress.subscribe((val) => {
52
+ tweenedProgress = val;
53
+ });
54
+
55
+ return unsubscribe;
56
+ });
57
+
58
+ let displayWidth = $derived(
59
+ isCompleted && completedValue !== null ? completedValue : tweenedProgress
60
+ );
61
+
62
+ let displayPercentage = $derived(
63
+ isCompleted && completedValue !== null
64
+ ? Math.round(completedValue)
65
+ : Math.round(tweenedProgress)
66
+ );
67
+
68
+ $effect(() => {
69
+ const currentPercentage = percentage;
70
+ if (!completed) {
71
+ currentProgress = currentPercentage;
72
+ if (animated) {
73
+ progress.set(currentPercentage, {
74
+ duration,
75
+ easing: easingFunctions[easing]
76
+ });
77
+ } else {
78
+ progress.set(currentPercentage, { duration: 0 });
79
+ }
80
+ }
81
+ });
82
+
83
+ $effect(() => {
84
+ if (completed && !isCompleted) {
85
+ completedValue = tweenedProgress;
86
+ isCompleted = true;
87
+
88
+ onComplete?.();
89
+ }
90
+
91
+ if (!completed && isCompleted) {
92
+ isCompleted = false;
93
+ completedValue = null;
94
+ }
95
+ });
96
+
97
+ let containerClasses = $derived(() => {
98
+ const classes = ['progress-container'];
99
+ if (className) classes.push(className);
100
+ return classes.join(' ');
101
+ });
102
+
103
+ let progressClasses = $derived(() => {
104
+ const classes = ['progress-fill'];
105
+ if (isCompleted) classes.push('completed');
106
+ if (variant !== 'default') classes.push(`progress-fill--${variant}`);
107
+ return classes.join(' ');
108
+ });
109
+
110
+ let displayStatusText = $derived(() => {
111
+ if (isCompleted) {
112
+ return 'Completed successfully';
113
+ }
114
+ return statusText || `${displayPercentage}%`;
115
+ });
116
+
117
+ let StatusIcon = $derived(() => {
118
+ if (isCompleted) {
119
+ return CheckCircleOutlinedIcon;
120
+ }
121
+ return WarningTriangleOutlineIcon;
122
+ });
123
+
124
+ let statusColor = $derived(() => {
125
+ if (isCompleted) return 'var(--greenText)';
126
+
127
+ switch (variant) {
128
+ case 'success':
129
+ return 'var(--greenText)';
130
+ case 'warning':
131
+ return 'var(--yellowText)';
132
+ case 'error':
133
+ return 'var(--redText)';
134
+ default:
135
+ return 'var(--text1)';
136
+ }
137
+ });
138
+
139
+ let progressFillColor = $derived(() => {
140
+ if (isCompleted) return 'var(--greenText)';
141
+
142
+ switch (variant) {
143
+ case 'success':
144
+ return 'var(--greenText)';
145
+ case 'warning':
146
+ return 'var(--yellowText)';
147
+ case 'error':
148
+ return 'var(--redText)';
149
+ default:
150
+ return 'var(--actionPrimaryBackground)';
151
+ }
152
+ });
153
+ </script>
154
+
155
+ <div
156
+ class={containerClasses()}
157
+ role="progressbar"
158
+ aria-valuenow={displayPercentage}
159
+ aria-valuemin="0"
160
+ aria-valuemax="100"
161
+ aria-label={displayStatusText()}
162
+ aria-live="polite"
163
+ {...restProps}
164
+ >
165
+ {#if showStatus || showPercentage || showSpinner}
166
+ <div class="progress-header">
167
+ <div class="status-info">
168
+ {#if showSpinner && !isCompleted}
169
+ <div class="progress-spinner">
170
+ <Loader size={12} margin="0" />
171
+ </div>
172
+ {/if}
173
+
174
+ <div class="status-icon" class:success={isCompleted}>
175
+ <StatusIcon />
176
+ </div>
177
+
178
+ {#if showStatus}
179
+ <Text
180
+ label={displayStatusText()}
181
+ fontColor={statusColor()}
182
+ fontSize="normal"
183
+ class="progress-status"
184
+ />
185
+ {/if}
186
+ </div>
187
+
188
+ <div class="progress-details">
189
+ {#if showPercentage}
190
+ <Text
191
+ label="{displayPercentage}%"
192
+ fontColor={statusColor()}
193
+ fontSize="normal"
194
+ class="progress-percentage"
195
+ />
196
+ {/if}
197
+ </div>
198
+ </div>
199
+ {/if}
200
+
201
+ <div class="progress-track" style="height: {height}px;">
202
+ <div
203
+ class={progressClasses()}
204
+ style="width: {displayWidth}%; height: {height}px; background-color: {progressFillColor()};"
205
+ ></div>
206
+ </div>
207
+ </div>
208
+
209
+ <style>
210
+ .progress-container {
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 8px;
214
+ width: 100%;
215
+ }
216
+
217
+ .progress-header {
218
+ display: flex;
219
+ justify-content: space-between;
220
+ align-items: center;
221
+ gap: 4px;
222
+ }
223
+
224
+ .status-info {
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 4px;
228
+ flex: 1;
229
+ min-width: 0;
230
+ }
231
+
232
+ .status-icon :global(svg) {
233
+ width: 20px;
234
+ height: 20px;
235
+ color: var(--text1);
236
+ }
237
+
238
+ .status-icon.success :global(svg) {
239
+ color: var(--greenText);
240
+ }
241
+
242
+ .progress-details {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 8px;
246
+ flex-shrink: 0;
247
+ }
248
+
249
+ .progress-spinner {
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ }
254
+
255
+ .progress-track {
256
+ width: 100%;
257
+ background-color: var(--black);
258
+ border-radius: 2px;
259
+ overflow: hidden;
260
+ position: relative;
261
+ }
262
+
263
+ .progress-fill {
264
+ height: 100%;
265
+ border-radius: 2px;
266
+ transition: width 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
267
+ min-width: 1px;
268
+ }
269
+
270
+ .progress-fill.completed {
271
+ background-color: var(--greenText) !important;
272
+ }
273
+
274
+ .progress-fill--success {
275
+ background-color: var(--greenText);
276
+ }
277
+
278
+ .progress-fill--warning {
279
+ background-color: var(--yellowText);
280
+ }
281
+
282
+ .progress-fill--error {
283
+ background-color: var(--redText);
284
+ }
285
+
286
+ /* Ensure smooth transitions */
287
+ .progress-fill {
288
+ will-change: width;
289
+ }
290
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { ProgressBarProps } from './types.js';
2
+ declare const ProgressBar: import("svelte").Component<ProgressBarProps, {}, "">;
3
+ type ProgressBar = ReturnType<typeof ProgressBar>;
4
+ export default ProgressBar;
@@ -0,0 +1,2 @@
1
+ export { default as ProgressBar } from './ProgressBar.svelte';
2
+ export * from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as ProgressBar } from './ProgressBar.svelte';
2
+ export * from './types.js';
@@ -0,0 +1,63 @@
1
+ export type ProgressBarVariant = 'default' | 'success' | 'warning' | 'error';
2
+ export type ProgressBarEasing = 'linear' | 'cubicIn' | 'cubicOut' | 'cubicInOut' | 'quartOut';
3
+ export interface ProgressBarProps {
4
+ /**
5
+ * Current progress value (0-100)
6
+ */
7
+ value: number;
8
+ /**
9
+ * Maximum value (default: 100)
10
+ */
11
+ max?: number;
12
+ /**
13
+ * Enable smooth animations (default: true)
14
+ */
15
+ animated?: boolean;
16
+ /**
17
+ * Animation duration in milliseconds (default: 400)
18
+ */
19
+ duration?: number;
20
+ /**
21
+ * Easing function (default: 'cubicOut')
22
+ */
23
+ easing?: ProgressBarEasing;
24
+ /**
25
+ * Show percentage text (default: true)
26
+ */
27
+ showPercentage?: boolean;
28
+ /**
29
+ * Show status text (default: false)
30
+ */
31
+ showStatus?: boolean;
32
+ /**
33
+ * Custom status text
34
+ */
35
+ statusText?: string;
36
+ /**
37
+ * Show loading spinner during progress (default: false)
38
+ */
39
+ showSpinner?: boolean;
40
+ /**
41
+ * Color scheme
42
+ */
43
+ variant?: ProgressBarVariant;
44
+ /**
45
+ * Height in pixels (default: 4)
46
+ */
47
+ height?: number;
48
+ /**
49
+ * Completed state - locks progress at final value
50
+ */
51
+ completed?: boolean;
52
+ /**
53
+ * Custom CSS class
54
+ */
55
+ class?: string;
56
+ /**
57
+ * Callback when progress completes
58
+ */
59
+ onComplete?: () => void;
60
+ }
61
+ export interface ProgressBarEvents {
62
+ complete: CustomEvent<void>;
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -68,6 +68,14 @@ declare const meta: {
68
68
  control: string;
69
69
  description: string;
70
70
  };
71
+ alert: {
72
+ control: string;
73
+ description: string;
74
+ };
75
+ invalid: {
76
+ control: string;
77
+ description: string;
78
+ };
71
79
  };
72
80
  };
73
81
  export default meta;
@@ -92,3 +100,11 @@ export declare const SingleOption: Story;
92
100
  export declare const LongOptions: Story;
93
101
  export declare const AccessibilityTest: Story;
94
102
  export declare const ManyOptions: Story;
103
+ export declare const WithErrorAlert: Story;
104
+ export declare const WithWarningAlert: Story;
105
+ export declare const WithSuccessAlert: Story;
106
+ export declare const WithInfoAlert: Story;
107
+ export declare const InvalidState: Story;
108
+ export declare const InvalidWithAlert: Story;
109
+ export declare const ValidationStates: Story;
110
+ export declare const FormValidationExample: Story;
@@ -114,6 +114,14 @@ const meta = {
114
114
  className: {
115
115
  control: 'text',
116
116
  description: 'Additional CSS classes'
117
+ },
118
+ alert: {
119
+ control: 'object',
120
+ description: 'Alert configuration for validation messages'
121
+ },
122
+ invalid: {
123
+ control: 'boolean',
124
+ description: 'Whether the select is in an invalid state'
117
125
  }
118
126
  }
119
127
  };
@@ -394,3 +402,168 @@ export const ManyOptions = {
394
402
  }
395
403
  }
396
404
  };
405
+ // Validation and error states
406
+ export const WithErrorAlert = {
407
+ args: {
408
+ options: basicOptions,
409
+ defaultText: 'Select a required option',
410
+ alert: {
411
+ type: 'error',
412
+ message: 'This field is required. Please select an option.'
413
+ }
414
+ },
415
+ parameters: {
416
+ docs: {
417
+ description: {
418
+ story: 'Shows an error alert with a validation message. Hover over the select to see the tooltip.'
419
+ }
420
+ }
421
+ }
422
+ };
423
+ export const WithWarningAlert = {
424
+ args: {
425
+ options: basicOptions,
426
+ selected: 'option3',
427
+ defaultText: 'Select an option',
428
+ alert: {
429
+ type: 'warning',
430
+ message: 'This option may affect performance. Consider choosing a different option.'
431
+ }
432
+ },
433
+ parameters: {
434
+ docs: {
435
+ description: {
436
+ story: 'Warning alert to inform users about potential issues with their selection.'
437
+ }
438
+ }
439
+ }
440
+ };
441
+ export const WithSuccessAlert = {
442
+ args: {
443
+ options: basicOptions,
444
+ selected: 'option1',
445
+ defaultText: 'Select an option',
446
+ alert: {
447
+ type: 'success',
448
+ message: 'Great choice! This option is optimized for your use case.'
449
+ }
450
+ },
451
+ parameters: {
452
+ docs: {
453
+ description: {
454
+ story: 'Success alert to confirm a good selection or provide positive feedback.'
455
+ }
456
+ }
457
+ }
458
+ };
459
+ export const WithInfoAlert = {
460
+ args: {
461
+ options: basicOptions,
462
+ defaultText: 'Select configuration',
463
+ alert: {
464
+ type: 'info',
465
+ message: 'Tip: You can change this setting later in your preferences.'
466
+ }
467
+ },
468
+ parameters: {
469
+ docs: {
470
+ description: {
471
+ story: 'Info alert to provide additional context or helpful information.'
472
+ }
473
+ }
474
+ }
475
+ };
476
+ export const InvalidState = {
477
+ args: {
478
+ options: basicOptions,
479
+ defaultText: 'Invalid selection',
480
+ invalid: true
481
+ },
482
+ parameters: {
483
+ docs: {
484
+ description: {
485
+ story: 'Shows the select in an invalid state with red outline styling.'
486
+ }
487
+ }
488
+ }
489
+ };
490
+ export const InvalidWithAlert = {
491
+ args: {
492
+ options: basicOptions,
493
+ defaultText: 'Select a value',
494
+ invalid: true,
495
+ alert: {
496
+ type: 'error',
497
+ message: 'Please select a valid option to continue.'
498
+ }
499
+ },
500
+ parameters: {
501
+ docs: {
502
+ description: {
503
+ story: 'Combines invalid state styling with an error alert message for comprehensive validation feedback.'
504
+ }
505
+ }
506
+ }
507
+ };
508
+ export const ValidationStates = {
509
+ args: {
510
+ options: [
511
+ { label: 'Option A', value: 'a' },
512
+ { label: 'Option B', value: 'b' },
513
+ { label: 'Option C', value: 'c' }
514
+ ],
515
+ defaultText: 'Validation example'
516
+ },
517
+ parameters: {
518
+ docs: {
519
+ description: {
520
+ story: 'Basic validation state. See other validation stories (WithErrorAlert, WithWarningAlert, etc.) for different alert types and invalid states.'
521
+ }
522
+ }
523
+ }
524
+ };
525
+ // Form validation example
526
+ export const FormValidationExample = {
527
+ render: (args) => ({
528
+ Component: Select,
529
+ props: {
530
+ ...args,
531
+ onchange: (event) => {
532
+ // Simulate form validation
533
+ const value = event.value;
534
+ if (!value) {
535
+ // Update to show required field error
536
+ console.log('Validation: Field is required');
537
+ }
538
+ else if (value === 'option3') {
539
+ // Show warning for specific option
540
+ console.log('Validation: Warning for option 3');
541
+ }
542
+ else {
543
+ // Valid selection
544
+ console.log('Validation: Valid selection');
545
+ }
546
+ }
547
+ }
548
+ }),
549
+ args: {
550
+ options: [
551
+ { label: 'Valid Option 1', value: 'option1' },
552
+ { label: 'Valid Option 2', value: 'option2' },
553
+ { label: 'Problematic Option', value: 'option3' },
554
+ { label: 'Another Valid Option', value: 'option4' }
555
+ ],
556
+ defaultText: 'Choose wisely...',
557
+ alert: {
558
+ type: 'info',
559
+ message: 'Select an option to see validation feedback'
560
+ }
561
+ },
562
+ parameters: {
563
+ docs: {
564
+ description: {
565
+ story: 'Interactive example showing how validation states might change based on user selection in a real form.'
566
+ }
567
+ }
568
+ }
569
+ };
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { CheckIcon, ChevronIcon, UndoIcon } from '../../icons';
14
14
 
15
+ import { Tooltip } from '..';
15
16
  import { Text } from '../text';
16
17
  import type { DropdownInstance, SelectInstanceManager, SelectProps } from './types.js';
17
18
 
@@ -33,6 +34,8 @@
33
34
  preventNoSelection = false,
34
35
  disabled = false,
35
36
  placement = 'bottom',
37
+ alert = null,
38
+ invalid = false,
36
39
  className = '',
37
40
  onchange,
38
41
  children
@@ -85,6 +88,9 @@
85
88
  }
86
89
  });
87
90
 
91
+ // Computed states
92
+ let hasAlert = $derived(alert?.message);
93
+
88
94
  // Computed styles based on state
89
95
  const dropdownStyles = $derived(() => {
90
96
  const base = {
@@ -101,17 +107,17 @@
101
107
  };
102
108
  }
103
109
 
104
- if (hasError) {
110
+ if (hasError || hasAlert || invalid) {
105
111
  return {
106
112
  ...base,
107
- border: '1px solid var(--redBorder)'
113
+ outline: '1px solid var(--redBorder)'
108
114
  };
109
115
  }
110
116
 
111
117
  if (isFocused) {
112
118
  return {
113
119
  ...base,
114
- border: '1px solid var(--blueBorder)'
120
+ outline: '1px solid var(--blueBorder)'
115
121
  };
116
122
  }
117
123
 
@@ -393,6 +399,23 @@
393
399
  debouncedFilterOptions(searchValue, options);
394
400
  };
395
401
 
402
+ /**
403
+ * Gets the tooltip background color based on alert type
404
+ */
405
+ const getTooltipColor = (alertType: string) => {
406
+ switch (alertType) {
407
+ case 'error':
408
+ return 'var(--redBackground, #ff4d4d)';
409
+ case 'warning':
410
+ return 'var(--orangeBackground, #ff9933)';
411
+ case 'success':
412
+ return 'var(--greenBackground, #00cc66)';
413
+ case 'info':
414
+ default:
415
+ return 'var(--blueBackground, #4d9fff)';
416
+ }
417
+ };
418
+
396
419
  // Lifecycle cleanup
397
420
  $effect(() => {
398
421
  return () => {
@@ -401,134 +424,159 @@
401
424
  });
402
425
  </script>
403
426
 
404
- <div
405
- class="dropdown-wrapper {className}"
406
- bind:this={dropdownWrapper}
407
- style="{hide ? 'display:none;' : ''} width: {width};"
408
- >
427
+ {#snippet selectWrapper()}
409
428
  <div
410
- class="dropdown"
411
- class:disabled
412
- {id}
413
- style="width:{width}; {Object.entries(dropdownStyles())
414
- .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
415
- .join('; ')}"
416
- aria-disabled={disabled}
417
- tabindex={disabled || isOpen ? -1 : 0}
418
- role="button"
419
- aria-haspopup="listbox"
420
- aria-labelledby={id}
421
- bind:this={target}
422
- onmouseenter={() => (isHovered = true)}
423
- onmouseleave={() => (isHovered = false)}
424
- onfocus={() => (isFocused = true)}
425
- onblur={() => (isFocused = false)}
429
+ class="dropdown-wrapper {className}"
430
+ bind:this={dropdownWrapper}
431
+ style="{hide ? 'display:none;' : ''} width: {width};"
426
432
  >
427
- <div class="dropdown-header" aria-disabled={disabled}>
428
- <div class="label">
429
- {selected ? selectedLabel || defaultText : defaultText}
430
- </div>
431
- <div class="arrow" style="transform:rotate({isOpen ? '270deg' : '90deg'})">
432
- <ChevronIcon />
433
- </div>
434
- </div>
435
-
436
433
  <div
434
+ class="dropdown"
435
+ class:disabled
436
+ {id}
437
+ style="width:{width}; {Object.entries(dropdownStyles())
438
+ .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
439
+ .join('; ')}"
440
+ aria-disabled={disabled}
437
441
  tabindex={disabled || isOpen ? -1 : 0}
438
- class="dropdown-list"
439
- role="listbox"
440
- style="width:{dropdownWidth}; max-height:{dropdownHeight};"
441
- onkeydown={(e) => {
442
- e.stopPropagation();
443
- e.preventDefault();
444
- handleKeyDown(e);
445
- }}
446
- bind:this={dropdownItems}
442
+ role="button"
443
+ aria-haspopup="listbox"
444
+ aria-labelledby={id}
445
+ bind:this={target}
446
+ onmouseenter={() => (isHovered = true)}
447
+ onmouseleave={() => (isHovered = false)}
448
+ onfocus={() => (isFocused = true)}
449
+ onblur={() => (isFocused = false)}
447
450
  >
448
- {#if selectedLabel}
449
- <div class="selected">
450
- <div class="label">
451
- <Text label={selectedLabel} fontSize="normal" fontColor="var(--text1)" />
452
- </div>
451
+ <div class="dropdown-header" aria-disabled={disabled}>
452
+ <div class="label">
453
+ {selected ? selectedLabel || defaultText : defaultText}
453
454
  </div>
454
- {/if}
455
-
456
- {#if enableSearch}
457
- <div class="search-container">
458
- <input
459
- type="text"
460
- placeholder="Search"
461
- oninput={(e) => {
455
+ <div class="arrow" style="transform:rotate({isOpen ? '270deg' : '90deg'})">
456
+ <ChevronIcon />
457
+ </div>
458
+ </div>
459
+
460
+ <div
461
+ tabindex={disabled || isOpen ? -1 : 0}
462
+ class="dropdown-list"
463
+ role="listbox"
464
+ style="width:{dropdownWidth}; max-height:{dropdownHeight};"
465
+ onkeydown={(e) => {
466
+ e.stopPropagation();
467
+ e.preventDefault();
468
+ handleKeyDown(e);
469
+ }}
470
+ bind:this={dropdownItems}
471
+ >
472
+ {#if selectedLabel}
473
+ <div class="selected">
474
+ <div class="label">
475
+ <Text label={selectedLabel} fontSize="normal" fontColor="var(--text1)" />
476
+ </div>
477
+ </div>
478
+ {/if}
479
+
480
+ {#if enableSearch}
481
+ <div class="search-container">
482
+ <input
483
+ type="text"
484
+ placeholder="Search"
485
+ oninput={(e) => {
486
+ e.stopPropagation();
487
+ e.preventDefault();
488
+ handleSearch(e);
489
+ }}
490
+ onkeydown={(e) => e.stopPropagation()}
491
+ />
492
+ </div>
493
+ {/if}
494
+
495
+ {#each optionsStore?.length > 0 ? optionsStore : options as { label, value, className = null, description = null, labelIcon = null, descriptionTitle = null, isDisabled = false }, index (index)}
496
+ {@const indexId = index + 1}
497
+ {@const itemId = ref ? ref.replace(' ', '-') : 'dropdown'}
498
+ <button
499
+ aria-posinset={indexId}
500
+ aria-selected={value === selected && selected?.trim() !== '' ? 'true' : 'false'}
501
+ id={`${itemId}-list-${indexId}-${id}`}
502
+ data-value={value}
503
+ class="dropdown-item {isDisabled ? 'disabled' : ''} {className}"
504
+ role="option"
505
+ onclick={(e) => {
506
+ e.stopPropagation();
507
+ if (isDisabled) return;
508
+ handleSelect(value, label, e.currentTarget);
509
+ }}
510
+ onkeydown={(e) => {
462
511
  e.stopPropagation();
463
512
  e.preventDefault();
464
- handleSearch(e);
465
513
  }}
466
- onkeydown={(e) => e.stopPropagation()}
467
- />
468
- </div>
469
- {/if}
470
-
471
- {#each optionsStore?.length > 0 ? optionsStore : options as { label, value, className = null, description = null, labelIcon = null, descriptionTitle = null, isDisabled = false }, index (index)}
472
- {@const indexId = index + 1}
473
- {@const itemId = ref ? ref.replace(' ', '-') : 'dropdown'}
474
- <button
475
- aria-posinset={indexId}
476
- aria-selected={value === selected && selected?.trim() !== '' ? 'true' : 'false'}
477
- id={`${itemId}-list-${indexId}-${id}`}
478
- data-value={value}
479
- class="dropdown-item {isDisabled ? 'disabled' : ''} {className}"
480
- role="option"
481
- onclick={(e) => {
482
- e.stopPropagation();
483
- if (isDisabled) return;
484
- handleSelect(value, label, e.currentTarget);
485
- }}
486
- onkeydown={(e) => {
487
- e.stopPropagation();
488
- e.preventDefault();
489
- }}
490
- onmouseenter={handleMouseEnter}
491
- aria-hidden={!isOpen}
492
- tabindex={value === selected ? 0 : -1}
493
- style={description ? 'align-items:start;' : ''}
494
- >
495
- <div class="icon" aria-label={label}>
496
- {#if value === selected && selected?.trim() !== ''}
497
- <CheckIcon />
498
- {/if}
499
- </div>
500
- <div class="label">
501
- {#if description || descriptionTitle || labelIcon}
502
- <div class="label-content">
503
- <div class="label-name">
504
- <Text {label} />
505
- {#if labelIcon}
506
- {@const IconComponent = labelIcon}
507
- <IconComponent />
508
- {/if}
509
- </div>
510
- <div class="label-description-title">
511
- <Text
512
- label={descriptionTitle || ''}
513
- fontColor="var(--greenText)"
514
- fontSize="10px"
515
- />
514
+ onmouseenter={handleMouseEnter}
515
+ aria-hidden={!isOpen}
516
+ tabindex={value === selected ? 0 : -1}
517
+ style={description ? 'align-items:start;' : ''}
518
+ >
519
+ <div class="icon" aria-label={label}>
520
+ {#if value === selected && selected?.trim() !== ''}
521
+ <CheckIcon />
522
+ {/if}
523
+ </div>
524
+ <div class="label">
525
+ {#if description || descriptionTitle || labelIcon}
526
+ <div class="label-content">
527
+ <div class="label-name">
528
+ <Text {label} />
529
+ {#if labelIcon}
530
+ {@const IconComponent = labelIcon}
531
+ <IconComponent />
532
+ {/if}
533
+ </div>
534
+ <div class="label-description-title">
535
+ <Text
536
+ label={descriptionTitle || ''}
537
+ fontColor="var(--greenText)"
538
+ fontSize="10px"
539
+ />
540
+ </div>
541
+ <div class="label-description">
542
+ <Text label={description || ''} fontColor="var(--text2)" fontSize="10px" />
543
+ </div>
516
544
  </div>
517
- <div class="label-description">
518
- <Text label={description || ''} fontColor="var(--text2)" fontSize="10px" />
519
- </div>
520
- </div>
521
- {:else}
522
- <Text {label} fontSize="normal" />
523
- {/if}
524
- </div>
525
- </button>
526
- {/each}
545
+ {:else}
546
+ <Text {label} fontSize="normal" />
547
+ {/if}
548
+ </div>
549
+ </button>
550
+ {/each}
551
+ </div>
527
552
  </div>
528
553
  </div>
529
- </div>
554
+ {/snippet}
555
+
556
+ <Tooltip
557
+ message={hasAlert ? alert?.message || '' : ''}
558
+ placement="top"
559
+ listener="hover"
560
+ listenerout="hover"
561
+ showArrow={true}
562
+ hidden={!hasAlert}
563
+ disabled={!hasAlert || !alert?.message}
564
+ fontColor="var(--actionPrimaryText)"
565
+ width="max-content"
566
+ padding="6px"
567
+ bgColor={getTooltipColor(alert?.type || 'info')}
568
+ class="select-tooltip"
569
+ >
570
+ {#snippet target()}
571
+ {@render selectWrapper()}
572
+ {/snippet}
573
+ </Tooltip>
530
574
 
531
575
  <style>
576
+ :global(.select-tooltip) {
577
+ padding: 0;
578
+ }
579
+
532
580
  .dropdown-item.disabled {
533
581
  opacity: 0.75;
534
582
  cursor: not-allowed;
@@ -619,7 +667,7 @@
619
667
  }
620
668
 
621
669
  .dropdown-list :global(.dropdown-item.hover-state) {
622
- border: 1px solid var(--blueBorder);
670
+ outline: 1px solid var(--blueBorder);
623
671
  }
624
672
 
625
673
  .dropdown-list {
@@ -1,5 +1,10 @@
1
1
  import type { Placement } from '@floating-ui/dom';
2
2
  import type { Component, Snippet } from 'svelte';
3
+ export type AlertType = 'error' | 'success' | 'info' | 'warning';
4
+ export interface AlertConfig {
5
+ type: AlertType;
6
+ message: string;
7
+ }
3
8
  export type SelectValue = string & {
4
9
  readonly brand: unique symbol;
5
10
  };
@@ -37,6 +42,14 @@ export interface SelectProps {
37
42
  preventNoSelection?: boolean;
38
43
  disabled?: boolean;
39
44
  placement?: Placement;
45
+ /**
46
+ * Alert configuration for showing validation messages
47
+ */
48
+ alert?: AlertConfig | null;
49
+ /**
50
+ * If true, the select will be invalid
51
+ */
52
+ invalid?: boolean;
40
53
  className?: string;
41
54
  onchange?: SelectChangeHandler;
42
55
  children?: Snippet;
@@ -345,11 +345,11 @@
345
345
  {#if tooltip}
346
346
  {@render tooltip()}
347
347
  {:else if message && raw}
348
- <div class="message" style="color:{fontColor};">
348
+ <div class="message" style="color:{fontColor}; background-color: {bgColor};">
349
349
  {@html message}
350
350
  </div>
351
351
  {:else if message}
352
- <div class="message">
352
+ <div class="message" style="color:{fontColor}; background-color: {bgColor};">
353
353
  <Text label={formattedMessage} fontSize="11px" fontWeight="500" {fontColor} />
354
354
  </div>
355
355
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finsweet/webflow-apps-utils",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "Shared utilities for Webflow apps",
5
5
  "homepage": "https://github.com/finsweet/webflow-apps-utils",
6
6
  "repository": {