atmo.rsvp
1<script lang="ts">
2 // @ts-nocheck
3 import { Popover } from 'bits-ui';
4 import { TimeField } from 'bits-ui';
5 import { Time } from '@internationalized/date';
6 import { untrack, tick } from 'svelte';
7
8 let {
9 value = $bindable(''),
10 required = false,
11 locale = 'en',
12 referenceTime = ''
13 }: {
14 value: string;
15 required?: boolean;
16 locale?: string;
17 referenceTime?: string;
18 } = $props();
19
20 let isOpen = $state(false);
21 let listEl: HTMLDivElement | undefined = $state(undefined);
22 let internalValue: Time | undefined = $state(undefined);
23
24 function parseTimeStr(str: string): Time | undefined {
25 if (!str) return undefined;
26 const [hourStr, minuteStr] = str.split(':');
27 const hour = parseInt(hourStr, 10);
28 const minute = parseInt(minuteStr, 10);
29 if (isNaN(hour) || isNaN(minute)) return undefined;
30 return new Time(hour, minute);
31 }
32
33 function formatTimeStr(t: Time): string {
34 const h = String(t.hour).padStart(2, '0');
35 const m = String(t.minute).padStart(2, '0');
36 return `${h}:${m}`;
37 }
38
39 $effect(() => {
40 const parsed = parseTimeStr(value);
41 untrack(() => {
42 if (parsed) {
43 if (
44 !internalValue ||
45 parsed.hour !== internalValue.hour ||
46 parsed.minute !== internalValue.minute
47 ) {
48 internalValue = parsed;
49 }
50 } else {
51 internalValue = undefined;
52 }
53 });
54 });
55
56 function handleValueChange(newVal: Time | undefined) {
57 if (newVal && newVal instanceof Time) {
58 internalValue = newVal;
59 value = formatTimeStr(newVal);
60 }
61 }
62
63 // Generate 48 half-hour slots
64 const slots = Array.from({ length: 48 }, (_, i) => {
65 const h = Math.floor(i / 2);
66 const m = i % 2 === 0 ? 0 : 30;
67 const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
68 const date = new Date(2000, 0, 1, h, m);
69 const label = date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
70 return { key, label };
71 });
72
73 function durationLabel(slotKey: string): string {
74 if (!referenceTime) return '';
75 const [rh, rm] = referenceTime.split(':').map(Number);
76 const [sh, sm] = slotKey.split(':').map(Number);
77 let diff = (sh * 60 + sm) - (rh * 60 + rm);
78 if (diff <= 0) return '';
79 const hours = Math.floor(diff / 60);
80 const mins = diff % 60;
81 if (hours === 0) return `${mins}m`;
82 if (mins === 0) return `${hours}h`;
83 return `${hours}h ${mins}m`;
84 }
85
86 function selectSlot(key: string) {
87 value = key;
88 isOpen = false;
89 }
90
91 // Scroll to selected/closest slot when popover opens
92 $effect(() => {
93 if (isOpen && listEl) {
94 tick().then(() => {
95 if (!listEl) return;
96 const selected = listEl.querySelector('[data-selected]');
97 if (selected) {
98 selected.scrollIntoView({ block: 'center' });
99 } else if (value) {
100 const [hStr, mStr] = value.split(':');
101 const totalMin = parseInt(hStr, 10) * 60 + parseInt(mStr, 10);
102 const closestIdx = Math.min(Math.round(totalMin / 30), 47);
103 const el = listEl.children[closestIdx];
104 if (el) el.scrollIntoView({ block: 'center' });
105 }
106 });
107 }
108 });
109</script>
110
111<div class="relative">
112 <TimeField.Root
113 bind:value={internalValue}
114 onValueChange={handleValueChange}
115 granularity="minute"
116 {locale}
117 {required}
118 >
119 <!-- svelte-ignore a11y_click_events_have_key_events -->
120 <!-- svelte-ignore a11y_no_static_element_interactions -->
121 <div
122 class="border-base-300 bg-base-100 text-base-900 focus-within:border-accent-500 dark:border-base-700 dark:bg-base-800 dark:text-base-100 dark:focus-within:border-accent-400 flex shrink-0 cursor-pointer items-center whitespace-nowrap rounded-xl border px-2.5 py-1.5 text-sm min-w-[7.5rem] transition-colors"
123 onfocusin={() => (isOpen = true)}
124 >
125 <TimeField.Input>
126 {#snippet children({ segments })}
127 {#each segments as segment, i (segment.part + i)}
128 {#if segment.part === 'literal'}
129 <span class="text-base-400 dark:text-base-500">{segment.value}</span>
130 {:else}
131 <TimeField.Segment
132 part={segment.part}
133 class="hover:bg-base-200 focus:bg-base-200 dark:hover:bg-base-700 dark:focus:bg-base-700 rounded px-0.5 focus:outline-none"
134 >
135 {segment.value}
136 </TimeField.Segment>
137 {/if}
138 {/each}
139 {/snippet}
140 </TimeField.Input>
141
142 <svg
143 xmlns="http://www.w3.org/2000/svg"
144 fill="none"
145 viewBox="0 0 24 24"
146 stroke-width="1.5"
147 stroke="currentColor"
148 class="text-base-400 dark:text-base-500 ml-auto size-4 pl-0.5"
149 >
150 <path
151 stroke-linecap="round"
152 stroke-linejoin="round"
153 d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
154 />
155 </svg>
156 </div>
157 </TimeField.Root>
158
159 {#if isOpen}
160 <!-- svelte-ignore a11y_no_static_element_interactions -->
161 <div
162 class="fixed inset-0 z-40"
163 onclick={() => (isOpen = false)}
164 onkeydown={(e) => { if (e.key === 'Escape') isOpen = false; }}
165 ></div>
166 <div
167 bind:this={listEl}
168 class="border-base-200 bg-base-50 dark:border-base-700 dark:bg-base-900 absolute left-0 z-50 mt-2 max-h-60 overflow-y-auto rounded-2xl border p-2 shadow-lg"
169 >
170 {#each slots as slot (slot.key)}
171 <button
172 type="button"
173 class="w-full rounded-lg px-4 py-1.5 text-left text-sm whitespace-nowrap transition-colors
174 {value === slot.key
175 ? 'bg-accent-100 dark:bg-accent-900 font-medium text-accent-900 dark:text-accent-100'
176 : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}"
177 data-selected={value === slot.key ? '' : undefined}
178 onclick={() => selectSlot(slot.key)}
179 >
180 {slot.label}
181 {#if durationLabel(slot.key)}
182 <span class="ml-2 opacity-50">{durationLabel(slot.key)}</span>
183 {/if}
184 </button>
185 {/each}
186 </div>
187 {/if}
188</div>