KeyValueEditor
Rows of keys and values to add and remove, with duplicate keys pointed out; posts JSON or name[key] fields.
Demo
Environment variables
Sent as one JSON object; up to 8 variables.
Request headers
2 headers: Accept, X-Request-Id
Labels
Nothing 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/key_value_editor.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/key_value_editor.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/key_value_editor.qwe
component KeyValueEditorDemoLive() { state headers = { Accept: "application/json", "X-Request-Id": "7f3a9c" } <div.demo-stack> <KeyValueEditor id="kv-headers" label="Request headers" key_label="Header" value={headers} key_placeholder="Header-Name" on_change={(m) => { headers = m }}/> <p.demo-note>{len(keys(headers))} header{len(keys(headers)) == 1 ? "" : "s"}: {join(keys(headers), ", ")}</p> </div>}view KeyValueEditorDemo() = <div.demo-stack style="max-width: 560px"> <KeyValueEditor id="kv-env" name="env" label="Environment variables" max={8} value={{ PORT: "8080", LOG_LEVEL: "info", DATABASE_URL: "postgres://db/app" }} key_placeholder="NAME" value_placeholder="value" hint="Sent as one JSON object; up to 8 variables."/> <KeyValueEditorDemoLive/> <KeyValueEditor id="kv-labels" name="labels" format="fields" label="Labels" value={[]} add_label="Add label"/> </div>Source
ui/key_value_editor.qwe
// KeyValueEditor: rows of a key and its value to add, change and remove: headers,// environment variables, labels, metadata. A key used twice is pointed out. A// <form> gets them under `name`, as one JSON object or as name[key] fields.//// <KeyValueEditor id="env" name="env" label="Environment" value={{ PORT: "8080", LOG: "info" }}/>// <KeyValueEditor id="hdr" label="Headers" key_label="Header" value={headers}// on_change={(m) => { headers = m }}/>//// id: a prefix for its fields' ids, unique on the page. value: the pairs to start// with, a map (or a list of { key, value }). name: what a form gets them as;// format "json" (default): one field, a JSON object of { key: value }; "fields": a// field `name[key]` for each pair. Rows with no key are left out; of two with the// same key, the last one wins, and both say so. key_label ("Key"), value_label// ("Value"), key_placeholder, value_placeholder: the columns' names and hints.// label, hint: above and below it. add_label ("Add"): the button that adds a row.// max: at most this many rows. Enter in a key goes to its value; Enter in the last// value adds a row. on_change(pairs): inside one of your components, called with// the map of { key: value }.import ui/theme.qwe use ui_clslet key_value_editor_css = css`.ui-kv { display: grid; gap: 8px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-kv-label { margin: 0; font-size: 13px; font-weight: 500; }.ui-kv-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-kv-rows { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-kv-head, .ui-kv-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 34px; gap: 6px; align-items: start; }.ui-kv-head { font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--ui-muted); }.ui-kv-head span { padding-left: 2px; }.ui-kv-input { box-sizing: border-box; width: 100%; height: 34px; margin: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px/1 var(--ui-mono); transition: border-color .15s, box-shadow .15s; }.ui-kv-input::placeholder { color: var(--ui-muted); opacity: 1; font-family: var(--ui-font); }.ui-kv-input:hover { border-color: var(--ui-strong); }.ui-kv-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-kv-input[aria-invalid="true"] { border-color: var(--ui-warning); background: color-mix(in srgb, var(--ui-warning) 7%, var(--ui-surface)); }.ui-kv-warn { grid-column: 1 / -1; margin: -2px 0 2px; font-size: 12px; color: var(--ui-warning); }.ui-kv-x { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 0; border: 1px solid transparent; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-kv-x:hover { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-kv-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-kv-x svg { width: 14px; height: 14px; }.ui-kv-add { justify-self: start; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px dashed var(--ui-strong); border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-kv-add:hover:not(:disabled) { border-style: solid; border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-kv-add:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-kv-add:disabled { opacity: .55; cursor: not-allowed; }.ui-kv-add svg { width: 12px; height: 12px; }.ui-kv-empty { margin: 0; padding: 10px 12px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); font-size: 13px; }@media (max-width: 480px) { .ui-kv-head, .ui-kv-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px; } }@media (prefers-reduced-motion: reduce) { .ui-kv-input { transition: none; } }`// The starting rows, from a map or a list of { key, value }, each with an id.fn key_value_editor_rows(value) -> list { let pairs = type_of(value) == "map" ? (for [k, v] in value => { key: k, value: v }) : (value ?? []) for [i, p] in enumerate(pairs) => { id: i, key: str(p.key ?? ""), value: p.value == null ? "" : str(p.value) }}// The map the rows make: trimmed keys, empty ones left out, the last of a key winning.fn key_value_editor_map(rows: list) -> map = reduce(rows, {}, (m, r) => trim(r.key) == "" ? m : set(m, trim(r.key), r.value))component KeyValueEditor(id: str, name: str? = null, value = {}, format: str = "json", label: str? = null, hint: str? = null, key_label: str = "Key", value_label: str = "Value", key_placeholder: str? = null, value_placeholder: str? = null, add_label: str = "Add", max: int? = null, on_change = null, class: str? = null) { style global key_value_editor_css state rows = key_value_editor_rows(value) state next_id = len(key_value_editor_rows(value)) state focus_id = null let pairs = key_value_editor_map(rows) let keys = rows | map((r) => trim(r.key)) let twice = (k: str) => k != "" && count(keys, (x) => x == k) > 1 let full = max != null && len(rows) >= max let update = (next: list) => { rows = next on_change && on_change(key_value_editor_map(next)) } let add = () => { if !full { focus_id = next_id update(push(rows, { id: next_id, key: "", value: "" })) next_id += 1 } } let field = (e, sel: str) => { let el = e.target.closest(".ui-kv").querySelector(sel) el && el.focus() } <div class={ui_cls(["ui-kv", class])} role="group" aria-labelledby={label != null ? `${id}-label` : null} aria-describedby={hint != null ? `${id}-hint` : null}> {label != null && <p.ui-kv-label id={`${id}-label`}>{label}</p>} {len(rows) > 0 ? <div.ui-kv-head aria-hidden="true"><span>{key_label}</span><span>{value_label}</span><span></span></div> : <p.ui-kv-empty>Nothing yet.</p>} <ul.ui-kv-rows> {for [i, r] in enumerate(rows) => <li.ui-kv-row key={r.id}> <input.ui-kv-input type="text" id={`${id}-key-${r.id}`} value={r.key} placeholder={key_placeholder} autocomplete="off" spellcheck="false" aria-label={`${key_label} ${i + 1}`} aria-invalid={twice(trim(r.key)) ? "true" : null} aria-describedby={twice(trim(r.key)) ? `${id}-warn-${r.id}` : null} @mount={(el) => { if focus_id == r.id { el.focus() } }} @input={(e) => { update(rows | map((x) => x.id == r.id ? { id: x.id, key: e.value, value: x.value } : x)) }} @keydown={(e) => { if e.key == "Enter" { e.preventDefault() field(e, `#${id}-value-${r.id}`) } }}/> <input.ui-kv-input type="text" id={`${id}-value-${r.id}`} value={r.value} placeholder={value_placeholder} autocomplete="off" spellcheck="false" aria-label={`${value_label} ${i + 1}`} @input={(e) => { update(rows | map((x) => x.id == r.id ? { id: x.id, key: x.key, value: e.value } : x)) }} @keydown={(e) => { if e.key == "Enter" { e.preventDefault() let at = index_of(rows | map((x) => x.id), r.id) ?? 0 if at == len(rows) - 1 { add() } else { field(e, `#${id}-key-${rows[at + 1].id}`) } } }}/> <button.ui-kv-x type="button" aria-label={trim(r.key) != "" ? `Remove ${trim(r.key)}` : `Remove row ${i + 1}`} @click={(e) => { let at = index_of(rows | map((x) => x.id), r.id) ?? 0 let root = e.target.closest(".ui-kv") update(rows | filter((x) => x.id != r.id)) js("queueMicrotask", () => { let rest = root.querySelectorAll(".ui-kv-row") let to = len(rest) == 0 ? root.querySelector(".ui-kv-add") : rest[at < len(rest) ? at : len(rest) - 1].querySelector("input") to && to.focus() }) }}> <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M2 2l6 6M8 2L2 8"/></svg> </button> {twice(trim(r.key)) && <p.ui-kv-warn id={`${id}-warn-${r.id}`}> {`“${trim(r.key)}” is used more than once; the last one is kept.`} </p>} </li>} </ul> <button.ui-kv-add type="button" disabled={full} @click={add}> <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M6 1.5v9M1.5 6h9"/></svg> {add_label} </button> {hint != null && <p.ui-kv-hint id={`${id}-hint`}>{hint}</p>} {name != null && format == "json" && <input type="hidden" name={name} value={json.stringify(pairs)}/>} {name != null && format != "json" && (for [k, v] in pairs => <input type="hidden" name={`${name}[${k}]`} value={v}/>)} </div>}Related
AddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsCardInputA payment card as it is typed: the number grouped, the brand, MM/YY and the code, with typos said.6 views · 6 downloadsCharacterCounterA text box or textarea with a live 120 / 280 count that warns near the limit, as a hard stop or a soft one that blocks sending.5 views · 6 downloadsCheckboxA checkbox drawn by CSS on a real input, with an indeterminate state, and a group of them in a fieldset.5 views · 6 downloadsCheckboxCardsAny number of choices as cards with a title, a line of detail and a check, each a real checkbox a form sends.5 views · 6 downloads
Thread
Thread 0 posts
On KeyValueEditor: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.