atmo.rsvp
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 188 lines 5.6 kB view raw
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>