@joewinke/jatui 0.41.1 → 0.43.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 (75) hide show
  1. package/CLAUDE.md +32 -6
  2. package/THIRD_PARTY_NOTICES.md +14 -0
  3. package/dist/actions/dismissOutside.d.ts +52 -0
  4. package/dist/actions/dismissOutside.js +80 -0
  5. package/dist/components/BookingScheduler.svelte +412 -0
  6. package/dist/components/BookingScheduler.svelte.d.ts +66 -0
  7. package/dist/components/CalendarPicker.svelte +190 -205
  8. package/dist/components/CalendarPicker.svelte.d.ts +19 -1
  9. package/dist/components/DatePicker.svelte +419 -0
  10. package/dist/components/DatePicker.svelte.d.ts +44 -0
  11. package/dist/components/DateRangePicker.svelte +471 -5
  12. package/dist/components/DateRangePicker.svelte.d.ts +32 -3
  13. package/dist/components/EventCalendar.svelte +1324 -0
  14. package/dist/components/EventCalendar.svelte.d.ts +101 -0
  15. package/dist/components/FileDropzone.svelte +165 -3
  16. package/dist/components/FileDropzone.svelte.d.ts +9 -0
  17. package/dist/components/TextInput.svelte +71 -20
  18. package/dist/components/TextInput.svelte.d.ts +7 -0
  19. package/dist/components/ThumbnailGrid.svelte +106 -1
  20. package/dist/components/ThumbnailGrid.svelte.d.ts +7 -0
  21. package/dist/components/TimeSlotPicker.svelte +174 -108
  22. package/dist/components/TimeSlotPicker.svelte.d.ts +27 -0
  23. package/dist/components/basic/Alert.svelte +113 -16
  24. package/dist/components/basic/Alert.svelte.d.ts +17 -1
  25. package/dist/components/basic/Checkbox.svelte +53 -1
  26. package/dist/components/basic/Checkbox.svelte.d.ts +6 -0
  27. package/dist/components/basic/RadioGroup.svelte +59 -3
  28. package/dist/components/basic/RadioGroup.svelte.d.ts +15 -0
  29. package/dist/components/basic/Slider.svelte +257 -9
  30. package/dist/components/basic/Slider.svelte.d.ts +14 -2
  31. package/dist/components/basic/sliderMath.d.ts +29 -0
  32. package/dist/components/basic/sliderMath.js +64 -0
  33. package/dist/components/calendar/CalendarHeader.svelte +98 -0
  34. package/dist/components/calendar/CalendarHeader.svelte.d.ts +24 -0
  35. package/dist/components/calendar/MonthGrid.svelte +361 -0
  36. package/dist/components/calendar/MonthGrid.svelte.d.ts +55 -0
  37. package/dist/components/calendar/calendarGrid.d.ts +219 -0
  38. package/dist/components/calendar/calendarGrid.js +494 -0
  39. package/dist/components/calendar/createCalendarGrid.d.ts +25 -0
  40. package/dist/components/calendar/createCalendarGrid.js +57 -0
  41. package/dist/components/calendar/dayOverlap.d.ts +92 -0
  42. package/dist/components/calendar/dayOverlap.js +147 -0
  43. package/dist/components/calendar/eventCalendar.d.ts +75 -0
  44. package/dist/components/calendar/eventCalendar.js +187 -0
  45. package/dist/components/calendar/index.d.ts +32 -0
  46. package/dist/components/calendar/index.js +25 -0
  47. package/dist/components/calendar/monthPacking.d.ts +97 -0
  48. package/dist/components/calendar/monthPacking.js +254 -0
  49. package/dist/components/calendar/timeSlots.d.ts +57 -0
  50. package/dist/components/calendar/timeSlots.js +163 -0
  51. package/dist/components/field/FieldHint.svelte +29 -0
  52. package/dist/components/field/FieldHint.svelte.d.ts +17 -0
  53. package/dist/components/field/FieldLabel.svelte +100 -0
  54. package/dist/components/field/FieldLabel.svelte.d.ts +29 -0
  55. package/dist/components/form-cards/CheckGlyph.svelte +138 -0
  56. package/dist/components/form-cards/CheckGlyph.svelte.d.ts +29 -0
  57. package/dist/components/form-cards/CheckboxCard.svelte +120 -0
  58. package/dist/components/form-cards/CheckboxCard.svelte.d.ts +33 -0
  59. package/dist/components/form-cards/ChoiceCard.svelte +156 -0
  60. package/dist/components/form-cards/ChoiceCard.svelte.d.ts +42 -0
  61. package/dist/components/form-cards/SwitchCard.svelte +114 -0
  62. package/dist/components/form-cards/SwitchCard.svelte.d.ts +28 -0
  63. package/dist/components/toast/ToastHost.svelte +56 -3
  64. package/dist/components/toast/ToastHost.svelte.d.ts +9 -1
  65. package/dist/components/toast/ToastItem.svelte +75 -23
  66. package/dist/components/toast/ToastItem.svelte.d.ts +2 -0
  67. package/dist/components/uploadProgress.d.ts +30 -0
  68. package/dist/components/uploadProgress.js +47 -0
  69. package/dist/index.d.ts +29 -2
  70. package/dist/index.js +22 -1
  71. package/dist/stores/toast.d.ts +35 -0
  72. package/dist/stores/toast.js +15 -1
  73. package/dist/styles/motion.css +72 -0
  74. package/dist/styles/rail.css +51 -0
  75. package/package.json +6 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * EventCalendar — month / week / day / agenda event calendar.
3
+ *
4
+ * The engine is upstreamed from MOS `CalendarGrid` (jatui-lfxyy): one lane
5
+ * allocator (`monthPacking`) places every bar and chip in a month row so they
6
+ * can't collide, lane capacity is measured from the row height, and timed
7
+ * events in week/day view split concurrent blocks side by side (`dayOverlap`).
8
+ * The skin takes clean-room ideas from BoardUI Pro's public calendar page:
9
+ * per-hue event token triplets (`--jatui-event-N{,-bg,-fg}`), a "+N more" that
10
+ * expands the week in place instead of navigating, a month-switcher pill that
11
+ * reuses the date-picker panel, and an agenda list.
12
+ *
13
+ * The host owns the events. Callbacks are all optional; each one switches a
14
+ * behaviour on (click to create, drag to move, drag/Shift-select a range).
15
+ * `onRangeChange` reports the visible range so a host can load just that.
16
+ *
17
+ * Keyboard (month): one tab stop on the grid; arrows / Home / End move the day,
18
+ * PageUp/PageDown change month (Shift = year), Enter creates (or opens the
19
+ * day), Shift+arrows extend a range when `onRangeSelect` is set. Event chips
20
+ * are buttons; Escape closes the details popover.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ import { type Weekday } from './calendar/calendarGrid';
24
+ import { type CalendarEventItem, type EventCalendarView } from './calendar/eventCalendar';
25
+ import { type HourCycle } from './calendar/timeSlots';
26
+ declare function $$render<E extends CalendarEventItem = CalendarEventItem>(): {
27
+ props: {
28
+ events: E[];
29
+ /** Current view (bindable). */
30
+ view?: EventCalendarView;
31
+ /** The focus date — which month / week / day is shown (bindable). */
32
+ date?: Date;
33
+ /** Views offered in the toolbar switcher. */
34
+ views?: EventCalendarView[];
35
+ weekStart?: Weekday | "locale";
36
+ locale?: string;
37
+ hourCycle?: HourCycle;
38
+ /** First and last (exclusive) hour of the week/day time grid. */
39
+ dayStartHour?: number;
40
+ dayEndHour?: number;
41
+ /** Pixel height of one hour in week/day view. */
42
+ hourHeight?: number;
43
+ /** Hour the time grid scrolls to on first show. */
44
+ scrollToHour?: number;
45
+ /** CSS height of the calendar body. */
46
+ height?: string;
47
+ toolbar?: boolean;
48
+ /** Extra toolbar content (filters, a "New event" button). */
49
+ toolbarEnd?: Snippet;
50
+ /** Custom chip content. */
51
+ eventContent?: Snippet<[E, {
52
+ view: EventCalendarView;
53
+ time: string;
54
+ }]>;
55
+ /** Details popover content, opened by clicking a chip. Gets the event and a close fn. */
56
+ eventDetails?: Snippet<[E, () => void]>;
57
+ /** `expand` (default): "+N more" grows the week in place. `day`: open day view. */
58
+ moreAction?: "expand" | "day";
59
+ onEventClick?: (ev: E, e: MouseEvent) => void;
60
+ /** Day number clicked. Defaults to opening day view when it is offered. */
61
+ onDayClick?: (date: Date) => void;
62
+ /** Empty cell / hour slot activated — the host opens its create flow. */
63
+ onCreate?: (date: Date, hour: number | undefined, e: MouseEvent | KeyboardEvent) => void;
64
+ /** Chip dropped on a day (and hour, in week/day view). Turns drag-to-move on. */
65
+ onEventMove?: (ev: E, target: {
66
+ date: Date;
67
+ hour?: number;
68
+ allDay: boolean;
69
+ }) => void;
70
+ /** Month cells dragged (or Shift+arrowed) across. */
71
+ onRangeSelect?: (start: Date, end: Date) => void;
72
+ /** The visible range changed — load events for it. */
73
+ onRangeChange?: (range: {
74
+ start: Date;
75
+ end: Date;
76
+ }, view: EventCalendarView) => void;
77
+ ariaLabel?: string;
78
+ class?: string;
79
+ };
80
+ exports: {};
81
+ bindings: "date" | "view";
82
+ slots: {};
83
+ events: {};
84
+ };
85
+ declare class __sveltets_Render<E extends CalendarEventItem = CalendarEventItem> {
86
+ props(): ReturnType<typeof $$render<E>>['props'];
87
+ events(): ReturnType<typeof $$render<E>>['events'];
88
+ slots(): ReturnType<typeof $$render<E>>['slots'];
89
+ bindings(): "date" | "view";
90
+ exports(): {};
91
+ }
92
+ interface $$IsomorphicComponent {
93
+ new <E extends CalendarEventItem = CalendarEventItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<E>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<E>['props']>, ReturnType<__sveltets_Render<E>['events']>, ReturnType<__sveltets_Render<E>['slots']>> & {
94
+ $$bindings?: ReturnType<__sveltets_Render<E>['bindings']>;
95
+ } & ReturnType<__sveltets_Render<E>['exports']>;
96
+ <E extends CalendarEventItem = CalendarEventItem>(internal: unknown, props: ReturnType<__sveltets_Render<E>['props']> & {}): ReturnType<__sveltets_Render<E>['exports']>;
97
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
98
+ }
99
+ declare const EventCalendar: $$IsomorphicComponent;
100
+ type EventCalendar<E extends CalendarEventItem = CalendarEventItem> = InstanceType<typeof EventCalendar<E>>;
101
+ export default EventCalendar;
@@ -17,6 +17,7 @@
17
17
 
18
18
  <script lang="ts">
19
19
  import FileThumbnail from "./FileThumbnail.svelte"
20
+ import { clampProgress, dashOffset, perimeterRect, progressPercent } from "./uploadProgress.js"
20
21
 
21
22
  // Lightweight extension→MIME map (avoids mime-types dependency)
22
23
  const EXT_TO_MIME: Record<string, string> = {
@@ -87,6 +88,15 @@
87
88
  dropSubtext?: string
88
89
  /** Called when the user selects or drops files (after validation) */
89
90
  onFilesSelected?: (files: File[], errors: string[]) => void
91
+ /**
92
+ * Upload progress, 0–100. When set, the zone's dashed border cross-fades
93
+ * into a primary stroke that fills clockwise around the perimeter, and a
94
+ * `%` badge slides in on the top edge — no layout shift. Leave it
95
+ * undefined/null for the plain (indeterminate) behaviour.
96
+ */
97
+ progress?: number | null
98
+ /** Accessible name for the progress indicator (default "Upload progress"). */
99
+ progressLabel?: string
90
100
  }
91
101
 
92
102
  let {
@@ -107,6 +117,8 @@
107
117
  dropLabel = "Upload files",
108
118
  dropSubtext,
109
119
  onFilesSelected,
120
+ progress = null,
121
+ progressLabel = "Upload progress",
110
122
  }: Props = $props()
111
123
 
112
124
  let isDragOver = $state(false)
@@ -114,6 +126,27 @@
114
126
  let dropzoneRef = $state<HTMLElement>(undefined!)
115
127
  let selectedFiles = $state<File[]>([])
116
128
 
129
+ // Upload progress perimeter (jatui-bns3w). The rect tracks the zone's
130
+ // border-box via ResizeObserver so the stroke lands exactly on the 2px border.
131
+ const ZONE_BORDER = 2
132
+ const ZONE_RADIUS = 8 // rounded-lg
133
+ let zoneW = $state(0)
134
+ let zoneH = $state(0)
135
+ const progressValue = $derived(clampProgress(progress))
136
+ const progressActive = $derived(progressValue !== null)
137
+ const progressDone = $derived(progressValue === 100)
138
+ const progressRect = $derived(perimeterRect(zoneW, zoneH, ZONE_BORDER, ZONE_RADIUS))
139
+
140
+ $effect(() => {
141
+ if (!dropzoneRef || typeof ResizeObserver === "undefined") return
142
+ const ro = new ResizeObserver(() => {
143
+ zoneW = dropzoneRef.offsetWidth
144
+ zoneH = dropzoneRef.offsetHeight
145
+ })
146
+ ro.observe(dropzoneRef)
147
+ return () => ro.disconnect()
148
+ })
149
+
117
150
  // Human-readable accepted extensions for display
118
151
  const acceptedExtensions = $derived(
119
152
  accept
@@ -305,14 +338,17 @@
305
338
  />
306
339
 
307
340
  <!-- Dropzone -->
341
+ <div class="relative">
308
342
  <div
309
343
  bind:this={dropzoneRef}
310
344
  class="relative border-2 border-dashed rounded-lg p-6 transition-all duration-200 cursor-pointer
311
345
  {isDragOver
312
346
  ? 'border-primary bg-primary/5 scale-[1.02]'
313
- : disabled
314
- ? 'border-base-300 bg-base-200 cursor-not-allowed'
315
- : 'border-base-300 hover:border-primary hover:bg-primary/5'}"
347
+ : progressActive
348
+ ? 'jatui-dz-border-hidden'
349
+ : disabled
350
+ ? 'border-base-300 bg-base-200 cursor-not-allowed'
351
+ : 'border-base-300 hover:border-primary hover:bg-primary/5'}"
316
352
  onclick={openFileDialog}
317
353
  ondragenter={handleDragEnter}
318
354
  ondragleave={handleDragLeave}
@@ -398,6 +434,50 @@
398
434
  {/if}
399
435
  </div>
400
436
 
437
+ <!-- Upload progress: perimeter stroke + % badge. A sibling of the
438
+ role="button" zone (a progressbar inside a button is flattened by AT). -->
439
+ <div
440
+ class="jatui-dz-progress"
441
+ class:is-active={progressActive && !isDragOver}
442
+ class:is-done={progressDone}
443
+ role={progressActive ? "progressbar" : undefined}
444
+ aria-label={progressActive ? progressLabel : undefined}
445
+ aria-valuemin={progressActive ? 0 : undefined}
446
+ aria-valuemax={progressActive ? 100 : undefined}
447
+ aria-valuenow={progressActive ? progressPercent(progressValue) : undefined}
448
+ aria-hidden={progressActive ? undefined : "true"}
449
+ >
450
+ {#if zoneW > 0 && zoneH > 0}
451
+ <svg class="jatui-dz-ring" width={zoneW} height={zoneH} aria-hidden="true">
452
+ <rect
453
+ class="jatui-dz-track"
454
+ x={progressRect.x}
455
+ y={progressRect.y}
456
+ width={progressRect.width}
457
+ height={progressRect.height}
458
+ rx={progressRect.rx}
459
+ stroke-width={ZONE_BORDER}
460
+ />
461
+ <rect
462
+ class="jatui-dz-fill"
463
+ x={progressRect.x}
464
+ y={progressRect.y}
465
+ width={progressRect.width}
466
+ height={progressRect.height}
467
+ rx={progressRect.rx}
468
+ stroke-width={ZONE_BORDER}
469
+ pathLength="100"
470
+ stroke-dasharray="100"
471
+ stroke-dashoffset={dashOffset(progressValue, 100)}
472
+ />
473
+ </svg>
474
+ {/if}
475
+ <span class="jatui-dz-badge badge badge-sm badge-primary tabular-nums" aria-hidden="true">
476
+ {progressPercent(progressValue)}%
477
+ </span>
478
+ </div>
479
+ </div>
480
+
401
481
  <!-- Selected Files (before upload) -->
402
482
  {#if selectedFiles.length > 0 && !isUploading && uploadedFiles.length === 0}
403
483
  <div class="mt-4 space-y-2">
@@ -490,4 +570,86 @@
490
570
  outline: 2px solid var(--color-primary);
491
571
  outline-offset: 2px;
492
572
  }
573
+
574
+ /* Upload progress perimeter (jatui-bns3w). The dashed border fades out and
575
+ an SVG stroke on the same 2px line fills clockwise; the % badge slides in
576
+ on the top edge. Opacity/transform only — the zone never changes size. */
577
+ .jatui-dz-border-hidden {
578
+ border-color: transparent;
579
+ }
580
+
581
+ .jatui-dz-progress {
582
+ position: absolute;
583
+ inset: 0;
584
+ pointer-events: none;
585
+ }
586
+
587
+ .jatui-dz-ring {
588
+ position: absolute;
589
+ inset: 0;
590
+ overflow: visible;
591
+ opacity: 0;
592
+ transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
593
+ }
594
+
595
+ .jatui-dz-progress.is-active .jatui-dz-ring {
596
+ opacity: 1;
597
+ }
598
+
599
+ .jatui-dz-track {
600
+ fill: none;
601
+ stroke: var(--color-base-300);
602
+ }
603
+
604
+ .jatui-dz-fill {
605
+ fill: none;
606
+ stroke: var(--color-primary);
607
+ stroke-linecap: round;
608
+ transition:
609
+ stroke-dashoffset var(--motion-move, 300ms) var(--motion-glide, ease-out),
610
+ stroke var(--motion-fast, 150ms) var(--motion-glide, ease-out);
611
+ }
612
+
613
+ .jatui-dz-progress.is-done .jatui-dz-fill {
614
+ stroke: var(--color-success);
615
+ }
616
+
617
+ .jatui-dz-badge {
618
+ position: absolute;
619
+ top: 0;
620
+ left: 50%;
621
+ opacity: 0;
622
+ transform: translate(-50%, calc(-50% - var(--motion-travel, 16px)));
623
+ transition:
624
+ opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out),
625
+ transform var(--motion-enter, 200ms) var(--motion-glide, ease-out);
626
+ }
627
+
628
+ .jatui-dz-progress.is-active .jatui-dz-badge {
629
+ opacity: 1;
630
+ transform: translate(-50%, -50%);
631
+ }
632
+
633
+ .jatui-dz-progress.is-done .jatui-dz-badge {
634
+ background-color: var(--color-success);
635
+ border-color: var(--color-success);
636
+ color: var(--color-success-content);
637
+ }
638
+
639
+ /* Calm: no slide, the badge just appears. */
640
+ :global([data-motion="calm"]) .jatui-dz-badge {
641
+ transform: translate(-50%, -50%);
642
+ }
643
+
644
+ @media (prefers-reduced-motion: reduce) {
645
+ .jatui-dz-ring,
646
+ .jatui-dz-fill,
647
+ .jatui-dz-badge {
648
+ transition: none;
649
+ }
650
+
651
+ .jatui-dz-badge {
652
+ transform: translate(-50%, -50%);
653
+ }
654
+ }
493
655
  </style>
@@ -34,6 +34,15 @@ interface Props {
34
34
  dropSubtext?: string;
35
35
  /** Called when the user selects or drops files (after validation) */
36
36
  onFilesSelected?: (files: File[], errors: string[]) => void;
37
+ /**
38
+ * Upload progress, 0–100. When set, the zone's dashed border cross-fades
39
+ * into a primary stroke that fills clockwise around the perimeter, and a
40
+ * `%` badge slides in on the top edge — no layout shift. Leave it
41
+ * undefined/null for the plain (indeterminate) behaviour.
42
+ */
43
+ progress?: number | null;
44
+ /** Accessible name for the progress indicator (default "Upload progress"). */
45
+ progressLabel?: string;
37
46
  }
38
47
  declare const FileDropzone: import("svelte").Component<Props, {}, "">;
39
48
  type FileDropzone = ReturnType<typeof FileDropzone>;
@@ -7,7 +7,10 @@
7
7
  * opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
8
8
  */
9
9
 
10
+ import type { Snippet } from 'svelte';
10
11
  import type { HTMLInputAttributes } from 'svelte/elements';
12
+ import FieldLabel from './field/FieldLabel.svelte';
13
+ import FieldHint from './field/FieldHint.svelte';
11
14
 
12
15
  /**
13
16
  * Any other native attribute (aria-label, aria-labelledby, data-*, title,
@@ -56,6 +59,12 @@
56
59
  * consumers are unaffected when this prop is absent/false.
57
60
  */
58
61
  floatingLabel?: boolean;
62
+ /** Leading icon / addon inside the field (e.g. a search glyph, "https://"). */
63
+ leading?: Snippet;
64
+ /** Trailing icon / addon inside the field (e.g. a unit, a clear button). */
65
+ trailing?: Snippet;
66
+ /** Info-icon hint after the label (shared `use:tooltip`). */
67
+ labelTooltip?: string;
59
68
  class?: string;
60
69
  id?: string;
61
70
  name?: string;
@@ -78,6 +87,9 @@
78
87
  pattern,
79
88
  showCount = false,
80
89
  floatingLabel = false,
90
+ leading,
91
+ trailing,
92
+ labelTooltip,
81
93
  class: className = '',
82
94
  id = `text-input-${Math.random().toString(36).substr(2, 9)}`,
83
95
  name = '',
@@ -87,7 +99,10 @@
87
99
 
88
100
  const sizeClass = $derived({ sm: 'input-sm', md: '', lg: 'input-lg' }[size]);
89
101
 
90
- const inputClass = $derived(
102
+ // With a leading/trailing addon the DaisyUI `input` skin moves to a wrapper
103
+ // (DaisyUI 5's `<label class="input">` pattern) and the <input> goes bare.
104
+ const hasAddons = $derived(!!leading || !!trailing);
105
+ const shellClass = $derived(
91
106
  [
92
107
  'input input-bordered w-full transition-shadow duration-200',
93
108
  sizeClass,
@@ -98,11 +113,13 @@
98
113
  .filter(Boolean)
99
114
  .join(' ')
100
115
  );
116
+ const inputClass = $derived(hasAddons ? 'jatui-input-bare' : shellClass);
101
117
 
102
118
  const characterCount = $derived(value?.length || 0);
103
119
  const isOverLimit = $derived(maxlength ? characterCount > maxlength : false);
104
120
 
105
121
  let inputEl: HTMLInputElement | undefined = $state();
122
+ let shellEl: HTMLDivElement | undefined = $state();
106
123
  let hadError = false;
107
124
 
108
125
  // Error shake: embedded keyframe (jatui-shake-x), no host-app dependency.
@@ -110,14 +127,15 @@
110
127
  // Self-contained reflow-restart so repeat errors re-shake.
111
128
  $effect(() => {
112
129
  const hasError = !!error;
113
- if (hasError && !hadError && inputEl) {
130
+ const target = hasAddons ? shellEl : inputEl;
131
+ if (hasError && !hadError && target) {
114
132
  const reduce =
115
133
  typeof window !== 'undefined' &&
116
134
  window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
117
135
  if (!reduce) {
118
- inputEl.classList.remove('jatui-input-shake');
119
- void inputEl.offsetWidth;
120
- inputEl.classList.add('jatui-input-shake');
136
+ target.classList.remove('jatui-input-shake');
137
+ void target.offsetWidth;
138
+ target.classList.add('jatui-input-shake');
121
139
  }
122
140
  }
123
141
  hadError = hasError;
@@ -126,17 +144,10 @@
126
144
 
127
145
  <div class="form-control w-full" class:jatui-float-wrap={floatingLabel}>
128
146
  {#if label && !floatingLabel}
129
- <label for={id} class="label">
130
- <span class="text-[0.8125rem] font-medium text-base-content/85">
131
- {label}
132
- {#if required}
133
- <span class="text-error ml-1">*</span>
134
- {/if}
135
- </span>
136
- </label>
147
+ <FieldLabel for={id} text={label} {required} tooltip={labelTooltip} />
137
148
  {/if}
138
149
 
139
- <div class:jatui-float-field={floatingLabel}>
150
+ {#snippet field()}
140
151
  <input
141
152
  {...rest}
142
153
  bind:this={inputEl}
@@ -161,6 +172,18 @@
161
172
  aria-invalid={error ? 'true' : 'false'}
162
173
  onanimationend={() => inputEl?.classList.remove('jatui-input-shake')}
163
174
  />
175
+ {/snippet}
176
+
177
+ <div class:jatui-float-field={floatingLabel}>
178
+ {#if hasAddons}
179
+ <div class="{shellClass} jatui-input-shell" bind:this={shellEl} onanimationend={() => shellEl?.classList.remove('jatui-input-shake')}>
180
+ {#if leading}<span class="jatui-input-addon text-base-content/55">{@render leading()}</span>{/if}
181
+ {@render field()}
182
+ {#if trailing}<span class="jatui-input-addon text-base-content/55">{@render trailing()}</span>{/if}
183
+ </div>
184
+ {:else}
185
+ {@render field()}
186
+ {/if}
164
187
 
165
188
  {#if label && floatingLabel}
166
189
  <label for={id} class="jatui-float-label">
@@ -181,13 +204,9 @@
181
204
  {/if}
182
205
 
183
206
  {#if error}
184
- <div class="label">
185
- <span id="{id}-error" class="text-[0.8125rem] text-error" role="alert">{error}</span>
186
- </div>
207
+ <FieldHint id="{id}-error" text={error} error />
187
208
  {:else if helpText}
188
- <div class="label">
189
- <span id="{id}-help" class="text-[0.8125rem] text-base-content/45">{helpText}</span>
190
- </div>
209
+ <FieldHint id="{id}-help" text={helpText} />
191
210
  {/if}
192
211
  </div>
193
212
 
@@ -213,6 +232,38 @@
213
232
  }
214
233
  }
215
234
 
235
+ /* ── Leading / trailing addons ────────────────────────────────
236
+ The shell carries the DaisyUI input skin; the inner input is bare and
237
+ grows. Focus ring comes from the shell's :focus-within. */
238
+ .jatui-input-shell {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: var(--jatui-space-base, 8px);
242
+ }
243
+ .jatui-input-shell:focus-within {
244
+ outline: 2px solid var(--color-base-content);
245
+ outline-offset: 2px;
246
+ }
247
+ .jatui-input-shell.input-error:focus-within {
248
+ outline-color: var(--color-error);
249
+ }
250
+ :global(.jatui-input-bare) {
251
+ flex: 1 1 auto;
252
+ min-width: 0;
253
+ height: 100%;
254
+ background: transparent;
255
+ border: 0;
256
+ outline: none;
257
+ font: inherit;
258
+ color: inherit;
259
+ }
260
+ .jatui-input-addon {
261
+ display: inline-flex;
262
+ flex-shrink: 0;
263
+ align-items: center;
264
+ white-space: nowrap;
265
+ }
266
+
216
267
  /* ── Floating Label ───────────────────────────────────────────
217
268
  Label sits inside the field at rest, lifts + scales on focus or filled.
218
269
  Pure CSS: uses :placeholder-shown to detect empty state (input must have
@@ -5,6 +5,7 @@
5
5
  * Adds: motion-token error shake (embedded keyframe, no host-app dependency),
6
6
  * opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
7
7
  */
8
+ import type { Snippet } from 'svelte';
8
9
  import type { HTMLInputAttributes } from 'svelte/elements';
9
10
  /**
10
11
  * Any other native attribute (aria-label, aria-labelledby, data-*, title,
@@ -35,6 +36,12 @@ interface Props extends Omit<HTMLInputAttributes, 'value' | 'type' | 'size' | 'c
35
36
  * consumers are unaffected when this prop is absent/false.
36
37
  */
37
38
  floatingLabel?: boolean;
39
+ /** Leading icon / addon inside the field (e.g. a search glyph, "https://"). */
40
+ leading?: Snippet;
41
+ /** Trailing icon / addon inside the field (e.g. a unit, a clear button). */
42
+ trailing?: Snippet;
43
+ /** Info-icon hint after the label (shared `use:tooltip`). */
44
+ labelTooltip?: string;
38
45
  class?: string;
39
46
  id?: string;
40
47
  name?: string;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { clampProgress, dashOffset, isInFlight, progressPercent } from './uploadProgress.js';
3
4
 
4
5
  /**
5
6
  * Generic thumbnail item — the decoupled chimaro shape. Unlike chimaro's
@@ -13,6 +14,13 @@
13
14
  url?: string | null;
14
15
  /** Alt text / display label. */
15
16
  alt: string;
17
+ /**
18
+ * Upload progress, 0–100. While below 100 a ring draws clockwise around
19
+ * the tile and a tiny % sits in the remove-× corner; at 100 the %
20
+ * cross-fades into the × (same slot, no layout shift). Omit for a
21
+ * plain tile.
22
+ */
23
+ progress?: number | null;
16
24
  }
17
25
 
18
26
  interface Props {
@@ -91,6 +99,8 @@
91
99
  : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'
92
100
  );
93
101
 
102
+ const hiddenWhileUploading = 'pointer-events-none opacity-0';
103
+
94
104
  function shortLabel(alt: string): string {
95
105
  return alt.length > 14 ? `${alt.slice(0, 14)}…` : alt;
96
106
  }
@@ -98,6 +108,8 @@
98
108
 
99
109
  <div class="{isGrid ? 'grid gap-2' : 'flex flex-wrap items-center gap-2'} {gridCols ?? ''} {className}">
100
110
  {#each visible as item (item.id)}
111
+ {@const progressValue = clampProgress(item.progress)}
112
+ {@const uploading = isInFlight(item.progress)}
101
113
  <div class="group relative">
102
114
  <button
103
115
  type="button"
@@ -120,10 +132,49 @@
120
132
  {/if}
121
133
  </button>
122
134
 
135
+ {#if progressValue !== null}
136
+ <!-- Upload ring: drawn ON the tile border (overflow visible, so the
137
+ 2px stroke straddles the edge); a sibling of the tile button so
138
+ the progressbar role is not flattened by the button. -->
139
+ <span
140
+ class="jatui-thumb-progress pointer-events-none absolute inset-0"
141
+ class:is-uploading={uploading}
142
+ role={uploading ? 'progressbar' : undefined}
143
+ aria-label={uploading ? `Uploading ${item.alt}` : undefined}
144
+ aria-valuemin={uploading ? 0 : undefined}
145
+ aria-valuemax={uploading ? 100 : undefined}
146
+ aria-valuenow={uploading ? progressPercent(progressValue) : undefined}
147
+ aria-hidden={uploading ? undefined : 'true'}
148
+ >
149
+ <svg class="jatui-thumb-ring absolute inset-0 h-full w-full" aria-hidden="true">
150
+ <rect class="jatui-thumb-track" width="100%" height="100%" rx="4" />
151
+ <rect
152
+ class="jatui-thumb-fill"
153
+ width="100%"
154
+ height="100%"
155
+ rx="4"
156
+ pathLength="1"
157
+ stroke-dasharray="1"
158
+ stroke-dashoffset={dashOffset(progressValue, 1)}
159
+ />
160
+ </svg>
161
+ <span
162
+ class="jatui-thumb-pct absolute -top-1 -right-1 z-20 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-0.5 text-primary-content tabular-nums"
163
+ aria-hidden="true"
164
+ >
165
+ {progressPercent(progressValue)}
166
+ </span>
167
+ </span>
168
+ {/if}
169
+
123
170
  {#if removable && onRemove}
124
171
  <button
125
172
  type="button"
126
- class="absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content {removeVisibility}"
173
+ class="jatui-thumb-remove absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content {uploading
174
+ ? hiddenWhileUploading
175
+ : removeVisibility}"
176
+ tabindex={uploading ? -1 : undefined}
177
+ aria-hidden={uploading ? 'true' : undefined}
127
178
  onclick={(e) => {
128
179
  e.stopPropagation();
129
180
  onRemove(item);
@@ -162,3 +213,57 @@
162
213
  </button>
163
214
  {/if}
164
215
  </div>
216
+
217
+ <style>
218
+ /* Upload ring + % badge (jatui-bns3w). Opacity/stroke only, on motion
219
+ tokens — under prefers-reduced-motion the tokens collapse to 0ms, so
220
+ the % → × swap is instant. */
221
+ .jatui-thumb-ring {
222
+ overflow: visible;
223
+ transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
224
+ }
225
+
226
+ .jatui-thumb-progress:not(.is-uploading) .jatui-thumb-ring {
227
+ opacity: 0;
228
+ }
229
+
230
+ .jatui-thumb-track,
231
+ .jatui-thumb-fill {
232
+ fill: none;
233
+ stroke-width: 2;
234
+ }
235
+
236
+ .jatui-thumb-track {
237
+ stroke: color-mix(in oklch, var(--color-base-content) 15%, transparent);
238
+ }
239
+
240
+ .jatui-thumb-fill {
241
+ stroke: var(--color-primary);
242
+ stroke-linecap: round;
243
+ transition: stroke-dashoffset var(--motion-move, 300ms) var(--motion-glide, ease-out);
244
+ }
245
+
246
+ .jatui-thumb-pct {
247
+ font-size: 0.5rem;
248
+ line-height: 1;
249
+ font-weight: 600;
250
+ transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
251
+ }
252
+
253
+ .jatui-thumb-progress:not(.is-uploading) .jatui-thumb-pct {
254
+ opacity: 0;
255
+ }
256
+
257
+ .jatui-thumb-remove {
258
+ transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
259
+ }
260
+
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .jatui-thumb-ring,
263
+ .jatui-thumb-fill,
264
+ .jatui-thumb-pct,
265
+ .jatui-thumb-remove {
266
+ transition: none;
267
+ }
268
+ }
269
+ </style>
@@ -11,6 +11,13 @@ export interface ThumbnailItem {
11
11
  url?: string | null;
12
12
  /** Alt text / display label. */
13
13
  alt: string;
14
+ /**
15
+ * Upload progress, 0–100. While below 100 a ring draws clockwise around
16
+ * the tile and a tiny % sits in the remove-× corner; at 100 the %
17
+ * cross-fades into the × (same slot, no layout shift). Omit for a
18
+ * plain tile.
19
+ */
20
+ progress?: number | null;
14
21
  }
15
22
  interface Props {
16
23
  items: ThumbnailItem[];