web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit c5b4b5c5fb452d2220329c2a261aa6396478e9b1
parent a1746e262ed885a249a6c3125318b5f2739012ac
Author: triesap <triesap@radroots.dev>
Date:   Fri, 26 Dec 2025 15:44:27 +0000

carousel: refactor to store-based registration and context

- Replace global carousel stores with CarouselStore factory and helpers
- Bind container/item elements and register them for scroll sync
- Add Svelte context support for implicit carousel wiring and view derivation
- Update farms add flow to use carousel_create and index store instead of casl_*

Diffstat:
Mapps-lib-pwa/src/lib/components/lib/carousel-container.svelte | 30++++++++++++++++++++++++++++--
Mapps-lib-pwa/src/lib/components/lib/carousel-item.svelte | 25+++++++++++++++++++++++--
Mapps-lib-pwa/src/lib/types/components/lib.ts | 11++++++-----
Mapps-lib-pwa/src/lib/views/farms/farms-add.svelte | 41++++++++++++++++-------------------------
Mapps-lib/src/lib/stores/carousel.ts | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mapps-lib/src/lib/utils/app/carousel.ts | 100+++++++++++++++----------------------------------------------------------------
Mapps-lib/src/lib/utils/index.ts | 1-
7 files changed, 355 insertions(+), 145 deletions(-)

diff --git a/apps-lib-pwa/src/lib/components/lib/carousel-container.svelte b/apps-lib-pwa/src/lib/components/lib/carousel-container.svelte @@ -1,6 +1,12 @@ <script lang="ts"> import type { ICarouselContainer } from "$lib/types/components/lib"; - import { fmt_cl } from "@radroots/apps-lib"; + import { + CAROUSEL_CONTEXT_KEY, + carousel_register_container, + carousel_watch, + fmt_cl, + set_context, + } from "@radroots/apps-lib"; import type { Snippet } from "svelte"; let { @@ -11,11 +17,31 @@ children: Snippet; } = $props(); + if (basis.carousel) set_context(CAROUSEL_CONTEXT_KEY, basis.carousel); + + const view = $derived(basis.view ?? basis.carousel?.view ?? ``); + const classes = $derived( `${fmt_cl(basis.classes)} carousel-container flex h-full w-full`, ); + + let container_el: HTMLDivElement | null = $state(null); + + $effect(() => { + if (!basis.carousel) return; + return carousel_register_container(basis.carousel, container_el); + }); + + $effect(() => { + if (!basis.carousel) return; + return carousel_watch(basis.carousel); + }); </script> -<div data-carousel-container={basis.view} class={classes}> +<div + bind:this={container_el} + data-carousel-container={view} + class={classes} +> {@render children()} </div> diff --git a/apps-lib-pwa/src/lib/components/lib/carousel-item.svelte b/apps-lib-pwa/src/lib/components/lib/carousel-item.svelte @@ -4,7 +4,13 @@ CarouselMouseEvent, ICarouselItem, } from "$lib/types/components/lib"; - import { fmt_cl } from "@radroots/apps-lib"; + import type { CarouselStore } from "@radroots/apps-lib"; + import { + CAROUSEL_CONTEXT_KEY, + carousel_register_item, + fmt_cl, + get_context, + } from "@radroots/apps-lib"; import type { Snippet } from "svelte"; let { @@ -15,10 +21,24 @@ children: Snippet; } = $props(); + const carousel_ctx = get_context<CarouselStore<string> | undefined>( + CAROUSEL_CONTEXT_KEY, + ); + + const carousel = $derived(basis.carousel ?? carousel_ctx); + const view = $derived(basis.view ?? carousel?.view ?? ``); + const classes = $derived( `${fmt_cl(basis.classes)} carousel-item flex flex-col h-full w-full`, ); + let item_el: HTMLDivElement | null = $state(null); + + $effect(() => { + if (!carousel) return; + return carousel_register_item(carousel, item_el); + }); + const handle_click = async (ev: MouseEvent): Promise<void> => { if (!basis.callback_click) return; const event_cast = ev as CarouselMouseEvent; @@ -34,7 +54,8 @@ <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div - data-carousel-item={basis.view} + bind:this={item_el} + data-carousel-item={view} class={classes} role={basis.role ?? undefined} tabindex={basis.tabindex ?? undefined} diff --git a/apps-lib-pwa/src/lib/types/components/lib.ts b/apps-lib-pwa/src/lib/types/components/lib.ts @@ -1,4 +1,4 @@ -import type { CallbackRoute, GeometryScreenPositionHorizontal, ICb, ICbOpt, IClOpt, IDisabledOpt, IGlyph, IGlyphKey, ILoadingOpt, ILyOpt, LoadingDimension } from "@radroots/apps-lib"; +import type { CallbackRoute, CarouselStore, GeometryScreenPositionHorizontal, ICb, ICbOpt, IClOpt, IDisabledOpt, IGlyph, IGlyphKey, ILoadingOpt, ILyOpt, LoadingDimension } from "@radroots/apps-lib"; import type { CallbackPromise, CallbackPromiseGeneric } from "@radroots/utils"; export type IButtonSimple = ILyOpt & { @@ -37,11 +37,13 @@ export type CarouselKeyboardEvent = KeyboardEvent & { }; export type ICarouselContainer<T extends string> = IClOpt & { - view: T; + carousel: CarouselStore<T>; + view?: T; }; export type ICarouselItem<T extends string> = IClOpt & { - view: T; + carousel?: CarouselStore<T>; + view?: T; role?: string; tabindex?: number; callback_click?: CallbackPromiseGeneric<CarouselMouseEvent>; @@ -54,4 +56,4 @@ export type ILoadCircle = IClOpt & { export type IFloatPage = { posx: Omit<GeometryScreenPositionHorizontal, "center">; -}; -\ No newline at end of file +}; diff --git a/apps-lib-pwa/src/lib/views/farms/farms-add.svelte b/apps-lib-pwa/src/lib/views/farms/farms-add.svelte @@ -13,10 +13,9 @@ import { schema_view_farms_add_submission } from "$lib/utils/farm/schema"; import { focus_map_marker } from "$lib/utils/map"; import { + carousel_create, carousel_dec, carousel_inc, - carousel_init, - casl_i, el_id, fmt_id, geop_init, @@ -32,12 +31,7 @@ type GeolocationAddress, type GeolocationPoint, } from "@radroots/geo"; - import { - handle_err, - parse_float, - type CallbackPromiseGeneric, - } from "@radroots/utils"; - import { onMount } from "svelte"; + import { parse_float, type CallbackPromiseGeneric } from "@radroots/utils"; const { ls, locale, lc_gui_alert, lc_geop_current, lc_geocode } = get_context<LibContext>(`lib`); @@ -62,17 +56,16 @@ let val_farmarea = $state(``); let val_farmarea_unit = $state(`ac`); - const carousel_view: "farms_add" = "farms_add"; + const carousel_farms_add = carousel_create({ + view: "farms_add", + max_index: 1, + }); - const disabled_submit = $derived($casl_i === 1 && !val_farmname); + const carousel_farms_add_index = carousel_farms_add.index; - onMount(async () => { - try { - await carousel_init(carousel_view, 1); - } catch (e) { - handle_err(e, `on_mount`); - } - }); + const disabled_submit = $derived( + $carousel_farms_add_index === 1 && !val_farmname, + ); const farm_geop_lat = $derived( geop_is_valid(map_geop) @@ -131,16 +124,16 @@ }; const handle_continue = async (): Promise<void> => { - switch ($casl_i) { + switch ($carousel_farms_add_index) { case 1: return await handle_continue_1(); default: - await carousel_inc(carousel_view); + await carousel_inc(carousel_farms_add); } }; const handle_back = async (): Promise<void> => { - switch ($casl_i) { + switch ($carousel_farms_add_index) { case 1: { if (!geop_is_valid(map_geop)) { const geop_cur = await lc_geop_current(); @@ -153,7 +146,7 @@ } } default: - return await carousel_dec(carousel_view); + return await carousel_dec(carousel_farms_add); } }; </script> @@ -193,12 +186,11 @@ </PageToolbar> <CarouselContainer basis={{ - view: carousel_view, + carousel: carousel_farms_add, }} > <CarouselItem basis={{ - view: carousel_view, classes: `justify-start items-center`, }} > @@ -211,7 +203,6 @@ </CarouselItem> <CarouselItem basis={{ - view: carousel_view, classes: `justify-start items-center`, }} > @@ -238,7 +229,7 @@ }, back: { label: `${$ls(`common.back`)}`, - visible: $casl_i > 0, + visible: $carousel_farms_add_index > 0, callback: handle_back, }, }} diff --git a/apps-lib/src/lib/stores/carousel.ts b/apps-lib/src/lib/stores/carousel.ts @@ -1,39 +1,275 @@ import { get_store } from "$lib/utils/app"; -import { writable } from "svelte/store"; +import { exe_iter } from "@radroots/utils"; +import { writable, type Writable } from "svelte/store"; -export const casl_active = writable<boolean>(false); -export const casl_i = writable<number>(0); -export const casl_imax = writable<number>(0); +const CAROUSEL_DELAY_MS = 150; +export const CAROUSEL_CONTEXT_KEY = "radroots:carousel"; -const create_carousel_num = (num_i: number, num_min: number) => { - const store = writable<number>(num_i); +export type CarouselStore<T extends string> = { + view: T; + container: Writable<HTMLElement | null>; + item: Writable<HTMLElement | null>; + index: Writable<number>; + max_index: Writable<number>; + step_count: Writable<number>; + wrap: Writable<boolean>; + active: Writable<boolean>; + duration_ms: Writable<number>; +}; + +export type CarouselInit<T extends string> = { + view: T; + index?: number; + max_index?: number; + step_count?: number; + wrap?: boolean; + duration_ms?: number; +}; + +const max_index_safe = (max_index: number): number => Math.max(max_index, 0); + +const step_count_safe = (step_count: number): number => + Math.max(step_count, 1); + +const duration_safe = (duration_ms: number): number => + Math.max(duration_ms, 0); + +const carousel_index_normalize = ( + index: number, + max_index: number, + wrap: boolean, +): number => { + const max_index_safe_val = max_index_safe(max_index); + if (max_index_safe_val === 0) return 0; + if (wrap) { + const range = max_index_safe_val + 1; + const mod = index % range; + return mod < 0 ? mod + range : mod; + } + if (index < 0) return 0; + if (index > max_index_safe_val) return max_index_safe_val; + return index; +}; + +const carousel_measure_width = <T extends string>( + carousel: CarouselStore<T>, +): number => { + const item = get_store(carousel.item); + if (item?.clientWidth) return item.clientWidth; + const container = get_store(carousel.container); + return container?.clientWidth ?? 0; +}; + +const carousel_scroll_to = <T extends string>( + carousel: CarouselStore<T>, + index: number, +): void => { + if (typeof window === "undefined") return; + const container = get_store(carousel.container); + if (!container) return; + const width = carousel_measure_width(carousel); + if (!width) return; + requestAnimationFrame(() => { + container.scrollLeft = width * index; + }); +}; + +const carousel_step = <T extends string>( + carousel: CarouselStore<T>, + delta: number, +): void => { + const is_active = get_store(carousel.active); + if (is_active) return; + carousel.active.set(true); + const index = get_store(carousel.index); + const max_index = get_store(carousel.max_index); + const wrap = get_store(carousel.wrap); + const next_index = carousel_index_normalize( + index + delta, + max_index, + wrap, + ); + carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); + carousel.active.set(false); +}; + +export const carousel_create = <T extends string>( + opts: CarouselInit<T>, +): CarouselStore<T> => { return { - subscribe: store.subscribe, - set: (num: number) => { - store.set(Math.max(num, num_min)); - }, - update: (updater: (num: number) => number) => { - store.update((num) => Math.max(updater(num), num_min)); - } + view: opts.view, + container: writable<HTMLElement | null>(null), + item: writable<HTMLElement | null>(null), + index: writable<number>(opts.index ?? 0), + max_index: writable<number>(max_index_safe(opts.max_index ?? 0)), + step_count: writable<number>(step_count_safe(opts.step_count ?? 1)), + wrap: writable<boolean>(opts.wrap ?? false), + active: writable<boolean>(false), + duration_ms: writable<number>( + duration_safe(opts.duration_ms ?? CAROUSEL_DELAY_MS), + ), + }; +}; + +export const carousel_register_container = <T extends string>( + carousel: CarouselStore<T>, + container: HTMLElement | null, +): (() => void) => { + const current = get_store(carousel.container); + if (current !== container) carousel.container.set(container); + if (container) carousel_sync(carousel); + return () => { + const stored = get_store(carousel.container); + if (stored === container) carousel.container.set(null); + }; +}; + +export const carousel_register_item = <T extends string>( + carousel: CarouselStore<T>, + item: HTMLElement | null, +): (() => void) => { + const current = get_store(carousel.item); + if (!current && item) carousel.item.set(item); + if (item) carousel_sync(carousel); + return () => { + const stored = get_store(carousel.item); + if (stored === item) carousel.item.set(null); + }; +}; + +export const carousel_sync = <T extends string>( + carousel: CarouselStore<T>, +): void => { + const index = get_store(carousel.index); + const max_index = get_store(carousel.max_index); + const wrap = get_store(carousel.wrap); + const next_index = carousel_index_normalize( + index, + max_index, + wrap, + ); + if (next_index !== index) carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); +}; + +export const carousel_watch = <T extends string>( + carousel: CarouselStore<T>, +): (() => void) => { + const unsubs = [ + carousel.index.subscribe(() => carousel_sync(carousel)), + carousel.max_index.subscribe(() => carousel_sync(carousel)), + carousel.wrap.subscribe(() => carousel_sync(carousel)), + carousel.container.subscribe(() => carousel_sync(carousel)), + carousel.item.subscribe(() => carousel_sync(carousel)), + ]; + return () => { + for (const unsub of unsubs) unsub(); }; -} -export const casl_num = create_carousel_num(1, 1); +}; + +export const carousel_set_index = <T extends string>( + carousel: CarouselStore<T>, + index: number, +): void => { + const max_index = get_store(carousel.max_index); + const wrap = get_store(carousel.wrap); + const next_index = carousel_index_normalize(index, max_index, wrap); + carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); +}; + +export const carousel_set_max = <T extends string>( + carousel: CarouselStore<T>, + max_index: number, +): void => { + const next_max = max_index_safe(max_index); + carousel.max_index.set(next_max); + const index = get_store(carousel.index); + const wrap = get_store(carousel.wrap); + const next_index = carousel_index_normalize(index, next_max, wrap); + carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); +}; -export const casl_inc = async (opts?: 'noflow'): Promise<void> => { - const casl_i_val = get_store(casl_i); - const casl_imax_val = get_store(casl_imax); - if (opts === 'noflow' && casl_i_val < casl_imax_val) casl_i.set(casl_i_val + 1); - else casl_i.set((casl_i_val + 1) % (casl_imax_val + 1)); +export const carousel_set_count = <T extends string>( + carousel: CarouselStore<T>, + count: number, +): void => { + const max_index = count > 0 ? count - 1 : 0; + carousel_set_max(carousel, max_index); }; -export const casl_dec = async (opts?: 'noflow'): Promise<void> => { - const casl_i_val = get_store(casl_i); - const casl_imax_val = get_store(casl_imax); - if (opts === 'noflow' && casl_i_val > 0) casl_i.set(casl_i_val - 1); - else casl_i.set((casl_i_val - 1 + (casl_imax_val + 1)) % (casl_imax_val + 1)); +export const carousel_set_wrap = <T extends string>( + carousel: CarouselStore<T>, + wrap: boolean, +): void => { + carousel.wrap.set(wrap); + const index = get_store(carousel.index); + const max_index = get_store(carousel.max_index); + const next_index = carousel_index_normalize(index, max_index, wrap); + carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); }; -export const casl_init = (index_curr: number, index_max: number): void => { - casl_i.set(index_curr); - casl_imax.set(index_max); +export const carousel_set_step_count = <T extends string>( + carousel: CarouselStore<T>, + step_count: number, +): void => { + carousel.step_count.set(step_count_safe(step_count)); +}; + +export const carousel_set_duration = <T extends string>( + carousel: CarouselStore<T>, + duration_ms: number, +): void => { + carousel.duration_ms.set(duration_safe(duration_ms)); +}; + +export const carousel_init = <T extends string>( + carousel: CarouselStore<T>, + opts?: Omit<CarouselInit<T>, "view">, +): void => { + if (!opts) return; + if (opts.wrap !== undefined) carousel.wrap.set(opts.wrap); + if (opts.step_count !== undefined) + carousel_set_step_count(carousel, opts.step_count); + if (opts.duration_ms !== undefined) + carousel_set_duration(carousel, opts.duration_ms); + if (opts.max_index !== undefined) + carousel.max_index.set(max_index_safe(opts.max_index)); + const wrap = get_store(carousel.wrap); + const max_index = get_store(carousel.max_index); + const index = opts.index ?? get_store(carousel.index); + const next_index = carousel_index_normalize(index, max_index, wrap); + carousel.index.set(next_index); + carousel_scroll_to(carousel, next_index); +}; + +export const carousel_inc = async <T extends string>( + carousel: CarouselStore<T>, +): Promise<void> => { + const step_count = get_store(carousel.step_count); + const duration_ms = get_store(carousel.duration_ms); + await exe_iter( + async () => { + carousel_step(carousel, 1); + }, + step_count, + duration_ms, + ); +}; + +export const carousel_dec = async <T extends string>( + carousel: CarouselStore<T>, +): Promise<void> => { + const step_count = get_store(carousel.step_count); + const duration_ms = get_store(carousel.duration_ms); + await exe_iter( + async () => { + carousel_step(carousel, -1); + }, + step_count, + duration_ms, + ); }; diff --git a/apps-lib/src/lib/utils/app/carousel.ts b/apps-lib/src/lib/utils/app/carousel.ts @@ -1,82 +1,18 @@ -import { casl_active, casl_i, casl_imax, casl_num } from "$lib/stores/carousel"; -import { exe_iter } from "@radroots/utils"; -import { get_store } from "./index"; - -const CAROUSEL_DELAY_MS = 150; - -const get_slide_container = <T extends string>( - view: T, -): Element | undefined => { - const el = document.querySelector( - `[data-carousel-container="${view}"]`, - ); - return el ? el : undefined; -}; - -const get_slide_item = <T extends string>(view: T): Element | undefined => { - const el = document.querySelector(`[data-carousel-item="${view}"]`); - return el ? el : undefined; -}; - -const carousel_dec_handler = async <T extends string>( - view: T, -): Promise<void> => { - const casl_active_val = get_store(casl_active); - if (casl_active_val) return; - casl_active.set(true); - const slide_item = get_slide_item<T>(view); - const slide_container = get_slide_container<T>(view); - if (slide_container && slide_item) { - const slide_w = slide_item?.clientWidth || 0; - slide_container.scrollLeft -= slide_w; - const casl_i_val = get_store(casl_i); - casl_i.set(Math.max(casl_i_val - 1, 0)); - } - casl_active.set(false); -}; - -const carousel_inc_handler = async <T extends string>( - view: T, -): Promise<void> => { - const casl_active_val = get_store(casl_active); - if (casl_active_val) return; - casl_active.set(true); - const slide_item = get_slide_item<T>(view); - const slide_container = get_slide_container<T>(view); - if (slide_container && slide_item) { - const slide_w = slide_item?.clientWidth || 0; - slide_container.scrollLeft += slide_w; - const casl_i_val = get_store(casl_i); - const casl_i_valmax = get_store(casl_imax); - casl_i.set( - Math.min(casl_i_val + 1, casl_i_valmax), - ); - } - casl_active.set(false); -}; - -export const carousel_inc = async <T extends string>( - view: T, - duration: number = CAROUSEL_DELAY_MS -): Promise<void> => { - const casl_num_val = get_store(casl_num); - casl_num.set(1); - await exe_iter(async () => carousel_inc_handler(view), casl_num_val, duration); -}; - - -export const carousel_dec = async <T extends string>( - view: T, - duration: number = CAROUSEL_DELAY_MS -): Promise<void> => { - const casl_num_val = get_store(casl_num); - casl_num.set(1); - await exe_iter(async () => carousel_dec_handler(view), casl_num_val, duration); -}; - -export const carousel_init = async <T extends string>(view: T, num_max: number): Promise<void> => { - await carousel_dec(view); - casl_i.set(0); - casl_imax.set(num_max); - casl_num.set(1); -}; +export { + CAROUSEL_CONTEXT_KEY, + carousel_create, + carousel_dec, + carousel_inc, + carousel_init, + carousel_register_container, + carousel_register_item, + carousel_set_count, + carousel_set_duration, + carousel_set_index, + carousel_set_max, + carousel_set_step_count, + carousel_set_wrap, + carousel_sync, + carousel_watch, +} from "$lib/stores/carousel"; +export type { CarouselInit, CarouselStore } from "$lib/stores/carousel"; diff --git a/apps-lib/src/lib/utils/index.ts b/apps-lib/src/lib/utils/index.ts @@ -1,5 +1,4 @@ export * from "./app/index.js"; -export * from "./app/carousel.js"; export * from "./browser.js"; export * from "./fetch.js"; export * from "./geo.js";