carousel.ts (8675B)
1 import { get_store } from "$lib/utils/app"; 2 import { exe_iter } from "@radroots/utils"; 3 import { writable, type Writable } from "svelte/store"; 4 5 const CAROUSEL_DELAY_MS = 150; 6 export const CAROUSEL_CONTEXT_KEY = "radroots:carousel"; 7 8 export type CarouselStore<T extends string> = { 9 view: T; 10 container: Writable<HTMLElement | null>; 11 item: Writable<HTMLElement | null>; 12 index: Writable<number>; 13 max_index: Writable<number>; 14 step_count: Writable<number>; 15 wrap: Writable<boolean>; 16 active: Writable<boolean>; 17 duration_ms: Writable<number>; 18 }; 19 20 export type CarouselInit<T extends string> = { 21 view: T; 22 index?: number; 23 max_index?: number; 24 step_count?: number; 25 wrap?: boolean; 26 duration_ms?: number; 27 }; 28 29 const max_index_safe = (max_index: number): number => Math.max(max_index, 0); 30 31 const step_count_safe = (step_count: number): number => 32 Math.max(step_count, 1); 33 34 const duration_safe = (duration_ms: number): number => 35 Math.max(duration_ms, 0); 36 37 const carousel_index_normalize = ( 38 index: number, 39 max_index: number, 40 wrap: boolean, 41 ): number => { 42 const max_index_safe_val = max_index_safe(max_index); 43 if (max_index_safe_val === 0) return 0; 44 if (wrap) { 45 const range = max_index_safe_val + 1; 46 const mod = index % range; 47 return mod < 0 ? mod + range : mod; 48 } 49 if (index < 0) return 0; 50 if (index > max_index_safe_val) return max_index_safe_val; 51 return index; 52 }; 53 54 const carousel_measure_width = <T extends string>( 55 carousel: CarouselStore<T>, 56 ): number => { 57 const item = get_store(carousel.item); 58 if (item?.clientWidth) return item.clientWidth; 59 const container = get_store(carousel.container); 60 return container?.clientWidth ?? 0; 61 }; 62 63 const carousel_scroll_to = <T extends string>( 64 carousel: CarouselStore<T>, 65 index: number, 66 ): void => { 67 if (typeof window === "undefined") return; 68 const container = get_store(carousel.container); 69 if (!container) return; 70 const width = carousel_measure_width(carousel); 71 if (!width) return; 72 requestAnimationFrame(() => { 73 container.scrollLeft = width * index; 74 }); 75 }; 76 77 const carousel_step = <T extends string>( 78 carousel: CarouselStore<T>, 79 delta: number, 80 ): void => { 81 const is_active = get_store(carousel.active); 82 if (is_active) return; 83 carousel.active.set(true); 84 const index = get_store(carousel.index); 85 const max_index = get_store(carousel.max_index); 86 const wrap = get_store(carousel.wrap); 87 const next_index = carousel_index_normalize( 88 index + delta, 89 max_index, 90 wrap, 91 ); 92 carousel.index.set(next_index); 93 carousel_scroll_to(carousel, next_index); 94 carousel.active.set(false); 95 }; 96 97 export const carousel_create = <T extends string>( 98 opts: CarouselInit<T>, 99 ): CarouselStore<T> => { 100 return { 101 view: opts.view, 102 container: writable<HTMLElement | null>(null), 103 item: writable<HTMLElement | null>(null), 104 index: writable<number>(opts.index ?? 0), 105 max_index: writable<number>(max_index_safe(opts.max_index ?? 0)), 106 step_count: writable<number>(step_count_safe(opts.step_count ?? 1)), 107 wrap: writable<boolean>(opts.wrap ?? false), 108 active: writable<boolean>(false), 109 duration_ms: writable<number>( 110 duration_safe(opts.duration_ms ?? CAROUSEL_DELAY_MS), 111 ), 112 }; 113 }; 114 115 export const carousel_register_container = <T extends string>( 116 carousel: CarouselStore<T>, 117 container: HTMLElement | null, 118 ): (() => void) => { 119 const current = get_store(carousel.container); 120 if (current !== container) carousel.container.set(container); 121 if (container) carousel_sync(carousel); 122 return () => { 123 const stored = get_store(carousel.container); 124 if (stored === container) carousel.container.set(null); 125 }; 126 }; 127 128 export const carousel_register_item = <T extends string>( 129 carousel: CarouselStore<T>, 130 item: HTMLElement | null, 131 ): (() => void) => { 132 const current = get_store(carousel.item); 133 if (!current && item) carousel.item.set(item); 134 if (item) carousel_sync(carousel); 135 return () => { 136 const stored = get_store(carousel.item); 137 if (stored === item) carousel.item.set(null); 138 }; 139 }; 140 141 export const carousel_sync = <T extends string>( 142 carousel: CarouselStore<T>, 143 ): void => { 144 const index = get_store(carousel.index); 145 const max_index = get_store(carousel.max_index); 146 const wrap = get_store(carousel.wrap); 147 const next_index = carousel_index_normalize( 148 index, 149 max_index, 150 wrap, 151 ); 152 if (next_index !== index) carousel.index.set(next_index); 153 carousel_scroll_to(carousel, next_index); 154 }; 155 156 export const carousel_watch = <T extends string>( 157 carousel: CarouselStore<T>, 158 ): (() => void) => { 159 const unsubs = [ 160 carousel.index.subscribe(() => carousel_sync(carousel)), 161 carousel.max_index.subscribe(() => carousel_sync(carousel)), 162 carousel.wrap.subscribe(() => carousel_sync(carousel)), 163 carousel.container.subscribe(() => carousel_sync(carousel)), 164 carousel.item.subscribe(() => carousel_sync(carousel)), 165 ]; 166 return () => { 167 for (const unsub of unsubs) unsub(); 168 }; 169 }; 170 171 export const carousel_set_index = <T extends string>( 172 carousel: CarouselStore<T>, 173 index: number, 174 ): void => { 175 const max_index = get_store(carousel.max_index); 176 const wrap = get_store(carousel.wrap); 177 const next_index = carousel_index_normalize(index, max_index, wrap); 178 carousel.index.set(next_index); 179 carousel_scroll_to(carousel, next_index); 180 }; 181 182 export const carousel_set_max = <T extends string>( 183 carousel: CarouselStore<T>, 184 max_index: number, 185 ): void => { 186 const next_max = max_index_safe(max_index); 187 carousel.max_index.set(next_max); 188 const index = get_store(carousel.index); 189 const wrap = get_store(carousel.wrap); 190 const next_index = carousel_index_normalize(index, next_max, wrap); 191 carousel.index.set(next_index); 192 carousel_scroll_to(carousel, next_index); 193 }; 194 195 export const carousel_set_count = <T extends string>( 196 carousel: CarouselStore<T>, 197 count: number, 198 ): void => { 199 const max_index = count > 0 ? count - 1 : 0; 200 carousel_set_max(carousel, max_index); 201 }; 202 203 export const carousel_set_wrap = <T extends string>( 204 carousel: CarouselStore<T>, 205 wrap: boolean, 206 ): void => { 207 carousel.wrap.set(wrap); 208 const index = get_store(carousel.index); 209 const max_index = get_store(carousel.max_index); 210 const next_index = carousel_index_normalize(index, max_index, wrap); 211 carousel.index.set(next_index); 212 carousel_scroll_to(carousel, next_index); 213 }; 214 215 export const carousel_set_step_count = <T extends string>( 216 carousel: CarouselStore<T>, 217 step_count: number, 218 ): void => { 219 carousel.step_count.set(step_count_safe(step_count)); 220 }; 221 222 export const carousel_set_duration = <T extends string>( 223 carousel: CarouselStore<T>, 224 duration_ms: number, 225 ): void => { 226 carousel.duration_ms.set(duration_safe(duration_ms)); 227 }; 228 229 export const carousel_init = <T extends string>( 230 carousel: CarouselStore<T>, 231 opts?: Omit<CarouselInit<T>, "view">, 232 ): void => { 233 if (!opts) return; 234 if (opts.wrap !== undefined) carousel.wrap.set(opts.wrap); 235 if (opts.step_count !== undefined) 236 carousel_set_step_count(carousel, opts.step_count); 237 if (opts.duration_ms !== undefined) 238 carousel_set_duration(carousel, opts.duration_ms); 239 if (opts.max_index !== undefined) 240 carousel.max_index.set(max_index_safe(opts.max_index)); 241 const wrap = get_store(carousel.wrap); 242 const max_index = get_store(carousel.max_index); 243 const index = opts.index ?? get_store(carousel.index); 244 const next_index = carousel_index_normalize(index, max_index, wrap); 245 carousel.index.set(next_index); 246 carousel_scroll_to(carousel, next_index); 247 }; 248 249 export const carousel_inc = async <T extends string>( 250 carousel: CarouselStore<T>, 251 ): Promise<void> => { 252 const step_count = get_store(carousel.step_count); 253 const duration_ms = get_store(carousel.duration_ms); 254 await exe_iter( 255 async () => { 256 carousel_step(carousel, 1); 257 }, 258 step_count, 259 duration_ms, 260 ); 261 }; 262 263 export const carousel_dec = async <T extends string>( 264 carousel: CarouselStore<T>, 265 ): Promise<void> => { 266 const step_count = get_store(carousel.step_count); 267 const duration_ms = get_store(carousel.duration_ms); 268 await exe_iter( 269 async () => { 270 carousel_step(carousel, -1); 271 }, 272 step_count, 273 duration_ms, 274 ); 275 };