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:
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";