Data

SelectableTable

Rows with checkboxes, a select-all that shows when only some are checked, a count of the selected, and hidden inputs for a form.

#data#table#forms#interactive

Demo

Preview
NameEmailRoleSeats
Ada Lovelaceada@example.comOwner12
Grace Hoppergrace@example.comAdmin4
Katherine Johnsonkatherine@example.comMember1
Hedy Lamarrhedy@example.comMember2
Margaret Hamiltonmargaret@example.comAdmin7
1 of 5 selected

on_change: 3

NameRole
Ada LovelaceOwner
Grace HopperAdmin
Katherine JohnsonMember
Hedy LamarrMember
0 of 4 selected (up to 2)

The second table takes at most two; the rest are disabled once two are checked. Shift+click checks a range.

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/selectable_table.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/selectable_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/selectable_table.qwe
let selectable_table_demo_users = [    { id: 1, name: "Ada Lovelace", email: "ada@example.com", role: "Owner", seats: 12 },    { id: 2, name: "Grace Hopper", email: "grace@example.com", role: "Admin", seats: 4 },    { id: 3, name: "Katherine Johnson", email: "katherine@example.com", role: "Member", seats: 1 },    { id: 4, name: "Hedy Lamarr", email: "hedy@example.com", role: "Member", seats: 2 },    { id: 5, name: "Margaret Hamilton", email: "margaret@example.com", role: "Admin", seats: 7 },]component SelectableTableDemoLive() {    state picked = ["3"]    <div.demo-stack>        <SelectableTable id="selectable-table-demo-users" label="Users" name="user" rows={selectable_table_demo_users} selected={[3]}                         columns={[{ key: "name", label: "Name" }, { key: "email", label: "Email" }, { key: "role", label: "Role" }, { key: "seats", label: "Seats", format: "number" }]}                         on_change={(ids) => { picked = ids }}/>        <p.demo-note>on_change: {len(picked) == 0 ? "nothing" : join(picked, ", ")}</p>    </div>}view SelectableTableDemo() =    <div.demo-stack>        <SelectableTableDemoLive/>        <SelectableTable id="selectable-table-demo-limit" label="Reviewers" rows={take(selectable_table_demo_users, 4)} limit={2} zebra={true}                         columns={[{ key: "name", label: "Name" }, { key: "role", label: "Role" }]}/>        <p.demo-note>The second table takes at most two; the rest are disabled once two are checked. Shift+click checks a range.</p>    </div>

Source

ui/selectable_table.qwe
// SelectableTable: rows with a checkbox each, one in the header that checks them// all (half-checked when some are), and a line saying how many are picked: the// table before a bulk action (delete, export, assign).////   <SelectableTable id="users" label="Users" name="user" rows={users} selected={[3]}//                    columns={[{ key: "name", label: "Name" }, { key: "email", label: "Email" }]}//                    on_change={(ids) => { picked = ids }}/>//// rows: maps with the columns' fields and an id (unique; the row's place by// default). columns: { key, label, align?, format?, currency? } as in DataTable.// selected: the ids checked at first. name: a <form> gets each checked id as a// field of that name. label: the table's name for screen readers; it names the// checkboxes too ("Select Ada Lovelace" from the first column). A click on a row// checks it, Shift+click checks every row from the last one clicked. Clear, in the// count line, unchecks all. limit: at most this many checked (the rest are// disabled). zebra: shades every other row. on_change(ids): inside one of your// components, called with the ids checked, in the rows' order.import ui/theme.qwe use ui_clslet selectable_table_css = css`.ui-selectable { display: grid; gap: 8px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-selectable-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-selectable table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-selectable th, .ui-selectable td { padding: 8px 14px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ui-border); }.ui-selectable thead th { background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-selectable tbody tr:last-child td { border-bottom: 0; }.ui-selectable tbody tr { cursor: pointer; }.ui-selectable tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-selectable tbody tr[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-selectable-zebra tbody tr:nth-child(even):not([aria-selected="true"]) td { background: color-mix(in srgb, var(--ui-subtle) 55%, transparent); }.ui-selectable .ui-selectable-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-selectable-box { width: 1%; padding-right: 4px; }.ui-selectable input { width: 16px; height: 16px; margin: 0; vertical-align: middle; accent-color: var(--ui-accent); cursor: pointer; }.ui-selectable input:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: 3px; }.ui-selectable input:disabled { cursor: not-allowed; opacity: .5; }.ui-selectable-count { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 24px; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-selectable-count b { color: var(--ui-fg); font-weight: 600; }.ui-selectable-clear { height: 24px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-selectable-clear:hover { background: var(--ui-accent-soft); }.ui-selectable-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-selectable-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); cursor: default; }`fn selectable_table_fmt(v, format: str? = null, currency: str = "$") -> str {    if v == null { return "—" }    if format == "date" { return type_of(v) == "str" && date.valid(v) ? date.format(v, "MMM D, YYYY") : str(v) }    if type_of(v) != "int" && type_of(v) != "float" { return str(v) }    let sign = v < 0 ? "-" : ""    match format {        "money" => `${sign}${currency}${format_number(abs(v), "en-US", 2)}`        "percent" => `${format_number(round(v * 1000) / 10, "en-US")}%`        _ => format_number(v, "en-US")    }}fn selectable_table_right(c: map) -> bool =    c.align == "right" || (c.align == null && (c.format == "number" || c.format == "money" || c.format == "percent"))component SelectableTable(id: str, columns: list = [], rows: list = [], selected: list = [], name: str? = null,                          label: str? = null, limit: int? = null, zebra: bool = false, empty: str = "Nothing to show.",                          on_change = null, class: str? = null) {    style global selectable_table_css    let ids = for [i, r] in enumerate(rows) => r.id != null ? str(r.id) : str(i)    state picked = selected | map((x) => str(x)) | filter((x) => index_of(ids, x) != null)    state last = null         // the row last clicked, for Shift+click    state head = null         // the header's checkbox, once on the page: half-checked when some rows are    let n = len(rows)    let k = len(picked)    let full = limit != null && k >= limit    let all = n > 0 && (k == n || full)    let some = k > 0 && !all    if head != null { head.indeterminate = some }    let set_to = (next: list) => {        picked = ids | filter((x) => index_of(next, x) != null)        on_change && on_change(picked)    }    let flip = (i: int, shift: bool) => {        let rid = ids[i]        let on = index_of(picked, rid) != null        if shift && last != null && last != i {            let lo = min([last, i])            let hi = max([last, i])            let span = slice(ids, lo, hi + 1)            let next = on ? picked | filter((x) => index_of(span, x) == null) : unique(picked + span)            set_to(limit != null && len(next) > limit ? take(next, limit) : next)        } else if on {            set_to(picked | filter((x) => x != rid))        } else if !full {            set_to(push(picked, rid))        }        last = i    }    let first_text = (r: map) => columns[0] != null ? selectable_table_fmt(r[columns[0].key], columns[0].format, columns[0].currency ?? "$") : ""    <div class={ui_cls(["ui-selectable", zebra && "ui-selectable-zebra", class])} id={id}>        <div.ui-selectable-scroll>            <table aria-label={label} aria-multiselectable="true">                <thead>                    <tr>                        <th scope="col" class="ui-selectable-box">                            <input type="checkbox" checked={all} aria-checked={some ? "mixed" : (all ? "true" : "false")}                                   aria-label={all ? "Unselect all rows" : "Select all rows"} disabled={n == 0}                                   @mount={(el) => { head = el }}                                   @change={() => { set_to(all ? [] : (limit != null ? take(ids, limit) : ids)); last = null }}/>                        </th>                        {for c in columns => <th scope="col" class={selectable_table_right(c) ? "ui-selectable-right" : null}>{c.label ?? c.key}</th>}                    </tr>                </thead>                <tbody>                    {n == 0 && <tr.ui-selectable-empty><td colspan={str(len(columns) + 1)}>{empty}</td></tr>}                    {for [i, r] in enumerate(rows) => {                        let rid = ids[i]                        let on = index_of(picked, rid) != null                        <tr key={rid} data-id={rid} aria-selected={on ? "true" : "false"}                            @click={(e) => {                                if e.target.closest("a, button, input, select, textarea, label") == null { flip(i, e.shiftKey == true) }                            }}>                            <td class="ui-selectable-box">                                <input type="checkbox" checked={on} disabled={!on && full} aria-label={`Select ${first_text(r)}`}                                       @click={(e) => { flip(i, e.shiftKey == true) }}/>                            </td>                            {for c in columns => <td class={selectable_table_right(c) ? "ui-selectable-right" : null}>                                {selectable_table_fmt(r[c.key], c.format, c.currency ?? "$")}                            </td>}                        </tr>                    }}                </tbody>            </table>        </div>        <div.ui-selectable-count>            <span role="status" aria-live="polite"><b>{k}</b> of {n} selected{limit != null && ` (up to ${limit})`}</span>            {k > 0 && <button.ui-selectable-clear type="button" @click={() => { set_to([]); last = null }}>Clear</button>}        </div>        {name != null && (for rid in picked => <input type="hidden" name={name} value={rid}/>)}    </div>}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.