@zvndev/darkmown 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +54 -3
  3. package/docs/cli.md +46 -16
  4. package/docs/spec-alignment.md +9 -3
  5. package/package.json +1 -1
  6. package/site/_/roadmap.json +3 -1
  7. package/site/pages/app.wd +1 -1
  8. package/site/pages/data.wd +2 -2
  9. package/site/pages/docs/index.wd +21 -0
  10. package/site/pages/folio/cart.wd +2 -2
  11. package/site/pages/forge/index.wd +1 -1
  12. package/site/pages/interactions/index.skin +113 -0
  13. package/site/pages/interactions/index.wd +86 -0
  14. package/src/api-runner.js +219 -0
  15. package/src/behaviors/carousel.js +153 -0
  16. package/src/behaviors/sortable.js +175 -0
  17. package/src/builder.js +199 -4
  18. package/src/cli.js +79 -54
  19. package/src/compiler/body.js +14 -0
  20. package/src/compiler/context.js +12 -1
  21. package/src/compiler/directives.js +123 -0
  22. package/src/compiler/loops.js +57 -8
  23. package/src/compiler/page.js +9 -3
  24. package/src/deploy.js +174 -0
  25. package/src/headers.js +4 -0
  26. package/src/runtime.js +3 -1
  27. package/src/scaffold.js +79 -147
  28. package/src/templates/blog/README.md +25 -0
  29. package/src/templates/blog/site/_/nav.wd +6 -0
  30. package/src/templates/blog/site/_/posts.json +16 -0
  31. package/src/templates/blog/site/pages/index.skin +51 -0
  32. package/src/templates/blog/site/pages/index.wd +24 -0
  33. package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
  34. package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
  35. package/src/templates/dashboard/README.md +25 -0
  36. package/src/templates/dashboard/api/metrics.js +15 -0
  37. package/src/templates/dashboard/site/_/nav.wd +6 -0
  38. package/src/templates/dashboard/site/pages/index.skin +74 -0
  39. package/src/templates/dashboard/site/pages/index.wd +75 -0
  40. package/src/templates/landing/README.md +25 -0
  41. package/src/templates/landing/site/_/nav.wd +6 -0
  42. package/src/templates/landing/site/pages/index.skin +140 -0
  43. package/src/templates/landing/site/pages/index.wd +54 -0
  44. package/src/templates/starter/README.md +32 -0
  45. package/src/templates/starter/site/_/nav.wd +7 -0
  46. package/src/templates/starter/site/pages/about.md +10 -0
  47. package/src/templates/starter/site/pages/index.skin +47 -0
  48. package/src/templates/starter/site/pages/index.wd +27 -0
  49. package/src/templates/store/README.md +28 -0
  50. package/src/templates/store/api/checkout.js +18 -0
  51. package/src/templates/store/site/_/nav.wd +6 -0
  52. package/src/templates/store/site/_/products.json +8 -0
  53. package/src/templates/store/site/pages/index.skin +100 -0
  54. package/src/templates/store/site/pages/index.wd +83 -0
@@ -0,0 +1,219 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * The local-dev serverless API runner. Gives `darkmown dev` the SAME `/api/*`
7
+ * behavior a project gets on Vercel / Cloudflare in production, so a `:fetch
8
+ * /api/x` or a form POST works identically before you ever deploy.
9
+ *
10
+ * The convention is deliberately plain: a backend endpoint is a plain-JS file
11
+ * under the project's top-level `api/` directory that default-exports a
12
+ * Web-standard handler `(request: Request, context) => Response | Promise<Response>`.
13
+ * That is the exact signature Vercel Edge, Cloudflare Pages, Netlify Edge and
14
+ * Deno already run — Darkmown owns no server, it just emulates the host locally.
15
+ *
16
+ * File → route, rooted at the `/api` URL prefix (matching Vercel's `api/`):
17
+ * api/echo.js → /api/echo
18
+ * api/users/index.js → /api/users
19
+ * api/users/list.js → /api/users/list
20
+ * api/users/[id].js → /api/users/:id (context.params.id)
21
+ *
22
+ * Nothing here mutates module state: routes are discovered per call so a newly
23
+ * added function is picked up without a restart, and each handler is imported
24
+ * with an mtime cache-buster so edits hot-reload.
25
+ */
26
+
27
+ /**
28
+ * A discovered API route.
29
+ * @typedef {object} ApiRoute
30
+ * @property {string} file Absolute path to the handler module.
31
+ * @property {string[]} segments Route segments after `/api`; a dynamic segment
32
+ * is stored as `[name]` (e.g. `["users", "[id]"]`).
33
+ */
34
+
35
+ /**
36
+ * Walk `apiDir` and return every `.js`/`.mjs` handler as an {@link ApiRoute}.
37
+ * Files/dirs whose name starts with `.`, `_`, or `-` are treated as private
38
+ * (helpers, fixtures) and never routed, matching the router's hidden-path rule.
39
+ * Returns `[]` when `apiDir` does not exist.
40
+ * @param {string} apiDir Absolute path to the project's `api/` directory.
41
+ * @returns {ApiRoute[]}
42
+ */
43
+ export function discoverApiRoutes(apiDir) {
44
+ if (!fs.existsSync(apiDir)) return [];
45
+ /** @type {ApiRoute[]} */
46
+ const routes = [];
47
+ /** @param {string} dir @param {string[]} trail */
48
+ const walk = (dir, trail) => {
49
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
50
+ if (/^[._-]/.test(entry.name)) continue; // private: helpers, fixtures, drafts
51
+ const abs = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ walk(abs, [...trail, entry.name]);
54
+ continue;
55
+ }
56
+ const ext = path.extname(entry.name);
57
+ if (ext !== ".js" && ext !== ".mjs") continue;
58
+ const base = entry.name.slice(0, -ext.length);
59
+ // `index` collapses onto its directory: api/users/index.js → /api/users.
60
+ const segments = base === "index" ? trail : [...trail, base];
61
+ routes.push({ file: abs, segments });
62
+ }
63
+ };
64
+ walk(apiDir, []);
65
+ // Static routes before dynamic ones so an exact file always beats a `[param]`.
66
+ return routes.sort((a, b) => dynamicCount(a.segments) - dynamicCount(b.segments));
67
+ }
68
+
69
+ /**
70
+ * @param {string[]} segments
71
+ * @returns {number} How many segments are dynamic (`[param]`).
72
+ */
73
+ function dynamicCount(segments) {
74
+ return segments.filter((s) => s.startsWith("[")).length;
75
+ }
76
+
77
+ /**
78
+ * Match a request pathname against discovered routes. The pathname is expected
79
+ * to start with `/api`; anything else returns `null` (caller serves static).
80
+ * @param {ApiRoute[]} routes
81
+ * @param {string} pathname Request pathname (no query string).
82
+ * @returns {{ file: string, params: Record<string, string> } | null}
83
+ */
84
+ export function matchApiRoute(routes, pathname) {
85
+ if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
86
+ const parts = pathname.split("/").filter(Boolean).slice(1); // drop leading "api"
87
+ for (const route of routes) {
88
+ if (route.segments.length !== parts.length) continue;
89
+ /** @type {Record<string, string>} */
90
+ const params = {};
91
+ let ok = true;
92
+ for (let i = 0; i < parts.length; i++) {
93
+ const seg = route.segments[i];
94
+ if (seg.startsWith("[") && seg.endsWith("]")) {
95
+ params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
96
+ } else if (seg !== parts[i]) {
97
+ ok = false;
98
+ break;
99
+ }
100
+ }
101
+ if (ok) return { file: route.file, params };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
108
+ * methods that carry one) is buffered fully — dev endpoints are small and this
109
+ * avoids the `duplex: "half"` streaming dance.
110
+ * @param {import("node:http").IncomingMessage} req
111
+ * @returns {Promise<Request>}
112
+ */
113
+ export async function nodeRequestToWeb(req) {
114
+ const host = req.headers.host || "localhost";
115
+ const url = new URL(req.url || "/", `http://${host}`);
116
+ /** @type {RequestInit} */
117
+ const init = { method: req.method, headers: /** @type {HeadersInit} */ (req.headers) };
118
+ const method = (req.method || "GET").toUpperCase();
119
+ if (method !== "GET" && method !== "HEAD") {
120
+ // A Node Buffer is a valid BodyInit at runtime; the DOM lib's narrower type
121
+ // doesn't list it, so widen at the assignment.
122
+ init.body = /** @type {BodyInit} */ (/** @type {unknown} */ (await readBody(req)));
123
+ }
124
+ return new Request(url, init);
125
+ }
126
+
127
+ /**
128
+ * @param {import("node:http").IncomingMessage} req
129
+ * @returns {Promise<Buffer>}
130
+ */
131
+ function readBody(req) {
132
+ return new Promise((resolve, reject) => {
133
+ /** @type {Buffer[]} */
134
+ const chunks = [];
135
+ req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
136
+ req.on("end", () => resolve(Buffer.concat(chunks)));
137
+ req.on("error", reject);
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Write a Web-standard `Response` back onto a Node `ServerResponse`.
143
+ * @param {Response} response
144
+ * @param {import("node:http").ServerResponse} res
145
+ * @returns {Promise<void>}
146
+ */
147
+ export async function writeWebResponse(response, res) {
148
+ /** @type {Record<string, string | string[]>} */
149
+ const headers = {};
150
+ // `Headers.forEach` folds multiple Set-Cookie into one comma-joined value, which
151
+ // corrupts cookies. Collect every other header here and emit Set-Cookie as a
152
+ // discrete array (Node writeHead emits one header line per array element) so the
153
+ // dev runner preserves the multi-cookie behavior the production hosts give.
154
+ response.headers.forEach((value, key) => {
155
+ if (key.toLowerCase() === "set-cookie") return;
156
+ headers[key] = value;
157
+ });
158
+ const cookies =
159
+ typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
160
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
161
+ res.writeHead(response.status, headers);
162
+ const buffer = Buffer.from(await response.arrayBuffer());
163
+ res.end(buffer);
164
+ }
165
+
166
+ /**
167
+ * Handle a request as an API call if its pathname matches a function under
168
+ * `apiDir`. Returns `true` when it owned the request (a route matched, whether
169
+ * the handler succeeded or threw), `false` when nothing matched so the caller
170
+ * should fall through to static serving.
171
+ *
172
+ * @param {object} io
173
+ * @param {string} io.apiDir Absolute path to the project's `api/` directory.
174
+ * @param {import("node:http").IncomingMessage} io.req
175
+ * @param {import("node:http").ServerResponse} io.res
176
+ * @returns {Promise<boolean>}
177
+ */
178
+ export async function handleApiRequest({ apiDir, req, res }) {
179
+ const pathname = new URL(req.url || "/", "http://localhost").pathname;
180
+ const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
181
+ if (!match) return false;
182
+
183
+ try {
184
+ const handler = await importHandler(match.file);
185
+ const request = await nodeRequestToWeb(req);
186
+ const context = { params: match.params, env: process.env };
187
+ const response = await handler(request, context);
188
+ if (!(response instanceof Response)) {
189
+ throw new TypeError(
190
+ `api/${path.relative(apiDir, match.file)} must return a Response (got ${typeof response}). ` +
191
+ "Use: return Response.json({ ... })"
192
+ );
193
+ }
194
+ await writeWebResponse(response, res);
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ res.writeHead(500, { "content-type": "application/json" });
198
+ res.end(JSON.stringify({ ok: false, error: message }));
199
+ }
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Import a handler module fresh (mtime cache-buster) and return its default
205
+ * export, validating it is callable.
206
+ * @param {string} file Absolute path to the handler module.
207
+ * @returns {Promise<(request: Request, context: { params: Record<string, string>, env: NodeJS.ProcessEnv }) => Response | Promise<Response>>}
208
+ */
209
+ async function importHandler(file) {
210
+ const mtime = fs.statSync(file).mtimeMs;
211
+ const module = await import(`${pathToFileURL(file).href}?v=${mtime}`);
212
+ const handler = module.default;
213
+ if (typeof handler !== "function") {
214
+ throw new TypeError(
215
+ `${path.basename(file)} must \`export default\` a function (request) => Response.`
216
+ );
217
+ }
218
+ return handler;
219
+ }
@@ -0,0 +1,153 @@
1
+ // Behavior: a lightweight, accessible carousel.
2
+ //
3
+ // Native CSS scroll-snap (from the page skin) does the layout + touch swiping; this
4
+ // module adds prev/next buttons, dot navigation, active-state syncing, optional
5
+ // autoplay (`data-wd-carousel-autoplay="3000"`), and mouse drag-to-scroll. It needs
6
+ // NO reactive runtime — emitted only on pages that use `:carousel`, as a standalone
7
+ // `/__wd/behaviors/carousel.js` module.
8
+
9
+ for (const root of document.querySelectorAll("[data-wd-carousel]")) {
10
+ initCarousel(/** @type {HTMLElement} */ (root));
11
+ }
12
+
13
+ /**
14
+ * @param {HTMLElement} root The `:carousel` region.
15
+ * @returns {void}
16
+ */
17
+ function initCarousel(root) {
18
+ const found = root.querySelector("[data-wd-carousel-track]");
19
+ if (!found) return;
20
+ const track = /** @type {HTMLElement} */ (found);
21
+ const slides = /** @type {HTMLElement[]} */ ([...track.children]);
22
+ if (slides.length === 0) return;
23
+
24
+ // Honor the OS "reduce motion" setting: jump instead of smooth-scroll, and
25
+ // never auto-advance (motion the user didn't initiate).
26
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
27
+
28
+ root.setAttribute("role", "region");
29
+ root.setAttribute("aria-roledescription", "carousel");
30
+
31
+ const prev = controlButton("‹", "Previous slide", "wd-carousel-prev");
32
+ const next = controlButton("›", "Next slide", "wd-carousel-next");
33
+ prev.addEventListener("click", () => go(currentIndex() - 1));
34
+ next.addEventListener("click", () => go(currentIndex() + 1));
35
+
36
+ // Plain buttons (not an ARIA tablist — there are no tabpanels to wire to);
37
+ // the active dot is marked with aria-current="true".
38
+ const dots = document.createElement("div");
39
+ dots.className = "wd-carousel-dots";
40
+ dots.setAttribute("role", "group");
41
+ dots.setAttribute("aria-label", "Choose slide to display");
42
+ const dotButtons = slides.map((_, i) => {
43
+ const dot = controlButton("", `Go to slide ${i + 1}`, "wd-carousel-dot");
44
+ dot.addEventListener("click", () => go(i));
45
+ dots.appendChild(dot);
46
+ return dot;
47
+ });
48
+
49
+ root.append(prev, next, dots);
50
+
51
+ /** @returns {number} Index of the slide nearest the track's scroll center. */
52
+ function currentIndex() {
53
+ const mid = track.scrollLeft + track.clientWidth / 2;
54
+ let best = 0;
55
+ let bestDist = Number.POSITIVE_INFINITY;
56
+ slides.forEach((slide, i) => {
57
+ const center = slide.offsetLeft + slide.clientWidth / 2;
58
+ const dist = Math.abs(center - mid);
59
+ if (dist < bestDist) {
60
+ bestDist = dist;
61
+ best = i;
62
+ }
63
+ });
64
+ return best;
65
+ }
66
+
67
+ /** @param {number} i @returns {void} */
68
+ function go(i) {
69
+ const clamped = Math.max(0, Math.min(slides.length - 1, i));
70
+ slides[clamped].scrollIntoView({
71
+ behavior: reduceMotion ? "auto" : "smooth",
72
+ inline: "start",
73
+ block: "nearest"
74
+ });
75
+ }
76
+
77
+ function sync() {
78
+ const i = currentIndex();
79
+ dotButtons.forEach((dot, n) => {
80
+ if (n === i) dot.setAttribute("aria-current", "true");
81
+ else dot.removeAttribute("aria-current");
82
+ });
83
+ prev.disabled = i === 0;
84
+ next.disabled = i === slides.length - 1;
85
+ }
86
+ track.addEventListener("scroll", () => requestAnimationFrame(sync), { passive: true });
87
+ sync();
88
+
89
+ const interval = Number(root.getAttribute("data-wd-carousel-autoplay") || 0);
90
+ if (interval > 0 && !reduceMotion) {
91
+ /** @type {ReturnType<typeof setInterval> | null} */
92
+ let timer = null;
93
+ const advance = () => go(currentIndex() >= slides.length - 1 ? 0 : currentIndex() + 1);
94
+ // start/stop are idempotent so stacked pointerleave/visibility events can't
95
+ // spin up duplicate timers.
96
+ const start = () => {
97
+ if (timer === null) timer = setInterval(advance, interval);
98
+ };
99
+ const stop = () => {
100
+ if (timer !== null) {
101
+ clearInterval(timer);
102
+ timer = null;
103
+ }
104
+ };
105
+ start();
106
+ root.addEventListener("pointerenter", stop);
107
+ root.addEventListener("pointerleave", start);
108
+ document.addEventListener("visibilitychange", () => {
109
+ if (document.hidden) stop();
110
+ else start();
111
+ });
112
+ }
113
+
114
+ // Mouse drag-to-scroll; touch already swipes natively via scroll-snap.
115
+ let dragging = false;
116
+ let startX = 0;
117
+ let startScroll = 0;
118
+ track.addEventListener("pointerdown", (event) => {
119
+ if (event.pointerType === "touch") return;
120
+ dragging = true;
121
+ startX = event.clientX;
122
+ startScroll = track.scrollLeft;
123
+ try {
124
+ track.setPointerCapture(event.pointerId);
125
+ } catch {
126
+ /* not capturable */
127
+ }
128
+ });
129
+ track.addEventListener("pointermove", (event) => {
130
+ if (!dragging) return;
131
+ track.scrollLeft = startScroll - (event.clientX - startX);
132
+ });
133
+ const release = () => {
134
+ dragging = false;
135
+ };
136
+ track.addEventListener("pointerup", release);
137
+ track.addEventListener("pointercancel", release);
138
+ }
139
+
140
+ /**
141
+ * @param {string} label
142
+ * @param {string} aria
143
+ * @param {string} className
144
+ * @returns {HTMLButtonElement}
145
+ */
146
+ function controlButton(label, aria, className) {
147
+ const button = document.createElement("button");
148
+ button.type = "button";
149
+ button.className = className;
150
+ button.textContent = label;
151
+ button.setAttribute("aria-label", aria);
152
+ return button;
153
+ }
@@ -0,0 +1,175 @@
1
+ // Behavior: drag-to-reorder for a `@loop … into … sortable` region.
2
+ //
3
+ // Pointer Events (mouse + touch) reorder the rows live for feedback; on drop the
4
+ // underlying :state/:store list is rewritten through the public `window.wd` API,
5
+ // so the keyed-loop reconciler repaints. Keyboard users reorder a focused row with
6
+ // Arrow Up/Down, with a polite live-region announcement. Pay-for-what-you-use:
7
+ // emitted only on pages that use `sortable`, as a standalone module.
8
+ //
9
+ // The compiler forbids where/sort/reverse/offset/limit on a sortable loop, so the
10
+ // visible row order always equals the array order: DOM indices map 1:1 to the list.
11
+
12
+ const wd = /** @type {any} */ (window).wd;
13
+ let helpSeq = 0;
14
+ if (wd) {
15
+ for (const container of document.querySelectorAll("[data-wd-sortable]")) {
16
+ initSortable(/** @type {HTMLElement} */ (container));
17
+ }
18
+ }
19
+
20
+ /**
21
+ * @param {HTMLElement} container The `data-wd-loop` region carrying data-wd-sortable.
22
+ * @returns {void}
23
+ */
24
+ function initSortable(container) {
25
+ const key = container.getAttribute("data-wd-sortable") || "";
26
+ const out = /** @type {HTMLElement} */ (
27
+ container.querySelector("[data-wd-loop-out]") || container
28
+ );
29
+ const usesHandles = Boolean(out.querySelector("[data-wd-drag-handle]"));
30
+
31
+ // Off-screen helpers: a shared instructions node (referenced via aria-describedby)
32
+ // and a polite live region that announces the new position after a reorder.
33
+ const help = offscreen("Press Arrow Up or Arrow Down to reorder this item.");
34
+ help.id = `wd-sortable-help-${++helpSeq}`;
35
+ const live = offscreen("");
36
+ live.setAttribute("aria-live", "polite");
37
+ container.append(help, live);
38
+
39
+ /** @type {HTMLElement | null} */
40
+ let dragRow = null;
41
+ let startIndex = -1;
42
+
43
+ /** @returns {HTMLElement[]} Row elements (keyed nodes) in current DOM order. */
44
+ const rows = () =>
45
+ /** @type {HTMLElement[]} */ (
46
+ [...out.children].filter((c) => c.hasAttribute("data-wd-loop-key"))
47
+ );
48
+
49
+ // The keyed node is sometimes a `data-wd-loop-piece` wrapper the compiler adds
50
+ // around a non-<li> row template; the author's styled element is then its child.
51
+ // Visual feedback (dragging state), focus, and a11y must land on THAT element.
52
+ /** @param {HTMLElement} row @returns {HTMLElement} */
53
+ const visual = (row) =>
54
+ row.hasAttribute("data-wd-loop-piece")
55
+ ? /** @type {HTMLElement} */ (row.firstElementChild || row)
56
+ : row;
57
+ /** @param {HTMLElement} row @returns {HTMLElement} */
58
+ const grip = (row) =>
59
+ /** @type {HTMLElement} */ (visual(row).querySelector("[data-wd-drag-handle]") || visual(row));
60
+
61
+ // Keep rows focusable + announced for assistive tech across every re-render, and
62
+ // disable touch panning on the grip so a touch-drag reorders instead of scrolling.
63
+ const decorate = () => {
64
+ out.setAttribute("role", "list");
65
+ const list = rows();
66
+ list.forEach((row, i) => {
67
+ const v = visual(row);
68
+ const g = grip(row);
69
+ v.setAttribute("role", "listitem");
70
+ v.setAttribute("aria-roledescription", "sortable item");
71
+ g.setAttribute("tabindex", "0");
72
+ g.setAttribute("aria-keyshortcuts", "ArrowUp ArrowDown");
73
+ g.setAttribute("aria-describedby", help.id);
74
+ g.setAttribute("aria-posinset", String(i + 1));
75
+ g.setAttribute("aria-setsize", String(list.length));
76
+ g.style.touchAction = "none";
77
+ });
78
+ };
79
+ wd.subscribe(key, decorate);
80
+
81
+ out.addEventListener("pointerdown", (event) => {
82
+ const target = /** @type {Element} */ (event.target);
83
+ if (usesHandles && !target.closest("[data-wd-drag-handle]")) return;
84
+ const row = /** @type {HTMLElement | null} */ (target.closest("[data-wd-loop-key]"));
85
+ if (!row || !out.contains(row)) return;
86
+ dragRow = row;
87
+ startIndex = rows().indexOf(row);
88
+ visual(row).setAttribute("data-wd-dragging", "");
89
+ try {
90
+ row.setPointerCapture(event.pointerId);
91
+ } catch {
92
+ /* element not capturable — drag still works via document hit-testing */
93
+ }
94
+ event.preventDefault();
95
+ });
96
+
97
+ out.addEventListener("pointermove", (event) => {
98
+ if (!dragRow) return;
99
+ const over = rowFromPoint(event.clientX, event.clientY);
100
+ if (!over || over === dragRow) return;
101
+ const list = rows();
102
+ if (list.indexOf(dragRow) < list.indexOf(over)) over.after(dragRow);
103
+ else over.before(dragRow);
104
+ });
105
+
106
+ const drop = () => {
107
+ if (!dragRow) return;
108
+ visual(dragRow).removeAttribute("data-wd-dragging");
109
+ const endIndex = rows().indexOf(dragRow);
110
+ dragRow = null;
111
+ if (endIndex !== -1 && endIndex !== startIndex) reorder(startIndex, endIndex);
112
+ };
113
+ out.addEventListener("pointerup", drop);
114
+ out.addEventListener("pointercancel", drop);
115
+
116
+ out.addEventListener("keydown", (event) => {
117
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
118
+ const row = /** @type {HTMLElement | null} */ (
119
+ /** @type {Element} */ (event.target).closest("[data-wd-loop-key]")
120
+ );
121
+ if (!row) return;
122
+ const list = rows();
123
+ const from = list.indexOf(row);
124
+ const to = event.key === "ArrowUp" ? from - 1 : from + 1;
125
+ if (to < 0 || to >= list.length) return;
126
+ event.preventDefault();
127
+ reorder(from, to);
128
+ // Re-focus the moved row's grip after the reconcile re-render, and announce it.
129
+ requestAnimationFrame(() => {
130
+ const moved = rows()[to];
131
+ if (moved) {
132
+ grip(moved).focus();
133
+ live.textContent = `Moved to position ${to + 1} of ${rows().length}.`;
134
+ }
135
+ });
136
+ });
137
+
138
+ /**
139
+ * @param {number} x
140
+ * @param {number} y
141
+ * @returns {HTMLElement | null}
142
+ */
143
+ function rowFromPoint(x, y) {
144
+ const el = document.elementFromPoint(x, y);
145
+ return el ? /** @type {HTMLElement | null} */ (el.closest("[data-wd-loop-key]")) : null;
146
+ }
147
+
148
+ /**
149
+ * Move the underlying list item from `from` to `to` and let the runtime repaint.
150
+ * @param {number} from
151
+ * @param {number} to
152
+ * @returns {void}
153
+ */
154
+ function reorder(from, to) {
155
+ const list = wd.get(key);
156
+ if (!Array.isArray(list) || from < 0 || from >= list.length) return;
157
+ const next = list.slice();
158
+ const [moved] = next.splice(from, 1);
159
+ next.splice(to, 0, moved);
160
+ wd.set(key, next);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A visually-hidden element (kept in the a11y tree) for instructions / live region.
166
+ * @param {string} text
167
+ * @returns {HTMLElement}
168
+ */
169
+ function offscreen(text) {
170
+ const node = document.createElement("div");
171
+ node.textContent = text;
172
+ node.style.cssText =
173
+ "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
174
+ return node;
175
+ }