@joewinke/jatui 0.41.1 → 0.42.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 (34) hide show
  1. package/CLAUDE.md +9 -3
  2. package/dist/components/BookingScheduler.svelte +412 -0
  3. package/dist/components/BookingScheduler.svelte.d.ts +66 -0
  4. package/dist/components/CalendarPicker.svelte +190 -205
  5. package/dist/components/CalendarPicker.svelte.d.ts +19 -1
  6. package/dist/components/DatePicker.svelte +419 -0
  7. package/dist/components/DatePicker.svelte.d.ts +44 -0
  8. package/dist/components/DateRangePicker.svelte +471 -5
  9. package/dist/components/DateRangePicker.svelte.d.ts +32 -3
  10. package/dist/components/EventCalendar.svelte +1324 -0
  11. package/dist/components/EventCalendar.svelte.d.ts +101 -0
  12. package/dist/components/TimeSlotPicker.svelte +174 -108
  13. package/dist/components/TimeSlotPicker.svelte.d.ts +27 -0
  14. package/dist/components/calendar/CalendarHeader.svelte +98 -0
  15. package/dist/components/calendar/CalendarHeader.svelte.d.ts +24 -0
  16. package/dist/components/calendar/MonthGrid.svelte +361 -0
  17. package/dist/components/calendar/MonthGrid.svelte.d.ts +55 -0
  18. package/dist/components/calendar/calendarGrid.d.ts +219 -0
  19. package/dist/components/calendar/calendarGrid.js +494 -0
  20. package/dist/components/calendar/createCalendarGrid.d.ts +25 -0
  21. package/dist/components/calendar/createCalendarGrid.js +57 -0
  22. package/dist/components/calendar/dayOverlap.d.ts +92 -0
  23. package/dist/components/calendar/dayOverlap.js +147 -0
  24. package/dist/components/calendar/eventCalendar.d.ts +75 -0
  25. package/dist/components/calendar/eventCalendar.js +187 -0
  26. package/dist/components/calendar/index.d.ts +32 -0
  27. package/dist/components/calendar/index.js +25 -0
  28. package/dist/components/calendar/monthPacking.d.ts +97 -0
  29. package/dist/components/calendar/monthPacking.js +254 -0
  30. package/dist/components/calendar/timeSlots.d.ts +57 -0
  31. package/dist/components/calendar/timeSlots.js +163 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +11 -0
  34. 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;
@@ -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 groupedSlots = $derived(() => {
34
- const morning: TimeSlot[] = [];
35
- const afternoon: TimeSlot[] = [];
36
- const evening: TimeSlot[] = [];
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
- for (const slot of slots) {
39
- const hour = slot.start.getHours();
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
- return { morning, afternoon, evening };
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 formatTime(date: Date): string {
53
- return date.toLocaleTimeString('en-US', {
54
- hour: 'numeric',
55
- minute: '2-digit',
56
- hour12: true
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 handleSlotClick(slot: TimeSlot) {
61
- if (slot.available) {
62
- onSelect(slot);
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 text-{accentColor}"></span>
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">No Available Times</h3>
81
- <p class="text-base-content/60">Please select a different date.</p>
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
- {#if morning.length > 0}
93
- <div>
94
- <h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
95
- <!-- Sun icon -->
96
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
97
- <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" />
98
- </svg>
99
- Morning
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 morning as slot}
169
+ {#each group.slots as slot, j (slot.start.getTime())}
170
+ {@const index = offset + j}
103
171
  <button
104
- onclick={() => handleSlotClick(slot)}
105
- disabled={!slot.available}
106
- class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
107
- {slot.available
108
- ? isSelected(slot)
109
- ? `bg-${accentColor} text-${accentColor}-content ring-2 ring-${accentColor} ring-offset-2 ring-offset-base-100 font-semibold`
110
- : `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
111
- : 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
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
- {formatTime(slot.start)}
186
+ {formatSlotTime(slot.start, { hourCycle, timeZone, locale })}
114
187
  </button>
115
188
  {/each}
116
189
  </div>
117
190
  </div>
118
- {/if}
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;
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ /**
3
+ * CalendarHeader — previous/next month buttons around a live month title.
4
+ * Shared by every month panel (CalendarPicker, DatePicker, DateRangePicker,
5
+ * BookingScheduler). `title` replaces the plain heading (EventCalendar uses it
6
+ * for its month-switcher pill).
7
+ */
8
+ import type { Snippet } from 'svelte';
9
+ import IconButton from '../IconButton.svelte';
10
+
11
+ let {
12
+ label,
13
+ onPrev,
14
+ onNext,
15
+ canPrev = true,
16
+ canNext = true,
17
+ size = 'md',
18
+ hidePrev = false,
19
+ hideNext = false,
20
+ prevLabel = 'Previous month',
21
+ nextLabel = 'Next month',
22
+ title
23
+ }: {
24
+ label: string;
25
+ onPrev?: () => void;
26
+ onNext?: () => void;
27
+ canPrev?: boolean;
28
+ canNext?: boolean;
29
+ size?: 'sm' | 'md';
30
+ /** Hide one arrow (the inner edges of a two-month view). Keeps its space. */
31
+ hidePrev?: boolean;
32
+ hideNext?: boolean;
33
+ prevLabel?: string;
34
+ nextLabel?: string;
35
+ title?: Snippet;
36
+ } = $props();
37
+ </script>
38
+
39
+ <div class="jatui-cal-header">
40
+ <span class:jatui-cal-header__ghost={hidePrev}>
41
+ <IconButton
42
+ label={prevLabel}
43
+ size={size === 'sm' ? 'xs' : 'sm'}
44
+ shape="circle"
45
+ disabled={!canPrev || hidePrev}
46
+ onclick={() => onPrev?.()}
47
+ >
48
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="jatui-cal-header__chev">
49
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
50
+ </svg>
51
+ </IconButton>
52
+ </span>
53
+ {#if title}
54
+ {@render title()}
55
+ {:else}
56
+ <h3 class="jatui-cal-header__title" class:is-sm={size === 'sm'} aria-live="polite">{label}</h3>
57
+ {/if}
58
+ <span class:jatui-cal-header__ghost={hideNext}>
59
+ <IconButton
60
+ label={nextLabel}
61
+ size={size === 'sm' ? 'xs' : 'sm'}
62
+ shape="circle"
63
+ disabled={!canNext || hideNext}
64
+ onclick={() => onNext?.()}
65
+ >
66
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="jatui-cal-header__chev">
67
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
68
+ </svg>
69
+ </IconButton>
70
+ </span>
71
+ </div>
72
+
73
+ <style>
74
+ .jatui-cal-header {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ gap: 0.5rem;
79
+ margin-block-end: 0.5rem;
80
+ }
81
+ .jatui-cal-header__title {
82
+ margin: 0;
83
+ font-size: 1rem;
84
+ font-weight: 600;
85
+ text-align: center;
86
+ flex: 1;
87
+ }
88
+ .jatui-cal-header__title.is-sm {
89
+ font-size: 0.875rem;
90
+ }
91
+ .jatui-cal-header__ghost {
92
+ visibility: hidden;
93
+ }
94
+ /* Chevrons point the reading direction. */
95
+ :global([dir='rtl']) .jatui-cal-header__chev {
96
+ transform: scaleX(-1);
97
+ }
98
+ </style>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CalendarHeader — previous/next month buttons around a live month title.
3
+ * Shared by every month panel (CalendarPicker, DatePicker, DateRangePicker,
4
+ * BookingScheduler). `title` replaces the plain heading (EventCalendar uses it
5
+ * for its month-switcher pill).
6
+ */
7
+ import type { Snippet } from 'svelte';
8
+ type $$ComponentProps = {
9
+ label: string;
10
+ onPrev?: () => void;
11
+ onNext?: () => void;
12
+ canPrev?: boolean;
13
+ canNext?: boolean;
14
+ size?: 'sm' | 'md';
15
+ /** Hide one arrow (the inner edges of a two-month view). Keeps its space. */
16
+ hidePrev?: boolean;
17
+ hideNext?: boolean;
18
+ prevLabel?: string;
19
+ nextLabel?: string;
20
+ title?: Snippet;
21
+ };
22
+ declare const CalendarHeader: import("svelte").Component<$$ComponentProps, {}, "">;
23
+ type CalendarHeader = ReturnType<typeof CalendarHeader>;
24
+ export default CalendarHeader;