Lane Board

Lanes of cards that can be dragged within a lane and from one lane to another.

Dark previewReplay previewOpen in new tab

Queued

2
  1. Offline drafts
    REL-231
    2d
  2. Export to CSV
    REL-238
    5h

Building

5
  1. Faster search
    REL-224
    1d
  2. Invite by link
    REL-229
    3h
  3. Keyboard shortcuts
    REL-233
    40m
  4. Saved filters
    REL-236
    25m
  5. Bulk archive
    REL-240
    10m

In review

1
  1. Audit log
    REL-219
    6h

Shipped

2
  1. Two-step sign in
    REL-207
    Live
  2. Dark charts
    REL-212
    Live

Use it for things that move through a few stages: changes going into a release, tasks, orders. Each stage is a lane with its cards and a count, and a card is dragged to another place in its lane or into another lane.

The board does not keep the cards. You give it the lanes and it tells you what they should become when a card is set down, so the order on screen is always the order in your state.

The board has a fixed height. A lane with more cards than fit scrolls on its own, and on a narrow screen the lanes scroll sideways. For one list that only needs reordering, a sortable list is the simpler thing.

Installation

Copy the folder into components/ui, where you can edit it. It uses reka-ui's ScrollArea and the cn helper, both already in a shadcn-vue project, and the theme's info, success and warning colours.

text
components/ui/lane-board/
├── LaneBoard.vue
├── LaneBoardTitle.vue … LaneBoardAvatar.vue
├── board.ts
└── index.ts

Usage

vue
<script setup lang="ts">
import { ref } from "vue";
import { LaneBoard, LaneBoardCode, LaneBoardTitle, type LaneBoardLane } from "@/components/ui/lane-board";

const lanes = ref<LaneBoardLane<Change>[]>([
  { id: "building", title: "Building", cards: [{ id: "REL-224", title: "Faster search" }] },
  { id: "shipped", title: "Shipped", cards: [] },
]);
</script>

<template>
  <LaneBoard v-model:lanes="lanes">
    <template #card="{ card: change }">
      <LaneBoardTitle>{{ change.title }}</LaneBoardTitle>
      <LaneBoardCode>{{ change.id }}</LaneBoardCode>
    </template>
  </LaneBoard>
</template>

Composition

<LaneBoard v-model:lanes="…">
  <template #card="{ card, lane, carried }">
    <LaneBoardRow>
      <LaneBoardTitle />
      <LaneBoardAvatar />
    </LaneBoardRow>
    <LaneBoardCode />
    <LaneBoardRow>
      <LaneBoardSteps />
      <LaneBoardMeta />
    </LaneBoardRow>
  </template>
  <template #lane-footer="{ lane }" />
</LaneBoard>
  • LaneBoardTitle: a card's name.
  • LaneBoardCode: a card's reference, in a fixed-width face so a column of them lines up.
  • LaneBoardRow: a line of a card with something at each end. It is the last line unless you move it up, as with a title and an avatar.
  • LaneBoardSteps: how far along a card is: a row of dashes with value of max lit, in the theme colour named by color.
  • LaneBoardTag: a word in a pill, for a card's state.
  • LaneBoardMeta: a short figure at the end of the row: a time, a count.
  • LaneBoardAvatar: who a card belongs to, as initials in a small circle.

A fuller board

A lane can have a color, drawn as a dot before its title, and a limit: the count is written against it and turns to the warning colour once the lane holds more. The #lane-footer slot draws under a lane's cards, here a button that adds one. The cards are built from the same parts, with a tag for priority and the owner's initials.

Dark previewReplay previewOpen in new tab

Inbox

2
  1. Refund not showing
    NO
    SUP-481
    Urgent
  2. Change the invoice name
    HL
    SUP-479
    Low

Working on

2/2
  1. Login loop on tablets
    TA
    SUP-472
    Urgent
  2. Export stops at row 500
    NO
    SUP-476
    Normal

Waiting on customer

1/3
  1. Which plan were you on?
    HL
    SUP-468
    Normal

Solved

1
  1. Password reset email
    TA
    SUP-461
    Low

Refusing a move

update:lanes is an offer. Here a task with steps left is not let into Done: the lanes it was offered are not applied, so the card goes back to where it was.

Dark previewReplay previewOpen in new tab

To do

2
  1. Write the migration
    1/3
  2. Update the runbook
    3/3

Doing

1
  1. Rotate the keys
    2/3

Done

0

Only a task with all three steps done can go to Done.

API Reference

LaneBoard

PropTypeDefaultDescription
lanesLaneBoardLane<T>[]—The lanes and their cards, when the caller holds them. Supports v-model:lanes.
defaultLanesLaneBoardLane<T>[]—The lanes to begin with, when the caller does not hold them.
cardName(card: T) => string(card) => card.idWhat a screen reader calls a card, where its id would not mean much.
labelstring"Board"What a screen reader calls the board.
heightnumber | string"28rem"How tall the board is: a number of pixels, or any CSS length. Lanes fill it, and one with more cards than fit scrolls. "auto" lets the board grow with its longest lane.
EventDescription
update:lanes(lanes) The lanes as they should now be, when a card is set down somewhere new.
move({ card, from, to }) The one card that moved, and where from and to: { lane, index } each.
SlotDescription
card{ card, lane, carried } Draws the inside of a card. carried is true for the card being moved, and for its lifted copy under the pointer.
lane-footer{ lane } Draws something under a lane's cards, such as a button that adds one.

LaneBoardSteps

PropTypeDefaultDescription
valuenumber—How many steps are done.
maxnumber5How many steps there are.
colorLaneBoardColor"primary"Which of the theme's roles the done steps are drawn in.
labelstring"Progress"What a screen reader calls it.

LaneBoardTag

PropTypeDefaultDescription
colorLaneBoardColor"primary"Which of the theme's roles the tag is drawn in.

Every part also takes the attributes of the element it renders, and a class. LaneBoardColor is "primary" | "info" | "success" | "warning" | "destructive".

Notes

The data

lanes is a list of { id, title, cards }, and a card is any object with an id of its own across the whole board. Everything else on a card is yours, and the #card slot draws it. The parts above are there for the common pieces and none of them is required.

Pass default-lanes in place of lanes and the board holds the order itself.

Lanes

Besides id, title and cards, a lane takes color, one of the theme's roles, and limit. The limit is advice: the count shows it and warns when the lane is over, and the board still lets a card in. To hold a lane to its limit, refuse the move in update:lanes, as the second example refuses one.

The #lane-footer slot is given the lane and draws under its cards. A button there is pressed as usual and does not start a drag.

Hearing about a move

update:lanes is emitted with all the lanes as they should now be. move is emitted with the one card and where it went from and to, which is the shape a request to a server usually wants. Neither is emitted when a card is set down where it started.

Dragging

A press on a card becomes a drag once the pointer has moved a few pixels. On a touch screen a card is picked up by resting a finger on it for a moment, so a swipe still scrolls. A button, a link or a field inside a card works as usual and does not start a drag; put data-no-drag on anything else that should not.

The lane a carried card is over is drawn with a firmer border, and the place it would land is outlined. Near either end of the board, a carried card scrolls it. Escape puts the card back.

Keyboard

Every card is a Tab stop. Space or Enter picks one up; up and down move it within its lane, left and right into the lane beside; Space or Enter sets it down and Escape puts it back. Moving focus away also puts it back.

Screen readers

Picking up, each move and setting down are announced with the card's name, its lane and its place in it. card-name says what a card is called; without it, its id is used. A lane is named by its title.

Width

Lanes share the board's width and are never narrower than 12.5rem. When they do not fit, the board scrolls sideways and stops on a lane.

Height

The board is 28rem tall unless height says otherwise: a number of pixels or any CSS length. Every lane fills it. A lane with more cards than fit scrolls, with a thin bar that shows while the pointer is on the lane, and its cards fade out towards the edge they carry on past. Adding a card to a full lane therefore moves nothing else on the page.

A card carried near the top or bottom of a lane scrolls it, so a card can be set down anywhere in a long lane. What #lane-footer draws stays under the lane and does not scroll away.

height="auto" lets the board grow with its longest lane. It then keeps the greatest height it has had and does not shrink when cards leave that lane, so nothing moves under the pointer during a drag.

Motion

Cards slide to their new places as one is carried over them, and a card that is let go flies the last of the way into its place. A card moved to another lane with the keyboard appears there without sliding. With reduced motion on, they move at once.

Public exports

  • Components: LaneBoard, LaneBoardTitle, LaneBoardCode, LaneBoardRow, LaneBoardSteps, LaneBoardTag, LaneBoardMeta, LaneBoardAvatar
  • Helpers: moveCard, locateCard, keyboardMove, describeMove
  • Types: LaneBoardLane, LaneBoardCard, LaneBoardColor, LaneBoardMove, LaneBoardPlace

Accessibility

Keyboard shortcuts and ARIA behavior.

Shortcut Description
TabMove focus to the next card. Every card is a Tab stop.
SpacePick up the focused card, or set down the one being carried. Enter does the same.
↑ + ↓Move the carried card up or down within its lane.
← + →Move the carried card into the lane beside, keeping its place.
EscPut the carried card back where it was picked up.
  • The board is a group named by label, each lane a section named by its title, and each card announces itself as "Card that can be moved".
  • Each pick-up, move and set-down is spoken through a live region with the card's name, lane and place.