Kanban#
A drag-and-drop board in plain HTML and JavaScript.
Four models, one per lane#
The board is one value. The modal holds three more — its interface, the card being edited, and the validation messages — each kept apart by lane and read from its own root with a bare val(). The errors sit in the same subtree as the form fields: the fields carry val-lane="form", their error root carries val-lane="error", and the two read apart with no wrapper between the value and its messages.
dom.one('[kanban-space]').val(); // { data: [ { title, cards: [ { title, color } ] } ] }
dom.one('[kanban-modal]').val(); // { anchor, mode, hidden }
dom.one('[kanban-form]').val(); // { title, color }
dom.one('[kanban-errors]').val(); // { title, color } — messages
The board value is ready to log, save, or send to a server; the modal's three models never leak into it.
Drag-and-drop#
Drag-and-drop is SortableJS, wrapped in an <s-sortable> element so a list drags as soon as it connects. It moves the same DOM nodes Saradom reads, so dropping a card in another column recomputes that column's count — the order is already in the DOM, with no virtual tree to reconcile. The boundary attribute keeps two boards on one page apart: a card moves only between lists under the same [kanban-space].
Uses: Util.js, Val.js, Vld.js, SCompute.js, SSortable.js, SInit.js, Sortable.min.js
<div kanban-space val-lane="value">
<div kanban-columns val val-fx="Arr" val-key="data" val-tpl="[kanban-column-tpl]"></div>
<template kanban-column-tpl>
<s-compute run="() => this.one('[kanban-count]').textContent = this.all('[kanban-card]').length">
<div kanban-column>
<header>
<span val val-fx="Text" val-key="title"></span>
<span kanban-count></span>
</header>
<s-sortable kanban-cards group="kanban" boundary="[kanban-space]" val val-fx="Arr" val-key="cards" val-tpl="[kanban-card-tpl]"></s-sortable>
<button kanban-add onclick="(() => {
const space = this.up('[kanban-space]');
space.one('[kanban-modal]').val({ anchor: this.up('[kanban-column]').one('[kanban-cards]'), mode: 'add', hidden: false, focus: true });
space.one('[kanban-form]').val({});
space.one('[kanban-errors]').val({});
})()">+ Add card</button>
</div>
</s-compute>
</template>
<template kanban-card-tpl>
<article kanban-card onclick="(() => {
const space = this.up('[kanban-space]');
space.one('[kanban-modal]').val({ anchor: this, mode: 'edit', hidden: false, focus: true });
space.one('[kanban-form]').val(this.val());
space.one('[kanban-errors]').val({});
})()">
<i val val-fx="Pattr" val-key="color" val-attr="kanban-color" hidden></i>
<span kanban-card-title val val-fx="Text" val-key="title"></span>
</article>
</template>
<div kanban-modal val-lane="modal" hidden kanban-modal-mode="add">
<i val val-fx="Pprop" val-key="anchor" hidden></i>
<i val val-fx="Pflag" val-key="hidden" val-attr="hidden" hidden></i>
<i val val-fx="Pattr" val-key="mode" val-attr="kanban-modal-mode" hidden></i>
<i val val-set="(d) => d.focus && this.up('[kanban-modal]').one('[kanban-focus]').focus()" hidden></i>
<div onclick="this.up('[kanban-modal]').patch({ hidden: true })"></div>
<section kanban-form val-lane="form">
<div kanban-errors val-lane="error">
<input kanban-focus kanban-rule="() => ({ path: 'title', rules: [{ min: 2 }] })" val val-lane="form" val-fx="Input" val-key="title" type="text" placeholder="Title">
<div val val-fx="Text" val-key="title"></div>
<div kanban-color kanban-rule="() => ({ path: 'color', rules: [] })" val val-lane="form" val-fx="Select" val-key="color" val-from="kanban-option" val-mark="kanban-selected">
<button kanban-option="#8b5cf6" style="background:#8b5cf6" onclick="this.up('[kanban-color]').val({ color: this.attr('kanban-option') })"></button>
<button kanban-option="#5a80e6" style="background:#5a80e6" onclick="this.up('[kanban-color]').val({ color: this.attr('kanban-option') })"></button>
<button kanban-option="#2dd4bf" style="background:#2dd4bf" onclick="this.up('[kanban-color]').val({ color: this.attr('kanban-option') })"></button>
<button kanban-option="#f59e0b" style="background:#f59e0b" onclick="this.up('[kanban-color]').val({ color: this.attr('kanban-option') })"></button>
<button kanban-option="#f43f5e" style="background:#f43f5e" onclick="this.up('[kanban-color]').val({ color: this.attr('kanban-option') })"></button>
</div>
<div val val-fx="Text" val-key="color"></div>
</div>
<footer>
<button onclick="(async () => {
const space = this.up('[kanban-space]');
const form = space.one('[kanban-form]');
const modal = space.one('[kanban-modal]');
const rules = [ [...space.all('[kanban-rule]')].flatMap(el => eval(el.attr('kanban-rule'))()) ];
const value = form.val();
const errors = await Vld.Validate(rules, value);
space.one('[kanban-errors]').val(Vld.byPath(errors));
if (errors.length) return;
const { anchor, mode } = modal.val();
mode === 'edit' ? anchor.val(value) : anchor.vappend([value]);
modal.patch({ hidden: true });
})()">Save</button>
<button onclick="this.up('[kanban-modal]').patch({ hidden: true })">Cancel</button>
<button kanban-delete onclick="(() => {
const modal = this.up('[kanban-modal]');
if (!confirm('Delete this card?')) return;
modal.val().anchor.remove();
modal.patch({ hidden: true });
})()">Delete</button>
</footer>
</section>
</div>
<s-init run="(el) => el.val({
data: [
{ title: 'To Do', cards: [
{ title: 'Design landing page', color: '#8b5cf6' },
{ title: 'Write API docs', color: '#5a80e6' },
{ title: 'Fix login bug', color: '#f43f5e' },
] },
{ title: 'In Progress', cards: [
{ title: 'Refactor auth module', color: '#f59e0b' },
{ title: 'Add dark mode', color: '#2dd4bf' },
] },
{ title: 'Done', cards: [
{ title: 'Set up CI pipeline', color: '#2dd4bf' },
] },
],
})"></s-init>
</div>