Skip to content

feat(useDragDrop): add headless drag-and-drop primitive#225

Merged
johnleider merged 45 commits into
masterfrom
feat/create-drag-drop
May 7, 2026
Merged

feat(useDragDrop): add headless drag-and-drop primitive#225
johnleider merged 45 commits into
masterfrom
feat/create-drag-drop

Conversation

@johnleider
Copy link
Copy Markdown
Member

@johnleider johnleider commented Apr 30, 2026

Summary

  • New composable useDragDrop in packages/0/src/composables/useDragDrop/ — a headless drag-and-drop primitive that owns two registries (draggables, zones) plus active-drag state, with pluggable pointer + keyboard adapters.
  • Lifecycle hooks (onBeforeStart, onMove, onBeforeDrop, onDrop, onCancel) and a plugin array provide three independent extension points without forking.
  • Hit-testing via document.elementFromPoint + ancestor walk; opt-in list orientation auto-computes drop index and indicator.
  • Deprecated ARIA omitted in favour of aria-live announcement guidance; consumers wire roving tabindex per zone via useRovingFocus.
  • Maturity lands at draft, since: null, category: system. Promotion to preview blocks on the first downstream consumer.
  • Plain use* factory: no auto-provide, caller owns the instance and threads it to children explicitly. If subtree DI is needed later (e.g. for a future <DropZone> component), it can be added additively as a createDragDropContext() companion mirroring createSelectionContext.

Files

  • packages/0/src/composables/useDragDrop/ — factory, types, two registry wrappers, indicator math, pointer + keyboard adapters
  • apps/docs/src/pages/composables/system/use-drag-drop.md + apps/docs/src/examples/composables/use-drag-drop/{basic,DragItem,DropList}.vue — docs page + working two-list example
  • packages/0/README.md, packages/0/src/maturity.json, packages/0/src/composables/index.ts, apps/docs/src/pages/composables/index.md — feature-checklist updates

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

commit: a7f6349

@johnleider johnleider self-assigned this May 1, 2026
@johnleider johnleider force-pushed the feat/create-drag-drop branch 2 times, most recently from 744c7a3 to 9602b21 Compare May 2, 2026 02:36
@johnleider johnleider added this to the v0.2.x milestone May 2, 2026
@johnleider johnleider force-pushed the feat/create-drag-drop branch from 9602b21 to b2ebe01 Compare May 4, 2026 17:18
@johnleider johnleider changed the title feat(createDragDrop): add headless drag-and-drop primitive feat(useDragDrop): add headless drag-and-drop primitive May 4, 2026
johnleider added 22 commits May 6, 2026 09:51
New composable in `packages/0/src/composables/createDragDrop` providing the
foundation for drag-and-drop interactions across v0. Owns two registries
(draggables and zones) plus active-drag state, with pluggable pointer and
keyboard transports as defaults. Lifecycle hooks (onBeforeStart, onMove,
onBeforeDrop, onDrop, onCancel) and a plugin array provide three independent
extension points without forking.

- Two internal `createRegistry` instances with `events: true` for late-mount
  reactive sync; tickets carry attrs / isDragging / isOver / willAccept /
  indicator.
- Default `pointerTransport` (mouse / touch / pen via Pointer Events) and
  `keyboardTransport` (Space / Enter / arrows / Escape) run in parallel.
- Hit-testing via `document.elementFromPoint` + ancestor walk; optional list
  orientation opts into automatic index/indicator computation.
- Deprecated ARIA (`aria-grabbed`, `aria-dropeffect`) intentionally omitted;
  consumers wire an `aria-live` region for announcements.
- Maturity: draft, since: null, category: system. Promotion to preview
  blocks on the first downstream consumer.
…export

The factory now calls the underlying createContext provide internally
when invoked inside a setup() scope. Public surface shrinks to
createDragDrop + useDragDrop, matching v0's compound-component
convention where the provide call is internal (TabsRoot precedent).

Also polish the basic docs example with hover affordances:
- cursor-grab on items, cursor-grabbing while dragging
- wrapper applies cursor-grabbing during drag so destination zones
  inherit the cursor instead of falling back to the default arrow
- destination zone shows ring + tinted bg when accepting an active drag
…rdening

Inspect-fix loop landed nine findings across the public-API typing,
lifecycle-chain coverage, and v0-convention compliance:

- Distribute ActiveDrag, DraggableTicketInput, DraggableTicket over their
  K parameter so a discriminated-union K narrows drag.value when the
  consumer narrows drag.type.
- Make useDragDrop generic so sub-components can recover K at the inject
  boundary instead of receiving DragDropContext<DragType>.
- Drop with no over-zone now routes through the cancel chain
  (zone.onLeave → draggable.onCancel → global.onCancel) instead of
  silently clearing active.
- indicator getter switched from toRef to computed so reactive reads
  are cached — getBoundingClientRect on every child of every zone is
  too expensive to recompute per access.
- Import section order corrected in the entry and adapter files
  (Globals → Composables → Adapters → Utilities → Types → Re-exports).
- Replaced raw Array.isArray and typeof === 'function' with isArray
  and isFunction from #v0/utilities.

Tests: cover zone-change onLeave/onEnter, drop-no-zone fires onCancel,
and `transports: []` actually disables both default transports.
19 tests passing, typecheck clean, lint clean.
…tion

The composable example was in one file with DragItem and DropList
inlined as defineComponent + h() calls. That hides the API surface
the docs are meant to demonstrate and breaks the multi-file convention
in .claude/rules/docs.md (Composable Page Structure: ::: example with
2+ files; supporting Vue files in PascalCase).

Split into:
- DragItem.vue  — registers itself as a draggable, binds attrs
- DropList.vue  — registers itself as a zone, emits move events
- basic.vue     — entry: owns data, calls createDragDrop, wires moves

Docs page rewrites the ## Examples block with multi-paragraph prose
explaining what the example demonstrates, when to reach for it, and
how to extend it, followed by the file-role table.
Per .claude/rules examples-prefer-data-attr-selectors and the
use-virtual-focus / Checkbox precedents, drive state styling off the
data-attrs the composable already exposes via attrs.value rather than
JS-conditional :class bindings.

- DragItem: cursor-grab base; data-[dragging]:cursor-grabbing +
  data-[dragging]:opacity-50 for the lift-up feedback
- DropList: surface base; data-[accepts]:* for the primary-tinted
  border / bg / ring (data-accepts is set only when isOver && willAccept)
- basic.vue wrapper: :data-dragging on the wrapper plus
  data-[dragging]:cursor-grabbing so the cursor stays consistent across
  zones during a drag

The conditional :class branches are gone — every state read flows
through the composable's attrs spread (v-bind="zone.attrs.value" /
"ticket.attrs.value") and the static class string.
position.index is undefined when the zone has no children for
resolveDropPosition to bin against — empty-list drops were silently
returning without emitting. Falls back to items.length so the dragged
item lands at the end.
…ones

When a zone declares orientation but has no children to bin against,
resolveDropPosition returns null. Previously this left position.index
undefined and pushed the empty-zone fallback onto every consumer.
Default to 0 — the only sensible drop position when there's nothing
to splice between — so consumer onDrop callbacks can splice without
their own fallback. Indicator stays null (nothing to point at).

Drops the parallel ?? items.length workaround in the docs example.
Add flex-wrap to the basic example wrapper and switch each DropList
from flex-1 (basis 0) to basis-48 grow so the two lists sit
side-by-side at wide widths and stack when the example container
narrows below ~25rem.
…fence

Per .claude/rules/docs.md composable section structure, Usage is a
short prose sentence followed by a collapsible code fence — not a
multi-paragraph explanation. The expanded prose belongs in the
Examples block (where it already lives).
… sections

Per .claude/rules/new-feature-checklist.md the `system` category is
exclusively for `use*` browser-API wrappers. Stateful UI-pattern
primitives (createBreadcrumbs, createOverflow) live in `utilities`.
Moving createDragDrop to match.

Doc expansion to match the composable-page structure rule:
- Architecture mermaid showing the factory's three pieces of state,
  pluggable transports, hook chain, and child registration paths
- Transports section with the built-ins table, default-replacement
  semantics, and the custom-transport interface contract
- Reactivity table covering every consumer-facing ref / shallow-ref
- Recipes block (multiple drag types, vetoing drops, custom transports)
- Accessibility section (list-of-lists ARIA convention, deprecated-attr
  rationale, roving-tabindex pointer)
- FAQ block: native HTML5 DnD tradeoff, position.index semantics, K
  parameter narrowing, dual-role elements, plugin extension points
…ention

The v0 ecosystem already has the adapter pattern documented in
composables.md and implementation.md, used by useDate, useLocale,
useLogger, useStorage, useNotifications, and createDataTable
(ClientAdapter, ServerAdapter, VirtualAdapter). Inventing 'transport'
just for createDragDrop fragments the vocabulary for no semantic gain.

Renames:
- DragDropTransport → DragDropAdapter
- TransportEmit → DragDropAdapterEmit
- pointerTransport → pointerAdapter
- keyboardTransport → keyboardAdapter
- PointerTransportOptions → PointerAdapterOptions
- KeyboardTransportOptions → KeyboardAdapterOptions
- options.transports → options.adapters
- ## Transports → ## Adapters (docs section)

The 'via' discriminator on ActiveDrag stays as 'pointer' | 'keyboard'
strings — those describe the input mechanism, not the implementation
type. Custom adapters declare their own 'via' value via Extensible.
… system

Drops the auto-provide DI plumbing (createContext tuple, useDragDrop
inject wrapper, hasInjectionContext branch) — the composable is now a
plain DOM-coupled hook in the `system` category alongside usePopover,
useRovingFocus, etc. Examples thread the context to children via an
explicit `:dnd` prop. If subtree DI is needed later (e.g. for a future
DropZone component), it can be added additively as a separate
`createDragDropContext()` companion mirroring `createSelectionContext`.
Spec required a forward-link to consumers who relied on the auto-provide
pattern; explains that wrapping useDragDrop() with their own provide/inject
pair is the manual workaround until a createDragDropContext() trinity
factory lands.
…ness fixes

Restructure pointer/keyboard adapters as PascalCase classes extending
an abstract DragDropAdapter base, mirroring createDataTable. Fold emit
into DragDropAdapterContext; rename install/uninstall to setup/dispose
to match v0's other adapters. Replace raw document.addEventListener
with useEventListener.

Naming: handle* -> on*, ctx -> context, *Ref suffix dropped from
ticket-locals, inline buildDraggableAttrs/buildZoneAttrs into toRef
getters, drop willZoneAcceptById and fireZoneHook helpers. Type-guard
hygiene: === null -> isNull, accept(drag) === true -> Boolean(...).

Correctness: cancel chain accepts a reason ('cancel' | 'reject') so
drop rejection is distinguishable from user cancel; in onMove, fire
onLeave/onEnter before the per-callback onMove so consumers see a
coherent over-state; dispose both registries on scope teardown.
Tickets now expose state and methods only; consumers wire DOM
attributes themselves. data-draggable / aria-roledescription /
data-dragging / data-dropzone / data-over / data-accepts / touch-action
all move from the composable's toRef builders to the example templates.
Headless-first — the composable shouldn't pre-decide attribute keys
that the design-system layer may want to control.

Docs accuracy pass: rename remaining lowercase pointerAdapter /
keyboardAdapter references in the architecture mermaid, FAQ, and
accessibility section to PascalCase classes; document the new
onCancel(drag, reason: 'cancel' | 'reject') signature in the vetoing
recipe; add Methods sub-section for dnd.cancel(); add DOM attributes
sub-section listing the canonical wiring (since the composable no
longer ships it); fix the architecture intro state count; replace
"arrow keys to navigate" with the accurate "nudge by step px"; show
position arg in the recipe onDrop signature.
Trim Usage prose to one sentence and remove the in-fence surface
listings that duplicated the Reactivity table. Multi-line the ticket
register for symmetry with the zone register. Add `collapse` to the
::: example block so the file list folds by default.

Rename multi-word identifiers to single-word per style.md: moveCard
(dangling reference) replaced with an inline comment showing the
onDrop arg shape; showRejectionToast → notify; MyWebXrAdapter /
MyCustomAdapter / MyAdapter → TouchAdapter (consistent canonical
example); autoScroll / flipAnimations → scroll / flip in the FAQ.
Match sibling pages: 'ts collapse no-filename' directive, declare
useTemplateRef('el') and primitive value, console.log the expected
output. The dangling 'Card' interface and 'card' identifier are gone;
the fence now compiles and runs standalone.
…rule

Switch the Usage code fence from a synthetic ts snippet to a Vue SFC
matching usePopover's convention — separate template refs for draggable
and dropzone, real DOM bindings showing where ref="..." goes, and the
canonical data-draggable / aria-roledescription / data-dropzone
attributes consumers must wire. Add the playground directive so readers
can edit it in-browser.

Frame the multi-type recipe with a "default to a single type per scope;
widen K only when types interact" overview before the union example,
since the existing prose jumped straight to the union case.
…icator

Sweep multi-word identifiers in index.ts: cancelDrag → bail,
dropPositionFor → position, findZone → at, getDraggable / getZone
inlined at call sites, willZoneAccept → accepts, overZoneId → over,
baseDraggables / baseZones → dragRegistry / zoneRegistry, previousOver
→ previous. Replace registerDraggable / registerZone outer functions
with method shorthand on the spread context objects so no multi-word
function names are exposed.

Adapter base method findTicket → locate (single-word verb matching the
v0 adapter vocabulary — date, t, can, generate, filter, etc.). Arrow
handlers in pointer / keyboard adapters stay arrows to avoid
this-aliasing eslint conflicts; the find → locate rename was the only
path that compiled clean without disables.

Merge indicator.ts into index.ts as a private resolveDropPosition
helper. ResolvedPosition becomes private; the 6 unit tests that
directly exercised resolveDropPosition are dropped — coverage rolls up
to the existing onDrop integration tests that flow position.index
through the cancel/drop chain.

Sibling DragItem.vue / DropList.vue: swap import order so vue imports
precede @vuetify/v0 imports per project convention.
Architectural — eliminate cast sprawl:
Widen registry generic to <Input, Input & Ticket> so .get() returns the
intersection directly. Removes 8 (registry.get(id) as Input & Output)
casts at lifecycle call sites. Drop distributivity from DraggableTicket
(not load-bearing for narrowing); keep it on ActiveDrag and
DraggableTicketInput where correlated narrowing is the headline feature.

Behavioral correctness:
- Implement `disabled` on draggables (skip start) and zones (skip in
  hit-test); previously declared but never honored.
- Wrap consumer hooks in safeCall helper; ensure active.value clears in
  finally so a throwing hook can't wedge the drag forever.
- Guard adapter setup() against re-entry — replace cleanup, log warn.
- Fire cancel chain in onScopeDispose if drag is active so consumer
  rollback runs before teardown. Wrap each disposer call so one throw
  doesn't skip registry dispose.
- Listen to unregister:ticket on both registries; cancel orphaned drag
  if source unregisters mid-drag, clear over field if zone disappears.
- KeyboardAdapter checks ticket.el.value for null with a useful warn,
  no more cryptic getBoundingClientRect on null.

Naming + JSDoc:
- dragRegistry/zoneRegistry -> _draggables/_zones (underscore-mirror).
- register(input) -> register(registration) (createSelection precedent).
- Add @module JSDoc to all 4 adapter files; @example blocks on the
  useDragDrop factory and on PointerAdapter / KeyboardAdapter classes.
- Use the ./adapters barrel from the parent index.ts.
- DragDropAdapterEmit type -> interface for object-contract consistency.
- childRects -> rects; i,r -> index,rect inside resolveDropPosition.

Tests +5: drop happy-path, empty oriented zone returns index:0,
disabled zone is skipped in hit-test, onLeave/onEnter fires before
per-callback onMove, adapter dispose runs on scope teardown.
Per feedback_name_composable_instance: don't chain method calls on
use*() invocations. Hoist `const logger = useLogger()` to the top of
the useDragDrop factory and to the top of each adapter setup() so the
10 scattered useLogger().warn / useLogger().error calls become single
references. Also multi-line the keyboard adapter's emit.start call for
readability.
Every type-level @example now starts with
`import { useDragDrop } from '@vuetify/v0'` plus a factory
instantiation so the snippet renders as runnable in the docs API page.
Previously the bodies referenced an undefined `dnd`. Affects 10 types:
DropIndicator, DropPosition, ActiveDrag, DraggableTicketInput,
DraggableTicket, DropZoneTicketInput, DropZoneTicket, DragDropPlugin,
DragDropOptions, DragDropContext.
johnleider added 14 commits May 6, 2026 09:51
…vers

The indicator computed previously called getBoundingClientRect on every
child of the over-zone every time active.value changed (~60 Hz during
a pointer drag). The old comment claimed caching but active.value is a
tracked dep, so the cache invalidated every move.

Replace with a real cache: a per-zone shallowRef<DOMRect[]> refreshed
only when useResizeObserver fires (zone resized) or useMutationObserver
fires with childList changes (children added / removed). Pointer moves
now do an O(N) scan against cached rects instead of O(N) layout queries.
position() drops its own duplicate rect collection — reads the zone's
cached indicator directly. Empty-oriented-zone fallback (index 0) is
preserved.

For sortable lists with many items the win is meaningful; for small
lists it's the same shape but no longer accidentally O(N) layout.
Adapters section restructured: PointerAdapter and KeyboardAdapter each
get their own H3 with an option table (threshold for pointer; activate
+ step for keyboard). Replacing-the-defaults and Custom-adapters move
into subsections so the abstract-class extension example is part of
the adapter story rather than orphaned.

The Recipes "Custom adapters?" recipe is now redundant — the new
Adapters subsections cover both how to define a custom adapter (via
the abstract base) and how to use it (via adapters: [new MyAdapter()]).
Drop the recipe entirely along with its trailing question mark.

Reactivity: add the success-check icon to the "every state field is
reactive" header line so the visual cue matches sibling pages
(createSelection, createStep). Correct the stale claim that
getBoundingClientRect runs per active.value change — after the
zone-rects cache landed, it runs only on resize / child mutation.
H1: per-zone useResizeObserver / useMutationObserver bound to the
calling component's scope, not the zone registration's lifecycle. If
a consumer called zone.unregister() while the component stayed mounted
(realistic in dynamic kanban use cases), the observers leaked. Wrap
per-zone observer setup in a dedicated effectScope() and stop it on
the matching _zones unregister:ticket event.

M1: useMutationObserver(childList: true) doesn't fire on initial
attach (spec); useResizeObserver fires async after attach. Populated
oriented zones could see rects.value === [] on the first pointer move
inside the same microtask. Prime rects synchronously via
watch(el, refresh, { immediate: true, flush: 'post' }) so the first
move sees a populated cache.

L1: drop `=== true` from the disabled gate in onStart() — every
sibling composable uses bare-truthy `if (toValue(disabled))`.

L2: add @example blocks to DraggablesContext and ZonesContext that
slipped through the recent self-contained-example pass.

M2: comment the invariant that registry dispose() clears listeners
before clear:registry fires, so the unregister:ticket handlers are
silent during factory teardown — the hot-drag teardown relies on
cancel() running first.
…ecks

Pressing Space or Enter anywhere on the page was silently calling
preventDefault even when no draggable was focused and no drag was active,
blocking page scroll, button activation, and form submit. Move the
preventDefault inside the isActive and ticket branches so default behavior
only suppressed when the keypress is genuinely consumed by a drag.
…cleanup

- Wrap exposed `active` ref in shallowReadonly so the Readonly<ShallowRef>
  type contract is enforced at runtime, not just at the type level.
- Track per-zone observer scopes in a `book` Map and stop them in the
  factory's onScopeDispose, plugging the listener-clear race in
  _zones.dispose() that previously orphaned observer scopes on registry
  mass-clear.
- Replace the O(zones * depth) zone walk inside at() with a `nodes` Map
  keyed on element, kept in sync via per-zone watchers and onWatcherCleanup.
  Pointer moves now do O(depth) lookups against the cache.
- Replace `(accept as string[]).includes(...)` with a typed `includes`
  through K['type'].
- Improve DropIndicator @example to demonstrate consuming index/edge/rect
  rather than just restating the access path.
- Rewrite test mutations through a CaptureAdapter pattern instead of
  `(dnd.active as any).value = ...`, plus add coverage for cancel chain,
  accepts predicate false path, oriented indicator math, pointer
  threshold, custom keyboard step/activate keys, double-setup warning,
  and throwing-hook recovery.
- Promote maturity to preview now that implementation, tests, and docs
  have all landed.
The exposed `query` ref was typed Readonly<ShallowRef<string>> but only
through a cast — the underlying ref was still writable at runtime, and
TypeScript's Readonly contract was a lie. Wrap with shallowReadonly so
the runtime enforces the same contract the type advertises. Sibling fix
to the same anti-pattern caught in useDragDrop's `active` ref.
Add a built-in adapters overview table at the top of the Adapters
section (PointerAdapter, KeyboardAdapter, DragDropAdapter base) per the
adapter-section docs convention. Wrap the existing reactive-fields table
under a Reactive fields subheading so the Reactivity section can grow
additional subsections later.
Add a third adapter-naming pattern alongside the Vuetify0-prefix default
and the third-party-branding rule: input-source adapters
(useDragDrop's PointerAdapter and KeyboardAdapter) ship as defaults but
take their name from the input modality, not from a brand, because
multiple are installed simultaneously rather than swapped. Keeps the
existing useDragDrop names in line with the rule.
- safeCall reads as a defensive call; next reads as "advance the lifecycle
  chain" which is what each invocation actually does. Rename the local
  ActiveDrag draft in onMove from `next` to `draft` to free the name and
  match the existing convention in onStart.
- Narrow the Partial<Input & Output> register casts to
  Partial<Input & RegistryTicket> — matches createRegistry's actual
  parameter shape and drops a redundant intersection.
- Rename the discriminated-type generic K to Z across the composable and
  adapters to match the convention used by every other registry-based
  composable in v0 (createModel, createSelection, createForm, …).
- Convert DraggablesContext and ZonesContext from `type X = Omit<...> & { ... }`
  to `interface X extends Omit<...> { ... }` to match the existing pattern
  in createModel, createSelection, createNested, useFeatures, useTheme,
  useStack, etc.
- Rename `decorated` to `input` at both register sites; the local IS the
  input being passed to the registry.
- Pass `true` as onScopeDispose's second argument so the factory's DOM /
  observer teardown is deferred (matches createObserver, useHotkey,
  useMediaQuery, useToggleScope, useLazy).
- Strip inline comments that restated the code; keep only the two that
  document non-obvious design choices (the empty-zone index-0 default and
  the "keep drag alive when zone unmounts" decision).
…nregister

- onMove computes willAccept against the new draft, not the stale active.value
- PointerAdapter tracks downId to ignore secondary pointers (multi-touch overwrote downSource)
- KeyboardAdapter preventDefault only when an Arrow branch actually matched
- Zone unregister fires the departing zone's onLeave before clearing over/willAccept
- locate() narrows EventTarget via instanceof Element instead of an unsound cast
- DragItem: tabindex="0", role="listitem", onBeforeUnmount(() => ticket.unregister())
- DropList: role="list", onBeforeUnmount(() => zone.unregister())
- basic: live region (role="status" aria-live="polite") that announces moves to screen readers

The previous example shipped non-focusable draggables (keyboard adapter could
not pick them up) and never deregistered on unmount, leaking registry tickets.
The list-of-lists ARIA semantics now match what the page's Accessibility
section calls for.
Source was renamed K→Z in 1368086; the docs page lagged. KanbanTypes (a user
type literal in the recipe) is left untouched.
The Cleanup table previously labeled onScopeDispose(cleanup, true) as
"deferred"; per Vue 3.5+ the second arg is failSilently and only suppresses
the no-active-scope dev warning. Updates the row to reflect the actual
semantics so callsites that pass true (useTheme, useLazy, useHotkey,
useToggleScope, etc.) read correctly.
@johnleider johnleider force-pushed the feat/create-drag-drop branch from b395a60 to c826ed3 Compare May 6, 2026 14:58
johnleider added 9 commits May 6, 2026 10:35
`resolveDropPosition` now resolves coords landing in margin/padding/gap
between children to the nearest boundary, instead of falling through to
"after last" — the latter mis-positioned drops in any layout with `gap`,
`margin`, or padding between items.

`KeyboardAdapter` no longer hijacks Space/Enter inside `<input>`,
`<textarea>`, `<select>`, `[contenteditable]`, or ARIA textbox/searchbox/
combobox/spinbutton, and ignores activation keys held with any modifier
(Ctrl/Shift/Alt/Meta) — both regressions for draggables containing
editable content or apps with their own keyboard shortcuts.
…Via, rename scopes

- Drop `DragDropAdapterInterface` per the rule that every adapter is an
  abstract class only (no parallel `XxxAdapterInterface`); `DragDropAdapter`
  is now the sole contract. `DragDropOptions.adapters` types as `DragDropAdapter<Z>[]`.
- Drop internal `ResolvedPosition` (structural duplicate of public `DropIndicator`).
- Extract `DragVia = Extensible<'pointer' | 'keyboard'>` type alias; reuse it
  across `ActiveDrag.via`, `onStart`'s parameter, and `DragDropAdapterEmit.start`.
- Rename `book` → `scopes` (Map<ID, EffectScope>); wrap dispose loop in
  try/catch + logger.error to match the surrounding `_draggables.dispose()`
  / `_zones.dispose()` defensive pattern.
- Use `isElement` from `#v0/utilities` instead of raw `instanceof Element`.
- `vetoed` now identifies which hook threw (zone vs options) in the log
  message instead of hardcoding 'onBeforeDrop'.
- Replace `context.active.value!` non-null assertion with explicit guard.
- Add inline comment on each `this`-using arrow handler in pointer/keyboard
  adapters explaining why arrow form is required (lexical `this`).
- Reorder import sections in adapter files per perfectionist/sort-imports.
- Test: migrate `CaptureAdapter` to `extends DragDropAdapter`; spy on
  `console.error` in throwing-hook test to satisfy the zero-stderr policy.
Picks up DocsApiIndex / DocsApiSearch in the components manifest, and
drops the now-unemitted `_ExtractParamParserType` import from the typed
router declaration.
`bail()` and `onDrop` now clear `active.value` BEFORE firing hooks. The
prior `try/finally` left the drag live during hook invocation, so a hook
calling `dnd.cancel()` or `ticket.unregister()` (which triggers the
unregister-handler's own `cancel()` call) re-entered `bail()` with the
same drag and double-fired the cancel chain.

`safeAccept` now detects when an `accept` function returns a Promise
and rejects with a logger.warn. `Boolean(Promise)` is always `true`, so
an `async` accept predicate would silently approve every drop regardless
of the eventual resolved value. Inlining the prior module-level `accepts`
helper into `safeAccept` puts the predicate call inside the logger's
scope so the warning can fire from the right place.

Hooks fired by the cancel/drop chains now read `drag` (the captured
snapshot) as the source of truth; `context.active.value` is already
cleared at hook-fire time.
…DragType example

- `DraggableTicket<Z>` is now a distributive conditional (`Z extends
  DragType ? RegistryTicket & {...} : never`), matching the existing
  shape of `DraggableTicketInput<Z>`. Previously a plain interface — for
  union `Z`, `type` and `value` widened independently to the cross-product
  rather than carrying per-member correlation. Now `if (ticket.type ===
  'card') ticket.value` narrows to the matching member's `value`.
- Disposer teardown switches to LIFO via `disposers.toReversed()`. Plugins
  installed on top of adapters tear down before the adapters they hooked.
- `DragType` interface now has an `@example` showing the discriminated-
  union extension pattern, per the per-symbol `@example` rule.
…ivity row for active.via

- Accessibility section gains a "Post-drop focus" recipe explaining the
  `nextTick` + refocus-by-id pattern, branched on
  `active.value.via === 'keyboard'` so pointer drags are unaffected.
- Adapters section names `DragVia` directly in prose instead of
  paraphrasing the underlying `Extensible<...>` shape.
- Reactivity table gains a `dnd.active.value.via` row pointing readers
  at the discriminator they need for keyboard-only branching.
`safeAccept`'s `instanceof Promise` check missed thenables from polyfilled
or third-party promise libraries (q, bluebird, et al.) — those fell
through to `Boolean(result)` and silently approved the drop. Switched to
a duck-typed `.then` check that catches any thenable.

Also documented the synchronous-only contract on `accept`'s field JSDoc.
…ntract

The post-drop focus recipe read `active.value.via` — but `active.value`
is `null` inside `onDrop` after the cleared-before-notify fix, so the
recipe would have thrown `Cannot read 'via' of null`. Switched to
`drag.via` (the first argument to `onDrop`).

Added a TIP to the Vetoing drops section making the hook contract
explicit: hooks fire after `active.value` is cleared, so consumers must
read the `drag` argument rather than re-reading the reactive ref. Also
noted the synchronous-only restriction on `accept` predicates.
Replaces raw `result !== null && typeof result === 'object'` with the
`isObject` utility per the project's hard rule on type guards. Narrowing
via `isObject` also drops the `as { then?: unknown }` cast.
@johnleider johnleider merged commit 748dc1c into master May 7, 2026
15 of 17 checks passed
@johnleider johnleider deleted the feat/create-drag-drop branch May 7, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant