From 8368df1bdb4fab301eec700082dea56616bda97e Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:32:48 +0000 Subject: [PATCH] feat: Add rowHeight prop to Board for customizable row height Adds an opt-in `rowHeight` prop to Board that overrides the default per-density grid row height (96px comfortable / 76px compact). This enables finer-grained vertical sizing of board items (e.g. 32px increments) as requested in AWSUI-61568. The value flows Board -> InternalBoard -> Grid, where it drives both the pixel math (getHeight) and the rendered grid-auto-rows via a CSS custom property, keeping layout and rendering in sync. Invalid values (non-positive/non-finite) fall back to the density default, so existing boards are unaffected. Includes grid + board unit tests and a dev page. --- pages/board/row-height.page.tsx | 88 +++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 11 +++ src/board/__tests__/board-row-height.test.tsx | 49 +++++++++++ src/board/interfaces.ts | 10 +++ src/board/internal.tsx | 2 + src/internal/grid/__tests__/grid.test.tsx | 25 ++++++ src/internal/grid/grid.tsx | 25 +++++- src/internal/grid/interfaces.ts | 6 ++ src/internal/grid/styles.scss | 7 +- 9 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 pages/board/row-height.page.tsx create mode 100644 src/board/__tests__/board-row-height.test.tsx diff --git a/pages/board/row-height.page.tsx b/pages/board/row-height.page.tsx new file mode 100644 index 00000000..d50de8a6 --- /dev/null +++ b/pages/board/row-height.page.tsx @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; + +import Box from "@cloudscape-design/components/box"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { Board, BoardItem, BoardProps } from "../../lib/components"; +import { ScreenshotArea } from "../screenshot-area"; +import { boardI18nStrings, boardItemI18nStrings } from "../shared/i18n"; +import { ItemData } from "../shared/interfaces"; + +const rowHeightOptions: Array = [undefined, 32, 48, 64, 96]; + +const initialItems: ReadonlyArray> = [ + { + id: "one", + columnSpan: 2, + rowSpan: 2, + definition: {}, + data: { title: "Content-light widget", description: "", content: "Two rows tall" }, + }, + { + id: "two", + columnSpan: 2, + rowSpan: 4, + definition: {}, + data: { title: "Taller widget", description: "", content: "Four rows tall" }, + }, + { + id: "three", + columnSpan: 2, + rowSpan: 3, + definition: {}, + data: { title: "Medium widget", description: "", content: "Three rows tall" }, + }, +]; + +export default function BoardRowHeightPage() { + const [rowHeight, setRowHeight] = useState(undefined); + const [items, setItems] = useState(initialItems); + + return ( + + + +
+ Board row height (AWSUI-61568) +
+ +
+ + {rowHeightOptions.map((option) => ( + + ))} + +
+ + + Current rowHeight: {rowHeight === undefined ? "default" : rowHeight} + + + ( + {item.data.title}} i18nStrings={boardItemI18nStrings}> + {item.data.content} + + )} + i18nStrings={boardI18nStrings} + onItemsChange={(event) => setItems(event.detail.items)} + empty="No items" + /> +
+
+
+ ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index faf98e1d..101b076f 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -534,6 +534,17 @@ The function takes the item and its associated actions (BoardProps.ItemActions) "optional": false, "type": "(item: BoardProps.Item, actions: BoardProps.ItemActions) => JSX.Element", }, + { + "description": "Overrides the height, in pixels, of a single board row. Board items span a whole number of +rows, so this value controls the granularity of their vertical sizing (for example, a smaller +row height lets content-light items occupy less vertical space and increases sizing precision). + +When not set, the density-based default is used (96px in comfortable mode, 76px in compact mode), +which preserves the existing behavior. Values that are not positive finite numbers are ignored.", + "name": "rowHeight", + "optional": true, + "type": "number", + }, ], "regions": [ { diff --git a/src/board/__tests__/board-row-height.test.tsx b/src/board/__tests__/board-row-height.test.tsx new file mode 100644 index 00000000..6269e66c --- /dev/null +++ b/src/board/__tests__/board-row-height.test.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeAll, expect, test } from "vitest"; + +import Board from "../../../lib/components/board"; +import { defaultProps } from "./utils"; + +import gridStyles from "../../../lib/components/internal/grid/styles.css.js"; + +const ROW_HEIGHT_CSS_PROPERTY = "--awsui-board-row-height"; + +beforeAll(() => { + // jsdom does not support this function + document.elementFromPoint = () => null; +}); + +afterEach(() => { + cleanup(); +}); + +function getGridRoot(container: HTMLElement) { + return container.querySelector(`.${gridStyles.grid}`)!; +} + +test("uses the default row height when rowHeight is not provided", () => { + const { container } = render(); + + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("96px"); +}); + +test("forwards a custom rowHeight to the underlying grid", () => { + const { container } = render(); + + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("32px"); +}); + +test("ignores a non-positive rowHeight and keeps the default", () => { + const { container } = render(); + + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("96px"); +}); + +test("does not leak rowHeight to the root element as a data attribute", () => { + const { container } = render(); + + // The root board element must not receive a `rowHeight` attribute. + expect(container.firstElementChild?.hasAttribute("rowheight")).toBe(false); +}); diff --git a/src/board/interfaces.ts b/src/board/interfaces.ts index dbdeaa35..335ad2ec 100644 --- a/src/board/interfaces.ts +++ b/src/board/interfaces.ts @@ -72,6 +72,16 @@ export interface BoardProps { * When items are loading the slot can be used to render the loading indicator. */ empty: ReactNode; + + /** + * Overrides the height, in pixels, of a single board row. Board items span a whole number of + * rows, so this value controls the granularity of their vertical sizing (for example, a smaller + * row height lets content-light items occupy less vertical space and increases sizing precision). + * + * When not set, the density-based default is used (96px in comfortable mode, 76px in compact mode), + * which preserves the existing behavior. Values that are not positive finite numbers are ignored. + */ + rowHeight?: number; } export namespace BoardProps { diff --git a/src/board/internal.tsx b/src/board/internal.tsx index bd3e68da..d507132f 100644 --- a/src/board/internal.tsx +++ b/src/board/internal.tsx @@ -41,6 +41,7 @@ export function InternalBoard({ onItemsChange, empty, i18nStrings, + rowHeight, __internalRootRef, ...rest }: BoardProps & InternalBaseComponentProps) { @@ -237,6 +238,7 @@ export function InternalBoard({ isRtl={isRtl} columns={itemsLayout.columns} layout={[...placeholdersLayout.items, ...itemsLayout.items]} + rowHeight={rowHeight} > {(gridContext) => { const layoutShift = transition?.layoutShift ?? removeTransition?.layoutShift; diff --git a/src/internal/grid/__tests__/grid.test.tsx b/src/internal/grid/__tests__/grid.test.tsx index f25ea38b..c4df454b 100644 --- a/src/internal/grid/__tests__/grid.test.tsx +++ b/src/internal/grid/__tests__/grid.test.tsx @@ -21,6 +21,12 @@ const defaultProps: GridProps = { ), }; +const ROW_HEIGHT_CSS_PROPERTY = "--awsui-board-row-height"; + +function getGridRoot(container: HTMLElement) { + return container.querySelector(`.${gridStyles.grid}`)!; +} + test("renders children content", async () => { const result = render(); @@ -52,3 +58,22 @@ test("assigns styles on individual elements", () => { "grid-column-end": "span 2", }); }); + +test("uses the default row height when rowHeight is not provided", () => { + const { container } = render(); + + // Defaults to the comfortable-mode row height (96px). + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("96px"); +}); + +test("applies a custom row height when rowHeight is provided", () => { + const { container } = render(); + + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("32px"); +}); + +test.each([0, -10, NaN, Infinity])("ignores invalid rowHeight value %s and falls back to default", (rowHeight) => { + const { container } = render(); + + expect(getGridRoot(container).style.getPropertyValue(ROW_HEIGHT_CSS_PROPERTY)).toBe("96px"); +}); diff --git a/src/internal/grid/grid.tsx b/src/internal/grid/grid.tsx index 9aa8b0cc..ba57894a 100644 --- a/src/internal/grid/grid.tsx +++ b/src/internal/grid/grid.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Children, useRef } from "react"; +import { Children, CSSProperties, useRef } from "react"; import clsx from "clsx"; import { useContainerQuery } from "@cloudscape-design/component-toolkit"; @@ -19,12 +19,21 @@ const GRID_GAP = { comfortable: 20, compact: 16 }; /* Matches grid-auto-rows in CSS. */ const ROWSPAN_HEIGHT = { comfortable: 96, compact: 76 }; -export default function Grid({ layout, children: render, columns, isRtl }: GridProps) { +/* Custom property consumed by grid-auto-rows in CSS to allow overriding the row height. */ +const ROW_HEIGHT_CSS_PROPERTY = "--awsui-board-row-height"; + +function isValidRowHeight(rowHeight?: number): rowHeight is number { + return typeof rowHeight === "number" && isFinite(rowHeight) && rowHeight > 0; +} + +export default function Grid({ layout, children: render, columns, isRtl, rowHeight }: GridProps) { const gridRef = useRef(null); const [gridWidth, containerQueryRef] = useContainerQuery((entry) => entry.contentBoxWidth, []); const densityMode = useDensityMode(gridRef); const gridGap = GRID_GAP[densityMode]; - const rowspanHeight = ROWSPAN_HEIGHT[densityMode]; + // An explicit row height, when provided, overrides the density-based default so that + // consumers can achieve finer-grained vertical sizing. + const rowspanHeight = isValidRowHeight(rowHeight) ? rowHeight : ROWSPAN_HEIGHT[densityMode]; // The below getters translate relative grid units into size/offset values in pixels. const getWidth = (colspan: number) => { @@ -44,9 +53,17 @@ export default function Grid({ layout, children: render, columns, isRtl }: GridP const zipped = zipTwoArrays(layout, Children.toArray(children)); + // Keep the rendered CSS row height in sync with the pixel math above. The custom property is + // always set to the resolved value so grid-auto-rows matches getHeight() exactly. + const gridStyle = { [ROW_HEIGHT_CSS_PROPERTY]: `${rowspanHeight}px` } as CSSProperties; + const ref = useMergeRefs(gridRef, containerQueryRef); return ( -
+
{zipped.map(([item, children]) => ( {children} diff --git a/src/internal/grid/interfaces.ts b/src/internal/grid/interfaces.ts index c877746d..92047a09 100644 --- a/src/internal/grid/interfaces.ts +++ b/src/internal/grid/interfaces.ts @@ -10,6 +10,12 @@ export interface GridProps { columns: number; children?: (context: GridContext) => ReactNode; isRtl?: () => boolean; + /** + * Overrides the default height (in pixels) of a single grid row. When not set, the + * density-based default is used (96px in comfortable mode, 76px in compact mode). + * Values that are not positive finite numbers are ignored and the default is used. + */ + rowHeight?: number; } export interface GridContext { diff --git a/src/internal/grid/styles.scss b/src/internal/grid/styles.scss index b6a4ef41..e8c407e4 100644 --- a/src/internal/grid/styles.scss +++ b/src/internal/grid/styles.scss @@ -10,12 +10,13 @@ $grid-col-width: minmax(0, 1fr); display: grid; /* Matches GRID_GAP constant used for calculations. */ gap: 20px; - /* Matches ROWSPAN_HEIGHT constant used for calculations. */ - grid-auto-rows: 96px; + /* Matches ROWSPAN_HEIGHT constant used for calculations. The custom property is set by the + Grid component and defaults to the comfortable-mode row height when absent. */ + grid-auto-rows: var(--awsui-board-row-height, 96px); &-compact { gap: 16px; - grid-auto-rows: 76px; + grid-auto-rows: var(--awsui-board-row-height, 76px); } }