solid-route-progress 0.1.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.

Potentially problematic release.


This version of solid-route-progress might be problematic. Click here for more details.

@@ -0,0 +1,315 @@
1
+ import { batch, createContext, createEffect, createSignal, getOwner, onCleanup, onMount, splitProps, useContext } from "solid-js";
2
+ import { isDev as DEV, isServer } from "solid-js/web";
3
+ //#region src/core.ts
4
+ /** `Symbol.dispose` where the runtime has it. */
5
+ const dispose = Symbol.dispose;
6
+ const DEFAULTS = {
7
+ trickleTo: .95,
8
+ delay: 200,
9
+ stopDelay: 0,
10
+ speed: 200
11
+ };
12
+ /**
13
+ * Headless progress state machine. Rendering is left to CSS: the controller only
14
+ * exposes a target `value` and a `state`, which `<Progress>` mirrors to
15
+ * `--sp-value` and `data-state`.
16
+ */
17
+ function createProgress(options = {}) {
18
+ const [value, setValue] = createSignal(0);
19
+ const [state, setState] = createSignal("idle");
20
+ const [error, setError] = createSignal(false);
21
+ const opt = (key) => options[key] ?? DEFAULTS[key];
22
+ const holds = /* @__PURE__ */ new Set();
23
+ /** The one scheduled step: reveal while `idle`, resume trickling while `active`, hide while `done`. */
24
+ let timer;
25
+ let stopTimer;
26
+ /** `true` once the browser has had a chance to paint the visible bar. */
27
+ let painted = false;
28
+ /** A hold was released as an `'error'` during the current load. */
29
+ let failed = false;
30
+ const schedule = (step, ms) => {
31
+ clearTimeout(timer);
32
+ timer = setTimeout(() => {
33
+ timer = void 0;
34
+ step();
35
+ }, ms);
36
+ };
37
+ const cancel = () => {
38
+ clearTimeout(timer);
39
+ timer = void 0;
40
+ };
41
+ /** A reveal is scheduled: the load has not lasted `delay` yet. */
42
+ const pending = () => state() === "idle" && timer !== void 0;
43
+ const move = (s, v) => batch(() => {
44
+ setState(s);
45
+ setValue(v);
46
+ });
47
+ const hide = () => {
48
+ failed = false;
49
+ batch(() => {
50
+ setError(false);
51
+ move("idle", 0);
52
+ });
53
+ };
54
+ const drop = () => {
55
+ cancel();
56
+ hide();
57
+ };
58
+ const reveal = (s, v) => {
59
+ painted = false;
60
+ requestAnimationFrame(() => painted = true);
61
+ move(s, v);
62
+ };
63
+ const show = () => reveal("trickle", opt("trickleTo"));
64
+ const finish = () => {
65
+ if (!painted) return drop();
66
+ batch(() => {
67
+ setError(failed);
68
+ move("done", 1);
69
+ });
70
+ schedule(hide, opt("speed"));
71
+ };
72
+ /** Every hold is gone: complete or drop the bar, or cancel a reveal that is not due yet. */
73
+ const settle = (cancelled) => {
74
+ const s = state();
75
+ if (s === "trickle" || s === "active") {
76
+ const end = cancelled && !failed ? drop : finish;
77
+ const stopDelay = opt("stopDelay");
78
+ clearTimeout(stopTimer);
79
+ if (stopDelay > 0) stopTimer = setTimeout(end, stopDelay);
80
+ else end();
81
+ } else if (s === "idle") {
82
+ cancel();
83
+ failed = false;
84
+ }
85
+ };
86
+ const release = (hold, outcome) => {
87
+ if (!holds.delete(hold)) return;
88
+ if (outcome === "error") failed = true;
89
+ if (!holds.size) settle(outcome === "cancel");
90
+ };
91
+ const start = () => {
92
+ const hold = {};
93
+ if (!isServer) {
94
+ holds.add(hold);
95
+ clearTimeout(stopTimer);
96
+ const s = state();
97
+ if (s === "done") {
98
+ hide();
99
+ schedule(show, opt("speed"));
100
+ } else if (s === "idle" && !pending()) {
101
+ const delay = opt("delay");
102
+ if (delay > 0) schedule(show, delay);
103
+ else show();
104
+ }
105
+ }
106
+ const releaseHold = (outcome) => release(hold, outcome);
107
+ if (dispose) releaseHold[dispose] = releaseHold;
108
+ return releaseHold;
109
+ };
110
+ const done = (outcome) => {
111
+ holds.clear();
112
+ if (outcome === "error") failed = true;
113
+ settle(outcome === "cancel");
114
+ };
115
+ const set = (n) => {
116
+ if (isServer) return;
117
+ n = Math.min(1, Math.max(0, n));
118
+ if (n === 1) return done();
119
+ const s = state();
120
+ if (s === "done" || pending()) return;
121
+ if (s === "idle") reveal("active", n);
122
+ else move("active", n);
123
+ const trickleTo = opt("trickleTo");
124
+ if (n < trickleTo) schedule(() => move("trickle", trickleTo), opt("speed"));
125
+ else cancel();
126
+ };
127
+ const track = (promise, options) => {
128
+ const releaseHold = start();
129
+ promise.then(() => releaseHold(), () => releaseHold("error"));
130
+ const timeout = options?.timeout;
131
+ if (timeout && !isServer) setTimeout(releaseHold, timeout);
132
+ return promise;
133
+ };
134
+ if (getOwner()) onCleanup(() => {
135
+ clearTimeout(timer);
136
+ clearTimeout(stopTimer);
137
+ });
138
+ return {
139
+ value,
140
+ state,
141
+ error,
142
+ active: () => state() !== "idle",
143
+ start,
144
+ done,
145
+ set,
146
+ track,
147
+ options
148
+ };
149
+ }
150
+ //#endregion
151
+ //#region src/dev.ts
152
+ const warn = (message) => console.warn(`[sprogress] ${message}`);
153
+ //#endregion
154
+ //#region src/components.tsx
155
+ /** Context carrying the active controller. Exposed for integrations; prefer `useProgress()`. */
156
+ const ProgressContext = createContext();
157
+ /**
158
+ * Read the nearest progress controller — from `<ProgressProvider>`, `<Progress>`,
159
+ * `<RouteProgress>` or `<NavigationProgress>`.
160
+ */
161
+ function useProgress() {
162
+ const controller = useContext(ProgressContext);
163
+ if (!controller) throw new Error("useProgress(): no progress controller in scope. Wrap the app in <ProgressProvider> (the route bar picks it up automatically) or call it inside <Progress>.");
164
+ return controller;
165
+ }
166
+ /** The props that configure a controller, as opposed to the bar element. */
167
+ const OPTION_KEYS = [
168
+ "trickleTo",
169
+ "delay",
170
+ "stopDelay",
171
+ "speed"
172
+ ];
173
+ /**
174
+ * The controller a bar drives: the `controller` prop, else the nearest `<ProgressProvider>`,
175
+ * else a new one built from `options`. Options only apply in the last case, so development
176
+ * builds say so when they would be dropped.
177
+ */
178
+ function useController(controller, options) {
179
+ const provided = controller ?? useContext(ProgressContext);
180
+ if (!provided) return createProgress(options);
181
+ if (DEV) {
182
+ const dropped = OPTION_KEYS.filter((key) => options[key] !== void 0);
183
+ if (dropped.length) warn(`${dropped.join(", ")} ignored: this bar drives an existing controller, so set options where it is created (createProgress() or <ProgressProvider>).`);
184
+ }
185
+ return provided;
186
+ }
187
+ /** Provides a controller to descendants without rendering anything. */
188
+ function ProgressProvider(props) {
189
+ const [local, options] = splitProps(props, ["controller", "children"]);
190
+ const controller = local.controller ?? createProgress(options);
191
+ return <ProgressContext.Provider value={controller}>{local.children}</ProgressContext.Provider>;
192
+ }
193
+ const LOCAL = [
194
+ "controller",
195
+ "style",
196
+ "label",
197
+ "getValueLabel",
198
+ "busyAttribute",
199
+ "ariaBusy",
200
+ "children",
201
+ "class"
202
+ ];
203
+ /** Bars currently marking the page busy, per attribute: it stays until the last one goes idle. */
204
+ const busyBars = /* @__PURE__ */ new Map();
205
+ function createBusyAttribute(attribute, value, active) {
206
+ let bars = busyBars.get(attribute);
207
+ if (!bars) busyBars.set(attribute, bars = /* @__PURE__ */ new Set());
208
+ const bar = {};
209
+ const sync = (on) => {
210
+ if (on) bars.add(bar);
211
+ else bars.delete(bar);
212
+ if (bars.size) document.documentElement.setAttribute(attribute, value);
213
+ else document.documentElement.removeAttribute(attribute);
214
+ };
215
+ createEffect(() => sync(active()));
216
+ onCleanup(() => sync(false));
217
+ }
218
+ /**
219
+ * The bar shell. Renders a fixed, full-width `role="progressbar"` element and mirrors the
220
+ * controller into `--sp-value` / `--sp-speed` / `data-state` / `data-error`; everything
221
+ * visual lives in `style.css`. Renders the idle shell on the server.
222
+ */
223
+ function Progress(props) {
224
+ const [local, options, rest] = splitProps(props, LOCAL, OPTION_KEYS);
225
+ const controller = useController(local.controller, options);
226
+ let root;
227
+ const valueNow = () => controller.state() === "trickle" ? void 0 : Math.round(controller.value() * 100);
228
+ const valueText = () => {
229
+ const percent = valueNow();
230
+ return percent === void 0 ? void 0 : local.getValueLabel?.(percent);
231
+ };
232
+ if (!isServer) {
233
+ if (DEV) onMount(() => {
234
+ if (root.getClientRects().length && getComputedStyle(root).position === "static") warn("style.css is not loaded: import 'solid-route-progress/style.css' once.");
235
+ });
236
+ createBusyAttribute("data-sp-busy", "", () => local.busyAttribute !== false && controller.active());
237
+ createBusyAttribute("aria-busy", "true", () => local.ariaBusy === true && controller.active());
238
+ }
239
+ return <ProgressContext.Provider value={controller}>
240
+ <div {...rest} ref={root} role="progressbar" aria-label={local.label ?? "Loading"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={valueNow()} aria-valuetext={valueText()} data-state={controller.state()} data-error={controller.error() ? "" : void 0} class={local.class ? `sprogress ${local.class}` : "sprogress"} style={{
241
+ ...local.style,
242
+ "--sp-value": controller.value(),
243
+ "--sp-speed": `${controller.options.speed ?? DEFAULTS.speed}ms`
244
+ }}>
245
+ {local.children ?? <Bar />}
246
+ </div>
247
+ </ProgressContext.Provider>;
248
+ }
249
+ /** The sliding bar: a full-width strip slid in from the inline-start edge. */
250
+ function Bar(props) {
251
+ const [local, rest] = splitProps(props, ["class"]);
252
+ return <div class={local.class ? `sprogress-bar ${local.class}` : "sprogress-bar"} {...rest} />;
253
+ }
254
+ //#endregion
255
+ //#region src/navigation-api.ts
256
+ /** `window.navigation`, or `undefined` where the Navigation API is unavailable. */
257
+ const getNavigation = () => globalThis.navigation;
258
+ /** Anchors (or their ancestors) carrying this attribute never trigger the bar. */
259
+ const IGNORE_ATTRIBUTE = "data-sp-ignore";
260
+ /** Whether `target` is, or sits inside, an element marked `data-sp-ignore`. */
261
+ const isIgnored = (target) => target instanceof Element && target.closest(`[data-sp-ignore]`) !== null;
262
+ /**
263
+ * An `AbortSignal` that fires when the surrounding Solid owner is disposed. Pass it to
264
+ * `addEventListener` and the listener removes itself.
265
+ */
266
+ function disposalSignal() {
267
+ const controller = new AbortController();
268
+ if (getOwner()) onCleanup(() => controller.abort());
269
+ return controller.signal;
270
+ }
271
+ //#endregion
272
+ //#region src/cross-document.ts
273
+ /**
274
+ * Show the bar for navigations the page starts that leave the current document: external
275
+ * links, plain form posts, `location` assignments and reloads, back/forward to another
276
+ * document. Browser-UI navigations (the reload button, the address bar, bookmarks) never
277
+ * reach the page, so they show nothing.
278
+ *
279
+ * Uses the Navigation API `navigate` event, which fires before the request is made; where
280
+ * the API is missing this does nothing. Links marked `data-sp-ignore` are skipped. The bar
281
+ * fades out when the navigation is cancelled before the document unloads (`navigateerror`:
282
+ * a stop, a newer navigation) or the page is restored from the back/forward cache, and
283
+ * completes after `timeout`, the only end for a `204` or a server-sent download, which
284
+ * Chromium does not report back. A navigation a router intercepts instead completes on
285
+ * `navigatesuccess` / `navigateerror`.
286
+ */
287
+ function createCrossDocumentProgress(controller, options = {}) {
288
+ const navigation = getNavigation();
289
+ if (isServer || !navigation) return;
290
+ const signal = disposalSignal();
291
+ let timer;
292
+ /** Releases the hold of the navigation still in flight. */
293
+ let release;
294
+ const finish = (outcome) => {
295
+ clearTimeout(timer);
296
+ release?.(outcome);
297
+ release = void 0;
298
+ };
299
+ navigation.addEventListener("navigate", (event) => {
300
+ if (event.defaultPrevented || event.destination.sameDocument || event.downloadRequest !== null || !/^https?:/.test(event.destination.url) || isIgnored(event.sourceElement) || options.filter?.(event) === false) return;
301
+ const previous = release;
302
+ release = controller.start();
303
+ previous?.();
304
+ clearTimeout(timer);
305
+ const timeout = options.timeout ?? 1e4;
306
+ if (timeout > 0) timer = setTimeout(finish, timeout);
307
+ }, { signal });
308
+ navigation.addEventListener("currententrychange", () => navigation.transition && clearTimeout(timer), { signal });
309
+ navigation.addEventListener("navigatesuccess", () => finish(), { signal });
310
+ navigation.addEventListener("navigateerror", (event) => finish(event.error?.name === "AbortError" ? "cancel" : "error"), { signal });
311
+ window.addEventListener("pageshow", (event) => event.persisted && finish("cancel"), { signal });
312
+ signal.addEventListener("abort", () => finish());
313
+ }
314
+ //#endregion
315
+ export { isIgnored as a, Progress as c, useController as d, useProgress as f, createProgress as h, getNavigation as i, ProgressContext as l, warn as m, IGNORE_ATTRIBUTE as n, Bar as o, DEV as p, disposalSignal as r, OPTION_KEYS as s, createCrossDocumentProgress as t, ProgressProvider as u };
package/dist/style.css ADDED
@@ -0,0 +1,84 @@
1
+ /*
2
+ * solid-route-progress — all rendering lives here. JS only writes `--sp-value`, `--sp-speed` and `data-state`.
3
+ *
4
+ * Tunables (set them anywhere: `:root`, `@theme`, a class, or inline):
5
+ * --sp-color bar colour oklch(0.65 0.14 241)
6
+ * --sp-height bar thickness 3px
7
+ * --sp-z-index 9999
8
+ * --sp-start value the bar is revealed at 0.08
9
+ * --sp-trickle-duration length of the loading drift 10s
10
+ * --sp-trickle-easing shape of the loading drift linear() curve
11
+ *
12
+ * `--sp-speed` mirrors the `speed` option so CSS transitions and JS timers agree: read it in
13
+ * your own rules, set it through `speed`.
14
+ *
15
+ * Everything sits in the `components.sprogress` sublayer, so Tailwind utilities, your own
16
+ * `@layer components` rules and unlayered CSS all win without `!important`.
17
+ */
18
+
19
+ @property --sp-value {
20
+ syntax: '<number>';
21
+ inherits: true;
22
+ initial-value: 0;
23
+ }
24
+
25
+ @layer components.sprogress {
26
+ .sprogress {
27
+ position: fixed;
28
+ inset: 0 0 auto 0;
29
+ z-index: var(--sp-z-index, 9999);
30
+ height: var(--sp-height, 3px);
31
+ pointer-events: none;
32
+ visibility: visible;
33
+ transition:
34
+ opacity var(--sp-speed) ease,
35
+ visibility 0s;
36
+ }
37
+
38
+ .sprogress[data-state='idle'] {
39
+ opacity: 0;
40
+ visibility: hidden;
41
+ /* keep painting while the opacity fades, then drop out of the a11y tree / hit-testing */
42
+ transition:
43
+ opacity var(--sp-speed) ease,
44
+ visibility 0s var(--sp-speed);
45
+ }
46
+
47
+ .sprogress-bar {
48
+ position: absolute;
49
+ inset: 0;
50
+ background: var(--sp-color, oklch(0.65 0.14 241));
51
+ /* full-width bar slid in from the inline-start edge */
52
+ transform: translateX(calc((var(--sp-value) - 1) * 100%));
53
+ /* fast out of the gate, then a long crawl — never quite arriving */
54
+ transition: transform var(--sp-trickle-duration, 10s)
55
+ var(
56
+ --sp-trickle-easing,
57
+ linear(0, 0.25 5%, 0.4 10%, 0.55 18%, 0.7 30%, 0.8 45%, 0.88 60%, 0.94 75%, 0.98 90%, 1)
58
+ );
59
+ }
60
+
61
+ .sprogress:dir(rtl) .sprogress-bar {
62
+ transform: translateX(calc((1 - var(--sp-value)) * 100%));
63
+ }
64
+
65
+ /* hidden: park the bar at the start position, but only after the fade-out finished */
66
+ .sprogress[data-state='idle'] .sprogress-bar {
67
+ --sp-value: var(--sp-start, 0.08);
68
+ transition: transform 0s var(--sp-speed);
69
+ }
70
+
71
+ /* explicit set() / done(): short, eased hop instead of the long drift */
72
+ .sprogress:is([data-state='active'], [data-state='done']) .sprogress-bar {
73
+ transition-duration: var(--sp-speed);
74
+ transition-timing-function: ease;
75
+ }
76
+
77
+ /* forced colours strip backgrounds; paint the bar with the system highlight instead */
78
+ @media (forced-colors: active) {
79
+ .sprogress-bar {
80
+ forced-color-adjust: none;
81
+ background: Highlight;
82
+ }
83
+ }
84
+ }
package/package.json ADDED
@@ -0,0 +1,114 @@
1
+ {
2
+ "name": "solid-route-progress",
3
+ "version": "0.1.0",
4
+ "description": "Web-native, CSS-driven route progress bar for SolidJS. The loading drift is one CSS transition; Tailwind v4 friendly.",
5
+ "license": "MIT",
6
+ "author": "kecan0406 (https://github.com/kecan0406)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kecan0406/solid-route-progress.git"
10
+ },
11
+ "homepage": "https://solid-route-progress.vercel.app",
12
+ "bugs": {
13
+ "url": "https://github.com/kecan0406/solid-route-progress/issues"
14
+ },
15
+ "type": "module",
16
+ "sideEffects": [
17
+ "**/*.css"
18
+ ],
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "solid": "./dist/index.jsx",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./router": {
32
+ "types": "./dist/router.d.ts",
33
+ "solid": "./dist/router.jsx",
34
+ "default": "./dist/router.js"
35
+ },
36
+ "./navigation": {
37
+ "types": "./dist/navigation.d.ts",
38
+ "solid": "./dist/navigation.jsx",
39
+ "default": "./dist/navigation.js"
40
+ },
41
+ "./style.css": "./dist/style.css",
42
+ "./package.json": "./package.json"
43
+ },
44
+ "typesVersions": {
45
+ "*": {
46
+ "router": [
47
+ "./dist/router.d.ts"
48
+ ],
49
+ "navigation": [
50
+ "./dist/navigation.d.ts"
51
+ ]
52
+ }
53
+ },
54
+ "scripts": {
55
+ "dev": "vite serve dev",
56
+ "dev:www": "pnpm --filter solid-route-progress-www dev",
57
+ "build:www": "pnpm --filter solid-route-progress-www build",
58
+ "build": "tsdown",
59
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "size": "node scripts/size.mjs",
63
+ "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm size",
64
+ "format": "prettier -w \"{src,test,dev,scripts}/**/*.{ts,tsx,css,mjs}\" \"www/src/**/*.{ts,tsx,css}\" \"www/*.{ts,json}\" \"*.{ts,md,json}\" \".github/**/*.yml\"",
65
+ "prepublishOnly": "pnpm check",
66
+ "lint": "oxlint --deny-warnings",
67
+ "format:check": "prettier --check \"{src,test,dev,scripts}/**/*.{ts,tsx,css,mjs}\" \"www/src/**/*.{ts,tsx,css}\" \"www/*.{ts,json}\" \"*.{ts,md,json}\" \".github/**/*.yml\"",
68
+ "changeset": "changeset"
69
+ },
70
+ "peerDependencies": {
71
+ "@solidjs/router": ">=1.0.0",
72
+ "solid-js": "^1.9.0"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@solidjs/router": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "keywords": [
80
+ "solid",
81
+ "solidjs",
82
+ "solid-router",
83
+ "progress",
84
+ "progress-bar",
85
+ "nprogress",
86
+ "bprogress",
87
+ "navigation",
88
+ "tailwindcss"
89
+ ],
90
+ "packageManager": "pnpm@10.33.2",
91
+ "devDependencies": {
92
+ "@arethetypeswrong/cli": "^0.18.5",
93
+ "@changesets/cli": "^3.0.3",
94
+ "@solidjs/router": "^1.0.0",
95
+ "@solidjs/testing-library": "^0.8.10",
96
+ "@tailwindcss/vite": "^4.3.3",
97
+ "@testing-library/jest-dom": "^7.0.1",
98
+ "@vitest/browser-playwright": "^4.1.11",
99
+ "eslint-plugin-solid": "^0.18.0",
100
+ "jsdom": "^30.0.1",
101
+ "oxlint": "^1.83.0",
102
+ "playwright": "^1.62.1",
103
+ "prettier": "^3.9.6",
104
+ "publint": "^0.3.24",
105
+ "solid-js": "^1.9.15",
106
+ "tailwindcss": "^4.3.3",
107
+ "tsdown": "^0.22.14",
108
+ "typescript": "^7.0.2",
109
+ "unplugin-solid": "^2.0.0",
110
+ "vite": "^8.2.2",
111
+ "vite-plugin-solid": "^2.11.14",
112
+ "vitest": "^4.1.11"
113
+ }
114
+ }