<!-- https://getkonjo.com/design/patterns/direct-manipulation · source: docs/design/patterns/direct-manipulation.md -->

# Direct manipulation

Drag, drop, reorder — and the keyboard equivalent that is not optional.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine
> apply here unless this chapter contradicts them.

Every other pattern in Konjo is a state machine over taps: press → navigate, press → validate,
press-arm → press-fire. This one is not. A drag is a continuous gesture with a mid-flight
state, a spatial target, a cancel, a rollback, and a **second complete interaction** — the
keyboard path — that has to produce identical outcomes through a different modality.

The chapter exists because a completeness test specified a Studio board and found the entire
specification of direct manipulation in this system was one clause in a screen inventory:
*"Drag-and-drop cards."* Thirty-three of that round's decisions were the agent's and not the
system's, twelve of them here. The shapes this covers: a pipeline board, a sortable list, the
belt-ladder editor, a schedule grid, and anything else where the person moves the object
rather than filling in a field about it.

## Before anything else: is a drag allowed here?

**A drag must be reversible, or it is not a drag.**

[Serious actions](destructive.md) require the consequence stated *before* the commit, "in
`caption`, next to the control that causes it". A drag has no control and no before-the-tap
moment — by the time anything is stated, the thing has happened. So:

- **Reversible move** → a drag is legal, and the announcement after the drop is the notice.
- **Irreversible, or it moves money, or someone is told** → it is not a drag. It is a control
  with a confirm, and the object may still be draggable as a *shortcut to the control*, which
  opens the confirm rather than committing.

If you cannot say what undoes it, you are not ready to build it.

## The three visual states, and why none of them is a shadow

The universal convention for a lifted object is a drop shadow, and
[L3](../konjo-design-language.md) bans shadow, blur, glow and gradient outright. The system's
one recipe for a floating surface — *scrim behind plus a 1px hairline* — cannot apply either: a
scrim would hide the drop targets that are the entire point of the gesture. So the lift is
built from what is left, and it is built from meaning rather than depth.

| | Treatment | Why |
|---|---|---|
| **The dragged object** | `opacity.pressed` 0.85 and a 2px `border.control` edge, keeping its own `radius` | Opacity is already the system's "this is under your finger" signal. `border.control on surface.page` is 3.15:1 and `border.control on surface.card` is 3.53:1, so the edge reads on either ground |
| **The origin slot** | a 1px `border.control` outline at the object's exact size, no fill | It holds the space so nothing reflows mid-gesture, and it is the answer to "where does this go back to" |
| **The drop target** | a 2px `border.focus` outline at the region's radius, inset `spacing.s8` | 14.73:1 on the page, 16.48:1 on a card |

**Focus and the drop target are the same thing, deliberately.** A drag in flight has exactly
one active region, and it is the one a drop would land in — whether the person is pointing at
it or arrowed to it. Giving them separate treatments would put two rings on one screen meaning
almost the same thing, and would make the pointer path and the keyboard path look like
different features. While a drag is in flight, **focus *is* the drop target**.

**Nothing here is dashed.** `borderWidth` ships `hairline` 1 and `emphasis` 2 and there is no
border-style token; a dashed edge is a fifth visual language nobody sanctioned.

## Pointer

| | |
|---|---|
| Rest | `cursor: grab` on the object. See [Studio's cursor table](../studio/metrics.md#pointer) |
| Activation | movement ≥ 5px. Below that, pointerup is a click and does whatever a click does |
| In flight | the object tracks the pointer 1:1; `cursor: grabbing` |
| Auto-scroll | a 48px zone at each edge of the scrolling container, ramping to ~12px per frame. Never the page — [horizontal scroll is contained](../studio/tables.md) |
| Cancel | `Escape`, and the object returns to its origin slot |
| Drop outside any target | the same as cancel. A drag never destroys anything by being released in the wrong place |

**A whole object may be the drag source, or a handle may be.** Use a handle when the object
also contains controls — a card with a link and a menu inside it — because a drag that starts
on a link is a link that sometimes does not work. Use the whole object when it contains only
text. The handle is `iconSize.inline` 16, `text.tertiary`, and it is not the object's only
affordance: the cursor and the keyboard path both work on the object itself.

## Keyboard, which is a requirement and not a courtesy

**WCAG SC 2.5.7 (Dragging Movements, 2.2 AA): every drag needs a single-pointer alternative
that is not path-based.** SC 2.1.1 additionally requires the whole thing be reachable by
keyboard. These are not the same requirement and one control can satisfy both.

**The best answer is usually not a keyboard drag.** It is a plain control that sets the same
value — a stage chip row, a "move to" select, a menu on the row. That serves keyboard users,
switch users, screen-reader users and anyone on a tremor-affected hand at once, and it is
derivable from [sheet-picker](sheet-picker.md) and [buttons](buttons.md) with nothing invented.
Build that first. If the collection is large enough that the control is unusable, add the
grab-and-move path below **as well**, never instead.

| Key | Behaviour |
|---|---|
| `Tab` | The collection is **one** tab stop; focus lands on the last-focused object |
| Arrows | Move focus within and between groups |
| `Home` / `End` | First / last in the group |
| `Enter` | The object's own action — open it |
| `Space` | **Grab.** The object takes the dragged treatment in place |
| Arrows, while grabbed | Move the grabbed object; announce on each |
| `Space` / `Enter`, while grabbed | Drop |
| `Escape`, while grabbed | Cancel and return |

**A collection is one tab stop, and that is an exception worth stating**, because
[accessibility](../foundations/accessibility.md) otherwise says tab reaches every control. A
40-card column with every card a tab stop reproduces exactly the problem a skip link exists to
solve. Roving `tabindex` — one `0`, the rest `-1` — is the mechanism. It applies to a composite
widget: a board, a grid, a tree, a toolbar. It does **not** apply to a list of links.

## What it says

A drag is invisible to a screen reader unless it is narrated. **Two live regions for the whole
collection** — not one, and not one per group:

- **`polite`** carries progress — the grab, and the in-flight message on every arrow press.
- **`assertive`** carries the outcome — the drop, the cancel, the failure.

One region cannot do both: politeness is read when the region is created, not per message. A
single polite region drops the message that matters (rapid arrow presses coalesce), and a single
assertive one interrupts the screen reader on every keypress.

| Event | Announcement |
|---|---|
| Grab | "Ana Torres grabbed, in New, 1 of 12. Arrows to choose a stage. Space to drop. Escape to cancel." |
| Move, in flight | "Contacted, 4." — short, because it repeats on every keypress |
| Drop | "Ana Torres moved from New to Contacted. New has 11, Contacted has 5." |
| Cancel | "Move cancelled. Ana Torres is still in New." |
| Failure | "Couldn't move Ana Torres. Ana Torres is still in New." |

- **The pointer path announces too.** A screen-magnifier user drags with a mouse.
- **In-flight messages are `polite` and will sometimes be dropped** when arrows repeat quickly.
  That is correct — and the outcome is `assertive` for the same reason, because it is the one
  that must survive. It restates the whole result, not just the change.
- **Never `aria-grabbed`/`aria-dropeffect`** — deprecated since ARIA 1.1. State lives in the
  live region and in `aria-roledescription` on the object.
- Voice: plain, name the person, no first person, no exclamation. See [voice](../voice/voice.md).

## Motion

| | |
|---|---|
| Tracking the pointer | 1:1, no easing, no lag. This is input, not animation |
| Drop settle | **No travel animation.** Remove from the origin, cross-fade in at the destination at `motion.duration.quick` 150. [Motion](../foundations/motion.md) puts the ceiling for a movement at 8–24pt; an object crossing three columns travels several hundred |
| Neighbours reflowing | no animation — content already on screen does not animate into place |
| Drop-target outline | `motion.duration.instant` 100 |

**Reduced motion, and the one exemption in the system.** Movement is normally *replaced*, never
deleted (WCAG 2.3.3). **1:1 pointer tracking is exempt**: it is not a transition, it is the
person's own hand, and removing it removes the interaction rather than its decoration. This is
the only essential-motion exemption Konjo has, and it does not generalise — everything else in a
drag still obeys the rule: the settle becomes an instant swap, auto-scroll steps instead of
ramping.

## States

- **Optimistic by default.** The object moves on drop; a failure rolls it back and says so in
  an inline block, never a toast — a toast disappears and this needs a decision.
- **Rollback returns the object to its exact origin**, not to the end of the origin group.
- **A drop into a group that is full, closed, or not allowed** shows no drop target at all.
  Never accept a drop and then reject it.
- **Two people on one board** is an ordinary Tuesday. If a move lands on stale state, the
  object goes where the server says and the announcement says what happened —
  never silently.

## The self-check

- Can someone who cannot drag do the same thing, in the same number of steps, without one?
- Does the drop announce the outcome, not just the action?
- If the network fails mid-gesture, where does the object end up, and is that said out loud?
- Is the thing this moves reversible? If not, why is it a drag?
