From 79841562d9c89cac9e94de3a65381c11cf95174b Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 10 Jul 2026 10:21:07 +0000 Subject: [PATCH 1/2] feat: SliderGallery component for interactive content switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a SliderGallery component to the viewer's component registry. It renders a Mantine Slider that shows/hides child nodes based on the slider value — each child has a `value` prop, and only the child matching the current slider position is rendered. Props: min, max, step, defaultValue, label (format string with {value} placeholder), marks (tick labels). Children: array of component nodes, each with a `value` prop. The nearest-value child is displayed when the slider moves. Use case: boards that want to show different visualizations at different parameter values (e.g., tile clustering at K=10 vs K=500). --- packages/core/src/validate.ts | 1 + .../client/renderers/component-resolver.tsx | 3 + .../src/client/renderers/slider-gallery.tsx | 99 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 packages/viewer/src/client/renderers/slider-gallery.tsx 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..e7a9599 --- /dev/null +++ b/packages/viewer/src/client/renderers/slider-gallery.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Slider, Stack, Text } from "@mantine/core"; +import { resolve, type ComponentNode } from "./component-resolver.js"; + +/** + * `SliderGallery` — an interactive slider that shows one child at a time based + * on the slider value. Each child must have a `value` prop matching a slider + * position. As the user drags the slider, only the child whose `value` matches + * the current position is rendered. + * + * Props: + * min — minimum slider value (default 0) + * max — maximum slider value (default 100) + * step — step between values (default 1) + * defaultValue — starting position (default min) + * label — format string, e.g. "K = {value}" — {value} is replaced + * marks — array of {value, label} for tick marks on the slider + * + * Children: array of {type, props: {value, ...}, children} nodes. Only the + * child whose `value` matches the slider is shown. If no exact match, the + * nearest child is displayed. + */ +export interface SliderGalleryProps { + min?: number; + max?: number; + step?: number; + defaultValue?: number; + label?: string; + marks?: Array<{ value: number; label?: string }>; + children?: unknown; +} + +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); + + const items: Array<{ value: number; node: ComponentNode }> = []; + if (Array.isArray(props.children)) { + for (const child of props.children) { + if ( + typeof child === "object" && + child !== null && + "props" in child && + typeof (child as ComponentNode).props === "object" + ) { + const cn = child as ComponentNode; + const v = (cn.props as Record)?.value; + if (typeof v === "number") { + items.push({ value: v, node: cn }); + } + } + } + } + + let active: ComponentNode | null = 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.node; + } + + const labelFn = + typeof props.label === "string" + ? (v: number) => props.label!.replace("{value}", String(v)) + : undefined; + + return ( + + + {active ? ( + resolve(active) + ) : ( + + No content for value {value} + + )} + + ); +} From 7f6dc72a03c5653f54e80a3dc5e612c45c50e1c8 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 10 Jul 2026 11:29:48 +0000 Subject: [PATCH 2/2] fix(slider-gallery): render pre-resolved React children directly The component resolver pre-resolves children into React elements before mounting SliderGallery. Calling resolve(active) on an already-resolved element hits the "missing type" error branch (React element.type is a function, not a string). Use isValidElement() to extract the value prop and render the matched child directly. --- .../src/client/renderers/slider-gallery.tsx | 54 +++++-------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/packages/viewer/src/client/renderers/slider-gallery.tsx b/packages/viewer/src/client/renderers/slider-gallery.tsx index e7a9599..552c4d8 100644 --- a/packages/viewer/src/client/renderers/slider-gallery.tsx +++ b/packages/viewer/src/client/renderers/slider-gallery.tsx @@ -1,25 +1,6 @@ -import { useState } from "react"; +import { useState, isValidElement, type ReactNode } from "react"; import { Slider, Stack, Text } from "@mantine/core"; -import { resolve, type ComponentNode } from "./component-resolver.js"; -/** - * `SliderGallery` — an interactive slider that shows one child at a time based - * on the slider value. Each child must have a `value` prop matching a slider - * position. As the user drags the slider, only the child whose `value` matches - * the current position is rendered. - * - * Props: - * min — minimum slider value (default 0) - * max — maximum slider value (default 100) - * step — step between values (default 1) - * defaultValue — starting position (default min) - * label — format string, e.g. "K = {value}" — {value} is replaced - * marks — array of {value, label} for tick marks on the slider - * - * Children: array of {type, props: {value, ...}, children} nodes. Only the - * child whose `value` matches the slider is shown. If no exact match, the - * nearest child is displayed. - */ export interface SliderGalleryProps { min?: number; max?: number; @@ -27,7 +8,7 @@ export interface SliderGalleryProps { defaultValue?: number; label?: string; marks?: Array<{ value: number; label?: string }>; - children?: unknown; + children?: ReactNode; } export function SliderGallery(props: SliderGalleryProps) { @@ -38,25 +19,18 @@ export function SliderGallery(props: SliderGalleryProps) { typeof props.defaultValue === "number" ? props.defaultValue : min; const [value, setValue] = useState(defaultValue); - const items: Array<{ value: number; node: ComponentNode }> = []; - if (Array.isArray(props.children)) { - for (const child of props.children) { - if ( - typeof child === "object" && - child !== null && - "props" in child && - typeof (child as ComponentNode).props === "object" - ) { - const cn = child as ComponentNode; - const v = (cn.props as Record)?.value; - if (typeof v === "number") { - items.push({ value: v, node: cn }); - } - } + // 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: ComponentNode | null = null; + let active: ReactNode = null; if (items.length > 0) { let closest = items[0]; let closestDist = Math.abs(items[0].value - value); @@ -67,7 +41,7 @@ export function SliderGallery(props: SliderGalleryProps) { closestDist = d; } } - active = closest.node; + active = closest.element; } const labelFn = @@ -87,9 +61,7 @@ export function SliderGallery(props: SliderGalleryProps) { marks={props.marks} styles={{ markLabel: { fontSize: 10 } }} /> - {active ? ( - resolve(active) - ) : ( + {active ?? ( No content for value {value}