All examples

Board with collapsible lanes

A board with swimlanes: one lane starts collapsed behind its disclosure while its cards still count toward the column limits, and every column-by-lane cell ends with an add button that knows its column and lane.

Board with collapsible lanes

data-board-lanes · data/board

data board
Props
  • cellFooter
  • collapsedLanes
  • lanes

Live example loads in an isolated same-origin preview frame.

Source (data-board-lanes/DataBoardLanesDemo.svelte)
<script lang="ts">
import { Board, type BoardLaneDef, type BoardMove, rankBetween } from '@texuli/data/board';

interface Issue {
  id: string;
  title: string;
  column: string;
  lane: string;
  rank: string;
}

const columns = [
  { id: 'todo', title: 'To do' },
  { id: 'doing', title: 'In progress', wip: { soft: 3, hard: 4 } },
  { id: 'done', title: 'Done' },
];
const lanes = [
  { id: 'web', title: 'Web' },
  { id: 'mobile', title: 'Mobile' },
];
// Fictional data only.
let issues = $state<Issue[]>([
  { id: 'i1', title: 'Sign-in redirect loop', column: 'todo', lane: 'web', rank: 'a0' },
  { id: 'i2', title: 'Dark mode contrast', column: 'doing', lane: 'web', rank: 'a0' },
  { id: 'i3', title: 'Offline drafts', column: 'doing', lane: 'mobile', rank: 'a0' },
  { id: 'i4', title: 'Push opt-in copy', column: 'doing', lane: 'mobile', rank: 'a1' },
  { id: 'i5', title: 'Tablet layout', column: 'done', lane: 'mobile', rank: 'a0' },
]);
let collapsedLanes = $state<string[]>(['mobile']);
let created = 0;

function onMove(move: BoardMove): void {
  const ranks = new Map((move.rerank ?? []).map((entry) => [entry.itemId, entry.rank]));
  issues = issues.map((issue) => {
    const at = { column: move.columnId, lane: move.laneId };
    if (issue.id === move.itemId) return { ...issue, ...at, rank: move.rank };
    const rank = ranks.get(issue.id);
    return rank === undefined ? issue : { ...issue, ...at, rank };
  });
}

function add(columnId: string, lane: BoardLaneDef): void {
  const last = issues
    .filter((issue) => issue.column === columnId && issue.lane === lane.id)
    .map((issue) => issue.rank)
    .sort()
    .at(-1);
  created += 1;
  issues = [
    ...issues,
    {
      id: `new-${created}`,
      title: `New ${lane.title.toLowerCase()} issue ${created}`,
      column: columnId,
      lane: lane.id,
      rank: rankBetween(last ?? null, null),
    },
  ];
}
</script>

<section class="demo" data-example="data-board-lanes" aria-labelledby="data-board-lanes-title">
  <h2 id="data-board-lanes-title">Release board</h2>
  <p>
    Mobile starts collapsed: its cards still count toward In progress, which has reached its soft
    limit. Open it from any column.
  </p>
  <Board
    {columns}
    {lanes}
    items={issues}
    bind:collapsedLanes
    getId={(issue) => issue.id}
    getColumn={(issue) => issue.column}
    getLane={(issue) => issue.lane}
    getRank={(issue) => issue.rank}
    getLabel={(issue) => issue.title}
    {onMove}
    labels={{ board: 'Release board columns' }}
  >
    {#snippet card(issue)}
      <strong>{issue.title}</strong>
    {/snippet}
    {#snippet cellFooter(column, lane)}
      <button
        type="button"
        class="demo-add"
        aria-label="Add to {column.title}, {lane.title}"
        onclick={() => add(column.id, lane)}>+ Add</button
      >
    {/snippet}
  </Board>
</section>

<style>
  .demo {
    display: grid;
    gap: 0.75rem;
    max-inline-size: 100%;
    padding: 1.5rem;
    color: var(--tx-foreground, CanvasText);
  }
  /* A grid item's automatic minimum is its content's min-content width. */
  .demo > * {
    min-inline-size: 0;
  }
  .demo h2,
  .demo p {
    margin: 0;
  }
  .demo-add {
    min-block-size: 2rem;
    padding-inline: 0.75rem;
    border: 1px dashed var(--tx-border-strong, ButtonBorder);
    border-radius: var(--tx-radius, 0.5rem);
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
  }
</style>