# Kanban board

Source: https://signy.dev/examples/kanban/

Drag cards between columns, or move them with the keyboard.

The board is a store with one array of cards per column. Dragging a card, or pressing the arrow buttons, moves it from one array to another. Each card has a menu that closes on a click outside it.

## Preview

[Open the page on its own](https://signy.dev/examples/kanban/demo/index.html.md)

## Code

Save it as an `.html` file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

**Show the full page**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Kanban board - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      .board {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
        gap: 1rem;
      }

      .column {
        min-height: 12rem;
        padding: 0.75rem;
        border: 2px dashed transparent;
        border-radius: var(--i-border-radius-large);
        background: var(--i-color-subtle-border);
      }

      .column.over {
        border-color: var(--i-color-primary);
      }

      .column ul {
        padding: 0;
        list-style: none;
      }

      .kanban-card {
        position: relative;
        padding: 0.75rem;
        margin-bottom: 0.5rem;
        border-radius: var(--i-border-radius);
        background: var(--i-color-background);
        cursor: grab;
      }

      .kanban-card .cluster {
        align-items: center;
        margin-top: 0.5rem;
      }

      .kanban-card button {
        margin: 0;
      }

      .card-menu ul {
        display: grid;
        gap: 0.25rem;
        margin: 0;
        list-style: none;
        position: absolute;
        inset-inline-end: 0.5rem;
        z-index: 1;
        padding: 0.25rem;
        border-radius: var(--i-border-radius);
        background: var(--i-color-background);
        box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <main class="container" id="app"></main>

    <script>
      const { Component, Signal, application, html, store, produce, effect, For, Show, DOM, createRef } = Signy;

      const STORAGE_KEY = "signy-kanban";
      const START = [
        { id: "todo", title: "To do", cards: [{ id: 1, text: "Write the release notes" }, { id: 2, text: "Plan the demo" }] },
        { id: "doing", title: "Doing", cards: [{ id: 3, text: "Fix the router guards" }] },
        { id: "done", title: "Done", cards: [{ id: 4, text: "Ship the website" }] },
      ];

      function load() {
        try {
          return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? START;
        } catch {
          return START;
        }
      }

      class Kanban extends Component {
        board = store({ columns: load() });
        nextId = Math.max(0, ...this.board.state.columns.flatMap((c) => c.cards.map((card) => card.id))) + 1;
        over = new Signal(null);
        menuFor = new Signal(null);
        menus = new Map();
        editing = new Signal(null);
        dialog = createRef();
        editor = createRef();

        setup() {
          // The open card menu closes on a click anywhere else
          DOM.onClickOutside(() => this.menus.get(this.menuFor.peek()), () => (this.menuFor.value = null));
          // Reading the whole state tracks every card, so any change saves
          effect(() => {
            const json = JSON.stringify(this.board.state.columns);
            try {
              localStorage.setItem(STORAGE_KEY, json);
            } catch {}
          });
        }

        locate(cardId) {
          const columns = this.board.state.columns;
          for (let c = 0; c < columns.length; c++) {
            const index = columns[c].cards.findIndex((card) => card.id === cardId);
            if (index !== -1) return { column: c, index };
          }
          return null;
        }

        move(cardId, toColumn) {
          const from = this.locate(cardId);
          if (!from || from.column === toColumn) return;
          this.board.set("columns", produce((columns) => {
            const [card] = columns[from.column].cards.splice(from.index, 1);
            columns[toColumn].cards.push(card);
          }));
        }

        add(column) {
          const id = this.nextId++;
          this.board.set("columns", column, "cards", (cards) => [...cards, { id, text: "New card" }]);
          this.edit(id);
        }

        remove(cardId) {
          const at = this.locate(cardId);
          this.board.set("columns", at.column, "cards", (cards) => cards.filter((card) => card.id !== cardId));
          this.menuFor.value = null;
        }

        edit(cardId) {
          const at = this.locate(cardId);
          this.menuFor.value = null;
          this.editing.value = cardId;
          this.editor.current.value = this.board.state.columns[at.column].cards[at.index].text;
          this.dialog.current.showModal();
        }

        save(event) {
          event.preventDefault();
          const at = this.locate(this.editing.peek());
          const text = this.editor.current.value.trim();
          if (at && text) this.board.set("columns", at.column, "cards", at.index, "text", text);
          this.dialog.current.close();
        }

        // A card is a plain object, so its text is read through the store to stay live
        textOf(cardId) {
          const at = this.locate(cardId);
          return at ? this.board.state.columns[at.column].cards[at.index].text : "";
        }

        card(card, columnIndex) {
          const columns = this.board.state.columns;
          return html`
            <li class="kanban-card" draggable="true"
              onDragStart=${(e) => e.dataTransfer.setData("text/plain", String(card.id))}>
              ${() => this.textOf(card.id)}
              <div class="cluster">
                <button class="small outlined" aria-label=${() => `Move ${this.textOf(card.id)} to ${columns[columnIndex() - 1]?.title}`}
                  disabled=${() => columnIndex() === 0} onClick=${() => this.move(card.id, columnIndex() - 1)}>←</button>
                <button class="small outlined" aria-label=${() => `Move ${this.textOf(card.id)} to ${columns[columnIndex() + 1]?.title}`}
                  disabled=${() => columnIndex() === columns.length - 1} onClick=${() => this.move(card.id, columnIndex() + 1)}>→</button>
                <div class="card-menu" ref=${(el) => this.menus.set(card.id, el)}>
                  <button class="small outlined" aria-label=${() => `Options for ${this.textOf(card.id)}`}
                    aria-expanded=${() => String(this.menuFor.value === card.id)}
                    onClick=${() => this.menuFor.set((id) => (id === card.id ? null : card.id))}>⋯</button>
                  ${Show({
                    when: () => this.menuFor.value === card.id,
                    children: () => html`
                      <ul>
                        <li><button class="small" onClick=${() => this.edit(card.id)}>Edit</button></li>
                        <li><button class="small danger" onClick=${() => this.remove(card.id)}>Delete</button></li>
                      </ul>
                    `,
                  })}
                </div>
              </div>
            </li>
          `;
        }

        render() {
          return html`
            <h1>Kanban</h1>
            <p><small>Drag cards between columns, or use the arrow buttons.</small></p>
            <div class="board">
              ${For({
                each: () => this.board.state.columns,
                keyBy: "id",
                children: (column, columnIndex) => html`
                  <section class=${() => `column ${this.over.value === column.id ? "over" : ""}`}
                    aria-label=${column.title}
                    onDragOver=${(e) => {
                      e.preventDefault();
                      this.over.value = column.id;
                    }}
                    onDragLeave=${() => (this.over.value = null)}
                    onDrop=${(e) => {
                      e.preventDefault();
                      this.over.value = null;
                      this.move(Number(e.dataTransfer.getData("text/plain")), columnIndex());
                    }}>
                    <h2>${column.title} <small>${() => this.board.state.columns[columnIndex()].cards.length}</small></h2>
                    <ul>
                      ${For({
                        each: () => this.board.state.columns[columnIndex()].cards,
                        keyBy: "id",
                        children: (card) => this.card(card, columnIndex),
                      })}
                    </ul>
                    <button class="small secondary outlined" onClick=${() => this.add(columnIndex())}>Add a card</button>
                  </section>
                `,
              })}
            </div>

            <dialog ref=${this.dialog} aria-labelledby="edit-title">
              <article>
                <header><h2 id="edit-title">Edit card</h2></header>
                <form id="edit-form" onSubmit=${(e) => this.save(e)}>
                  <label for="card-text">Text</label>
                  <textarea id="card-text" ref=${this.editor} rows="3"></textarea>
                </form>
                <footer class="cluster">
                  <button class="secondary outlined" onClick=${() => this.dialog.current.close()}>Cancel</button>
                  <button class="primary" type="submit" form="edit-form">Save</button>
                </footer>
              </article>
            </dialog>
          `;
        }
      }

      application(Kanban).mount("#app");
    </script>
  </body>
</html>

```

## What it uses

- [Store](https://signy.dev/documentation/1/reactivity/store/index.html.md) with `produce()`
- [For](https://signy.dev/documentation/1/templates/for-and-index/index.html.md) per column
- [DOM.onClickOutside](https://signy.dev/documentation/1/components/dom-helpers/index.html.md)
- [Effect](https://signy.dev/documentation/1/reactivity/effects/index.html.md) that saves to localStorage
