feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -1,15 +1,21 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useEffect, useRef, useState } 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 { enabledCustomJsxComponents } from "../core/component-registry";
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 = Readonly<Record<string, unknown>>;
@@ -27,23 +33,157 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
: undefined;
}
function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
export type WidgetInputValue = string | number | boolean | string[] | number[];
export type WidgetInputType = CustomJsxBindingType;
interface CustomJsxInputsContextValue {
scopeId: string;
inputs: Record<string, WidgetInputValue>;
inputTypes: Record<string, WidgetInputType>;
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void;
}
function wrap(component: ComponentType<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), ...additions } as never);
const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>(null);
export function CustomJsxInputsProvider({
children,
scopeId,
inputs,
inputTypes,
registerInput,
setInputValue,
}: CustomJsxInputsContextValue & { children: ReactNode }) {
return (
<CustomJsxInputsContext.Provider value={{ scopeId, inputs, inputTypes, registerInput, setInputValue }}>
{children}
</CustomJsxInputsContext.Provider>
);
}
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<string, unknown>): Record<string, unknown> {
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 isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
useEffect(() => {
if (isBound && binding && context && inputType) context.registerInput(binding, inputType, initialValue);
}, [binding, context, initialValue, inputType, isBound]);
if (!isBound || !binding || !context || !inputType) return sanitized;
delete sanitized.defaultValue;
delete sanitized.defaultChecked;
const currentValue = context.inputs[binding] ?? initialValue;
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<string, unknown>): 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") 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"))
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<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
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<string, unknown>)
: {};
return { ...candidate, withinPortal: false };
}
function SafeLink({ component, props }: { component: ComponentType<never>; props: Record<string, unknown> }) {
const sanitized = sanitizeCustomJsxProps(props);
return createElement(component, {
...sanitized,
href: safeUrl(props.href) ? props.href : undefined,
rel: sanitized.target === "_blank" ? "noopener noreferrer" : sanitized.rel,
} as never);
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 }) {
@@ -90,7 +230,17 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
for (const descriptor of enabledCustomJsxComponents) {
const namespace = namespaces[descriptor.package];
const component = namespace ? resolveExport(namespace, descriptor.name) : undefined;
if (component) components[descriptor.name] = component;
if (component) {
const additions: Record<string, unknown> = {};
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 = Core as unknown as Namespace;
for (const name of [
@@ -107,29 +257,12 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
"Chip",
] as const) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component);
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) => <SafeLink component={anchor} props={props} />;
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
for (const [name, additions] of Object.entries({
Rating: { readOnly: true },
Slider: { readOnly: true },
Switch: { readOnly: true },
SegmentedControl: { readOnly: true },
Pagination: { disabled: true },
})) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component, additions);
}
const dates = Dates as unknown as Namespace;
for (const descriptor of enabledCustomJsxComponents.filter(
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
)) {
const component = resolveExport(dates, descriptor.name);
if (component) components[descriptor.name] = wrap(component, { static: true });
}
Object.assign(components, {
CopyButton: createCopyButton(adapters.copyLabels),
PaginatedList,