dash-startup-loading-plugin 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,23 @@
1
+ """Full-screen startup loading overlay for Dash applications."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("dash-startup-loading-plugin")
7
+ except PackageNotFoundError: # pragma: no cover - source tree fallback
8
+ __version__ = "0.1.0"
9
+
10
+ from .plugin import ( # noqa: E402
11
+ StartupLoadingConfig,
12
+ configure,
13
+ get_config,
14
+ reset_config,
15
+ )
16
+
17
+ __all__ = [
18
+ "StartupLoadingConfig",
19
+ "__version__",
20
+ "configure",
21
+ "get_config",
22
+ "reset_config",
23
+ ]
@@ -0,0 +1,198 @@
1
+ """Dash Hooks registration and startup-overlay configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import asdict, dataclass, fields, replace
8
+ from html import escape
9
+ from threading import RLock
10
+ from typing import Any, Iterable
11
+
12
+ from dash import hooks
13
+
14
+ _OVERLAY_MARKER = "data-dash-startup-loading"
15
+ _BODY_PATTERN = re.compile(r"<body(?:\s[^>]*)?>", flags=re.IGNORECASE)
16
+ _CONFIG_LOCK = RLock()
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class StartupLoadingConfig:
21
+ """Configuration serialized into the startup overlay.
22
+
23
+ ``custom_loader_html`` is inserted verbatim and must only contain trusted
24
+ HTML supplied by the application author.
25
+ """
26
+
27
+ enabled: bool = True
28
+ overlay_id: str = "dash-startup-loading"
29
+ aria_label: str = "Loading"
30
+ root_selector: str = "#react-entry-point"
31
+ required_selectors: tuple[str, ...] = ("#react-entry-point",)
32
+ pending_selector: str | None = "[data-dac-async-placeholder]"
33
+ timeout_ms: int | None = 6000
34
+ minimum_display_ms: int = 0
35
+ fade_duration_ms: int = 160
36
+ z_index: int = 9999
37
+ background: str = "#ffffff"
38
+ dark_background: str = "#0f0f0f"
39
+ color: str = "#1677ff"
40
+ dark_color: str = "#4096ff"
41
+ spinner_size_px: int = 28
42
+ spinner_stroke_px: int = 3
43
+ hide_default_loading: bool = True
44
+ custom_loader_html: str | None = None
45
+
46
+
47
+ _DEFAULT_CONFIG = StartupLoadingConfig()
48
+ _config = _DEFAULT_CONFIG
49
+
50
+
51
+ def _selector_tuple(value: Iterable[str] | str) -> tuple[str, ...]:
52
+ if isinstance(value, str):
53
+ raise TypeError("required_selectors must be an iterable of CSS selector strings")
54
+ selectors = tuple(value)
55
+ if not all(isinstance(selector, str) and selector.strip() for selector in selectors):
56
+ raise ValueError("required_selectors must contain non-empty CSS selector strings")
57
+ return selectors
58
+
59
+
60
+ def _validate(config: StartupLoadingConfig) -> StartupLoadingConfig:
61
+ if not isinstance(config.enabled, bool):
62
+ raise TypeError("enabled must be a boolean")
63
+ if not isinstance(config.overlay_id, str) or not config.overlay_id.strip():
64
+ raise ValueError("overlay_id must be a non-empty string")
65
+ if not isinstance(config.root_selector, str) or not config.root_selector.strip():
66
+ raise ValueError("root_selector must be a non-empty CSS selector")
67
+ if config.pending_selector is not None and (
68
+ not isinstance(config.pending_selector, str) or not config.pending_selector.strip()
69
+ ):
70
+ raise ValueError("pending_selector must be None or a non-empty CSS selector")
71
+ if config.timeout_ms is not None and config.timeout_ms < 0:
72
+ raise ValueError("timeout_ms must be None or greater than or equal to zero")
73
+ for name in ("minimum_display_ms", "fade_duration_ms", "spinner_size_px", "spinner_stroke_px"):
74
+ if getattr(config, name) < 0:
75
+ raise ValueError(f"{name} must be greater than or equal to zero")
76
+ return config
77
+
78
+
79
+ def configure(**changes: Any) -> StartupLoadingConfig:
80
+ """Update the process-wide plugin configuration.
81
+
82
+ Call this before creating ``dash.Dash``. The Dash hooks registry is
83
+ process-wide, so one configuration is shared by all apps in the process.
84
+ """
85
+
86
+ valid_names = {field.name for field in fields(StartupLoadingConfig)}
87
+ unknown = set(changes).difference(valid_names)
88
+ if unknown:
89
+ names = ", ".join(sorted(unknown))
90
+ raise TypeError(f"Unknown startup loading option(s): {names}")
91
+ if "required_selectors" in changes:
92
+ changes["required_selectors"] = _selector_tuple(changes["required_selectors"])
93
+
94
+ global _config
95
+ with _CONFIG_LOCK:
96
+ _config = _validate(replace(_config, **changes))
97
+ return _config
98
+
99
+
100
+ def get_config() -> StartupLoadingConfig:
101
+ """Return the active immutable configuration."""
102
+
103
+ with _CONFIG_LOCK:
104
+ return _config
105
+
106
+
107
+ def reset_config() -> StartupLoadingConfig:
108
+ """Restore the default configuration, primarily for tests."""
109
+
110
+ global _config
111
+ with _CONFIG_LOCK:
112
+ _config = _DEFAULT_CONFIG
113
+ return _config
114
+
115
+
116
+ def _client_config(config: StartupLoadingConfig) -> dict[str, Any]:
117
+ values = asdict(config)
118
+ return {
119
+ "rootSelector": values["root_selector"],
120
+ "requiredSelectors": list(values["required_selectors"]),
121
+ "pendingSelector": values["pending_selector"],
122
+ "timeoutMs": values["timeout_ms"],
123
+ "minimumDisplayMs": values["minimum_display_ms"],
124
+ "fadeDurationMs": values["fade_duration_ms"],
125
+ }
126
+
127
+
128
+ def _overlay_html(config: StartupLoadingConfig) -> str:
129
+ client_config = escape(
130
+ json.dumps(_client_config(config), ensure_ascii=False, separators=(",", ":")),
131
+ quote=True,
132
+ )
133
+ overlay_id = escape(config.overlay_id, quote=True)
134
+ aria_label = escape(config.aria_label, quote=True)
135
+ classes = ["dash-startup-loading"]
136
+ if config.hide_default_loading:
137
+ classes.append("dash-startup-loading--hide-default")
138
+ class_name = " ".join(classes)
139
+ styles = {
140
+ "--dash-startup-loading-background": config.background,
141
+ "--dash-startup-loading-dark-background": config.dark_background,
142
+ "--dash-startup-loading-color": config.color,
143
+ "--dash-startup-loading-dark-color": config.dark_color,
144
+ "--dash-startup-loading-size": f"{config.spinner_size_px}px",
145
+ "--dash-startup-loading-stroke": f"{config.spinner_stroke_px}px",
146
+ "--dash-startup-loading-fade-duration": f"{config.fade_duration_ms}ms",
147
+ "--dash-startup-loading-z-index": str(config.z_index),
148
+ }
149
+ style = escape(";".join(f"{name}:{value}" for name, value in styles.items()), quote=True)
150
+ loader = config.custom_loader_html
151
+ if loader is None:
152
+ loader = '<span class="dash-startup-loading__spinner" aria-hidden="true"></span>'
153
+
154
+ return (
155
+ f'<div id="{overlay_id}" class="{class_name}" {_OVERLAY_MARKER} '
156
+ f'data-config="{client_config}" role="status" aria-live="polite" '
157
+ f'aria-label="{aria_label}" aria-busy="true" style="{style}">'
158
+ f'<div class="dash-startup-loading__content">{loader}</div>'
159
+ "</div>"
160
+ )
161
+
162
+
163
+ def _inject_overlay(app_index: str) -> str:
164
+ config = get_config()
165
+ if not config.enabled or _OVERLAY_MARKER in app_index:
166
+ return app_index
167
+
168
+ body_match = _BODY_PATTERN.search(app_index)
169
+ if body_match is None:
170
+ return app_index
171
+ position = body_match.end()
172
+ return app_index[:position] + _overlay_html(config) + app_index[position:]
173
+
174
+
175
+ hooks.stylesheet(
176
+ [
177
+ {
178
+ "relative_package_path": "resources/startup-loading.css",
179
+ "namespace": "dash_startup_loading_plugin",
180
+ }
181
+ ]
182
+ )
183
+
184
+ hooks.script(
185
+ [
186
+ {
187
+ "relative_package_path": "resources/startup-loading.js",
188
+ "namespace": "dash_startup_loading_plugin",
189
+ }
190
+ ]
191
+ )
192
+
193
+
194
+ @hooks.index(priority=100)
195
+ def inject_startup_loading(app_index: str) -> str:
196
+ """Inject the pre-React overlay into the final HTML document."""
197
+
198
+ return _inject_overlay(app_index)
@@ -0,0 +1,66 @@
1
+ .dash-startup-loading {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: var(--dash-startup-loading-z-index, 9999);
5
+ display: grid;
6
+ place-items: center;
7
+ overflow: hidden;
8
+ opacity: 1;
9
+ color: var(--dash-startup-loading-color, #1677ff);
10
+ background: var(--dash-startup-loading-background, #fff);
11
+ transition: opacity var(--dash-startup-loading-fade-duration, 160ms) ease;
12
+ }
13
+
14
+ .dash-startup-loading.is-ready {
15
+ pointer-events: none;
16
+ opacity: 0;
17
+ }
18
+
19
+ .dash-startup-loading__content {
20
+ display: grid;
21
+ place-items: center;
22
+ }
23
+
24
+ .dash-startup-loading__spinner {
25
+ box-sizing: border-box;
26
+ display: block;
27
+ width: var(--dash-startup-loading-size, 28px);
28
+ height: var(--dash-startup-loading-size, 28px);
29
+ border: var(--dash-startup-loading-stroke, 3px) solid transparent;
30
+ border-top-color: currentcolor;
31
+ border-radius: 50%;
32
+ animation: dash-startup-loading-spin 0.8s linear infinite;
33
+ }
34
+
35
+ .dash-startup-loading--hide-default ~ #react-entry-point ._dash-loading,
36
+ .dash-startup-loading--hide-default ~ * ._dash-loading {
37
+ display: none;
38
+ }
39
+
40
+ html.dark .dash-startup-loading {
41
+ color: var(--dash-startup-loading-dark-color, #4096ff);
42
+ background: var(--dash-startup-loading-dark-background, #0f0f0f);
43
+ }
44
+
45
+ @media (prefers-color-scheme: dark) {
46
+ html:not(.light) .dash-startup-loading {
47
+ color: var(--dash-startup-loading-dark-color, #4096ff);
48
+ background: var(--dash-startup-loading-dark-background, #0f0f0f);
49
+ }
50
+ }
51
+
52
+ @media (prefers-reduced-motion: reduce) {
53
+ .dash-startup-loading {
54
+ transition: none;
55
+ }
56
+
57
+ .dash-startup-loading__spinner {
58
+ animation-duration: 1.6s;
59
+ }
60
+ }
61
+
62
+ @keyframes dash-startup-loading-spin {
63
+ to {
64
+ transform: rotate(360deg);
65
+ }
66
+ }
@@ -0,0 +1,177 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ var overlays = new Map();
5
+
6
+ function warn(message, error) {
7
+ if (window.console && typeof window.console.warn === "function") {
8
+ window.console.warn("[dash-startup-loading] " + message, error || "");
9
+ }
10
+ }
11
+
12
+ function parseConfig(overlay) {
13
+ try {
14
+ return JSON.parse(overlay.getAttribute("data-config") || "{}");
15
+ } catch (error) {
16
+ warn("Invalid overlay configuration.", error);
17
+ return {};
18
+ }
19
+ }
20
+
21
+ function query(selector, root) {
22
+ if (!selector) {
23
+ return null;
24
+ }
25
+ try {
26
+ return (root || document).querySelector(selector);
27
+ } catch (error) {
28
+ warn("Invalid CSS selector: " + selector, error);
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function hasRenderedContent(root) {
34
+ if (!root || query("._dash-loading", root)) {
35
+ return false;
36
+ }
37
+ return Array.prototype.some.call(root.childNodes, function (node) {
38
+ return node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim());
39
+ });
40
+ }
41
+
42
+ function createController(overlay) {
43
+ var config = parseConfig(overlay);
44
+ var startedAt = window.performance && performance.now ? performance.now() : Date.now();
45
+ var observer = null;
46
+ var timeoutId = null;
47
+ var minimumTimerId = null;
48
+ var removalTimerId = null;
49
+ var scheduled = false;
50
+ var finished = false;
51
+
52
+ function now() {
53
+ return window.performance && performance.now ? performance.now() : Date.now();
54
+ }
55
+
56
+ function isReady() {
57
+ var root = query(config.rootSelector || "#react-entry-point");
58
+ if (!hasRenderedContent(root)) {
59
+ return false;
60
+ }
61
+
62
+ var required = Array.isArray(config.requiredSelectors) ? config.requiredSelectors : [];
63
+ if (!required.every(function (selector) { return Boolean(query(selector)); })) {
64
+ return false;
65
+ }
66
+
67
+ return !config.pendingSelector || !query(config.pendingSelector, root);
68
+ }
69
+
70
+ function cleanup() {
71
+ if (observer) {
72
+ observer.disconnect();
73
+ }
74
+ window.removeEventListener("load", check);
75
+ if (timeoutId !== null) {
76
+ window.clearTimeout(timeoutId);
77
+ }
78
+ if (minimumTimerId !== null) {
79
+ window.clearTimeout(minimumTimerId);
80
+ }
81
+ }
82
+
83
+ function finish(reason) {
84
+ if (finished) {
85
+ return;
86
+ }
87
+
88
+ var minimumDisplayMs = Math.max(Number(config.minimumDisplayMs) || 0, 0);
89
+ var remaining = minimumDisplayMs - (now() - startedAt);
90
+ if (remaining > 0 && reason !== "timeout") {
91
+ if (minimumTimerId === null) {
92
+ minimumTimerId = window.setTimeout(function () {
93
+ minimumTimerId = null;
94
+ finish(reason);
95
+ }, remaining);
96
+ }
97
+ return;
98
+ }
99
+
100
+ finished = true;
101
+ cleanup();
102
+ overlay.classList.add("is-ready");
103
+ overlay.setAttribute("aria-busy", "false");
104
+ overlay.dispatchEvent(new CustomEvent("dash-startup-loading:ready", {
105
+ bubbles: true,
106
+ detail: { reason: reason || "manual" }
107
+ }));
108
+
109
+ var fadeDurationMs = Math.max(Number(config.fadeDurationMs) || 0, 0);
110
+ removalTimerId = window.setTimeout(function () {
111
+ overlays.delete(overlay.id);
112
+ if (overlay.isConnected) {
113
+ overlay.remove();
114
+ }
115
+ }, fadeDurationMs + 20);
116
+ }
117
+
118
+ function check() {
119
+ if (finished || scheduled || !isReady()) {
120
+ return;
121
+ }
122
+ scheduled = true;
123
+ window.requestAnimationFrame(function () {
124
+ window.requestAnimationFrame(function () {
125
+ scheduled = false;
126
+ if (isReady()) {
127
+ finish("ready");
128
+ }
129
+ });
130
+ });
131
+ }
132
+
133
+ observer = new MutationObserver(check);
134
+ observer.observe(document.documentElement, { childList: true, subtree: true });
135
+ window.addEventListener("load", check);
136
+ if (config.timeoutMs !== null && config.timeoutMs !== undefined) {
137
+ timeoutId = window.setTimeout(function () { finish("timeout"); }, Math.max(Number(config.timeoutMs) || 0, 0));
138
+ }
139
+ check();
140
+
141
+ return {
142
+ check: check,
143
+ finish: finish,
144
+ destroy: function () {
145
+ cleanup();
146
+ if (removalTimerId !== null) {
147
+ window.clearTimeout(removalTimerId);
148
+ }
149
+ }
150
+ };
151
+ }
152
+
153
+ function boot() {
154
+ document.querySelectorAll("[data-dash-startup-loading]").forEach(function (overlay) {
155
+ if (!overlays.has(overlay.id)) {
156
+ overlays.set(overlay.id, createController(overlay));
157
+ }
158
+ });
159
+ }
160
+
161
+ window.dashStartupLoading = {
162
+ check: function (overlayId) {
163
+ var controller = overlays.get(overlayId || "dash-startup-loading");
164
+ if (controller) {
165
+ controller.check();
166
+ }
167
+ },
168
+ finish: function (overlayId) {
169
+ var controller = overlays.get(overlayId || "dash-startup-loading");
170
+ if (controller) {
171
+ controller.finish("manual");
172
+ }
173
+ }
174
+ };
175
+
176
+ boot();
177
+ }());
@@ -0,0 +1,308 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-startup-loading-plugin
3
+ Version: 0.1.0
4
+ Summary: A configurable full-screen startup loading overlay for Dash apps, packaged as a Dash Hooks plugin.
5
+ Author-email: Ethan Zhang <ethan.zhang2016@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: dash,plotly,loading,plugin,hooks
8
+ Classifier: Framework :: Dash
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: dash>=3.0.3
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # dash-startup-loading-plugin
18
+
19
+ `dash-startup-loading-plugin` is an installable [Dash Hooks plugin](https://dash.plotly.com/dash-plugins-using-hooks)
20
+ that displays a full-screen loading overlay while a Dash application performs
21
+ its initial browser-side startup.
22
+
23
+ The overlay is injected into the final HTML document with `hooks.index`, so it
24
+ is visible before Dash and React mount the application layout. The packaged CSS
25
+ and JavaScript are registered with `hooks.stylesheet` and `hooks.script`; an app
26
+ does not need to copy assets or replace Dash's `index_string`.
27
+
28
+ ## Features
29
+
30
+ - Appears before the Dash renderer starts, avoiding a blank startup page.
31
+ - Is discovered automatically through Dash's `dash_hooks` entry point.
32
+ - Waits for real rendered content and optional app-specific readiness selectors.
33
+ - Can wait for lazy-loading placeholders to disappear.
34
+ - Includes a timeout fallback, minimum display time, and fade-out transition.
35
+ - Supports light and dark themes, reduced-motion preferences, custom colors,
36
+ custom spinner geometry, and trusted custom loader markup.
37
+ - Exposes a small browser API and emits a completion event.
38
+ - Requires no changes to the app layout or callbacks.
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.9 or later
43
+ - Dash 3.0.3 or later
44
+
45
+ Dash Hooks were introduced in Dash 3.0. The plugin uses automatic hook
46
+ discovery, resource hooks, and the index hook described in the official
47
+ [Dash plugin documentation](https://dash.plotly.com/dash-plugins-using-hooks).
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pip install dash-startup-loading-plugin
53
+ ```
54
+
55
+ The package declares the following entry point:
56
+
57
+ ```toml
58
+ [project.entry-points."dash_hooks"]
59
+ dash_startup_loading_plugin = "dash_startup_loading_plugin"
60
+ ```
61
+
62
+ Dash imports registered `dash_hooks` packages automatically. Installing the
63
+ package therefore enables the default startup overlay for Dash applications in
64
+ that Python environment; an explicit import is only needed when changing its
65
+ configuration or using its Python API.
66
+
67
+ ## Quick start
68
+
69
+ The defaults work without any plugin-specific code:
70
+
71
+ ```python
72
+ from dash import Dash, html
73
+
74
+ app = Dash(__name__)
75
+ app.layout = html.Main(
76
+ [
77
+ html.H1("My Dash app"),
78
+ html.P("The overlay disappears after this layout is mounted."),
79
+ ]
80
+ )
81
+
82
+ if __name__ == "__main__":
83
+ app.run(debug=True)
84
+ ```
85
+
86
+ To customize the readiness conditions or appearance, call `configure()` before
87
+ creating the `Dash` instance:
88
+
89
+ ```python
90
+ from dash import Dash, html
91
+ from dash_startup_loading_plugin import configure
92
+
93
+ configure(
94
+ required_selectors=["#page-header", "#sidebar-menu"],
95
+ pending_selector="[data-dac-async-placeholder]",
96
+ timeout_ms=6000,
97
+ minimum_display_ms=200,
98
+ fade_duration_ms=160,
99
+ color="#1677ff",
100
+ dark_color="#4096ff",
101
+ )
102
+
103
+ app = Dash(__name__)
104
+ app.layout = html.Div(
105
+ [
106
+ html.Header("Dashboard", id="page-header"),
107
+ html.Nav("Navigation", id="sidebar-menu"),
108
+ ]
109
+ )
110
+ ```
111
+
112
+ See [`examples/basic.py`](examples/basic.py) for a runnable example.
113
+
114
+ ## Readiness behavior
115
+
116
+ The overlay closes with the `ready` reason after all of these conditions are
117
+ true:
118
+
119
+ 1. `root_selector` exists.
120
+ 2. The root no longer contains Dash's initial `._dash-loading` element.
121
+ 3. The root contains an element or non-empty text node.
122
+ 4. Every CSS selector in `required_selectors` exists in the document.
123
+ 5. No node matching `pending_selector` remains under the root.
124
+ 6. The conditions stay true for two consecutive animation frames.
125
+
126
+ A `MutationObserver` rechecks these conditions as the page changes. The
127
+ two-frame confirmation prevents the overlay from disappearing during an
128
+ intermediate render.
129
+
130
+ `timeout_ms` is a safety fallback and dismisses the overlay even when the
131
+ readiness contract is not satisfied. Set it to `None` to disable forced
132
+ dismissal. A timeout is not delayed by `minimum_display_ms`.
133
+
134
+ ## Configuration reference
135
+
136
+ `configure(**changes)` updates only the supplied values and returns the active,
137
+ immutable `StartupLoadingConfig` instance.
138
+
139
+ | Option | Default | Description |
140
+ |---|---:|---|
141
+ | `enabled` | `True` | Inject the startup overlay. Set to `False` to disable it. |
142
+ | `overlay_id` | `"dash-startup-loading"` | HTML `id` of the injected overlay; also used by the browser API. |
143
+ | `aria_label` | `"Loading"` | Accessible label on the overlay's `role="status"` element. |
144
+ | `root_selector` | `"#react-entry-point"` | Dash renderer root observed for mounted content. |
145
+ | `required_selectors` | `("#react-entry-point",)` | Iterable of document-level CSS selectors that must all exist. A single string is not accepted. |
146
+ | `pending_selector` | `"[data-dac-async-placeholder]"` | Selector for placeholders under the root that delay dismissal. Use `None` to disable this check. |
147
+ | `timeout_ms` | `6000` | Forced-dismiss timeout in milliseconds. Use `None` to disable it. |
148
+ | `minimum_display_ms` | `0` | Minimum display time for ready or manual dismissal. |
149
+ | `fade_duration_ms` | `160` | Opacity transition duration before the overlay is removed. |
150
+ | `z_index` | `9999` | Overlay stacking order. |
151
+ | `background` | `"#ffffff"` | Light-theme background color. |
152
+ | `dark_background` | `"#0f0f0f"` | Dark-theme background color. |
153
+ | `color` | `"#1677ff"` | Light-theme spinner/current color. |
154
+ | `dark_color` | `"#4096ff"` | Dark-theme spinner/current color. |
155
+ | `spinner_size_px` | `28` | Default spinner width and height in pixels. |
156
+ | `spinner_stroke_px` | `3` | Default spinner stroke width in pixels. |
157
+ | `hide_default_loading` | `True` | Hide Dash's built-in initial `._dash-loading` indicator while the overlay is present. |
158
+ | `custom_loader_html` | `None` | Trusted HTML that replaces the default spinner. |
159
+
160
+ The default stylesheet recognizes `html.dark` and `html.light`. When neither
161
+ class forces a theme, it follows `prefers-color-scheme`. It also adjusts its
162
+ animation when the user enables `prefers-reduced-motion`.
163
+
164
+ ### Custom loader markup
165
+
166
+ ```python
167
+ from dash_startup_loading_plugin import configure
168
+
169
+ configure(
170
+ aria_label="Loading dashboard",
171
+ custom_loader_html="""
172
+ <div class="brand-loader" aria-hidden="true">
173
+ <span></span><span></span><span></span>
174
+ </div>
175
+ """,
176
+ )
177
+ ```
178
+
179
+ Add the matching `.brand-loader` rules to the app's normal `assets` directory.
180
+ `custom_loader_html` is inserted verbatim and must be trusted application
181
+ configuration. Never populate it with user input.
182
+
183
+ ## Python API
184
+
185
+ ```python
186
+ from dash_startup_loading_plugin import (
187
+ StartupLoadingConfig,
188
+ configure,
189
+ get_config,
190
+ reset_config,
191
+ )
192
+ ```
193
+
194
+ - `configure(**changes)` validates and applies a partial configuration update.
195
+ - `get_config()` returns the current immutable configuration.
196
+ - `reset_config()` restores all defaults. It is primarily useful in tests.
197
+ - `StartupLoadingConfig` is the frozen dataclass containing all options.
198
+
199
+ Unknown option names raise `TypeError`. Invalid selector collections and
200
+ negative timing or spinner values are rejected rather than silently ignored.
201
+
202
+ ## Browser API and events
203
+
204
+ The plugin exposes two methods for integrations that need explicit control:
205
+
206
+ ```javascript
207
+ // Recheck the configured readiness conditions.
208
+ window.dashStartupLoading.check();
209
+
210
+ // Begin a manual dismissal.
211
+ window.dashStartupLoading.finish();
212
+
213
+ // A custom overlay_id can be supplied to either method.
214
+ window.dashStartupLoading.finish("my-loading-overlay");
215
+ ```
216
+
217
+ Before fading out, the overlay dispatches a bubbling
218
+ `dash-startup-loading:ready` event. Its `detail.reason` is `"ready"`,
219
+ `"timeout"`, or `"manual"`:
220
+
221
+ ```javascript
222
+ document.addEventListener("dash-startup-loading:ready", function (event) {
223
+ console.log("Startup overlay finished:", event.detail.reason);
224
+ });
225
+ ```
226
+
227
+ ## Migrating from a custom `index_string`
228
+
229
+ If an app previously injected a loader into a hand-written `index_string`, move
230
+ its readiness contract into the plugin and remove the `app.index_string = ...`
231
+ assignment:
232
+
233
+ ```python
234
+ from dash_startup_loading_plugin import configure
235
+
236
+ configure(
237
+ required_selectors=["#usage-header", "#usage-sidebar-menu"],
238
+ pending_selector="[data-dac-async-placeholder]",
239
+ timeout_ms=6000,
240
+ fade_duration_ms=160,
241
+ )
242
+ ```
243
+
244
+ The plugin preserves Dash's normal index template, injects the overlay directly
245
+ after the opening `<body>` tag, and registers its versioned package assets via
246
+ Dash Hooks. The index hook uses priority `100`, so it runs before lower-priority
247
+ index hooks. If multiple hooks share the same priority, Dash does not guarantee
248
+ their relative order.
249
+
250
+ ## Scope and process model
251
+
252
+ Dash's hook registry is process-wide. `configure()` therefore affects every
253
+ Dash app created in the same Python process. Use one shared configuration per
254
+ process, or set `enabled=False` when the overlay should not be injected.
255
+
256
+ This plugin handles initial application startup only. It does not show a
257
+ full-screen overlay for later callback execution; use `dcc.Loading` or another
258
+ callback-specific loading pattern for that use case.
259
+
260
+ ## Troubleshooting
261
+
262
+ ### The overlay only disappears after the timeout
263
+
264
+ One of the configured selectors is probably never becoming ready. Check that:
265
+
266
+ - `root_selector` matches the actual Dash renderer root.
267
+ - Every `required_selectors` entry exists in the final document.
268
+ - `pending_selector` does not match a placeholder that remains permanently.
269
+ - Custom selectors are valid CSS selectors.
270
+
271
+ Temporarily keep the fallback enabled and listen for the completion event. A
272
+ `"timeout"` reason confirms that the readiness contract was not met.
273
+
274
+ ### The overlay disappears too quickly
275
+
276
+ Set `minimum_display_ms` to keep it visible for a predictable minimum duration,
277
+ or add stable application elements to `required_selectors`.
278
+
279
+ ### The overlay appears in every app in the environment
280
+
281
+ This is expected with automatic `dash_hooks` discovery. Use a dedicated virtual
282
+ environment, uninstall the package where it is not wanted, or call
283
+ `configure(enabled=False)` before constructing those Dash apps.
284
+
285
+ ## Development
286
+
287
+ Clone the repository, then install the project and test dependencies:
288
+
289
+ ```bash
290
+ uv sync --extra test
291
+ ```
292
+
293
+ Run the tests and example:
294
+
295
+ ```bash
296
+ uv run pytest
297
+ uv run python examples/basic.py
298
+ ```
299
+
300
+ Build the source distribution and wheel:
301
+
302
+ ```bash
303
+ uv build
304
+ ```
305
+
306
+ ## License
307
+
308
+ MIT. See [`LICENSE`](LICENSE).
@@ -0,0 +1,10 @@
1
+ dash_startup_loading_plugin/__init__.py,sha256=mhRGKUhJY9UjP1zUJGwPxWGDBokyjp3YfQzccsm6WNg,519
2
+ dash_startup_loading_plugin/plugin.py,sha256=YIMv4xmDgWvDxEcLR3LEvWLkd6TUS3d1fuKstALPaXs,7030
3
+ dash_startup_loading_plugin/resources/startup-loading.css,sha256=rAJSLe9YQjVEfLA3QGFsulqVhajMu6Z7jMfzFGUm5fg,1758
4
+ dash_startup_loading_plugin/resources/startup-loading.js,sha256=okhYdwYlzDrcUp8Sd1HI91b1iXnlXEEmkzfUsT06J8g,5739
5
+ dash_startup_loading_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
6
+ dash_startup_loading_plugin-0.1.0.dist-info/METADATA,sha256=4YxAoc8XPumzQ0VdZgB8ouRPowG-jYFNWy51U2woyjM,10740
7
+ dash_startup_loading_plugin-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ dash_startup_loading_plugin-0.1.0.dist-info/entry_points.txt,sha256=TLRonncU9df9fZjsZzCSicZ0XToewEf1zrihk0dUzHQ,71
9
+ dash_startup_loading_plugin-0.1.0.dist-info/top_level.txt,sha256=bF7ddV1nLIby8uvgcLlytofPH5ci8GqOX0uvaNTUEXc,28
10
+ dash_startup_loading_plugin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [dash_hooks]
2
+ dash_startup_loading_plugin = dash_startup_loading_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dash_startup_loading_plugin