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.
- dash_startup_loading_plugin/__init__.py +23 -0
- dash_startup_loading_plugin/plugin.py +198 -0
- dash_startup_loading_plugin/resources/startup-loading.css +66 -0
- dash_startup_loading_plugin/resources/startup-loading.js +177 -0
- dash_startup_loading_plugin-0.1.0.dist-info/METADATA +308 -0
- dash_startup_loading_plugin-0.1.0.dist-info/RECORD +10 -0
- dash_startup_loading_plugin-0.1.0.dist-info/WHEEL +5 -0
- dash_startup_loading_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- dash_startup_loading_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
- dash_startup_loading_plugin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|