Kanban

A drag-and-drop board in plain HTML and JavaScript. Cards move between columns, each column counts its cards, and a modal adds or edits a card with validation.

It is the largest example and uses six toolkits. Util for the DOM helpers. Val for the board's data, in four lanes. Vld for the card form's rules. Compute for the counts. Sortable for the drag. Init to fill the board in place.

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 a Val 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 Sortable: 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 through Compute — 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 →