@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.
- package/CLAUDE.md +32 -6
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/dist/actions/dismissOutside.d.ts +52 -0
- package/dist/actions/dismissOutside.js +80 -0
- package/dist/components/BookingScheduler.svelte +412 -0
- package/dist/components/BookingScheduler.svelte.d.ts +66 -0
- package/dist/components/CalendarPicker.svelte +190 -205
- package/dist/components/CalendarPicker.svelte.d.ts +19 -1
- package/dist/components/DatePicker.svelte +419 -0
- package/dist/components/DatePicker.svelte.d.ts +44 -0
- package/dist/components/DateRangePicker.svelte +471 -5
- package/dist/components/DateRangePicker.svelte.d.ts +32 -3
- package/dist/components/EventCalendar.svelte +1324 -0
- package/dist/components/EventCalendar.svelte.d.ts +101 -0
- package/dist/components/FileDropzone.svelte +165 -3
- package/dist/components/FileDropzone.svelte.d.ts +9 -0
- package/dist/components/TextInput.svelte +71 -20
- package/dist/components/TextInput.svelte.d.ts +7 -0
- package/dist/components/ThumbnailGrid.svelte +106 -1
- package/dist/components/ThumbnailGrid.svelte.d.ts +7 -0
- package/dist/components/TimeSlotPicker.svelte +174 -108
- package/dist/components/TimeSlotPicker.svelte.d.ts +27 -0
- package/dist/components/basic/Alert.svelte +113 -16
- package/dist/components/basic/Alert.svelte.d.ts +17 -1
- package/dist/components/basic/Checkbox.svelte +53 -1
- package/dist/components/basic/Checkbox.svelte.d.ts +6 -0
- package/dist/components/basic/RadioGroup.svelte +59 -3
- package/dist/components/basic/RadioGroup.svelte.d.ts +15 -0
- package/dist/components/basic/Slider.svelte +257 -9
- package/dist/components/basic/Slider.svelte.d.ts +14 -2
- package/dist/components/basic/sliderMath.d.ts +29 -0
- package/dist/components/basic/sliderMath.js +64 -0
- package/dist/components/calendar/CalendarHeader.svelte +98 -0
- package/dist/components/calendar/CalendarHeader.svelte.d.ts +24 -0
- package/dist/components/calendar/MonthGrid.svelte +361 -0
- package/dist/components/calendar/MonthGrid.svelte.d.ts +55 -0
- package/dist/components/calendar/calendarGrid.d.ts +219 -0
- package/dist/components/calendar/calendarGrid.js +494 -0
- package/dist/components/calendar/createCalendarGrid.d.ts +25 -0
- package/dist/components/calendar/createCalendarGrid.js +57 -0
- package/dist/components/calendar/dayOverlap.d.ts +92 -0
- package/dist/components/calendar/dayOverlap.js +147 -0
- package/dist/components/calendar/eventCalendar.d.ts +75 -0
- package/dist/components/calendar/eventCalendar.js +187 -0
- package/dist/components/calendar/index.d.ts +32 -0
- package/dist/components/calendar/index.js +25 -0
- package/dist/components/calendar/monthPacking.d.ts +97 -0
- package/dist/components/calendar/monthPacking.js +254 -0
- package/dist/components/calendar/timeSlots.d.ts +57 -0
- package/dist/components/calendar/timeSlots.js +163 -0
- package/dist/components/field/FieldHint.svelte +29 -0
- package/dist/components/field/FieldHint.svelte.d.ts +17 -0
- package/dist/components/field/FieldLabel.svelte +100 -0
- package/dist/components/field/FieldLabel.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckGlyph.svelte +138 -0
- package/dist/components/form-cards/CheckGlyph.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckboxCard.svelte +120 -0
- package/dist/components/form-cards/CheckboxCard.svelte.d.ts +33 -0
- package/dist/components/form-cards/ChoiceCard.svelte +156 -0
- package/dist/components/form-cards/ChoiceCard.svelte.d.ts +42 -0
- package/dist/components/form-cards/SwitchCard.svelte +114 -0
- package/dist/components/form-cards/SwitchCard.svelte.d.ts +28 -0
- package/dist/components/toast/ToastHost.svelte +56 -3
- package/dist/components/toast/ToastHost.svelte.d.ts +9 -1
- package/dist/components/toast/ToastItem.svelte +75 -23
- package/dist/components/toast/ToastItem.svelte.d.ts +2 -0
- package/dist/components/uploadProgress.d.ts +30 -0
- package/dist/components/uploadProgress.js +47 -0
- package/dist/index.d.ts +29 -2
- package/dist/index.js +22 -1
- package/dist/stores/toast.d.ts +35 -0
- package/dist/stores/toast.js +15 -1
- package/dist/styles/motion.css +72 -0
- package/dist/styles/rail.css +51 -0
- 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
|
-
:
|
|
314
|
-
? '
|
|
315
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
void
|
|
120
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
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[];
|