import type { ComponentType, ReactNode } from "react"; import { createContext, createElement, useContext, useEffect, useId, useRef, useState } from "react"; import * as Core from "@mantine/core"; import * as Charts from "@mantine/charts"; import * as Dates from "@mantine/dates"; import { customJsxBindableComponentNames, enabledCustomJsxComponents, getCustomJsxBindingType, } from "../core/component-registry"; import type { CustomJsxBindingType } from "../core/component-registry"; import { ActionButton, ToggleSwitch } from "../runtime/actions"; import { SubData } from "../runtime/data"; import { RefreshButton } from "../runtime/refresh-button"; import { SubFetch } from "../runtime/sub-fetch"; import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components"; import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy"; import { sanitizeCustomJsxProps } from "./safe-properties"; type Namespace = object; function resolveExport(namespace: Namespace, name: string): ComponentType | undefined { let value: unknown = namespace; for (const segment of name.split(".")) { if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) { return undefined; } value = Reflect.get(value, segment); } return typeof value === "function" || (typeof value === "object" && value !== null) ? (value as ComponentType) : undefined; } export type WidgetInputValue = string | number | boolean | string[] | number[]; export type WidgetInputType = CustomJsxBindingType; interface CustomJsxInputsContextValue { scopeId: string; inputs: Record; inputTypes: Record; registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): () => void; setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void; } const CustomJsxInputsContext = createContext(null); export function CustomJsxInputsProvider({ children, scopeId, inputs, inputTypes, registerInput, setInputValue, }: CustomJsxInputsContextValue & { children: ReactNode }) { return ( {children} ); } const checkedComponents = new Set(["Checkbox", "Switch"]); const openedComponents = new Set(["Menu", "Popover"]); const activeComponents = new Set(["Stepper"]); const comboboxComponents = new Set(["Autocomplete", "MultiSelect", "Select", "TagsInput", "TreeSelect"]); const popoverInputComponents = new Set([ "ColorInput", "DateInput", "DatePickerInput", "DateTimePicker", "MonthPickerInput", "TimePicker", "YearPickerInput", ]); const modalPickerComponents = new Set(["DatePickerInput", "DateTimePicker", "MonthPickerInput", "YearPickerInput"]); const namedRadioComponents = new Set(["Radio", "Radio.Card", "Radio.Group", "RadioCard", "RadioGroup"]); const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]); function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null { if (value && typeof value === "object" && "currentTarget" in value) { const target = (value as { currentTarget?: { checked?: unknown; value?: unknown } }).currentTarget; const result = checked ? target?.checked : target?.value; return typeof result === "string" || typeof result === "number" || typeof result === "boolean" ? result : null; } if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value; if (Array.isArray(value) && value.every((entry) => typeof entry === "string" || entry === null)) { return value.filter((entry): entry is string => typeof entry === "string"); } if (Array.isArray(value) && value.every((entry) => typeof entry === "number")) return value; return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null ? value : null; } function useBoundProps(componentName: string, props: Record): Record { const detachedScopeId = useId(); const binding = typeof props.bind === "string" ? props.bind : undefined; const context = useContext(CustomJsxInputsContext); const sanitized = sanitizeCustomJsxProps(props, componentName); if (namedRadioComponents.has(componentName) && typeof sanitized.name === "string") { sanitized.name = getScopedCustomJsxControlName(context?.scopeId ?? detachedScopeId, sanitized.name); } delete sanitized.bind; const inputType = getCustomJsxBindingType(componentName, sanitized); const initialValue = getInitialInputValue(inputType, sanitized); const serializedInitialValue = JSON.stringify(initialValue); const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName)); const registerInput = context?.registerInput; useEffect(() => { if (!isBound || !binding || !registerInput || !inputType || serializedInitialValue === undefined) return; return registerInput(binding, inputType, JSON.parse(serializedInitialValue) as WidgetInputValue); }, [binding, inputType, isBound, registerInput, serializedInitialValue]); if (!isBound || !binding || !context || !inputType) return sanitized; delete sanitized.defaultValue; delete sanitized.defaultChecked; let currentValue = initialValue; if (context.inputTypes[binding] === inputType && Object.hasOwn(context.inputs, binding)) { const storedValue = context.inputs[binding]; if (storedValue !== undefined) currentValue = storedValue; } const update = (value: unknown, checked = false) => { const extracted = extractEventValue(value, checked); context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType)); }; if (checkedComponents.has(componentName)) { return { ...sanitized, checked: Boolean(currentValue), onChange: (event: unknown) => update(event, true), }; } if (openedComponents.has(componentName)) { return { ...sanitized, opened: Boolean(currentValue), onChange: update, withinPortal: false }; } if (activeComponents.has(componentName)) { return { ...sanitized, active: Number(currentValue), onStepClick: update }; } return { ...sanitized, value: currentValue, onChange: update }; } function getInitialInputValue(type: WidgetInputType | null, props: Record): WidgetInputValue { if (!type) return ""; const candidate = type === "boolean" ? props.defaultChecked : props.defaultValue; if (type === "boolean" && typeof candidate === "boolean") return candidate; if (type === "number" && typeof candidate === "number" && Number.isFinite(candidate)) return candidate; if (type === "string" && typeof candidate === "string") return candidate; if (type === "string[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "string")) return candidate; if ( type === "number[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "number" && Number.isFinite(item)) ) return candidate; return emptyInputValue(type); } function emptyInputValue(type: WidgetInputType): WidgetInputValue { if (type.endsWith("[]")) return []; if (type === "number") return 0; if (type === "boolean") return false; return ""; } function wrap(componentName: string, component: ComponentType, additions: Record = {}) { return function SafeComponent(props: Record) { const bound = useBoundProps(componentName, props); const scoped = { ...bound, ...additions }; if (comboboxComponents.has(componentName)) scoped.comboboxProps = scopeOverlayProps(bound.comboboxProps); if (popoverInputComponents.has(componentName)) scoped.popoverProps = scopeOverlayProps(bound.popoverProps); return createElement(component, scoped as never, props.children as ReactNode); }; } function scopeOverlayProps(value: unknown) { const candidate = value !== null && typeof value === "object" && !Array.isArray(value) ? sanitizeCustomJsxProps(value as Record) : {}; return { ...candidate, withinPortal: false }; } function SafeLink({ component, props }: { component: ComponentType; props: Record }) { const sanitized = sanitizeCustomJsxProps(props); const target = sanitized.target === "_blank" || sanitized.target === "_self" ? sanitized.target : undefined; return createElement( component, { ...sanitized, href: isSafeCustomJsxUrl(props.href) ? props.href : undefined, target, rel: target === "_blank" ? "noopener noreferrer" : sanitized.rel, } as never, props.children as ReactNode, ); } function createCopyButton(labels: { copy: string; copied: string }) { return function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) { const [copied, setCopied] = useState(false); const timer = useRef>(undefined); useEffect(() => () => clearTimeout(timer.current), []); const copy = async () => { if (!value) return; try { await navigator.clipboard.writeText(value); setCopied(true); clearTimeout(timer.current); timer.current = setTimeout(() => setCopied(false), 2_000); } catch { setCopied(false); } }; return ( void copy()} > {copied ? labels.copied : labels.copy} ); }; } export interface CustomJsxComponentAdapters { TablerIcon: ComponentType; copyLabels: { copy: string; copied: string }; } export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record> { const namespaces: Record = { "@mantine/core": Core, "@mantine/charts": Charts, "@mantine/dates": Dates, }; const components: Record> = {}; for (const descriptor of enabledCustomJsxComponents) { const namespace = namespaces[descriptor.package]; const component = namespace ? resolveExport(namespace, descriptor.name) : undefined; if (component) { const additions: Record = {}; if ( ["HoverCard", "Menu", "Popover", "Tooltip", "Tooltip.Floating", "TooltipFloating"].includes(descriptor.name) ) { additions.withinPortal = false; } if (modalPickerComponents.has(descriptor.name)) additions.dropdownType = "popover"; if (buttonRootComponents.has(descriptor.name)) additions.type = "button"; components[descriptor.name] = wrap(descriptor.name, component, additions); } } const core: Namespace = Core; for (const name of [ "Notification", "LoadingOverlay", "Overlay", "Breadcrumbs", "Stepper", "Tree", "Button", "ActionIcon", "Burger", "CloseButton", "Chip", ] as const) { const component = resolveExport(core, name); if (component) components[name] = wrap(name, component, buttonRootComponents.has(name) ? { type: "button" } : {}); } const anchor = resolveExport(core, "Anchor"); const navLink = resolveExport(core, "NavLink"); if (anchor) components.Anchor = (props) => ; if (navLink) components.NavLink = (props) => ; Object.assign(components, { CopyButton: createCopyButton(adapters.copyLabels), PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge, TablerIcon: adapters.TablerIcon, SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton, }); return components; }