Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ generated by `dash-generate-components` from `src/lib/components/`.
4. Register the family in `dash_loading_components/registry.py`, including its
native tempo prop and each spinner's normal tempo if it has one.
5. Add the family to `NOTICE`.
6. Add it to the gallery in `gallery.py`: an `UPSTREAM_CREDITS` entry,
`FAMILY_PROP_ORDER`, a `prop_spec` branch for any prop whose range or default
differs from `PROP_SPECS`, `IGNORED_PROPS` for controls a spinner ignores,
and `UPSTREAM_DEFAULTS` where a spinner's own default differs.
7. Add `assets/icons/<family>.svg` and its `.dlc-icon-<family>` rule in
`assets/gallery.css`, then a row in the README's family table.
8. If the family draws on a canvas, add it to `UNINSPECTABLE_FAMILIES` in
`tests/test_gallery_controls.py`: there is no DOM state to compare.
13 changes: 12 additions & 1 deletion NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,20 @@ Notable upstream projects (non-exhaustive; see inventory for pins and status):
- flicker-dot (laurieesc/flicker-dot) — MIT. The dlc.flicker presets are
original PhylaTech designs under this package's MIT license; none of the
flicker.laurie.fyi gallery patterns are included.
- gridora (0x65dgerunner/gridora) — MIT. 133 motion variants and bitmap-font
- gridora (0x65dgerunner/gridora) -- MIT. 133 motion variants and bitmap-font
text, wrapped as dlc.gridora.GridLoader and dlc.gridora.Text. The studio
site (gridora.gabrielayer.com) patterns are not redistributed.
- loadersz (lumberjacque/loadersz-core) -- MIT. 160 canvas-based loading
animations as a native Web Component, wrapped as dlc.loadersz.Loader.
Includes chart-shaped loaders (heatmap, sankey, treemap, candlestick,
bar chart) suited for data dashboards.
Note: the first nine semantic state names (working, searching, connecting,
weaving, shaping, listening, breathing, composing, solving) overlap with
thinking-orbs (Jakub Antalik, MIT). The two libraries are independently
implemented: loadersz is a framework-agnostic Web Component using its own
canvas renderer with Dot/OrbLine/OrbRect/OrbArc primitives, while
thinking-orbs is a React-only 3D dotted-sphere renderer. The shared names
describe common AI/agent activity verbs. No code or assets are copied.

Hard skips (not wrapped): css-spinners (GPL-3.0), react18-loaders (MPL-2.0).

Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# dash-loading-components (`dlc`)

Loading indicators for [Plotly Dash](https://dash.plotly.com), wrapping eleven
React spinner libraries as one install. 301 components under stable namespaces,
Loading indicators for [Plotly Dash](https://dash.plotly.com), wrapping twelve
React spinner libraries as one install. 461 components under stable namespaces,
with a common factory over the top so you can switch families without
relearning each one's props.

Expand Down Expand Up @@ -44,9 +44,9 @@ dcc.Loading(children, custom_spinner=dlc.ldrs.Ring(size=48))
Discover what is available at runtime:

```python
dlc.list_libraries() # ['loading_dev', 'ldrs', 'spinners', ..., 'gridora']
dlc.list_libraries() # ['loading_dev', 'ldrs', 'spinners', ..., 'gridora', 'loadersz']
dlc.list_spinners("ldrs") # ['Ring', 'Helix', 'DotPulse', ...]
dlc.list_spinners("gridora") # ['GridLoader', 'Text']
dlc.list_spinners("loadersz") # ['working', 'searching', 'connecting', ...]
```

## Requirements
Expand Down Expand Up @@ -74,6 +74,7 @@ resolve it; it will accept the final 4.5 release once published.
| `m3` | [@alerix/m3-loading-indicator](https://www.npmjs.com/package/@alerix/m3-loading-indicator) | 1 |
| `flicker` | [flicker-dot](https://www.npmjs.com/package/flicker-dot) | 61 |
| `gridora` | [gridora](https://www.npmjs.com/package/gridora) | 133 |
| `loadersz` | [loadersz](https://www.npmjs.com/package/loadersz) | 160 |

## Flip-dot presets

Expand Down Expand Up @@ -127,6 +128,23 @@ common `size`, `dlc.gridora` derives the cell size from the grid geometry. Pass
cycle duration in seconds (higher = slower), not a multiplier; `rate` translates:
`speed = 1 / rate`.

## Canvas loaders

loadersz ships 160 canvas loaders as a Web Component, from product states
(`working`, `searching`) to chart-shaped ones (`heatmapping`, `sankeying`,
`treemapping`). `dlc.loadersz.Loader` renders any of them; the state is a prop,
so there is no `dlc.loadersz.working`.

```python
dlc.loadersz.Loader(state="heatmapping", size=96, color="#f97316")
dlc.loadersz.Loader(state="working", hue=200, density=1.5)
```

`color` paints every particle itself and wins over `hue` and `theme`. Leave it
off to get loadersz's own palette (grey, or a loader's own tones), tinted by
`hue` (0 to 360) and drawn for a light or dark page by `theme` (`"auto"`
follows the operating system, not the page).

Every component is also exported prefixed at the top level
(`dlc.LoadingDevArc`, `dlc.LdrsRing`) for Dash callbacks that want a flat name.

Expand All @@ -139,9 +157,9 @@ Version pins, SPDX licenses, upstream URLs and React peers:
`rate` is a rate, not a duration. `1.0` reproduces the tempo the upstream
component runs at on its own, and the value is translated per family in the
wrappers: a duration in milliseconds for loading-dev, epic and premium,
seconds per loop for ldrs, a multiplier for react-spinners, m3 and flicker, a
percentage for spinners-react, an offset for react-loading-indicators, and
seconds per cycle (inverted) for gridora.
seconds per loop for ldrs, a multiplier for react-spinners, m3, flicker and
loadersz, a percentage for spinners-react, an offset for
react-loading-indicators, and seconds per cycle (inverted) for gridora.

That baseline is per spinner, not per family. `loading_dev.Compass` is normally
500ms and `loading_dev.Slide` is 2400ms, so `rate=1.0` gives each of them its
Expand Down
70 changes: 66 additions & 4 deletions assets/gallery.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ p { text-wrap: pretty; }
.dlc-icon-epic { --dlc-icon: url(icons/epic.svg); }
.dlc-icon-flicker { --dlc-icon: url(icons/flicker.svg); }
.dlc-icon-gridora { --dlc-icon: url(icons/gridora.svg); }
.dlc-icon-loadersz { --dlc-icon: url(icons/loadersz.svg); }
.dlc-icon-overview { --dlc-icon: url(icons/overview.svg); }
.dlc-icon-credits { --dlc-icon: url(icons/credits.svg); }
.dlc-icon-request { --dlc-icon: url(icons/request.svg); }
Expand Down Expand Up @@ -112,20 +113,58 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
border-radius: var(--mantine-radius-md); box-shadow: var(--mantine-shadow-md);
}
.dlc-search-option {
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
padding: 8px 10px; border-radius: var(--mantine-radius-sm);
display: flex; align-items: center; gap: 10px;
padding: 6px 10px; border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-text); font-size: 14px; text-decoration: none;
}
/* Same slot as the brand mark, so a cloned mark sits in it unchanged. */
.dlc-search-preview {
position: relative; display: inline-flex; flex-shrink: 0; width: 24px; height: 24px;
align-items: center; justify-content: center; overflow: hidden; color: var(--dlc-accent);
}
.dlc-search-preview .dlc-icon { width: 16px; height: 16px; color: var(--mantine-color-dimmed); }
/* The element sets inline-block itself; its canvas would sit on a text baseline. */
.dlc-search-preview loadersz-loader { display: flex !important; }
.dlc-search-title { flex: 1; min-width: 0; }
.dlc-search-option[data-active] { background: var(--mantine-color-default-hover); }
.dlc-search-group { font-size: 12px; color: var(--mantine-color-dimmed); font-family: var(--mantine-font-family-monospace); }
.dlc-search-empty { padding: 10px; font-size: 14px; color: var(--mantine-color-dimmed); }
@media (max-width: 48em) { .dlc-search { width: 170px; } }

/* ---------- Navbar ---------- */
.dlc-nav-scroll { height: 100%; padding: 12px 10px 24px; }
.dlc-nav-section { padding: 18px 12px 6px; color: var(--mantine-color-dimmed); }
/* Families are <details>, closed until the page is inside one (assets/gallery.js),
so 461 loaders read as a dozen families instead of one wall. */
.dlc-nav-family > summary, .dlc-nav-cat > summary { list-style: none; cursor: pointer; }
.dlc-nav-family > summary::-webkit-details-marker, .dlc-nav-cat > summary::-webkit-details-marker { display: none; }
.dlc-nav-section {
display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 10px 12px;
border-radius: var(--mantine-radius-sm); color: var(--mantine-color-dimmed);
transition-property: background-color; transition-duration: 150ms; transition-timing-function: ease-out;
}
.dlc-nav-section:hover { background: var(--mantine-color-default-hover); }
.dlc-nav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dlc-nav-section > * { flex-shrink: 0; }
.dlc-nav-section > .dlc-nav-label { flex-shrink: 1; }
.dlc-nav-chevron {
width: 0.8em; height: 0.8em; opacity: 0.7;
transition-property: rotate; transition-duration: 150ms; transition-timing-function: ease-out;
}
.dlc-nav-family[open] > summary .dlc-nav-chevron, .dlc-nav-cat[open] > summary .dlc-nav-chevron { rotate: 90deg; }
.dlc-nav-link { padding-left: 36px; min-height: 32px; }
.dlc-nav-link .mantine-NavLink-label { font-size: 13px; }
.dlc-nav-cat-title {
display: flex; align-items: center; gap: 6px; min-height: 30px;
margin: 2px 0; padding: 0 12px 0 28px; border-radius: var(--mantine-radius-sm);
font-size: 12px; font-weight: 600; color: var(--mantine-color-dimmed);
transition-property: background-color; transition-duration: 150ms; transition-timing-function: ease-out;
}
.dlc-nav-cat-title:hover { background: var(--mantine-color-default-hover); }
.dlc-nav-cat[open] > .dlc-nav-cat-title { color: var(--mantine-color-text); }
.dlc-nav-cat .dlc-nav-link { padding-left: 48px; }
.dlc-nav-family > summary:focus-visible, .dlc-nav-cat > summary:focus-visible {
outline: 2px solid var(--dlc-accent); outline-offset: -2px;
}

/* ---------- Page frame ---------- */
.dlc-page {
Expand Down Expand Up @@ -295,6 +334,29 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
--Dash-Fill-Inverse-Strong: var(--mantine-color-default);
font-size: 14px;
}
/* Size control: presets above, an exact px field right of the label. */
.dlc-size-input {
position: relative; display: flex; align-items: center; gap: 4px; height: 28px; padding: 0 8px;
border: 1px solid var(--dlc-border); border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default); cursor: text;
}
.dlc-size-input:focus-within { border-color: var(--dlc-accent); }
/* dcc.Input wraps the real <input> in a div that carries the class. */
.dlc-size-field, .dlc-size-field:focus-within {
width: 44px; height: 100%; margin: 0; border: none; outline: none; box-shadow: none; background: none;
}
.dlc-size-field .dash-input-element {
width: 100%; height: 100%; margin: 0; padding: 0; border: none; outline: none; box-shadow: none;
background: none; color: var(--mantine-color-text); text-align: right; font-size: 13px;
font-family: var(--mantine-font-family-monospace); font-variant-numeric: tabular-nums;
appearance: textfield; -moz-appearance: textfield;
}
.dlc-size-field .dash-input-element::-webkit-inner-spin-button,
.dlc-size-field .dash-input-element::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.dlc-sr-only {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.dlc-size-unit { font-size: 12px; color: var(--mantine-color-dimmed); }
/* Color control: swatch (native picker behind it) + free-text field. */
.dlc-color-input {
display: flex; align-items: center; height: 36px;
Expand All @@ -311,7 +373,7 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
.dlc-color-picker, .dlc-color-picker .dash-input-element {
position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer;
}
.dlc-color-text { flex: 1; min-width: 0; height: 100%; margin: 0; border: none; box-shadow: none; }
.dlc-color-text { flex: 1; min-width: 0; height: 100%; margin: 0; border: none; box-shadow: none; background: none; }
.dlc-color-text .dash-input-element {
width: 100%; height: 100%; margin: 0; padding: 0; border: none; background: none; outline: none;
box-shadow: none; color: var(--mantine-color-text); font-size: 14px;
Expand Down
51 changes: 48 additions & 3 deletions assets/gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,52 @@
if (!list) return;
list.hidden = true;
list.replaceChildren();
if (previews) previews.disconnect();
input().setAttribute('aria-expanded', 'false');
active = -1;
};

// A small live preview beside each result. Almost every loader is already in
// the page, hidden, as a brand-mark slot: clone that. loadersz is not (its
// canvases never stop animating), so it is made on the spot. What has
// neither (m3 draws from React, a few loaders overflow the slot) gets its
// family icon, which is also what library and page rows show.
const iconFor = (href) => {
const family = href.match(/^\/(?:c\/([^/]+)\/.*|#family-(.+))$/);
return (family && (family[1] || family[2])) || (href === '/credits' ? 'credits' : 'overview');
};
const preview = (slot, href) => {
const detail = href.match(/^\/c\/([^/]+)\/(.+)$/);
if (detail && detail[1] === 'loadersz') {
const el = document.createElement('loadersz-loader');
el.setAttribute('state', decodeURIComponent(detail[2]));
el.setAttribute('size', '22');
return slot.append(el);
}
const mark = detail && document.querySelector(
`.dlc-brand-mark .dlc-mark[data-family="${CSS.escape(detail[1])}"][data-name="${CSS.escape(decodeURIComponent(detail[2]))}"]`);
if (mark) {
const copy = mark.cloneNode(true);
copy.hidden = false;
copy.classList.remove('is-entering', 'is-leaving');
return slot.append(copy);
}
const icon = document.createElement('span');
icon.className = `dlc-icon dlc-icon-${iconFor(href)}`;
slot.append(icon);
};
// Only rows scrolled into view get one, so a long result list stays cheap.
let previews = null;
const showPreviews = (slots) => {
previews && previews.disconnect();
previews = new IntersectionObserver((seen) => seen.forEach((entry) => {
if (!entry.isIntersecting) return;
previews.unobserve(entry.target);
preview(entry.target, entry.target.parentElement.getAttribute('href'));
}), {root: results()});
slots.forEach((slot) => previews.observe(slot));
};

const render = (query) => {
const list = results();
if (!list) return;
Expand All @@ -41,12 +83,13 @@
a.className = 'dlc-search-option';
a.setAttribute('role', 'option');
a.dataset.index = i;
a.innerHTML = `<span class="dlc-search-title"></span><span class="dlc-search-group"></span>`;
a.firstChild.textContent = e.title;
a.innerHTML = `<span class="dlc-search-preview" aria-hidden="true"></span><span class="dlc-search-title"></span><span class="dlc-search-group"></span>`;
a.children[1].textContent = e.title;
a.lastChild.textContent = e.group;
a.addEventListener('mousedown', (ev) => { ev.preventDefault(); go(e.href); });
return a;
}));
showPreviews(list.querySelectorAll('.dlc-search-preview'));
if (!hits.length) {
const empty = document.createElement('div');
empty.className = 'dlc-search-empty';
Expand Down Expand Up @@ -121,14 +164,16 @@
target.scrollIntoView({block: 'start'});
}).observe(document.documentElement, {childList: true, subtree: true});

// Keep the current component's nav link in view. Arriving on /c/epic/...
// Keep the current component's nav link in view, its family and category open. Arriving on /c/epic/...
// from a search or a shared link otherwise leaves it 100 rows below the fold.
let lastPath = null;
new MutationObserver(() => {
if (location.pathname === lastPath) return;
const active = document.querySelector('#dlc-navbar a[data-active]');
if (!active || !active.getAttribute('href').endsWith(location.pathname)) return;
lastPath = location.pathname;
// Families and categories start closed; open the ones holding this link.
for (let d = active.closest('details'); d; d = d.parentElement.closest('details')) d.open = true;
active.scrollIntoView({block: 'nearest'});
}).observe(document.documentElement, {subtree: true, attributes: true, attributeFilter: ['data-active']});

Expand Down
1 change: 1 addition & 0 deletions assets/icons/loadersz.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading