From 2ad9847e7b66db7b28621b2b6a852bd07f1d8252 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:50:10 +0200 Subject: [PATCH 1/7] feat(grid-item): add GridItem widget for per-cell grid placement Transparent wrapper that carries col_span/row_span/area placement metadata for a child mounted inside a Grid cell. 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. --- src/manywidgets/__init__.py | 2 + src/manywidgets/grid_item/__init__.py | 3 ++ src/manywidgets/grid_item/src/index.ts | 19 ++++++++ src/manywidgets/grid_item/style.css | 10 ++++ .../grid_item/tests/grid_item.test.ts | 25 ++++++++++ .../grid_item/tests/test_grid_item.py | 40 ++++++++++++++++ src/manywidgets/grid_item/widget.py | 47 +++++++++++++++++++ 7 files changed, 146 insertions(+) create mode 100644 src/manywidgets/grid_item/__init__.py create mode 100644 src/manywidgets/grid_item/src/index.ts create mode 100644 src/manywidgets/grid_item/style.css create mode 100644 src/manywidgets/grid_item/tests/grid_item.test.ts create mode 100644 src/manywidgets/grid_item/tests/test_grid_item.py create mode 100644 src/manywidgets/grid_item/widget.py 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_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/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) From 87852b37f51be9b6de60c22a58e652a711d18c38 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:51:22 +0200 Subject: [PATCH 2/7] feat(grid): place GridItem children by col_span/row_span or named area Grid resolves each child's placement before mounting it into its cell: a GridItem wrapper's col_span/row_span apply as grid-column/grid-row spans, or its area places the cell into a new template_areas region. Bare (unwrapped) children keep today's 1x1 row-major behaviour. --- src/manywidgets/grid/src/index.ts | 25 +++++++++- src/manywidgets/grid/tests/grid.test.ts | 62 ++++++++++++++++++++++++- src/manywidgets/grid/widget.py | 13 ++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/manywidgets/grid/src/index.ts b/src/manywidgets/grid/src/index.ts index 4171744..5cc86c6 100644 --- a/src/manywidgets/grid/src/index.ts +++ b/src/manywidgets/grid/src/index.ts @@ -1,10 +1,23 @@ 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; gap: string; + template_areas: string; +} + +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> { @@ -17,8 +30,10 @@ async function render(args: RenderProps): Promise<() => 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.gridTemplateAreas = areas || ""; container.style.gap = model.get("gap") || "8px"; } @@ -30,6 +45,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 +62,7 @@ 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:children", () => { void build(); }); diff --git a/src/manywidgets/grid/tests/grid.test.ts b/src/manywidgets/grid/tests/grid.test.ts index 9900673..14ef9be 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(); diff --git a/src/manywidgets/grid/widget.py b/src/manywidgets/grid/widget.py index 1822bcb..dbcd230 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 @@ -24,6 +29,14 @@ class Grid(BaseWidget): ).tag(sync=True, **widget_serialization) columns = traitlets.Int(2, help="Number of equal-width columns.").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) _myst_child_traits = traitlets.List(["children"]).tag(sync=True) From e17be15572cf826da7f65021bdc175a3de8b3479 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:51:39 +0200 Subject: [PATCH 3/7] fix(grid): stretch cell content to fill its grid cell A cell's child previously kept its own intrinsic width/height (e.g. Stat's inline-block sizing), so a spanned or full-width cell left the visible card sitting at its natural narrow size instead of filling the wider track. Cells are now flex containers with their child set to flex: 1 1 auto, so any mounted widget stretches to fill its cell regardless of its own display value. --- src/manywidgets/grid/style.css | 6 ++++++ 1 file changed, 6 insertions(+) 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; } From b44d0c5bbce2a13895cbe1d0f3545aee3fd67eaf Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:52:18 +0200 Subject: [PATCH 4/7] feat(grid): support asymmetric columns via a raw CSS track string columns now accepts either an int (today's N-equal-columns behaviour) or a raw grid-template-columns track string (e.g. "200px 1fr"), for a sidebar narrower than the main content instead of a forced 50/50 split. --- src/manywidgets/grid/src/index.ts | 10 +++++++--- src/manywidgets/grid/tests/grid.test.ts | 11 +++++++++++ src/manywidgets/grid/widget.py | 10 +++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/manywidgets/grid/src/index.ts b/src/manywidgets/grid/src/index.ts index 5cc86c6..80d017d 100644 --- a/src/manywidgets/grid/src/index.ts +++ b/src/manywidgets/grid/src/index.ts @@ -3,11 +3,16 @@ import { asNumber, renderChild, resolveModel, type RenderArgs } from "@manywidge interface GridModel { children: string[]; - columns: number; + columns: number | string; gap: string; template_areas: 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, @@ -29,10 +34,9 @@ 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.gap = model.get("gap") || "8px"; } diff --git a/src/manywidgets/grid/tests/grid.test.ts b/src/manywidgets/grid/tests/grid.test.ts index 14ef9be..6f37152 100644 --- a/src/manywidgets/grid/tests/grid.test.ts +++ b/src/manywidgets/grid/tests/grid.test.ts @@ -89,4 +89,15 @@ 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", + ); + }); }); diff --git a/src/manywidgets/grid/widget.py b/src/manywidgets/grid/widget.py index dbcd230..abc2b74 100644 --- a/src/manywidgets/grid/widget.py +++ b/src/manywidgets/grid/widget.py @@ -27,7 +27,15 @@ 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( "", From a7ee2f27a6014cfc6b2a82ec6c3289ecb8f8553f Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:53:08 +0200 Subject: [PATCH 5/7] feat(grid): add template_rows and height for proportional row sizing template_rows is a raw grid-template-rows track string (e.g. "auto 1fr auto") for a thin header/footer with a tall middle row; height gives the Grid itself a defined size so fr tracks have space to distribute, matching how CSS Grid's own 1fr row sizing requires a defined container height to mean anything. --- src/manywidgets/grid/src/index.ts | 6 ++++++ src/manywidgets/grid/tests/grid.test.ts | 17 +++++++++++++++++ src/manywidgets/grid/tests/test_grid.py | 12 ++++++++++++ src/manywidgets/grid/widget.py | 13 +++++++++++++ 4 files changed, 48 insertions(+) diff --git a/src/manywidgets/grid/src/index.ts b/src/manywidgets/grid/src/index.ts index 80d017d..5f03f8b 100644 --- a/src/manywidgets/grid/src/index.ts +++ b/src/manywidgets/grid/src/index.ts @@ -6,6 +6,8 @@ interface GridModel { columns: number | string; gap: string; template_areas: string; + template_rows: string; + height: string; } function columnsTracks(columns: number | string): string { @@ -38,6 +40,8 @@ async function render(args: RenderProps): Promise<() => void> { container.style.display = "grid"; 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"; } @@ -67,6 +71,8 @@ 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/tests/grid.test.ts b/src/manywidgets/grid/tests/grid.test.ts index 6f37152..0f8cfa0 100644 --- a/src/manywidgets/grid/tests/grid.test.ts +++ b/src/manywidgets/grid/tests/grid.test.ts @@ -100,4 +100,21 @@ describe("Grid", () => { "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 abc2b74..b0de0e4 100644 --- a/src/manywidgets/grid/widget.py +++ b/src/manywidgets/grid/widget.py @@ -45,6 +45,19 @@ class Grid(BaseWidget): "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) From ae26093785377e63d98a92e6169f49297f28c869 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 12:53:19 +0200 Subject: [PATCH 6/7] docs(grid): document spans, named areas, asymmetric columns and row sizing Adds GridItem's page under the Layout nav section, and documents col_span/row_span, template_areas, the columns track-string form, and template_rows/height on Grid and GridItem's own doc pages. --- docs/myst.yml | 1 + src/manywidgets/grid/doc.md | 30 +++++++++++++++++ src/manywidgets/grid_item/doc.md | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/manywidgets/grid_item/doc.md 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/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_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. From 277183a49d64a1347a4687137fcbfac713be145e Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Tue, 11 Aug 2026 13:06:24 +0200 Subject: [PATCH 7/7] fix(skill): include GridItem in the Layout containers group GridItem wasn't listed in build_skill_reference.py's GROUPS, so it fell into the unsectioned "Other" catch-all and got skipped entirely from the generated per-widget API reference. Regenerated widgets-api.md to match (CI's "Skill reference is up to date" check diffs this file against a fresh regen). --- scripts/build_skill_reference.py | 2 +- .../skill/references/widgets-api.md | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) 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/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`