Drag & Drop PlannerContext subsystem
Two parallel input systems — the browser's HTML5 Drag and Drop API (mouse) and a custom touch implementation — converge on the same four handlers. The central design constraint is that the touch system must work on iOS Safari, which has poor native DnD support.
dragInfo — set by both systems, read by handlers and UIcanDropSem(semId) — called on every dragover to decide if a drop is legalOverall pipeline
dragInfo and call the same handlers. The touch system uses refs (not React state) during the gesture to avoid re-renders.dragInfo shape
{ id: string, // item ID — null for new term templates dragged from the bank
type: string, // "course" | "specialTerm"
typeId?: string, // adapter term type — e.g. "coop" | "intern" — only for "specialTerm"
fromSem: string, // source semId (null if dragging from bank/template)
duration?: number // months — only present for "specialTerm"
}
Drop targets
| Target | Data attribute | Types accepted | Effect |
|---|---|---|---|
| Semester zone | data-sem-id | course, specialTerm | onDrop(semId) — places or moves item. For courses: coreq partners move silently to the same semester. |
| Bank panel | data-drop-bank | course, specialTerm | onDropBank() — removes placement. Special terms deleted entirely; course returns to bank. |
| Placed-out zone | data-drop-placedout | course only | onDropPlacedOut() — adds to placedOut set, removes from placements. |
| Another course card | data-drag-id on target | course only | onDropOnCard(targetId, semId) — reorders within the semester by swapping positions in semOrders. |
Drop validation
canDropSem(semId) is called on every dragover event. If it returns false, e.preventDefault() is not called and the browser shows the "no drop" cursor. The same function runs again inside onDrop as a safety check before committing the mutation.
| Item | Duration | Valid semester types | Blocked if |
|---|---|---|---|
| Course | — | Any | Semester is occupied by a special term (start or continuation row) |
Co-op (typeId: "coop") | 4-month | Fall or spring (single row); summer (normalizes to sumA, spans sumA→sumB) | Any other term in start or continuation slot |
Co-op (typeId: "coop") | 6-month | Spring→sumA or sumB→Fall only | Any other term in either of the two spanned semesters |
Internship (typeId: "intern") | 2-month | Summer only (single row) | Any other term already there |
Internship (typeId: "intern") | 4-month | Fall or spring (single); summer (normalizes to sumA, spans sumA→sumB) | Any other term in start or continuation slot |
| Custom type | any | Determined by weight-based span logic | Any slot already occupied (isSlotOccupied) |
Corequisite partner movement
When a course is dropped onto a semester or the bank, PlannerContext scans allEdges for all corequisite partners of the dragged course and moves them to the same destination. This is silent — no confirmation, no visual indicator. The partners are also removed from semOrders in their old semesters. This means dragging one course in a coreq pair always relocates both.
Touch drag pipeline
A single touchstart / touchmove / touchend listener is attached to document at mount. All drag state lives in refs (not React state) to avoid triggering re-renders during the gesture. INPUT, BUTTON, SELECT, and TEXTAREA elements receive native touch handling and do not trigger drag.
then data-drop-bank, then data-sem-id Doc->>Context: call onDropPlacedOut / onDropBank / onDrop
Placed-out bounding-rect fallback
target.closest('[data-drop-placedout]') can miss its target when the ghost element is positioned over it — elementFromPoint returns the ghost instead of the container below. The code handles this by iterating all [data-drop-placedout] elements and doing a manual getBoundingClientRect() hit test as a fallback when closest() returns null.