InspectorPanel
inspector-panelSchema-driven property inspector that derives its controls from a field description.
Usage
Basic usage
Five built-in schema presets plus theme token binding; every change is emitted live to the preview box.
const [style, setStyle] = useState(initialStyle);
<InspectorPanel
selectedElement="Card / Title"
props={style}
tokenSource={tokens}
onChange={(path, value) => setStyle((prev) => ({ ...prev, [path]: value }))}
/>Custom schema (beyond CSS)
Swap sections for business properties: the panel itself knows no concrete property, it only derives controls from kind.
<InspectorPanel
title="Card settings"
sections={[
{
id: "meta",
label: "Content",
fields: [
{ key: "headline", label: "Headline", kind: "text" },
{ key: "featured", label: "Pinned", kind: "toggle" },
],
},
]}
props={values}
onChange={(path, value) => setValues((prev) => ({ ...prev, [path]: value }))}
/>Mixed values across a multi-selection + emit on release
Pass MIXED for a property whose values disagree and it renders as "Multiple values"; commitMode="commit" suppresses emission while dragging or typing.
- Emission happens only after release or blur
<InspectorPanel
selectedElement="3 elements"
commitMode="commit"
categories={["typography", "effects"]}
props={{ fontSize: MIXED, fontWeight: MIXED, opacity: MIXED }}
onChange={(path, value) => apply(path, value)}
/>Only some categories
categories picks which built-in presets to use and in what order; an unknown id is ignored.
<InspectorPanel categories={["layout", "border"]} props={style} onChange={onChange} />When to use
Use InspectorPanel for the property panel that stays bound to the current selection in a design tool or low-code builder. Describe the properties as a schema; the panel derives a control per kind, reads values by key, and emits changes by key.
It is not a form: for submission semantics, validation, and field dependencies use Form or ProForm. To edit a single property, reach for Slider, ColorPicker, or Segmented directly. The five built-in presets are only a default; domain properties such as weight, link target, or pinned work just as well, as shown in the custom schema example.
Import
import { InspectorPanel, MIXED, inspectorSections, layoutFields, spacingSides } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| onChange* | (path: string, value: InspectorValue) => void | — | Emits a single path change; path is the key declared on the field. |
| props | Record<string, unknown> | — | Property value table. Flat keys win first, then a.b.c dotted lookup. |
| selectedElement | string | null | — | Selection identifier. Passing null shows the empty state; omitting it skips the empty check. |
| sections | InspectorSection[] | — | Full category schema. When present, categories is ignored. |
| categories | readonly string[] | — | Picks built-in presets and orders them as given (layout, color, typography, border, effects). |
| tokenSource | readonly InspectorToken[] | — | Theme tokens offered by color controls; shaped like the docs site SEMANTIC_GROUPS swatches. |
| commitMode | "change" | "commit" | "change" | change emits on every drag frame and keystroke; commit emits on release, blur, or Enter. |
| onBatchChange | (changes: InspectorChange[]) => void | — | Batch emit when one interaction changes several paths; see Events. |
| title | ReactNode | From locale | Panel heading. Pass null to drop the header. |
| emptyText | ReactNode | From locale | Empty state copy. |
| labels | Partial<InspectorPanelLabels> | — | Overrides the copy taken from the locale, such as mixed, linkSides, and the four side names. |
| className | string | — | Class name on the panel shell. |
Field types form the InspectorField discriminated union, narrowed by kind:
| kind | Derived control | Fields specific to this kind |
|---|---|---|
| spacing | Four numeric inputs plus a link toggle | sides? to override derived paths, min / max / step / unit |
| color | Swatch that opens ColorPicker, a text input, and a token palette | tokenGroup? |
| length | Slider plus numeric input | min / max / step / unit |
| number | Numeric input | min / max / step / unit |
| enum | Segmented up to four options, Select beyond that | options*, display?: "segmented" | "select" |
| toggle | Switch | — |
| text | Text input | placeholder? |
Shared fields: key is the property path and the emitted path, `label` is the visible label and the control aria-label, plus optional hint and disabled.
Events
| Event | Type | Description |
|---|---|---|
| onChange | (path: string, value: InspectorValue) => void | Fires once per changed path. A linked spacing edit fires four times in the same tick. |
| onBatchChange | (changes: InspectorChange[]) => void | When provided, multi-path changes go through it only and no longer emit per path; single-path changes always use onChange. |
The emitted shape is decided by the field, not by what was typed. With unit the panel emits the string "12px"; without unit it emits the number 12. Clearing a numeric input emits null, meaning delete the property rather than set it to 0. A token swatch emits that token's value, or var(--token) when no literal value was given.
Accessibility
- Every control carries an
aria-labeltaken from the fieldlabel. The four spacing inputs are named per side, so they never share one name. - In a
lengthrow the slider and the numeric input are two ways into the same property and share one accessible name; the slider points at the visible label througharia-labelledby, and the differing roles keep them distinguishable. - The link toggle exposes its state through
aria-pressedrather than color alone. - Token swatches take their accessible name from
tokenSource[].label, falling back totoken, rather than thevar(--color-x)string, so a screen reader announces the human name instead of a variable. The token name printed under the palette is a current-value readout for sighted users, not an accessibility fallback. - Category folding uses Collapsible, which owns
aria-expandedand keyboard access. - Mixed values are never conveyed by gray text alone: text and numeric kinds use a
placeholder, while toggle and enum kinds put readable text beside the control.
Pitfalls
- `onChange` can fire several times in one tick (four times for a linked spacing edit). Consumers must use a functional update,
setState((prev) => …). WritingsetState({ ...style, [path]: value })lets the last three calls overwrite the first three, which looks like "only one side applied". PassonBatchChangeto receive them as one call instead. commitModecovers sliders, inputs, and the ColorPicker inside the swatch popover alike. Undercommitthe picker emits on pointer release, blur, or Enter, and every mid-drag frame stays inside the panel; the picker runs uncontrolled during that drag and resyncs only when the externalpropsvalue changes.tokenSource[].tokenmust carry the `color-` prefix (color-primary, notprimary). Tailwind v4@themenames include the prefix, and a bare name paints nothing while raising no error.- The mixed sentinel is
Symbol.for("hulian.inspector.mixed")and cannot survive JSON serialization. If the property table crosses the network or storage, translate it to your own marker at that boundary and back. - Clearing a numeric input emits
null, not0. Treatnullas "delete this property" so that "unset" stays distinct from "set to zero". - The panel holds no domain value. If
propsis not written back, the control will not move, apart from the draft incommitmode and the slider mid-drag. It is a controlled component, not a self-contained editor. - The panel chrome (
title, empty state,mixed,linkSides, the four side names, the two color control names) follows the ConfigProvider locale, so wrapping the tree in<ConfigProvider locale={enUS}>turns it English. Priority islabels/title/emptyTextprop first, then the locale, then the built-in Chinese fallback. - Built-in preset field labels are still Chinese and are not covered by the locale — they live in the
sectionsdata, not in the panel. For a fully English surface, pass your ownsections.
Related
Form · ProForm · ColorPicker · ColorSwatchPicker · Slider · Segmented · Collapsible · Flow