chartPieInteractive
A Charts block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call chartPieInteractive() with no arguments for a working demo, or edit the code below live.
Implementation notes
Selection is a single State<string> source of truth driving three things together: the active wedge's radius (reactive style.d using CSS path() interpolation for a ~260ms ease-out grow/shrink transition), the donut's center total text, and the native <select>'s own value — matching the spec's architecture note. Two genuine browser-API gaps versus the visual spec: (1) native HTML <option> elements cannot host a child swatch element, so 'each option carries its own color swatch' is approximated by tinting the option's own text color instead of a true swatch icon; (2) a native <select>'s dropdown popup is rendered by the OS/browser chrome and cannot be given a custom fade+scale open/close transition in cross-browser CSS/JS without replacing it with a fully custom (non-native) listbox widget, which would go against reusing the existing select() primitive. Everything else (enlarge/shrink transition, center value swap, swatch next to the trigger, onSelectionChange callback) is fully implemented.
Status: partial · Reference: shadcn/ui original
// shadcn/ui "charts/pie-interactive" — clean-room reimplementation.
//
// A donut chart paired with a select control that lets the user pick one
// category; the chosen category's wedge is drawn enlarged and its value
// becomes the donut's center total. Selection is a single source-of-truth
// `State<string>` that both the active-wedge radius and the center text
// read from. Implemented purely from the block's public functional/visual
// spec — no upstream source was viewed.
import type { DomphyElement, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
import { motion, select } from "@domphy/ui";
import {
type PieDatum,
DEFAULT_DONUT_INNER_RADIUS,
DEFAULT_PIE_DATA,
PIE_OUTER_RADIUS,
arcSlicePath,
colorSwatch,
createPieTooltipState,
defaultValueFormatter,
layoutPieSlices,
pieCard,
pieCardDescription,
pieCardTitle,
pieCenterText,
pieChartContainer,
resolveSliceColor,
wedgeTooltipHandlers,
} from "./pie-chart-shared.js";
export interface ChartPieInteractiveProps {
data?: PieDatum[];
title?: string;
description?: string;
valueFormatter?: (value: number) => string;
innerRadius?: number;
/** Controlled selected category key. Defaults to the first record. */
activeKey?: string;
onSelectionChange?: (key: string) => void;
centerCaption?: string;
/** Extra outer-radius (viewBox units) the selected wedge grows to. */
activeRadiusDelta?: number;
}
/**
* A select-driven donut chart: the chosen category's wedge grows and its
* value fills the donut's hollow center. Call with no arguments for a fully
* working demo.
*/
function chartPieInteractive(props: ChartPieInteractiveProps = {}): DomphyElement<"div"> {
const {
data = DEFAULT_PIE_DATA,
title = "Pie Chart - Interactive",
description = "January - June 2024",
valueFormatter = defaultValueFormatter,
innerRadius = DEFAULT_DONUT_INNER_RADIUS,
activeKey,
onSelectionChange,
centerCaption = "Visitors",
activeRadiusDelta = 12,
} = props;
const selectedKey = toState(activeKey ?? data[0]?.key ?? "");
const slices = layoutPieSlices(data);
const tooltipState = createPieTooltipState();
const containerRef = { current: null as HTMLElement | null };
const setSelection = (key: string) => {
selectedKey.set(key);
onSelectionChange?.(key);
};
// Reactive per-wedge `style.d` (not the plain `d` attribute): CSS lets
// browsers interpolate between two `path()` values sharing the same
// command structure — which arcSlicePath's output always does — so the
// active wedge's enlarge/shrink reads as a quick ease-out transition
// instead of a hard snap.
const wedges: DomphyElement<"path">[] = slices.map((slice) => {
const isSelected = (l: Listener) => selectedKey.get(l) === slice.datum.key;
return {
path: null,
style: {
d: (l: Listener) => {
const outerRadius = isSelected(l) ? PIE_OUTER_RADIUS + activeRadiusDelta : PIE_OUTER_RADIUS;
return `path("${arcSlicePath(slice, innerRadius, outerRadius, 0.018)}")`;
},
transition: "d 260ms ease-out",
},
fill: (l: Listener) => themeColor(l, "shift-9", slice.color),
strokeWidth: (l: Listener) => (isSelected(l) ? "2.5" : "1.5"),
stroke: (l: Listener) => themeColor(l, "inherit"),
strokeLinejoin: "round",
cursor: "pointer",
_key: slice.datum.key,
...wedgeTooltipHandlers(slice, { containerRef, tooltipState, valueFormatter }),
} as DomphyElement<"path">;
});
const options: DomphyElement<"option">[] = data.map((datum, index) => ({
option: datum.name,
value: datum.key,
// Native `<option>` elements cannot host a child swatch element — tinting
// the option's own text color is the closest in-grammar approximation of
// "each option carries its own color swatch" (see this block's fidelity
// note in the port report).
style: { color: (l: Listener) => themeColor(l, "shift-9", resolveSliceColor(datum, index)) },
_key: datum.key,
}));
const centerValueText = (l: Listener) => {
const key = selectedKey.get(l);
const datum = data.find((record) => record.key === key) ?? data[0];
return datum ? valueFormatter(datum.value) : "";
};
const selectedSwatchColor = (l: Listener) => {
const key = selectedKey.get(l);
const index = Math.max(
data.findIndex((record) => record.key === key),
0,
);
return resolveSliceColor(data[index] ?? { key: "", name: "", value: 0 }, index);
};
return pieCard([
pieCardTitle(title, false),
pieCardDescription(description, false),
{
aside: [
colorSwatch(selectedSwatchColor),
{
select: options,
value: (l: Listener) => selectedKey.get(l),
onChange: (e: Event) => setSelection((e.target as HTMLSelectElement).value),
ariaLabel: "Select a category",
$: [select()],
},
],
style: { display: "flex", alignItems: "center", gap: themeSpacing(2) },
},
pieChartContainer(
containerRef,
[
{
g: wedges,
ariaHidden: "true",
style: { transformOrigin: "100px 100px" },
$: [
motion({
initial: { opacity: 0, scale: 0.7 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 700, easing: "ease-out" },
}),
],
} as DomphyElement<"g">,
pieCenterText(centerValueText, centerCaption),
],
tooltipState,
),
]);
}
export { chartPieInteractive };