@makolabs/ripple 3.0.1 → 3.0.2

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.
@@ -6,6 +6,7 @@
6
6
  import Portal from '../utils/Portal.svelte';
7
7
  import { fly } from 'svelte/transition';
8
8
  import { quintOut } from 'svelte/easing';
9
+ import { onMount } from 'svelte';
9
10
 
10
11
  let {
11
12
  startDate = $bindable(),
@@ -27,6 +28,16 @@
27
28
 
28
29
  const tokens = $derived(formSizeTokens[size]);
29
30
 
31
+ let isMobile = $state(
32
+ typeof window !== 'undefined' && window.matchMedia('(max-width: 639.98px)').matches
33
+ );
34
+ onMount(() => {
35
+ const mql = window.matchMedia('(max-width: 639.98px)');
36
+ const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
37
+ mql.addEventListener('change', handler);
38
+ return () => mql.removeEventListener('change', handler);
39
+ });
40
+
30
41
  let isOpen = $state(false);
31
42
  let hoveredDate = $state<Date | null>(null);
32
43
  let datePickerRef = $state<HTMLDivElement | null>(null);
@@ -309,213 +320,219 @@
309
320
  </div>
310
321
  {/if}
311
322
 
312
- {#if isOpen}
323
+ {#if isOpen && !isMobile}
313
324
  <Portal target={datePickerRef}>
314
325
  <div
315
326
  bind:this={calendarRef}
316
327
  class="ring-opacity-5 ring-default-300 absolute z-10 mt-1 w-full origin-top-left rounded-md bg-white p-4 shadow-lg ring-1 focus:outline-none"
317
328
  transition:fly={{ y: -8, duration: 300, easing: quintOut }}
318
329
  >
319
- <div class="mb-2 flex items-center justify-between">
320
- {#if viewMode === 'days'}
321
- <button
322
- type="button"
323
- aria-label="Previous month"
324
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
325
- onclick={prevMonth}
326
- >
327
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
328
- <path
329
- fill-rule="evenodd"
330
- d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
331
- clip-rule="evenodd"
332
- />
333
- </svg>
334
- </button>
335
- <button
336
- type="button"
337
- class="text-default-700 hover:bg-default-100 inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
338
- onclick={showMonths}
339
- >
340
- {getMonthName(viewDate.getMonth())}
341
- {viewDate.getFullYear()}
342
- </button>
343
- <button
344
- type="button"
345
- aria-label="Next month"
346
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
347
- onclick={nextMonth}
348
- >
349
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
350
- <path
351
- fill-rule="evenodd"
352
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
353
- clip-rule="evenodd"
354
- />
355
- </svg>
356
- </button>
357
- {:else if viewMode === 'months'}
358
- <button
359
- type="button"
360
- aria-label="Previous year"
361
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
362
- onclick={() =>
363
- (viewDate = new Date(viewDate.getFullYear() - 1, viewDate.getMonth(), 1))}
364
- >
365
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
366
- <path
367
- fill-rule="evenodd"
368
- d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
369
- clip-rule="evenodd"
370
- />
371
- </svg>
372
- </button>
373
- <button
374
- type="button"
375
- aria-label="Current year"
376
- class="text-default-700 inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
377
- onclick={showYears}
378
- >
379
- {viewDate.getFullYear()}
380
- </button>
381
- <button
382
- type="button"
383
- aria-label="Next year"
384
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
385
- onclick={() =>
386
- (viewDate = new Date(viewDate.getFullYear() + 1, viewDate.getMonth(), 1))}
387
- >
388
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
389
- <path
390
- fill-rule="evenodd"
391
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
392
- clip-rule="evenodd"
393
- />
394
- </svg>
395
- </button>
396
- {:else if viewMode === 'years'}
397
- <button
398
- type="button"
399
- aria-label="Previous year"
400
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
401
- onclick={() =>
402
- (viewDate = new Date(viewDate.getFullYear() - 12, viewDate.getMonth(), 1))}
403
- >
404
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
405
- <path
406
- fill-rule="evenodd"
407
- d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
408
- clip-rule="evenodd"
409
- />
410
- </svg>
411
- </button>
412
- <button
413
- type="button"
414
- aria-label="Current year range"
415
- class="text-default-700 inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
416
- >
417
- {viewDate.getFullYear() - 6} - {viewDate.getFullYear() + 5}
418
- </button>
419
- <button
420
- type="button"
421
- aria-label="Next year"
422
- class="text-default-500 hover:bg-default-100 inline-flex items-center rounded-md p-1 text-sm"
423
- onclick={() =>
424
- (viewDate = new Date(viewDate.getFullYear() + 12, viewDate.getMonth(), 1))}
425
- >
426
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
427
- <path
428
- fill-rule="evenodd"
429
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
430
- clip-rule="evenodd"
431
- />
432
- </svg>
433
- </button>
434
- {/if}
435
- </div>
436
-
437
- {#if viewMode === 'days'}
438
- <div class="text-default-500 mb-1 grid grid-cols-7 gap-1 text-center text-xs font-medium">
439
- <div>Su</div>
440
- <div>Mo</div>
441
- <div>Tu</div>
442
- <div>We</div>
443
- <div>Th</div>
444
- <div>Fr</div>
445
- <div>Sa</div>
446
- </div>
447
- <div class="grid grid-cols-7 gap-1">
448
- {#each getDaysInMonth(viewDate) as { date, isCurrentMonth, isToday, isSelected, isInRange, isDisabled } (date.getTime())}
449
- <button
450
- type="button"
451
- class={cn(
452
- 'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
453
- isDisabled ? 'text-default-300 cursor-not-allowed' : 'hover:bg-default-100',
454
- isSelected ? 'bg-primary-500 hover:bg-primary-600 text-white' : '',
455
- isInRange && !isSelected ? 'bg-primary-100 text-primary-800' : '',
456
- !isCurrentMonth && !isSelected && !isInRange ? 'text-default-400' : '',
457
- isToday && !isSelected ? 'border-primary-500 border' : ''
458
- )}
459
- onclick={() => handleDateClick(date)}
460
- onmouseenter={() => handleDateHover(date)}
461
- disabled={isDisabled}
462
- >
463
- {date.getDate()}
464
- </button>
465
- {/each}
466
- </div>
467
- {:else if viewMode === 'months'}
468
- <div class="grid grid-cols-3 gap-2">
469
- <!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
470
- {#each Array(12).fill(0) as _, month (month)}
471
- <button
472
- type="button"
473
- class={cn(
474
- 'flex items-center justify-center rounded-md px-2 py-1 text-sm font-medium',
475
- viewDate.getMonth() === month
476
- ? 'bg-primary-500 text-white'
477
- : 'text-default-700 hover:bg-default-100'
478
- )}
479
- onclick={() => selectMonth(month)}
480
- >
481
- {getMonthName(month).substring(0, 3)}
482
- </button>
483
- {/each}
484
- </div>
485
- {:else if viewMode === 'years'}
486
- <div class="grid grid-cols-3 gap-2">
487
- <!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
488
- {#each Array(12).fill(0) as _, i (i)}
489
- {@const year = viewDate.getFullYear() - 6 + i}
490
- <button
491
- type="button"
492
- class={cn(
493
- 'flex items-center justify-center rounded-md px-2 py-1 text-sm font-medium',
494
- viewDate.getFullYear() === year
495
- ? 'bg-primary-500 text-white'
496
- : 'text-default-700 hover:bg-default-100'
497
- )}
498
- onclick={() => selectYear(year)}
499
- >
500
- {year}
501
- </button>
502
- {/each}
503
- </div>
504
- {/if}
505
-
506
- {#if startDate || endDate}
507
- <div
508
- class="border-default-200 text-default-500 mt-4 flex flex-wrap justify-between gap-x-4 gap-y-1 border-t pt-3 text-xs"
509
- >
510
- <div>
511
- {startDate ? `${startLabel}: ${formatDate(startDate)}` : ''}
512
- </div>
513
- <div>
514
- {endDate ? `${endLabel}: ${formatDate(endDate)}` : ''}
515
- </div>
516
- </div>
517
- {/if}
330
+ {@render calendarContent()}
518
331
  </div>
519
332
  </Portal>
520
333
  {/if}
334
+
335
+ {#if isOpen && isMobile}
336
+ <button
337
+ type="button"
338
+ class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
339
+ aria-label="Close"
340
+ onclick={() => (isOpen = false)}
341
+ ></button>
342
+ <div
343
+ class="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[85vh] min-h-48 flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl"
344
+ transition:fly={{ y: 300, duration: 200, easing: quintOut }}
345
+ bind:this={calendarRef}
346
+ >
347
+ <div class="flex justify-center py-2">
348
+ <div class="bg-default-300 h-1 w-8 rounded-full"></div>
349
+ </div>
350
+ <div class="flex-1 cursor-pointer overflow-y-auto p-4">
351
+ {@render calendarContent()}
352
+ </div>
353
+ </div>
354
+ {/if}
521
355
  </div>
356
+
357
+ {#snippet calendarContent()}
358
+ <div class="mb-2 flex items-center justify-between">
359
+ {#if viewMode === 'days'}
360
+ <button
361
+ type="button"
362
+ aria-label="Previous month"
363
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
364
+ onclick={prevMonth}
365
+ >
366
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
367
+ ><path
368
+ fill-rule="evenodd"
369
+ d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
370
+ clip-rule="evenodd"
371
+ /></svg
372
+ >
373
+ </button>
374
+ <button
375
+ type="button"
376
+ class="text-default-700 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md px-2 py-1 text-sm font-medium"
377
+ onclick={showMonths}
378
+ >
379
+ {getMonthName(viewDate.getMonth())}
380
+ {viewDate.getFullYear()}
381
+ </button>
382
+ <button
383
+ type="button"
384
+ aria-label="Next month"
385
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
386
+ onclick={nextMonth}
387
+ >
388
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
389
+ ><path
390
+ fill-rule="evenodd"
391
+ d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
392
+ clip-rule="evenodd"
393
+ /></svg
394
+ >
395
+ </button>
396
+ {:else if viewMode === 'months'}
397
+ <button
398
+ type="button"
399
+ aria-label="Previous year"
400
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
401
+ onclick={() => (viewDate = new Date(viewDate.getFullYear() - 1, viewDate.getMonth(), 1))}
402
+ >
403
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
404
+ ><path
405
+ fill-rule="evenodd"
406
+ d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
407
+ clip-rule="evenodd"
408
+ /></svg
409
+ >
410
+ </button>
411
+ <button
412
+ type="button"
413
+ class="text-default-700 inline-flex cursor-pointer items-center rounded-md px-2 py-1 text-sm font-medium"
414
+ onclick={showYears}>{viewDate.getFullYear()}</button
415
+ >
416
+ <button
417
+ type="button"
418
+ aria-label="Next year"
419
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
420
+ onclick={() => (viewDate = new Date(viewDate.getFullYear() + 1, viewDate.getMonth(), 1))}
421
+ >
422
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
423
+ ><path
424
+ fill-rule="evenodd"
425
+ d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
426
+ clip-rule="evenodd"
427
+ /></svg
428
+ >
429
+ </button>
430
+ {:else if viewMode === 'years'}
431
+ <button
432
+ type="button"
433
+ aria-label="Previous years"
434
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
435
+ onclick={() => (viewDate = new Date(viewDate.getFullYear() - 12, viewDate.getMonth(), 1))}
436
+ >
437
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
438
+ ><path
439
+ fill-rule="evenodd"
440
+ d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
441
+ clip-rule="evenodd"
442
+ /></svg
443
+ >
444
+ </button>
445
+ <button
446
+ type="button"
447
+ class="text-default-700 inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
448
+ >{viewDate.getFullYear() - 6} - {viewDate.getFullYear() + 5}</button
449
+ >
450
+ <button
451
+ type="button"
452
+ aria-label="Next years"
453
+ class="text-default-500 hover:bg-default-100 inline-flex cursor-pointer items-center rounded-md p-1 text-sm"
454
+ onclick={() => (viewDate = new Date(viewDate.getFullYear() + 12, viewDate.getMonth(), 1))}
455
+ >
456
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
457
+ ><path
458
+ fill-rule="evenodd"
459
+ d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
460
+ clip-rule="evenodd"
461
+ /></svg
462
+ >
463
+ </button>
464
+ {/if}
465
+ </div>
466
+
467
+ {#if viewMode === 'days'}
468
+ <div class="text-default-500 mb-1 grid grid-cols-7 gap-1 text-center text-xs font-medium">
469
+ <div>Su</div>
470
+ <div>Mo</div>
471
+ <div>Tu</div>
472
+ <div>We</div>
473
+ <div>Th</div>
474
+ <div>Fr</div>
475
+ <div>Sa</div>
476
+ </div>
477
+ <div class="grid grid-cols-7 gap-1">
478
+ {#each getDaysInMonth(viewDate) as { date, isCurrentMonth, isToday, isSelected, isInRange, isDisabled } (date.getTime())}
479
+ <button
480
+ type="button"
481
+ class={cn(
482
+ 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-sm font-medium',
483
+ isDisabled ? 'text-default-300 cursor-not-allowed' : 'hover:bg-default-100',
484
+ isSelected ? 'bg-primary-500 hover:bg-primary-600 text-white' : '',
485
+ isInRange && !isSelected ? 'bg-primary-100 text-primary-800' : '',
486
+ !isCurrentMonth && !isSelected && !isInRange ? 'text-default-400' : '',
487
+ isToday && !isSelected ? 'border-primary-500 border' : ''
488
+ )}
489
+ onclick={() => handleDateClick(date)}
490
+ onmouseenter={() => handleDateHover(date)}
491
+ disabled={isDisabled}>{date.getDate()}</button
492
+ >
493
+ {/each}
494
+ </div>
495
+ {:else if viewMode === 'months'}
496
+ <div class="grid grid-cols-3 gap-2">
497
+ <!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
498
+ {#each Array(12).fill(0) as _, month (month)}
499
+ <button
500
+ type="button"
501
+ class={cn(
502
+ 'flex cursor-pointer items-center justify-center rounded-md px-2 py-1 text-sm font-medium',
503
+ viewDate.getMonth() === month
504
+ ? 'bg-primary-500 text-white'
505
+ : 'text-default-700 hover:bg-default-100'
506
+ )}
507
+ onclick={() => selectMonth(month)}>{getMonthName(month).substring(0, 3)}</button
508
+ >
509
+ {/each}
510
+ </div>
511
+ {:else if viewMode === 'years'}
512
+ <div class="grid grid-cols-3 gap-2">
513
+ <!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
514
+ {#each Array(12).fill(0) as _, i (i)}
515
+ {@const year = viewDate.getFullYear() - 6 + i}
516
+ <button
517
+ type="button"
518
+ class={cn(
519
+ 'flex cursor-pointer items-center justify-center rounded-md px-2 py-1 text-sm font-medium',
520
+ viewDate.getFullYear() === year
521
+ ? 'bg-primary-500 text-white'
522
+ : 'text-default-700 hover:bg-default-100'
523
+ )}
524
+ onclick={() => selectYear(year)}>{year}</button
525
+ >
526
+ {/each}
527
+ </div>
528
+ {/if}
529
+
530
+ {#if startDate || endDate}
531
+ <div
532
+ class="border-default-200 text-default-500 mt-4 flex flex-wrap justify-between gap-x-4 gap-y-1 border-t pt-3 text-xs"
533
+ >
534
+ <div>{startDate ? `${startLabel}: ${formatDate(startDate)}` : ''}</div>
535
+ <div>{endDate ? `${endLabel}: ${formatDate(endDate)}` : ''}</div>
536
+ </div>
537
+ {/if}
538
+ {/snippet}
@@ -13,20 +13,53 @@
13
13
  placeholder = 'Enter a number',
14
14
  size = Size.MD,
15
15
  class: className = '',
16
+ icon: LeadingIcon,
17
+ iconPreset,
16
18
  units = [],
17
19
  errors,
18
20
  disabled = false,
19
21
  dropdownIcon: DropdownIcon,
20
22
  onunitchange: onUnitChange,
23
+ formatThousands = true,
24
+ locale = 'en-US',
21
25
  testId,
22
26
  ...restProps
23
27
  }: NumberInputProps = $props();
24
28
 
29
+ const showPresetIcon = $derived(!LeadingIcon && iconPreset);
30
+
25
31
  let showUnitDropdown = $state(false);
26
32
  let containerRef = $state<HTMLDivElement | null>(null);
33
+ let inputFocused = $state(false);
27
34
 
28
35
  const selectedOption = $derived(units.find((u) => u.value === unit));
29
36
 
37
+ // Only render the dropdown trigger when there's something to switch
38
+ // between. A single-unit field looks like a static currency label
39
+ // rather than an editable selector — no chevron, no click target.
40
+ const hasMultipleUnits = $derived(units.length > 1);
41
+
42
+ // While focused, show the raw number so typing stays predictable
43
+ // (typed commas would otherwise fight the formatter). On blur we
44
+ // reformat with locale thousands separators for readability.
45
+ const displayValue = $derived(
46
+ value == null || Number.isNaN(value)
47
+ ? ''
48
+ : inputFocused || !formatThousands
49
+ ? String(value)
50
+ : value.toLocaleString(locale)
51
+ );
52
+
53
+ function handleInput(e: Event) {
54
+ const raw = (e.currentTarget as HTMLInputElement).value.replace(/[^0-9.-]/g, '');
55
+ if (raw === '' || raw === '-') {
56
+ value = 0;
57
+ return;
58
+ }
59
+ const num = Number(raw);
60
+ if (!Number.isNaN(num)) value = num;
61
+ }
62
+
30
63
  const tokens = $derived(formSizeTokens[size]);
31
64
 
32
65
  const containerClass = $derived(
@@ -87,27 +120,27 @@
87
120
  >
88
121
  {/if}
89
122
  <div class={containerClass} bind:this={containerRef}>
90
- <svg
91
- xmlns="http://www.w3.org/2000/svg"
92
- width="24"
93
- height="24"
94
- viewBox="0 0 24 24"
95
- class="text-default-500 ml-3 size-4 flex-shrink-0"
96
- >
97
- <path
98
- fill="none"
99
- stroke="currentColor"
100
- stroke-linecap="round"
101
- stroke-linejoin="round"
102
- stroke-width="2"
103
- d="M17 9V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2m2 4h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2m7-5a2 2 0 1 1-4 0a2 2 0 0 1 4 0"
104
- />
105
- </svg>
123
+ {#if LeadingIcon}
124
+ <span class={cn('text-default-500 flex shrink-0 items-center', tokens.padX)}>
125
+ <LeadingIcon class={tokens.iconSize} />
126
+ </span>
127
+ {:else if showPresetIcon}
128
+ <span
129
+ class={cn('text-default-500 flex shrink-0 items-center', tokens.padX)}
130
+ aria-hidden="true"
131
+ >
132
+ {@render presetIconSvg(iconPreset)}
133
+ </span>
134
+ {/if}
106
135
  <input
107
136
  {name}
108
137
  id={name}
109
- bind:value
110
- type="number"
138
+ type="text"
139
+ inputmode="decimal"
140
+ value={displayValue}
141
+ oninput={handleInput}
142
+ onfocus={() => (inputFocused = true)}
143
+ onblur={() => (inputFocused = false)}
111
144
  {placeholder}
112
145
  {disabled}
113
146
  class={inputClass}
@@ -115,27 +148,41 @@
115
148
  {...restProps}
116
149
  />
117
150
 
118
- <button
119
- type="button"
120
- class="hover:bg-default-100 flex items-center gap-1 rounded px-1"
121
- onclick={handleUnitToggle}
122
- {disabled}
123
- >
124
- {#if selectedOption?.icon}
125
- {@const Icon = selectedOption.icon}
126
- <Icon />
127
- {/if}
128
- <span class="text-sm">{unit}</span>
129
- {#if DropdownIcon}
130
- <DropdownIcon class={iconClass} />
131
- {:else}
132
- <svg class={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
133
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
134
- </svg>
135
- {/if}
136
- </button>
137
-
138
- {#if showUnitDropdown}
151
+ {#if hasMultipleUnits}
152
+ <!-- Clickable selector: renders only when there's more than one
153
+ unit to pick from. Otherwise the unit is static text below. -->
154
+ <button
155
+ type="button"
156
+ class="hover:bg-default-100 flex items-center gap-1 rounded px-1"
157
+ onclick={handleUnitToggle}
158
+ {disabled}
159
+ >
160
+ {#if selectedOption?.icon}
161
+ {@const Icon = selectedOption.icon}
162
+ <Icon />
163
+ {/if}
164
+ <span class={cn(tokens.text)}>{unit}</span>
165
+ {#if DropdownIcon}
166
+ <DropdownIcon class={iconClass} />
167
+ {:else}
168
+ <svg class={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
169
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
170
+ </svg>
171
+ {/if}
172
+ </button>
173
+ {:else if unit}
174
+ <!-- Static unit label — no chevron, not clickable. Matches the
175
+ field's text size so it sits inline. -->
176
+ <span class={cn('text-default-500 flex items-center gap-1 pr-2', tokens.text)}>
177
+ {#if selectedOption?.icon}
178
+ {@const Icon = selectedOption.icon}
179
+ <Icon />
180
+ {/if}
181
+ {unit}
182
+ </span>
183
+ {/if}
184
+
185
+ {#if showUnitDropdown && hasMultipleUnits}
139
186
  <div class={dropdownClass}>
140
187
  {#each units as unitOption (unitOption.value)}
141
188
  <button
@@ -163,3 +210,65 @@
163
210
  {/each}
164
211
  {/if}
165
212
  </div>
213
+
214
+ {#snippet presetIconSvg(preset: string | undefined)}
215
+ {#if preset === 'currency'}
216
+ <svg
217
+ class={tokens.iconSize}
218
+ viewBox="0 0 24 24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ stroke-width="2"
222
+ stroke-linecap="round"
223
+ stroke-linejoin="round"
224
+ >
225
+ <path
226
+ d="M17 9V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2m2 4h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2m7-5a2 2 0 1 1-4 0a2 2 0 0 1 4 0"
227
+ />
228
+ </svg>
229
+ {:else if preset === 'quantity'}
230
+ <span class={cn(tokens.iconSize, 'flex items-center justify-center font-bold')}>#</span>
231
+ {:else if preset === 'percentage'}
232
+ <svg
233
+ class={tokens.iconSize}
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ stroke-width="2"
238
+ stroke-linecap="round"
239
+ stroke-linejoin="round"
240
+ >
241
+ <path d="M19 5 5 19" /><circle cx="6.5" cy="6.5" r="2.5" /><circle
242
+ cx="17.5"
243
+ cy="17.5"
244
+ r="2.5"
245
+ />
246
+ </svg>
247
+ {:else if preset === 'weight'}
248
+ <svg
249
+ class={tokens.iconSize}
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ stroke-width="2"
254
+ stroke-linecap="round"
255
+ stroke-linejoin="round"
256
+ >
257
+ <path
258
+ d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
259
+ /><path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
260
+ </svg>
261
+ {:else if preset === 'temperature'}
262
+ <svg
263
+ class={tokens.iconSize}
264
+ viewBox="0 0 24 24"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ stroke-width="2"
268
+ stroke-linecap="round"
269
+ stroke-linejoin="round"
270
+ >
271
+ <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" />
272
+ </svg>
273
+ {/if}
274
+ {/snippet}
@@ -41,7 +41,7 @@
41
41
 
42
42
  const rootClass = $derived(
43
43
  cn(
44
- orientation === 'auto' ? '@container w-full' : 'w-fit',
44
+ orientation === 'auto' ? '@container w-full' : 'w-full sm:w-fit',
45
45
  labelLayout === 'inline' ? 'flex flex-row items-center gap-2' : 'flex flex-col gap-2',
46
46
  orientation === 'auto' &&
47
47
  labelLayout === 'inline' &&