refactor(custom-widgets): extract domain package

This commit is contained in:
Thomas Camlong committed 2026-07-14 16:59:06 +02:00
1 parent c5703e7e53
commit 4c6ffcd0fa
167 files changed
+12282 -10033

No files matched your search

@@ -1,300 +0,0 @@
export interface CustomJsxExample {
id: string;
title: string;
description: string;
template: string;
requests: readonly Record<string, unknown>[];
}
export const customJsxExamples: readonly CustomJsxExample[] = [
{
id: "simple-metric",
title: "Simple metric",
description: "A compact status and metric display with no nested requests.",
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.name}</Text>
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
</Group>
<Title order={2}>{data.value}</Title>
</Stack>`,
requests: [],
},
{
id: "pokedex-detail",
title: "Pokédex detail list",
description: "Paginates the list and fetches details only for visible Pokémon.",
template: `<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon) =>
<Card key={pokemon.name} withBorder p="xs" mb="xs">
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap={4}>
<Group gap="xs">
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
</Group>
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
</Stack>
}
</SubFetch>
</Card>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 300,
},
],
},
{
id: "stocks-dashboard",
title: "Stocks dashboard",
description: "Responsive portfolio values and chart with parent refresh.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map((stock) =>
<Card key={stock.symbol} withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
</Stack>`,
requests: [],
},
{
id: "navidrome-player",
title: "Navidrome mini-player",
description: "Media controls backed by named actions with explicit permissions.",
template: `<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
</Group>
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
</Stack>`,
requests: [
{
id: "previous",
kind: "action",
method: "POST",
pathTemplate: "/api/player/previous",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "toggle-playback",
kind: "action",
method: "POST",
pathTemplate: "/api/player/toggle",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "next",
kind: "action",
method: "POST",
pathTemplate: "/api/player/next",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "shuffle",
kind: "action",
method: "PUT",
pathTemplate: "/api/player/shuffle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
],
},
{
id: "pokedex-interactive",
title: "Interactive Pokédex",
description:
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
<Box>
<Title order={3}>Pokédex</Title>
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
</Box>
</Group>
<RefreshButton label="Refresh" size="xs" />
</Group>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, index) =>
<Accordion key={pokemon.name} variant="separated" radius="md">
<Accordion.Item value={pokemon.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
</Box>
</Group>
<Badge size="xs" variant="outline">Details</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap="sm">
<Grid gutter="sm">
<Grid.Col span={{ base: 12, sm: 5 }}>
<Center>
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
</Center>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 7 }}>
<Stack gap="xs">
<Group gap="xs">
{detail.types.map((entry) =>
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
)}
</Group>
<SimpleGrid cols={3} spacing="xs">
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
</SimpleGrid>
<Group gap="xs">
{detail.abilities.map((entry) =>
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
</Badge>
)}
</Group>
</Stack>
</Grid.Col>
</Grid>
<Tabs defaultValue="stats" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="stats">Stats</Tabs.Tab>
<Tabs.Tab value="moves">Moves</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="sm">
<Stack gap="xs">
{detail.stats.map((entry) =>
<Box key={entry.stat.name}>
<Group justify="space-between" mb={2}>
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
<Text size="xs" fw={700}>{entry.base_stat}</Text>
</Group>
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
</Box>
)}
<Group justify="space-between">
<Text size="sm" fw={700}>Total</Text>
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="moves" pt="sm">
<ScrollArea h={120} type="auto">
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
{detail.moves.slice(0, 30).map((entry) =>
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
{entry.move.name.replaceAll("-", " ")}
</Badge>
)}
</SimpleGrid>
</ScrollArea>
</Tabs.Panel>
</Tabs>
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
</Stack>
}
</SubFetch>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 3600,
},
],
},
{
id: "smart-home-control",
title: "Smart-home control",
description: "A stateful light control with an explicit destructive confirmation.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
</Stack>`,
requests: [
{
id: "set-light",
kind: "action",
method: "PUT",
pathTemplate: "/api/lights/living-room",
parameters: { state: "string" },
bodyTemplate: { state: { $param: "state" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "delete-scene",
kind: "action",
method: "DELETE",
pathTemplate: "/api/scenes/living-room",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
},
],
},
];
-739
View File
@@ -1,739 +0,0 @@
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
export type CustomJsxComponentCategory =
| "layout"
| "content"
| "feedback"
| "navigation"
| "charts"
| "dates"
| "interaction"
| "network"
| "blocked";
export interface CustomJsxComponentDescriptor {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
supportedProps: readonly string[];
subcomponents: readonly string[];
accessibilityRequirements: readonly string[];
documentationUrl: string;
reason?: string;
}
const commonSafeProps = [
"key",
"id",
"style",
"title",
"role",
"aria-label",
"aria-describedby",
"aria-hidden",
"tabIndex",
"w",
"h",
"miw",
"maw",
"mih",
"mah",
"m",
"mx",
"my",
"mt",
"mr",
"mb",
"ml",
"p",
"px",
"py",
"pt",
"pr",
"pb",
"pl",
"bg",
"c",
"opacity",
"display",
"hiddenFrom",
"visibleFrom",
"bd",
"bdrs",
"flex",
"fz",
"fw",
"lh",
"lts",
"ta",
"td",
"tt",
"radius",
"shadow",
"variant",
"color",
"size",
"withBorder",
] as const;
const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: [
"gap",
"align",
"justify",
"wrap",
"direction",
"grow",
"cols",
"spacing",
"verticalSpacing",
"gutter",
"span",
"offset",
"order",
"type",
"orientation",
"withBorder",
"shadow",
"radius",
],
content: [
"alt",
"src",
"href",
"target",
"rel",
"size",
"fw",
"fs",
"td",
"tt",
"ta",
"lts",
"lh",
"lineClamp",
"truncate",
"inherit",
"gradient",
"variant",
"color",
"value",
"prefix",
"suffix",
"decimalScale",
"thousandSeparator",
"striped",
"highlightOnHover",
"withTableBorder",
"withColumnBorders",
"type",
"icon",
"image",
"order",
"fit",
"fallbackSrc",
"loading",
"referrerPolicy",
"crossOrigin",
"decoding",
"draggable",
"center",
"spacing",
"listStyleType",
],
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
navigation: [
"value",
"defaultValue",
"active",
"label",
"description",
"leftSection",
"rightSection",
"opened",
"disabled",
"multiple",
"chevronPosition",
"orientation",
"keepMounted",
"transitionDuration",
],
charts: [
"data",
"dataKey",
"series",
"type",
"curveType",
"color",
"colors",
"withLegend",
"withTooltip",
"withXAxis",
"withYAxis",
"withDots",
"gridAxis",
"unit",
"valueFormatter",
"strokeWidth",
"fillOpacity",
"connectNulls",
"tooltipAnimationDuration",
],
dates: [
"date",
"defaultDate",
"value",
"minDate",
"maxDate",
"locale",
"firstDayOfWeek",
"weekendDays",
"hideOutsideDates",
"hideWeekdays",
"showWeekNumbers",
"numberOfColumns",
"level",
"size",
],
interaction: [
"label",
"value",
"values",
"data",
"checked",
"defaultChecked",
"disabled",
"readOnly",
"color",
"variant",
"size",
"radius",
"leftSection",
"rightSection",
"loading",
"min",
"max",
"step",
"total",
"page",
"pageSize",
"defaultTab",
"title",
"defaultOpen",
"type",
"fullWidth",
],
network: [
"requestId",
"params",
"onParams",
"offParams",
"initialValue",
"label",
"loadingLabel",
"successMessage",
"errorMessage",
"confirmMessage",
"invalidate",
"refreshInterval",
"trigger",
"render",
"path",
"as",
"fallback",
"icon",
"color",
"variant",
"size",
"radius",
"disabled",
],
blocked: [],
};
const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
Avatar: ["Avatar.Group"],
Card: ["Card.Section"],
Chip: ["Chip.Group"],
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
Grid: ["Grid.Col"],
List: ["List.Item"],
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
Progress: ["Progress.Section"],
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
Timeline: ["Timeline.Item"],
};
const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: ["Keep visual order consistent with reading order."],
content: ["Provide meaningful text alternatives for non-text content."],
feedback: ["Do not rely on color alone to communicate status."],
navigation: ["Provide an accessible label for controls without visible text."],
charts: ["Include a nearby text summary of the chart data."],
dates: ["Include a textual date when the visual calendar carries meaning."],
interaction: ["Provide a visible label or aria-label."],
network: ["Action labels must describe their effect; destructive actions require confirmation."],
blocked: [],
};
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
"Tabs.List": ["grow"],
ScrollArea: ["offsetScrollbars"],
SubData: ["alt", "fit"],
};
function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
const slug = (name.split(".")[0] ?? "")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
return `https://mantine.dev/${section}/${slug}/`;
}
const describe = (
packageName: CustomJsxComponentPackage,
category: CustomJsxComponentCategory,
safety: CustomJsxComponentSafety,
names: readonly string[],
reason?: string,
): CustomJsxComponentDescriptor[] =>
names.map((name) => ({
name,
package: packageName,
category,
safety,
supportedProps:
safety === "denied"
? []
: [
...new Set<string>([
...commonSafeProps,
...categorySafeProps[category],
...(componentSafeProps[name] ?? []),
]),
],
subcomponents: subcomponentsByName[name] ?? [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl(packageName, name),
reason,
}));
/**
* The source of truth for the Custom JSX public component surface.
*
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
* the registry so Mantine upgrades cannot silently expand the template surface.
*/
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
...describe("@mantine/core", "layout", "allowed", [
"Box",
"Stack",
"Group",
"Flex",
"Grid",
"Grid.Col",
"SimpleGrid",
"Center",
"Space",
"Container",
"AspectRatio",
"ScrollArea",
"Paper",
"Card",
"Card.Section",
"Divider",
"Fieldset",
"VisuallyHidden",
"Typography",
"FloatingIndicator",
]),
...describe("@mantine/core", "content", "allowed", [
"Text",
"Title",
"Code",
"Highlight",
"Mark",
"Kbd",
"Blockquote",
"NumberFormatter",
"Badge",
"ThemeIcon",
"ColorSwatch",
"Table",
"Table.Thead",
"Table.Tbody",
"Table.Tfoot",
"Table.Caption",
"Table.Tr",
"Table.Th",
"Table.Td",
"List",
"List.Item",
"Timeline",
"Timeline.Item",
"Indicator",
"Pill",
"Image",
"Avatar",
"Avatar.Group",
"BackgroundImage",
"DataList",
"DataList.Item",
"DataList.ItemLabel",
"DataList.ItemValue",
"EmptyState",
"EmptyState.Indicator",
"EmptyState.Title",
"EmptyState.Description",
"EmptyState.Actions",
"Marquee",
"RollingNumber",
]),
...describe("@mantine/core", "feedback", "allowed", [
"Alert",
"Progress",
"Progress.Section",
"RingProgress",
"SemiCircleProgress",
"Skeleton",
"Loader",
]),
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
...describe("@mantine/core", "navigation", "allowed", [
"Accordion",
"Accordion.Item",
"Accordion.Control",
"Accordion.Panel",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Stepper.Step",
"HoverCard",
"HoverCard.Target",
"HoverCard.Dropdown",
"Menu",
"Menu.Target",
"Menu.Dropdown",
"Menu.Item",
"Menu.Label",
"Menu.Divider",
"Tooltip",
"Spoiler",
"Collapse",
"Transition",
]),
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
...describe("@mantine/core", "interaction", "wrapped", [
"Button",
"ActionIcon",
"Burger",
"CloseButton",
"Chip",
"Chip.Group",
"CopyButton",
"Pagination",
"Rating",
"SegmentedControl",
"Slider",
"Switch",
]),
...describe("@mantine/charts", "charts", "allowed", [
"AreaChart",
"BarChart",
"BarsList",
"BubbleChart",
"ChartLegend",
"ChartTooltip",
"CompositeChart",
"DonutChart",
"FunnelChart",
"Heatmap",
"LineChart",
"PieChart",
"RadarChart",
"RadialBarChart",
"SankeyChart",
"ScatterChart",
"Sparkline",
"Treemap",
]),
...describe("@mantine/dates", "dates", "wrapped", [
"Calendar",
"MiniCalendar",
"DatePicker",
"Day",
"WeekdaysRow",
"Month",
"CalendarHeader",
"YearPicker",
"MonthPicker",
"TimeGrid",
"PickerControl",
"YearsList",
"MonthsList",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
]),
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
...describe("@homarr/widgets", "interaction", "allowed", [
"PaginatedList",
"TabsContainer",
"TabPanel",
"Collapsible",
"StatBar",
"TypeBadge",
]),
...describe("@homarr/widgets", "network", "wrapped", [
"SubFetch",
"SubData",
"ActionButton",
"ToggleSwitch",
"RefreshButton",
]),
{
name: "TablerIcon",
package: "@homarr/widgets",
category: "content",
safety: "wrapped",
supportedProps: ["name", "size", "color", "stroke"],
subcomponents: [],
accessibilityRequirements: accessibilityByCategory.content,
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
},
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AppShell",
"Affix",
"ComboboxPopover",
"Dialog",
"Drawer",
"FloatingWindow",
"FocusTrap",
"Menubar",
"Modal",
"ModalBase",
"Portal",
"Popover",
"RemoveScroll",
],
"Escapes the widget layout or focus boundary",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AngleSlider",
"Autocomplete",
"Checkbox",
"ColorInput",
"ColorPicker",
"Combobox",
"FileButton",
"FileInput",
"Input",
"InputBase",
"JsonInput",
"MaskInput",
"MultiSelect",
"NativeSelect",
"NumberInput",
"PasswordInput",
"PillsInput",
"PinInput",
"Radio",
"Select",
"TagsInput",
"Textarea",
"TextInput",
"TreeSelect",
"UnstyledButton",
],
"Arbitrary form controls are not connected to the declarative action model",
),
...describe(
"@mantine/core",
"blocked",
"denied",
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
"Requires callback, document, or layout capabilities that are not exposed to templates",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AccordionChevron",
"ActionIconGroup",
"ActionIconGroupSection",
"AlphaSlider",
"AppShellAside",
"AppShellFooter",
"AppShellHeader",
"AppShellMain",
"AppShellNavbar",
"AppShellSection",
"ButtonGroup",
"ButtonGroupSection",
"CheckIcon",
"CheckboxCard",
"CheckboxGroup",
"CheckboxIndicator",
"CloseIcon",
"ColorSchemeScript",
"ComboboxChevron",
"ComboboxClearButton",
"ComboboxDropdown",
"ComboboxDropdownTarget",
"ComboboxEmpty",
"ComboboxEventsTarget",
"ComboboxFooter",
"ComboboxGroup",
"ComboboxHeader",
"ComboboxHiddenInput",
"ComboboxOption",
"ComboboxOptions",
"ComboboxPopoverTarget",
"ComboboxSearch",
"ComboboxTarget",
"DirectionProvider",
"DrawerBody",
"DrawerCloseButton",
"DrawerContent",
"DrawerHeader",
"DrawerOverlay",
"DrawerRoot",
"DrawerStack",
"DrawerTitle",
"FlatTreeNode",
"FloatingArrow",
"FocusTrapInitialFocus",
"HeadlessMantineProvider",
"HoverCardGroup",
"HueSlider",
"InlineStyles",
"InputClearButton",
"InputClearSection",
"InputDescription",
"InputError",
"InputLabel",
"InputPlaceholder",
"InputSuccess",
"InputWrapper",
"MantineProvider",
"MantineThemeProvider",
"MenuCheckboxGroup",
"MenuCheckboxItem",
"MenuContextMenu",
"MenuRadioGroup",
"MenuRadioItem",
"MenuSearch",
"MenuSub",
"MenuSubDropdown",
"MenuSubItem",
"MenuSubTarget",
"MenubarDropdown",
"MenubarMenu",
"MenubarTarget",
"ModalBaseBody",
"ModalBaseCloseButton",
"ModalBaseContent",
"ModalBaseHeader",
"ModalBaseOverlay",
"ModalBaseTitle",
"ModalBody",
"ModalCloseButton",
"ModalContent",
"ModalHeader",
"ModalOverlay",
"ModalRoot",
"ModalStack",
"ModalTitle",
"NativeScrollArea",
"OptionalPortal",
"OptionsDropdown",
"PaginationControl",
"PaginationDots",
"PaginationFirst",
"PaginationItems",
"PaginationLabel",
"PaginationLast",
"PaginationNext",
"PaginationPrevious",
"PaginationRoot",
"PillGroup",
"PillsInputField",
"PopoverContextMenu",
"PopoverDropdown",
"PopoverTarget",
"ProgressLabel",
"ProgressRoot",
"RadioCard",
"RadioGroup",
"RadioIcon",
"RadioIndicator",
"RangeSlider",
"ScrollAreaAutosize",
"SplitterPane",
"StepperCompleted",
"SwitchGroup",
"TableScrollContainer",
"TooltipFloating",
"TooltipGroup",
"TreeSelectOption",
],
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
),
...describe(
"@mantine/charts",
"blocked",
"denied",
["AreaGradient"],
"Low-level chart implementation helper that is not a standalone declarative display component",
),
...describe(
"@mantine/dates",
"blocked",
"denied",
[
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"PickerInputBase",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
],
"Input and provider components are outside the read-only template surface",
),
];
export const enabledCustomJsxComponents = customJsxComponentRegistry.filter(({ safety }) => safety !== "denied");
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
export const customJsxSupportedPropsByName = new Map(
enabledCustomJsxComponents.map((component) => [component.name, new Set(component.supportedProps)]),
);
+1 -1
View File
@@ -7,7 +7,7 @@ import { z } from "zod/v4";
import { createDocumentationLink } from "./index";
import { integrationDocSlugs } from "./integration-doc-slugs";
import { widgetDocSlugs } from "./widget-doc-slugs";
import { customJsxComponentRegistry } from "../custom-jsx";
import { customJsxComponentRegistry } from "@homarr/custom-widgets/core";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
-2
View File
@@ -18,5 +18,3 @@ export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
export * from "./release-provider";
export * from "./custom-jsx";
export * from "./custom-jsx-examples";
@@ -1,229 +0,0 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../custom-jsx";
const mantineCore941ComponentExports = [
"RemoveScroll",
"Collapse",
"ScrollArea",
"UnstyledButton",
"VisuallyHidden",
"Paper",
"Popover",
"ActionIcon",
"CloseButton",
"Group",
"Loader",
"Overlay",
"ModalBase",
"Input",
"InputBase",
"Flex",
"FloatingIndicator",
"Accordion",
"Affix",
"Alert",
"Anchor",
"AngleSlider",
"AppShell",
"AspectRatio",
"Autocomplete",
"Avatar",
"BackgroundImage",
"Badge",
"Blockquote",
"Breadcrumbs",
"Burger",
"Button",
"Card",
"Center",
"Checkbox",
"Chip",
"Code",
"ColorPicker",
"ColorInput",
"ColorSwatch",
"Combobox",
"ComboboxPopover",
"Container",
"CopyButton",
"DataList",
"Dialog",
"Divider",
"Drawer",
"EmptyState",
"Fieldset",
"FileButton",
"FileInput",
"FloatingWindow",
"FocusTrap",
"Grid",
"Highlight",
"HoverCard",
"Image",
"Indicator",
"JsonInput",
"Kbd",
"List",
"LoadingOverlay",
"Mark",
"Marquee",
"MaskInput",
"Menu",
"Menubar",
"Modal",
"MultiSelect",
"NativeSelect",
"NavLink",
"Notification",
"NumberFormatter",
"NumberInput",
"OverflowList",
"Pagination",
"PasswordInput",
"Pill",
"PillsInput",
"PinInput",
"Portal",
"Progress",
"Radio",
"Rating",
"RollingNumber",
"RingProgress",
"Scroller",
"SegmentedControl",
"Select",
"SemiCircleProgress",
"SimpleGrid",
"Skeleton",
"Slider",
"Space",
"Splitter",
"Spoiler",
"Stack",
"Stepper",
"Switch",
"Table",
"TableOfContents",
"Tabs",
"TagsInput",
"Text",
"Textarea",
"TextInput",
"ThemeIcon",
"Timeline",
"Title",
"Tooltip",
"Transition",
"Tree",
"TreeSelect",
"Typography",
] as const;
const mantineCharts941ComponentExports = [
"ChartTooltip",
"ChartLegend",
"AreaChart",
"BarChart",
"LineChart",
"Sparkline",
"DonutChart",
"PieChart",
"RadarChart",
"ScatterChart",
"BubbleChart",
"CompositeChart",
"RadialBarChart",
"FunnelChart",
"Heatmap",
"BarsList",
"Treemap",
"SankeyChart",
] as const;
const mantineDates941ComponentExports = [
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"TimeValue",
"Day",
"WeekdaysRow",
"Month",
"PickerControl",
"YearsList",
"MonthsList",
"CalendarHeader",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
"PickerInputBase",
"Calendar",
"YearPicker",
"MonthPicker",
"DatePicker",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
"TimeGrid",
"MiniCalendar",
] as const;
describe("customJsxComponentRegistry", () => {
test("classifies every component name exactly once", () => {
const names = customJsxComponentRegistry.map(({ name }) => name);
expect(new Set(names).size).toBe(names.length);
});
test.each([
["ChartTooltip", "allowed"],
["ChartLegend", "allowed"],
["VisuallyHidden", "allowed"],
["SubFetch", "wrapped"],
["Portal", "denied"],
["TextInput", "denied"],
] as const)("classifies %s as %s", (name, safety) => {
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
});
test("documents why every denied component is unavailable", () => {
for (const component of customJsxComponentRegistry) {
if (component.safety === "denied") {
expect(component.reason, component.name).toBeTruthy();
}
}
});
test("publishes complete authoring metadata for every component", () => {
for (const component of customJsxComponentRegistry) {
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
}
});
test.each([
["Tabs.List", "grow"],
["ScrollArea", "offsetScrollbars"],
["SubData", "fit"],
["SubData", "alt"],
] as const)("supports the %s.%s authoring prop", (component, prop) => {
expect(customJsxComponentByName.get(component)?.supportedProps).toContain(prop);
});
test.each([
...mantineCore941ComponentExports,
...mantineCharts941ComponentExports,
...mantineDates941ComponentExports,
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
});