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 change: 1 addition & 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/grid_item.ipynb
- file: widgets/fullscreen.ipynb
- title: Lonboard
children:
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", "fullscreen"]),
("Layout containers", ["row", "column", "grid", "grid_item", "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 @@ -19,6 +19,7 @@
from .dropdown import Dropdown
from .fullscreen import Fullscreen
from .grid import Grid
from .grid_item import GridItem
from .legend import Legend
from .number_display import NumberDisplay
from .number_input import NumberInput
Expand All @@ -39,6 +40,7 @@
"Dropdown",
"Fullscreen",
"Grid",
"GridItem",
"Legend",
"NumberDisplay",
"NumberInput",
Expand Down
30 changes: 30 additions & 0 deletions src/manywidgets/grid/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,36 @@ Grid(
)
```

## Spanning and named areas

Wrap a child in [GridItem](./grid_item.md) to span multiple columns/rows, or
to place it into a `template_areas` region; see its docs for both forms.
Unwrapped children keep today's 1x1 behaviour.

## Asymmetric columns and row sizing

`columns` also accepts a raw CSS grid-template-columns track string, for a
sidebar narrower than the main content instead of N equal columns.
`template_rows` is the row equivalent (e.g. `"auto 1fr auto"` for a thin
header/footer and a middle row that grows), but a `1fr` track only has
space to grow into if the `Grid` itself has an explicit `height`; unset,
rows default to auto-sizing (fit content), same as CSS Grid itself:

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

Grid(
GridItem(Stat(label="header"), area="header"),
GridItem(Stat(label="sidebar"), area="sidebar"),
GridItem(Stat(label="main"), area="main"),
GridItem(Stat(label="footer"), area="footer"),
columns="200px 1fr", gap="12px",
template_areas='"header header" "sidebar main" "footer footer"',
template_rows="auto 1fr auto",
height="300px",
)
```

## API

{api-table}
Expand Down
41 changes: 37 additions & 4 deletions src/manywidgets/grid/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import type { RenderProps } from "@anywidget/types";
import { renderChild, type RenderArgs } from "@manywidgets/core";
import { asNumber, renderChild, resolveModel, type RenderArgs } from "@manywidgets/core";

interface GridModel {
children: string[];
columns: number;
columns: number | string;
gap: string;
template_areas: string;
template_rows: string;
height: string;
}

function columnsTracks(columns: number | string): string {
if (typeof columns === "string") return columns;
return `repeat(${Math.max(1, columns || 1)}, minmax(0, 1fr))`;
}

async function placementFor(
args: RenderArgs,
ref: string,
): Promise<{ colSpan: number; rowSpan: number; area: string }> {
const handle = await resolveModel(args.model, ref).catch(() => null);
return {
colSpan: Math.max(1, asNumber(handle?.get("col_span"), 1)),
rowSpan: Math.max(1, asNumber(handle?.get("row_span"), 1)),
area: (handle?.get("area") as string) || "",
};
}

async function render(args: RenderProps<GridModel>): Promise<() => void> {
Expand All @@ -16,9 +36,12 @@ async function render(args: RenderProps<GridModel>): Promise<() => void> {
let cleanups: Array<() => void> = [];

function applyStyle(): void {
const columns = Math.max(1, Number(model.get("columns")) || 1);
const areas = model.get("template_areas") || "";
container.style.display = "grid";
container.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
container.style.gridTemplateColumns = columnsTracks(model.get("columns"));
container.style.gridTemplateAreas = areas || "";
container.style.gridTemplateRows = model.get("template_rows") || "";
container.style.height = model.get("height") || "";
container.style.gap = model.get("gap") || "8px";
}

Expand All @@ -30,6 +53,13 @@ async function render(args: RenderProps<GridModel>): Promise<() => void> {
for (const ref of refs) {
const cell = document.createElement("div");
cell.className = "manywidgets-grid__cell";
const { colSpan, rowSpan, area } = await placementFor(args as unknown as RenderArgs, ref);
if (area) {
cell.style.gridArea = area;
} else {
if (colSpan > 1) cell.style.gridColumn = `span ${colSpan}`;
if (rowSpan > 1) cell.style.gridRow = `span ${rowSpan}`;
}
container.appendChild(cell);
cleanups.push(await renderChild(args as unknown as RenderArgs, ref, cell));
}
Expand All @@ -40,6 +70,9 @@ async function render(args: RenderProps<GridModel>): Promise<() => void> {

model.on("change:columns", applyStyle);
model.on("change:gap", applyStyle);
model.on("change:template_areas", applyStyle);
model.on("change:template_rows", applyStyle);
model.on("change:height", applyStyle);
model.on("change:children", () => {
void build();
});
Expand Down
6 changes: 6 additions & 0 deletions src/manywidgets/grid/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
}

.manywidgets-grid__cell {
display: flex;
min-width: 0;
}

.manywidgets-grid__cell > * {
flex: 1 1 auto;
min-width: 0;
}
90 changes: 88 additions & 2 deletions src/manywidgets/grid/tests/grid.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,66 @@
import { describe, expect, it } from "vitest";
import { fakeHost, fakeModel, mountEl } from "@manywidgets/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { fakeHost, fakeModel, installHostRegistry, mountEl } from "@manywidgets/test-utils";
import widget from "../src/index";

describe("Grid", () => {
afterEach(() => {
(globalThis as Record<string, unknown>).__myst_anywidget_hosts &&
delete (globalThis as Record<string, unknown>).__myst_anywidget_hosts;
});

it("applies col_span/row_span from a GridItem child to its cell", async () => {
const host = fakeHost();
const el = mountEl();
const item = fakeModel(
{ widget_id: "item_1", col_span: 2, row_span: 2 },
{ model_id: "item_1" },
);
const uninstall = installHostRegistry([item]);
const model = fakeModel({ children: ["IPY_MODEL_item_1"], columns: 3, gap: "8px" });
await widget.render({ model, el, host } as never);

const cell = el.querySelector<HTMLElement>(".manywidgets-grid__cell")!;
expect(cell.style.gridColumn).toBe("span 2");
expect(cell.style.gridRow).toBe("span 2");
uninstall();
});

it("places a GridItem child by named area, ignoring spans", async () => {
const host = fakeHost();
const el = mountEl();
const item = fakeModel(
{ widget_id: "item_1", col_span: 2, area: "main" },
{ model_id: "item_1" },
);
const uninstall = installHostRegistry([item]);
const model = fakeModel({
children: ["IPY_MODEL_item_1"],
columns: 2,
gap: "8px",
template_areas: '"header header" "sidebar main"',
});
await widget.render({ model, el, host } as never);

expect(el.querySelector<HTMLElement>(".manywidgets-grid")!.style.gridTemplateAreas).toBe(
'"header header" "sidebar main"',
);
const cell = el.querySelector<HTMLElement>(".manywidgets-grid__cell")!;
expect(cell.style.gridArea).toBe("main");
expect(cell.style.gridColumn).toBe("");
uninstall();
});

it("defaults a bare (non-GridItem) child to a 1x1 cell", async () => {
const host = fakeHost();
const el = mountEl();
const model = fakeModel({ children: ["IPY_MODEL_a"], columns: 2, gap: "8px" });
await widget.render({ model, el, host } as never);

const cell = el.querySelector<HTMLElement>(".manywidgets-grid__cell")!;
expect(cell.style.gridColumn).toBe("");
expect(cell.style.gridRow).toBe("");
});

it("mounts children in a CSS grid with the given column count", async () => {
const host = fakeHost();
const el = mountEl();
Expand Down Expand Up @@ -31,4 +89,32 @@ describe("Grid", () => {
"repeat(4, minmax(0, 1fr))",
);
});

it("passes a string columns value straight through as track sizes", async () => {
const host = fakeHost();
const el = mountEl();
const model = fakeModel({ children: [], columns: "200px 1fr", gap: "8px" });
await widget.render({ model, el, host } as never);

expect(el.querySelector<HTMLElement>(".manywidgets-grid")!.style.gridTemplateColumns).toBe(
"200px 1fr",
);
});

it("applies template_rows and height so a row can grow beyond its content", async () => {
const host = fakeHost();
const el = mountEl();
const model = fakeModel({
children: [],
columns: 2,
gap: "8px",
template_rows: "auto 1fr auto",
height: "500px",
});
await widget.render({ model, el, host } as never);

const container = el.querySelector<HTMLElement>(".manywidgets-grid")!;
expect(container.style.gridTemplateRows).toBe("auto 1fr auto");
expect(container.style.height).toBe("500px");
});
});
12 changes: 12 additions & 0 deletions src/manywidgets/grid/tests/test_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ def test_marker_and_sync():

def test_auto_widget_id_prefix():
assert Grid().widget_id.startswith("grid_")


def test_template_rows_and_height_kwargs():
g = Grid(Stat(), template_rows="auto 1fr auto", height="500px")
assert g.template_rows == "auto 1fr auto"
assert g.height == "500px"


def test_template_rows_and_height_default_empty():
g = Grid()
assert g.template_rows == ""
assert g.height == ""
36 changes: 35 additions & 1 deletion src/manywidgets/grid/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
Children flow left-to-right, top-to-bottom into ``columns`` equal columns. Like
:class:`~manywidgets.Row`/:class:`~manywidgets.Column`, children stay interactive
and linked, live and in static export.

Wrap a child in :class:`~manywidgets.GridItem` to control how many
columns/rows its cell spans, or to place it into a named
``template_areas`` region. Bare (unwrapped) children default to 1x1 cells,
exactly as before.
"""

from __future__ import annotations
Expand All @@ -22,8 +27,37 @@ class Grid(BaseWidget):
children = traitlets.List(
trait=traitlets.Instance(Widget), help="Child widgets, in row-major order."
).tag(sync=True, **widget_serialization)
columns = traitlets.Int(2, help="Number of equal-width columns.").tag(sync=True)
columns = traitlets.Union(
[traitlets.Int(), traitlets.Unicode()],
default_value=2,
help=(
"Either an int (N equal-width columns) or a raw CSS "
"grid-template-columns track string (e.g. \"200px 1fr\") for "
"asymmetric columns, e.g. a narrower sidebar beside a wider main."
),
).tag(sync=True)
gap = traitlets.Unicode("8px", help="CSS gap between cells.").tag(sync=True)
template_areas = traitlets.Unicode(
"",
help=(
"Optional CSS grid-template-areas string (e.g. "
'\'"header header" "sidebar main"\'). When set, GridItem children '
"place by their area name instead of row-major flow."
),
).tag(sync=True)
template_rows = traitlets.Unicode(
"",
help=(
"Optional CSS grid-template-rows track string (e.g. "
'"auto 1fr auto") to size rows unequally, e.g. a thin header/footer '
"with a tall middle row. Rows default to auto-sizing (fit content) "
"when unset. \"fr\" tracks need Grid's own height set for there to "
"be extra space to distribute."
),
).tag(sync=True)
height = traitlets.Unicode(
"", help="Optional CSS height (e.g. \"600px\", \"100vh\")."
).tag(sync=True)

_myst_child_traits = traitlets.List(["children"]).tag(sync=True)

Expand Down
3 changes: 3 additions & 0 deletions src/manywidgets/grid_item/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .widget import GridItem

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

Wrap a `Grid` child to control how many columns/rows its cell spans, or to
place it into a named `template_areas` region. A bare (unwrapped) child stays
a plain 1x1 cell, exactly like before; `GridItem` is opt-in.

## Import

```python
from manywidgets import Grid, GridItem
```

## Example

One cell spanning 2 columns and 2 rows, in a 4-column grid. Every cell here
is a plain bordered `Stat` card labeled with its own span, so the grid lines
are visible directly from the cell borders:

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

Grid(
GridItem(Stat(label="col_span=2, row_span=2"), col_span=2, row_span=2),
Stat(label="1x1"),
Stat(label="1x1"),
Stat(label="1x1"),
Stat(label="1x1"),
columns=4, gap="12px",
)
```

Named areas (set `template_areas` on the `Grid`, and `area=` on each item)
place a cell by name instead of row-major flow. Combine with `template_rows`
and an explicit `height` on the `Grid` to give a middle row (here `1fr`) more
space than the thin header/footer bars; see
[Grid's asymmetric-columns section](./grid.md) for why `height` matters:

```{code-cell} python
Grid(
GridItem(Stat(label="header"), area="header"),
GridItem(Stat(label="sidebar"), area="sidebar"),
GridItem(Stat(label="main"), area="main"),
GridItem(Stat(label="footer"), area="footer"),
columns="200px 1fr", gap="12px",
template_areas='"header header" "sidebar main" "footer footer"',
template_rows="auto 1fr auto",
height="300px",
)
```

## API

{api-table}

`area` takes precedence over `col_span`/`row_span` when the parent `Grid` has
`template_areas` set.
Loading
Loading