diff --git a/.changeset/progress-linear.md b/.changeset/progress-linear.md new file mode 100644 index 000000000..81f7a5181 --- /dev/null +++ b/.changeset/progress-linear.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +`progress` now takes `"circular"` and `"linear"` as well as a boolean, the same values as webpack-dev-server's `client.progress`. `"circular"` is the badge this package has always shown, and what `true` still selects; `"linear"` renders a thin bar across the top of the viewport diff --git a/client-src/index.js b/client-src/index.js index 876af591e..e492fe445 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -46,7 +46,7 @@ import stripAnsi from "./utils/strip-ansi.js"; * @property {string} name limit updates to this compilation name * @property {boolean} autoConnect connect immediately when the entry runs * @property {number=} reconnect how many times to reconnect before giving up, unset to use the transport's default - * @property {boolean} progress show a small badge while a rebuild is in progress + * @property {boolean | "circular" | "linear"} progress show an indicator while a rebuild is in progress — `true` and `"circular"` a small badge, `"linear"` a thin bar across the top of the viewport */ /** @type {ClientOptions} */ @@ -187,7 +187,12 @@ function setOverrides(overrides) { } if (overrides.progress) { - options.progress = overrides.progress !== "false"; + // Same values as webpack-dev-server's `client.progress`, so the shape it + // puts in this query needs no translating. + options.progress = + overrides.progress === "linear" || overrides.progress === "circular" + ? overrides.progress + : overrides.progress !== "false"; } if (overrides.dynamicPublicPath && overrides.dynamicPublicPath !== "false") { @@ -594,6 +599,9 @@ if (typeof window !== "undefined") { } reporter = window[REPORTER_KEY]; + // `true` keeps the badge this package has always shown. + indicator.configure(options.progress === "linear" ? "linear" : "circular"); + // Only what the transport in use needs has to exist: asking for a WebSocket // on a browser without `EventSource` is fine, and so is the reverse. An // injected client speaks for itself, so nothing is required of the browser diff --git a/client-src/indicator.js b/client-src/indicator.js index 72e28b3e7..6c942514f 100644 --- a/client-src/indicator.js +++ b/client-src/indicator.js @@ -12,13 +12,23 @@ const RING_LENGTH = 2 * Math.PI * 6; // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ +/** + * @typedef {"circular" | "linear"} IndicatorType + */ + /** * @typedef {object} IndicatorState * @property {HTMLElement | null} host badge host element + * @property {IndicatorType} type which indicator is rendered * @property {HTMLElement | null} label label inside the badge * @property {HTMLElement | null} dot pulsing dot (indeterminate mode) * @property {SVGSVGElement | null} ring progress ring (determinate mode) * @property {SVGCircleElement | null} ringValue ring value circle + * @property {HTMLElement | null} bar filled part of the linear indicator + * @property {EXPECTED_ANY} barAnimation the bar's sweep, when one is running + * @property {EXPECTED_ANY[]} animations every running animation, so motion can be stopped on request + * @property {EXPECTED_ANY} motionListener what watches for motion being declined mid-build + * @property {EXPECTED_ANY} motionMediaQuery the query that listener sits on, which is the only object it can be removed from * @property {Record} building sources with a build in progress — the badge hides only when every source finished */ @@ -26,10 +36,16 @@ const RING_LENGTH = 2 * Math.PI * 6; function createIndicatorState() { return { host: null, + type: "circular", label: null, dot: null, ring: null, ringValue: null, + bar: null, + barAnimation: null, + animations: [], + motionListener: null, + motionMediaQuery: null, building: {}, }; } @@ -66,6 +82,105 @@ const state = (() => { return holder[INDICATOR_STATE_KEY]; })(); +/** + * @returns {EXPECTED_ANY} the reduced-motion query, or null where there is none + */ +function motionQuery() { + /* istanbul ignore next -- @preserve */ + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return null; + } + + return window.matchMedia("(prefers-reduced-motion: reduce)"); +} + +/** + * Stop every animation this module started, and stop listening for the + * preference that would have stopped them. + */ +function stopAnimations() { + for (const animation of state.animations) { + animation.cancel(); + } + + state.animations = []; + + // The query the listener was registered on, not a fresh one: `matchMedia` + // returns a new `MediaQueryList` for every call, so removing from another + // object silently does nothing and the listeners pile up one per build. + const query = state.motionMediaQuery; + + if (query && state.motionListener) { + if (typeof query.removeEventListener === "function") { + query.removeEventListener("change", state.motionListener); + } else if (typeof query.removeListener === "function") { + query.removeListener(state.motionListener); + } + } + + state.motionListener = null; + state.motionMediaQuery = null; +} + +/** + * Stop the motion, and leave what was moving in a state that still reads as a + * build in progress — a sweep cancelled where it happens to be would otherwise + * look like progress that stalled. + */ +function declineMotion() { + const wasSweeping = Boolean(state.barAnimation); + + stopAnimations(); + state.barAnimation = null; + + if (wasSweeping && state.bar) { + state.bar.style.width = "100%"; + } +} + +/** + * Start a looping animation, unless the viewer asked not to see motion — and + * stop it if they ask while it is running. Every animation started this way is + * tracked, so `hide` can stop them and drop the listener with them. + * @param {EXPECTED_ANY} element what to animate + * @param {EXPECTED_ANY} keyframes keyframes + * @param {EXPECTED_ANY} options animation options + * @returns {EXPECTED_ANY} the animation, or null when none was started + */ +function animate(element, keyframes, options) { + const query = motionQuery(); + + if ((query && query.matches) || typeof element.animate !== "function") { + return null; + } + + const animation = element.animate(keyframes, options); + + state.animations.push(animation); + + // Asked for mid-build: stop what is already moving rather than wait it out. + if (query && !state.motionListener) { + state.motionMediaQuery = query; + state.motionListener = () => { + if (query.matches) { + declineMotion(); + } + }; + + if (typeof query.addEventListener === "function") { + query.addEventListener("change", state.motionListener); + } else if (typeof query.addListener === "function") { + // Safari below 14 has only the deprecated spelling. + query.addListener(state.motionListener); + } + } + + return animation; +} + /** * @param {EXPECTED_ANY} element element * @param {Record} style style map @@ -76,6 +191,32 @@ function applyStyle(element, style) { } } +/** + * Build the linear indicator: a thin bar across the top of the viewport, the + * shape `progress: "linear"` selects in webpack-dev-server. + * @param {ShadowRoot} root the host's shadow root + */ +function buildBar(root) { + applyStyle(/** @type {HTMLElement} */ (state.host), { + position: "fixed", + top: "0", + left: "0", + width: "100%", + height: "4px", + zIndex: 2147483645, + pointerEvents: "none", + }); + + state.bar = document.createElement("div"); + applyStyle(state.bar, { + width: "0%", + height: "4px", + background: theme.accent, + }); + + root.appendChild(state.bar); +} + /** * Create (or reuse) the indicator host element. */ @@ -92,6 +233,16 @@ function ensureIndicator() { state.host = document.createElement("div"); state.host.id = INDICATOR_ID; + + const root = state.host.attachShadow({ mode: "open" }); + + if (state.type === "linear") { + buildBar(root); + document.body.appendChild(state.host); + + return; + } + applyStyle(state.host, { position: "fixed", right: "16px", @@ -100,8 +251,6 @@ function ensureIndicator() { pointerEvents: "none", }); - const root = state.host.attachShadow({ mode: "open" }); - const badge = document.createElement("div"); applyStyle(badge, { display: "flex", @@ -126,12 +275,10 @@ function ensureIndicator() { }); // Pulse through the Web Animations API — no