Building an inspector ​
Edit the properties of the selected object from a Lit element, with one FieldBinding per field and no mirrored @state.
1. Bind a field to the model ​
ts
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FieldBinding } from "@jolly-pixel/ui";
import "@jolly-pixel/ui/elements/slider";
@customElement("light-inspector")
export class LightInspector extends LitElement {
@property({ attribute: false })
declare light: Light | null;
#intensity = new FieldBinding<number>(this, {
read: () => this.light?.intensity ?? 0,
write: (value) => {
this.light?.setIntensity(value);
}
});
constructor() {
super();
this.light = null;
}
override render() {
if (this.light === null) {
return nothing;
}
return html`
<jolly-slider
label="Intensity"
min="0"
max="10"
step="0.1"
.value=${this.#intensity.value}
@jolly-input=${this.#intensity.input}
@jolly-change=${this.#intensity.commit}
></jolly-slider>
`;
}
}read()runs on every render, so the field shows what the model holds. A model that clamps or refuses a write needs no rollback.write()is followed by an update of the host.- Bind
commitalone for a checkbox or a select. Addinputfor a slider or a scrubbed number. Preview and commit covers whatwrite()should do with each. - An edit made elsewhere, by undo or by a peer, shows only once the host renders again. Keeping an element in sync subscribes the host to the model.
2. Write one source for many keys ​
A settings object with several keys needs the same source for each. Write a factory once:
ts
import { FieldBinding, type FieldSource } from "@jolly-pixel/ui";
#intensity = new FieldBinding(this, this.#source("intensity"));
#range = new FieldBinding(this, this.#source("range"));
#castShadow = new FieldBinding(this, this.#source("castShadow"));
#source<TKey extends keyof LightSettings>(
key: TKey
): FieldSource<LightSettings[TKey]> {
return {
read: () => (this.light?.settings ?? Light.defaults)[key],
write: (value) => {
const patch: Partial<LightSettings> = {};
patch[key] = value;
this.light?.update(patch);
}
};
}A source that adjusts values, such as one that rounds a position to the grid, can be a class implementing FieldSource<T>, shared between panels.
3. Pick a control per value ​
string:jolly-text.- Unbounded
number:jolly-number. Bounded:jolly-slider, orjolly-spin-sliderin a narrow column. { from, to }interval:jolly-range.boolean:jolly-checkbox, orjolly-togglewith the label inside the box.- One value out of a list:
jolly-select, orjolly-button-groupto keep every option visible. - Bitmask:
jolly-flagsfor a few named bits,jolly-layer-gridfor up to 32 layers. - Hex colour:
jolly-color. - Vectors, rotations and transforms: the axis fields.
4. Bind a vector ​
ts
#position = new FieldBinding<Vec3Like>(this, {
read: () => {
const { x, y, z } = this.light?.position ?? kOrigin;
return { x, y, z };
},
write: (value) => {
this.light?.moveTo(value);
}
});ts
html`
<jolly-vector3
label="Position"
.value=${this.#position.value}
@jolly-input=${this.#position.input}
@jolly-change=${this.#position.commit}
></jolly-vector3>
`;- Return a copy from
read(). The field compares the new value with the previous one axis by axis, and a vector mutated in place equals itself. Vec3Likeis structural: aTHREE.Vector3fits, and the package does not depend on Three.js.
5. Show a multi-selection ​
When the selected objects disagree, show Mixed:
ts
import { FieldBinding, Mixed, type FieldValue } from "@jolly-pixel/ui";
#intensity = new FieldBinding<FieldValue<number>>(this, {
read: () => shared(this.selection, (light) => light.intensity),
write: (value) => {
if (value === Mixed) {
return;
}
for (const light of this.selection) {
light.setIntensity(value);
}
}
});
function shared<T>(
lights: readonly Light[],
pick: (light: Light) => T
): FieldValue<T> {
const [first, ...rest] = lights.map(pick);
return rest.every((value) => value === first) ? first : Mixed;
}- The field shows a placeholder until an edit commits a concrete value, which then goes to every selected object.
- Render nothing for an empty selection:
sharedexpects at least one object. - Each axis of a vector field can be
Mixedon its own. An edit to one axis emits the whole vector with the other axes stillMixed, so write the concrete axes only:
ts
write: (value: VectorValue<"x" | "y" | "z">) => {
if (value === Mixed) {
return;
}
for (const light of this.selection) {
for (const axis of ["x", "y", "z"] as const) {
if (value[axis] !== Mixed) {
light.position[axis] = value[axis];
}
}
}
}6. Lay out the rows ​
html
<jolly-folder label="Shadows" storage-key="light-inspector:shadows">
<jolly-checkbox label="Cast"></jolly-checkbox>
<jolly-separator label="Bias"></jolly-separator>
<jolly-number label="Normal"></jolly-number>
</jolly-folder>
<jolly-property-row label="Preset">
<jolly-button>Save</jolly-button>
</jolly-property-row>css
light-inspector {
--jolly-label-width: 10ch;
}jolly-folderfolds a group. Withstorage-key, it keeps its open state across reloads.jolly-separatorcaptions a group that does not fold.jolly-property-rowlines up custom content with the field labels.--jolly-label-widthgives every row the same label column, so the values align..default=${Light.defaults.intensity}shows a revert button while the value differs. Clicking it emitsjolly-changewith the default.label-position="auto"stacks the label above the value in a narrow pane.jolly-tabssplits a long inspector into tabbed regions.