diff --git a/docs/myst.yml b/docs/myst.yml index ee69e8b..a9f4323 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -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: diff --git a/scripts/build_skill_reference.py b/scripts/build_skill_reference.py index d52fcf7..dfb346a 100644 --- a/scripts/build_skill_reference.py +++ b/scripts/build_skill_reference.py @@ -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"]), ] diff --git a/src/manywidgets/__init__.py b/src/manywidgets/__init__.py index f5effe8..44ef698 100644 --- a/src/manywidgets/__init__.py +++ b/src/manywidgets/__init__.py @@ -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 @@ -39,6 +40,7 @@ "Dropdown", "Fullscreen", "Grid", + "GridItem", "Legend", "NumberDisplay", "NumberInput", diff --git a/src/manywidgets/grid/doc.md b/src/manywidgets/grid/doc.md index 1a2a95f..71fbeda 100644 --- a/src/manywidgets/grid/doc.md +++ b/src/manywidgets/grid/doc.md @@ -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} diff --git a/src/manywidgets/grid/src/index.ts b/src/manywidgets/grid/src/index.ts index 4171744..5f03f8b 100644 --- a/src/manywidgets/grid/src/index.ts +++ b/src/manywidgets/grid/src/index.ts @@ -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): Promise<() => void> { @@ -16,9 +36,12 @@ async function render(args: RenderProps): 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"; } @@ -30,6 +53,13 @@ async function render(args: RenderProps): 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)); } @@ -40,6 +70,9 @@ async function render(args: RenderProps): 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(); }); diff --git a/src/manywidgets/grid/style.css b/src/manywidgets/grid/style.css index c458b85..286f325 100644 --- a/src/manywidgets/grid/style.css +++ b/src/manywidgets/grid/style.css @@ -3,5 +3,11 @@ } .manywidgets-grid__cell { + display: flex; + min-width: 0; +} + +.manywidgets-grid__cell > * { + flex: 1 1 auto; min-width: 0; } diff --git a/src/manywidgets/grid/tests/grid.test.ts b/src/manywidgets/grid/tests/grid.test.ts index 9900673..0f8cfa0 100644 --- a/src/manywidgets/grid/tests/grid.test.ts +++ b/src/manywidgets/grid/tests/grid.test.ts @@ -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).__myst_anywidget_hosts && + delete (globalThis as Record).__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(".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(".manywidgets-grid")!.style.gridTemplateAreas).toBe( + '"header header" "sidebar main"', + ); + const cell = el.querySelector(".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(".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(); @@ -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(".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(".manywidgets-grid")!; + expect(container.style.gridTemplateRows).toBe("auto 1fr auto"); + expect(container.style.height).toBe("500px"); + }); }); diff --git a/src/manywidgets/grid/tests/test_grid.py b/src/manywidgets/grid/tests/test_grid.py index 8a5486b..2c5339f 100644 --- a/src/manywidgets/grid/tests/test_grid.py +++ b/src/manywidgets/grid/tests/test_grid.py @@ -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 == "" diff --git a/src/manywidgets/grid/widget.py b/src/manywidgets/grid/widget.py index 1822bcb..b0de0e4 100644 --- a/src/manywidgets/grid/widget.py +++ b/src/manywidgets/grid/widget.py @@ -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 @@ -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) diff --git a/src/manywidgets/grid_item/__init__.py b/src/manywidgets/grid_item/__init__.py new file mode 100644 index 0000000..51047c9 --- /dev/null +++ b/src/manywidgets/grid_item/__init__.py @@ -0,0 +1,3 @@ +from .widget import GridItem + +__all__ = ["GridItem"] diff --git a/src/manywidgets/grid_item/doc.md b/src/manywidgets/grid_item/doc.md new file mode 100644 index 0000000..9546cc2 --- /dev/null +++ b/src/manywidgets/grid_item/doc.md @@ -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. diff --git a/src/manywidgets/grid_item/src/index.ts b/src/manywidgets/grid_item/src/index.ts new file mode 100644 index 0000000..3af7427 --- /dev/null +++ b/src/manywidgets/grid_item/src/index.ts @@ -0,0 +1,19 @@ +import type { RenderProps } from "@anywidget/types"; +import { renderChild, type RenderArgs } from "@manywidgets/core"; + +interface GridItemModel { + child: unknown; +} + +async function render(args: RenderProps): Promise<() => void> { + const { model, el } = args; + el.className = "manywidgets-grid-item"; + + const dispose = model.get("child") + ? await renderChild(args as unknown as RenderArgs, model.get("child") as string, el) + : (): void => {}; + + return dispose; +} + +export default { render }; diff --git a/src/manywidgets/grid_item/style.css b/src/manywidgets/grid_item/style.css new file mode 100644 index 0000000..0f122a2 --- /dev/null +++ b/src/manywidgets/grid_item/style.css @@ -0,0 +1,10 @@ +.manywidgets-grid-item { + display: flex; + min-width: 0; + min-height: 0; +} + +.manywidgets-grid-item > * { + flex: 1 1 auto; + min-width: 0; +} diff --git a/src/manywidgets/grid_item/tests/grid_item.test.ts b/src/manywidgets/grid_item/tests/grid_item.test.ts new file mode 100644 index 0000000..b7799b2 --- /dev/null +++ b/src/manywidgets/grid_item/tests/grid_item.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { fakeHost, fakeModel, mountEl } from "@manywidgets/test-utils"; +import widget from "../src/index"; + +describe("GridItem", () => { + it("mounts its child with no extra wrapper markup", async () => { + const host = fakeHost(); + const el = mountEl(); + const model = fakeModel({ child: "IPY_MODEL_c", col_span: 2, row_span: 2, area: "" }); + await widget.render({ model, el, host } as never); + + expect(el.className).toBe("manywidgets-grid-item"); + expect(host.mounted).toEqual(["IPY_MODEL_c"]); + expect(el.getAttribute("data-child")).toBe("IPY_MODEL_c"); + }); + + it("no-ops when there is no child", async () => { + const host = fakeHost(); + const el = mountEl(); + const model = fakeModel({ child: null, col_span: 1, row_span: 1, area: "" }); + await widget.render({ model, el, host } as never); + + expect(host.mounted).toEqual([]); + }); +}); diff --git a/src/manywidgets/grid_item/tests/test_grid_item.py b/src/manywidgets/grid_item/tests/test_grid_item.py new file mode 100644 index 0000000..92532d2 --- /dev/null +++ b/src/manywidgets/grid_item/tests/test_grid_item.py @@ -0,0 +1,40 @@ +from manywidgets import Grid, GridItem, Stat + + +def test_positional_child_and_defaults(): + chart = Stat() + item = GridItem(chart) + assert item.child is chart + assert item.col_span == 1 + assert item.row_span == 1 + assert item.area == "" + + +def test_span_kwargs(): + item = GridItem(Stat(), col_span=2, row_span=3) + assert item.col_span == 2 + assert item.row_span == 3 + + +def test_area_kwarg(): + item = GridItem(Stat(), area="header") + assert item.area == "header" + + +def test_marker_and_sync(): + item = GridItem() + assert item._myst_child_traits == ["child"] + for name in ("child", "col_span", "row_span", "area", "widget_id"): + assert item.trait_metadata(name, "sync") is True + assert item.trait_metadata("child", "to_json") is not None + + +def test_auto_widget_id_prefix(): + assert GridItem().widget_id.startswith("griditem_") + + +def test_composes_inside_grid(): + g = Grid(GridItem(Stat(), col_span=2), Stat(), columns=3) + assert len(g.children) == 2 + assert isinstance(g.children[0], GridItem) + assert g.children[0].col_span == 2 diff --git a/src/manywidgets/grid_item/widget.py b/src/manywidgets/grid_item/widget.py new file mode 100644 index 0000000..bd8167c --- /dev/null +++ b/src/manywidgets/grid_item/widget.py @@ -0,0 +1,47 @@ +"""GridItem: placement metadata for a widget inside a :class:`~manywidgets.Grid`. + +A transparent wrapper: it renders its ``child`` with no visible markup of its +own, so wrapping a widget never changes how it looks, only where/how big its +cell is when the wrapper sits directly inside a ``Grid``. Children not wrapped +in a ``GridItem`` default to a single 1x1 cell, exactly like today. +""" + +from __future__ import annotations + +import traitlets +from ipywidgets import Widget, widget_serialization + +from .._base import BaseWidget, asset + + +class GridItem(BaseWidget): + """Wrap a widget with grid placement (span or named area). + + Pass the wrapped widget positionally:: + + GridItem(chart, col_span=2, row_span=2) + GridItem(header, area="header") + + ``area`` takes precedence over ``col_span``/``row_span`` when the parent + ``Grid`` has ``template_areas`` set; otherwise spans apply against the + grid's row-major auto-placement. + """ + + _esm = asset(__file__, "dist", "widget.js") + _css = asset(__file__, "style.css") + + child = traitlets.Instance( + Widget, allow_none=True, help="The wrapped widget." + ).tag(sync=True, **widget_serialization) + col_span = traitlets.Int(1, help="Number of grid columns this cell spans.").tag(sync=True) + row_span = traitlets.Int(1, help="Number of grid rows this cell spans.").tag(sync=True) + area = traitlets.Unicode( + "", help="Named grid-template-area; requires the parent Grid's template_areas." + ).tag(sync=True) + + _myst_child_traits = traitlets.List(["child"]).tag(sync=True) + + def __init__(self, child=None, **kwargs): + if child is not None: + kwargs.setdefault("child", child) + super().__init__(**kwargs) diff --git a/src/manywidgets/skill/references/widgets-api.md b/src/manywidgets/skill/references/widgets-api.md index 54421f6..cc71487 100644 --- a/src/manywidgets/skill/references/widgets-api.md +++ b/src/manywidgets/skill/references/widgets-api.md @@ -9,7 +9,7 @@ Display a widget by leaving it as the last expression in a notebook cell. - **Charts & displays:** `Chart`, `Stat`, `NumberDisplay`, `Text`, `Legend` - **Input controls:** `Slider`, `RangeSlider`, `Dropdown`, `Toggle`, `Button`, `NumberInput` -- **Layout containers:** `Row`, `Column`, `Grid`, `Fullscreen` +- **Layout containers:** `Row`, `Column`, `Grid`, `GridItem`, `Fullscreen` - **Linking:** `Binder` - **Lonboard interop:** `LayerToggle`, `LayerFilter`, `FilterBinder`, `MapFlyer`, `MapCompare` @@ -238,7 +238,7 @@ Also: `Column(child1, child2, ...)` — children passed positionally. Arrange child widgets in an N-column grid. ```python -Grid(children, columns=2, gap='8px') +Grid(children, columns=2, gap='8px', template_areas='', template_rows='', height='') ``` Also: `Grid(child1, child2, ...)` — children passed positionally. @@ -246,8 +246,27 @@ Also: `Grid(child1, child2, ...)` — children passed positionally. | Trait | Type | Default | Description | |---|---|---|---| | `children` | List | — | Child widgets, in row-major order. | -| `columns` | Int | `2` | Number of equal-width columns. | +| `columns` | Union | `2` | 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. | | `gap` | Unicode | `'8px'` | CSS gap between cells. | +| `template_areas` | Unicode | `''` | 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. | +| `template_rows` | Unicode | `''` | 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. | +| `height` | Unicode | `''` | Optional CSS height (e.g. "600px", "100vh"). | +| `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | + +### `GridItem` + +Wrap a widget with grid placement (span or named area). + +```python +GridItem(child, col_span=1, row_span=1, area='') +``` + +| Trait | Type | Default | Description | +|---|---|---|---| +| `child` | Instance | — | The wrapped widget. | +| `col_span` | Int | `1` | Number of grid columns this cell spans. | +| `row_span` | Int | `1` | Number of grid rows this cell spans. | +| `area` | Unicode | `''` | Named grid-template-area; requires the parent Grid's template_areas. | | `widget_id` | Unicode | `''` | Stable unique id used for cross-widget linking (auto-assigned). | ### `Fullscreen`