@softwareone/spi-sv5-library 1.1.0 → 1.2.0

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,457 @@
1
+ <script lang="ts">
2
+ import { onMount, type Snippet } from 'svelte';
3
+ import type { Action } from 'svelte/action';
4
+
5
+ import { Search, type SelectOption } from '../../index.js';
6
+ import Label from '../Label.svelte';
7
+
8
+ interface SelectProps {
9
+ options: string[] | SelectOption[];
10
+ label?: string;
11
+ placeholder?: string;
12
+ required?: boolean;
13
+ optional?: boolean;
14
+ searchable?: boolean;
15
+ clearable?: boolean;
16
+ multiple?: boolean;
17
+ closeAfterSelect?: boolean;
18
+ readonly?: boolean;
19
+ disableValidationColor?: boolean;
20
+ value?: string | string[] | null;
21
+ error?: string | string[];
22
+ customSelection?: Snippet<[option: SelectOption]>;
23
+ customOption?: Snippet<[option: SelectOption]>;
24
+ }
25
+
26
+ let {
27
+ options,
28
+ label,
29
+ placeholder,
30
+ required = false,
31
+ optional = false,
32
+ searchable = false,
33
+ clearable = false,
34
+ multiple = false,
35
+ closeAfterSelect = false,
36
+ readonly = false,
37
+ disableValidationColor = false,
38
+ value = $bindable(),
39
+ error,
40
+ customSelection,
41
+ customOption
42
+ }: SelectProps = $props();
43
+
44
+ let dropdownElement: HTMLElement;
45
+
46
+ const isStringArray = (items: string[] | SelectOption[]): items is string[] =>
47
+ typeof items[0] === 'string';
48
+
49
+ const originalOptions: SelectOption[] = isStringArray(options)
50
+ ? options.map((value) => ({ label: value, value }))
51
+ : options;
52
+
53
+ let searchText = $state('');
54
+ let showInTopPosition = $state(false);
55
+ let showListOptions = $state(false);
56
+ let filteredOptions = $state(originalOptions);
57
+ let selectedOptions = $state<SelectOption[]>([]);
58
+ let selectedOption = $state<SelectOption | undefined>();
59
+
60
+ const isInvalid: boolean = $derived(!!error && !disableValidationColor);
61
+ const isValid: boolean = $derived(!isInvalid && (!!value || optional) && !disableValidationColor);
62
+ const noOptionsAvailable: boolean = $derived(
63
+ options.length === 0 || (multiple && selectedOptions?.length === options.length)
64
+ );
65
+
66
+ const generateSelectOption = (value: string): SelectOption => ({ label: value, value });
67
+ const generateMultiselectValue = (option: SelectOption): string => option.value;
68
+
69
+ const canBeVisible = (option: SelectOption): boolean =>
70
+ !selectedOptions.some((selectedOption) => selectedOption.value === option.value);
71
+
72
+ const onCloseAfterSelect = () => {
73
+ if (closeAfterSelect && showListOptions) {
74
+ showListOptions = false;
75
+ }
76
+ };
77
+
78
+ const onSelectOption = (option: SelectOption) => {
79
+ if (multiple) {
80
+ selectedOptions = [...selectedOptions, option];
81
+ value = selectedOptions.map(generateMultiselectValue);
82
+ } else {
83
+ selectedOption = option;
84
+ value = option.value;
85
+ }
86
+ onCloseAfterSelect();
87
+ };
88
+
89
+ const onClearAll = () => {
90
+ if (multiple) {
91
+ selectedOptions = [];
92
+ value = [];
93
+ } else {
94
+ selectedOption = undefined;
95
+ value = '';
96
+ }
97
+
98
+ onCloseAfterSelect();
99
+ };
100
+
101
+ const onRemoveSelectedOption = (index: number) => {
102
+ const newSelectedOptions = [...selectedOptions];
103
+ newSelectedOptions.splice(index, 1);
104
+ selectedOptions = newSelectedOptions;
105
+ value = selectedOptions.map(generateMultiselectValue);
106
+ };
107
+
108
+ const onInputSearch = () => {
109
+ const text = searchText.toLowerCase();
110
+
111
+ filteredOptions = text
112
+ ? originalOptions.filter((option) => option.label.toLowerCase().includes(text))
113
+ : originalOptions;
114
+ };
115
+
116
+ const onClearSearch = () => {
117
+ filteredOptions = originalOptions;
118
+ };
119
+
120
+ const onHandleClickOutside = (event: MouseEvent) => {
121
+ if (showListOptions && dropdownElement && !dropdownElement.contains(event.target as Node)) {
122
+ showListOptions = false;
123
+ }
124
+ };
125
+
126
+ const activeOptionScroll: Action<HTMLElement, boolean> = (node, isActive) => {
127
+ $effect(() => {
128
+ if (isActive) {
129
+ node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
130
+ }
131
+ });
132
+ };
133
+
134
+ const autoDirection: Action = () => {
135
+ $effect(() => {
136
+ const rect = dropdownElement.getBoundingClientRect();
137
+ const viewportHeight = window.innerHeight;
138
+ const footer = 300;
139
+
140
+ showInTopPosition = rect.bottom + footer > viewportHeight;
141
+ });
142
+ };
143
+
144
+ onMount(() => {
145
+ if (!value) return;
146
+
147
+ if (multiple && Array.isArray(value)) {
148
+ selectedOptions = value.map(generateSelectOption);
149
+ } else if (!multiple && typeof value === 'string') {
150
+ selectedOption = generateSelectOption(value);
151
+ }
152
+ });
153
+ </script>
154
+
155
+ <svelte:window on:click={onHandleClickOutside} />
156
+
157
+ <div class="form-container">
158
+ {#if label}
159
+ <Label {label} {required} {optional} />
160
+ {/if}
161
+
162
+ <div
163
+ class={['dropdown', readonly ? 'readonly' : [isInvalid && 'invalid', isValid && 'valid']]}
164
+ bind:this={dropdownElement}
165
+ >
166
+ <section
167
+ class="dropdown-container"
168
+ role="button"
169
+ tabindex="0"
170
+ onclick={() => (showListOptions = !showListOptions)}
171
+ onkeypress={() => {}}
172
+ >
173
+ <div class="dropdown-container-selected-options">
174
+ {#if selectedOption || selectedOptions.length}
175
+ {#if multiple}
176
+ {#each selectedOptions as selectedOption, index}
177
+ {@render selectedOptionTemplate(selectedOption, index)}
178
+ {/each}
179
+ {:else if customSelection}
180
+ <p>
181
+ {@render customSelection({
182
+ label: selectedOption?.label,
183
+ value: selectedOption?.value
184
+ } as SelectOption)}
185
+ </p>
186
+ {:else}
187
+ <p>{selectedOption?.label}</p>
188
+ {/if}
189
+ {:else}
190
+ <p class="placeholder">{placeholder}</p>
191
+ {/if}
192
+ </div>
193
+
194
+ {#if clearable && (selectedOption || selectedOptions.length)}
195
+ {@render removeButton(onClearAll)}
196
+ {/if}
197
+ </section>
198
+
199
+ {#if showListOptions}
200
+ <section class="dropdown-list" class:upwards={showInTopPosition} use:autoDirection>
201
+ {#if noOptionsAvailable}
202
+ <p class="dropdown-list-no-options-message">No options</p>
203
+ {:else}
204
+ {#if searchable}
205
+ <Search
206
+ placeholder="Search"
207
+ bind:value={searchText}
208
+ oninput={onInputSearch}
209
+ onclear={onClearSearch}
210
+ />
211
+ {/if}
212
+ <ul class="dropdown-list-options-container">
213
+ {#each filteredOptions as filteredOption}
214
+ {@const isVisible = multiple ? canBeVisible(filteredOption) : true}
215
+ {@const isActive = !multiple && filteredOption.value === selectedOption?.value}
216
+
217
+ {#if isVisible}
218
+ <li
219
+ class={['dropdown-list-option', isActive && 'active']}
220
+ use:activeOptionScroll={!multiple && isActive}
221
+ >
222
+ {@render dropdownOptionTemplate(filteredOption)}
223
+ </li>
224
+ {/if}
225
+ {/each}
226
+ </ul>
227
+ {/if}
228
+ </section>
229
+ {/if}
230
+ </div>
231
+ {#if isInvalid}
232
+ <p class="form-message-error">
233
+ {Array.isArray(error) ? error[0] : error}
234
+ </p>
235
+ {/if}
236
+ </div>
237
+
238
+ {#snippet dropdownOptionTemplate(option: SelectOption)}
239
+ <button
240
+ type="button"
241
+ onclick={(event) => {
242
+ event.stopPropagation();
243
+ onSelectOption(option);
244
+ }}
245
+ >
246
+ {#if customOption}
247
+ {@render customOption(option)}
248
+ {:else}
249
+ {option.label}
250
+ {/if}
251
+ </button>
252
+ {/snippet}
253
+
254
+ {#snippet selectedOptionTemplate(option: SelectOption, index: number)}
255
+ <div class="dropdown-container-selected-option">
256
+ {#if customSelection}
257
+ <p>{@render customSelection(option)}</p>
258
+ {:else}
259
+ <p>{option.label}</p>
260
+ {/if}
261
+
262
+ {@render removeButton(() => onRemoveSelectedOption(index))}
263
+ </div>
264
+ {/snippet}
265
+
266
+ {#snippet removeButton(event: VoidFunction)}
267
+ <button
268
+ type="button"
269
+ class="clear-button"
270
+ onclick={(e) => {
271
+ e.stopPropagation();
272
+ event();
273
+ }}
274
+ >
275
+
276
+ </button>
277
+ {/snippet}
278
+
279
+ <style>
280
+ .form-container {
281
+ --primary-color: #472aff;
282
+ --white: #fff;
283
+ --black: #000;
284
+ --error: #dc2626;
285
+ --success: #10b981;
286
+ --info-1: #eaecff;
287
+ --gray-1: #f4f6f8;
288
+ --gray-2: #e0e5e8;
289
+ --gray-3: #aeb1b9;
290
+ --gray-4: #6b7180;
291
+ --border: 1px solid var(--gray-3);
292
+
293
+ display: flex;
294
+ flex-direction: column;
295
+ width: 100%;
296
+ gap: 8px;
297
+ font-size: 14px;
298
+ }
299
+
300
+ .form-container > .form-message-error {
301
+ font-size: 12px;
302
+ color: var(--error);
303
+ }
304
+
305
+ .dropdown {
306
+ position: relative;
307
+ border: var(--border);
308
+ border-radius: 8px;
309
+ background: transparent;
310
+ transition:
311
+ border-color 0.2s ease-in-out,
312
+ box-shadow 0.2s ease-in-out;
313
+ }
314
+
315
+ .dropdown.readonly {
316
+ cursor: not-allowed;
317
+
318
+ > .dropdown-container {
319
+ pointer-events: none;
320
+ background: var(--gray-2);
321
+ }
322
+ }
323
+
324
+ .dropdown:not(.readonly, .invalid, .valid):hover {
325
+ border: 1px solid var(--primary-color);
326
+ }
327
+
328
+ .dropdown:not(.readonly, .invalid, .valid):focus-within {
329
+ border: 1px solid var(--primary-color);
330
+ box-shadow: 0 0 0 3px rgba(149, 155, 255, 0.3);
331
+ }
332
+
333
+ .dropdown.valid {
334
+ border: 1px solid var(--success);
335
+ }
336
+
337
+ .dropdown.valid:focus-within {
338
+ box-shadow: 0px 0px 0px 3px rgba(16, 185, 129, 0.15);
339
+ }
340
+
341
+ .dropdown.invalid {
342
+ border-color: var(--error);
343
+ }
344
+
345
+ .dropdown.invalid:focus-within {
346
+ box-shadow: 0px 0px 0px 3px rgba(220, 38, 38, 0.2);
347
+ }
348
+
349
+ .dropdown > .dropdown-container {
350
+ display: grid;
351
+ grid-template-columns: 1fr auto;
352
+ min-height: 42px;
353
+ padding: 8px;
354
+ gap: 8px;
355
+ align-items: center;
356
+ cursor: pointer;
357
+ border: none;
358
+ border-radius: 8px;
359
+ background: var(--white);
360
+ }
361
+
362
+ .dropdown > .dropdown-container > .dropdown-container-selected-options {
363
+ display: flex;
364
+ flex-wrap: wrap;
365
+ max-height: 250px;
366
+ gap: 8px;
367
+ align-items: center;
368
+ overflow-y: auto;
369
+
370
+ > .placeholder {
371
+ color: var(--gray-4);
372
+ opacity: 1;
373
+ }
374
+
375
+ > .dropdown-container-selected-option {
376
+ display: grid;
377
+ grid-template-columns: auto auto;
378
+ padding: 8px;
379
+ gap: 8px;
380
+ align-items: center;
381
+ cursor: default;
382
+ border-radius: 8px;
383
+ background: var(--gray-1);
384
+ }
385
+ }
386
+
387
+ .dropdown > .dropdown-list {
388
+ position: absolute;
389
+ display: flex;
390
+ flex-direction: column;
391
+ z-index: 100;
392
+ width: 100%;
393
+ padding: 8px;
394
+ margin-top: 8px;
395
+ gap: 8px;
396
+ border-radius: 8px;
397
+ background: var(--white);
398
+ box-shadow:
399
+ 0px 4px 5px rgba(51, 56, 64, 0.15),
400
+ 0px 1px 3px rgba(51, 56, 64, 0.2),
401
+ 0px 1px 16px rgba(51, 56, 64, 0.1);
402
+
403
+ > .dropdown-list-no-options-message {
404
+ text-align: center;
405
+ color: var(--gray-4);
406
+ }
407
+ }
408
+
409
+ .dropdown > .dropdown-list.upwards {
410
+ top: auto;
411
+ transform-origin: bottom;
412
+ bottom: 100%;
413
+ margin-bottom: 8px;
414
+ }
415
+
416
+ .dropdown > .dropdown-list > .dropdown-list-options-container {
417
+ display: flex;
418
+ flex-direction: column;
419
+ max-height: 200px;
420
+ gap: 8px;
421
+ overflow-y: auto;
422
+
423
+ .dropdown-list-option {
424
+ > button {
425
+ display: flex;
426
+ align-items: center;
427
+ text-align: left;
428
+ width: 100%;
429
+ min-height: 40px;
430
+ padding: 0px 8px;
431
+ cursor: pointer;
432
+ border: none;
433
+ background: transparent;
434
+ }
435
+ }
436
+
437
+ .dropdown-list-option:not(.active) > button:hover {
438
+ background: var(--gray-1);
439
+ }
440
+
441
+ .dropdown-list-option.active {
442
+ background: var(--info-1);
443
+ }
444
+ }
445
+
446
+ .clear-button {
447
+ border: none;
448
+ color: var(--gray-4);
449
+ background: transparent;
450
+ transition: color 0.2s ease-in-out;
451
+ }
452
+
453
+ .clear-button:hover {
454
+ cursor: pointer;
455
+ color: var(--black);
456
+ }
457
+ </style>
@@ -0,0 +1,22 @@
1
+ import { type Snippet } from 'svelte';
2
+ import { type SelectOption } from '../../index.js';
3
+ interface SelectProps {
4
+ options: string[] | SelectOption[];
5
+ label?: string;
6
+ placeholder?: string;
7
+ required?: boolean;
8
+ optional?: boolean;
9
+ searchable?: boolean;
10
+ clearable?: boolean;
11
+ multiple?: boolean;
12
+ closeAfterSelect?: boolean;
13
+ readonly?: boolean;
14
+ disableValidationColor?: boolean;
15
+ value?: string | string[] | null;
16
+ error?: string | string[];
17
+ customSelection?: Snippet<[option: SelectOption]>;
18
+ customOption?: Snippet<[option: SelectOption]>;
19
+ }
20
+ declare const Select: import("svelte").Component<SelectProps, {}, "value">;
21
+ type Select = ReturnType<typeof Select>;
22
+ export default Select;
@@ -0,0 +1,4 @@
1
+ export interface SelectOption {
2
+ value: string;
3
+ label: string;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
+ import Label from '../Label.svelte';
3
4
 
4
5
  interface TextAreaProps extends Omit<HTMLTextareaAttributes, 'value'> {
5
6
  label?: string;
@@ -15,6 +16,7 @@
15
16
  label,
16
17
  value = $bindable(''),
17
18
  optional = false,
19
+ required = false,
18
20
  error,
19
21
  description,
20
22
  maximumCharactersAllowed,
@@ -36,16 +38,7 @@
36
38
  </script>
37
39
 
38
40
  <div class="form-container">
39
- {#if label}
40
- <div class="form-label-container">
41
- <label for={textareaId}>{label}</label>
42
- {#if props.required}
43
- <span class="form-label-required">Required</span>
44
- {:else if optional}
45
- <span class="form-label-optional">Optional</span>
46
- {/if}
47
- </div>
48
- {/if}
41
+ <Label {label} {id} {optional} {required} />
49
42
 
50
43
  <div class={['form-textarea-wrapper', isInvalid && 'error', isValid && 'success']}>
51
44
  <textarea
@@ -94,19 +87,6 @@
94
87
  line-height: 20px;
95
88
  }
96
89
 
97
- .form-label-container {
98
- display: flex;
99
- gap: 8px;
100
- font-weight: 500;
101
- }
102
-
103
- .form-label-optional {
104
- color: #6b7180;
105
- }
106
-
107
- .form-label-required {
108
- color: #dc2626;
109
- }
110
90
 
111
91
  .form-message {
112
92
  font-size: 12px;
@@ -9,7 +9,7 @@
9
9
  let {
10
10
  showModal = $bindable(false),
11
11
  title,
12
- width = 'md',
12
+ width = 'xs',
13
13
  errorIcon,
14
14
  onclose = () => {},
15
15
  children,
@@ -80,6 +80,10 @@
80
80
  width: var(--modal-width);
81
81
  }
82
82
 
83
+ .modal.xs {
84
+ --modal-width: 500px;
85
+ }
86
+
83
87
  .modal.md {
84
88
  --modal-width: 600px;
85
89
  }
@@ -12,4 +12,4 @@ export interface ModalHeaderProps {
12
12
  export interface ModalFooterProps {
13
13
  footer?: Snippet;
14
14
  }
15
- export type WidthModal = 'md' | 'lg' | 'xl';
15
+ export type WidthModal = 'xs' | 'md' | 'lg' | 'xl';
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import type { NotificationProps } from './notificationState.svelte';
3
+
4
+ let { title, disableBorder = false, content, type }: NotificationProps = $props();
5
+ </script>
6
+
7
+ <aside class="notification-container" class:border={!disableBorder}>
8
+ <span class="status-indicator {type}"></span>
9
+ <div class="notification-content">
10
+ {#if title}
11
+ <span class="title">{title}</span>
12
+ {/if}
13
+ {@render content()}
14
+ </div>
15
+ </aside>
16
+
17
+ <style>
18
+ .notification-container {
19
+ width: 100%;
20
+ height: 100%;
21
+ padding: 16px;
22
+ gap: 16px;
23
+ display: flex;
24
+ }
25
+
26
+ .border {
27
+ border: 1px solid #e0e5e8;
28
+ border-radius: 8px;
29
+ }
30
+
31
+ .status-indicator {
32
+ width: 8px;
33
+ border-radius: 4px;
34
+ background-color: var(--toast-bg);
35
+ }
36
+
37
+ .notification-content {
38
+ flex-direction: column;
39
+ flex: 1 1 0;
40
+ gap: 4px;
41
+ display: flex;
42
+ font-size: 14px;
43
+ line-height: 20px;
44
+ }
45
+
46
+ .title {
47
+ font-weight: 700;
48
+ }
49
+
50
+ .status-indicator.info {
51
+ --toast-bg: #472aff;
52
+ }
53
+
54
+ .status-indicator.warning {
55
+ --toast-bg: #e87d1e;
56
+ }
57
+
58
+ .status-indicator.danger {
59
+ --toast-bg: #dc182c;
60
+ }
61
+
62
+ .status-indicator.neutral {
63
+ --toast-bg: #6b7180;
64
+ }
65
+
66
+ .status-indicator.success {
67
+ --toast-bg: #008556;
68
+ }
69
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { NotificationProps } from './notificationState.svelte';
2
+ declare const Notification: import("svelte").Component<NotificationProps, {}, "">;
3
+ type Notification = ReturnType<typeof Notification>;
4
+ export default Notification;
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ type NotificationType = 'info' | 'success' | 'warning' | 'danger' | 'neutral';
3
+ export interface NotificationProps {
4
+ title?: string;
5
+ type: NotificationType;
6
+ disableBorder?: boolean;
7
+ content: Snippet;
8
+ }
9
+ export {};
@@ -0,0 +1 @@
1
+ export {};