@makolabs/ripple 2.2.0 → 2.4.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.
Files changed (39) hide show
  1. package/dist/charts/Chart.svelte +307 -53
  2. package/dist/charts/chart-types.d.ts +12 -0
  3. package/dist/elements/accordion/Accordion.svelte +27 -10
  4. package/dist/elements/accordion/accordion-types.d.ts +6 -0
  5. package/dist/elements/dropdown/Select.svelte +191 -63
  6. package/dist/elements/dropdown/dropdown-types.d.ts +13 -1
  7. package/dist/elements/dropdown/select.d.ts +15 -0
  8. package/dist/elements/dropdown/select.js +14 -8
  9. package/dist/elements/file-upload/FileUpload.svelte +72 -14
  10. package/dist/forms/DateRange.svelte +16 -3
  11. package/dist/forms/Input.svelte +6 -4
  12. package/dist/forms/MarketSelector.svelte +7 -27
  13. package/dist/forms/NumberInput.svelte +9 -6
  14. package/dist/forms/SegmentedControl.svelte +5 -18
  15. package/dist/forms/Tags.svelte +1 -1
  16. package/dist/forms/form-types.d.ts +2 -31
  17. package/dist/forms/market/market-selector-types.d.ts +1 -21
  18. package/dist/forms/segmented-control.d.ts +4 -34
  19. package/dist/forms/segmented-control.js +19 -59
  20. package/dist/index.d.ts +3 -9
  21. package/dist/index.js +0 -6
  22. package/dist/variants.js +6 -6
  23. package/package.json +1 -1
  24. package/dist/elements/collapsible/Collapsible.svelte +0 -79
  25. package/dist/elements/collapsible/Collapsible.svelte.d.ts +0 -4
  26. package/dist/elements/collapsible/CollapsibleTestWrapper.svelte +0 -23
  27. package/dist/elements/collapsible/CollapsibleTestWrapper.svelte.d.ts +0 -8
  28. package/dist/elements/collapsible/collapsible-types.d.ts +0 -16
  29. package/dist/elements/collapsible/collapsible-types.js +0 -1
  30. package/dist/elements/combobox/Combobox.svelte +0 -274
  31. package/dist/elements/combobox/Combobox.svelte.d.ts +0 -25
  32. package/dist/elements/combobox/ComboboxTestWrapper.svelte +0 -38
  33. package/dist/elements/combobox/ComboboxTestWrapper.svelte.d.ts +0 -4
  34. package/dist/elements/combobox/combobox-types.d.ts +0 -39
  35. package/dist/elements/combobox/combobox-types.js +0 -1
  36. package/dist/forms/RadioInputs.svelte +0 -73
  37. package/dist/forms/RadioInputs.svelte.d.ts +0 -4
  38. package/dist/forms/RadioPill.svelte +0 -66
  39. package/dist/forms/RadioPill.svelte.d.ts +0 -4
@@ -23,10 +23,17 @@
23
23
  searchInputClass = '',
24
24
  icon: Icon,
25
25
  iconClass = '',
26
- triggerClass = '', // recently, just now
26
+ triggerClass = '',
27
+ errors = [],
27
28
  onselect = () => {},
28
29
  onopen = () => {},
29
30
  onclose = () => {},
31
+ onsearch,
32
+ debounceMs = 200,
33
+ minSearchLength = 0,
34
+ loadingText = 'Loading...',
35
+ emptyText = 'No items found',
36
+ itemSnippet,
30
37
  testId
31
38
  }: SelectProps = $props();
32
39
 
@@ -35,10 +42,17 @@
35
42
  let labelRef = $state<HTMLLabelElement | null>(null);
36
43
  let searchInputRef = $state<HTMLInputElement | null>(null);
37
44
  let highlightedIndex = $state(-1);
45
+ let asyncResults = $state<SelectItem[]>([]);
46
+ let asyncLoading = $state(false);
47
+ let hasSearched = $state(false);
48
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
49
+ let searchSeq = 0;
50
+
51
+ const isAsync = $derived(!!onsearch);
52
+ const isSearchable = $derived(searchable || isAsync);
38
53
 
39
54
  const selectId = crypto.randomUUID().slice(0, 8);
40
55
 
41
- // Convert value to array for internal processing if multiple is true
42
56
  const valueArray = $derived.by(() => {
43
57
  if (multiple) {
44
58
  return Array.isArray(value) ? value : value ? [value] : [];
@@ -50,7 +64,8 @@
50
64
  selectTV({
51
65
  size,
52
66
  disabled,
53
- multiple
67
+ multiple,
68
+ error: !!errors?.length
54
69
  })
55
70
  );
56
71
 
@@ -63,25 +78,86 @@
63
78
  const itemClass_ = $derived(cn(item(), itemClass));
64
79
  const emptyMessageClass = $derived(cn(emptyMessage()));
65
80
 
66
- const selectedItem = $derived(items.find((item) => item.value === value));
67
- const selectedItems = $derived(items.filter((item) => valueArray.includes(item.value)));
68
-
69
- const filteredItems = $derived(
70
- searchable && searchQuery
71
- ? items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase()))
72
- : items
81
+ const selectedItem = $derived(
82
+ (isAsync ? asyncResults : items).find((i) => i.value === value) ??
83
+ items.find((i) => i.value === value)
73
84
  );
85
+ const selectedItems = $derived(items.filter((i) => valueArray.includes(i.value)));
86
+
87
+ const filteredItems = $derived.by<SelectItem[]>(() => {
88
+ if (isAsync) return asyncResults;
89
+ if (isSearchable && searchQuery) {
90
+ return items.filter(
91
+ (i) =>
92
+ i.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
93
+ (i.description ?? '').toLowerCase().includes(searchQuery.toLowerCase())
94
+ );
95
+ }
96
+ return items;
97
+ });
98
+
99
+ const groupedItems = $derived.by(() => {
100
+ const hasGroups = filteredItems.some((i) => i.group);
101
+ if (!hasGroups) return null;
102
+ const groups: Array<{ label: string | null; items: SelectItem[] }> = [];
103
+ const idxMap: Record<string, number> = {};
104
+ let nullIdx = -1;
105
+ for (const i of filteredItems) {
106
+ const key = i.group;
107
+ let idx: number;
108
+ if (!key) {
109
+ if (nullIdx === -1) {
110
+ nullIdx = groups.length;
111
+ groups.push({ label: null, items: [] });
112
+ }
113
+ idx = nullIdx;
114
+ } else {
115
+ idx = idxMap[key] ?? -1;
116
+ if (idx === -1) {
117
+ idx = groups.length;
118
+ idxMap[key] = idx;
119
+ groups.push({ label: key, items: [] });
120
+ }
121
+ }
122
+ groups[idx].items.push(i);
123
+ }
124
+ return groups;
125
+ });
126
+
127
+ $effect(() => {
128
+ if (!isAsync || !onsearch || !hasSearched) return;
129
+ const query = searchQuery;
130
+ clearTimeout(debounceTimer);
131
+ if (query.length < minSearchLength) {
132
+ ++searchSeq;
133
+ asyncResults = [];
134
+ asyncLoading = false;
135
+ return;
136
+ }
137
+ debounceTimer = setTimeout(async () => {
138
+ const seq = ++searchSeq;
139
+ asyncLoading = true;
140
+ try {
141
+ const fetched = await onsearch(query);
142
+ if (seq !== searchSeq) return;
143
+ asyncResults = fetched;
144
+ } finally {
145
+ if (seq === searchSeq) asyncLoading = false;
146
+ }
147
+ }, debounceMs);
148
+ return () => clearTimeout(debounceTimer);
149
+ });
74
150
 
75
151
  function handleToggle() {
76
152
  if (disabled) return;
77
153
  open = !open;
78
154
 
79
155
  if (open) {
80
- highlightedIndex = !multiple ? filteredItems.findIndex((item) => item.value === value) : -1;
156
+ highlightedIndex = !multiple ? filteredItems.findIndex((i) => i.value === value) : -1;
81
157
 
82
158
  onopen();
83
159
 
84
- if (searchable) {
160
+ if (isSearchable) {
85
161
  tick().then(() => {
86
162
  searchInputRef?.focus();
87
163
  });
@@ -89,6 +165,7 @@
89
165
  } else {
90
166
  onclose();
91
167
  searchQuery = '';
168
+ hasSearched = false;
92
169
  }
93
170
  }
94
171
 
@@ -107,7 +184,7 @@
107
184
  }
108
185
 
109
186
  // Keep dropdown open when multiple selection is enabled
110
- if (searchable && searchInputRef) {
187
+ if (isSearchable && searchInputRef) {
111
188
  searchInputRef.focus();
112
189
  }
113
190
  } else {
@@ -196,6 +273,69 @@
196
273
  }
197
274
  </script>
198
275
 
276
+ {#snippet itemButton(
277
+ selectItem: SelectItem,
278
+ index: number,
279
+ selected: boolean,
280
+ highlighted: boolean
281
+ )}
282
+ <li>
283
+ <button
284
+ type="button"
285
+ onclick={(event) => {
286
+ handleSelect(selectItem);
287
+ event.preventDefault();
288
+ }}
289
+ disabled={selectItem.disabled}
290
+ class={itemClass_}
291
+ role="option"
292
+ aria-selected={selected}
293
+ data-selected={selected}
294
+ data-highlighted={highlighted}
295
+ data-index={index}
296
+ data-testid={buildTestId('select', 'option', testId, index)}
297
+ >
298
+ {#if itemSnippet}
299
+ {@render itemSnippet(selectItem, { highlighted, selected })}
300
+ {:else}
301
+ <span class="flex w-full items-center justify-between">
302
+ <span class="flex items-center gap-2 overflow-hidden">
303
+ {#if selectItem.icon}
304
+ {@const ItemIcon = selectItem.icon}
305
+ <ItemIcon class="h-4 w-4 flex-shrink-0" />
306
+ {/if}
307
+ <span class="min-w-0">
308
+ <span class="block truncate">{selectItem.label}</span>
309
+ {#if selectItem.description}
310
+ <span class="text-default-500 block truncate text-xs">
311
+ {selectItem.description}
312
+ </span>
313
+ {/if}
314
+ </span>
315
+ </span>
316
+
317
+ {#if selected}
318
+ <svg
319
+ xmlns="http://www.w3.org/2000/svg"
320
+ width="16"
321
+ height="16"
322
+ viewBox="0 0 24 24"
323
+ fill="none"
324
+ stroke="currentColor"
325
+ stroke-width="2"
326
+ stroke-linecap="round"
327
+ stroke-linejoin="round"
328
+ class="text-info-500 flex-shrink-0"
329
+ >
330
+ <polyline points="20 6 9 17 4 12" />
331
+ </svg>
332
+ {/if}
333
+ </span>
334
+ {/if}
335
+ </button>
336
+ </li>
337
+ {/snippet}
338
+
199
339
  <svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
200
340
 
201
341
  <label
@@ -228,7 +368,7 @@
228
368
  {selectedItem.label}
229
369
  </span>
230
370
  {:else}
231
- <span id="{selectId}-label" class="text-default-500 px-1">
371
+ <span id="{selectId}-label" class="text-default-400 px-1">
232
372
  {placeholder}
233
373
  </span>
234
374
  {/if}
@@ -254,6 +394,12 @@
254
394
  </span>
255
395
  </label>
256
396
 
397
+ {#if errors?.length}
398
+ {#each errors as error, i (i)}
399
+ <p class="text-danger-600 mt-1 text-sm">{error}</p>
400
+ {/each}
401
+ {/if}
402
+
257
403
  {#if open}
258
404
  <Portal target={labelRef}>
259
405
  <div
@@ -262,7 +408,7 @@
262
408
  aria-labelledby="{selectId}-label"
263
409
  data-testid={buildTestId('select', 'list', testId)}
264
410
  >
265
- {#if searchable}
411
+ {#if isSearchable}
266
412
  <div class={searchInputClass_}>
267
413
  <svg
268
414
  xmlns="http://www.w3.org/2000/svg"
@@ -280,63 +426,45 @@
280
426
  bind:this={searchInputRef}
281
427
  bind:value={searchQuery}
282
428
  type="text"
283
- class="ring-0 outline-0"
429
+ class="w-full ring-0 outline-0"
284
430
  placeholder="Search..."
285
431
  aria-label="Search select options"
432
+ oninput={() => (hasSearched = true)}
286
433
  data-testid={buildTestId('select', 'search', testId)}
287
434
  />
288
435
  </div>
289
436
  {/if}
290
437
 
291
- {#if filteredItems.length === 0}
292
- <div class={emptyMessageClass}>No items found</div>
293
- {:else}
438
+ {#if asyncLoading}
439
+ <div class={emptyMessageClass} data-select-loading="">{loadingText}</div>
440
+ {:else if filteredItems.length === 0}
441
+ <div class={emptyMessageClass}>{emptyText}</div>
442
+ {:else if groupedItems}
294
443
  <ul class={listClass_}>
295
- {#each filteredItems as item, index (item.value)}
296
- <li>
297
- <button
298
- type="button"
299
- onclick={(event) => {
300
- handleSelect(item);
301
- event.preventDefault();
302
- }}
303
- disabled={item.disabled}
304
- class={itemClass_}
305
- role="option"
306
- aria-selected={valueArray.includes(item.value)}
307
- data-selected={valueArray.includes(item.value)}
308
- data-highlighted={index === highlightedIndex}
309
- data-index={index}
310
- data-testid={buildTestId('select', 'option', testId, index)}
444
+ {#each groupedItems as group, gIdx (group.label ?? `__null__${gIdx}`)}
445
+ {#if group.label !== null}
446
+ <li
447
+ class="text-default-500 bg-default-50 px-3 py-1.5 text-xs font-semibold tracking-wide uppercase"
448
+ role="presentation"
449
+ data-select-group={group.label}
311
450
  >
312
- <span class="flex w-full items-center justify-between">
313
- <span class="flex items-center gap-2 overflow-hidden">
314
- {#if item.icon}
315
- {@const Icon = item.icon}
316
- <Icon class="h-4 w-4 flex-shrink-0" />
317
- {/if}
318
- <span class="truncate">{item.label}</span>
319
- </span>
320
-
321
- {#if valueArray.includes(item.value)}
322
- <svg
323
- xmlns="http://www.w3.org/2000/svg"
324
- width="16"
325
- height="16"
326
- viewBox="0 0 24 24"
327
- fill="none"
328
- stroke="currentColor"
329
- stroke-width="2"
330
- stroke-linecap="round"
331
- stroke-linejoin="round"
332
- class="text-info-500"
333
- >
334
- <polyline points="20 6 9 17 4 12" />
335
- </svg>
336
- {/if}
337
- </span>
338
- </button>
339
- </li>
451
+ {group.label}
452
+ </li>
453
+ {/if}
454
+ {#each group.items as groupItem (groupItem.value)}
455
+ {@const flatIdx = filteredItems.indexOf(groupItem)}
456
+ {@const selected = valueArray.includes(groupItem.value)}
457
+ {@const highlighted = flatIdx === highlightedIndex}
458
+ {@render itemButton(groupItem, flatIdx, selected, highlighted)}
459
+ {/each}
460
+ {/each}
461
+ </ul>
462
+ {:else}
463
+ <ul class={listClass_}>
464
+ {#each filteredItems as flatItem, index (flatItem.value)}
465
+ {@const selected = valueArray.includes(flatItem.value)}
466
+ {@const highlighted = index === highlightedIndex}
467
+ {@render itemButton(flatItem, index, selected, highlighted)}
340
468
  {/each}
341
469
  </ul>
342
470
  {/if}
@@ -41,9 +41,11 @@ export type SelectItem = {
41
41
  value: string;
42
42
  disabled?: boolean;
43
43
  icon?: Component;
44
+ description?: string;
45
+ group?: string;
44
46
  };
45
47
  export type SelectProps = {
46
- items: SelectItem[];
48
+ items?: SelectItem[];
47
49
  value?: string | string[];
48
50
  multiple?: boolean;
49
51
  placeholder?: string;
@@ -59,10 +61,20 @@ export type SelectProps = {
59
61
  clearable?: boolean;
60
62
  icon?: Component;
61
63
  iconClass?: ClassValue;
64
+ errors?: string[];
62
65
  onselect?: ({ value }: {
63
66
  value: string | string[];
64
67
  }) => void;
65
68
  onopen?: () => void;
66
69
  onclose?: () => void;
67
70
  testId?: string;
71
+ onsearch?: (query: string) => SelectItem[] | Promise<SelectItem[]>;
72
+ debounceMs?: number;
73
+ minSearchLength?: number;
74
+ loadingText?: string;
75
+ emptyText?: string;
76
+ itemSnippet?: Snippet<[SelectItem, {
77
+ highlighted: boolean;
78
+ selected: boolean;
79
+ }]>;
68
80
  };
@@ -50,6 +50,11 @@ export declare const selectTV: import("tailwind-variants").TVReturnType<{
50
50
  item: string;
51
51
  };
52
52
  };
53
+ error: {
54
+ true: {
55
+ trigger: string;
56
+ };
57
+ };
53
58
  multiple: {
54
59
  true: {
55
60
  trigger: string;
@@ -116,6 +121,11 @@ export declare const selectTV: import("tailwind-variants").TVReturnType<{
116
121
  item: string;
117
122
  };
118
123
  };
124
+ error: {
125
+ true: {
126
+ trigger: string;
127
+ };
128
+ };
119
129
  multiple: {
120
130
  true: {
121
131
  trigger: string;
@@ -182,6 +192,11 @@ export declare const selectTV: import("tailwind-variants").TVReturnType<{
182
192
  item: string;
183
193
  };
184
194
  };
195
+ error: {
196
+ true: {
197
+ trigger: string;
198
+ };
199
+ };
185
200
  multiple: {
186
201
  true: {
187
202
  trigger: string;
@@ -2,7 +2,7 @@ import { tv } from 'tailwind-variants';
2
2
  import { Size } from '../../variants.js';
3
3
  export const selectTV = tv({
4
4
  slots: {
5
- base: '',
5
+ base: 'w-full',
6
6
  trigger: `relative flex items-center justify-between w-full text-left bg-white border
7
7
  border-default-300 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:border-primary-500 focus-within:ring-primary-500 rounded-lg shadow-xs cursor-pointer transition-colors hover:border-default-400`,
8
8
  triggerIcon: 'transition-transform duration-200 text-default-500',
@@ -23,42 +23,42 @@ export const selectTV = tv({
23
23
  triggerIcon: 'h-3 w-3',
24
24
  container: 'max-h-40',
25
25
  item: 'px-2 py-1 text-xs',
26
- base: 'w-24'
26
+ base: 'min-w-24'
27
27
  },
28
28
  [Size.SM]: {
29
29
  trigger: 'h-8 px-3 py-2 text-sm gap-1.5',
30
30
  triggerIcon: 'h-3.5 w-3.5',
31
31
  container: 'max-h-48',
32
32
  item: 'px-2.5 py-1.5 text-xs',
33
- base: 'w-32'
33
+ base: 'min-w-32'
34
34
  },
35
35
  [Size.BASE]: {
36
36
  trigger: 'h-10 px-3 py-2 text-base gap-2',
37
37
  triggerIcon: 'h-4 w-4',
38
38
  container: 'max-h-60',
39
39
  item: 'px-3 py-2 text-sm',
40
- base: 'w-40'
40
+ base: 'min-w-40'
41
41
  },
42
42
  [Size.LG]: {
43
43
  trigger: 'h-12 px-3 py-2 text-lg gap-2.5',
44
44
  triggerIcon: 'h-5 w-5',
45
45
  container: 'max-h-72',
46
46
  item: 'px-4 py-2.5 text-base',
47
- base: 'w-48'
47
+ base: 'min-w-48'
48
48
  },
49
49
  [Size.XL]: {
50
50
  trigger: 'h-12 px-5 py-3 text-lg gap-3',
51
51
  triggerIcon: 'h-6 w-6',
52
52
  container: 'max-h-80',
53
53
  item: 'px-5 py-3 text-lg',
54
- base: 'w-56'
54
+ base: 'min-w-56'
55
55
  },
56
56
  [Size.XXL]: {
57
57
  trigger: 'h-14 px-6 py-3.5 text-xl gap-4',
58
58
  triggerIcon: 'h-7 w-7',
59
59
  container: 'max-h-96',
60
60
  item: 'px-6 py-3.5 text-xl',
61
- base: 'w-64'
61
+ base: 'min-w-64'
62
62
  }
63
63
  },
64
64
  disabled: {
@@ -68,6 +68,11 @@ export const selectTV = tv({
68
68
  item: 'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent'
69
69
  }
70
70
  },
71
+ error: {
72
+ true: {
73
+ trigger: 'border-danger-300 focus-within:ring-danger-500 focus-within:border-danger-500'
74
+ }
75
+ },
71
76
  multiple: {
72
77
  true: {
73
78
  trigger: 'flex-wrap min-h-[2.5rem]'
@@ -77,6 +82,7 @@ export const selectTV = tv({
77
82
  defaultVariants: {
78
83
  size: 'base',
79
84
  disabled: false,
80
- multiple: false
85
+ multiple: false,
86
+ error: false
81
87
  }
82
88
  });
@@ -40,6 +40,8 @@
40
40
  const dropzoneEnabled = $derived(!disabled && !dropzoneFull);
41
41
 
42
42
  let isDragging = $state(false);
43
+ let truncationMessage = $state('');
44
+ let truncationTimer: ReturnType<typeof setTimeout> | undefined;
43
45
  let inputRef: HTMLInputElement;
44
46
 
45
47
  function makeId(): string {
@@ -60,6 +62,18 @@
60
62
  // Multi-file mode: cap and stage
61
63
  const room = Math.max(0, maxFiles - files.length);
62
64
  const accepted = arr.slice(0, room);
65
+ const dropped = arr.length - accepted.length;
66
+
67
+ if (dropped > 0) {
68
+ clearTimeout(truncationTimer);
69
+ truncationMessage = `${dropped} file${dropped > 1 ? 's' : ''} could not be added — maximum of ${maxFiles} reached.`;
70
+ truncationTimer = setTimeout(() => {
71
+ truncationMessage = '';
72
+ }, 5000);
73
+ } else {
74
+ truncationMessage = '';
75
+ }
76
+
63
77
  const toAdd: StagedFile[] = accepted.map((file) => ({
64
78
  id: makeId(),
65
79
  file,
@@ -70,10 +84,12 @@
70
84
 
71
85
  function handleRemove(fileId: string) {
72
86
  files = files.filter((f) => f.id !== fileId);
87
+ truncationMessage = '';
73
88
  }
74
89
 
75
90
  function handleClearAll() {
76
91
  files = [];
92
+ truncationMessage = '';
77
93
  }
78
94
 
79
95
  const readyCount = $derived(files.filter((f) => !f.status || f.status === 'ready').length);
@@ -167,6 +183,7 @@
167
183
  ondragover={handleDragOver}
168
184
  ondrop={handleDrop}
169
185
  for={id}
186
+ data-dropzone-full={dropzoneFull ? 'true' : undefined}
170
187
  >
171
188
  <input
172
189
  type="file"
@@ -190,34 +207,75 @@
190
207
  </div>
191
208
 
192
209
  <div class={slots.textBlock()}>
193
- {#if !uploadContent}
210
+ {#if dropzoneFull}
211
+ <div class={slots.mainText()}>
212
+ <span class="text-default-500 font-medium">Maximum of {maxFiles} files reached.</span>
213
+ </div>
214
+ <div class={slots.hintText()}>Remove a file to add more.</div>
215
+ {:else if !uploadContent}
194
216
  <div class={slots.mainText()}>
195
217
  <span class="text-primary-500 font-medium">Click here</span>
196
218
  <span class="text-default-600"> to upload your file or drag and drop.</span>
197
219
  </div>
220
+ <div class={slots.hintText()}>
221
+ Supported Format: {allowedMimeTypes.length
222
+ ? allowedMimeTypes.join(', ')
223
+ : 'SVG, JPG, PNG'}
224
+ {#if maxSize}
225
+ ({formatFileSize(maxSize)} each)
226
+ {:else}
227
+ (10MB each)
228
+ {/if}
229
+ </div>
198
230
  {:else}
199
231
  {@render uploadContent()}
232
+ <div class={slots.hintText()}>
233
+ Supported Format: {allowedMimeTypes.length
234
+ ? allowedMimeTypes.join(', ')
235
+ : 'SVG, JPG, PNG'}
236
+ {#if maxSize}
237
+ ({formatFileSize(maxSize)} each)
238
+ {:else}
239
+ (10MB each)
240
+ {/if}
241
+ </div>
200
242
  {/if}
201
-
202
- <div class={slots.hintText()}>
203
- Supported Format: {allowedMimeTypes.length
204
- ? allowedMimeTypes.join(', ')
205
- : 'SVG, JPG, PNG'}
206
- {#if maxSize}
207
- ({formatFileSize(maxSize)} each)
208
- {:else}
209
- (10MB each)
210
- {/if}
211
- </div>
212
243
  </div>
213
244
  </div>
214
245
  </label>
215
246
 
247
+ {#if truncationMessage}
248
+ <div
249
+ class="text-warning-700 bg-warning-50 border-warning-200 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs"
250
+ data-fileupload-truncation-warning=""
251
+ >
252
+ <svg
253
+ class="text-warning-500 size-4 shrink-0"
254
+ fill="none"
255
+ viewBox="0 0 24 24"
256
+ stroke="currentColor"
257
+ >
258
+ <path
259
+ stroke-linecap="round"
260
+ stroke-linejoin="round"
261
+ stroke-width="2"
262
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
263
+ />
264
+ </svg>
265
+ {truncationMessage}
266
+ </div>
267
+ {/if}
268
+
216
269
  {#if isMulti && hasAnyFile}
217
270
  <div class="flex flex-col gap-2">
218
271
  <div class="flex items-center justify-between">
219
- <span class="text-default-700 text-sm font-medium">
220
- {filesListLabel} ({files.length})
272
+ <span
273
+ class={cn('text-sm font-medium', dropzoneFull ? 'text-warning-600' : 'text-default-700')}
274
+ >
275
+ {filesListLabel} ({files.length}/{maxFiles})
276
+ {#if dropzoneFull}
277
+ <span class="text-warning-500 ml-1 text-xs font-normal">— full</span>
278
+ {/if}
221
279
  </span>
222
280
  <div class="flex gap-2">
223
281
  <Button
@@ -14,6 +14,7 @@
14
14
  startLabel = 'Start date',
15
15
  endLabel = 'End date',
16
16
  format = 'MM/dd/yyyy',
17
+ errors = [],
17
18
  id,
18
19
  name,
19
20
  onselect
@@ -236,14 +237,18 @@
236
237
  {id}
237
238
  type="button"
238
239
  class={cn(
239
- 'border-default-300 flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm shadow-sm',
240
+ 'border-default-300 flex w-full items-center justify-between rounded-lg border bg-white px-3 py-2 text-sm shadow-xs',
240
241
  disabled
241
- ? 'bg-default-100 text-default-400 cursor-not-allowed'
242
- : 'focus:border-primary-500 focus:ring-primary-500 hover:border-default-400 focus:ring-2'
242
+ ? 'bg-default-100 text-default-400 cursor-not-allowed opacity-50'
243
+ : errors?.length
244
+ ? 'border-danger-300 focus-within:ring-danger-500 focus-within:border-danger-500 focus-within:ring-2'
245
+ : 'focus-visible:border-primary-500 focus-visible:ring-primary-500 hover:border-default-400 focus-visible:ring-2'
243
246
  )}
244
247
  onclick={toggleDatepicker}
245
248
  aria-haspopup="true"
246
249
  aria-expanded={isOpen}
250
+ aria-invalid={errors?.length ? 'true' : undefined}
251
+ aria-describedby={errors?.length ? `${id}-errors` : undefined}
247
252
  {disabled}
248
253
  >
249
254
  <span class={startDate && endDate ? 'text-default-900' : 'text-default-500'}>
@@ -280,6 +285,14 @@
280
285
  {/if}
281
286
  </div>
282
287
 
288
+ {#if errors?.length}
289
+ <div id="{id}-errors">
290
+ {#each errors as error, i (i)}
291
+ <p class="text-danger-600 mt-1 text-sm">{error}</p>
292
+ {/each}
293
+ </div>
294
+ {/if}
295
+
283
296
  {#if isOpen}
284
297
  <Portal target={datePickerRef}>
285
298
  <div