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
1,892 changes: 1,892 additions & 0 deletions docs/examples/fullscreen-dashboard.ipynb

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ project:
- file: widgets/row.ipynb
- file: widgets/column.ipynb
- file: widgets/grid.ipynb
- file: widgets/fullscreen.ipynb
- title: Lonboard
children:
- file: widgets/layer_toggle.ipynb
Expand All @@ -60,6 +61,7 @@ project:
- file: examples/layout.ipynb
- file: examples/lonboard-map.ipynb
- file: examples/map-compare.ipynb
- file: examples/fullscreen-dashboard.ipynb
site:
template: book-theme
nav:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "manywidgets",
"version": "0.1.0",
"version": "0.2.0a1",
"private": true,
"description": "Build pipeline for the manywidgets anywidget bundles (esbuild over each widget + the shared @manywidgets/core module).",
"type": "module",
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ ensured-targets = [
"src/manywidgets/chart/dist/widget.js",
"src/manywidgets/column/dist/widget.js",
"src/manywidgets/dropdown/dist/widget.js",
"src/manywidgets/fullscreen/dist/widget.js",
"src/manywidgets/grid/dist/widget.js",
"src/manywidgets/legend/dist/widget.js",
"src/manywidgets/lonboard/filter_binder/dist/widget.js",
Expand All @@ -101,6 +102,7 @@ skip-if-exists = [
"src/manywidgets/chart/dist/widget.js",
"src/manywidgets/column/dist/widget.js",
"src/manywidgets/dropdown/dist/widget.js",
"src/manywidgets/fullscreen/dist/widget.js",
"src/manywidgets/grid/dist/widget.js",
"src/manywidgets/legend/dist/widget.js",
"src/manywidgets/lonboard/filter_binder/dist/widget.js",
Expand Down
2 changes: 1 addition & 1 deletion scripts/build_skill_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
GROUPS: list[tuple[str, list[str]]] = [
("Charts & displays", ["chart", "stat", "number_display", "text", "legend"]),
("Input controls", ["slider", "range_slider", "dropdown", "toggle", "button", "number_input"]),
("Layout containers", ["row", "column", "grid"]),
("Layout containers", ["row", "column", "grid", "fullscreen"]),
("Linking", ["binder"]),
("Lonboard interop", ["layer_toggle", "layer_filter", "filter_binder", "map_flyer", "map_compare"]),
]
Expand Down
2 changes: 2 additions & 0 deletions src/manywidgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .chart import Chart
from .column import Column
from .dropdown import Dropdown
from .fullscreen import Fullscreen
from .grid import Grid
from .legend import Legend
from .number_display import NumberDisplay
Expand All @@ -36,6 +37,7 @@
"Chart",
"Column",
"Dropdown",
"Fullscreen",
"Grid",
"Legend",
"NumberDisplay",
Expand Down
2 changes: 1 addition & 1 deletion src/manywidgets/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.2.0a1"
3 changes: 3 additions & 0 deletions src/manywidgets/fullscreen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .widget import Fullscreen

__all__ = ["Fullscreen"]
87 changes: 87 additions & 0 deletions src/manywidgets/fullscreen/doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Fullscreen

Wrap a widget with an expand button that opens a viewport-covering overlay —
either the same widget expanded, or a different layout (e.g. inline a single
chart, fullscreen a whole dashboard). Close with the ✕ button or `Esc`.

## Import

```python
from manywidgets import Fullscreen
```

## Example

Expand a chart to fill the screen:

```{code-cell} python
import numpy as np
from manywidgets import Chart, Fullscreen

x = np.linspace(0, 10, 200)
chart = Chart(title="NDVI over time", x_label="week", height=320)
chart.add_series(x=x, y=0.4 + 0.2 * np.sin(x), name="NDVI")
Fullscreen(chart)
```

Show a different layout in fullscreen — inline a single stat, fullscreen a
dashboard:

```{code-cell} python
from manywidgets import Fullscreen, Grid, Stat

stat = Stat(label="Uptime", value=99, unit="%")
Fullscreen(
stat,
fullscreen=Grid(
stat,
Stat(label="Revenue", value=1234, unit="USD", delta=12),
Stat(label="Users", value=987, delta=-3),
Stat(label="Latency", value=42, unit="ms"),
columns=2,
),
)
```

Open and close it from Python:

```{code-cell} python
f = Fullscreen(Stat(label="Latency", value=42, unit="ms"))
f.is_open = True # opens the overlay
f.is_open = False # closes it again
f
```

## API

{api-table}

## Sharing a fullscreen link

On a statically exported page, the overlay state is mirrored into the URL:
opening fullscreen adds `?fullscreen=<widget_id>` to the address bar, so you can
copy the URL to share a link that opens the page directly in that fullscreen
view. A bare `?fullscreen=true` opens the page's first `Fullscreen` widget.

Auto-assigned ids (`fullscreen_1`, …) depend on widget creation order, so pass
an explicit id for links that should survive notebook edits:

```python
Fullscreen(chart, fullscreen=dashboard, widget_id="ndvi-dashboard")
# share as: https://example.org/report.html?fullscreen=ndvi-dashboard
```

## Notes

- The overlay is a CSS layer covering the viewport (not the browser's
fullscreen mode), so it works in static export and can be driven from Python.
- A directly-expanded child (no `fullscreen=` layout) stretches to fill the
overlay. Inside a `fullscreen=` layout, children keep their own sizing and
the panel scrolls — give that layout its own heights (e.g. a map's `height`
trait) rather than expecting it to stretch.
- The same widget instance can appear both inline and in the `fullscreen=`
layout — the inline view is hidden while the overlay is open, and trait
changes stay in sync between the two.

See the [fullscreen dashboard example](../examples/fullscreen-dashboard.ipynb)
for a lonboard map + chart + controls layout behind a single inline chart.
194 changes: 194 additions & 0 deletions src/manywidgets/fullscreen/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import type { RenderProps } from "@anywidget/types";
import {
applyThemeVars,
idOf,
type RenderArgs,
renderChild,
safeSaveChanges,
} from "@manywidgets/core";

interface FullscreenModel {
child: unknown;
fullscreen: unknown;
is_open: boolean;
widget_id: string;
}

// Page-global claim flag so a bare `?fullscreen` / `?fullscreen=true` URL opens
// only the FIRST Fullscreen widget that renders, not every one on the page.
const URL_CLAIM = "__mw_fullscreen_url_claimed";

async function render(args: RenderProps<FullscreenModel>): Promise<() => void> {
const { model, el } = args;
el.className = "manywidgets-fullscreen";
const disposeTheme = applyThemeVars(el, model);

const inlineWrap = document.createElement("div");
inlineWrap.className = "mwfs__inline";
const slot = document.createElement("div");
slot.className = "mwfs__slot";
const expandBtn = document.createElement("button");
expandBtn.type = "button";
expandBtn.className = "mwfs__expand";
expandBtn.textContent = "⛶";
expandBtn.setAttribute("aria-label", "Enter fullscreen");
inlineWrap.append(slot, expandBtn);

const overlay = document.createElement("div");
overlay.className = "mwfs__overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "mwfs__close";
closeBtn.textContent = "✕";
closeBtn.setAttribute("aria-label", "Close fullscreen");
const content = document.createElement("div");
content.className = "mwfs__content";
overlay.append(closeBtn, content);

el.append(inlineWrap, overlay);

const disposeChild = model.get("child")
? await renderChild(args as unknown as RenderArgs, model.get("child") as string, slot)
: (): void => {};

// Lazy, memoised mount of the alternate fullscreen layout: mounted on first
// open, kept afterwards. The memoised promise means a fast open/close/open
// can never double-mount.
let disposeAlt: (() => void) | null = null;
let altMount: Promise<void> | null = null;
const hasAlt = (): boolean => !!idOf(model.get("fullscreen"));
const ensureAlt = (): Promise<void> =>
(altMount ??= renderChild(
args as unknown as RenderArgs,
model.get("fullscreen") as string,
content,
).then(
(d) => {
disposeAlt = d;
},
(err) => {
altMount = null; // let a later open retry instead of caching the failure
console.warn("[manywidgets:fullscreen] fullscreen layout failed to render:", err);
},
));

const doc = el.ownerDocument;
const win = doc.defaultView ?? window;

// Deep-linking is static-export only: in a live kernel (JupyterLab, VS Code)
// the page URL is the notebook's and must not be touched.
const isStatic = !!(args as unknown as RenderArgs).host;
const widgetId = String(model.get("widget_id") || "");

const reflectUrl = (isOpen: boolean): void => {
if (!isStatic || !widgetId) return;
try {
const url = new URL(win.location.href);
if (isOpen) url.searchParams.set("fullscreen", widgetId);
else url.searchParams.delete("fullscreen");
const prevBase = doc.baseURI;
// replaceState, not pushState: no history spam, no popstate handling.
win.history.replaceState(win.history.state, "", url.toString());
// The static-export host keys its per-page widget registry by
// document.baseURI, which replaceState just changed — alias the existing
// registry under the new key or every later child mount / cross-widget
// lookup on this page would start from an empty registry and time out.
const hosts = (globalThis as { __myst_anywidget_hosts?: Map<string, unknown> })
.__myst_anywidget_hosts;
const nextBase = doc.baseURI;
if (hosts && nextBase !== prevBase && hosts.has(prevBase) && !hosts.has(nextBase)) {
hosts.set(nextBase, hosts.get(prevBase));
}
} catch {
// Sandboxed iframes can forbid history access.
}
};

// Size-caching children (deck.gl/lonboard; Chart.js self-heals anyway via
// responsive:true) re-measure after the overlay lays out.
const kickResize = (): void => {
if (win.requestAnimationFrame) {
win.requestAnimationFrame(() => win.dispatchEvent(new Event("resize")));
} else {
win.dispatchEvent(new Event("resize"));
}
};

function onKeydown(ev: KeyboardEvent): void {
if (ev.key === "Escape") setOpen(false);
}

// Idempotent, trait-driven state machine: buttons/Esc only write the trait;
// this single change:is_open listener does all DOM work, so Python-driven and
// click-driven opens share one code path.
// reflectReady stays false through the initial sync: a widget exported with
// is_open=True must open on load WITHOUT rewriting the page URL — only
// toggles after render (clicks, deep link, kernel writes) touch the URL.
let reflectReady = false;
let isOpen = false;
function sync(): void {
const want = !!model.get("is_open");
if (want === isOpen) return;
isOpen = want;
el.classList.toggle("mwfs--open", want);
if (reflectReady) reflectUrl(want);
if (want) {
// With no alternate layout, re-parent the live inline DOM instead of
// re-rendering: renderChild has no remount primitive, and moving the
// node preserves canvas/WebGL state.
if (hasAlt()) void ensureAlt();
else content.appendChild(slot);
doc.addEventListener("keydown", onKeydown);
} else {
if (!hasAlt()) inlineWrap.insertBefore(slot, expandBtn);
doc.removeEventListener("keydown", onKeydown);
}
kickResize();
}

function setOpen(value: boolean): void {
model.set("is_open", value);
safeSaveChanges(model);
}

expandBtn.addEventListener("click", () => setOpen(true));
closeBtn.addEventListener("click", () => setOpen(false));
model.on("change:is_open", sync);
sync(); // honour is_open=True at construction time (no URL write)
reflectReady = true;

// Deep link: `?fullscreen=<widget_id>` opens this widget; a bare
// `?fullscreen` / `?fullscreen=true` opens the page's first Fullscreen
// widget. reflectUrl then normalizes the URL to the concrete widget_id, so
// the address bar is always copyable as a deterministic link.
if (isStatic) {
let param: string | null = null;
try {
param = new URL(win.location.href).searchParams.get("fullscreen");
} catch {
// Unparseable location (unlikely) — no deep link.
}
if (param !== null && !model.get("is_open")) {
const g = globalThis as Record<string, unknown>;
if (param === widgetId && widgetId) {
setOpen(true);
} else if ((param === "" || param === "true") && !g[URL_CLAIM]) {
g[URL_CLAIM] = true;
setOpen(true);
}
}
}

return () => {
doc.removeEventListener("keydown", onKeydown);
model.off("change:is_open", sync);
disposeTheme();
disposeChild();
disposeAlt?.();
overlay.remove();
};
}

export default { render };
Loading
Loading