diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index c1669fd..59861e6 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -75,6 +75,7 @@ export const KNOWN_COMPONENTS = [ // custom nodes (charts, map, interactive checklist, video card, masonry gallery, // map detail pane, image carousel) "VegaLite", "Map", "MapLink", "Checklist", "Video", "Masonry", "MapDetail", "ImageCarousel", + "SliderGallery", ] as const; export type KnownComponent = (typeof KNOWN_COMPONENTS)[number]; const KNOWN_COMPONENT_SET = new Set(KNOWN_COMPONENTS); diff --git a/packages/viewer/src/client/renderers/component-resolver.tsx b/packages/viewer/src/client/renderers/component-resolver.tsx index 83abbaa..42bf1f7 100644 --- a/packages/viewer/src/client/renderers/component-resolver.tsx +++ b/packages/viewer/src/client/renderers/component-resolver.tsx @@ -16,6 +16,7 @@ import { MapLink } from "./map-link-card.js"; import { Masonry } from "./masonry.js"; import { MapDetail } from "./map-detail.js"; import { ImageCarousel } from "./image-carousel.js"; +import { SliderGallery } from "./slider-gallery.js"; export interface ComponentNode { type: string; @@ -64,6 +65,8 @@ const REGISTRY = { MapDetail, // dependency-free swipeable image strip (scroll-snap + arrows + dots) for per-card galleries ImageCarousel, + // interactive slider gallery — shows one child at a time based on slider position + SliderGallery, // eslint-disable-next-line @typescript-eslint/no-explicit-any } satisfies Record>; diff --git a/packages/viewer/src/client/renderers/slider-gallery.tsx b/packages/viewer/src/client/renderers/slider-gallery.tsx new file mode 100644 index 0000000..552c4d8 --- /dev/null +++ b/packages/viewer/src/client/renderers/slider-gallery.tsx @@ -0,0 +1,71 @@ +import { useState, isValidElement, type ReactNode } from "react"; +import { Slider, Stack, Text } from "@mantine/core"; + +export interface SliderGalleryProps { + min?: number; + max?: number; + step?: number; + defaultValue?: number; + label?: string; + marks?: Array<{ value: number; label?: string }>; + children?: ReactNode; +} + +export function SliderGallery(props: SliderGalleryProps) { + const min = typeof props.min === "number" ? props.min : 0; + const max = typeof props.max === "number" ? props.max : 100; + const step = typeof props.step === "number" ? props.step : 1; + const defaultValue = + typeof props.defaultValue === "number" ? props.defaultValue : min; + const [value, setValue] = useState(defaultValue); + + // Children arrive pre-resolved by the component resolver — they're React elements, not raw + // ComponentNode JSON. Extract the `value` prop to match against the slider position. + const items: Array<{ value: number; element: ReactNode }> = []; + const kids = Array.isArray(props.children) ? props.children : props.children ? [props.children] : []; + for (const child of kids) { + if (isValidElement(child)) { + const v = (child.props as Record)?.value; + if (typeof v === "number") items.push({ value: v, element: child }); + } + } + + let active: ReactNode = null; + if (items.length > 0) { + let closest = items[0]; + let closestDist = Math.abs(items[0].value - value); + for (const item of items) { + const d = Math.abs(item.value - value); + if (d < closestDist) { + closest = item; + closestDist = d; + } + } + active = closest.element; + } + + const labelFn = + typeof props.label === "string" + ? (v: number) => props.label!.replace("{value}", String(v)) + : undefined; + + return ( + + + {active ?? ( + + No content for value {value} + + )} + + ); +}