Skip to content

V3 - migration to TypeScript#68

Open
sunpietro wants to merge 11 commits into
masterfrom
v3
Open

V3 - migration to TypeScript#68
sunpietro wants to merge 11 commits into
masterfrom
v3

Conversation

@sunpietro
Copy link
Copy Markdown
Owner

No description provided.

sunpietro added 6 commits May 1, 2026 16:04
Captures the architectural decisions reached during the planning session:
goal scope, public API redesign (class + typed event emitter), distribution
shape (npm + unpkg/jsdelivr, no committed dist), build toolchain (Rollup,
ESM-only, target ES2022), source module layout, strict mode policy, test
strategy (Vitest + Playwright), branch model, and the deliverable PR
sequence into the v3 branch.
Replaces the legacy build pipeline (module-generator.js + uglifyjs) with a
modern TypeScript-first setup, ahead of the v3 rewrite landing in subsequent
PRs into this branch.

- package.json: type:module, version 3.0.0-dev, ESM-only exports map,
  files allow-list, new scripts (build/dev/typecheck/lint/test/test:unit/
  test:e2e/format/release).
- Replace deps: drop cypress, @testing-library/cypress, eslint-plugin-cypress,
  cypress-mcp, uglify-js. Add typescript, rollup + plugins (typescript,
  node-resolve, terser, dts), tslib, vitest, happy-dom, @playwright/test,
  typescript-eslint, @types/node.
- tsconfig.json: strict + noUncheckedIndexedAccess, target ES2022,
  moduleResolution bundler, declaration + sourcemap, rootDir src.
- rollup.config.js: two-pass (JS bundle + .d.ts bundle), banner inlined
  (lifted from dragster-comment.js), terser-minified ESM output.
- vitest.config.ts: happy-dom env, src/**/*.test.ts.
- playwright.config.ts: Chromium + Firefox + WebKit projects, webServer
  runs build + serve on port 8370.
- eslint.config.js: ESM, drop eslint-plugin-cypress, add typescript-eslint
  with type-aware rules for src/, ignore legacy 2.x files until PR 9.
- scripts/bump-version.js: ESM with import.meta.url for __dirname.
- .husky hooks: commit-msg validates Conventional Commits format always,
  auto-bumps version only on master and only for feat/fix/breaking;
  post-commit auto-tags only on master and skips when no version change;
  pre-push runs typecheck + lint + unit tests (replaces removed
  test:headless cypress runner).
- .github/workflows/ci.yml: typecheck, lint, unit, build, then Playwright
  cross-browser e2e on PR.
- src/index.ts: stub class so the build produces a valid dist artefact;
  real implementation lands in PRs 2-7.
- .gitignore: dist, coverage, playwright-report, .DS_Store; drop cypress
  paths.
Lifts the pure DOM helpers tangled inside dragster-script.js into a small
self-contained module that other PRs in the v3 series can depend on. No
module-level state; every export is either pure or a single, predictable
side effect.

Exports:
- findAncestor(start, predicate, stopAt?) — replaces getElement's recursive
  walk with an iterative version. The optional stopAt boundary preserves the
  "do not cross a non-drag-only region" semantics the original encoded
  inline.
- insertBefore / insertAfter — null-safe wrappers around parentNode.insertBefore.
- removeNode — accepts null/undefined and detached nodes; uses Element.remove()
  rather than parentNode.removeChild().
- getEventPoint(event) + isTouchEvent(event) — normalize the changedTouches[0]
  vs MouseEvent branch the original repeated 3+ times in mousedown/mousemove/mouseup.
  isTouchEvent is a typed user-defined type guard.
- createElement<K>(tag, { classes, dataset, style }) — eliminates the
  document.createElement(DIV) + classList.add + dataset assignment boilerplate
  that the placeholder/shadow/wrapper/temp-container creators all repeated.
- getVerticalMargins — parses computed marginTop + marginBottom into numbers
  with a 0 fallback for NaN. Used by regions for height calculations.

Tests: 26 cases in src/dom.test.ts under happy-dom. v8 coverage on dom.ts is
100% statements / 100% branches / 100% functions / 100% lines.

Adds @vitest/coverage-v8@^2 (pinned to match vitest@2; v4 of the coverage
provider requires vitest@4 which we are not on).
Replaces the seven onBefore*/onAfter* callback options from the 2.x options
bag with a single typed pub/sub surface. Public consumers will hit this via
dragster.on('beforeDragStart', info => …) once the public class is wired in
PR 7; for now the module is internal infrastructure for state-machine.ts.

Module exports:
- DragsterEventInfo: payload shape mirroring legacy event.dragster, so
  consumer code reading event.dragster.drag.node etc. ports across with
  identical field paths.
- DragsterEventMap / DragsterEventName: discriminated event-name → payload
  mapping. New events can be added without breaking existing subscribers.
- DragsterListener<E>: void | false return type encodes the cancellation
  contract (return false from a before* listener to cancel; after* listeners
  are fire-and-forget).
- createDefaultEventInfo(): factory producing a fresh nulled DragsterEventInfo
  on each call. Replaces the 2.x JSON.parse(JSON.stringify(defaults)) deep
  clone trick.
- EventEmitter<EventMap>: minimal generic emitter. on/off/emit/
  removeAllListeners/listenerCount. Cancellation aggregates: every listener
  runs regardless, emit returns false if any returned false. Iteration is
  snapshot-safe — listeners added or removed during emit() do not affect the
  in-flight emission.

EventMap is left unconstrained on the generic so it accepts both interface
and type-alias shapes. Constraining to Record<string, unknown> rejected
DragsterEventMap (interfaces lack implicit string index signatures).

Tests: 23 cases in src/events.test.ts. v8 coverage on events.ts is 100% on
all four metrics. The "type-level (compile-time)" describe block uses
@ts-expect-error to assert that unknown event names, wrong payload shapes,
and listeners returning non-(void|false) values fail to compile;
expectTypeOf checks payload inference inside listener bodies.
@sunpietro sunpietro self-assigned this May 1, 2026
sunpietro added 5 commits May 1, 2026 17:34
First behavioural module of the v3 rewrite. Resolves the deferred
layout-thrashing concern from the earlier audit (recorded in
project_dragster_audit memory).

The legacy updateRegionsHeight already separated reads from writes via
.map() then .forEach(), but the contract was implicit and one careless
refactor away from being broken. Hundreds of draggable elements per
region — multiplied by every pointer-move tick — make it worth pinning
the contract down with a test.

Module exports:
- RegionTracker class
  - constructor(config) discovers regions and tags them with the internal
    CLASS_REGION class and the instance's data-dragster-id stamp.
  - refresh() re-runs discovery; nodes that no longer match the selector
    get untagged, new matches get tagged, the rest stay tagged.
  - getRegions() returns a readonly snapshot for consumer iteration.
  - updateHeights() is the layout-thrash-safe path: phase 1 reads every
    element's offsetHeight + computed margins into a buffer, phase 2
    applies all style.height writes from the buffer. No DOM read happens
    after the first DOM write within a single call. Empty regions keep
    their existing height (matches legacy 2.x semantics).
  - destroy() untags every tracked region while preserving any inline
    style.height set during the lifetime, and only clears its own
    data-dragster-id stamp (a different instance taking ownership of
    the same node is left intact).

- isRegionBoundary(element, dragOnlyClass) — replaces the
  region-but-not-drag-only stopAt check the legacy getElement walked
  inline. Lets state-machine.ts pass it as the stopAt argument to
  findAncestor without leaking the internal CLASS_REGION constant.

CLASS_REGION ('dragster-drag-region') stays module-private; consumers
go through isRegionBoundary or look at the tracker output.

Tests: 17 cases in src/regions.test.ts. v8 coverage on regions.ts is
100% statements / 100% functions / 100% lines / 96.3% branches (the
uncovered branch is the defensive measurements[i] !== undefined check
that the buffer length contract makes unreachable). The
"layout-thrash batching" describe block intercepts each item's
offsetHeight getter and each region's style.height setter, then
asserts lastIndexOf('read') < indexOf('write') across the full op
trace — the test fails the moment any future refactor interleaves a
read after a write.
Owns three concerns: pointer-mouse/touch normalisation, shadow element
create/position/cleanup, and lifecycle event emission with cancellation.
Placeholder positioning, scroll-on-edge, and the actual reorder are out
of scope for this module — they wire in via the `onDrop` injection
point or via subscribing to lifecycle events directly.

State model is two-valued (`idle` / `dragging`). The legacy transient
states (`picking`, `dropping`) are not modelled because pickup and drop
are atomic in the rewrite; there is no async work between phases.

40 vitest tests cover every transition, cancellation path, touch
normalisation, destroy semantics, and region rebind including mid-drag.
…croll

placeholder.ts: PlaceholderManager owns the drop-slot indicator's lifetime.
update() takes a pointer target + cursor Y and converges on a single DOM
state — top/bottom relative to a draggable, or appended to a region. Repeat
calls with the same input perform zero DOM mutations after the first insert.
Cleaner than legacy: placeholder lands as a SIBLING of the wrapper rather
than nested inside it, simplifying the drop reorder PR 7 will wire in.

scroll.ts: autoScroll(clientY) reads window.innerHeight on each call (no
cached value) and steps the viewport when the cursor sits within
SCROLL_EDGE_THRESHOLD (60px) of an edge. The constants are named exports
so consumers and tests can refer to them by name.

Both modules deliberately leave the wiring to PR 7. Replace-elements mode
is also out of scope here — it shifts the placeholder semantics enough
that bundling it in would obscure the basic decision tree this PR
establishes.

34 new vitest tests (25 placeholder + 9 scroll) at 100% coverage on both
modules.
Composes RegionTracker, PlaceholderManager, StateMachine, and autoScroll
into the typed Dragster class promised by the §2.4 surface. Construction
wraps user items into internal dragster-draggable wrappers stamped with a
per-instance id, sets up tracking, and arms pointer-down listeners.
on()/off() delegate to the EventEmitter; update()/updateRegions() pick up
newly-added DOM; destroy() tears everything down idempotently.

Drop reorder: at pointer-up, the dragged wrapper takes the placeholder's
slot via insertBefore + clear. Cleaner than legacy 2.x — no per-mode
dispatch (move/replace/clone deferred), no nested-inside-wrapper
placeholder placement, no separate dropActions module.

Two state-machine extensions land alongside:
  - onMove hook symmetric with onDrop, exposing live cursor coordinates
    (the public event payload doesn't carry them) so the orchestrator
    can drive elementFromPoint and autoScroll;
  - defensive copy of the regions array so RegionTracker.refresh's
    in-place mutation doesn't strand listener handles on removed regions.
Shadow gets pointer-events:none so elementFromPoint always sees through it.

163 vitest tests across 7 files, 100% lines coverage on every module.
index.html now loads ./dist/dragster.js as an ES module and constructs
each Dragster instance with `new Dragster(...)`. The clone-elements (d0)
and replace-elements (d3) options are dropped from the demo init since
those modes are deferred to a follow-up PR; the static markup stays in
place so the diff stays small when they land.

Replaces cypress/e2e/dragster.cy.js with tests/e2e/dragster.spec.ts:
basic-move, source-region-height, post-drop cleanup, and drag-only-region
non-acceptance — passing on all three Playwright projects (Chromium,
Firefox, WebKit). Replace and clone specs are recorded as test.fixme
markers so the matrix stays visible. The legacy Cypress directory
remains for now and gets deleted in PR 9.

Two supporting changes:
  - tsconfig.json now includes tests/. Build-time config split into
    tsconfig.build.json so rollup keeps its src-only rootDir.
  - src/index.ts handleMove walks up from elementFromPoint to the
    nearest HTMLElement. Without this, a cursor over an inline `<svg>`
    descendant (which lives inside several .dragster-block items in the
    demo) hands a non-HTMLElement to the placeholder logic and the drop
    slot never updates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant