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>

Next: The simple way →