TransferList
Two filterable lists to move options between, several at a time, by mouse or keyboard.
Demo
Editor role permissions
Available
9- Delete posts
- Moderate comments
- Manage users
- Invite users
- View billing
- Change billing
- Export data
- View audit log
- Manage API keys
Chosen
3- Read posts
- Write posts
- Read comments
Select with a click, Space or Shift; move with the buttons, Enter or a double click.
Columns to show
Hidden
5- Company
- Plan
- Signed up
- Last seen
- Country
Shown
2- Name
Showing: Name, Email
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/transfer_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/transfer_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/transfer_list.qwe
let transfer_list_demo_perms = [ { label: "Read posts", value: "posts.read" }, { label: "Write posts", value: "posts.write" }, { label: "Delete posts", value: "posts.delete" }, { label: "Read comments", value: "comments.read" }, { label: "Moderate comments", value: "comments.moderate" }, { label: "Manage users", value: "users.manage" }, { label: "Invite users", value: "users.invite" }, { label: "View billing", value: "billing.read" }, { label: "Change billing", value: "billing.write" }, { label: "Export data", value: "data.export" }, { label: "View audit log", value: "audit.read" }, { label: "Manage API keys", value: "keys.manage" },]component TransferListDemoLive() { state cols = ["Name", "Email"] <div.demo-stack> <TransferList id="tl-cols" label="Columns to show" available_label="Hidden" chosen_label="Shown" search={false} height="170px" value={cols} options={["Name", "Email", "Company", "Plan", "Signed up", "Last seen", "Country"]} on_change={(vs) => { cols = vs }}/> <p.demo-note>Showing: {join(cols, ", ")}</p> </div>}view TransferListDemo() = <div.demo-stack> <TransferList id="tl-perms" name="permissions" label="Editor role permissions" options={transfer_list_demo_perms} value={["posts.read", "posts.write", "comments.read"]} hint="Select with a click, Space or Shift; move with the buttons, Enter or a double click."/> <TransferListDemoLive/> </div>Source
ui/transfer_list.qwe
// TransferList: two lists side by side, what is available and what is chosen, to// move options between: permissions for a role, columns to show, members of a team.// Each list can be filtered, holds several selected at once and moves them with// the buttons between, Enter or a double click. A <form> gets the chosen values,// one `name` field each.//// <TransferList id="perms" name="permissions" label="Permissions" value={["read"]}// options={[{ label: "Read posts", value: "read" }, { label: "Write posts", value: "write" }]}/>//// id: a prefix for its ids, unique on the page. options: strings, or maps of// { label, value }. value: the values chosen to start with. name: what a form gets// them as. label: the whole control's name; available_label ("Available") and// chosen_label ("Chosen") head the two lists. search (true): a box above each list// that keeps the options whose label has what is typed. height: the lists' CSS// height ("240px"). hint: a line below.// In a list: the arrow keys move, Space selects, Shift with an arrow or a click// selects a run, Ctrl (or ⌘) + A selects every option shown, and Enter moves what// is selected to the other list. A click selects or unselects one option.// on_change(values): inside one of your components, called with the values// chosen, in the options' order.import ui/theme.qwe use ui_clslet transfer_list_css = css`.ui-tl { display: grid; gap: 8px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-tl-label { margin: 0; font-size: 13px; font-weight: 500; }.ui-tl-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-tl-body { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 10px; align-items: center; }.ui-tl-side { display: grid; grid-template-rows: auto auto 1fr; min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overflow: hidden; align-self: stretch; }.ui-tl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.ui-tl-title { margin: 0; font-size: 13px; font-weight: 600; }.ui-tl-count { font-size: 12px; color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-tl-filter { box-sizing: border-box; width: calc(100% - 16px); height: 30px; margin: 8px 8px 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-font); }.ui-tl-filter::placeholder { color: var(--ui-muted); opacity: 1; }.ui-tl-filter:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-tl-list { margin: 0; padding: 6px; list-style: none; overflow: auto; min-height: 120px; outline: none; }.ui-tl-list:focus-visible { box-shadow: inset var(--ui-focus); }.ui-tl-opt { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 4px; cursor: pointer; user-select: none; -webkit-user-select: none; }.ui-tl-opt:hover { background: var(--ui-subtle); }.ui-tl-opt[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-tl-list:focus-visible .ui-tl-active { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-tl-box { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border: 1.5px solid var(--ui-strong); border-radius: 4px; background: var(--ui-surface); }.ui-tl-box svg { width: 10px; height: 10px; opacity: 0; }[aria-selected="true"] > .ui-tl-box { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }[aria-selected="true"] > .ui-tl-box svg { opacity: 1; }.ui-tl-none { padding: 14px 8px; color: var(--ui-muted); font-size: 13px; text-align: center; }.ui-tl-moves { display: grid; gap: 6px; }.ui-tl-moves button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 32px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); cursor: pointer; }.ui-tl-moves button:hover:not(:disabled) { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-tl-moves button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-tl-moves button:disabled { opacity: .45; cursor: not-allowed; }.ui-tl-moves svg { width: 16px; height: 16px; }@media (max-width: 560px) { .ui-tl-body { grid-template-columns: minmax(0, 1fr); } .ui-tl-moves { grid-auto-flow: column; justify-content: center; } .ui-tl-moves svg { transform: rotate(90deg); }}`// Options as maps of { label, value, i } (i: its place among them), from strings or maps.fn transfer_list_options(options: list) -> list = for [i, o] in enumerate(options) => type_of(o) == "map" ? { label: o.label ?? str(o.value), value: o.value ?? o.label, i } : { label: str(o), value: o, i }// The option an arrow key, Home or End goes to from option `at` (-1 for none) of n,// stopping at the ends, or null for another key or an empty list.fn transfer_list_key(key: str, at: int, n: int) -> int? = n == 0 ? null : match key { "ArrowDown" => at + 1 < n ? at + 1 : n - 1 "ArrowUp" => at > 0 ? at - 1 : 0 "Home" => 0 "End" => n - 1 _ => null}// The options selected after a Shift+click or Shift+arrow: those already selected, and// every one shown from `from` (where the keys were) to `to`, by i.fn transfer_list_span(chosen: list, ids: list, from: int?, to: int) -> list { let a = index_of(ids, from ?? to) ?? 0 let b = index_of(ids, to) ?? 0 unique(sort(chosen + slice(ids, min([a, b]), max([a, b]) + 1)))}// The SVG path of the move buttons' arrows, by what each does.fn transfer_list_arrow(kind: str) -> str = match kind { "add" => "M6 3l5 5-5 5" "add_all" => "M3 3l5 5-5 5M8 3l5 5-5 5" "remove" => "M10 3L5 8l5 5" _ => "M13 3L8 8l5 5M8 3L3 8l5 5"}component TransferList(id: str, options: list = [], value: list = [], name: str? = null, label: str? = null, available_label: str = "Available", chosen_label: str = "Chosen", search: bool = true, height: str = "240px", hint: str? = null, on_change = null, class: str? = null) { style global transfer_list_css let opts = transfer_list_options(options) state picked = transfer_list_options(options) | filter((o) => index_of(value, o.value) != null) | map((o) => o.i) state sel = { left: [], right: [] } // the options selected in each list, by i state active = { left: null, right: null } // the option the keys are on, by i state query = { left: "", right: "" } state said = "" let on_side = (s: str) => opts | filter((o) => (index_of(picked, o.i) != null) == (s == "right")) let shown_of = (s: str) => { let needle = lower(trim(query[s])) on_side(s) | filter((o) => needle == "" || contains(lower(o.label), needle)) } let left_all = on_side("left") let right_all = on_side("right") let left_shown = shown_of("left") let right_shown = shown_of("right") let shown = (s: str) => s == "left" ? left_shown : right_shown let title = (s: str) => s == "left" ? available_label : chosen_label let chosen_values = opts | filter((o) => index_of(picked, o.i) != null) | map((o) => o.value) let set_side = (m: map, s: str, v) => s == "left" ? { left: v, right: m.right } : { left: m.left, right: v } // Moves the given options (by i) out of side s, into the other. let move = (s: str, which: list) => { if len(which) > 0 { let before = shown(s) | map((o) => o.i) let rest = before | filter((i) => index_of(which, i) == null) let at = index_of(before, active[s] ?? which[0]) ?? 0 picked = s == "left" ? sort(picked + which) : picked | filter((i) => index_of(which, i) == null) sel = { left: [], right: [] } let later = slice(before, at + 1, len(before)) | filter((i) => index_of(which, i) == null) active = set_side(set_side(active, s, len(later) > 0 ? later[0] : last(rest)), s == "left" ? "right" : "left", which[0]) let n = len(which) said = `${s == "left" ? "Added" : "Removed"} ${n == 1 ? (opts[which[0]].label) : `${n} options`}${s == "left" ? ` to ${chosen_label}` : ` from ${chosen_label}`}` on_change && on_change(opts | filter((o) => index_of(picked, o.i) != null) | map((o) => o.value)) } } let move_selected = (s: str) => { let mine = sel[s] | filter((i) => any(shown(s), (o) => o.i == i)) move(s, len(mine) > 0 ? mine : []) } let toggle = (s: str, i: int) => { sel = set_side(sel, s, index_of(sel[s], i) != null ? sel[s] | filter((x) => x != i) : sort(sel[s] + [i])) } let run = (s: str, from: int?, to: int) => { sel = set_side(sel, s, transfer_list_span(sel[s], shown(s) | map((o) => o.i), from, to)) } let reveal = (e, i: int) => { let el = e.target.closest(".ui-tl").querySelector(`#${id}-opt-${i}`) el && el.scrollIntoView({ block: "nearest" }) } let icon = (kind: str) => <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={transfer_list_arrow(kind)}/></svg> <div class={ui_cls(["ui-tl", class])} role="group" aria-labelledby={label != null ? `${id}-label` : null} aria-describedby={hint != null ? `${id}-hint` : null}> {label != null && <p.ui-tl-label id={`${id}-label`}>{label}</p>} <div.ui-tl-body> {for s in ["left", "right"] => <div.ui-tl-side key={s} style={`order: ${s == "left" ? 0 : 2}`}> <div.ui-tl-head> <p.ui-tl-title id={`${id}-${s}-title`}>{title(s)}</p> <span.ui-tl-count> {len(sel[s]) > 0 ? `${len(sel[s])} of ${len(s == "left" ? left_all : right_all)} selected` : `${len(s == "left" ? left_all : right_all)}`} </span> </div> {search && <input.ui-tl-filter type="search" placeholder="Filter" value={query[s]} aria-label={`Filter ${lower(title(s))}`} aria-controls={`${id}-${s}-list`} @input={(e) => { query = set_side(query, s, e.value) }}/>} <ul.ui-tl-list id={`${id}-${s}-list`} role="listbox" aria-multiselectable="true" tabindex="0" aria-labelledby={`${id}-${s}-title`} style={`height: ${height}`} aria-activedescendant={active[s] != null && any(shown(s), (o) => o.i == active[s]) ? `${id}-opt-${active[s]}` : null} @focus={() => { if active[s] == null || !any(shown(s), (o) => o.i == active[s]) { active = set_side(active, s, len(shown(s)) > 0 ? shown(s)[0].i : null) } }} @keydown={(e) => { let ids = shown(s) | map((o) => o.i) let n = len(ids) let at = index_of(ids, active[s]) ?? -1 // Where the keys go is set before anything that can let a second key, sent // at once, in first (on some Quillo versions a `match`, a block or a call // to a function of the component's own), so it starts from this one's. let to = transfer_list_key(e.key, at, n) if to != null { e.preventDefault() let from = active[s] active = set_side(active, s, ids[to]) if e.shiftKey { sel = set_side(sel, s, transfer_list_span(sel[s], ids, from, ids[to])) } reveal(e, ids[to]) } else if e.key == " " && at >= 0 { e.preventDefault() toggle(s, ids[at]) } else if e.key == "Enter" { e.preventDefault() move(s, len(sel[s]) > 0 ? sel[s] : (at >= 0 ? [ids[at]] : [])) } else if lower(e.key) == "a" && (e.ctrlKey || e.metaKey) { e.preventDefault() sel = set_side(sel, s, len(sel[s]) == n ? [] : ids) } }}> {for o in shown(s) => <li.ui-tl-opt key={o.i} id={`${id}-opt-${o.i}`} role="option" class={active[s] == o.i ? "ui-tl-active" : null} aria-selected={index_of(sel[s], o.i) != null ? "true" : "false"} @mousedown={(e) => { if e.shiftKey { e.preventDefault() } }} @click={(e) => { if e.shiftKey { run(s, active[s], o.i) } else { toggle(s, o.i) } active = set_side(active, s, o.i) }} @dblclick={() => { move(s, [o.i]) }}> <span.ui-tl-box aria-hidden="true"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5.2l2 2 4-4.4"/></svg></span> <span>{o.label}</span> </li>} {len(shown(s)) == 0 && <li.ui-tl-none role="presentation"> {len(s == "left" ? left_all : right_all) == 0 ? "None" : "No matches"} </li>} </ul> </div>} <div.ui-tl-moves style="order: 1"> <button type="button" aria-label={`Add selected to ${lower(chosen_label)}`} title="Add selected" disabled={len(sel.left) == 0} @click={() => { move_selected("left") }}>{icon("add")}</button> <button type="button" aria-label={`Add all shown to ${lower(chosen_label)}`} title="Add all" disabled={len(left_shown) == 0} @click={() => { move("left", left_shown | map((o) => o.i)) }}>{icon("add_all")}</button> <button type="button" aria-label={`Remove selected from ${lower(chosen_label)}`} title="Remove selected" disabled={len(sel.right) == 0} @click={() => { move_selected("right") }}>{icon("remove")}</button> <button type="button" aria-label={`Remove all shown from ${lower(chosen_label)}`} title="Remove all" disabled={len(right_shown) == 0} @click={() => { move("right", right_shown | map((o) => o.i)) }}>{icon("remove_all")}</button> </div> </div> {hint != null && <p.ui-tl-hint id={`${id}-hint`}>{hint}</p>} {name != null && (for v in chosen_values => <input type="hidden" name={name} value={v}/>)} <span.ui-sr aria-live="polite">{said}</span> </div>}Related
BirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 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 downloadsChecklistInputCheckboxes under a progress line that says how many are done and fills as they are ticked; each posts with a form.5 views · 7 downloadsChipGroupChips to pick one or several of, like filters: real radios or checkboxes that a form sends, with counts.6 views · 4 downloadsComboboxA text box that filters a list of options as you type, or asks for them (a server, a map service), with the arrow keys, Enter and Escape.5 views · 6 downloadsCountryPickerA drop-down of every country with its flag and ISO code, favourites first, sending the two-letter code with a form.5 views · 7 downloads
Thread
Thread 0 posts
On TransferList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.