Skip to the solution
MediumFrontend7 min read

Build a Selectable Cell Grid (drag to select)

887 views

The problem

Question presented to candidate: "Build a seat picker: a 6 by 8 grid of cells. Pressing on a cell and dragging selects the rectangle between where you started and where you are, like selecting cells in a spreadsheet. Holding Ctrl or Cmd adds the rectangle to the existing selection, or removes it if you start on a selected cell. Show how many cells are selected, and make it usable from the keyboard."

Requirements:

  • Press and drag selects the rectangle from the anchor cell to the current cell, in any direction, with a live preview while dragging.
  • A plain drag replaces the selection; Ctrl or Cmd adds, or subtracts when the drag starts on a selected cell.
  • Nothing is committed until the pointer is released; Escape during a drag cancels it, and pointercancel does too.
  • Dragging outside the grid keeps selecting up to the nearest edge.
  • No text selection while dragging, and it works with touch.
  • Keyboard: arrow keys move a single focus through the grid, Shift + arrows extend a rectangle from the anchor, Space toggles a cell, Ctrl + A selects all, Escape clears.
  • role="grid" with aria-multiselectable, aria-selected per cell and a live count.

What a strong answer should cover:

  • Separating committed selection from the drag in progress, and deriving what is shown from both.
  • Mapping pointer coordinates to a cell from the grid box, clamped to the edges, instead of relying on per-cell mouseenter.
  • Pointer capture (setPointerCapture) so moves keep arriving outside the grid and on touch devices.
  • Only re-rendering when the pointer crosses into a new cell.
  • A roving tabindex so the grid is one tab stop.
  • Immutable Set updates and why they matter for change detection.

Clarifying questions expected:

  • "Rectangle selection or freehand painting of every cell the pointer touches?"
  • "Should some cells be disabled, such as sold seats?"
  • "Is the grid size fixed, or can it be large enough to need virtualisation?"

Code / implementation expected: Yes. The grid component with pointer and keyboard selection.

  • Grid
  • Mouse events
  • Selection

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read ยท 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useMemo, useRef, useState } from "react";
import { ROWS, COLS, key, rectKeys, combine, cellAt } from "./src/utils/selection";

export default function App() {
  const [committed, setCommitted] = useState(() => new Set());
  // { anchor, current, mode } while the pointer is down
  const [drag, setDrag] = useState(null);
  const [focus, setFocus] = useState([0, 0]); // roving tabindex position
  // corner that Shift+Arrow extends from
  const [anchor, setAnchor] = useState([0, 0]);
  const gridRef = useRef(null);
  const cellRefs = useRef({});
  const rectRef = useRef(null); // grid box measured at pointerdown

  const shown = useMemo(() => combine(committed, drag), [committed, drag]);

  // Move DOM focus with the roving tabindex, but only once the grid has focus.
  useEffect(() => {
    if (gridRef.current.contains(document.activeElement))
      cellRefs.current[key(...focus)].focus();
  }, [focus]);

  function onPointerDown(e) {
    if (e.button !== 0) return;
    e.preventDefault(); // no text selection; we move focus ourselves
    rectRef.current = gridRef.current.getBoundingClientRect();
    const cell = cellAt(rectRef.current, e.clientX, e.clientY);
    const additive = e.ctrlKey || e.metaKey;
    const mode = !additive
      ? "replace"
      : committed.has(key(...cell))
        ? "subtract"
        : "add";
    // Capture: moves keep arriving here even when the pointer leaves the grid.
    if (gridRef.current.setPointerCapture)
      gridRef.current.setPointerCapture(e.pointerId);
    setDrag({ anchor: cell, current: cell, mode });
    setAnchor(cell);
    setFocus(cell);
    cellRefs.current[key(...cell)].focus();
  }
  function onPointerMove(e) {
    if (!drag) return;
    const cell = cellAt(rectRef.current, e.clientX, e.clientY);
    if (cell[0] !== drag.current[0] || cell[1] !== drag.current[1]) {
      // re-render only when the cell changes
      setDrag({ ...drag, current: cell });
      setFocus(cell);
    }
  }
  function onPointerUp() {
    if (!drag) return;
    setCommitted(shown);
    setDrag(null);
  }

  function onKeyDown(e) {
    if (e.key === "Escape") {
      e.preventDefault();
      if (drag)
        setDrag(null); // cancel the drag in progress
      else setCommitted(new Set());
      return;
    }
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
      e.preventDefault();
      setCommitted(new Set(rectKeys([0, 0], [ROWS - 1, COLS - 1])));
      return;
    }
    if (e.key === " ") {
      e.preventDefault();
      const k = key(...focus);
      const next = new Set(committed);
      next.has(k) ? next.delete(k) : next.add(k);
      setCommitted(next);
      setAnchor(focus);
      return;
    }
    const d = {
      ArrowUp: [-1, 0],
      ArrowDown: [1, 0],
      ArrowLeft: [0, -1],
      ArrowRight: [0, 1],
    }[e.key];
    if (!d) return;
    e.preventDefault();
    const to = [
      Math.min(ROWS - 1, Math.max(0, focus[0] + d[0])),
      Math.min(COLS - 1, Math.max(0, focus[1] + d[1])),
    ];
    setFocus(to);
    if (e.shiftKey) setCommitted(new Set(rectKeys(anchor, to)));
    else setAnchor(to);
  }

  return (
    <main className="app">
      <h1>Seat picker</h1>
      <p className="hint" id="grid-help">
        Drag to select a block. Ctrl or Cmd + drag adds (or removes, if you
        start on a selected seat). Keyboard: arrows move, Shift + arrows extend,
        Space toggles, Ctrl + A all, Escape clears.
      </p>
      <div
        ref={gridRef}
        className={"grid" + (drag ? " dragging" : "")}
        role="grid"
        aria-multiselectable="true"
        aria-label="Seats"
        aria-describedby="grid-help"
        style={{ gridTemplateColumns: "repeat(" + COLS + ", 1fr)" }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={() => setDrag(null)}
        onKeyDown={onKeyDown}
      >
        {Array.from({ length: ROWS }, (_, r) => (
          <div role="row" key={r} className="row">
            {Array.from({ length: COLS }, (_, c) => {
              const k = key(r, c);
              const on = shown.has(k);
              return (
                <div
                  key={k}
                  ref={(el) => (cellRefs.current[k] = el)}
                  role="gridcell"
                  data-cell={k}
                  aria-selected={on}
                  aria-label={"Row " + (r + 1) + ", seat " + (c + 1)}
                  tabIndex={focus[0] === r && focus[1] === c ? 0 : -1}
                  className={
                    "cell" +
                    (on ? " on" : "") +
                    (drag && on !== committed.has(k) ? " preview" : "")
                  }
                />
              );
            })}
          </div>
        ))}
      </div>
      <p role="status" data-testid="count">
        {shown.size} {shown.size === 1 ? "seat" : "seats"} selected
      </p>
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 37 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Command Palette (kbar / cmdk) with Fuzzy Search

Continue