KanbanBoard
Columns of cards moved by dragging, by keyboard (Space, the arrows, Space) or with each card's arrows.
Demo
Press Space to pick up a card, the arrow keys to move it, Space to put it down or Escape to put it back.
Backlog
3 cardsExport invoices as CSV
Dark mode for the dashboard
Follow the system setting.
Rate-limit the login form
In progress
2 / 2 cardsSearch across projects
Ada
Upgrade to Postgres 17
Grace
Review
1 cardsKeyboard shortcuts help
Done
0 cards- No cards
Backlog: 3 · In progress: 2 · Review: 1 · Done: 0
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/kanban_board.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/kanban_board.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/kanban_board.qwe
let kanban_board_demo_columns = [ { id: "backlog", title: "Backlog", cards: [ { id: "c1", title: "Export invoices as CSV", tags: ["billing"] }, { id: "c2", title: "Dark mode for the dashboard", note: "Follow the system setting.", tags: ["ui"] }, { id: "c3", title: "Rate-limit the login form", tags: ["security"] }, ] }, { id: "doing", title: "In progress", limit: 2, cards: [ { id: "c4", title: "Search across projects", note: "Ada", tags: ["search"] }, { id: "c5", title: "Upgrade to Postgres 17", note: "Grace", tags: ["ops"] }, ] }, { id: "review", title: "Review", cards: [ { id: "c6", title: "Keyboard shortcuts help", tags: ["ui", "a11y"] }, ] }, { id: "done", title: "Done", cards: [] },]component KanbanBoardDemoLive() { state cols = kanban_board_demo_columns <div.demo-stack> <KanbanBoard id="kb-sprint" label="Sprint 14" columns={cols} on_change={(next) => { cols = next }}/> <p.demo-note>{join(cols | map((c) => `${c.title}: ${len(c.cards)}`), " · ")}</p> </div>}view KanbanBoardDemo() = <KanbanBoardDemoLive/>Source
ui/kanban_board.qwe
// KanbanBoard: columns of cards (to do, doing, done) to move between columns and// up and down in them: by dragging, by keyboard, or with the arrows on each card.//// <KanbanBoard id="sprint" label="Sprint 14" on_change={(cols) => { board = cols }} columns={[// { id: "todo", title: "To do", cards: [{ id: "t1", title: "Write the docs", tags: ["docs"] }] },// { id: "doing", title: "Doing", limit: 3, cards: [] },// { id: "done", title: "Done", cards: [] },// ]}/>//// id: a prefix for its ids, unique on the page. columns: maps of { id, title, cards,// limit? }: limit is how many cards it should hold, and the column shows when it// has more. cards: maps of { id, title, note?, tags? }, ids unique on the board.// label: the board's name for screen readers.// With the keyboard: Tab reaches each card; Space or Enter picks it up, the arrow// keys move it (← → to another column, ↑ ↓ in its column), Space or Enter puts it// down and Escape puts it back. Not carrying one, the arrow keys go from card to// card. Each move is announced. The ‹ › on a card (on hover, and always on touch// screens) move it to the column before or after.// on_change(columns): inside one of your components, called with the columns, as// given but with the cards where they are now, after each move.import ui/theme.qwe use ui_clslet kanban_board_css = css`.ui-kb { min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-kb-cols { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 4px; scroll-snap-type: x proximity; }.ui-kb-col { flex: 1 0 220px; max-width: 360px; display: grid; gap: 8px; padding: 10px; border-radius: var(--ui-radius); background: var(--ui-subtle); scroll-snap-align: start; }.ui-kb-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px; }.ui-kb .ui-kb-title { margin: 0; font-size: 13px; font-weight: 600; line-height: 1.4; }.ui-kb-count { min-width: 22px; padding: 1px 7px; border-radius: 999px; background: var(--ui-surface); color: var(--ui-muted); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }.ui-kb-over .ui-kb-count { background: var(--ui-warning-soft); color: var(--ui-warning); font-weight: 600; }.ui-kb-list { display: grid; gap: 8px; min-height: 64px; margin: 0; padding: 2px; list-style: none; border-radius: var(--ui-radius-sm); transition: background .15s; }.ui-kb-target { background: color-mix(in srgb, var(--ui-accent) 8%, transparent); }.ui-kb-end { box-shadow: inset 0 -3px 0 var(--ui-accent); }.ui-kb-empty { display: grid; place-items: center; min-height: 60px; border: 1.5px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); font-size: 13px; }.ui-kb-card { position: relative; display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: 0 1px 2px color-mix(in srgb, var(--ui-fg) 6%, transparent); cursor: grab; outline: none; transition: box-shadow .15s, transform .15s, opacity .15s; }.ui-kb-card:hover { border-color: var(--ui-strong); }.ui-kb-card:focus-visible { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-kb-dragging { opacity: .45; }.ui-kb-lifted { border-color: var(--ui-accent); box-shadow: var(--ui-focus), var(--ui-shadow); transform: rotate(-1deg) scale(1.02); cursor: grabbing; }.ui-kb-before::before { content: ""; position: absolute; left: 0; right: 0; top: -6px; height: 3px; border-radius: 2px; background: var(--ui-accent); }.ui-kb-card-title { margin: 0; padding-right: 48px; font-weight: 500; overflow-wrap: anywhere; }.ui-kb-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-kb-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-kb-tags li { padding: 1px 7px; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 11px; font-weight: 500; }.ui-kb-moves { position: absolute; top: 6px; right: 6px; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }.ui-kb-card:hover .ui-kb-moves, .ui-kb-card:focus-within .ui-kb-moves { opacity: 1; }.ui-kb-moves button { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--ui-muted); font: 16px/1 var(--ui-font); cursor: pointer; }.ui-kb-moves button:hover:not(:disabled) { background: var(--ui-subtle); color: var(--ui-fg); }.ui-kb-moves button:disabled { visibility: hidden; }@media (hover: none) { .ui-kb-moves { opacity: 1; } .ui-kb-moves button { width: 30px; height: 30px; } .ui-kb-card-title { padding-right: 64px; } }@media (prefers-reduced-motion: reduce) { .ui-kb-card, .ui-kb-list, .ui-kb-moves { transition: none; } .ui-kb-lifted { transform: none; } }`// Where a card is: { c, i }, its column's and its own place; null if it is not there.fn kanban_board_find(cols: list, card) { let c = index_of(cols | map((col) => any(col.cards ?? [], (x) => x.id == card)), true) c == null ? null : { c, i: index_of(cols[c].cards | map((x) => x.id), card) }}// The columns with the card moved into column `to`, at place `at` (counted without it).fn kanban_board_move(cols: list, card, to: int, at: int) -> list { let from = kanban_board_find(cols, card) if from == null { return cols } let it = cols[from.c].cards[from.i] let without = for [k, col] in enumerate(cols) => set(col, "cards", col.cards | filter((x) => x.id != card)) for [k, col] in enumerate(without) => k != to ? col : set(col, "cards", slice(col.cards, 0, at) + [it] + slice(col.cards, at, len(col.cards)))}component KanbanBoard(id: str, columns: list = [], label: str? = null, on_change = null, class: str? = null) { style global kanban_board_css state cols = columns | map((col) => set(col, "cards", col.cards ?? [])) state dragging = null // the id of the card being dragged state over = null // where it would go: { c, at } state lifted = null // the id of the card carried by keyboard state origin = null // where it was picked up: the columns then state said = "" state board = null // the board's own element, from its @mount let help = `${id}-help` // Not from the event: a handler that has waited (a call to one of the component's own // functions may) has already redrawn, and the card that had the key is no longer on the page. let focus = (card) => { js("queueMicrotask", () => { let el = board.querySelector(`[data-card="${card}"]`) el && el.focus() }) } let place = (next: list, card) => { let p = kanban_board_find(next, card) `${next[p.c].title}, position ${p.i + 1} of ${len(next[p.c].cards)}` } let title_of = (card) => { let p = kanban_board_find(cols, card) p == null ? "" : cols[p.c].cards[p.i].title } // Moves a card for good: from a drop, a button or a keyboard drop. let commit = (next: list, card) => { cols = next said = `${title_of(card)} moved to ${place(next, card)}.` on_change && on_change(next) } <div class={ui_cls(["ui-kb", class])} role="group" aria-label={label} @mount={(el) => { board = el }}> <p.ui-sr id={help}>Press Space to pick up a card, the arrow keys to move it, Space to put it down or Escape to put it back.</p> <div.ui-kb-cols> {for [ci, col] in enumerate(cols) => <section key={col.id} class={ui_cls(["ui-kb-col", col.limit != null && len(col.cards) > col.limit && "ui-kb-over"])} aria-labelledby={`${id}-col-${ci}`}> <div.ui-kb-head> <h3.ui-kb-title id={`${id}-col-${ci}`}>{col.title}</h3> <span.ui-kb-count> {col.limit != null ? `${len(col.cards)} / ${col.limit}` : `${len(col.cards)}`} <span.ui-sr>{col.limit != null && len(col.cards) > col.limit ? " cards, over the limit" : " cards"}</span> </span> </div> <ul class={ui_cls(["ui-kb-list", dragging != null && over != null && over.c == ci && "ui-kb-target", dragging != null && over != null && over.c == ci && over.at == len(col.cards) && len(col.cards) > 0 && "ui-kb-end"])} @dragover={(e) => { if dragging != null { e.preventDefault() if e.target.closest(".ui-kb-card") == null { let at = len(col.cards) if over == null || over.c != ci || over.at != at { over = { c: ci, at } } } } }} @drop={(e) => { e.preventDefault() if dragging != null && over != null { let card = dragging let p = kanban_board_find(cols, card) // A place counted with the card still there, made one less below it in its own column. let at = p.c == over.c && p.i < over.at ? over.at - 1 : over.at let next = kanban_board_move(cols, card, over.c, at) dragging = null over = null if next != cols { commit(next, card) } } }}> {len(col.cards) == 0 && <li.ui-kb-empty role="presentation">{dragging != null || lifted != null ? "Drop here" : "No cards"}</li>} {for [i, card] in enumerate(col.cards) => <li key={card.id} data-card={card.id} tabindex="0" draggable="true" aria-describedby={help} aria-roledescription="card" aria-label={lifted == card.id ? `${card.title}, picked up` : null} class={ui_cls(["ui-kb-card", dragging == card.id && "ui-kb-dragging", lifted == card.id && "ui-kb-lifted", dragging != null && over != null && over.c == ci && over.at == i && "ui-kb-before"])} @dragstart={(e) => { dragging = card.id lifted = null e.dataTransfer.effectAllowed = "move" e.dataTransfer.setData("text/plain", str(card.id)) }} @dragend={() => { dragging = null over = null }} @dragover={(e) => { if dragging != null { e.preventDefault() let r = e.target.closest(".ui-kb-card").getBoundingClientRect() let at = e.clientY < r.top + r.height / 2 ? i : i + 1 if over == null || over.c != ci || over.at != at { over = { c: ci, at } } } }} @keydown={(e) => { let k = e.key if e.target.tagName != "LI" { null } else if lifted == card.id { let p = kanban_board_find(cols, card.id) let to = match k { "ArrowUp" => p.i > 0 ? { c: p.c, at: p.i - 1 } : null "ArrowDown" => p.i < len(cols[p.c].cards) - 1 ? { c: p.c, at: p.i + 1 } : null "ArrowLeft" => p.c > 0 ? { c: p.c - 1, at: min([p.i, len(cols[p.c - 1].cards)]) } : null "ArrowRight" => p.c < len(cols) - 1 ? { c: p.c + 1, at: min([p.i, len(cols[p.c + 1].cards)]) } : null _ => null } if to != null { e.preventDefault() cols = kanban_board_move(cols, card.id, to.c, to.at) said = place(cols, card.id) focus(card.id) } else if k == " " || k == "Enter" { e.preventDefault() lifted = null if cols != origin { commit(cols, card.id) } else { said = `${card.title} put down where it was.` } } else if k == "Escape" { e.preventDefault() cols = origin lifted = null said = `${card.title} put back in ${place(origin, card.id)}.` focus(card.id) } else if k == "Tab" { e.preventDefault() } } else if k == " " || k == "Enter" { e.preventDefault() lifted = card.id origin = cols said = `${card.title} picked up, in ${place(cols, card.id)}.` } else { let to = match k { "ArrowUp" => i > 0 ? cols[ci].cards[i - 1] : null "ArrowDown" => cols[ci].cards[i + 1] "ArrowLeft" => ci > 0 ? (cols[ci - 1].cards[min([i, len(cols[ci - 1].cards) - 1])]) : null "ArrowRight" => ci < len(cols) - 1 ? (cols[ci + 1].cards[min([i, len(cols[ci + 1].cards) - 1])]) : null _ => null } if to != null { e.preventDefault() focus(to.id) } } }} @blur={(e) => { // Leaving a card carried puts it down, unless the focus came back to it // (moving it to another column makes its element again). let doc = e.target.ownerDocument js("queueMicrotask", () => { let now = doc.activeElement if lifted == card.id && (now == null || now.dataset == null || now.dataset.card != str(card.id)) { lifted = null if cols != origin { commit(cols, card.id) } } }) }}> <p.ui-kb-card-title>{card.title}</p> {card.note != null && <p.ui-kb-note>{card.note}</p>} {len(card.tags ?? []) > 0 && <ul.ui-kb-tags aria-label="Tags">{for t in card.tags => <li>{t}</li>}</ul>} <div.ui-kb-moves> <button type="button" tabindex="-1" disabled={ci == 0} aria-label={ci > 0 ? `Move ${card.title} to ${cols[ci - 1].title}` : "Move back"} @click={(e) => { commit(kanban_board_move(cols, card.id, ci - 1, len(cols[ci - 1].cards)), card.id) focus(card.id) }}>‹</button> <button type="button" tabindex="-1" disabled={ci == len(cols) - 1} aria-label={ci < len(cols) - 1 ? `Move ${card.title} to ${cols[ci + 1].title}` : "Move on"} @click={(e) => { commit(kanban_board_move(cols, card.id, ci + 1, len(cols[ci + 1].cards)), card.id) focus(card.id) }}>›</button> </div> </li>} </ul> </section>} </div> <p.ui-sr aria-live="assertive">{said}</p> </div>}Related
ActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsChangelogReleases with version, date and label, their changes grouped by kind: Added, Changed, Fixed, Security and the rest.6 views · 7 downloadsDataListRecords as rows with a title, a line of facts, a status badge and actions as links or buttons.6 views · 8 downloadsDescriptionListTerms and their details, stacked or in two columns that stack on phones, on a real <dl>.5 views · 7 downloadsFileListFiles as a table or a grid, with kind icons, sizes and dates, sorted, picked, downloaded or deleted.7 views · 6 downloadsLeaderboardPeople ranked by score with medals for the top three, shared places for ties, and your own row kept in view.7 views · 7 downloads
Thread
Thread 0 posts
On KanbanBoard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.