Drag & Drop
Compose withInteraction and FluxGantt wires three drag gestures into every mount()
automatically — unless the instance is readOnly. You don't
call anything per gesture; the mixin plus mounting is enough:
import { createGantt } from '@fluxgantt/core';
import { withRender } from '@fluxgantt/core/render';
import { withInteraction } from '@fluxgantt/core/interaction';
const gantt = withInteraction(withRender(createGantt({ tasks: [/* … */] })));
gantt.mount(document.getElementById('gantt')!);Interaction is opt-in so a consumer who only needs a static painted chart (a report, a
thumbnail, a print view) never downloads the gesture recognizers. Apply both mixins before
the first mount() call — hooks are read at mount time, so composing withInteraction onto an
already-mounted instance takes effect only on the next mount.
The three gestures
| Gesture | What it does | Event emitted |
|---|---|---|
| Move — drag a task bar horizontally | Reschedules the task, preserving its duration | task:moved |
| Resize — drag a bar's start/end edge handle | Changes the task's duration | task:resized |
| Create dependency — drag from a task's link handle onto another task | Adds an FS dependency | dependency:added |
Gesture priority is resolved automatically: an edge-handle claim wins over a whole-bar claim, and a link-handle claim wins over an edge zone — so the three never fight over the same pointer.
Canvas mode (2,000+ tasks)
All three gestures work in the Canvas renderer too, with the same snapping (whole calendar days), the same events and the same undo behaviour. A few things look different, because a Canvas chart is one bitmap rather than a tree of elements:
- Ghost preview. While you drag, the original bar stays painted and a translucent copy follows the pointer on an overlay layer; the chart itself is not repainted until you drop. Escape cancels with nothing to undo.
- Link handles are drawn on that overlay for the bar under the mouse and for selected bars. On a touch screen, tap a bar first to select it: its handles appear and can then be dragged.
- Hover cursors:
grabover a bar,ew-resizeover its right edge,crosshairover a handle. - Touch works under
withResponsive: a finger on a bar drags it, a finger on empty chart area still pans. - There is no auto-scroll near the edges (SVG has none either), and Ctrl+wheel zoom is still SVG-only.
Keyboard shortcuts
withInteraction also wires keyboard navigation and the shortcut set below. Nothing here needs
configuring — mounting is enough — and every binding calls the same facade method you could call
yourself, so the events and undo behaviour are identical either way.
| Keys | Action | Method | Gated by readOnly |
|---|---|---|---|
| Tab | Move focus into the chart — one stop for the whole grid (WAI-ARIA APG grid pattern), not one per row | — | no |
| ↑ / ↓ | Move focus one row and select it, replacing the previous selection. No wraparound at either end | select | no |
| Shift+↑/↓ | Extend the selection from the anchor row | select | no |
| Home / End | Move focus to the first / last visible row and select it. With Shift, extend the selection from the anchor row | select | no |
| PageUp / PageDown | Move focus one page of rows (the container height, minus one row of overlap), clamped at both ends. With Shift, extend the selection | select | no |
| g | Scroll the focused row's bar into view horizontally (G works too). Needs withViewport; moves neither focus nor selection | scrollToTask | no |
| Space | Toggle the focused row's selection | select / deselect | no |
| Enter | Collapse/expand the focused row. On a leaf: nothing, unless withEditHooks is applied, which then emits task:activated | toggleCollapse | no |
| F2 | With withEditHooks: emit task:activated for the focused row (summary rows too, where Enter still collapses) | — | no |
| ContextMenu / Shift+F10 | With withEditHooks: emit task:context-requested for the focused row | — | no |
| Delete / Backspace | Remove the selection, as one undo step | removeTask | yes |
| Ctrl/Cmd+Z | Undo | undo | yes |
| Ctrl/Cmd+Shift+Z | Redo | redo | yes |
| Ctrl/Cmd+D | Duplicate the selection | duplicateTask | yes |
| Ctrl/Cmd++ / = | Zoom in one level | zoomIn | no |
| Ctrl/Cmd+− | Zoom out one level | zoomOut | no |
| Ctrl+wheel | Zoom (anywhere over the chart container, the pinned header included). Also how a macOS trackpad pinch arrives | zoomIn / zoomOut | no |
| Click | Select a row (plus its descendants) | select | no |
| Ctrl/Cmd+click | Add/remove one row from the selection | select | no |
| Shift+click | Select the range from the anchor row | select | no |
- ↑/↓ scroll vertically only, so stepping through rows never moves the timeline sideways. g is the explicit "go to bar". In the SVG renderer the row label sits in the pinned label column, so the focused row stays visible even when its bar is off-screen.
- PageUp/PageDown assume the chart's own scroll container. They page by the
container's height, so give the container a fixed height with
overflow: auto; in an auto-height container that the page scrolls instead, a "page" is the whole chart. - Home/End always mean first/last row. Cell-level Home/End is not offered because the grid has no cells.
Two things worth knowing before you bind your own handlers on top:
- The zoom and undo/redo shortcuts call
preventDefault(), because Ctrl/Cmd++/− is the browser's own page zoom and Ctrl/Cmd+D is "bookmark this page" — without it both the chart and the browser would react to one keystroke. A plain wheel event is never intercepted, so the container's own scrolling is untouched. - Ctrl/Cmd+D is not always suppressible. Some browsers (notably Firefox in some configurations) don't let a page cancel that reserved shortcut, so the bookmark dialog may still appear alongside the duplicate. That is a known, accepted v1 gap.
There is deliberately no Ctrl/Cmd+A binding — "select all" in an embedded
widget would fight the host page's own text selection. Call selectAll() from your own handler
if you want it.
In readOnly mode the gated rows above become inert without calling preventDefault() —
the keystroke passes through to the host page rather than being swallowed. Focus, selection,
collapse and zoom all keep working, because none of them mutate data.
Keyboard alternative to dragging
Every drag gesture has a keyboard equivalent (WCAG 2.2 SC 2.5.7, Dragging Movements). They act on
the focused row only, even if several rows are selected (drag moves one bar too), go through the
same commit path as the pointer (same events, same cascade, same undo), and are inert under
readOnly (enableDependencyDrag: false also turns off linking).
| Keys | Action | Mirrors |
|---|---|---|
| Alt+→ / ← | Move the task one calendar day later / earlier | drag-move |
| Alt+Shift+→ / ← | Lengthen / shorten the task by moving its end one day (never shorter than one day, or than the task already is) | drag-resize |
| Shift+↑/↓ to the target row, then Alt+Enter | Link the row where the Shift range began (source) to the focused row (target), finish-to-start | drag-create-dependency |
- Holding the key repeats, one day per tick, and each press is its own undo step. Auto-repeat
fires about 30 times a second, so a few seconds of holding fills the history limit (
historyLimit, default 100) and evicts older undo entries. Tap the key for single steps. - Resizing steps past weekends and holidays: one press changes the working time of the painted span (up to 366 days of stepping), so a task ending on a Friday ends on the next working day afterwards. If nothing can change (the one-day minimum, a milestone, a rolled-up summary) nothing is written and the live region says "cannot resize" / "cannot move".
- Left/right are logical on the time axis: in an RTL chart later dates are visually on the left, so Alt+← moves the task later there.
- Only the end edge is resizable, exactly like dragging (the start edge never moves).
- Rolled-up summary bars (when
rollupis configured) and milestone resizes are declined, as in drag. - The bar is scrolled back into view after a move or resize (needs
withViewport, as for g). - Alt+←/→ is "Back / Forward" in Chrome, Edge and Firefox on
Windows and Linux; the chart calls
preventDefault()on it. Ctrl/Cmd+Alt+arrow is left alone (tab switching, screen readers). - On Windows, Alt+Shift is the default shortcut for switching input language, so users with several keyboard layouts may see the layout flip on the resize keys.
- On macOS Option is Alt. Option+letter types a symbol, which is why every binding here is an arrow or Enter.
- The rows between the source and the target stay selected, so a Delete pressed right after linking removes all of them. Press ↑/↓ once to collapse the selection.
- A source row hidden by a collapse is not linked from.
- Results are announced to screen readers through a polite live region (moved / resized with the new
start and end including times, "cannot move" / "cannot resize", linked, "link not created" with a
reasonofduplicateorcycle). Translate or reword them withmessages.announce, which receives aGanttAnnouncement. Pointer drags are not announced; hosts that want that listen totask:moved/task:resized, which carry no keyboard-versus-pointer flag. - Invalid links (to itself, a duplicate, or one that would create a cycle) are dropped without an event, like a rejected drop; the keyboard path announces "link not created". Linking from a source hidden by a collapse does nothing (and is not announced).
See also read-only charts.
Listening to the results
Every gesture commits through the same pipeline as the programmatic API, so the events carry the same payloads:
gantt.on('task:moved', (task, prevStart) => {
// task is the updated Task; prevStart is where it started (a DateInput)
console.log(`${task.name}: ${prevStart} → ${task.start}`);
});
gantt.on('task:resized', (task, prevDuration) => {
// prevDuration is the previous duration in working hours
console.log(`${task.name} resized (was ${prevDuration}h)`);
});
gantt.on('dependency:added', (dependency) => {
console.log(`linked ${dependency.from} → ${dependency.to} (${dependency.type})`);
});Each on(...) returns an unsubscribe function:
const off = gantt.on('task:moved', handler);
off(); // stop listeningThe same operations, programmatically
The drag gestures are just interactive wrappers over methods you can also call directly:
gantt.moveTask(id, '2026-08-06'); // ≡ drag-move
gantt.resizeTask(id, 24); // ≡ drag-resize (24 working hours)
gantt.linkTasks(fromId, toId, 'FS'); // ≡ drag-create-dependencyCascade on drag (opt-in)
By default a move/resize affects only the dragged task (schedulingMode: 'manual'). Set
schedulingMode: 'auto' at construction to have dependent tasks pushed along their FS/SS/FF/SF
links (+lag) when you drag — each shifted task emits its own task:moved:
const gantt = withInteraction(
withRender(createGantt({ tasks: [/* … */], dependencies: [/* … */], schedulingMode: 'auto' })),
);Invalid drops are silently reverted
Dragging out a dependency that would be a self-link, a duplicate, or a cycle is rejected — the gesture simply snaps back, with no error thrown into the page and no event emitted.
With the pinned label column
In SVG mode the label column is pinned (position: sticky) over the chart. A bar scrolled under it
is covered, so you can only grab its visible part, a press on the column never starts a drag, and a
dependency drag released over the column creates nothing. scrollToTask(id, { align: 'start' })
parks the bar directly right of the column. See the
withViewport notes for the full pinning model.