@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
|
@@ -4,9 +4,31 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Accepts an array of TimeSlot objects with `start` (Date), `end` (Date), and `available` (boolean).
|
|
6
6
|
* Grouped automatically by hour: morning (<12), afternoon (<17), evening (17+).
|
|
7
|
+
*
|
|
8
|
+
* jatui-lfxyy upgrades (all additive):
|
|
9
|
+
* - One `role="radiogroup"`; each slot is a `role="radio"` with `aria-checked`
|
|
10
|
+
* and a full accessible name ("Wednesday, March 4, 9:30 AM"). Roving focus:
|
|
11
|
+
* arrows move between available slots (Up/Down jump a visual row), Home/End,
|
|
12
|
+
* Space/Enter choose. `selectOnFocus` makes arrows choose too (radio
|
|
13
|
+
* default) — off by default because choosing usually advances a booking flow.
|
|
14
|
+
* - `hourCycle` ('h12' | 'h23') and `timeZone` (IANA): times and the
|
|
15
|
+
* morning/afternoon/evening grouping follow the zone's wall clock.
|
|
16
|
+
* - `timezone` snippet: a row above the slots for a zone picker / 12-24h toggle.
|
|
17
|
+
* - The accent comes from a static token map (the old interpolated
|
|
18
|
+
* `bg-${accentColor}` classes were never generated by Tailwind).
|
|
7
19
|
*/
|
|
8
20
|
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
9
22
|
import type { TimeSlot } from '../types/booking';
|
|
23
|
+
import {
|
|
24
|
+
formatSlotLabel,
|
|
25
|
+
formatSlotTime,
|
|
26
|
+
groupSlotsByDayPart,
|
|
27
|
+
nextSlotIndex,
|
|
28
|
+
type DayPart,
|
|
29
|
+
type HourCycle
|
|
30
|
+
} from './calendar/timeSlots';
|
|
31
|
+
import { calendarAccentStyle } from './calendar/calendarGrid';
|
|
10
32
|
|
|
11
33
|
export type { TimeSlot };
|
|
12
34
|
|
|
@@ -16,6 +38,14 @@
|
|
|
16
38
|
onSelect,
|
|
17
39
|
selectedSlot = null,
|
|
18
40
|
accentColor = 'primary',
|
|
41
|
+
hourCycle = 'h12',
|
|
42
|
+
timeZone,
|
|
43
|
+
locale = 'en-US',
|
|
44
|
+
selectOnFocus = false,
|
|
45
|
+
ariaLabel = 'Available times',
|
|
46
|
+
emptyTitle = 'No Available Times',
|
|
47
|
+
emptyMessage = 'Please select a different date.',
|
|
48
|
+
timezone
|
|
19
49
|
}: {
|
|
20
50
|
slots: TimeSlot[];
|
|
21
51
|
loading?: boolean;
|
|
@@ -24,153 +54,189 @@
|
|
|
24
54
|
* Compared by start-time value (not object identity) so a re-fetched
|
|
25
55
|
* slots array still matches the selection. */
|
|
26
56
|
selectedSlot?: TimeSlot | null;
|
|
57
|
+
/** primary | secondary | accent | info | success | warning | error | neutral */
|
|
27
58
|
accentColor?: string;
|
|
59
|
+
/** 'h12' → "9:30 AM", 'h23' → "09:30". */
|
|
60
|
+
hourCycle?: HourCycle;
|
|
61
|
+
/** IANA zone the times (and their grouping) are shown in. Defaults to the runtime zone. */
|
|
62
|
+
timeZone?: string;
|
|
63
|
+
locale?: string;
|
|
64
|
+
/** Arrow keys also choose the slot they land on (strict radio behaviour). */
|
|
65
|
+
selectOnFocus?: boolean;
|
|
66
|
+
ariaLabel?: string;
|
|
67
|
+
emptyTitle?: string;
|
|
68
|
+
emptyMessage?: string;
|
|
69
|
+
/** Rendered above the slot list — a time-zone row or a 12/24h toggle. */
|
|
70
|
+
timezone?: Snippet;
|
|
28
71
|
} = $props();
|
|
29
72
|
|
|
73
|
+
const uid = `tsp-${Math.random().toString(36).slice(2, 8)}`;
|
|
74
|
+
const PART_LABEL: Record<DayPart, string> = { morning: 'Morning', afternoon: 'Afternoon', evening: 'Evening' };
|
|
75
|
+
|
|
30
76
|
const isSelected = (slot: TimeSlot): boolean =>
|
|
31
77
|
selectedSlot != null && slot.start.getTime() === selectedSlot.start.getTime();
|
|
32
78
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
79
|
+
const groups = $derived(groupSlotsByDayPart(slots, timeZone));
|
|
80
|
+
/** Slots in visual order (group by group) — the roving-focus sequence. */
|
|
81
|
+
const ordered = $derived(groups.flatMap((g) => g.slots));
|
|
82
|
+
const availableCount = $derived(slots.filter((s) => s.available).length);
|
|
37
83
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (hour < 12) {
|
|
41
|
-
morning.push(slot);
|
|
42
|
-
} else if (hour < 17) {
|
|
43
|
-
afternoon.push(slot);
|
|
44
|
-
} else {
|
|
45
|
-
evening.push(slot);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
84
|
+
let rootEl = $state<HTMLDivElement | null>(null);
|
|
85
|
+
let focusIndex = $state(-1);
|
|
48
86
|
|
|
49
|
-
|
|
87
|
+
/** The one slot in the tab order: the focused one, else the selection, else the first available. */
|
|
88
|
+
const tabIndexSlot = $derived.by(() => {
|
|
89
|
+
if (focusIndex >= 0 && ordered[focusIndex]?.available) return focusIndex;
|
|
90
|
+
const sel = ordered.findIndex((s) => s.available && isSelected(s));
|
|
91
|
+
if (sel >= 0) return sel;
|
|
92
|
+
return ordered.findIndex((s) => s.available);
|
|
50
93
|
});
|
|
51
94
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
function handleSlotClick(slot: TimeSlot, index: number) {
|
|
96
|
+
focusIndex = index;
|
|
97
|
+
if (slot.available) onSelect(slot);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Columns in the slot's own grid row, read from layout (3 on mobile, 4 from sm). */
|
|
101
|
+
function columnsAt(el: HTMLElement): number {
|
|
102
|
+
const grid = el.parentElement;
|
|
103
|
+
if (!grid) return 1;
|
|
104
|
+
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
|
|
105
|
+
return cols || 1;
|
|
58
106
|
}
|
|
59
107
|
|
|
60
|
-
function
|
|
61
|
-
if (
|
|
62
|
-
|
|
108
|
+
function handleKeydown(e: KeyboardEvent, index: number) {
|
|
109
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
const slot = ordered[index];
|
|
112
|
+
if (slot?.available) onSelect(slot);
|
|
113
|
+
return;
|
|
63
114
|
}
|
|
115
|
+
const rtl = getComputedStyle(e.currentTarget as HTMLElement).direction === 'rtl';
|
|
116
|
+
const next = nextSlotIndex(ordered, index, e.key, { columns: columnsAt(e.currentTarget as HTMLElement), rtl });
|
|
117
|
+
if (next === null) return;
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
focusIndex = next;
|
|
120
|
+
rootEl?.querySelector<HTMLElement>(`[data-slot-index="${next}"]`)?.focus();
|
|
121
|
+
if (selectOnFocus && ordered[next]?.available) onSelect(ordered[next]);
|
|
64
122
|
}
|
|
65
123
|
</script>
|
|
66
124
|
|
|
67
|
-
<div class="time-slot-picker">
|
|
125
|
+
<div class="time-slot-picker" style={calendarAccentStyle(accentColor)} bind:this={rootEl}>
|
|
126
|
+
{#if timezone}
|
|
127
|
+
<div class="time-slot-picker__tz">{@render timezone()}</div>
|
|
128
|
+
{/if}
|
|
129
|
+
|
|
68
130
|
{#if loading}
|
|
69
|
-
<div class="flex justify-center py-12">
|
|
70
|
-
<span class="loading loading-spinner loading-lg
|
|
131
|
+
<div class="flex justify-center py-12" role="status" aria-label="Loading times">
|
|
132
|
+
<span class="loading loading-spinner loading-lg time-slot-picker__spinner"></span>
|
|
71
133
|
</div>
|
|
72
134
|
{:else if slots.length === 0}
|
|
73
135
|
<div class="text-center py-12">
|
|
74
136
|
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-base-200 flex items-center justify-center">
|
|
75
137
|
<!-- Clock icon -->
|
|
76
|
-
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
138
|
+
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
77
139
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
78
140
|
</svg>
|
|
79
141
|
</div>
|
|
80
|
-
<h3 class="text-lg font-semibold mb-2">
|
|
81
|
-
<p class="text-base-content/60">
|
|
142
|
+
<h3 class="text-lg font-semibold mb-2">{emptyTitle}</h3>
|
|
143
|
+
<p class="text-base-content/60">{emptyMessage}</p>
|
|
82
144
|
</div>
|
|
83
145
|
{:else}
|
|
84
|
-
{@const { morning, afternoon, evening } = groupedSlots()}
|
|
85
|
-
{@const availableCount = slots.filter(s => s.available).length}
|
|
86
|
-
|
|
87
146
|
<p class="text-[0.8125rem] text-base-content/45 mb-4">
|
|
88
147
|
{availableCount} time{availableCount !== 1 ? 's' : ''} available
|
|
89
148
|
</p>
|
|
90
149
|
|
|
91
|
-
<div class="space-y-6">
|
|
92
|
-
{#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
150
|
+
<div class="space-y-6" role="radiogroup" aria-label={ariaLabel}>
|
|
151
|
+
{#each groups as group (group.part)}
|
|
152
|
+
{@const offset = ordered.indexOf(group.slots[0])}
|
|
153
|
+
<div role="group" aria-labelledby="{uid}-{group.part}">
|
|
154
|
+
<h4 id="{uid}-{group.part}" class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
|
|
155
|
+
{#if group.part === 'evening'}
|
|
156
|
+
<!-- Moon icon -->
|
|
157
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
158
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
159
|
+
</svg>
|
|
160
|
+
{:else}
|
|
161
|
+
<!-- Sun icon -->
|
|
162
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
163
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
164
|
+
</svg>
|
|
165
|
+
{/if}
|
|
166
|
+
{PART_LABEL[group.part]}
|
|
100
167
|
</h4>
|
|
101
168
|
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
|
102
|
-
{#each
|
|
169
|
+
{#each group.slots as slot, j (slot.start.getTime())}
|
|
170
|
+
{@const index = offset + j}
|
|
103
171
|
<button
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
172
|
+
type="button"
|
|
173
|
+
role="radio"
|
|
174
|
+
aria-checked={isSelected(slot)}
|
|
175
|
+
aria-disabled={!slot.available ? 'true' : undefined}
|
|
176
|
+
aria-label="{formatSlotLabel(slot.start, { hourCycle, timeZone, locale })}{slot.available ? '' : ', unavailable'}"
|
|
177
|
+
tabindex={index === tabIndexSlot ? 0 : -1}
|
|
178
|
+
data-slot-index={index}
|
|
179
|
+
onclick={() => handleSlotClick(slot, index)}
|
|
180
|
+
onkeydown={(e) => handleKeydown(e, index)}
|
|
181
|
+
onfocus={() => (focusIndex = index)}
|
|
182
|
+
class="time-slot-picker__slot py-3 px-4 rounded-lg text-[0.9375rem] font-medium tabular-nums"
|
|
183
|
+
class:is-selected={slot.available && isSelected(slot)}
|
|
184
|
+
class:is-unavailable={!slot.available}
|
|
112
185
|
>
|
|
113
|
-
{
|
|
186
|
+
{formatSlotTime(slot.start, { hourCycle, timeZone, locale })}
|
|
114
187
|
</button>
|
|
115
188
|
{/each}
|
|
116
189
|
</div>
|
|
117
190
|
</div>
|
|
118
|
-
{/
|
|
119
|
-
|
|
120
|
-
{#if afternoon.length > 0}
|
|
121
|
-
<div>
|
|
122
|
-
<h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
|
|
123
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
124
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
125
|
-
</svg>
|
|
126
|
-
Afternoon
|
|
127
|
-
</h4>
|
|
128
|
-
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
|
129
|
-
{#each afternoon as slot}
|
|
130
|
-
<button
|
|
131
|
-
onclick={() => handleSlotClick(slot)}
|
|
132
|
-
disabled={!slot.available}
|
|
133
|
-
class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
|
|
134
|
-
{slot.available
|
|
135
|
-
? isSelected(slot)
|
|
136
|
-
? `bg-${accentColor} text-${accentColor}-content ring-2 ring-${accentColor} ring-offset-2 ring-offset-base-100 font-semibold`
|
|
137
|
-
: `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
|
|
138
|
-
: 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
|
|
139
|
-
>
|
|
140
|
-
{formatTime(slot.start)}
|
|
141
|
-
</button>
|
|
142
|
-
{/each}
|
|
143
|
-
</div>
|
|
144
|
-
</div>
|
|
145
|
-
{/if}
|
|
146
|
-
|
|
147
|
-
{#if evening.length > 0}
|
|
148
|
-
<div>
|
|
149
|
-
<h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
|
|
150
|
-
<!-- Moon icon -->
|
|
151
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
152
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
153
|
-
</svg>
|
|
154
|
-
Evening
|
|
155
|
-
</h4>
|
|
156
|
-
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
|
157
|
-
{#each evening as slot}
|
|
158
|
-
<button
|
|
159
|
-
onclick={() => handleSlotClick(slot)}
|
|
160
|
-
disabled={!slot.available}
|
|
161
|
-
class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
|
|
162
|
-
{slot.available
|
|
163
|
-
? isSelected(slot)
|
|
164
|
-
? `bg-${accentColor} text-${accentColor}-content ring-2 ring-${accentColor} ring-offset-2 ring-offset-base-100 font-semibold`
|
|
165
|
-
: `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
|
|
166
|
-
: 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
|
|
167
|
-
>
|
|
168
|
-
{formatTime(slot.start)}
|
|
169
|
-
</button>
|
|
170
|
-
{/each}
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
{/if}
|
|
191
|
+
{/each}
|
|
174
192
|
</div>
|
|
175
193
|
{/if}
|
|
176
194
|
</div>
|
|
195
|
+
|
|
196
|
+
<style>
|
|
197
|
+
.time-slot-picker {
|
|
198
|
+
--_accent: var(--jatui-cal-accent, var(--jatui-selection-solid, var(--color-primary)));
|
|
199
|
+
--_accent-fg: var(--jatui-cal-accent-fg, var(--jatui-selection-solid-fg, var(--color-primary-content)));
|
|
200
|
+
}
|
|
201
|
+
.time-slot-picker__tz {
|
|
202
|
+
margin-block-end: 0.75rem;
|
|
203
|
+
}
|
|
204
|
+
.time-slot-picker__spinner {
|
|
205
|
+
color: var(--_accent);
|
|
206
|
+
}
|
|
207
|
+
.time-slot-picker__slot {
|
|
208
|
+
background: color-mix(in oklab, var(--color-base-200) 50%, transparent);
|
|
209
|
+
color: var(--color-base-content);
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
transition:
|
|
212
|
+
background-color var(--motion-fast, 150ms) var(--motion-standard, ease),
|
|
213
|
+
color var(--motion-fast, 150ms) var(--motion-standard, ease);
|
|
214
|
+
}
|
|
215
|
+
.time-slot-picker__slot:hover:not(.is-unavailable):not(.is-selected) {
|
|
216
|
+
background: var(--_accent);
|
|
217
|
+
color: var(--_accent-fg);
|
|
218
|
+
}
|
|
219
|
+
.time-slot-picker__slot:focus-visible {
|
|
220
|
+
outline: var(--jatui-focus-ring-width, 2px) solid var(--jatui-focus-ring, var(--color-primary));
|
|
221
|
+
outline-offset: var(--jatui-focus-ring-offset, 2px);
|
|
222
|
+
}
|
|
223
|
+
.time-slot-picker__slot.is-selected {
|
|
224
|
+
background: var(--_accent);
|
|
225
|
+
color: var(--_accent-fg);
|
|
226
|
+
font-weight: 600;
|
|
227
|
+
box-shadow:
|
|
228
|
+
0 0 0 2px var(--color-base-100),
|
|
229
|
+
0 0 0 4px var(--_accent);
|
|
230
|
+
}
|
|
231
|
+
.time-slot-picker__slot.is-unavailable {
|
|
232
|
+
background: color-mix(in oklab, var(--color-base-200) 20%, transparent);
|
|
233
|
+
color: color-mix(in oklab, var(--color-base-content) 30%, transparent);
|
|
234
|
+
cursor: not-allowed;
|
|
235
|
+
text-decoration: line-through;
|
|
236
|
+
}
|
|
237
|
+
@media (prefers-reduced-motion: reduce) {
|
|
238
|
+
.time-slot-picker__slot {
|
|
239
|
+
transition: none;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
@@ -3,8 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Accepts an array of TimeSlot objects with `start` (Date), `end` (Date), and `available` (boolean).
|
|
5
5
|
* Grouped automatically by hour: morning (<12), afternoon (<17), evening (17+).
|
|
6
|
+
*
|
|
7
|
+
* jatui-lfxyy upgrades (all additive):
|
|
8
|
+
* - One `role="radiogroup"`; each slot is a `role="radio"` with `aria-checked`
|
|
9
|
+
* and a full accessible name ("Wednesday, March 4, 9:30 AM"). Roving focus:
|
|
10
|
+
* arrows move between available slots (Up/Down jump a visual row), Home/End,
|
|
11
|
+
* Space/Enter choose. `selectOnFocus` makes arrows choose too (radio
|
|
12
|
+
* default) — off by default because choosing usually advances a booking flow.
|
|
13
|
+
* - `hourCycle` ('h12' | 'h23') and `timeZone` (IANA): times and the
|
|
14
|
+
* morning/afternoon/evening grouping follow the zone's wall clock.
|
|
15
|
+
* - `timezone` snippet: a row above the slots for a zone picker / 12-24h toggle.
|
|
16
|
+
* - The accent comes from a static token map (the old interpolated
|
|
17
|
+
* `bg-${accentColor}` classes were never generated by Tailwind).
|
|
6
18
|
*/
|
|
19
|
+
import type { Snippet } from 'svelte';
|
|
7
20
|
import type { TimeSlot } from '../types/booking';
|
|
21
|
+
import { type HourCycle } from './calendar/timeSlots';
|
|
8
22
|
type $$ComponentProps = {
|
|
9
23
|
slots: TimeSlot[];
|
|
10
24
|
loading?: boolean;
|
|
@@ -13,7 +27,20 @@ type $$ComponentProps = {
|
|
|
13
27
|
* Compared by start-time value (not object identity) so a re-fetched
|
|
14
28
|
* slots array still matches the selection. */
|
|
15
29
|
selectedSlot?: TimeSlot | null;
|
|
30
|
+
/** primary | secondary | accent | info | success | warning | error | neutral */
|
|
16
31
|
accentColor?: string;
|
|
32
|
+
/** 'h12' → "9:30 AM", 'h23' → "09:30". */
|
|
33
|
+
hourCycle?: HourCycle;
|
|
34
|
+
/** IANA zone the times (and their grouping) are shown in. Defaults to the runtime zone. */
|
|
35
|
+
timeZone?: string;
|
|
36
|
+
locale?: string;
|
|
37
|
+
/** Arrow keys also choose the slot they land on (strict radio behaviour). */
|
|
38
|
+
selectOnFocus?: boolean;
|
|
39
|
+
ariaLabel?: string;
|
|
40
|
+
emptyTitle?: string;
|
|
41
|
+
emptyMessage?: string;
|
|
42
|
+
/** Rendered above the slot list — a time-zone row or a 12/24h toggle. */
|
|
43
|
+
timezone?: Snippet;
|
|
17
44
|
};
|
|
18
45
|
declare const TimeSlotPicker: import("svelte").Component<$$ComponentProps, {
|
|
19
46
|
TimeSlot: typeof TimeSlot;
|
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { motionScale } from '../../motion/transitions';
|
|
4
|
+
import CloseButton from '../CloseButton.svelte';
|
|
5
|
+
|
|
2
6
|
interface Props {
|
|
3
7
|
type?: 'info' | 'success' | 'warning' | 'error';
|
|
4
8
|
title?: string;
|
|
5
9
|
class?: string;
|
|
6
|
-
children?:
|
|
10
|
+
children?: Snippet;
|
|
11
|
+
/** Show a Dismiss (×) button. The alert exits, then `onClose` fires. */
|
|
12
|
+
dismissible?: boolean;
|
|
13
|
+
/** Fired after the dismiss exit transition has finished. */
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
/** Call-to-action area (buttons/links). Full-width in `variant="card"`. */
|
|
16
|
+
action?: Snippet;
|
|
17
|
+
/**
|
|
18
|
+
* Delay (ms) before the alert enters — lets surrounding UI settle first.
|
|
19
|
+
* Reduced motion: it still waits, then appears without animating.
|
|
20
|
+
*/
|
|
21
|
+
introDelay?: number;
|
|
22
|
+
/** 'card' = vertical, sidebar-density layout: icon on top, title, text, CTA. */
|
|
23
|
+
variant?: 'default' | 'card';
|
|
24
|
+
/** Accessible label for the dismiss button. */
|
|
25
|
+
dismissLabel?: string;
|
|
7
26
|
}
|
|
8
27
|
|
|
9
|
-
let {
|
|
28
|
+
let {
|
|
29
|
+
type = 'info',
|
|
30
|
+
title,
|
|
31
|
+
class: className = '',
|
|
32
|
+
children,
|
|
33
|
+
dismissible = false,
|
|
34
|
+
onClose,
|
|
35
|
+
action,
|
|
36
|
+
introDelay = 0,
|
|
37
|
+
variant = 'default',
|
|
38
|
+
dismissLabel = 'Dismiss'
|
|
39
|
+
}: Props = $props();
|
|
10
40
|
|
|
11
41
|
const typeClass = $derived(
|
|
12
42
|
{ info: 'alert-info', success: 'alert-success', warning: 'alert-warning', error: 'alert-error' }[type]
|
|
@@ -18,19 +48,86 @@
|
|
|
18
48
|
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
|
19
49
|
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'
|
|
20
50
|
};
|
|
51
|
+
|
|
52
|
+
// introDelay: start hidden, enter after the delay (the `in:` transition only
|
|
53
|
+
// plays on a toggle, never on first render — so no delay means no intro).
|
|
54
|
+
let visible = $state(introDelay <= 0);
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (visible || introDelay <= 0) return;
|
|
57
|
+
const t = setTimeout(() => (visible = true), introDelay);
|
|
58
|
+
return () => clearTimeout(t);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function dismiss() {
|
|
62
|
+
visible = false;
|
|
63
|
+
}
|
|
21
64
|
</script>
|
|
22
65
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
{#snippet closeButton(extra: string)}
|
|
67
|
+
<CloseButton
|
|
68
|
+
label={dismissLabel}
|
|
69
|
+
size="xs"
|
|
70
|
+
shape="square"
|
|
71
|
+
class="jatui-alert-close {extra}"
|
|
72
|
+
onclick={dismiss}
|
|
73
|
+
/>
|
|
74
|
+
{/snippet}
|
|
75
|
+
|
|
76
|
+
<!-- `{#if visible}` wraps each element directly: transitions are local, so
|
|
77
|
+
they only play when their IMMEDIATE block toggles. -->
|
|
78
|
+
{#if variant === 'card'}
|
|
79
|
+
{#if visible}
|
|
80
|
+
<div
|
|
81
|
+
class="alert {typeClass} jatui-alert-card relative flex flex-col items-start gap-2 text-start {dismissible ? 'pe-10' : ''} {className}"
|
|
82
|
+
role={type === 'error' ? 'alert' : 'status'}
|
|
83
|
+
in:motionScale={{ start: 0.95 }}
|
|
84
|
+
out:motionScale={{ start: 0.85 }}
|
|
85
|
+
onoutroend={() => onClose?.()}
|
|
86
|
+
>
|
|
87
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
88
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icons[type]} />
|
|
89
|
+
</svg>
|
|
90
|
+
<div class="w-full min-w-0">
|
|
91
|
+
{#if title}
|
|
92
|
+
<h3 class="font-bold">{title}</h3>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if children}<div class="text-sm opacity-80">{@render children()}</div>{/if}
|
|
95
|
+
</div>
|
|
96
|
+
{#if action}
|
|
97
|
+
<div class="jatui-alert-action jatui-alert-action--full w-full">{@render action()}</div>
|
|
98
|
+
{/if}
|
|
99
|
+
{#if dismissible}{@render closeButton('absolute top-2 end-2')}{/if}
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
{:else}
|
|
103
|
+
{#if visible}
|
|
104
|
+
<div
|
|
105
|
+
class="alert {typeClass} {className}"
|
|
106
|
+
role={type === 'error' ? 'alert' : 'status'}
|
|
107
|
+
in:motionScale={{ start: 0.95 }}
|
|
108
|
+
out:motionScale={{ start: 0.85 }}
|
|
109
|
+
onoutroend={() => onClose?.()}
|
|
110
|
+
>
|
|
111
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
112
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icons[type]} />
|
|
113
|
+
</svg>
|
|
114
|
+
<div>
|
|
115
|
+
{#if title}
|
|
116
|
+
<h3 class="font-bold">{title}</h3>
|
|
117
|
+
{/if}
|
|
118
|
+
{#if children}{@render children()}{/if}
|
|
119
|
+
</div>
|
|
120
|
+
{#if action}
|
|
121
|
+
<div class="jatui-alert-action flex flex-wrap items-center gap-2">{@render action()}</div>
|
|
122
|
+
{/if}
|
|
123
|
+
{#if dismissible}{@render closeButton('')}{/if}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
/* Full-width CTA in the card layout: stretch any direct button/link child. */
|
|
130
|
+
.jatui-alert-action--full > :global(*) {
|
|
131
|
+
width: 100%;
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
interface Props {
|
|
2
3
|
type?: 'info' | 'success' | 'warning' | 'error';
|
|
3
4
|
title?: string;
|
|
4
5
|
class?: string;
|
|
5
|
-
children?:
|
|
6
|
+
children?: Snippet;
|
|
7
|
+
/** Show a Dismiss (×) button. The alert exits, then `onClose` fires. */
|
|
8
|
+
dismissible?: boolean;
|
|
9
|
+
/** Fired after the dismiss exit transition has finished. */
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
/** Call-to-action area (buttons/links). Full-width in `variant="card"`. */
|
|
12
|
+
action?: Snippet;
|
|
13
|
+
/**
|
|
14
|
+
* Delay (ms) before the alert enters — lets surrounding UI settle first.
|
|
15
|
+
* Reduced motion: it still waits, then appears without animating.
|
|
16
|
+
*/
|
|
17
|
+
introDelay?: number;
|
|
18
|
+
/** 'card' = vertical, sidebar-density layout: icon on top, title, text, CTA. */
|
|
19
|
+
variant?: 'default' | 'card';
|
|
20
|
+
/** Accessible label for the dismiss button. */
|
|
21
|
+
dismissLabel?: string;
|
|
6
22
|
}
|
|
7
23
|
declare const Alert: import("svelte").Component<Props, {}, "">;
|
|
8
24
|
type Alert = ReturnType<typeof Alert>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import CheckGlyph from '../form-cards/CheckGlyph.svelte';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Any other native attribute (aria-label, aria-describedby, data-*, title,
|
|
@@ -25,6 +26,12 @@
|
|
|
25
26
|
id?: string;
|
|
26
27
|
onchange?: (checked: boolean) => void;
|
|
27
28
|
class?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Draw the box with the shared `CheckGlyph` (the same one `CheckboxCard`
|
|
31
|
+
* uses) and draw the tick on when the user checks it. The native input
|
|
32
|
+
* stays in the DOM, visually hidden, so keys, forms and AT are unchanged.
|
|
33
|
+
*/
|
|
34
|
+
animated?: boolean;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
let {
|
|
@@ -38,9 +45,14 @@
|
|
|
38
45
|
id,
|
|
39
46
|
onchange,
|
|
40
47
|
class: className = '',
|
|
48
|
+
animated = false,
|
|
41
49
|
...rest
|
|
42
50
|
}: Props = $props();
|
|
43
51
|
|
|
52
|
+
// Only animate the tick after the user has changed the box, so a box that
|
|
53
|
+
// renders pre-checked shows its tick at rest instead of drawing it on load.
|
|
54
|
+
let interacted = $state(false);
|
|
55
|
+
|
|
44
56
|
const sizeClass = $derived({ xs: 'checkbox-xs', sm: 'checkbox-sm', md: '', lg: 'checkbox-lg' }[size]);
|
|
45
57
|
const variantClass = $derived(
|
|
46
58
|
{
|
|
@@ -68,11 +80,32 @@
|
|
|
68
80
|
|
|
69
81
|
function handleChange(e: Event) {
|
|
70
82
|
checked = (e.target as HTMLInputElement).checked;
|
|
83
|
+
interacted = true;
|
|
71
84
|
onchange?.(checked);
|
|
72
85
|
}
|
|
73
86
|
</script>
|
|
74
87
|
|
|
75
|
-
{#if
|
|
88
|
+
{#if animated}
|
|
89
|
+
<label
|
|
90
|
+
class="jatui-checkbox-target jatui-checkbox-animated {label ? 'jatui-checkbox-row' : 'jatui-checkbox-bare'} {className}"
|
|
91
|
+
class:jatui-checkbox-disabled={disabled}
|
|
92
|
+
for={inputId}
|
|
93
|
+
>
|
|
94
|
+
<input
|
|
95
|
+
{...rest}
|
|
96
|
+
bind:this={inputEl}
|
|
97
|
+
type="checkbox"
|
|
98
|
+
{name}
|
|
99
|
+
id={inputId}
|
|
100
|
+
class="jatui-checkbox-native"
|
|
101
|
+
checked={checked}
|
|
102
|
+
{disabled}
|
|
103
|
+
onchange={handleChange}
|
|
104
|
+
/>
|
|
105
|
+
<CheckGlyph {checked} {indeterminate} {disabled} {size} {variant} animate={interacted} />
|
|
106
|
+
{#if label}<span class="label-text">{label}</span>{/if}
|
|
107
|
+
</label>
|
|
108
|
+
{:else if label}
|
|
76
109
|
<label class="jatui-checkbox-target jatui-checkbox-row" for={inputId}>
|
|
77
110
|
<input
|
|
78
111
|
{...rest}
|
|
@@ -114,6 +147,25 @@
|
|
|
114
147
|
display: flex;
|
|
115
148
|
gap: 0.5rem;
|
|
116
149
|
}
|
|
150
|
+
/* animated: the native input is visually hidden but stays focusable */
|
|
151
|
+
.jatui-checkbox-animated {
|
|
152
|
+
position: relative;
|
|
153
|
+
}
|
|
154
|
+
.jatui-checkbox-native {
|
|
155
|
+
position: absolute;
|
|
156
|
+
width: 1px;
|
|
157
|
+
height: 1px;
|
|
158
|
+
margin: 0;
|
|
159
|
+
opacity: 0;
|
|
160
|
+
pointer-events: none;
|
|
161
|
+
}
|
|
162
|
+
.jatui-checkbox-animated:has(.jatui-checkbox-native:focus-visible) :global(.jatui-check-glyph) {
|
|
163
|
+
outline: 2px solid color-mix(in oklch, var(--color-primary) 65%, transparent);
|
|
164
|
+
outline-offset: 2px;
|
|
165
|
+
}
|
|
166
|
+
.jatui-checkbox-disabled {
|
|
167
|
+
cursor: not-allowed;
|
|
168
|
+
}
|
|
117
169
|
.jatui-checkbox-bare {
|
|
118
170
|
display: inline-flex;
|
|
119
171
|
justify-content: center;
|
|
@@ -19,6 +19,12 @@ interface Props extends Omit<HTMLInputAttributes, 'type' | 'checked' | 'size' |
|
|
|
19
19
|
id?: string;
|
|
20
20
|
onchange?: (checked: boolean) => void;
|
|
21
21
|
class?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Draw the box with the shared `CheckGlyph` (the same one `CheckboxCard`
|
|
24
|
+
* uses) and draw the tick on when the user checks it. The native input
|
|
25
|
+
* stays in the DOM, visually hidden, so keys, forms and AT are unchanged.
|
|
26
|
+
*/
|
|
27
|
+
animated?: boolean;
|
|
22
28
|
}
|
|
23
29
|
declare const Checkbox: import("svelte").Component<Props, {}, "checked">;
|
|
24
30
|
type Checkbox = ReturnType<typeof Checkbox>;
|