nteract
componentwidget-controlssource: ok · hit
Widget Controls
Built-in shadcn-backed widget components for standard ipywidgets including controls (IntSlider, FloatSlider, IntRangeSlider, FloatRangeSlider, IntProgress, Button, Checkbox, Text, Textarea, HTML, ColorPicker, Dropdown, RadioButtons, ToggleButton, ToggleButtons, SelectMultiple) and layout containers (VBox, HBox, Box, GridBox, Accordion, Tab).
npx shadcn add "https://nteract-elements.vercel.app/r/widget-controls.json"free
Interaction
carouselaccordiondropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@nteract/widget-view@nteract/output-areasliderprogressbuttoncheckboxinputtextarealabelselectradio-grouptoggletoggle-groupaccordiontabscommandpopoverbadge
Files58 · source cached 10/14/2026, 3:15:52 PM
registry/widgets/controls/index.ts
/**
* Built-in widget components for @jupyter-widgets/controls.
*
* This module registers all built-in widget components with the widget registry.
* Import this module to enable rendering of standard ipywidgets.
*/
import { registerWidget } from "../widget-registry";
import { AccordionWidget } from "./accordion-widget";
import { AudioWidget } from "./audio-widget";
import { BoundedFloatTextWidget } from "./bounded-float-text-widget";
import { BoundedIntTextWidget } from "./bounded-int-text-widget";
import { BoxWidget } from "./box-widget";
import { ButtonWidget } from "./button-widget";
import { CheckboxWidget } from "./checkbox-widget";
import { ColorPicker } from "./color-picker";
import { ColorsInputWidget } from "./colors-input-widget";
import { ComboboxWidget } from "./combobox-widget";
import { ControllerAxisWidget } from "./controller-axis-widget";
import { ControllerButtonWidget } from "./controller-button-widget";
import { ControllerWidget } from "./controller-widget";
import { DatePickerWidget } from "./date-picker-widget";
import { DatetimeWidget } from "./datetime-widget";
// Selection widgets
import { DropdownWidget } from "./dropdown-widget";
import { FileUploadWidget } from "./file-upload-widget";
import { FloatLogSlider } from "./float-log-slider";
import { FloatProgress } from "./float-progress";
import { FloatRangeSlider } from "./float-range-slider";
import { FloatSlider } from "./float-slider";
import { FloatTextWidget } from "./float-text-widget";
import { FloatsInputWidget } from "./floats-input-widget";
import { GridBoxWidget } from "./gridbox-widget";
import { HBoxWidget } from "./hbox-widget";
import { HTMLMathWidget } from "./html-math-widget";
import { HTMLWidget } from "./html-widget";
import { ImageWidget } from "./image-widget";
import { IntProgress } from "./int-progress";
import { IntRangeSlider } from "./int-range-slider";
// Import widget components
import { IntSlider } from "./int-slider";
import { IntTextWidget } from "./int-text-widget";
import { IntsInputWidget } from "./ints-input-widget";
import { LabelWidget } from "./label-widget";
import { DirectionalLinkWidget, LinkWidget } from "./link-widget";
import { OutputWidget } from "./output-widget";
import { PasswordWidget } from "./password-widget";
import { PlayWidget } from "./play-widget";
import { RadioButtonsWidget } from "./radio-buttons-widget";
import { SelectMultipleWidget } from "./select-multiple-widget";
import { SelectWidget } from "./select-widget";
import { SelectionRangeSliderWidget } from "./selection-range-slider-widget";
import { SelectionSliderWidget } from "./selection-slider-widget";
import { StackWidget } from "./stack-widget";
import { TabWidget } from "./tab-widget";
import { TagsInputWidget } from "./tags-input-widget";
import { TextWidget } from "./text-widget";
import { TextareaWidget } from "./textarea-widget";
import { TimePickerWidget } from "./time-picker-widget";
import { ToggleButtonWidget } from "./toggle-button-widget";
import { ToggleButtonsWidget } from "./toggle-buttons-widget";
import { ValidWidget } from "./valid-widget";
// Import layout widget components
import { VBoxWidget } from "./vbox-widget";
import { VideoWidget } from "./video-widget";
// Register all widgets with their model names
registerWidget("IntSliderModel", IntSlider);
registerWidget("FloatSliderModel", FloatSlider);
registerWidget("FloatLogSliderModel", FloatLogSlider);
registerWidget("IntRangeSliderModel", IntRangeSlider);
registerWidget("FloatRangeSliderModel", FloatRangeSlider);
registerWidget("SelectionSliderModel", SelectionSliderWidget);
registerWidget("SelectionRangeSliderModel", SelectionRangeSliderWidget);
registerWidget("IntProgressModel", IntProgress);
registerWidget("FloatProgressModel", FloatProgress);
registerWidget("ButtonModel", ButtonWidget);
registerWidget("PlayModel", PlayWidget);
registerWidget("CheckboxModel", CheckboxWidget);
registerWidget("TextModel", TextWidget);
registerWidget("TextareaModel", TextareaWidget);
registerWidget("HTMLModel", HTMLWidget);
registerWidget("HTMLMathModel", HTMLMathWidget);
registerWidget("LabelModel", LabelWidget);
registerWidget("ImageModel", ImageWidget);
registerWidget("AudioModel", AudioWidget);
registerWidget("VideoModel", VideoWidget);
registerWidget("ColorPickerModel", ColorPicker);
registerWidget("DatePickerModel", DatePickerWidget);
registerWidget("DatetimeModel", DatetimeWidget);
registerWidget("NaiveDatetimeModel", DatetimeWidget);
registerWidget("TimePickerModel", TimePickerWidget);
registerWidget("TimeModel", TimePickerWidget);
// Numeric text inputs
registerWidget("IntTextModel", IntTextWidget);
registerWidget("FloatTextModel", FloatTextWidget);
registerWidget("BoundedIntTextModel", BoundedIntTextWidget);
registerWidget("BoundedFloatTextModel", BoundedFloatTextWidget);
registerWidget("PasswordModel", PasswordWidget);
registerWidget("ValidModel", ValidWidget);
// Selection widgets
registerWidget("DropdownModel", DropdownWidget);
registerWidget("ComboboxModel", ComboboxWidget);
registerWidget("RadioButtonsModel", RadioButtonsWidget);
registerWidget("SelectModel", SelectWidget);
registerWidget("SelectMultipleModel", SelectMultipleWidget);
registerWidget("ToggleButtonModel", ToggleButtonWidget);
registerWidget("ToggleButtonsModel", ToggleButtonsWidget);
// Multi-value inputs
registerWidget("TagsInputModel", TagsInputWidget);
registerWidget("ColorsInputModel", ColorsInputWidget);
registerWidget("IntsInputModel", IntsInputWidget);
registerWidget("FloatsInputModel", FloatsInputWidget);
registerWidget("FileUploadModel", FileUploadWidget);
// Link widgets (frontend-only property synchronization)
registerWidget("LinkModel", LinkWidget);
registerWidget("DirectionalLinkModel", DirectionalLinkWidget);
// Register layout widgets
registerWidget("VBoxModel", VBoxWidget);
registerWidget("HBoxModel", HBoxWidget);
registerWidget("BoxModel", BoxWidget);
registerWidget("GridBoxModel", GridBoxWidget);
registerWidget("AccordionModel", AccordionWidget);
registerWidget("TabModel", TabWidget);
registerWidget("StackModel", StackWidget);
// Controller widgets (Gamepad API)
registerWidget("ControllerModel", ControllerWidget);
registerWidget("ControllerButtonModel", ControllerButtonWidget);
registerWidget("ControllerAxisModel", ControllerAxisWidget);
// Output widget (@jupyter-widgets/output)
registerWidget("OutputModel", OutputWidget);
export { AccordionWidget } from "./accordion-widget";
export { AudioWidget } from "./audio-widget";
export { BoundedFloatTextWidget } from "./bounded-float-text-widget";
export { BoundedIntTextWidget } from "./bounded-int-text-widget";
export { BoxWidget } from "./box-widget";
export { ButtonWidget } from "./button-widget";
export { CheckboxWidget } from "./checkbox-widget";
export { ColorPicker } from "./color-picker";
export { ColorsInputWidget } from "./colors-input-widget";
export { ComboboxWidget } from "./combobox-widget";
export { ControllerAxisWidget } from "./controller-axis-widget";
export { ControllerButtonWidget } from "./controller-button-widget";
export { ControllerWidget } from "./controller-widget";
export { DatePickerWidget } from "./date-picker-widget";
export { DatetimeWidget } from "./datetime-widget";
// Selection widgets
export { DropdownWidget } from "./dropdown-widget";
export { FileUploadWidget } from "./file-upload-widget";
export { FloatLogSlider } from "./float-log-slider";
export { FloatProgress } from "./float-progress";
export { FloatRangeSlider } from "./float-range-slider";
export { FloatSlider } from "./float-slider";
export { FloatTextWidget } from "./float-text-widget";
export { FloatsInputWidget } from "./floats-input-widget";
export { GridBoxWidget } from "./gridbox-widget";
export { HBoxWidget } from "./hbox-widget";
export { HTMLMathWidget } from "./html-math-widget";
export { HTMLWidget } from "./html-widget";
export { ImageWidget } from "./image-widget";
export { IntProgress } from "./int-progress";
export { IntRangeSlider } from "./int-range-slider";
// Re-export components for direct use
export { IntSlider } from "./int-slider";
export { IntTextWidget } from "./int-text-widget";
export { IntsInputWidget } from "./ints-input-widget";
export { LabelWidget } from "./label-widget";
export { DirectionalLinkWidget, LinkWidget } from "./link-widget";
export { OutputWidget } from "./output-widget";
export { PasswordWidget } from "./password-widget";
export { PlayWidget } from "./play-widget";
export { RadioButtonsWidget } from "./radio-buttons-widget";
export { SelectMultipleWidget } from "./select-multiple-widget";
export { SelectWidget } from "./select-widget";
export { SelectionRangeSliderWidget } from "./selection-range-slider-widget";
export { SelectionSliderWidget } from "./selection-slider-widget";
export { StackWidget } from "./stack-widget";
export { TabWidget } from "./tab-widget";
export { TagsInputWidget } from "./tags-input-widget";
export { TextWidget } from "./text-widget";
export { TextareaWidget } from "./textarea-widget";
export { TimePickerWidget } from "./time-picker-widget";
export { ToggleButtonWidget } from "./toggle-button-widget";
export { ToggleButtonsWidget } from "./toggle-buttons-widget";
export { ValidWidget } from "./valid-widget";
// Re-export layout widgets
export { VBoxWidget } from "./vbox-widget";
export { VideoWidget } from "./video-widget";
registry/widgets/use-layout-styles.ts
import type { CSSProperties } from "react";
import { useMemo } from "react";
import {
extractChildGridStyles,
extractContainerGridStyles,
extractGeneralStyles,
hasContainerGridStyles,
} from "./layout-utils";
import type { WidgetModel } from "./widget-store";
import { useResolvedModelValue } from "./widget-store-context";
/**
* Hook to extract CSS styles from a widget's Layout model.
*
* ipywidgets widgets have a `layout` property that references a LayoutModel.
* This hook resolves that reference and extracts CSS properties for styling.
*
* @param modelId - The widget model ID
* @returns Object with containerStyle (for grid containers) and childStyle (for grid_area positioning)
*/
export function useLayoutStyles(modelId: string): {
/** CSS grid container styles (grid_template_columns, grid_template_rows, etc.) + general styles */
containerStyle: CSSProperties;
/** CSS child placement styles (grid_area, grid_row, grid_column) */
childStyle: CSSProperties;
/** Whether the layout has grid container properties */
hasGridLayout: boolean;
} {
// Resolve the layout model reference (IPY_MODEL_xxx -> LayoutModel)
const layoutModel = useResolvedModelValue(modelId, "layout") as
| WidgetModel
| undefined;
return useMemo(() => {
if (!layoutModel?.state) {
return {
containerStyle: {},
childStyle: {},
hasGridLayout: false,
};
}
const state = layoutModel.state;
// Extract different style categories
const gridContainerStyles = extractContainerGridStyles(state);
const generalStyles = extractGeneralStyles(state);
const childStyles = extractChildGridStyles(state);
// Combine container grid styles with general styles for the container
const containerStyle: CSSProperties = {
...generalStyles,
...gridContainerStyles,
};
return {
containerStyle,
childStyle: childStyles,
hasGridLayout: hasContainerGridStyles(state),
};
}, [layoutModel]);
}
registry/widgets/layout-utils.ts
import type { CSSProperties } from "react";
/**
* Utilities for converting ipywidgets Layout model properties to CSS.
*
* ipywidgets uses snake_case for CSS properties (e.g., grid_template_columns),
* which need to be converted to camelCase for React's style prop.
*/
/**
* Map of ipywidgets Layout model property names to React CSS property names.
* Only includes properties that need explicit mapping (non-trivial conversions).
*/
const LAYOUT_CSS_MAP: Record<string, string> = {
// Grid container properties
grid_template_columns: "gridTemplateColumns",
grid_template_rows: "gridTemplateRows",
grid_template_areas: "gridTemplateAreas",
grid_auto_columns: "gridAutoColumns",
grid_auto_rows: "gridAutoRows",
grid_auto_flow: "gridAutoFlow",
grid_gap: "gridGap",
// Grid child placement properties
grid_row: "gridRow",
grid_column: "gridColumn",
grid_area: "gridArea",
// Size properties
min_width: "minWidth",
max_width: "maxWidth",
min_height: "minHeight",
max_height: "maxHeight",
// Flexbox properties
flex_flow: "flexFlow",
align_items: "alignItems",
align_self: "alignSelf",
align_content: "alignContent",
justify_content: "justifyContent",
justify_items: "justifyItems",
// Text properties
object_fit: "objectFit",
object_position: "objectPosition",
// Overflow
overflow_x: "overflowX",
overflow_y: "overflowY",
};
/**
* Grid container properties that apply to the parent grid element.
*/
const CONTAINER_GRID_PROPERTIES = new Set([
"grid_template_columns",
"grid_template_rows",
"grid_template_areas",
"grid_auto_columns",
"grid_auto_rows",
"grid_auto_flow",
"grid_gap",
]);
/**
* Child placement properties that apply to grid children.
*/
const CHILD_GRID_PROPERTIES = new Set(["grid_row", "grid_column", "grid_area"]);
/**
* General layout properties that can apply to any element.
*/
const GENERAL_PROPERTIES = new Set([
"width",
"height",
"min_width",
"max_width",
"min_height",
"max_height",
"margin",
"padding",
"border",
"overflow",
"overflow_x",
"overflow_y",
"visibility",
"display",
"flex",
"flex_flow",
"align_items",
"align_self",
"align_content",
"justify_content",
"justify_items",
"order",
"object_fit",
"object_position",
]);
/**
* Convert a snake_case property name to camelCase.
*/
function snakeToCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
/**
* Convert an ipywidgets Layout property name to a React CSS property name.
*/
function toReactCSSProperty(snakeCase: string): string {
return LAYOUT_CSS_MAP[snakeCase] ?? snakeToCamelCase(snakeCase);
}
/**
* Extract CSS properties from a Layout model state.
*
* @param state - The Layout model's state object
* @param propertyFilter - Optional set of property names to include
* @returns React CSSProperties object
*/
export function layoutStateToCSS(
state: Record<string, unknown>,
propertyFilter?: Set<string>,
): CSSProperties {
const style: Record<string, string> = {};
for (const [key, value] of Object.entries(state)) {
// Skip internal ipywidgets properties
if (key.startsWith("_")) continue;
// Skip null, undefined, or empty string values
if (value === null || value === undefined || value === "") continue;
// Apply filter if provided
if (propertyFilter && !propertyFilter.has(key)) continue;
// Only include string values (CSS values)
if (typeof value !== "string") continue;
const cssProperty = toReactCSSProperty(key);
style[cssProperty] = value;
}
return style as CSSProperties;
}
/**
* Extract container grid CSS properties from a Layout model state.
* These properties should be applied to grid container elements.
*/
export function extractContainerGridStyles(
state: Record<string, unknown>,
): CSSProperties {
return layoutStateToCSS(state, CONTAINER_GRID_PROPERTIES);
}
/**
* Extract child placement CSS properties from a Layout model state.
* These properties should be applied to grid children for positioning.
*/
export function extractChildGridStyles(
state: Record<string, unknown>,
): CSSProperties {
return layoutStateToCSS(state, CHILD_GRID_PROPERTIES);
}
/**
* Extract general layout CSS properties from a Layout model state.
* These can be applied to any element (width, height, margin, etc.).
*/
export function extractGeneralStyles(
state: Record<string, unknown>,
): CSSProperties {
return layoutStateToCSS(state, GENERAL_PROPERTIES);
}
/**
* Check if a Layout model state has any grid container properties.
*/
export function hasContainerGridStyles(
state: Record<string, unknown>,
): boolean {
for (const key of CONTAINER_GRID_PROPERTIES) {
const value = state[key];
if (value !== null && value !== undefined && value !== "") {
return true;
}
}
return false;
}
registry/widgets/controls/int-slider.tsx
/**
* IntSlider widget - renders an integer slider.
*
* Maps to ipywidgets IntSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function IntSlider({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys for fine-grained updates
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const handleChange = (newValue: number[]) => {
// Round to step and clamp to range
const v = Math.round(newValue[0] / step) * step;
const clamped = Math.min(max, Math.max(min, v));
sendUpdate(modelId, { value: clamped });
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="IntSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={[value]}
min={min}
max={max}
step={step}
disabled={disabled}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "flex-1 min-w-24"}
/>
{readout && (
<span className="w-12 text-right tabular-nums text-sm text-muted-foreground">
{value}
</span>
)}
</div>
);
}
export default IntSlider;
registry/widgets/controls/float-slider.tsx
/**
* FloatSlider widget - renders a floating point slider.
*
* Maps to ipywidgets FloatSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function FloatSlider({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys for fine-grained updates
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const readoutFormat =
useWidgetModelValue<string>(modelId, "readout_format") ?? ".2f";
const handleChange = (newValue: number[]) => {
// Clamp to range (no integer rounding for floats)
const clamped = Math.min(max, Math.max(min, newValue[0]));
sendUpdate(modelId, { value: clamped });
};
// Format value for display based on readout_format
const formatValue = (v: number): string => {
// Parse Python-style format spec (e.g., ".2f")
const match = readoutFormat.match(/\.(\d+)f/);
if (match) {
return v.toFixed(parseInt(match[1], 10));
}
return String(v);
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="FloatSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={[value]}
min={min}
max={max}
step={step}
disabled={disabled}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "flex-1 min-w-24"}
/>
{readout && (
<span className="w-16 text-right tabular-nums text-sm text-muted-foreground">
{formatValue(value)}
</span>
)}
</div>
);
}
export default FloatSlider;
registry/widgets/controls/int-range-slider.tsx
/**
* IntRangeSlider widget - renders a dual-handle range slider for integers.
*
* Maps to ipywidgets IntRangeSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function IntRangeSlider({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// ipywidgets uses "value" as a tuple [lower, upper]
const value = useWidgetModelValue<[number, number]>(modelId, "value") ?? [
25, 75,
];
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const handleChange = (newValue: number[]) => {
// Round to step and clamp to range
const lower = Math.round(newValue[0] / step) * step;
const upper = Math.round(newValue[1] / step) * step;
const clampedLower = Math.min(max, Math.max(min, lower));
const clampedUpper = Math.min(max, Math.max(min, upper));
sendUpdate(modelId, { value: [clampedLower, clampedUpper] });
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="IntRangeSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "flex-1 min-w-24"}
/>
{readout && (
<span className="w-20 text-right tabular-nums text-sm text-muted-foreground">
{value[0]} – {value[1]}
</span>
)}
</div>
);
}
export default IntRangeSlider;
registry/widgets/controls/float-range-slider.tsx
/**
* FloatRangeSlider widget - renders a dual-handle range slider for floats.
*
* Maps to ipywidgets FloatRangeSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function FloatRangeSlider({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// ipywidgets uses "value" as a tuple [lower, upper]
const value = useWidgetModelValue<[number, number]>(modelId, "value") ?? [
25.0, 75.0,
];
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const readoutFormat =
useWidgetModelValue<string>(modelId, "readout_format") ?? ".2f";
const handleChange = (newValue: number[]) => {
// Clamp to range (no integer rounding for floats)
const clampedLower = Math.min(max, Math.max(min, newValue[0]));
const clampedUpper = Math.min(max, Math.max(min, newValue[1]));
sendUpdate(modelId, { value: [clampedLower, clampedUpper] });
};
// Format value for display based on readout_format
const formatValue = (v: number): string => {
// Parse Python-style format spec (e.g., ".2f")
const match = readoutFormat.match(/\.(\d+)f/);
if (match) {
return v.toFixed(parseInt(match[1], 10));
}
return String(v);
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="FloatRangeSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "flex-1 min-w-24"}
/>
{readout && (
<span className="w-28 text-right tabular-nums text-sm text-muted-foreground">
{formatValue(value[0])} – {formatValue(value[1])}
</span>
)}
</div>
);
}
export default FloatRangeSlider;
registry/widgets/controls/int-progress.tsx
/**
* IntProgress widget - renders an integer progress bar.
*
* Maps to ipywidgets IntProgressModel.
*/
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function IntProgress({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const description = useWidgetModelValue<string>(modelId, "description");
const barStyle =
useWidgetModelValue<"success" | "info" | "warning" | "danger" | "">(
modelId,
"bar_style",
) ?? "";
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
// Calculate percentage
const range = max - min;
const percentage = range > 0 ? ((value - min) / range) * 100 : 0;
// Map bar_style to Tailwind classes
const barStyleClasses: Record<string, string> = {
success: "[&>[data-slot=progress-indicator]]:bg-green-500",
info: "[&>[data-slot=progress-indicator]]:bg-blue-500",
warning: "[&>[data-slot=progress-indicator]]:bg-yellow-500",
danger: "[&>[data-slot=progress-indicator]]:bg-red-500",
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "flex-1 items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="IntProgress"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Progress
value={percentage}
className={cn(
isVertical ? "h-32 w-2" : "flex-1 min-w-24",
barStyle && barStyleClasses[barStyle],
)}
/>
</div>
);
}
export default IntProgress;
registry/widgets/controls/float-progress.tsx
/**
* FloatProgress widget - renders a floating point progress bar.
*
* Maps to ipywidgets FloatProgressModel.
*/
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function FloatProgress({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const description = useWidgetModelValue<string>(modelId, "description");
const barStyle =
useWidgetModelValue<"success" | "info" | "warning" | "danger" | "">(
modelId,
"bar_style",
) ?? "";
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
// Calculate percentage
const range = max - min;
const percentage = range > 0 ? ((value - min) / range) * 100 : 0;
// Map bar_style to Tailwind classes
const barStyleClasses: Record<string, string> = {
success: "[&>[data-slot=progress-indicator]]:bg-green-500",
info: "[&>[data-slot=progress-indicator]]:bg-blue-500",
warning: "[&>[data-slot=progress-indicator]]:bg-yellow-500",
danger: "[&>[data-slot=progress-indicator]]:bg-red-500",
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "flex-1 items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="FloatProgress"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Progress
value={percentage}
className={cn(
isVertical ? "h-32 w-2" : "flex-1 min-w-24",
barStyle && barStyleClasses[barStyle],
)}
/>
</div>
);
}
export default FloatProgress;
registry/widgets/controls/button-widget.tsx
/**
* Button widget - renders a clickable button.
*
* Maps to ipywidgets ButtonModel.
*/
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
import { getButtonStyle } from "./button-style-utils";
export function ButtonWidget({ modelId, className }: WidgetComponentProps) {
const { sendCustom } = useWidgetStoreRequired();
// Subscribe to individual state keys
const description = useWidgetModelValue<string>(modelId, "description") ?? "";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const buttonStyle =
useWidgetModelValue<string>(modelId, "button_style") ?? "";
const icon = useWidgetModelValue<string>(modelId, "icon");
const tooltip = useWidgetModelValue<string>(modelId, "tooltip");
const handleClick = () => {
// Send click event to kernel
sendCustom(modelId, { event: "click" });
};
const { variant, className: styleClassName } = getButtonStyle(buttonStyle);
return (
<Button
variant={variant}
disabled={disabled}
onClick={handleClick}
title={tooltip}
className={cn(styleClassName, className)}
data-widget-id={modelId}
data-widget-type="Button"
>
{icon && <span className="mr-1">{icon}</span>}
{description || "Button"}
</Button>
);
}
export default ButtonWidget;
registry/widgets/controls/checkbox-widget.tsx
/**
* Checkbox widget - renders a checkbox input.
*
* Maps to ipywidgets CheckboxModel.
*/
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function CheckboxWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const indent = useWidgetModelValue<boolean>(modelId, "indent") ?? true;
const handleChange = (checked: boolean | "indeterminate") => {
if (checked !== "indeterminate") {
sendUpdate(modelId, { value: checked });
}
};
return (
<div
className={cn("flex items-center gap-2", indent && "pl-4", className)}
data-widget-id={modelId}
data-widget-type="Checkbox"
>
<Checkbox
id={`checkbox-${modelId}`}
checked={value}
disabled={disabled}
onCheckedChange={handleChange}
/>
{description && (
<Label
htmlFor={`checkbox-${modelId}`}
className={cn("text-sm", disabled && "opacity-50 cursor-not-allowed")}
>
{description}
</Label>
)}
</div>
);
}
export default CheckboxWidget;
registry/widgets/controls/text-widget.tsx
"use client";
/**
* Text widget - renders a text input field.
*
* Maps to ipywidgets TextModel.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function TextWidget({ modelId, className }: WidgetComponentProps) {
// sendUpdate and sendCustom are now stable (useCommRouter uses refs internally)
const { sendUpdate, sendCustom } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
// Local state for non-continuous updates
const [localValue, setLocalValue] = useState(value);
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
sendUpdate(modelId, { value: newValue });
}
},
[modelId, continuousUpdate, sendUpdate],
);
const handleBlur = useCallback(() => {
if (!continuousUpdate && localValue !== value) {
sendUpdate(modelId, { value: localValue });
}
}, [modelId, continuousUpdate, localValue, value, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// Send submit event
sendCustom(modelId, { event: "submit" });
// Also ensure value is synced
if (!continuousUpdate && localValue !== value) {
sendUpdate(modelId, { value: localValue });
}
}
},
[modelId, continuousUpdate, localValue, value, sendUpdate, sendCustom],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="Text"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="text"
value={localValue}
placeholder={placeholder}
disabled={disabled}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="flex-1"
/>
</div>
);
}
export default TextWidget;
registry/widgets/controls/textarea-widget.tsx
"use client";
/**
* Textarea widget - renders a multi-line text input.
*
* Maps to ipywidgets TextareaModel.
*/
import { useCallback, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function TextareaWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const rows = useWidgetModelValue<number>(modelId, "rows") ?? 4;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
// Local state for non-continuous updates
const [localValue, setLocalValue] = useState(value);
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
sendUpdate(modelId, { value: newValue });
}
},
[modelId, continuousUpdate, sendUpdate],
);
const handleBlur = useCallback(() => {
if (!continuousUpdate && localValue !== value) {
sendUpdate(modelId, { value: localValue });
}
}, [modelId, continuousUpdate, localValue, value, sendUpdate]);
return (
<div
className={cn("flex flex-col gap-2", className)}
data-widget-id={modelId}
data-widget-type="Textarea"
>
{description && <Label className="text-sm">{description}</Label>}
<Textarea
value={localValue}
placeholder={placeholder}
disabled={disabled}
rows={rows}
onChange={handleChange}
onBlur={handleBlur}
className="resize-y"
/>
</div>
);
}
export default TextareaWidget;
registry/widgets/controls/html-widget.tsx
/**
* HTML widget - renders arbitrary HTML content.
*
* Maps to ipywidgets HTMLModel.
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function HTMLWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder");
// Show placeholder if value is empty
const displayValue = value || placeholder || "";
return (
<div
className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
data-widget-id={modelId}
data-widget-type="HTML"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<div
className="widget-html-content"
dangerouslySetInnerHTML={{ __html: displayValue }}
/>
</div>
);
}
export default HTMLWidget;
registry/widgets/controls/color-picker.tsx
/**
* ColorPicker widget - renders a color selection input.
*
* Maps to ipywidgets ColorPickerModel.
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function ColorPicker({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
const value = useWidgetModelValue<string>(modelId, "value") ?? "#000000";
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const concise = useWidgetModelValue<boolean>(modelId, "concise") ?? false;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
sendUpdate(modelId, { value: e.target.value });
};
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="ColorPicker"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<input
type="color"
value={value}
disabled={disabled}
onChange={handleChange}
className={cn(
"h-8 w-12 cursor-pointer rounded border border-input bg-transparent p-0.5",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
{!concise && (
<span className="text-sm text-muted-foreground font-mono">{value}</span>
)}
</div>
);
}
export default ColorPicker;
registry/widgets/controls/dropdown-widget.tsx
/**
* Dropdown widget - renders a native select dropdown.
*
* Maps to ipywidgets DropdownModel.
*
* Uses native HTML <select> instead of Radix UI Select because Radix
* uses Portal which doesn't work in sandboxed iframes (no allow-same-origin).
* See: https://github.com/runtimed/runt/issues/62
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function DropdownWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const index = useWidgetModelValue<number | null>(modelId, "index");
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
// Convert index to string value for select
const value =
index !== null && index !== undefined && index >= 0 ? String(index) : "";
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newIndex = parseInt(event.target.value, 10);
if (!Number.isNaN(newIndex)) {
sendUpdate(modelId, { index: newIndex });
}
};
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="Dropdown"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<select
value={value}
onChange={handleChange}
disabled={disabled}
className={cn(
// Base styles matching shadcn SelectTrigger
"h-9 w-48 appearance-none rounded-md border border-input px-3 py-2 text-sm",
"bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30 dark:hover:bg-input/50",
// Focus styles
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
// Disabled state
"disabled:cursor-not-allowed disabled:opacity-50",
// Custom dropdown arrow using background image (chevron-down)
"bg-[length:16px_16px] bg-[right_8px_center] bg-no-repeat",
"bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%239ca3af%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E')]",
"pr-9", // Extra padding for the arrow
)}
>
{options.map((option, idx) => (
<option key={idx} value={String(idx)}>
{option}
</option>
))}
</select>
</div>
);
}
export default DropdownWidget;
registry/widgets/controls/radio-buttons-widget.tsx
/**
* RadioButtons widget - renders a group of radio buttons.
*
* Maps to ipywidgets RadioButtonsModel.
*/
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function RadioButtonsWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const index = useWidgetModelValue<number | null>(modelId, "index");
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
// Convert index to string value for RadioGroup
const value =
index !== null && index !== undefined && index >= 0
? String(index)
: undefined;
const handleValueChange = (newValue: string) => {
const newIndex = parseInt(newValue, 10);
if (!Number.isNaN(newIndex)) {
sendUpdate(modelId, { index: newIndex });
}
};
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="RadioButtons"
>
{description && (
<Label className="shrink-0 text-sm pt-0.5">{description}</Label>
)}
<RadioGroup
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
{options.map((option, idx) => (
<div key={idx} className="flex items-center gap-2">
<RadioGroupItem
value={String(idx)}
id={`${modelId}-radio-${idx}`}
disabled={disabled}
/>
<Label
htmlFor={`${modelId}-radio-${idx}`}
className={cn(
"text-sm font-normal cursor-pointer",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{option}
</Label>
</div>
))}
</RadioGroup>
</div>
);
}
export default RadioButtonsWidget;
registry/widgets/controls/select-multiple-widget.tsx
/**
* SelectMultiple widget - renders a multi-select listbox.
*
* Maps to ipywidgets SelectMultipleModel.
*/
import { CheckIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function SelectMultipleWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const selectedIndices = useWidgetModelValue<number[]>(modelId, "index") ?? [];
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const rows = useWidgetModelValue<number>(modelId, "rows") ?? 5;
const handleToggle = (idx: number) => {
if (disabled) return;
const newIndices = selectedIndices.includes(idx)
? selectedIndices.filter((i) => i !== idx)
: [...selectedIndices, idx].sort((a, b) => a - b);
sendUpdate(modelId, { index: newIndices });
};
// Calculate height based on rows
const itemHeight = 32; // approximate height per item in px
const maxHeight = rows * itemHeight;
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="SelectMultiple"
>
{description && (
<Label className="shrink-0 text-sm pt-1">{description}</Label>
)}
<div
role="listbox"
aria-multiselectable="true"
aria-disabled={disabled}
className={cn(
"w-48 overflow-y-auto rounded-md border border-input bg-background shadow-xs",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{ maxHeight }}
>
{options.map((option, idx) => {
const isSelected = selectedIndices.includes(idx);
return (
<div
key={idx}
role="option"
aria-selected={isSelected}
onClick={() => handleToggle(idx)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer select-none",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50",
disabled && "pointer-events-none",
)}
>
<span className="flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-3" />}
</span>
<span>{option}</span>
</div>
);
})}
</div>
</div>
);
}
export default SelectMultipleWidget;
registry/widgets/controls/toggle-button-widget.tsx
/**
* ToggleButton widget - renders a single toggle button.
*
* Maps to ipywidgets ToggleButtonModel.
*/
import { Toggle } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function ToggleButtonWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
const description = useWidgetModelValue<string>(modelId, "description") ?? "";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const icon = useWidgetModelValue<string>(modelId, "icon");
const buttonStyle =
useWidgetModelValue<string>(modelId, "button_style") ?? "";
const handlePressedChange = (pressed: boolean) => {
sendUpdate(modelId, { value: pressed });
};
// Map button_style to variant - outline for styled buttons
const variant = buttonStyle ? "outline" : "default";
return (
<Toggle
pressed={value}
onPressedChange={handlePressedChange}
disabled={disabled}
variant={variant}
className={cn(className)}
data-widget-id={modelId}
data-widget-type="ToggleButton"
>
{icon && <span className="mr-1">{icon}</span>}
{description || "Toggle"}
</Toggle>
);
}
export default ToggleButtonWidget;
registry/widgets/controls/toggle-buttons-widget.tsx
/**
* ToggleButtons widget - renders a group of toggle buttons (single selection).
*
* Maps to ipywidgets ToggleButtonsModel.
*/
import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function ToggleButtonsWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const index = useWidgetModelValue<number | null>(modelId, "index");
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const icons = useWidgetModelValue<string[]>(modelId, "icons") ?? [];
const tooltips = useWidgetModelValue<string[]>(modelId, "tooltips") ?? [];
// Convert index to string value for ToggleGroup
const value =
index !== null && index !== undefined && index >= 0
? String(index)
: undefined;
const handleValueChange = (newValue: string) => {
if (newValue === "") {
// Deselection - ToggleButtons typically require a selection
return;
}
const newIndex = parseInt(newValue, 10);
if (!Number.isNaN(newIndex)) {
sendUpdate(modelId, { index: newIndex });
}
};
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="ToggleButtons"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<ToggleGroup
type="single"
value={value}
onValueChange={handleValueChange}
disabled={disabled}
variant="outline"
>
{options.map((option, idx) => (
<ToggleGroupItem
key={idx}
value={String(idx)}
title={tooltips[idx] || undefined}
disabled={disabled}
>
{icons[idx] && <span className="mr-1">{icons[idx]}</span>}
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export default ToggleButtonsWidget;
registry/widgets/controls/vbox-widget.tsx
/**
* VBox widget - vertical flex container.
*
* Maps to ipywidgets VBoxModel. Arranges children in a vertical column.
*/
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";
// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
"": "",
primary:
"border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
success:
"border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
warning:
"border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
danger:
"border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};
export function VBoxWidget({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";
const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";
return (
<div
className={cn("flex flex-col gap-2", styleClass, className)}
data-widget-id={modelId}
data-widget-type="VBox"
>
{children?.map((childRef) => {
const childId = parseModelRef(childRef);
return childId ? <WidgetView key={childId} modelId={childId} /> : null;
})}
</div>
);
}
export default VBoxWidget;
registry/widgets/controls/hbox-widget.tsx
/**
* HBox widget - horizontal flex container.
*
* Maps to ipywidgets HBoxModel. Arranges children in a horizontal row.
*/
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";
// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
"": "",
primary:
"border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
success:
"border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
warning:
"border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
danger:
"border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};
export function HBoxWidget({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";
const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";
return (
<div
className={cn(
"flex flex-row flex-wrap items-baseline gap-1",
styleClass,
className,
)}
data-widget-id={modelId}
data-widget-type="HBox"
>
{children?.map((childRef) => {
const childId = parseModelRef(childRef);
return childId ? <WidgetView key={childId} modelId={childId} /> : null;
})}
</div>
);
}
export default HBoxWidget;
registry/widgets/controls/box-widget.tsx
/**
* Box widget - generic flex container.
*
* Maps to ipywidgets BoxModel. Base container for layout widgets.
* Defaults to vertical stacking (like VBox).
*/
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";
// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
"": "",
primary:
"border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
success:
"border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
warning:
"border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
danger:
"border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};
export function BoxWidget({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";
const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";
return (
<div
className={cn("flex flex-col gap-2", styleClass, className)}
data-widget-id={modelId}
data-widget-type="Box"
>
{children?.map((childRef) => {
const childId = parseModelRef(childRef);
return childId ? <WidgetView key={childId} modelId={childId} /> : null;
})}
</div>
);
}
export default BoxWidget;
registry/widgets/controls/gridbox-widget.tsx
/**
* GridBox widget - CSS grid container.
*
* Maps to ipywidgets GridBoxModel. Arranges children in a CSS grid layout.
* Default is a responsive 2-column grid.
*/
import { cn } from "@/lib/utils";
import { useLayoutStyles } from "../use-layout-styles";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";
// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
"": "",
primary:
"border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
success:
"border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
warning:
"border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
danger:
"border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};
export function GridBoxWidget({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";
// Get layout styles from the Layout model
const { containerStyle, hasGridLayout } = useLayoutStyles(modelId);
const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";
return (
<div
className={cn(
"grid",
// Only apply default Tailwind grid classes if no layout grid is specified
!hasGridLayout && "grid-cols-1 sm:grid-cols-2 gap-2",
styleClass,
className,
)}
style={containerStyle}
data-widget-id={modelId}
data-widget-type="GridBox"
>
{children?.map((childRef) => {
const childId = parseModelRef(childRef);
return childId ? <WidgetView key={childId} modelId={childId} /> : null;
})}
</div>
);
}
export default GridBoxWidget;
registry/widgets/controls/accordion-widget.tsx
/**
* Accordion widget - collapsible panel container.
*
* Maps to ipywidgets AccordionModel. Displays children as collapsible panels.
*/
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
parseModelRef,
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";
export function AccordionWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const titles = useWidgetModelValue<string[]>(modelId, "_titles") ?? [];
const selectedIndex = useWidgetModelValue<number | null>(
modelId,
"selected_index",
);
const handleValueChange = (value: string) => {
// Convert string value back to index (or null if collapsed)
const index = value !== "" ? parseInt(value, 10) : null;
sendUpdate(modelId, { selected_index: index });
};
return (
<Accordion
type="single"
collapsible
value={selectedIndex !== null ? String(selectedIndex) : ""}
onValueChange={handleValueChange}
className={cn("w-full", className)}
data-widget-id={modelId}
data-widget-type="Accordion"
>
{children?.map((childRef, index) => {
const childId = parseModelRef(childRef);
if (!childId) return null;
return (
<AccordionItem key={childId} value={String(index)}>
<AccordionTrigger>
{titles[index] ?? `Panel ${index + 1}`}
</AccordionTrigger>
<AccordionContent>
<WidgetView modelId={childId} />
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
}
export default AccordionWidget;
registry/widgets/controls/tab-widget.tsx
/**
* Tab widget - tabbed panel container.
*
* Maps to ipywidgets TabModel. Displays children as tabbed panels.
*/
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
parseModelRef,
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";
export function TabWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const titles = useWidgetModelValue<string[]>(modelId, "_titles") ?? [];
const selectedIndex =
useWidgetModelValue<number>(modelId, "selected_index") ?? 0;
const handleValueChange = (value: string) => {
sendUpdate(modelId, { selected_index: parseInt(value, 10) });
};
return (
<Tabs
value={String(selectedIndex)}
onValueChange={handleValueChange}
className={cn("w-full", className)}
data-widget-id={modelId}
data-widget-type="Tab"
>
<TabsList>
{children?.map((_, index) => (
<TabsTrigger key={index} value={String(index)}>
{titles[index] ?? `Tab ${index + 1}`}
</TabsTrigger>
))}
</TabsList>
{children?.map((childRef, index) => {
const childId = parseModelRef(childRef);
if (!childId) return null;
return (
<TabsContent key={childId} value={String(index)}>
<WidgetView modelId={childId} />
</TabsContent>
);
})}
</Tabs>
);
}
export default TabWidget;
registry/widgets/controls/label-widget.tsx
/**
* Label widget - renders plain text content.
*
* Maps to ipywidgets LabelModel.
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function LabelWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder");
// Show placeholder if value is empty
const displayValue = value || placeholder || "";
return (
<div
className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
data-widget-id={modelId}
data-widget-type="Label"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<span className="widget-label-content">{displayValue}</span>
</div>
);
}
export default LabelWidget;
registry/widgets/controls/link-widget.tsx
/**
* Link widgets — frontend-only property synchronization.
*
* LinkModel: bidirectional sync (widgets.jslink)
* DirectionalLinkModel: one-way sync (widgets.jsdlink)
*
* These are headless widgets that render no visible UI. Property
* synchronization is handled at the store level by createLinkManager
* (see link-subscriptions.ts), which runs automatically in the
* WidgetStoreProvider.
*
* These components exist in the widget registry so that WidgetView
* renders null instead of showing "Unsupported widget".
*/
import type { WidgetComponentProps } from "../widget-registry";
/**
* DirectionalLinkWidget — one-way property synchronization (source → target).
* Renders nothing. Sync logic runs at the store level.
*/
export function DirectionalLinkWidget(_props: WidgetComponentProps) {
return null;
}
/**
* LinkWidget — bidirectional property synchronization (source ↔ target).
* Renders nothing. Sync logic runs at the store level.
*/
export function LinkWidget(_props: WidgetComponentProps) {
return null;
}
registry/widgets/controls/image-widget.tsx
/**
* Image widget - displays images from the kernel.
*
* Maps to ipywidgets ImageModel.
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function ImageWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
const format = useWidgetModelValue<string>(modelId, "format") ?? "png";
const width = useWidgetModelValue<string>(modelId, "width") ?? "";
const height = useWidgetModelValue<string>(modelId, "height") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const src = buildMediaSrc(value, "image", format);
if (!src) {
return null;
}
// Build style object for width/height
const style: React.CSSProperties = {};
if (width) style.width = width;
if (height) style.height = height;
return (
<div
className={cn("inline-flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="Image"
>
{description && (
<Label className="shrink-0 pt-1 text-sm">{description}</Label>
)}
<img
src={src}
alt={description || "Widget image"}
className="block max-w-full h-auto"
style={{ objectFit: "contain", ...style }}
/>
</div>
);
}
export default ImageWidget;
registry/widgets/controls/int-text-widget.tsx
"use client";
/**
* IntText widget - renders a numeric text input for integers.
*
* Maps to ipywidgets IntTextModel.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function IntTextWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min");
const max = useWidgetModelValue<number>(modelId, "max");
const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;
// Local state for the input value (as string for editing)
const [localValue, setLocalValue] = useState(String(value));
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const clampValue = useCallback(
(val: number): number => {
let clamped = val;
if (min !== undefined && min !== null) {
clamped = Math.max(min, clamped);
}
if (max !== undefined && max !== null) {
clamped = Math.min(max, clamped);
}
return clamped;
},
[min, max],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
const parsed = parseInt(newValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, continuousUpdate, clampValue, sendUpdate],
);
const handleBlur = useCallback(() => {
const parsed = parseInt(localValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
if (clamped !== value) {
sendUpdate(modelId, { value: clamped });
}
} else {
// Reset to current value if invalid
setLocalValue(String(value));
}
}, [modelId, localValue, value, clampValue, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const parsed = parseInt(localValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, localValue, clampValue, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="IntText"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="number"
value={localValue}
disabled={disabled}
step={step}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-24"
/>
</div>
);
}
export default IntTextWidget;
registry/widgets/controls/float-text-widget.tsx
"use client";
/**
* FloatText widget - renders a numeric text input for floats.
*
* Maps to ipywidgets FloatTextModel.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function FloatTextWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min");
const max = useWidgetModelValue<number>(modelId, "max");
const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;
// Local state for the input value (as string for editing)
const [localValue, setLocalValue] = useState(String(value));
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const clampValue = useCallback(
(val: number): number => {
let clamped = val;
if (min !== undefined && min !== null) {
clamped = Math.max(min, clamped);
}
if (max !== undefined && max !== null) {
clamped = Math.min(max, clamped);
}
return clamped;
},
[min, max],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
const parsed = parseFloat(newValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, continuousUpdate, clampValue, sendUpdate],
);
const handleBlur = useCallback(() => {
const parsed = parseFloat(localValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
if (clamped !== value) {
sendUpdate(modelId, { value: clamped });
}
} else {
// Reset to current value if invalid
setLocalValue(String(value));
}
}, [modelId, localValue, value, clampValue, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const parsed = parseFloat(localValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, localValue, clampValue, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="FloatText"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="number"
value={localValue}
disabled={disabled}
step={step}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-24"
/>
</div>
);
}
export default FloatTextWidget;
registry/widgets/controls/bounded-int-text-widget.tsx
"use client";
/**
* BoundedIntText widget - renders a bounded numeric text input for integers.
*
* Maps to ipywidgets BoundedIntTextModel.
* This is functionally the same as IntText since both support min/max bounds.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function BoundedIntTextWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;
// Local state for the input value (as string for editing)
const [localValue, setLocalValue] = useState(String(value));
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const clampValue = useCallback(
(val: number): number => {
return Math.max(min, Math.min(max, val));
},
[min, max],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
const parsed = parseInt(newValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, continuousUpdate, clampValue, sendUpdate],
);
const handleBlur = useCallback(() => {
const parsed = parseInt(localValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
if (clamped !== value) {
sendUpdate(modelId, { value: clamped });
}
} else {
// Reset to current value if invalid
setLocalValue(String(value));
}
}, [modelId, localValue, value, clampValue, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const parsed = parseInt(localValue, 10);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, localValue, clampValue, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="BoundedIntText"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="number"
value={localValue}
disabled={disabled}
step={step}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-24"
/>
</div>
);
}
export default BoundedIntTextWidget;
registry/widgets/controls/bounded-float-text-widget.tsx
"use client";
/**
* BoundedFloatText widget - renders a bounded numeric text input for floats.
*
* Maps to ipywidgets BoundedFloatTextModel.
* This is functionally the same as FloatText since both support min/max bounds.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function BoundedFloatTextWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;
// Local state for the input value (as string for editing)
const [localValue, setLocalValue] = useState(String(value));
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const clampValue = useCallback(
(val: number): number => {
return Math.max(min, Math.min(max, val));
},
[min, max],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
const parsed = parseFloat(newValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, continuousUpdate, clampValue, sendUpdate],
);
const handleBlur = useCallback(() => {
const parsed = parseFloat(localValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
if (clamped !== value) {
sendUpdate(modelId, { value: clamped });
}
} else {
// Reset to current value if invalid
setLocalValue(String(value));
}
}, [modelId, localValue, value, clampValue, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const parsed = parseFloat(localValue);
if (!Number.isNaN(parsed)) {
const clamped = clampValue(parsed);
setLocalValue(String(clamped));
sendUpdate(modelId, { value: clamped });
}
}
},
[modelId, localValue, clampValue, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="BoundedFloatText"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="number"
value={localValue}
disabled={disabled}
step={step}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-24"
/>
</div>
);
}
export default BoundedFloatTextWidget;
registry/widgets/controls/password-widget.tsx
"use client";
/**
* Password widget - renders a masked text input field.
*
* Maps to ipywidgets PasswordModel.
*/
import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function PasswordWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate, sendCustom } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
// Local state for non-continuous updates
const [localValue, setLocalValue] = useState(value);
// Sync local state when value changes from kernel
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (continuousUpdate) {
sendUpdate(modelId, { value: newValue });
}
},
[modelId, continuousUpdate, sendUpdate],
);
const handleBlur = useCallback(() => {
if (!continuousUpdate && localValue !== value) {
sendUpdate(modelId, { value: localValue });
}
}, [modelId, continuousUpdate, localValue, value, sendUpdate]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// Send submit event
sendCustom(modelId, { event: "submit" });
// Also ensure value is synced
if (!continuousUpdate && localValue !== value) {
sendUpdate(modelId, { value: localValue });
}
}
},
[modelId, continuousUpdate, localValue, value, sendUpdate, sendCustom],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="Password"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="password"
value={localValue}
placeholder={placeholder}
disabled={disabled}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="flex-1"
/>
</div>
);
}
export default PasswordWidget;
registry/widgets/controls/valid-widget.tsx
/**
* Valid widget - displays a validation indicator (checkmark or X).
*
* Maps to ipywidgets ValidModel.
*/
import { CheckIcon, XIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function ValidWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
const readout = useWidgetModelValue<string>(modelId, "readout") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
return (
<div
className={cn("inline-flex items-center gap-2", className)}
data-widget-id={modelId}
data-widget-type="Valid"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
{value ? (
<CheckIcon className="size-4 text-green-500" />
) : (
<XIcon className="size-4 text-red-500" />
)}
{!value && readout && (
<span className="text-sm text-muted-foreground">{readout}</span>
)}
</div>
);
}
export default ValidWidget;
registry/widgets/controls/select-widget.tsx
/**
* Select widget - renders a single-selection listbox.
*
* Maps to ipywidgets SelectModel.
*/
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function SelectWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const selectedIndex = useWidgetModelValue<number | null>(modelId, "index");
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const rows = useWidgetModelValue<number>(modelId, "rows") ?? 5;
const handleSelect = (idx: number) => {
if (disabled) return;
sendUpdate(modelId, { index: idx });
};
// Calculate height based on rows
const itemHeight = 32; // approximate height per item in px
const maxHeight = rows * itemHeight;
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="Select"
>
{description && (
<Label className="shrink-0 pt-1 text-sm">{description}</Label>
)}
<div
role="listbox"
aria-disabled={disabled}
className={cn(
"w-48 overflow-y-auto rounded-md border border-input bg-background shadow-xs",
disabled && "cursor-not-allowed opacity-50",
)}
style={{ maxHeight }}
>
{options.map((option, idx) => {
const isSelected = selectedIndex === idx;
return (
<div
key={idx}
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(idx)}
className={cn(
"flex cursor-pointer select-none items-center px-3 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground",
disabled && "pointer-events-none",
)}
>
<span>{option}</span>
</div>
);
})}
</div>
</div>
);
}
export default SelectWidget;
registry/widgets/controls/html-math-widget.tsx
/**
* HTMLMath widget - renders HTML content with LaTeX math support.
*
* Maps to ipywidgets HTMLMathModel.
* Uses KaTeX to render math expressions in $...$ and $$...$$ delimiters.
*/
import "katex/dist/katex.min.css";
import katex from "katex";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
// Process math in content using KaTeX
function processMath(content: string): string {
// Process display math ($$...$$)
let processed = content.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
try {
return katex.renderToString(math.trim(), {
displayMode: true,
throwOnError: false,
});
} catch {
return `$$${math}$$`;
}
});
// Process inline math ($...$)
processed = processed.replace(/\$([^$\n]+?)\$/g, (_, math) => {
try {
return katex.renderToString(math.trim(), {
displayMode: false,
throwOnError: false,
});
} catch {
return `$${math}$`;
}
});
return processed;
}
export function HTMLMathWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const description = useWidgetModelValue<string>(modelId, "description");
const placeholder = useWidgetModelValue<string>(modelId, "placeholder");
// Show placeholder if value is empty
const displayValue = value || placeholder || "";
// Process content for math rendering
const processedContent = displayValue ? processMath(displayValue) : "";
return (
<div
className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
data-widget-id={modelId}
data-widget-type="HTMLMath"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<div
className="widget-html-math-content"
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
</div>
);
}
export default HTMLMathWidget;
registry/widgets/controls/date-picker-widget.tsx
"use client";
/**
* DatePicker widget - renders a date input field.
*
* Maps to ipywidgets DatePickerModel.
*/
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
type IpyDate = { year: number; month: number; date: number } | null;
// Convert ipywidgets date object to YYYY-MM-DD format for input
function toDateString(value: IpyDate): string {
if (!value) return "";
const year = String(value.year).padStart(4, "0");
const month = String(value.month + 1).padStart(2, "0");
const date = String(value.date).padStart(2, "0");
return `${year}-${month}-${date}`;
}
export function DatePickerWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<IpyDate>(modelId, "value") ?? null;
const min = useWidgetModelValue<IpyDate>(modelId, "min") ?? null;
const max = useWidgetModelValue<IpyDate>(modelId, "max") ?? null;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (newValue) {
const [year, month, day] = newValue.split("-").map(Number);
sendUpdate(modelId, { value: { year, month: month - 1, date: day } });
} else {
sendUpdate(modelId, { value: null });
}
},
[modelId, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="DatePicker"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="date"
value={toDateString(value)}
min={toDateString(min)}
max={toDateString(max)}
disabled={disabled}
onChange={handleChange}
className="w-40"
/>
</div>
);
}
export default DatePickerWidget;
registry/widgets/controls/time-picker-widget.tsx
"use client";
/**
* TimePicker widget - renders a time input field.
*
* Maps to ipywidgets TimePickerModel.
*/
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
// Convert time object or string to HH:MM format for input
function toTimeString(
value: { hours: number; minutes: number; seconds?: number } | string | null,
): string {
if (!value) return "";
if (typeof value === "string") return value;
const hours = String(value.hours).padStart(2, "0");
const minutes = String(value.minutes).padStart(2, "0");
return `${hours}:${minutes}`;
}
export function TimePickerWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value =
useWidgetModelValue<
{ hours: number; minutes: number; seconds?: number } | string | null
>(modelId, "value") ?? null;
const min =
useWidgetModelValue<{ hours: number; minutes: number } | string | null>(
modelId,
"min",
) ?? null;
const max =
useWidgetModelValue<{ hours: number; minutes: number } | string | null>(
modelId,
"max",
) ?? null;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (newValue) {
const [hours, minutes] = newValue.split(":").map(Number);
sendUpdate(modelId, {
value: { hours, minutes, seconds: 0, milliseconds: 0 },
});
} else {
sendUpdate(modelId, { value: null });
}
},
[modelId, sendUpdate],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="TimePicker"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="time"
value={toTimeString(value)}
min={toTimeString(min)}
max={toTimeString(max)}
disabled={disabled}
onChange={handleChange}
className="w-32"
/>
</div>
);
}
export default TimePickerWidget;
registry/widgets/controls/float-log-slider.tsx
/**
* FloatLogSlider widget - renders a logarithmic scale slider.
*
* Maps to ipywidgets FloatLogSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function FloatLogSlider({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 1;
const base = useWidgetModelValue<number>(modelId, "base") ?? 10;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0; // exponent min
const max = useWidgetModelValue<number>(modelId, "max") ?? 4; // exponent max
const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const readoutFormat =
useWidgetModelValue<string>(modelId, "readout_format") ?? ".3g";
// Convert value to log scale position (exponent)
const valueToExponent = (v: number): number => {
if (v <= 0) return min;
return Math.log(v) / Math.log(base);
};
// Convert exponent to actual value
const exponentToValue = (exp: number): number => {
return base ** exp;
};
const currentExponent = valueToExponent(value);
const handleChange = (newValue: number[]) => {
const newExponent = Math.min(max, Math.max(min, newValue[0]));
const newActualValue = exponentToValue(newExponent);
sendUpdate(modelId, { value: newActualValue });
};
// Format value for display
const formatValue = (v: number): string => {
// Parse Python-style format spec (e.g., ".3g")
const matchG = readoutFormat.match(/\.(\d+)g/);
if (matchG) {
return v.toPrecision(parseInt(matchG[1], 10));
}
const matchF = readoutFormat.match(/\.(\d+)f/);
if (matchF) {
return v.toFixed(parseInt(matchF[1], 10));
}
const matchE = readoutFormat.match(/\.(\d+)e/);
if (matchE) {
return v.toExponential(parseInt(matchE[1], 10));
}
return String(v);
};
const isVertical = orientation === "vertical";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="FloatLogSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={[currentExponent]}
min={min}
max={max}
step={step}
disabled={disabled}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "min-w-24 flex-1"}
/>
{readout && (
<span className="w-20 text-right text-sm tabular-nums text-muted-foreground">
{formatValue(value)}
</span>
)}
</div>
);
}
export default FloatLogSlider;
registry/widgets/controls/play-widget.tsx
"use client";
/**
* Play widget - animation control with play/pause/step.
*
* Maps to ipywidgets PlayModel.
*/
import {
PauseIcon,
PlayIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function PlayWidget({ modelId, className }: WidgetComponentProps) {
// sendUpdate is now stable (useCommRouter uses refs internally)
const { sendUpdate } = useWidgetStoreRequired();
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Subscribe to individual state keys
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
const interval = useWidgetModelValue<number>(modelId, "interval") ?? 100; // ms
const playing = useWidgetModelValue<boolean>(modelId, "_playing") ?? false;
const repeat = useWidgetModelValue<boolean>(modelId, "repeat") ?? false;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
// Ref for animation state values - these change frequently and shouldn't restart the interval.
// sendUpdate is stable so it doesn't need a ref.
const valuesRef = useRef({ value, min, max, step, repeat });
// Keep values ref up-to-date without triggering the main effect
useEffect(() => {
valuesRef.current = { value, min, max, step, repeat };
});
// Handle animation interval - only restart when play state or interval timing changes
useEffect(() => {
if (playing && !disabled) {
intervalRef.current = setInterval(() => {
const { value, min, max, step, repeat } = valuesRef.current;
const nextValue =
value + step > max ? (repeat ? min : max) : value + step;
const shouldStop = value + step > max && !repeat;
sendUpdate(modelId, {
value: nextValue,
_playing: !shouldStop,
});
}, interval);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [playing, disabled, interval, modelId, sendUpdate]);
const handlePlayPause = useCallback(() => {
sendUpdate(modelId, { _playing: !playing });
}, [modelId, playing, sendUpdate]);
const handleStepBack = useCallback(() => {
const { min, step, value } = valuesRef.current;
const newValue = Math.max(min, value - step);
sendUpdate(modelId, { value: newValue, _playing: false });
}, [modelId, sendUpdate]);
const handleStepForward = useCallback(() => {
const { max, step, value } = valuesRef.current;
const newValue = Math.min(max, value + step);
sendUpdate(modelId, { value: newValue, _playing: false });
}, [modelId, sendUpdate]);
return (
<div
className={cn("flex items-center gap-2", className)}
data-widget-id={modelId}
data-widget-type="Play"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handleStepBack}
disabled={disabled || value <= min}
>
<SkipBackIcon className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handlePlayPause}
disabled={disabled}
>
{playing ? (
<PauseIcon className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handleStepForward}
disabled={disabled || value >= max}
>
<SkipForwardIcon className="size-4" />
</Button>
</div>
<span className="w-12 text-right text-sm tabular-nums text-muted-foreground">
{value}
</span>
</div>
);
}
export default PlayWidget;
registry/widgets/controls/combobox-widget.tsx
"use client";
/**
* Combobox widget - text input with autocomplete suggestions.
*
* Maps to ipywidgets ComboboxModel.
*
* Uses native HTML <input> with <datalist> instead of Radix UI Popover
* because Radix uses Portal which doesn't work in sandboxed iframes
* (no allow-same-origin). See: https://github.com/runtimed/runt/issues/62
*/
import { useCallback, useEffect, useId, useState } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function ComboboxWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate, sendCustom } = useWidgetStoreRequired();
const datalistId = useId();
// Subscribe to individual state keys
const value = useWidgetModelValue<string>(modelId, "value") ?? "";
const options = useWidgetModelValue<string[]>(modelId, "options") ?? [];
const placeholder =
useWidgetModelValue<string>(modelId, "placeholder") ?? "Select or type...";
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const ensureOption =
useWidgetModelValue<boolean>(modelId, "ensure_option") ?? false;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
const [inputValue, setInputValue] = useState(value);
// Sync input value when value changes from kernel
useEffect(() => {
setInputValue(value);
}, [value]);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setInputValue(newValue);
if (continuousUpdate) {
// Only update if not enforcing option or value is in options
if (!ensureOption || options.includes(newValue)) {
sendUpdate(modelId, { value: newValue });
}
}
},
[modelId, continuousUpdate, ensureOption, options, sendUpdate],
);
const handleBlur = useCallback(() => {
if (!continuousUpdate) {
if (!ensureOption || options.includes(inputValue)) {
sendUpdate(modelId, { value: inputValue });
} else {
// Reset to last valid value
setInputValue(value);
}
}
sendCustom(modelId, { event: "submit" });
}, [
modelId,
continuousUpdate,
ensureOption,
options,
inputValue,
value,
sendUpdate,
sendCustom,
]);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="Combobox"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<input
type="text"
list={datalistId}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className={cn(
// Base styles matching shadcn input
"h-9 w-48 rounded-md border border-input px-3 py-2 text-sm",
"bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30 dark:hover:bg-input/50",
// Placeholder
"placeholder:text-muted-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
// Disabled state
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<datalist id={datalistId}>
{options.map((option, idx) => (
<option key={idx} value={option} />
))}
</datalist>
</div>
);
}
export default ComboboxWidget;
registry/widgets/controls/selection-slider-widget.tsx
/**
* SelectionSlider widget - slider that selects from discrete options.
*
* Maps to ipywidgets SelectionSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function SelectionSliderWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const index = useWidgetModelValue<number>(modelId, "index") ?? 0;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
const handleChange = (newValue: number[]) => {
const newIndex = Math.round(newValue[0]);
const clampedIndex = Math.min(options.length - 1, Math.max(0, newIndex));
if (continuousUpdate || clampedIndex !== index) {
sendUpdate(modelId, { index: clampedIndex });
}
};
const isVertical = orientation === "vertical";
const currentLabel = options[index] ?? "";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="SelectionSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={[index]}
min={0}
max={Math.max(0, options.length - 1)}
step={1}
disabled={disabled || options.length === 0}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "min-w-24 flex-1"}
/>
{readout && (
<span className="min-w-16 text-sm text-muted-foreground">
{currentLabel}
</span>
)}
</div>
);
}
export default SelectionSliderWidget;
registry/widgets/controls/selection-range-slider-widget.tsx
/**
* SelectionRangeSlider widget - range slider that selects from discrete options.
*
* Maps to ipywidgets SelectionRangeSliderModel.
*/
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function SelectionRangeSliderWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const options =
useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
const index = useWidgetModelValue<number[]>(modelId, "index") ?? [0, 0];
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const orientation =
useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
"horizontal";
const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
const continuousUpdate =
useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;
const [lowIndex, highIndex] = index;
const handleChange = (newValue: number[]) => {
const newLow = Math.round(newValue[0]);
const newHigh = Math.round(newValue[1]);
const clampedLow = Math.min(options.length - 1, Math.max(0, newLow));
const clampedHigh = Math.min(options.length - 1, Math.max(0, newHigh));
if (
continuousUpdate ||
clampedLow !== lowIndex ||
clampedHigh !== highIndex
) {
sendUpdate(modelId, { index: [clampedLow, clampedHigh] });
}
};
const isVertical = orientation === "vertical";
const lowLabel = options[lowIndex] ?? "";
const highLabel = options[highIndex] ?? "";
return (
<div
className={cn(
"flex gap-3",
isVertical ? "flex-col items-center" : "items-center",
className,
)}
data-widget-id={modelId}
data-widget-type="SelectionRangeSlider"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Slider
value={[lowIndex, highIndex]}
min={0}
max={Math.max(0, options.length - 1)}
step={1}
disabled={disabled || options.length === 0}
orientation={orientation}
onValueChange={handleChange}
className={isVertical ? "h-32" : "min-w-24 flex-1"}
/>
{readout && (
<span className="min-w-24 text-sm text-muted-foreground">
{lowLabel} - {highLabel}
</span>
)}
</div>
);
}
export default SelectionRangeSliderWidget;
registry/widgets/controls/file-upload-widget.tsx
"use client";
/**
* FileUpload widget - file upload button.
*
* Maps to ipywidgets FileUploadModel.
*/
import { UploadIcon } from "lucide-react";
import { useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
import { getButtonStyle } from "./button-style-utils";
interface FileData {
name: string;
type: string;
size: number;
content: string; // base64
last_modified: number;
}
export function FileUploadWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
const inputRef = useRef<HTMLInputElement>(null);
// Subscribe to individual state keys
const value = useWidgetModelValue<FileData[]>(modelId, "value") ?? [];
const accept = useWidgetModelValue<string>(modelId, "accept") ?? "";
const multiple = useWidgetModelValue<boolean>(modelId, "multiple") ?? false;
const description =
useWidgetModelValue<string>(modelId, "description") ?? "Upload";
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const buttonStyle =
useWidgetModelValue<string>(modelId, "button_style") ?? "";
const icon = useWidgetModelValue<string>(modelId, "icon") ?? "upload";
const { variant, className: styleClassName } = getButtonStyle(buttonStyle);
const handleClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const fileDataArray: FileData[] = [];
for (const file of Array.from(files)) {
// Read file as base64
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove data URL prefix to get just base64
const base64 = result.split(",")[1] || "";
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
fileDataArray.push({
name: file.name,
type: file.type,
size: file.size,
content,
last_modified: file.lastModified,
});
}
sendUpdate(modelId, { value: fileDataArray });
// Reset input so same file can be selected again
if (inputRef.current) {
inputRef.current.value = "";
}
},
[modelId, sendUpdate],
);
const fileCount = value.length;
const buttonText =
fileCount > 0
? `${fileCount} file${fileCount > 1 ? "s" : ""} selected`
: description;
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="FileUpload"
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
disabled={disabled}
className="hidden"
/>
<Button
variant={variant}
onClick={handleClick}
disabled={disabled}
className={cn(styleClassName, "gap-2")}
>
{icon === "upload" && <UploadIcon className="size-4" />}
{buttonText}
</Button>
{fileCount > 0 && (
<Label className="text-sm text-muted-foreground">
{value.map((f) => f.name).join(", ")}
</Label>
)}
</div>
);
}
export default FileUploadWidget;
registry/widgets/controls/tags-input-widget.tsx
"use client";
/**
* TagsInput widget - multi-value string tag input.
*
* Maps to ipywidgets TagsInputModel.
*/
import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function TagsInputWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<string[]>(modelId, "value") ?? [];
const allowedTags = useWidgetModelValue<string[]>(modelId, "allowed_tags");
const allowDuplicates =
useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
const placeholder =
useWidgetModelValue<string>(modelId, "placeholder") ?? "Add tag...";
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const [inputValue, setInputValue] = useState("");
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
const newTag = inputValue.trim();
// Check if tag is allowed
if (
allowedTags &&
allowedTags.length > 0 &&
!allowedTags.includes(newTag)
) {
return;
}
// Check for duplicates
if (!allowDuplicates && value.includes(newTag)) {
return;
}
sendUpdate(modelId, { value: [...value, newTag] });
setInputValue("");
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
// Remove last tag on backspace when input is empty
sendUpdate(modelId, { value: value.slice(0, -1) });
}
},
[modelId, inputValue, value, allowedTags, allowDuplicates, sendUpdate],
);
const handleRemoveTag = useCallback(
(indexToRemove: number) => {
sendUpdate(modelId, {
value: value.filter((_, idx) => idx !== indexToRemove),
});
},
[modelId, value, sendUpdate],
);
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="TagsInput"
>
{description && (
<Label className="shrink-0 pt-2 text-sm">{description}</Label>
)}
<div
className={cn(
"flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
disabled && "cursor-not-allowed opacity-50",
)}
>
{value.map((tag, idx) => (
<Badge
key={`${tag}-${idx}`}
variant="secondary"
className="gap-1 pr-1"
>
{tag}
{!disabled && (
<button
type="button"
onClick={() => handleRemoveTag(idx)}
className="rounded-full p-0.5 hover:bg-muted"
>
<XIcon className="size-3" />
</button>
)}
</Badge>
))}
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
disabled={disabled}
className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
);
}
export default TagsInputWidget;
registry/widgets/controls/colors-input-widget.tsx
"use client";
/**
* ColorsInput widget - multi-value color input.
*
* Maps to ipywidgets ColorsInputModel.
*/
import { PlusIcon, XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function ColorsInputWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
const colorInputRef = useRef<HTMLInputElement>(null);
// Subscribe to individual state keys
const value = useWidgetModelValue<string[]>(modelId, "value") ?? [];
const allowedTags = useWidgetModelValue<string[]>(modelId, "allowed_tags");
const allowDuplicates =
useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const handleAddColor = useCallback(() => {
colorInputRef.current?.click();
}, []);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value;
// Check if color is allowed
if (
allowedTags &&
allowedTags.length > 0 &&
!allowedTags.includes(newColor)
) {
return;
}
// Check for duplicates
if (!allowDuplicates && value.includes(newColor)) {
return;
}
if (editingIndex !== null) {
// Update existing color
const newValue = [...value];
newValue[editingIndex] = newColor;
sendUpdate(modelId, { value: newValue });
setEditingIndex(null);
} else {
// Add new color
sendUpdate(modelId, { value: [...value, newColor] });
}
},
[modelId, value, allowedTags, allowDuplicates, editingIndex, sendUpdate],
);
const handleRemoveColor = useCallback(
(indexToRemove: number) => {
sendUpdate(modelId, {
value: value.filter((_, idx) => idx !== indexToRemove),
});
},
[modelId, value, sendUpdate],
);
const handleEditColor = useCallback((idx: number) => {
setEditingIndex(idx);
// Trigger color picker with slight delay to allow state update
setTimeout(() => {
colorInputRef.current?.click();
}, 0);
}, []);
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="ColorsInput"
>
{description && (
<Label className="shrink-0 pt-2 text-sm">{description}</Label>
)}
<div
className={cn(
"flex flex-wrap items-center gap-2",
disabled && "cursor-not-allowed opacity-50",
)}
>
{value.map((color, idx) => (
<div
key={`${color}-${idx}`}
className="group relative flex items-center"
>
<button
type="button"
onClick={() => handleEditColor(idx)}
disabled={disabled}
className="size-8 rounded-md border border-input shadow-sm transition-transform hover:scale-105"
style={{ backgroundColor: color }}
title={color}
/>
{!disabled && (
<button
type="button"
onClick={() => handleRemoveColor(idx)}
className="absolute -right-1 -top-1 rounded-full bg-background p-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
))}
<input
ref={colorInputRef}
type="color"
value={editingIndex !== null ? value[editingIndex] : "#000000"}
onChange={handleColorChange}
disabled={disabled}
className="sr-only"
/>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handleAddColor}
disabled={disabled}
>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
);
}
export default ColorsInputWidget;
registry/widgets/controls/controller-widget.tsx
"use client";
/**
* Controller widget - gamepad input via Web Gamepad API.
*
* Maps to ipywidgets ControllerModel. Polls connected gamepads
* and updates button/axis child widgets with current state.
*/
import { GamepadIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
parseModelRef,
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";
export function ControllerWidget({ modelId, className }: WidgetComponentProps) {
// sendUpdate is now stable (useCommRouter uses refs internally)
const { sendUpdate } = useWidgetStoreRequired();
const animationRef = useRef<number | null>(null);
const lastStateRef = useRef<{
buttons: { pressed: boolean; value: number }[];
axes: number[];
} | null>(null);
// Subscribe to widget state
const index = useWidgetModelValue<number>(modelId, "index") ?? 0;
const connected = useWidgetModelValue<boolean>(modelId, "connected") ?? false;
const name = useWidgetModelValue<string>(modelId, "name") ?? "";
const mapping = useWidgetModelValue<string>(modelId, "mapping") ?? "";
const buttons = useWidgetModelValue<string[]>(modelId, "buttons") ?? [];
const axes = useWidgetModelValue<string[]>(modelId, "axes") ?? [];
// Ref for polling state values - these change during polling and shouldn't restart the rAF loop.
// sendUpdate is stable so it doesn't need a ref.
const stateRef = useRef({ connected, buttons, axes });
// Keep state ref up-to-date without triggering the polling effect
useEffect(() => {
stateRef.current = { connected, buttons, axes };
});
// Poll gamepad state - only depends on index, modelId, sendUpdate to avoid loop restarts
const pollGamepad = useCallback(() => {
const gamepads = navigator.getGamepads();
const gamepad = gamepads[index];
const { connected, buttons, axes } = stateRef.current;
if (gamepad) {
// Check if we need to update connection state
if (!connected) {
sendUpdate(modelId, {
connected: true,
name: gamepad.id,
mapping: gamepad.mapping,
timestamp: gamepad.timestamp,
});
}
const lastState = lastStateRef.current;
let hasChanges = false;
// Update button states
gamepad.buttons.forEach((button, i) => {
const buttonRef = buttons[i];
if (!buttonRef) return;
const buttonId = parseModelRef(buttonRef);
if (!buttonId) return;
const lastButton = lastState?.buttons[i];
if (
!lastButton ||
lastButton.pressed !== button.pressed ||
Math.abs(lastButton.value - button.value) > 0.01
) {
sendUpdate(buttonId, {
pressed: button.pressed,
value: button.value,
});
hasChanges = true;
}
});
// Update axis states
gamepad.axes.forEach((axisValue, i) => {
const axisRef = axes[i];
if (!axisRef) return;
const axisId = parseModelRef(axisRef);
if (!axisId) return;
const lastAxis = lastState?.axes[i];
if (lastAxis === undefined || Math.abs(lastAxis - axisValue) > 0.01) {
sendUpdate(axisId, { value: axisValue });
hasChanges = true;
}
});
// Store current state for comparison
if (hasChanges || !lastState) {
lastStateRef.current = {
buttons: gamepad.buttons.map((b) => ({
pressed: b.pressed,
value: b.value,
})),
axes: [...gamepad.axes],
};
}
// Update timestamp
if (hasChanges) {
sendUpdate(modelId, { timestamp: gamepad.timestamp });
}
} else if (connected) {
// Gamepad disconnected
sendUpdate(modelId, { connected: false });
lastStateRef.current = null;
}
animationRef.current = requestAnimationFrame(pollGamepad);
}, [index, modelId, sendUpdate]);
// Start/stop polling based on component lifecycle
useEffect(() => {
// Start polling
animationRef.current = requestAnimationFrame(pollGamepad);
// Handle gamepad connect/disconnect events
const handleConnect = (e: GamepadEvent) => {
if (e.gamepad.index === index) {
sendUpdate(modelId, {
connected: true,
name: e.gamepad.id,
mapping: e.gamepad.mapping,
});
}
};
const handleDisconnect = (e: GamepadEvent) => {
if (e.gamepad.index === index) {
sendUpdate(modelId, { connected: false });
lastStateRef.current = null;
}
};
window.addEventListener("gamepadconnected", handleConnect);
window.addEventListener("gamepaddisconnected", handleDisconnect);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
window.removeEventListener("gamepadconnected", handleConnect);
window.removeEventListener("gamepaddisconnected", handleDisconnect);
};
}, [index, modelId, pollGamepad, sendUpdate]);
return (
<div
className={cn("flex flex-col gap-3 rounded-lg border p-4", className)}
data-widget-id={modelId}
data-widget-type="Controller"
>
{/* Header */}
<div className="flex items-center gap-2">
<GamepadIcon
className={cn(
"size-5",
connected ? "text-green-500" : "text-muted-foreground",
)}
/>
<Label className="font-medium">{name || `Controller ${index}`}</Label>
<span
className={cn(
"ml-auto text-xs",
connected ? "text-green-500" : "text-muted-foreground",
)}
>
{connected ? "Connected" : "Disconnected"}
</span>
</div>
{connected && (
<>
{/* Buttons */}
{buttons.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Buttons</Label>
<div className="flex flex-wrap gap-1">
{buttons.map((buttonRef, i) => {
const buttonId = parseModelRef(buttonRef);
return buttonId ? (
<WidgetView key={buttonId} modelId={buttonId} />
) : (
<div
key={i}
className="flex size-8 items-center justify-center rounded-full border bg-muted text-xs"
>
{i}
</div>
);
})}
</div>
</div>
)}
{/* Axes */}
{axes.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Axes</Label>
<div className="flex flex-col gap-1">
{axes.map((axisRef, i) => {
const axisId = parseModelRef(axisRef);
return axisId ? (
<div key={axisId} className="flex items-center gap-2">
<span className="w-8 text-xs text-muted-foreground">
{i}:
</span>
<WidgetView modelId={axisId} />
</div>
) : null;
})}
</div>
</div>
)}
{/* Mapping info */}
{mapping && (
<div className="text-xs text-muted-foreground">
Mapping: {mapping}
</div>
)}
</>
)}
{!connected && (
<div className="py-4 text-center text-sm text-muted-foreground">
Press a button on a gamepad to connect
</div>
)}
</div>
);
}
export default ControllerWidget;
registry/widgets/controls/controller-button-widget.tsx
/**
* ControllerButton widget - displays a gamepad button state.
*
* Maps to ipywidgets ControllerButtonModel.
*/
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function ControllerButtonWidget({
modelId,
className,
}: WidgetComponentProps) {
const pressed = useWidgetModelValue<boolean>(modelId, "pressed") ?? false;
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
return (
<div
className={cn(
"flex size-8 items-center justify-center rounded-full border-2 text-xs font-medium transition-colors",
pressed
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/30 bg-muted text-muted-foreground",
className,
)}
data-widget-id={modelId}
data-widget-type="ControllerButton"
title={`Value: ${value.toFixed(2)}`}
>
{pressed ? "●" : "○"}
</div>
);
}
export default ControllerButtonWidget;
registry/widgets/controls/controller-axis-widget.tsx
/**
* ControllerAxis widget - displays a gamepad axis value.
*
* Maps to ipywidgets ControllerAxisModel.
*/
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function ControllerAxisWidget({
modelId,
className,
}: WidgetComponentProps) {
// Axis value ranges from -1 to 1
const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
// Convert -1..1 to 0..100 for progress display
const progressValue = ((value + 1) / 2) * 100;
return (
<div
className={cn("flex items-center gap-2", className)}
data-widget-id={modelId}
data-widget-type="ControllerAxis"
>
<Progress value={progressValue} className="h-2 w-16" />
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
{value.toFixed(2)}
</span>
</div>
);
}
export default ControllerAxisWidget;
registry/widgets/controls/output-widget.tsx
"use client";
/**
* Output widget - renders captured Jupyter outputs.
*
* Maps to ipywidgets OutputModel (@jupyter-widgets/output).
* Renders an array of Jupyter outputs using the OutputArea component.
* Media rendering configuration (custom renderers, priority) is
* inherited from MediaProvider context if present.
*
* Note: The Output widget protocol is particularly complex. Rather than
* simply setting outputs on the model from Python, Jupyter sends outputs
* as custom messages that must be accumulated client-side. This includes
* handling clear_output(wait=True) which defers clearing until the next
* output arrives. We sync rendered state back to the model to keep
* Python's `out.outputs` in sync with what's displayed.
*/
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type JupyterOutput, OutputArea } from "@/registry/cell/OutputArea";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
interface OutputCustomMessage {
method?: unknown;
output?: unknown;
wait?: unknown;
}
function isJupyterOutput(value: unknown): value is JupyterOutput {
if (!value || typeof value !== "object") return false;
const output = value as Partial<JupyterOutput>;
return (
output.output_type === "execute_result" ||
output.output_type === "display_data" ||
output.output_type === "stream" ||
output.output_type === "error"
);
}
export function OutputWidget({ modelId, className }: WidgetComponentProps) {
const { store, sendUpdate } = useWidgetStoreRequired();
const stateOutputs =
useWidgetModelValue<JupyterOutput[]>(modelId, "outputs") ?? [];
const stateOutputsRef = useRef(stateOutputs);
const shouldClearOnNextOutputRef = useRef(false);
const [renderedOutputs, setRenderedOutputs] =
useState<JupyterOutput[]>(stateOutputs);
const renderedOutputsRef = useRef(renderedOutputs);
useEffect(() => {
stateOutputsRef.current = stateOutputs;
renderedOutputsRef.current = stateOutputs;
setRenderedOutputs(stateOutputs);
}, [stateOutputs]);
useEffect(() => {
renderedOutputsRef.current = renderedOutputs;
}, [renderedOutputs]);
useEffect(() => {
let replayingBufferedMessages = true;
const unsubscribe = store.subscribeToCustomMessage(modelId, (content) => {
const message = content as OutputCustomMessage;
const method =
typeof message.method === "string" ? message.method : undefined;
// OutputModel may have already synchronized state.outputs.
// Skip initial buffered custom messages in that case to avoid duplicates.
if (replayingBufferedMessages && stateOutputsRef.current.length > 0) {
return;
}
if (method === "clear_output") {
const wait = Boolean(message.wait);
if (wait) {
shouldClearOnNextOutputRef.current = true;
} else {
shouldClearOnNextOutputRef.current = false;
renderedOutputsRef.current = [];
setRenderedOutputs([]);
// Keep Python-side `out.outputs` in sync with displayed output state.
sendUpdate(modelId, { outputs: [] });
}
return;
}
if (method !== "output" || !isJupyterOutput(message.output)) {
return;
}
const nextOutput: JupyterOutput = message.output;
const prev = renderedOutputsRef.current;
const next = shouldClearOnNextOutputRef.current
? [nextOutput]
: [...prev, nextOutput];
shouldClearOnNextOutputRef.current = false;
renderedOutputsRef.current = next;
setRenderedOutputs(next);
// Keep Python-side `out.outputs` in sync with displayed output state.
sendUpdate(modelId, { outputs: next });
});
replayingBufferedMessages = false;
return unsubscribe;
}, [modelId, sendUpdate, store]);
if (renderedOutputs.length === 0) {
return null;
}
// Check if we're already inside an iframe (isolated context).
// If so, skip nested isolation since the outer iframe already provides security.
// This prevents double-nesting: widget iframe → OutputWidget iframe → content
const isInIframe = typeof window !== "undefined" && window.parent !== window;
return (
<div
className={cn("widget-output", className)}
data-widget-id={modelId}
data-widget-type="Output"
>
<OutputArea
outputs={renderedOutputs}
isolated={isInIframe ? false : "auto"}
/>
</div>
);
}
export default OutputWidget;
registry/widgets/controls/button-style-utils.ts
/**
* Shared utility for mapping ipywidgets button_style to shadcn Button
* variant + Tailwind className overrides.
*
* The shadcn Button only has default, destructive, outline, secondary, ghost,
* link variants — no info/success/warning. We use the "default" variant as a
* base and apply Tailwind color classes via className. tailwind-merge resolves
* the conflict in favor of our overrides.
*/
type ButtonStyleVariant = "default" | "destructive" | "outline";
interface ButtonStyleResult {
variant: ButtonStyleVariant;
className: string;
}
const BUTTON_STYLE_MAP: Record<string, ButtonStyleResult> = {
primary: {
variant: "default",
className: "bg-blue-600 text-white hover:bg-blue-700",
},
success: {
variant: "default",
className: "bg-green-600 text-white hover:bg-green-700",
},
info: {
variant: "default",
className: "bg-sky-600 text-white hover:bg-sky-700",
},
warning: {
variant: "default",
className: "bg-amber-500 text-white hover:bg-amber-600",
},
danger: {
variant: "destructive",
className: "",
},
"": {
variant: "outline",
className: "",
},
};
const DEFAULT_STYLE: ButtonStyleResult = { variant: "outline", className: "" };
export function getButtonStyle(buttonStyle: string): ButtonStyleResult {
return BUTTON_STYLE_MAP[buttonStyle] ?? DEFAULT_STYLE;
}
registry/widgets/controls/audio-widget.tsx
/**
* Audio widget - plays audio from the kernel.
*
* Maps to ipywidgets AudioModel.
*/
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function AudioWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
const format = useWidgetModelValue<string>(modelId, "format") ?? "mp3";
const autoplay = useWidgetModelValue<boolean>(modelId, "autoplay") ?? true;
const loop = useWidgetModelValue<boolean>(modelId, "loop") ?? true;
const controls = useWidgetModelValue<boolean>(modelId, "controls") ?? true;
const description = useWidgetModelValue<string>(modelId, "description");
const src = useMemo(
() => buildMediaSrc(value, "audio", format),
[value, format],
);
if (!value) {
return null;
}
return (
<div
className={cn("inline-flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="Audio"
>
{description && (
<Label className="shrink-0 pt-1 text-sm">{description}</Label>
)}
{/* biome-ignore lint/a11y/useMediaCaption: ipywidgets audio does not provide captions */}
<audio src={src} autoPlay={autoplay} loop={loop} controls={controls} />
</div>
);
}
export default AudioWidget;
registry/widgets/controls/video-widget.tsx
/**
* Video widget - plays video from the kernel.
*
* Maps to ipywidgets VideoModel.
*/
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";
export function VideoWidget({ modelId, className }: WidgetComponentProps) {
const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
const format = useWidgetModelValue<string>(modelId, "format") ?? "mp4";
const width = useWidgetModelValue<string>(modelId, "width") ?? "";
const height = useWidgetModelValue<string>(modelId, "height") ?? "";
const autoplay = useWidgetModelValue<boolean>(modelId, "autoplay") ?? true;
const loop = useWidgetModelValue<boolean>(modelId, "loop") ?? true;
const controls = useWidgetModelValue<boolean>(modelId, "controls") ?? true;
const description = useWidgetModelValue<string>(modelId, "description");
const src = useMemo(
() => buildMediaSrc(value, "video", format),
[value, format],
);
if (!value) {
return null;
}
const style: React.CSSProperties = {};
if (width) style.width = width;
if (height) style.height = height;
return (
<div
className={cn("inline-flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="Video"
>
{description && (
<Label className="shrink-0 pt-1 text-sm">{description}</Label>
)}
{/* biome-ignore lint/a11y/useMediaCaption: ipywidgets video does not provide captions */}
<video
src={src}
autoPlay={autoplay}
loop={loop}
controls={controls}
style={style}
className="max-w-full"
/>
</div>
);
}
export default VideoWidget;
registry/widgets/controls/ints-input-widget.tsx
"use client";
/**
* IntsInput widget - multi-value integer tag input.
*
* Maps to ipywidgets IntsInputModel.
*/
import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function IntsInputWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number[]>(modelId, "value") ?? [];
const allowDuplicates =
useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
const placeholder =
useWidgetModelValue<string>(modelId, "placeholder") ?? "Add integer...";
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const min = useWidgetModelValue<number>(modelId, "min");
const max = useWidgetModelValue<number>(modelId, "max");
const [inputValue, setInputValue] = useState("");
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
const parsed = parseInt(inputValue.trim(), 10);
// Validate integer
if (Number.isNaN(parsed)) return;
// Check bounds
if (min != null && parsed < min) return;
if (max != null && parsed > max) return;
// Check for duplicates
if (!allowDuplicates && value.includes(parsed)) return;
sendUpdate(modelId, { value: [...value, parsed] });
setInputValue("");
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
sendUpdate(modelId, { value: value.slice(0, -1) });
}
},
[modelId, inputValue, value, allowDuplicates, min, max, sendUpdate],
);
const handleRemoveTag = useCallback(
(indexToRemove: number) => {
sendUpdate(modelId, {
value: value.filter((_, idx) => idx !== indexToRemove),
});
},
[modelId, value, sendUpdate],
);
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="IntsInput"
>
{description && (
<Label className="shrink-0 pt-2 text-sm">{description}</Label>
)}
<div
className={cn(
"flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
disabled && "cursor-not-allowed opacity-50",
)}
>
{value.map((num, idx) => (
<Badge
key={`${num}-${idx}`}
variant="secondary"
className="gap-1 pr-1"
>
{num}
{!disabled && (
<button
type="button"
onClick={() => handleRemoveTag(idx)}
className="rounded-full p-0.5 hover:bg-muted"
>
<XIcon className="size-3" />
</button>
)}
</Badge>
))}
<Input
type="text"
inputMode="numeric"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
disabled={disabled}
className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
);
}
export default IntsInputWidget;
registry/widgets/controls/floats-input-widget.tsx
"use client";
/**
* FloatsInput widget - multi-value float tag input.
*
* Maps to ipywidgets FloatsInputModel.
*/
import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
export function FloatsInputWidget({
modelId,
className,
}: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
// Subscribe to individual state keys
const value = useWidgetModelValue<number[]>(modelId, "value") ?? [];
const allowDuplicates =
useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
const placeholder =
useWidgetModelValue<string>(modelId, "placeholder") ?? "Add number...";
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const min = useWidgetModelValue<number>(modelId, "min");
const max = useWidgetModelValue<number>(modelId, "max");
const [inputValue, setInputValue] = useState("");
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
const parsed = parseFloat(inputValue.trim());
// Validate float
if (Number.isNaN(parsed)) return;
// Check bounds
if (min != null && parsed < min) return;
if (max != null && parsed > max) return;
// Check for duplicates
if (!allowDuplicates && value.includes(parsed)) return;
sendUpdate(modelId, { value: [...value, parsed] });
setInputValue("");
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
sendUpdate(modelId, { value: value.slice(0, -1) });
}
},
[modelId, inputValue, value, allowDuplicates, min, max, sendUpdate],
);
const handleRemoveTag = useCallback(
(indexToRemove: number) => {
sendUpdate(modelId, {
value: value.filter((_, idx) => idx !== indexToRemove),
});
},
[modelId, value, sendUpdate],
);
return (
<div
className={cn("flex items-start gap-3", className)}
data-widget-id={modelId}
data-widget-type="FloatsInput"
>
{description && (
<Label className="shrink-0 pt-2 text-sm">{description}</Label>
)}
<div
className={cn(
"flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
disabled && "cursor-not-allowed opacity-50",
)}
>
{value.map((num, idx) => (
<Badge
key={`${num}-${idx}`}
variant="secondary"
className="gap-1 pr-1"
>
{num}
{!disabled && (
<button
type="button"
onClick={() => handleRemoveTag(idx)}
className="rounded-full p-0.5 hover:bg-muted"
>
<XIcon className="size-3" />
</button>
)}
</Badge>
))}
<Input
type="text"
inputMode="decimal"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
disabled={disabled}
className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
);
}
export default FloatsInputWidget;
registry/widgets/controls/datetime-widget.tsx
"use client";
/**
* Datetime widget - renders a datetime-local input field.
*
* Maps to ipywidgets DatetimeModel (UTC/timezone-aware) and NaiveDatetimeModel (local time).
*
* DatetimeModel: Values are in UTC. We convert to local time for display and back to UTC on change.
* NaiveDatetimeModel: Values are in local time. No conversion needed.
*/
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
useWidgetModel,
useWidgetModelValue,
useWidgetStoreRequired,
} from "../widget-store-context";
type DatetimeValue =
| {
year: number;
month: number;
date: number;
hours: number;
minutes: number;
seconds: number;
milliseconds?: number;
}
| string
| null;
/**
* Create a Date from UTC components (for DatetimeModel).
* The returned Date stores the time internally as UTC, so local getters will
* automatically convert to the user's timezone.
*/
function utcComponentsToDate(
value: Exclude<DatetimeValue, string | null>,
): Date {
const date = new Date();
date.setUTCFullYear(value.year, value.month, value.date);
date.setUTCHours(
value.hours,
value.minutes,
value.seconds,
value.milliseconds ?? 0,
);
return date;
}
/**
* Create a Date from local components (for NaiveDatetimeModel).
*/
function localComponentsToDate(
value: Exclude<DatetimeValue, string | null>,
): Date {
const date = new Date();
date.setFullYear(value.year, value.month, value.date);
date.setHours(
value.hours,
value.minutes,
value.seconds,
value.milliseconds ?? 0,
);
return date;
}
/**
* Format a Date as datetime-local input string (YYYY-MM-DDTHH:MM).
* Always uses local time getters since datetime-local displays in local time.
*/
function formatDateForInput(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* Convert datetime value to datetime-local input format (YYYY-MM-DDTHH:MM).
* @param value - The datetime value from ipywidgets
* @param isUtc - Whether the value is in UTC (DatetimeModel) or local time (NaiveDatetimeModel)
*/
function toDatetimeLocalString(value: DatetimeValue, isUtc: boolean): string {
if (!value) return "";
if (typeof value === "string") {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return formatDateForInput(date);
}
// Convert components to Date, then format for display
const date = isUtc
? utcComponentsToDate(value)
: localComponentsToDate(value);
return formatDateForInput(date);
}
/**
* Extract UTC components from a Date (for DatetimeModel).
*/
function dateToUtcComponents(d: Date) {
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth(),
date: d.getUTCDate(),
hours: d.getUTCHours(),
minutes: d.getUTCMinutes(),
seconds: d.getUTCSeconds(),
milliseconds: d.getUTCMilliseconds(),
};
}
/**
* Extract local components from a Date (for NaiveDatetimeModel).
*/
function dateToLocalComponents(d: Date) {
return {
year: d.getFullYear(),
month: d.getMonth(),
date: d.getDate(),
hours: d.getHours(),
minutes: d.getMinutes(),
seconds: d.getSeconds(),
milliseconds: d.getMilliseconds(),
};
}
export function DatetimeWidget({ modelId, className }: WidgetComponentProps) {
const { sendUpdate } = useWidgetStoreRequired();
const model = useWidgetModel(modelId);
// Determine if this is a timezone-aware (UTC) model or naive (local) model
const isUtc = model?.modelName === "DatetimeModel";
// Subscribe to individual state keys
const value = useWidgetModelValue<DatetimeValue>(modelId, "value") ?? null;
const min = useWidgetModelValue<DatetimeValue>(modelId, "min") ?? null;
const max = useWidgetModelValue<DatetimeValue>(modelId, "max") ?? null;
const description = useWidgetModelValue<string>(modelId, "description");
const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (newValue) {
// datetime-local input gives us a local time string
const d = new Date(newValue);
// Convert to appropriate format based on model type
const components = isUtc
? dateToUtcComponents(d)
: dateToLocalComponents(d);
sendUpdate(modelId, { value: components });
} else {
sendUpdate(modelId, { value: null });
}
},
[modelId, sendUpdate, isUtc],
);
return (
<div
className={cn("flex items-center gap-3", className)}
data-widget-id={modelId}
data-widget-type="Datetime"
>
{description && <Label className="shrink-0 text-sm">{description}</Label>}
<Input
type="datetime-local"
value={toDatetimeLocalString(value, isUtc)}
min={toDatetimeLocalString(min, isUtc)}
max={toDatetimeLocalString(max, isUtc)}
disabled={disabled}
onChange={handleChange}
className="w-52"
/>
</div>
);
}
export default DatetimeWidget;
registry/widgets/controls/stack-widget.tsx
/**
* Stack widget - single-child container showing one child at a time.
*
* Maps to ipywidgets StackModel. Like Tab but without the tab headers —
* displays only the child at `selected_index`.
*/
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";
export function StackWidget({ modelId, className }: WidgetComponentProps) {
// Subscribe to individual state keys
const children = useWidgetModelValue<string[]>(modelId, "children");
const selectedIndex =
useWidgetModelValue<number>(modelId, "selected_index") ?? 0;
if (!children || children.length === 0) return null;
const selectedRef = children[selectedIndex];
if (!selectedRef) return null;
const childId = parseModelRef(selectedRef);
if (!childId) return null;
return (
<div
className={cn("w-full", className)}
data-widget-id={modelId}
data-widget-type="Stack"
>
<WidgetView modelId={childId} />
</div>
);
}
export default StackWidget;