InspectorPanel
A right-hand panel of properties for the selected thing, in sections, live both ways, and a sheet on a phone.
Demo
No change yet
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/inspector_panel.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/inspector_panel.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/inspector_panel.qwe
let inspector_panel_demo_css = css`.demo-inspect-stage { position: relative; min-height: 420px; height: 100%; box-sizing: border-box; background: var(--ui-subtle); }.demo-inspect-tools { position: absolute; left: 10px; top: 10px; z-index: 1; display: flex; flex-wrap: wrap; gap: 8px; }.demo-inspect-shape { position: absolute; display: grid; place-items: center; box-sizing: border-box; min-width: 44px; min-height: 44px; padding: 0; border: 2px solid transparent; color: #fff; font: 600 13px/1.2 var(--ui-font); cursor: pointer; }.demo-inspect-shape[aria-pressed="true"] { border-color: var(--ui-fg); box-shadow: var(--ui-focus); }.demo-inspect-out { margin: 0; }`let inspector_panel_demo_shapes = [ { id: "a", name: "Header", kind: "rect", x: 40, y: 80, width: 180, height: 80, corners: 8, fill: "#5b4bdb", locked: false, layer: "Front" }, { id: "b", name: "Badge", kind: "circle", x: 260, y: 120, width: 90, height: 90, fill: "#18794e", locked: true, layer: "Middle" }, { id: "c", name: "Photo", kind: "rect", x: 80, y: 200, width: 140, height: 120, corners: 0, fill: "#a35200", locked: false, layer: "Back" },]let inspector_panel_demo_sections = [ { title: "About", fields: [ { key: "name", label: "Name", type: "text" }, { key: "kind", label: "Kind", type: "readonly" }, ] }, { title: "Place", fields: [ { key: "x", label: "X", type: "number", min: 0, max: 800, step: 10, unit: "px" }, { key: "y", label: "Y", type: "number", min: 0, max: 800, step: 10, unit: "px" }, { key: "width", label: "Width", type: "number", min: 44, max: 400, step: 10, unit: "px" }, ] }, { title: "Look", fields: [ { key: "corners", label: "Corners", type: "number", min: 0, max: 40, step: 2, unit: "px", kinds: ["rect"] }, { key: "fill", label: "Fill", type: "color" }, { key: "layer", label: "Layer", type: "select", options: ["Back", "Middle", "Front"] }, { key: "locked", label: "Locked", type: "toggle" }, ] }, { title: "Rounding", kinds: ["circle"], closed: true, fields: [ { key: "height", label: "Height", type: "number", min: 44, max: 400, unit: "px" }, ] },]component InspectorPanelDemoLive() { style global inspector_panel_demo_css state shapes = inspector_panel_demo_shapes state chosen = "" state said = "No change yet" state tall = "100%" let picked = find(shapes, (s) => s.id == chosen) let change = (k, v) => { shapes = shapes | map((s) => s.id == chosen ? set(s, k, v) : s) said = `${k} is ${v}` } <div.demo-stack> <div.demo-tablet> <InspectorPanel title="Shape" selection={picked} sections={inspector_panel_demo_sections} on_change={change} id="inspect-demo" height={tall}> <div.demo-inspect-stage> <div.demo-inspect-tools> <Button id="inspect-demo-nudge" size="lg" variant="outline" on_click={() => { shapes = shapes | map((s) => s.id == chosen ? set(s, "x", s.x + 5) : s) }}>Nudge right</Button> <Button id="inspect-demo-none" size="lg" variant="outline" on_click={() => { chosen = "" }}>Select nothing</Button> </div> {for s in shapes => <button.demo-inspect-shape key={s.id} type="button" id={`inspect-demo-${s.id}`} aria-pressed={s.id == chosen ? "true" : "false"} style={`left: ${s.x}px; top: ${s.y}px; width: ${s.width}px; height: ${s.height}px; background: ${s.fill}; border-radius: ${s.kind == "circle" ? "50%" : `${get(s, "corners", 0)}px`}`} @click={() => { chosen = s.id }}>{s.name}</button>} </div> </InspectorPanel> </div> <div.demo-row> <Button id="inspect-demo-tall" size="lg" variant="outline" on_click={() => { tall = tall == "100%" ? "96%" : "100%" }}>Change the height</Button> <p.demo-note.demo-inspect-out id="inspect-demo-said">{said}</p> </div> </div>}view InspectorPanelDemo() = <InspectorPanelDemoLive/>Source
ui/inspector_panel.qwe
// InspectorPanel: the panel down the right of a design or admin tool, showing the// properties of what is selected, in sections, live: a change in the panel goes straight// out, and a change to the thing shows straight in the panel. On a phone it is a sheet that// leaves the thing in view: a strip when closed, half the screen, or nearly all of it.//// import ui/inspector_panel.qwe use InspectorPanel//// <InspectorPanel title="Shape" selection={shape} sections={[// { title: "Place", fields: [// { key: "x", label: "X", type: "number", unit: "px" },// { key: "width", label: "Width", type: "number", min: 1, max: 800, step: 10, unit: "px" },// ] },// { title: "Look", kinds: ["rect"], fields: [// { key: "fill", label: "Fill", type: "color" },// { key: "locked", label: "Locked", type: "toggle" },// { key: "layer", label: "Layer", type: "select", options: ["Back", "Middle", "Front"] },// ] },// ]} on_change={(key, value) => { shape = set(shape, key, value) }}>// <Canvas/>// </InspectorPanel>//// selection: the selected thing, a map holding each field's key; null shows `empty`. Its// `id` tells one thing from another; `name` heads the panel; `kind` picks the sections.// sections: { title, fields, kinds?, closed? }: kinds keeps a section to those kinds, and a// field may say `kinds` too; closed starts it folded. A section stays folded or open as// the selection changes.// fields: { key, label, type, ... }. type: "text" (the default), "number", "select", "toggle",// "color" or "readonly". A number takes min, max, step (1) and unit. A select takes options,// each text or { value, label }.// on_change(key, value): inside one of your components, at each change. A number is given// as a number, and only when it is good: what cannot be a number, or is past min or max,// is said beside the field and sent nowhere, and leaving the field puts back the last good// value. The field a person is typing in is never overwritten from outside.// title: its heading ("Properties"). empty: what it says with nothing selected// ("Nothing selected"). children: the thing's own view, beside the panel or under the sheet.// height: as CSS ("100%" of the box around it).//// From 600 px wide, on its own box, it is the side panel, which Hide folds away and Show// brings back. Under that it is the sheet: drag its grip, tap it, or use the arrow keys on// it; selecting something raises it from the strip and selecting nothing lowers it. It// rises above the on-screen keyboard.import ui/theme.qwe use ui_clslet inspector_panel_css = css`.ui-inspector { position: relative; display: flex; box-sizing: border-box; min-width: 0; height: var(--ui-inspector-height, 100%); overflow: hidden; container: ui-inspector / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 14px/1.4 var(--ui-font);}.ui-inspector-frame { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; height: 100%; }.ui-inspector-stage { position: relative; flex: 1; min-width: 0; min-height: 0; overflow: auto; }.ui-inspector-panel { display: flex; flex-direction: column; flex: none; box-sizing: border-box; width: 300px; min-height: 0; background: var(--ui-surface); border-left: 1px solid var(--ui-border); }.ui-inspector-closed .ui-inspector-panel { display: none; }.ui-inspector-show { position: absolute; top: 8px; right: 8px; z-index: 2; display: none; align-items: center; min-height: 44px; box-sizing: border-box; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow); font: 500 14px/1.2 var(--ui-font); cursor: pointer;}.ui-inspector-closed .ui-inspector-show { display: inline-flex; }.ui-inspector-grip { display: none; flex: none; place-items: center; width: 100%; height: 44px; margin: 0; padding: 0; border: 0; background: transparent; cursor: grab; touch-action: none; -webkit-tap-highlight-color: transparent; }.ui-inspector-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-inspector-grip:focus-visible { outline: none; }.ui-inspector-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-inspector-head { display: flex; align-items: center; gap: 6px; flex: none; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 14px; border-bottom: 1px solid var(--ui-border); }.ui-inspector-heading { flex: 1; min-width: 0; }.ui-inspector-heading h2 { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .04em; color: var(--ui-muted); text-transform: uppercase; }.ui-inspector-heading p { margin: 0; overflow: hidden; font-size: 15px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-inspector-hide { flex: none; min-width: 44px; min-height: 44px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 14px/1.2 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent; }.ui-inspector-hide:focus-visible, .ui-inspector-show:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-inspector-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding-bottom: env(safe-area-inset-bottom); }.ui-inspector-empty { margin: 0; padding: 28px 16px; color: var(--ui-muted); text-align: center; }.ui-inspector-section { border-bottom: 1px solid var(--ui-border); }.ui-inspector-section summary { display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; min-height: 44px; padding: 0 14px; font-weight: 600; list-style: none; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-inspector-section summary::-webkit-details-marker { display: none; }.ui-inspector-section summary::after { content: ""; width: 8px; height: 8px; border-right: 2px solid var(--ui-muted); border-bottom: 2px solid var(--ui-muted); transform: rotate(-45deg); transition: transform .15s; }.ui-inspector-section[open] summary::after { transform: rotate(45deg); }.ui-inspector-section summary:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-inspector-fields { display: grid; gap: 8px; padding: 2px 14px 14px; }.ui-inspector-field { display: grid; grid-template-columns: 84px minmax(0, 1fr); align-items: center; gap: 2px 8px; min-height: 44px; }.ui-inspector-field label, .ui-inspector-label { min-width: 0; overflow: hidden; color: var(--ui-muted); text-overflow: ellipsis; white-space: nowrap; }.ui-inspector-input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.2 var(--ui-font);}.ui-inspector-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-inspector-input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-inspector-number { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; align-items: center; gap: 2px; min-width: 0; }.ui-inspector-number .ui-inspector-input { text-align: center; padding: 0 2px; }.ui-inspector-step { width: 44px; height: 44px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-fg); font: 500 20px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent; }.ui-inspector-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-inspector-unit { grid-column: 2; color: var(--ui-muted); font-size: 12px; text-align: center; }.ui-inspector-note { grid-column: 1 / -1; color: var(--ui-danger); font-size: 13px; }.ui-inspector-color { box-sizing: border-box; width: 100%; height: 44px; padding: 2px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-inspector-read { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-inspector-switch { justify-self: end; width: 26px; height: 26px; margin: 0; accent-color: var(--ui-accent); }@container ui-inspector (max-width: 599.98px) { .ui-inspector-stage { padding-bottom: 100px; box-sizing: border-box; } .ui-inspector-panel { position: absolute; left: 0; right: 0; bottom: var(--ui-inspector-kb, 0px); z-index: 3; width: auto; height: 96px; max-height: calc(100% - var(--ui-inspector-kb, 0px) - 24px); border-left: 0; border-top: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; box-shadow: var(--ui-shadow-lg); transition: height .24s cubic-bezier(.2, .8, .2, 1), bottom .2s; } .ui-inspector-panel[data-detent="half"] { height: 52%; } .ui-inspector-panel[data-detent="full"] { height: calc(100% - 24px); } .ui-inspector-dragging { transition: none; } .ui-inspector-closed .ui-inspector-panel { display: flex; } .ui-inspector-closed .ui-inspector-show { display: none; } .ui-inspector-grip { display: grid; } .ui-inspector-head { min-height: 48px; border-bottom: 0; } .ui-inspector-hide { display: none; } .ui-inspector-panel[data-detent="half"] .ui-inspector-head, .ui-inspector-panel[data-detent="full"] .ui-inspector-head { border-bottom: 1px solid var(--ui-border); } .ui-inspector-panel[data-detent="peek"] .ui-inspector-body { visibility: hidden; }}@media (prefers-reduced-motion: reduce) { .ui-inspector-panel { transition: none !important; } .ui-inspector-section summary::after { transition: none; } }`// Whether a field or section is for a kind: with no `kinds` it is for every kind.fn inspector_panel_for(part, kind) -> bool { let kinds = get(part, "kinds") kinds == null || contains(kinds, kind)}// The sections for a selection, each with only the fields for its kind, and none left empty.fn inspector_panel_shown(sections: list, selection) -> list { if selection == null { return [] } let kind = get(selection, "kind") sections | filter((s) => inspector_panel_for(s, kind)) | map((s) => { title: str(get(s, "title", "")), fields: get(s, "fields", []) | filter((f) => inspector_panel_for(f, kind)) }) | filter((s) => len(s.fields) > 0)}// Text for a number: whole numbers without a point, so 2.0 is 2 in both the server and the browser.fn inspector_panel_show(value) -> str { if value == null { return "" } let t = str(value) ends_with(t, ".0") ? slice(t, 0, len(t) - 2) : t}// What a typed number comes to: { ok, value, note }. Up to 15 digits and six places, which// a float holds exactly enough; a comma as the point, as many keyboards give it.fn inspector_panel_number(text, field) { let t = replace(trim(str(text ?? "")), ",", ".") if !re.test(t, "^-?[0-9]{1,15}(\\.[0-9]{1,6})?$") { return { ok: false, value: null, note: "Must be a number" } } let n = float(t) let lo = get(field, "min") let hi = get(field, "max") if lo != null && n < lo { return { ok: false, value: null, note: `At least ${inspector_panel_show(lo)}` } } if hi != null && n > hi { return { ok: false, value: null, note: `At most ${inspector_panel_show(hi)}` } } { ok: true, value: n, note: "" }}// One press of a stepper, kept in range, and to six places, so 0.1 and 0.2 do not come to// 0.30000000000000004. Anything not a number starts from 0.fn inspector_panel_step(value, by: num, field) -> num { let base = type_of(value) == "int" || type_of(value) == "float" ? value : 0 let next = round((base + by) * 1000000) / 1000000 let lo = get(field, "min") let hi = get(field, "max") lo != null && next < lo ? lo : hi != null && next > hi ? hi : next}// A colour an <input type=color> will take: seven characters, or black.fn inspector_panel_color(value) -> str { let t = str(value ?? "") re.test(t, "^#[0-9a-fA-F]{6}$") ? t : "#000000"}fn inspector_panel_option(o) = type_of(o) == "map" ? { value: str(get(o, "value", "")), label: str(get(o, "label", get(o, "value", ""))) } : { value: str(o), label: str(o) }// The height a sheet settles at when let go. now: where it was. tall: the height it was// let go at. room: the height it has. speed: px a millisecond, up being positive. A flick// goes one on in its direction, else the nearest wins.fn inspector_panel_detent(now: str, tall: num, room: num, speed: num) -> str { let names = ["peek", "half", "full"] let at = index_of(names, now) ?? 0 if speed > 0.5 { return names[at < 2 ? at + 1 : 2] } if speed < -0.5 { return names[at > 0 ? at - 1 : 0] } let heights = [96, room * 0.52, room - 24] let gaps = heights | map((h) => abs(h - tall)) let least = min(gaps) names[index_of(gaps, least) ?? at]}component InspectorPanelField(field, value, uid: str, on_change = null) { let kind = get(field, "type", "text") let key_name = str(get(field, "key", "")) let inner = `${uid}-${key_name}` let note_id = `${inner}-note` state text = kind == "number" ? inspector_panel_show(value) : str(value ?? "") state seen = value state typing = false state note = "" // What came from outside shows, but never under someone's fingers. if value != seen { seen = value if !typing { text = kind == "number" ? inspector_panel_show(value) : str(value ?? "") note = "" } } let send = (v) => { on_change && on_change(key_name, v) } let typed = (raw) => { text = raw if kind == "number" { let n = inspector_panel_number(raw, field) note = n.note if n.ok { send(n.value) } } else { send(raw) } } let step_by = (dir: int) => { let by = get(field, "step", 1) * dir let n = inspector_panel_number(text, field) let next = inspector_panel_step(n.ok ? n.value : value, by, field) text = inspector_panel_show(next) note = "" send(next) } <div.ui-inspector-field data-field={key_name}> {kind == "toggle" ? <label for={inner}>{str(get(field, "label", key_name))}</label> : kind == "readonly" ? <span.ui-inspector-label>{str(get(field, "label", key_name))}</span> : <label for={inner}>{str(get(field, "label", key_name))}</label>} {kind == "number" && <div.ui-inspector-number> <button.ui-inspector-step type="button" aria-label={`Less ${str(get(field, "label", key_name))}`} @click={() => { step_by(-1) }}>−</button> <input.ui-inspector-input id={inner} type="text" inputmode="decimal" autocomplete="off" value={text} aria-invalid={note != "" ? "true" : null} aria-describedby={note != "" ? note_id : null} @focus={() => { typing = true }} @blur={() => { typing = false text = inspector_panel_show(value) note = "" }} @input={(e) => { typed(e.value) }}/> <button.ui-inspector-step type="button" aria-label={`More ${str(get(field, "label", key_name))}`} @click={() => { step_by(1) }}>+</button> {get(field, "unit") != null && <span.ui-inspector-unit>{str(get(field, "unit"))}</span>} </div>} {kind == "text" && <input.ui-inspector-input id={inner} type="text" autocomplete="off" value={text} @focus={() => { typing = true }} @blur={() => { typing = false }} @input={(e) => { typed(e.value) }}/>} {kind == "select" && <select.ui-inspector-input id={inner} @change={(e) => { send(e.value) }}> {for o in get(field, "options", []) | map(inspector_panel_option) => <option key={o.value} value={o.value} selected={o.value == str(value ?? "")}>{o.label}</option>} </select>} {kind == "color" && <input.ui-inspector-color id={inner} type="color" value={inspector_panel_color(value)} @input={(e) => { send(e.value) }}/>} {kind == "toggle" && <input.ui-inspector-switch id={inner} type="checkbox" role="switch" checked={value == true} data-small-target @change={(e) => { send(e.checked) }}/>} {kind == "readonly" && <span.ui-inspector-read>{str(value ?? "")}</span>} {note != "" && <span.ui-inspector-note id={note_id} role="alert">{note}</span>} </div>}component InspectorPanel(selection = null, sections: list = [], title: str = "Properties", empty: str = "Nothing selected", on_change = null, id: str = "ui-inspector", height: str? = null, class: str? = null, children) { style global inspector_panel_css state closed = false state detent = selection != null ? "half" : "peek" state seen_id = selection != null ? str(get(selection, "id", "")) : "" state root = null state pull = null // a finger on the grip: { y, tall, last_y, last_at, speed, on } state folded = sections | filter((s) => get(s, "closed") == true) | map((s) => str(get(s, "title", ""))) let shown = inspector_panel_shown(sections, selection) let now_id = selection != null ? str(get(selection, "id", "")) : "" let heading = selection != null ? str(get(selection, "name", get(selection, "id", ""))) : empty if now_id != seen_id { let had = seen_id seen_id = now_id if now_id != "" && detent == "peek" { detent = "half" } if now_id == "" && had != "" { detent = "peek" } } let panel = () => root.querySelector(".ui-inspector-panel") <div class={ui_cls(["ui-inspector", closed && "ui-inspector-closed", class])} style={height != null && !re.test(height, "[;{}<>\"]") ? `--ui-inspector-height: ${height}` : null} @mount={(el) => { let w = js("window") let vv = w.visualViewport let lift = (_e = null) => { if vv != null { // On the frame, not the root, whose style={...} is written whole when `height` changes. el.querySelector(".ui-inspector-frame").style.setProperty("--ui-inspector-kb", `${max([0, round(w.innerHeight - vv.height - vv.offsetTop)])}px`) } } if vv != null { vv.addEventListener("resize", lift, { passive: true }) vv.addEventListener("scroll", lift, { passive: true }) } root = el () => { if vv != null { vv.removeEventListener("resize", lift) vv.removeEventListener("scroll", lift) } } }}> <div.ui-inspector-frame> <div.ui-inspector-stage>{children}</div> <button.ui-inspector-show type="button" @click={() => { closed = false }}>Show {title}</button> <aside.ui-inspector-panel aria-label={title} data-detent={detent}> <button.ui-inspector-grip type="button" aria-label={detent == "peek" ? "Raise the panel" : detent == "half" ? "Make the panel taller" : "Make the panel shorter"} @pointerdown={(e) => { if e.button == 0 { e.target.setPointerCapture(e.pointerId) root.dataset.pulled = "" pull = { y: e.clientY, tall: panel().offsetHeight, last_y: e.clientY, last_at: e.timeStamp, speed: 0, on: false } } }} @pointermove={(e) => { if pull != null { let up = pull.y - e.clientY let on = pull.on || abs(up) > 8 if on { let p = panel() if !pull.on { p.classList.add("ui-inspector-dragging") } p.style.height = `${max([96, pull.tall + up])}px` } let dt = e.timeStamp - pull.last_at let step = dt > 0 ? (pull.last_y - e.clientY) / dt : pull.speed pull = { y: pull.y, tall: pull.tall, last_y: e.clientY, last_at: e.timeStamp, speed: pull.speed == 0 ? step : pull.speed * 0.4 + step * 0.6, on: on } } }} @pointerup={(e) => { if pull != null { let was = pull pull = null if was.on { let p = panel() let tall = p.offsetHeight let run = e.timeStamp - was.last_at < 100 ? was.speed : 0 p.classList.remove("ui-inspector-dragging") p.style.height = "" detent = inspector_panel_detent(detent, tall, root.clientHeight, run) root.dataset.pulled = "true" } } }} @pointercancel={() => { if pull != null { pull = null panel().classList.remove("ui-inspector-dragging") panel().style.height = "" } }} @click={() => { // A drag ends in a click too: only a press that stayed put counts. if root.dataset.pulled == "true" { root.dataset.pulled = "" } else { detent = detent == "peek" ? "half" : detent == "half" ? "full" : "half" } }} @keydown={(e) => { let names = ["peek", "half", "full"] let at = index_of(names, detent) ?? 0 let next = e.key == "ArrowUp" ? (at < 2 ? at + 1 : 2) : e.key == "ArrowDown" ? (at > 0 ? at - 1 : 0) : null if next != null { e.preventDefault() detent = names[next] } }}><span aria-hidden="true"></span></button> <div.ui-inspector-head> <div.ui-inspector-heading> <h2>{title}</h2> <p aria-live="polite">{heading}</p> </div> <button.ui-inspector-hide type="button" @click={() => { closed = true }}>Hide</button> </div> <div.ui-inspector-body> {selection == null && <p.ui-inspector-empty>{empty}</p>} {for s in shown => <details.ui-inspector-section key={s.title} open={!contains(folded, s.title)} @toggle={(e) => { let open_now = e.target.open let rest = folded | filter((t) => t != s.title) folded = open_now ? rest : rest + [s.title] }}> <summary>{s.title}</summary> <div.ui-inspector-fields> {for f in s.fields => <InspectorPanelField key={`${now_id}:${str(get(f, "key", ""))}`} field={f} value={get(selection, str(get(f, "key", "")))} uid={`${id}-${now_id}`} on_change={on_change}/>} </div> </details>} </div> </aside> </div> </div>}Related
ActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAnchoredPopoverA popover with an arrow tied to its button on a tablet and a sheet on a phone, re-anchored on rotation and when a keyboard opens.3 views · 5 downloadsCartSheetA cart in a sheet with quantities, swipe to remove with undo, live totals in decimals, a free-shipping note and a checkout button.2 views · 9 downloadsCheckoutSheetA one-screen checkout in a sheet with address, delivery, payment and a live total, every part validated, and a slide to pay.3 views · 6 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 8 downloads
Thread
Thread 0 posts
On InspectorPanel: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.