Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/widgets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,21 @@ ui.column({}, [

Falsy values (`false`, `null`, `undefined`) are filtered from children.

### VNode Factory Guarantees

`ui.*` factories are contract-tested for deterministic VNode creation.

- Factories that expose a `key` prop forward it to the resulting VNode for reconciliation.
- Container-style child arrays filter `null`, `false`, and `undefined` values.
- Nested child arrays are flattened before VNode children are stored.
- Interactive widgets validate required runtime props before layout:
- `button`: non-empty `id`, `label`
- `input`: non-empty `id`, `value`
- `select`: non-empty `id`, `value`, `options` array
- `slider`: non-empty `id`, finite numeric range with `min <= max`, `step > 0`
- `checkbox`: non-empty `id`, boolean `checked`
- `radioGroup`: non-empty `id`, `value`, non-empty `options`

### Dynamic Lists

When rendering lists, provide a `key` prop for efficient reconciliation:
Expand Down Expand Up @@ -244,4 +259,13 @@ All focusable widgets can be clicked with the mouse to receive focus. Scrollable

## API Reference

### Baseline Lock

- Timestamp: `2026-02-18T11:26:33Z`
- Base commit: `a441bba78ddc99ece4eb76965ce36c0aec9225fe`
- Branch: `vnode-factory-audit`
- Node: `v20.19.5`
- npm: `10.8.2`
- Baseline tests: `2488` passing

For complete type definitions, see the [API Reference](../api.md).
69 changes: 37 additions & 32 deletions packages/core/src/layout/kinds/leaf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import type { Axis, Size } from "../types.js";
import type { LayoutResult } from "../validateProps.js";
import {
validateButtonProps,
validateCheckboxProps,
validateInputProps,
validateRadioGroupProps,
validateSelectProps,
validateSliderProps,
validateSpacerProps,
validateTextProps,
} from "../validateProps.js";
Expand Down Expand Up @@ -56,29 +60,20 @@ export function measureLeaf(
return ok({ w, h });
}
case "slider": {
const props = vnode.props as {
value?: number;
min?: number;
max?: number;
step?: number;
width?: number;
label?: string;
showValue?: boolean;
};
const propsRes = validateSliderProps(vnode.props);
if (!propsRes.ok) return propsRes;
const props = propsRes.value;
const normalized = normalizeSliderState({
value: props.value ?? Number.NaN,
value: props.value,
min: props.min,
max: props.max,
step: props.step,
});
const labelText =
typeof props.label === "string" && props.label.length > 0 ? `${props.label} ` : "";
const showValue = props.showValue !== false;
props.label !== undefined && props.label.length > 0 ? `${props.label} ` : "";
const showValue = props.showValue;
const valueText = showValue ? ` ${formatSliderValue(normalized.value, normalized.step)}` : "";
const explicitTrack =
typeof props.width === "number" && Number.isFinite(props.width) && props.width > 0
? Math.trunc(props.width)
: undefined;
const explicitTrack = props.width !== undefined && props.width > 0 ? props.width : undefined;
const trackWidth = explicitTrack ?? DEFAULT_SLIDER_TRACK_WIDTH;
const intrinsicW = measureTextCells(labelText) + 2 + trackWidth + measureTextCells(valueText);
return ok({ w: Math.min(maxW, intrinsicW), h: Math.min(maxH, 1) });
Expand Down Expand Up @@ -290,32 +285,40 @@ export function measureLeaf(
return ok({ w: Math.min(maxW, totalW), h: Math.min(maxH, 1) });
}
case "select": {
// Select dropdowns show selected value in a fixed-width area.
const textW = measureTextCells(vnode.props.placeholder ?? "Select...");
const propsRes = validateSelectProps(vnode.props);
if (!propsRes.ok) return propsRes;
const selected = propsRes.value.options.find(
(option) => option.value === propsRes.value.value,
);
const displayText = selected?.label ?? propsRes.value.placeholder ?? "Select…";
const textW = measureTextCells(displayText);
return ok({ w: Math.min(maxW, textW + 4), h: Math.min(maxH, 1) });
}
case "checkbox": {
// Checkbox: [x] + optional label
const labelW = vnode.props.label ? measureTextCells(vnode.props.label) + 1 : 0;
const propsRes = validateCheckboxProps(vnode.props);
if (!propsRes.ok) return propsRes;
const labelW =
propsRes.value.label === undefined ? 0 : measureTextCells(propsRes.value.label) + 1;
return ok({ w: Math.min(maxW, 3 + labelW), h: Math.min(maxH, 1) });
}
case "radioGroup": {
// Radio group: stack of options
const direction = vnode.props.direction ?? "vertical";
const propsRes = validateRadioGroupProps(vnode.props);
if (!propsRes.ok) return propsRes;
const direction = propsRes.value.direction;
if (direction === "horizontal") {
let totalW = 0;
for (const opt of vnode.props.options) {
for (const opt of propsRes.value.options) {
totalW += measureTextCells(opt.label) + 5; // "(x) label "
}
return ok({ w: Math.min(maxW, totalW), h: Math.min(maxH, 1) });
}
// Vertical: max width of options, height = num options
let maxOptW = 0;
for (const opt of vnode.props.options) {
for (const opt of propsRes.value.options) {
const w = measureTextCells(opt.label) + 4; // "(x) label"
if (w > maxOptW) maxOptW = w;
}
return ok({ w: Math.min(maxW, maxOptW), h: Math.min(maxH, vnode.props.options.length) });
return ok({ w: Math.min(maxW, maxOptW), h: Math.min(maxH, propsRes.value.options.length) });
}
default:
return {
Expand Down Expand Up @@ -425,34 +428,36 @@ export function layoutLeafKind(
});
}
case "select": {
// Select dropdown is an interactive leaf widget.
const propsRes = validateSelectProps(vnode.props);
if (!propsRes.ok) return propsRes;
return ok({
vnode,
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
children: Object.freeze([]),
});
}
case "slider": {
// Slider is an interactive leaf widget.
const propsRes = validateSliderProps(vnode.props);
if (!propsRes.ok) return propsRes;
return ok({
vnode,
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
children: Object.freeze([]),
});
}
case "checkbox": {
// Checkbox is an interactive leaf widget.
const propsRes = validateCheckboxProps(vnode.props);
if (!propsRes.ok) return propsRes;
return ok({
vnode,
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
children: Object.freeze([]),
});
}
case "radioGroup": {
// Radio group layout depends on direction.
const direction = vnode.props.direction ?? "vertical";
const optionCount = Array.isArray(vnode.props.options) ? vnode.props.options.length : 0;
const naturalH = direction === "vertical" ? optionCount : 1;
const propsRes = validateRadioGroupProps(vnode.props);
if (!propsRes.ok) return propsRes;
const naturalH = propsRes.value.direction === "vertical" ? propsRes.value.options.length : 1;
return ok({
vnode,
rect: { x, y, w: rectW, h: Math.min(rectH, naturalH) },
Expand Down
Loading
Loading