Forms

TransferList

Two filterable lists to move options between, several at a time, by mouse or keyboard.

#forms#select#list#interactive

Demo

Preview

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
  • Email

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.qwe
main.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>}
Thread
55Useful? Like it, or add to its thread below.

Thread 0 posts

On TransferList: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.