PropertyGrid
Grouped name and value rows that fold, each value edited by the control its type asks for, with changes marked and undone.
Demo
px
Inside the border
px
#5b4bdb
btn-7
on_change: nothing yet. on_values: width is 120.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/property_grid.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/property_grid.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/property_grid.qwe
component PropertyGridDemoLive() { state last = "nothing yet" state width = 120 <div.demo-stack.demo-narrow style="max-width: 420px"> <PropertyGrid id="property-grid-demo" label="Button properties" name="props" on_change={(field, v) => { last = `${field} = ${json.stringify(v)}` }} on_values={(vs) => { width = vs.width ?? 0 }} groups={[ { label: "Layout", items: [ { field: "width", label: "Width", type: "number", value: 120, unit: "px" }, { field: "align", label: "Align", type: "select", value: "left", options: ["left", "center", "right"] }, { field: "padding", label: "Padding", type: "number", value: 8, unit: "px", hint: "Inside the border" }, ] }, { label: "Look", items: [ { field: "color", label: "Colour", type: "color", value: "#5b4bdb" }, { field: "rounded", label: "Rounded", type: "bool", value: true }, { field: "text", label: "Text", value: "Save" }, ] }, { label: "Meta", open: false, items: [ { field: "id", label: "Id", value: "btn-7", readonly: true }, { field: "created", label: "Created", type: "date", value: "2026-10-28" }, ] }, ]}/> <p.demo-note>on_change: {last}. on_values: width is {width}.</p> </div>}view PropertyGridDemo() = <PropertyGridDemoLive/>Source
ui/property_grid.qwe
// PropertyGrid: an object's properties as name and value rows, grouped under// headings that fold, with each value edited in place by the control its type// asks for: an inspector panel, a settings sheet, a design tool's sidebar.// A changed value is marked, with a button that puts it back.//// <PropertyGrid id="props" label="Button" name="props" groups={[// { label: "Layout", items: [// { field: "width", label: "Width", type: "number", value: 120, unit: "px" },// { field: "align", label: "Align", type: "select", value: "left", options: ["left", "center", "right"] },// ] },// { label: "Look", items: [// { field: "color", label: "Colour", type: "color", value: "#5b4bdb" },// { field: "rounded", label: "Rounded", type: "bool", value: true },// { field: "id", label: "Id", value: "btn-7", readonly: true },// ] },// ]} on_change={(field, v) => { set_prop(field, v) }}/>//// groups: maps of { label, items, open? (false: folded at first) }; items: maps// of { field, label?, value, type?, options?, unit?, hint?, readonly? }: type// "text" (default), "number", "bool" (a switch-like checkbox), "select" (options:// strings or { label, value }), "color" or "date". unit: after a number ("px").// hint: under the name, and the control's description. readonly: shown, not// edited. Each control is a real field named field (or name + "." + field when// name is given), so a <form> around it sends the values. id: a prefix for its// ids, unique on the page. label: the grid's name for screen readers.// on_change(field, value): inside one of your components, called with the// field and its new value (a number for number, true or false for bool).// on_values(values): called with every field's value, as a map.import ui/theme.qwe use ui_clslet property_grid_css = css`.ui-propgrid { min-width: 0; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-propgrid-head { display: flex; align-items: center; gap: 6px; width: 100%; height: 32px; padding: 0 10px; border: 0; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); color: var(--ui-fg); font: 600 12px/1 var(--ui-font); letter-spacing: .03em; text-transform: uppercase; text-align: left; cursor: pointer; }.ui-propgrid-head:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-propgrid-head svg { width: 10px; height: 10px; color: var(--ui-muted); transition: transform .15s; }.ui-propgrid-head[aria-expanded="true"] svg { transform: rotate(90deg); }.ui-propgrid-count { margin-left: auto; color: var(--ui-muted); font-weight: 500; }.ui-propgrid-rows { margin: 0; }.ui-propgrid-row { display: grid; grid-template-columns: minmax(90px, 40%) minmax(0, 1fr) 24px; align-items: center; gap: 8px; min-height: 34px; padding: 3px 6px 3px 12px; border-bottom: 1px solid var(--ui-border); }.ui-propgrid-group:last-child .ui-propgrid-row:last-child { border-bottom: 0; }.ui-propgrid-name { min-width: 0; overflow: hidden; color: var(--ui-muted); text-overflow: ellipsis; white-space: nowrap; }.ui-propgrid-name label { cursor: default; }.ui-propgrid-changed .ui-propgrid-name { color: var(--ui-fg); font-weight: 600; }.ui-propgrid-hint { display: block; color: var(--ui-muted); font-size: 11px; font-weight: 400; white-space: normal; }.ui-propgrid-val { display: flex; align-items: center; gap: 6px; min-width: 0; margin: 0; }.ui-propgrid-val input:not([type="checkbox"]):not([type="color"]), .ui-propgrid-val select { width: 100%; min-width: 0; height: 26px; padding: 0 7px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: var(--ui-fg); font: 13px/1 var(--ui-font); }.ui-propgrid-val input:hover, .ui-propgrid-val select:hover { border-color: var(--ui-border); }.ui-propgrid-val input:focus, .ui-propgrid-val select:focus { outline: none; border-color: var(--ui-accent); background: var(--ui-surface); box-shadow: var(--ui-focus); }.ui-propgrid-val input[type="number"] { text-align: right; font-variant-numeric: tabular-nums; }.ui-propgrid-val input[type="checkbox"] { width: 16px; height: 16px; margin: 0 7px; accent-color: var(--ui-accent); }.ui-propgrid-val input[type="color"] { width: 26px; height: 22px; padding: 0; border: 1px solid var(--ui-border); border-radius: 4px; background: none; }.ui-propgrid-val input[type="checkbox"]:focus-visible, .ui-propgrid-val input[type="color"]:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-propgrid-unit, .ui-propgrid-code { color: var(--ui-muted); font: 12px/1 var(--ui-mono); }.ui-propgrid-ro { padding: 0 8px; color: var(--ui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-propgrid-reset { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--ui-accent); cursor: pointer; }.ui-propgrid-reset:hover { background: var(--ui-accent-soft); }.ui-propgrid-reset:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-propgrid-reset svg { width: 12px; height: 12px; }@media (prefers-reduced-motion: reduce) { .ui-propgrid-head svg { transition: none; } }`fn property_grid_options(it: map) -> list = (it.options ?? []) | map((o) => type_of(o) == "map" ? { label: o.label ?? str(o.value), value: o.value ?? o.label } : { label: str(o), value: o })// Every item's value, as a map from field to value.fn property_grid_values(groups: list) -> map = reduce(flatten(groups | map((g) => g.items ?? [])), {}, (m, it) => set(m, it.field, it.value))component PropertyGrid(id: str, groups: list = [], name: str? = null, label: str? = null, on_change = null, on_values = null, class: str? = null) { style global property_grid_css state values = property_grid_values(groups) state closed = for [i, g] in enumerate(groups) => g.open == false ? str(i) : null let first = property_grid_values(groups) let put = (field: str, v) => { values = set(values, field, v) on_change && on_change(field, v) on_values && on_values(values) } let field_name = (it: map) => name != null ? `${name}.${it.field}` : it.field <div class={ui_cls(["ui-propgrid", class])} id={id} role="group" aria-label={label}> {for [gi, g] in enumerate(groups) => { let open = index_of(closed, str(gi)) == null <div.ui-propgrid-group key={gi}> <button.ui-propgrid-head type="button" aria-expanded={open ? "true" : "false"} aria-controls={`${id}-g${gi}`} @click={() => { closed = open ? push(closed, str(gi)) : closed | filter((x) => x != str(gi)) }}> <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5L8 6l-3.5 3.5"/></svg> {g.label} <span.ui-propgrid-count>{len(g.items ?? [])}</span> </button> <div.ui-propgrid-rows id={`${id}-g${gi}`} hidden={!open}> {for it in g.items ?? [] => { let fid = `${id}-${re.replace(it.field, "[^A-Za-z0-9_-]+", "-")}` let v = values[it.field] let kind = it.type ?? "text" let changed = v != first[it.field] let hint_id = it.hint != null ? `${fid}-hint` : null <div class={ui_cls(["ui-propgrid-row", changed && "ui-propgrid-changed"])} data-field={it.field}> <div.ui-propgrid-name> <label for={fid} title={it.label ?? it.field}>{it.label ?? it.field}</label> {it.hint != null && <span.ui-propgrid-hint id={hint_id}>{it.hint}</span>} </div> <div.ui-propgrid-val> {it.readonly == true ? [<span.ui-propgrid-ro id={fid}>{v == null ? "—" : str(v)}</span>, <input type="hidden" name={field_name(it)} value={v == null ? "" : str(v)}/>] : match kind { "bool" => [<input type="hidden" name={field_name(it)} value="false"/>, <input type="checkbox" id={fid} name={field_name(it)} value="true" checked={v == true} aria-describedby={hint_id} @change={(e) => { put(it.field, e.checked) }}/>, <span.ui-propgrid-code aria-hidden="true">{v == true ? "true" : "false"}</span>] "select" => <select id={fid} name={field_name(it)} aria-describedby={hint_id} @change={(e) => { let o = find(property_grid_options(it), (o) => str(o.value) == e.value) put(it.field, o != null ? o.value : e.value) }}> {for o in property_grid_options(it) => <option value={str(o.value)} selected={o.value == v}>{o.label}</option>} </select> "number" => [<input type="number" id={fid} name={field_name(it)} value={v == null ? "" : str(v)} step="any" aria-describedby={hint_id} @change={(e) => { let n = re.test(trim(e.value), "^-?\\d+$") ? int(trim(e.value)) : float(e.value) put(it.field, trim(e.value) == "" || is_err(n) ? null : n) }}/>, it.unit != null && <span.ui-propgrid-unit>{it.unit}</span>] "color" => [<input type="color" id={fid} name={field_name(it)} value={v ?? "#000000"} aria-describedby={hint_id} @input={(e) => { put(it.field, e.value) }}/>, <span.ui-propgrid-code>{v}</span>] _ => <input type={kind == "date" ? "date" : "text"} id={fid} name={field_name(it)} value={v == null ? "" : str(v)} aria-describedby={hint_id} @change={(e) => { put(it.field, e.value) }}/> }} </div> {changed && it.readonly != true ? <button.ui-propgrid-reset type="button" title="Reset" aria-label={`Reset ${it.label ?? it.field}`} @click={() => { put(it.field, first[it.field]) }}> <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 5A3.7 3.7 0 1 1 3 8.5M2.5 2v3h3"/></svg> </button> : <span></span>} </div> }} </div> </div> }} </div>}Related
KeyValueEditorRows of keys and values to add and remove, with duplicate keys pointed out; posts JSON or name[key] fields.5 views · 6 downloadsEditableTableA grid edited in place: text, number, select, checkbox and date cells, checked, sorted, with rows added and deleted.7 views · 6 downloadsSelectableTableRows with checkboxes, a select-all that shows when only some are checked, a count of the selected, and hidden inputs for a form.7 views · 7 downloadsApiKeyFieldA secret shown masked in a read-only field, with Show and Copy buttons and a hidden input for forms.5 views · 6 downloadsFieldsetA <fieldset> with a legend and a line about it, its fields stacked, in a row or in a grid; disabled as one.6 views · 7 downloadsAddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloads
Thread
Thread 0 posts
On PropertyGrid: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.