@zvndev/darkmown 1.1.0 → 1.2.1
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.
- package/CHANGELOG.md +17 -0
- package/README.md +54 -3
- package/docs/cli.md +46 -16
- package/docs/spec-alignment.md +9 -3
- package/package.json +1 -1
- package/site/_/roadmap.json +3 -1
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +2 -2
- package/site/pages/docs/index.wd +21 -0
- package/site/pages/folio/cart.wd +2 -2
- package/site/pages/forge/index.wd +1 -1
- package/site/pages/interactions/index.skin +113 -0
- package/site/pages/interactions/index.wd +86 -0
- package/src/api-runner.js +219 -0
- package/src/behaviors/carousel.js +153 -0
- package/src/behaviors/sortable.js +175 -0
- package/src/builder.js +199 -4
- package/src/cli.js +79 -54
- package/src/compiler/body.js +14 -0
- package/src/compiler/context.js +12 -1
- package/src/compiler/directives.js +123 -0
- package/src/compiler/index.js +1 -0
- package/src/compiler/loops.js +57 -8
- package/src/compiler/page.js +9 -3
- package/src/deploy.js +174 -0
- package/src/headers.js +4 -0
- package/src/runtime.js +3 -1
- package/src/scaffold.js +79 -147
- package/src/templates/blog/README.md +25 -0
- package/src/templates/blog/site/_/nav.wd +6 -0
- package/src/templates/blog/site/_/posts.json +16 -0
- package/src/templates/blog/site/pages/index.skin +51 -0
- package/src/templates/blog/site/pages/index.wd +24 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
- package/src/templates/dashboard/README.md +25 -0
- package/src/templates/dashboard/api/metrics.js +15 -0
- package/src/templates/dashboard/site/_/nav.wd +6 -0
- package/src/templates/dashboard/site/pages/index.skin +74 -0
- package/src/templates/dashboard/site/pages/index.wd +75 -0
- package/src/templates/landing/README.md +25 -0
- package/src/templates/landing/site/_/nav.wd +6 -0
- package/src/templates/landing/site/pages/index.skin +140 -0
- package/src/templates/landing/site/pages/index.wd +54 -0
- package/src/templates/starter/README.md +32 -0
- package/src/templates/starter/site/_/nav.wd +7 -0
- package/src/templates/starter/site/pages/about.md +10 -0
- package/src/templates/starter/site/pages/index.skin +47 -0
- package/src/templates/starter/site/pages/index.wd +27 -0
- package/src/templates/store/README.md +28 -0
- package/src/templates/store/api/checkout.js +18 -0
- package/src/templates/store/site/_/nav.wd +6 -0
- package/src/templates/store/site/_/products.json +8 -0
- package/src/templates/store/site/pages/index.skin +100 -0
- 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
|
+
}
|