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.
- package/LICENSE +21 -0
- package/README.md +376 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.jsx +2 -0
- package/dist/navigation.d.ts +17 -0
- package/dist/navigation.js +50 -0
- package/dist/navigation.jsx +50 -0
- package/dist/router.d.ts +45 -0
- package/dist/router.js +67 -0
- package/dist/router.jsx +67 -0
- package/dist/shared.d.ts +202 -0
- package/dist/shared.js +360 -0
- package/dist/shared.jsx +315 -0
- package/dist/style.css +84 -0
- package/package.json +114 -0
package/dist/router.jsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { a as isIgnored, c as Progress, d as useController, r as disposalSignal, s as OPTION_KEYS, t as createCrossDocumentProgress } from "./shared.jsx";
|
|
2
|
+
import { createEffect, on, onCleanup, splitProps } from "solid-js";
|
|
3
|
+
import { isServer } from "solid-js/web";
|
|
4
|
+
import { useBeforeLeave, useIsRouting } from "@solidjs/router";
|
|
5
|
+
//#region src/router.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Wire a controller to `@solidjs/router`: `useIsRouting()` covers `<A>` clicks,
|
|
8
|
+
* `navigate()`, back/forward and action redirects, including any `<Suspense>` the new
|
|
9
|
+
* route waits on. `useBeforeLeave()` supplies the target for `shallow` / `filter`.
|
|
10
|
+
*/
|
|
11
|
+
function createRouteProgress(controller, options = {}) {
|
|
12
|
+
if (isServer) return;
|
|
13
|
+
const isRouting = useIsRouting();
|
|
14
|
+
let skip = false;
|
|
15
|
+
const skipNext = () => {
|
|
16
|
+
skip = true;
|
|
17
|
+
queueMicrotask(() => {
|
|
18
|
+
skip = false;
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener("click", (event) => {
|
|
22
|
+
if (isIgnored(event.target)) skipNext();
|
|
23
|
+
}, {
|
|
24
|
+
capture: true,
|
|
25
|
+
signal: disposalSignal()
|
|
26
|
+
});
|
|
27
|
+
useBeforeLeave((event) => {
|
|
28
|
+
if (typeof event.to !== "string") return;
|
|
29
|
+
if (options.shallow === true && samePathname(event.to, event.from.pathname) || options.filter?.(event.to, event.from) === false) skipNext();
|
|
30
|
+
});
|
|
31
|
+
let release;
|
|
32
|
+
createEffect(on(isRouting, (routing) => {
|
|
33
|
+
release?.();
|
|
34
|
+
release = routing && !skip ? controller.start() : void 0;
|
|
35
|
+
if (routing) skip = false;
|
|
36
|
+
}));
|
|
37
|
+
onCleanup(() => release?.());
|
|
38
|
+
if (options.crossDocument !== false) createCrossDocumentProgress(controller, typeof options.crossDocument === "object" ? options.crossDocument : void 0);
|
|
39
|
+
}
|
|
40
|
+
const samePathname = (to, pathname) => {
|
|
41
|
+
const end = to.search(/[?#]/);
|
|
42
|
+
return (end === -1 ? to : to.slice(0, end)).replace(/\/+$/, "") === pathname.replace(/\/+$/, "");
|
|
43
|
+
};
|
|
44
|
+
const LOCAL = [
|
|
45
|
+
"shallow",
|
|
46
|
+
"filter",
|
|
47
|
+
"crossDocument",
|
|
48
|
+
"controller"
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Drop-in route progress bar. Place it anywhere under `<Router>` — typically in the root
|
|
52
|
+
* layout — and import `style.css` once. Inside a `<ProgressProvider>` it drives that
|
|
53
|
+
* provider's controller, so `useProgress()` works anywhere in the app.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <Router root={(props) => <><RouteProgress /><Suspense>{props.children}</Suspense></>}>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
function RouteProgress(props) {
|
|
61
|
+
const [local, options, rest] = splitProps(props, LOCAL, OPTION_KEYS);
|
|
62
|
+
const controller = useController(local.controller, options);
|
|
63
|
+
createRouteProgress(controller, local);
|
|
64
|
+
return <Progress controller={controller} {...rest} />;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { RouteProgress, createRouteProgress };
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Accessor, Context, JSX, ParentProps } from "solid-js";
|
|
2
|
+
//#region src/core.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Visual state of the bar. Mirrored to the root element as `data-state`.
|
|
5
|
+
*
|
|
6
|
+
* - `idle` hidden (faded out, `visibility: hidden`)
|
|
7
|
+
* - `trickle` visible, drifting toward `trickleTo` on a long CSS transition
|
|
8
|
+
* - `active` visible, moving to an explicit `set()` value on a short transition
|
|
9
|
+
* - `done` visible, moving to 100% before fading out
|
|
10
|
+
*/
|
|
11
|
+
type ProgressState = "idle" | "trickle" | "active" | "done";
|
|
12
|
+
/**
|
|
13
|
+
* How a load ended, when it did not simply succeed: `'error'` completes the bar with
|
|
14
|
+
* `data-error` set, `'cancel'` fades it out without running to 100%.
|
|
15
|
+
*/
|
|
16
|
+
type Outcome = "error" | "cancel";
|
|
17
|
+
/**
|
|
18
|
+
* `{ [Symbol.dispose](): void }` where the consumer's TypeScript `lib` knows `Symbol.dispose`,
|
|
19
|
+
* and no constraint otherwise, so the type never forces `esnext.disposable` on anyone.
|
|
20
|
+
*/
|
|
21
|
+
type DisposableLike = SymbolConstructor extends {
|
|
22
|
+
readonly dispose: infer K extends symbol;
|
|
23
|
+
} ? { [P in K]: () => void; } : unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Lets go of one hold on the bar. Calling it again is a no-op. Where `Symbol.dispose`
|
|
26
|
+
* exists it is also a disposable, so `using hold = progress.start()` releases on scope exit.
|
|
27
|
+
*/
|
|
28
|
+
type Release = ((outcome?: Outcome) => void) & DisposableLike;
|
|
29
|
+
interface TrackOptions {
|
|
30
|
+
/** Release the hold after this many milliseconds even if the promise never settles. */
|
|
31
|
+
timeout?: number;
|
|
32
|
+
}
|
|
33
|
+
interface ProgressOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Value (0–1) the bar drifts toward while loading. The drift itself is a single CSS
|
|
36
|
+
* transition (`--sp-trickle-duration` / `--sp-trickle-easing`); no JS timer steps it.
|
|
37
|
+
* @default 0.95
|
|
38
|
+
*/
|
|
39
|
+
trickleTo?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Milliseconds a load must last before the bar shows, so quick loads never draw one. A
|
|
42
|
+
* load that settles before the next frame is dropped unseen even with `0`.
|
|
43
|
+
* @default 200
|
|
44
|
+
*/
|
|
45
|
+
delay?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Milliseconds to wait before completing once the last hold is released. Useful when a
|
|
48
|
+
* route mounts and immediately kicks off another load you also want covered.
|
|
49
|
+
* @default 0
|
|
50
|
+
*/
|
|
51
|
+
stopDelay?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Milliseconds for the short transitions: `set()`, `done()` and the fade in/out. The bar
|
|
54
|
+
* writes it to `--sp-speed`, so JS timers and CSS transitions always agree.
|
|
55
|
+
* @default 200
|
|
56
|
+
*/
|
|
57
|
+
speed?: number;
|
|
58
|
+
}
|
|
59
|
+
interface ProgressController {
|
|
60
|
+
/** Target value (0–1) the bar is transitioning toward. */
|
|
61
|
+
readonly value: Accessor<number>;
|
|
62
|
+
/** Current visual state. */
|
|
63
|
+
readonly state: Accessor<ProgressState>;
|
|
64
|
+
/** `true` while the bar is visible (any state other than `idle`). */
|
|
65
|
+
readonly active: Accessor<boolean>;
|
|
66
|
+
/** `true` during the `done` phase of a load that failed. Mirrored as `data-error`. */
|
|
67
|
+
readonly error: Accessor<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* Hold the bar open: shows it (after `delay`) and starts trickling. Returns a release
|
|
70
|
+
* function; the bar completes once every hold is released, so the router, `track()` and
|
|
71
|
+
* your own code never finish each other's loads.
|
|
72
|
+
*/
|
|
73
|
+
start(): Release;
|
|
74
|
+
/**
|
|
75
|
+
* Complete the bar (after `stopDelay`), dropping every hold. No-op while hidden. An
|
|
76
|
+
* `outcome` marks the load as failed or cancelled, as with a release.
|
|
77
|
+
*/
|
|
78
|
+
done(outcome?: Outcome): void;
|
|
79
|
+
/**
|
|
80
|
+
* Move the bar to an explicit value (0–1) on a short transition, then resume
|
|
81
|
+
* trickling. Values `>= 1` complete the bar. Shows the bar if it is hidden, except
|
|
82
|
+
* while a `delay` is still pending: a load that finishes early stays invisible.
|
|
83
|
+
*/
|
|
84
|
+
set(value: number): void;
|
|
85
|
+
/**
|
|
86
|
+
* Hold the bar open until `promise` settles; a rejection ends it as an `'error'`. Returns
|
|
87
|
+
* `promise` itself, so it can wrap a call in place.
|
|
88
|
+
*/
|
|
89
|
+
track<P extends PromiseLike<unknown>>(promise: P, options?: TrackOptions): P;
|
|
90
|
+
/** The options this controller was created with (read lazily, so reactive props work). */
|
|
91
|
+
readonly options: ProgressOptions;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Headless progress state machine. Rendering is left to CSS: the controller only
|
|
95
|
+
* exposes a target `value` and a `state`, which `<Progress>` mirrors to
|
|
96
|
+
* `--sp-value` and `data-state`.
|
|
97
|
+
*/
|
|
98
|
+
declare function createProgress(options?: ProgressOptions): ProgressController;
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/components.d.ts
|
|
101
|
+
/** Context carrying the active controller. Exposed for integrations; prefer `useProgress()`. */
|
|
102
|
+
declare const ProgressContext: Context<ProgressController | undefined>;
|
|
103
|
+
/**
|
|
104
|
+
* Read the nearest progress controller — from `<ProgressProvider>`, `<Progress>`,
|
|
105
|
+
* `<RouteProgress>` or `<NavigationProgress>`.
|
|
106
|
+
*/
|
|
107
|
+
declare function useProgress(): ProgressController;
|
|
108
|
+
interface ProgressProviderProps extends ProgressOptions {
|
|
109
|
+
/** Share an existing controller instead of creating one. */
|
|
110
|
+
controller?: ProgressController;
|
|
111
|
+
children?: JSX.Element;
|
|
112
|
+
}
|
|
113
|
+
/** Provides a controller to descendants without rendering anything. */
|
|
114
|
+
declare function ProgressProvider(props: ProgressProviderProps): JSX.Element;
|
|
115
|
+
interface ProgressProps extends ProgressOptions, Omit<JSX.HTMLAttributes<HTMLDivElement>, "style" | "children" | "role"> {
|
|
116
|
+
/** Drive this bar from an existing controller (defaults to the nearest provided one, else a new one). */
|
|
117
|
+
controller?: ProgressController;
|
|
118
|
+
/** Inline styles; merged with the custom properties the component sets. */
|
|
119
|
+
style?: JSX.CSSProperties;
|
|
120
|
+
/** Accessible name announced for the progress bar. @default "Loading" */
|
|
121
|
+
label?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Human-readable text for a known value, announced as `aria-valuetext` (as in Kobalte and
|
|
124
|
+
* Radix), e.g. `(percent) => percent + ' percent uploaded'`. Not used while trickling.
|
|
125
|
+
*/
|
|
126
|
+
getValueLabel?: (percent: number) => string;
|
|
127
|
+
/**
|
|
128
|
+
* Set `data-sp-busy` on `<html>` while the bar is visible, for page-wide styling hooks
|
|
129
|
+
* (e.g. Tailwind `in-data-sp-busy:opacity-50`). With several bars it stays set until the
|
|
130
|
+
* last of them goes idle.
|
|
131
|
+
* @default true
|
|
132
|
+
*/
|
|
133
|
+
busyAttribute?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Also set `aria-busy="true"` on `<html>` while the bar is visible, as Turbo does. Off by
|
|
136
|
+
* default: how screen readers treat a busy root varies.
|
|
137
|
+
* @default false
|
|
138
|
+
*/
|
|
139
|
+
ariaBusy?: boolean;
|
|
140
|
+
/** Custom template. Defaults to `<Bar />`. */
|
|
141
|
+
children?: JSX.Element;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The bar shell. Renders a fixed, full-width `role="progressbar"` element and mirrors the
|
|
145
|
+
* controller into `--sp-value` / `--sp-speed` / `data-state` / `data-error`; everything
|
|
146
|
+
* visual lives in `style.css`. Renders the idle shell on the server.
|
|
147
|
+
*/
|
|
148
|
+
declare function Progress(props: ProgressProps): JSX.Element;
|
|
149
|
+
/** The sliding bar: a full-width strip slid in from the inline-start edge. */
|
|
150
|
+
declare function Bar(props: ParentProps<JSX.HTMLAttributes<HTMLDivElement>>): JSX.Element;
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/navigation-api.d.ts
|
|
153
|
+
/**
|
|
154
|
+
* Minimal structural typings for the Navigation API (Baseline 2026-01), kept local so the
|
|
155
|
+
* library type-checks against any `lib.dom` version.
|
|
156
|
+
*/
|
|
157
|
+
interface NavigateEventLike extends Event {
|
|
158
|
+
readonly navigationType: "push" | "replace" | "reload" | "traverse";
|
|
159
|
+
readonly destination: {
|
|
160
|
+
readonly url: string;
|
|
161
|
+
readonly sameDocument: boolean;
|
|
162
|
+
};
|
|
163
|
+
readonly canIntercept: boolean;
|
|
164
|
+
readonly userInitiated: boolean;
|
|
165
|
+
readonly hashChange: boolean;
|
|
166
|
+
readonly downloadRequest: string | null;
|
|
167
|
+
readonly formData: FormData | null;
|
|
168
|
+
/** The element that initiated the navigation, when known (Chrome 135, Firefox 147, Safari 26.2). */
|
|
169
|
+
readonly sourceElement?: Element | null;
|
|
170
|
+
}
|
|
171
|
+
/** Anchors (or their ancestors) carrying this attribute never trigger the bar. */
|
|
172
|
+
declare const IGNORE_ATTRIBUTE = "data-sp-ignore";
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/cross-document.d.ts
|
|
175
|
+
interface CrossDocumentOptions {
|
|
176
|
+
/**
|
|
177
|
+
* Safety net: a cross-document navigation that never unloads the page and is never
|
|
178
|
+
* reported as cancelled (a `204` response, a server-sent download) completes the bar after
|
|
179
|
+
* this many milliseconds. `0` disables it.
|
|
180
|
+
* @default 10000
|
|
181
|
+
*/
|
|
182
|
+
timeout?: number;
|
|
183
|
+
/** Decide per navigation. Return `false` to keep the bar hidden. */
|
|
184
|
+
filter?: (event: NavigateEventLike) => boolean;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Show the bar for navigations the page starts that leave the current document: external
|
|
188
|
+
* links, plain form posts, `location` assignments and reloads, back/forward to another
|
|
189
|
+
* document. Browser-UI navigations (the reload button, the address bar, bookmarks) never
|
|
190
|
+
* reach the page, so they show nothing.
|
|
191
|
+
*
|
|
192
|
+
* Uses the Navigation API `navigate` event, which fires before the request is made; where
|
|
193
|
+
* the API is missing this does nothing. Links marked `data-sp-ignore` are skipped. The bar
|
|
194
|
+
* fades out when the navigation is cancelled before the document unloads (`navigateerror`:
|
|
195
|
+
* a stop, a newer navigation) or the page is restored from the back/forward cache, and
|
|
196
|
+
* completes after `timeout`, the only end for a `204` or a server-sent download, which
|
|
197
|
+
* Chromium does not report back. A navigation a router intercepts instead completes on
|
|
198
|
+
* `navigatesuccess` / `navigateerror`.
|
|
199
|
+
*/
|
|
200
|
+
declare function createCrossDocumentProgress(controller: ProgressController, options?: CrossDocumentOptions): void;
|
|
201
|
+
//#endregion
|
|
202
|
+
export { Release as _, Bar as a, ProgressProps as c, useProgress as d, DisposableLike as f, ProgressState as g, ProgressOptions as h, NavigateEventLike as i, ProgressProvider as l, ProgressController as m, createCrossDocumentProgress as n, Progress as o, Outcome as p, IGNORE_ATTRIBUTE as r, ProgressContext as s, CrossDocumentOptions as t, ProgressProviderProps as u, TrackOptions as v, createProgress as y };
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { batch, createContext, createEffect, createSignal, getOwner, onCleanup, onMount, splitProps, useContext } from "solid-js";
|
|
2
|
+
import { createComponent, getNextElement, insert, isDev as DEV, isServer, memo, mergeProps, runHydrationEvents, spread, template, use } 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
|
+
var _tmpl$ = /*#__PURE__*/ template(`<div>`);
|
|
156
|
+
/** Context carrying the active controller. Exposed for integrations; prefer `useProgress()`. */
|
|
157
|
+
const ProgressContext = createContext();
|
|
158
|
+
/**
|
|
159
|
+
* Read the nearest progress controller — from `<ProgressProvider>`, `<Progress>`,
|
|
160
|
+
* `<RouteProgress>` or `<NavigationProgress>`.
|
|
161
|
+
*/
|
|
162
|
+
function useProgress() {
|
|
163
|
+
const controller = useContext(ProgressContext);
|
|
164
|
+
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>.");
|
|
165
|
+
return controller;
|
|
166
|
+
}
|
|
167
|
+
/** The props that configure a controller, as opposed to the bar element. */
|
|
168
|
+
const OPTION_KEYS = [
|
|
169
|
+
"trickleTo",
|
|
170
|
+
"delay",
|
|
171
|
+
"stopDelay",
|
|
172
|
+
"speed"
|
|
173
|
+
];
|
|
174
|
+
/**
|
|
175
|
+
* The controller a bar drives: the `controller` prop, else the nearest `<ProgressProvider>`,
|
|
176
|
+
* else a new one built from `options`. Options only apply in the last case, so development
|
|
177
|
+
* builds say so when they would be dropped.
|
|
178
|
+
*/
|
|
179
|
+
function useController(controller, options) {
|
|
180
|
+
const provided = controller ?? useContext(ProgressContext);
|
|
181
|
+
if (!provided) return createProgress(options);
|
|
182
|
+
if (DEV) {
|
|
183
|
+
const dropped = OPTION_KEYS.filter((key) => options[key] !== void 0);
|
|
184
|
+
if (dropped.length) warn(`${dropped.join(", ")} ignored: this bar drives an existing controller, so set options where it is created (createProgress() or <ProgressProvider>).`);
|
|
185
|
+
}
|
|
186
|
+
return provided;
|
|
187
|
+
}
|
|
188
|
+
/** Provides a controller to descendants without rendering anything. */
|
|
189
|
+
function ProgressProvider(props) {
|
|
190
|
+
const [local, options] = splitProps(props, ["controller", "children"]);
|
|
191
|
+
const controller = local.controller ?? createProgress(options);
|
|
192
|
+
return createComponent(ProgressContext.Provider, {
|
|
193
|
+
value: controller,
|
|
194
|
+
get children() {
|
|
195
|
+
return local.children;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const LOCAL = [
|
|
200
|
+
"controller",
|
|
201
|
+
"style",
|
|
202
|
+
"label",
|
|
203
|
+
"getValueLabel",
|
|
204
|
+
"busyAttribute",
|
|
205
|
+
"ariaBusy",
|
|
206
|
+
"children",
|
|
207
|
+
"class"
|
|
208
|
+
];
|
|
209
|
+
/** Bars currently marking the page busy, per attribute: it stays until the last one goes idle. */
|
|
210
|
+
const busyBars = /* @__PURE__ */ new Map();
|
|
211
|
+
function createBusyAttribute(attribute, value, active) {
|
|
212
|
+
let bars = busyBars.get(attribute);
|
|
213
|
+
if (!bars) busyBars.set(attribute, bars = /* @__PURE__ */ new Set());
|
|
214
|
+
const bar = {};
|
|
215
|
+
const sync = (on) => {
|
|
216
|
+
if (on) bars.add(bar);
|
|
217
|
+
else bars.delete(bar);
|
|
218
|
+
if (bars.size) document.documentElement.setAttribute(attribute, value);
|
|
219
|
+
else document.documentElement.removeAttribute(attribute);
|
|
220
|
+
};
|
|
221
|
+
createEffect(() => sync(active()));
|
|
222
|
+
onCleanup(() => sync(false));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The bar shell. Renders a fixed, full-width `role="progressbar"` element and mirrors the
|
|
226
|
+
* controller into `--sp-value` / `--sp-speed` / `data-state` / `data-error`; everything
|
|
227
|
+
* visual lives in `style.css`. Renders the idle shell on the server.
|
|
228
|
+
*/
|
|
229
|
+
function Progress(props) {
|
|
230
|
+
const [local, options, rest] = splitProps(props, LOCAL, OPTION_KEYS);
|
|
231
|
+
const controller = useController(local.controller, options);
|
|
232
|
+
let root;
|
|
233
|
+
const valueNow = () => controller.state() === "trickle" ? void 0 : Math.round(controller.value() * 100);
|
|
234
|
+
const valueText = () => {
|
|
235
|
+
const percent = valueNow();
|
|
236
|
+
return percent === void 0 ? void 0 : local.getValueLabel?.(percent);
|
|
237
|
+
};
|
|
238
|
+
if (!isServer) {
|
|
239
|
+
if (DEV) onMount(() => {
|
|
240
|
+
if (root.getClientRects().length && getComputedStyle(root).position === "static") warn("style.css is not loaded: import 'solid-route-progress/style.css' once.");
|
|
241
|
+
});
|
|
242
|
+
createBusyAttribute("data-sp-busy", "", () => local.busyAttribute !== false && controller.active());
|
|
243
|
+
createBusyAttribute("aria-busy", "true", () => local.ariaBusy === true && controller.active());
|
|
244
|
+
}
|
|
245
|
+
return createComponent(ProgressContext.Provider, {
|
|
246
|
+
value: controller,
|
|
247
|
+
get children() {
|
|
248
|
+
var _el$ = getNextElement(_tmpl$);
|
|
249
|
+
var _ref$ = root;
|
|
250
|
+
typeof _ref$ === "function" ? use(_ref$, _el$) : root = _el$;
|
|
251
|
+
spread(_el$, mergeProps(rest, {
|
|
252
|
+
"role": "progressbar",
|
|
253
|
+
get ["aria-label"]() {
|
|
254
|
+
return local.label ?? "Loading";
|
|
255
|
+
},
|
|
256
|
+
"aria-valuemin": 0,
|
|
257
|
+
"aria-valuemax": 100,
|
|
258
|
+
get ["aria-valuenow"]() {
|
|
259
|
+
return valueNow();
|
|
260
|
+
},
|
|
261
|
+
get ["aria-valuetext"]() {
|
|
262
|
+
return valueText();
|
|
263
|
+
},
|
|
264
|
+
get ["data-state"]() {
|
|
265
|
+
return controller.state();
|
|
266
|
+
},
|
|
267
|
+
get ["data-error"]() {
|
|
268
|
+
return controller.error() ? "" : void 0;
|
|
269
|
+
},
|
|
270
|
+
get ["class"]() {
|
|
271
|
+
return memo(() => !!local.class)() ? `sprogress ${local.class}` : "sprogress";
|
|
272
|
+
},
|
|
273
|
+
get style() {
|
|
274
|
+
return {
|
|
275
|
+
...local.style,
|
|
276
|
+
"--sp-value": controller.value(),
|
|
277
|
+
"--sp-speed": `${controller.options.speed ?? DEFAULTS.speed}ms`
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}), false, true);
|
|
281
|
+
insert(_el$, () => local.children ?? createComponent(Bar, {}));
|
|
282
|
+
runHydrationEvents();
|
|
283
|
+
return _el$;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** The sliding bar: a full-width strip slid in from the inline-start edge. */
|
|
288
|
+
function Bar(props) {
|
|
289
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
290
|
+
return (() => {
|
|
291
|
+
var _el$2 = getNextElement(_tmpl$);
|
|
292
|
+
spread(_el$2, mergeProps({ get ["class"]() {
|
|
293
|
+
return memo(() => !!local.class)() ? `sprogress-bar ${local.class}` : "sprogress-bar";
|
|
294
|
+
} }, rest), false, false);
|
|
295
|
+
runHydrationEvents();
|
|
296
|
+
return _el$2;
|
|
297
|
+
})();
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/navigation-api.ts
|
|
301
|
+
/** `window.navigation`, or `undefined` where the Navigation API is unavailable. */
|
|
302
|
+
const getNavigation = () => globalThis.navigation;
|
|
303
|
+
/** Anchors (or their ancestors) carrying this attribute never trigger the bar. */
|
|
304
|
+
const IGNORE_ATTRIBUTE = "data-sp-ignore";
|
|
305
|
+
/** Whether `target` is, or sits inside, an element marked `data-sp-ignore`. */
|
|
306
|
+
const isIgnored = (target) => target instanceof Element && target.closest(`[data-sp-ignore]`) !== null;
|
|
307
|
+
/**
|
|
308
|
+
* An `AbortSignal` that fires when the surrounding Solid owner is disposed. Pass it to
|
|
309
|
+
* `addEventListener` and the listener removes itself.
|
|
310
|
+
*/
|
|
311
|
+
function disposalSignal() {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
if (getOwner()) onCleanup(() => controller.abort());
|
|
314
|
+
return controller.signal;
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/cross-document.ts
|
|
318
|
+
/**
|
|
319
|
+
* Show the bar for navigations the page starts that leave the current document: external
|
|
320
|
+
* links, plain form posts, `location` assignments and reloads, back/forward to another
|
|
321
|
+
* document. Browser-UI navigations (the reload button, the address bar, bookmarks) never
|
|
322
|
+
* reach the page, so they show nothing.
|
|
323
|
+
*
|
|
324
|
+
* Uses the Navigation API `navigate` event, which fires before the request is made; where
|
|
325
|
+
* the API is missing this does nothing. Links marked `data-sp-ignore` are skipped. The bar
|
|
326
|
+
* fades out when the navigation is cancelled before the document unloads (`navigateerror`:
|
|
327
|
+
* a stop, a newer navigation) or the page is restored from the back/forward cache, and
|
|
328
|
+
* completes after `timeout`, the only end for a `204` or a server-sent download, which
|
|
329
|
+
* Chromium does not report back. A navigation a router intercepts instead completes on
|
|
330
|
+
* `navigatesuccess` / `navigateerror`.
|
|
331
|
+
*/
|
|
332
|
+
function createCrossDocumentProgress(controller, options = {}) {
|
|
333
|
+
const navigation = getNavigation();
|
|
334
|
+
if (isServer || !navigation) return;
|
|
335
|
+
const signal = disposalSignal();
|
|
336
|
+
let timer;
|
|
337
|
+
/** Releases the hold of the navigation still in flight. */
|
|
338
|
+
let release;
|
|
339
|
+
const finish = (outcome) => {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
release?.(outcome);
|
|
342
|
+
release = void 0;
|
|
343
|
+
};
|
|
344
|
+
navigation.addEventListener("navigate", (event) => {
|
|
345
|
+
if (event.defaultPrevented || event.destination.sameDocument || event.downloadRequest !== null || !/^https?:/.test(event.destination.url) || isIgnored(event.sourceElement) || options.filter?.(event) === false) return;
|
|
346
|
+
const previous = release;
|
|
347
|
+
release = controller.start();
|
|
348
|
+
previous?.();
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
const timeout = options.timeout ?? 1e4;
|
|
351
|
+
if (timeout > 0) timer = setTimeout(finish, timeout);
|
|
352
|
+
}, { signal });
|
|
353
|
+
navigation.addEventListener("currententrychange", () => navigation.transition && clearTimeout(timer), { signal });
|
|
354
|
+
navigation.addEventListener("navigatesuccess", () => finish(), { signal });
|
|
355
|
+
navigation.addEventListener("navigateerror", (event) => finish(event.error?.name === "AbortError" ? "cancel" : "error"), { signal });
|
|
356
|
+
window.addEventListener("pageshow", (event) => event.persisted && finish("cancel"), { signal });
|
|
357
|
+
signal.addEventListener("abort", () => finish());
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
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 };
|