diff --git a/dev/dashboard-layout.html b/dev/dashboard-layout.html
new file mode 100644
index 00000000000..649cabb195b
--- /dev/null
+++ b/dev/dashboard-layout.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ Dashboard layout
+
+
+
+
+
+
+
+
+
+ Item 0
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+
+
+
diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts b/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts
new file mode 100644
index 00000000000..350c7115468
--- /dev/null
+++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (c) 2000 - 2024 Vaadin Ltd.
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ *
+ * See https://vaadin.com/commercial-license-and-service-terms for the full
+ * license.
+ */
+import type { Constructor } from '@open-wc/dedupe-mixin';
+
+/**
+ * A mixin to enable the dashboard layout functionality.
+ */
+export declare function DashboardLayoutMixin>(
+ base: T,
+): Constructor & T;
+
+export declare class DashboardLayoutMixinClass {}
diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js
new file mode 100644
index 00000000000..e4afeac34d2
--- /dev/null
+++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (c) 2000 - 2024 Vaadin Ltd.
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ *
+ * See https://vaadin.com/commercial-license-and-service-terms for the full
+ * license.
+ */
+import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
+
+/**
+ * A mixin to enable the dashboard layout functionality
+ *
+ * @polymerMixin
+ */
+export const DashboardLayoutMixin = (superClass) =>
+ class DashboardLayoutMixinClass extends superClass {
+ static get styles() {
+ return css`
+ :host {
+ display: grid;
+ /* Default min and max column widths */
+ --_vaadin-dashboard-default-col-min-width: 25rem;
+ --_vaadin-dashboard-default-col-max-width: 1fr;
+ /* Effective min and max column widths */
+ --_vaadin-dashboard-col-min-width: var(
+ --vaadin-dashboard-col-min-width,
+ var(--_vaadin-dashboard-default-col-min-width)
+ );
+ --_vaadin-dashboard-col-max-width: var(
+ --vaadin-dashboard-col-max-width,
+ var(--_vaadin-dashboard-default-col-max-width)
+ );
+
+ grid-template-columns: repeat(
+ auto-fill,
+ minmax(var(--_vaadin-dashboard-col-min-width), var(--_vaadin-dashboard-col-max-width))
+ );
+ }
+ `;
+ }
+ };
diff --git a/packages/dashboard/src/vaadin-dashboard-layout.d.ts b/packages/dashboard/src/vaadin-dashboard-layout.d.ts
new file mode 100644
index 00000000000..ab4d7425eba
--- /dev/null
+++ b/packages/dashboard/src/vaadin-dashboard-layout.d.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright (c) 2000 - 2024 Vaadin Ltd.
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ *
+ * See https://vaadin.com/commercial-license-and-service-terms for the full
+ * license.
+ */
+import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
+import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
+
+/**
+ * A responsive, grid-based dashboard layout component
+ */
+declare class DashboardLayout extends ElementMixin(ThemableMixin(HTMLElement)) {}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'vaadin-dashboard-layout': DashboardLayout;
+ }
+}
+
+export { DashboardLayout };
diff --git a/packages/dashboard/src/vaadin-dashboard-layout.js b/packages/dashboard/src/vaadin-dashboard-layout.js
new file mode 100644
index 00000000000..a4f799f6b51
--- /dev/null
+++ b/packages/dashboard/src/vaadin-dashboard-layout.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (c) 2000 - 2024 Vaadin Ltd.
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ *
+ * See https://vaadin.com/commercial-license-and-service-terms for the full
+ * license.
+ */
+import { html, LitElement } from 'lit';
+import { defineCustomElement } from '@vaadin/component-base/src/define.js';
+import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
+import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
+import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
+import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js';
+
+/**
+ * A responsive, grid-based dashboard layout component
+ *
+ * @customElement
+ * @extends HTMLElement
+ * @mixes DashboardLayoutMixin
+ * @mixes ElementMixin
+ * @mixes ThemableMixin
+ */
+class DashboardLayout extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement)))) {
+ static get is() {
+ return 'vaadin-dashboard-layout';
+ }
+
+ /** @protected */
+ render() {
+ return html``;
+ }
+}
+
+defineCustomElement(DashboardLayout);
+
+export { DashboardLayout };
diff --git a/packages/dashboard/src/vaadin-dashboard.d.ts b/packages/dashboard/src/vaadin-dashboard.d.ts
index 9ddad5ac0a7..bf19da0601b 100644
--- a/packages/dashboard/src/vaadin-dashboard.d.ts
+++ b/packages/dashboard/src/vaadin-dashboard.d.ts
@@ -9,11 +9,12 @@
* license.
*/
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
+import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js';
/**
* A responsive, grid-based dashboard layout component
*/
-declare class Dashboard extends ElementMixin(HTMLElement) {}
+declare class Dashboard extends DashboardLayoutMixin(ElementMixin(HTMLElement)) {}
declare global {
interface HTMLElementTagNameMap {
diff --git a/packages/dashboard/src/vaadin-dashboard.js b/packages/dashboard/src/vaadin-dashboard.js
index 09e890c7b61..60bb5ad67f5 100644
--- a/packages/dashboard/src/vaadin-dashboard.js
+++ b/packages/dashboard/src/vaadin-dashboard.js
@@ -13,6 +13,7 @@ import { html, LitElement } from 'lit';
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
+import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js';
/**
* A responsive, grid-based dashboard layout component
@@ -20,8 +21,9 @@ import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
* @customElement
* @extends HTMLElement
* @mixes ElementMixin
+ * @mixes DashboardLayoutMixin
*/
-class Dashboard extends ElementMixin(PolylitMixin(LitElement)) {
+class Dashboard extends DashboardLayoutMixin(ElementMixin(PolylitMixin(LitElement))) {
static get is() {
return 'vaadin-dashboard';
}
diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts
new file mode 100644
index 00000000000..1ac507dd023
--- /dev/null
+++ b/packages/dashboard/test/dashboard-layout.test.ts
@@ -0,0 +1,155 @@
+import { expect } from '@vaadin/chai-plugins';
+import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
+import '../vaadin-dashboard-layout.js';
+import type { DashboardLayout } from '../vaadin-dashboard-layout.js';
+import {
+ getColumnWidths,
+ getElementFromCell,
+ setGap,
+ setMaximumColumnWidth,
+ setMinimumColumnWidth,
+} from './helpers.js';
+
+/**
+ * Validates the given grid layout.
+ *
+ * This function iterates through a number matrix representing the IDs of
+ * the items in the layout, and checks if the elements in the corresponding
+ * cells of the grid match the expected IDs.
+ *
+ * For example, the following layout would expect a grid with two columns
+ * and three rows, where the first row has one element with ID "item-0" spanning
+ * two columns, and the second row has two elements with IDs "item-1" and "item-2"
+ * where the first one spans two rows, and the last cell in the third row has
+ * an element with ID "item-3":
+ *
+ * ```
+ * [
+ * [0, 0],
+ * [1, 2],
+ * [1, 3]
+ * ]
+ * ```
+ */
+function expectLayout(dashboard: DashboardLayout, layout: number[][]) {
+ layout.forEach((row, rowIndex) => {
+ row.forEach((itemId, columnIndex) => {
+ const element = getElementFromCell(dashboard, rowIndex, columnIndex);
+ if (!element) {
+ expect(itemId).to.be.undefined;
+ } else {
+ expect(element.id).to.equal(`item-${itemId}`);
+ }
+ });
+ });
+}
+
+describe('dashboard layout', () => {
+ let dashboard: DashboardLayout;
+ const columnWidth = 100;
+ const remValue = parseFloat(getComputedStyle(document.documentElement).fontSize);
+
+ beforeEach(async () => {
+ dashboard = fixtureSync(`
+
+ Item 0
+ Item 1
+
+ `);
+ // Disable gap between items in these tests
+ setGap(dashboard, 0);
+ // Set the column width to a fixed value
+ setMinimumColumnWidth(dashboard, columnWidth);
+ setMaximumColumnWidth(dashboard, columnWidth);
+ // Make the dashboard wide enough to fit all items on a single row
+ dashboard.style.width = `${columnWidth * dashboard.childElementCount}px`;
+
+ await nextFrame();
+
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth, columnWidth]);
+ /* prettier-ignore */
+ expectLayout(dashboard, [
+ [0, 1],
+ ]);
+ });
+
+ it('should be responsive', () => {
+ // Narrow down the component to fit one column
+ dashboard.style.width = `${columnWidth}px`;
+
+ /* prettier-ignore */
+ expectLayout(dashboard, [
+ [0],
+ [1],
+ ]);
+ });
+
+ describe('minimum column width', () => {
+ it('should have a default minimum column width', () => {
+ // Clear the minimum column width used in the tests
+ setMinimumColumnWidth(dashboard, undefined);
+ // Narrow down the component to have the width of 0
+ dashboard.style.width = '0';
+ // Expect the column width to equal the default minimum column width
+ expect(getColumnWidths(dashboard)).to.eql([remValue * 25]);
+ });
+
+ it('should have one overflown column if narrowed below minimum column width', () => {
+ // Narrow down the component to have the width of half a column
+ dashboard.style.width = `${columnWidth / 2}px`;
+ // Expect the column width to still be the same (overflown)
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth]);
+ });
+
+ it('should not overflow if narrowed to the minimum column width', () => {
+ // Set the min column width to half of the column width
+ setMinimumColumnWidth(dashboard, columnWidth / 2);
+ // Narrow down the component to have the width of half a column
+ dashboard.style.width = `${columnWidth / 2}px`;
+ // Expect the column width to equal the min column width
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth / 2]);
+ });
+
+ it('should have one wide column with large minimum column width', () => {
+ setMaximumColumnWidth(dashboard, columnWidth * 2);
+ // Set the min column width to be twice as wide
+ setMinimumColumnWidth(dashboard, columnWidth * 2);
+ // Expect there to only be one column with twice the width
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth * 2]);
+ /* prettier-ignore */
+ expectLayout(dashboard, [
+ [0],
+ [1],
+ ]);
+ });
+ });
+
+ describe('maximum column width', () => {
+ it('should have a default maximum column width', () => {
+ // Clear the maximum column width used in the tests
+ setMaximumColumnWidth(dashboard, undefined);
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth, columnWidth]);
+ // Widen the component to have the width of 2.5 columns
+ dashboard.style.width = `${columnWidth * 2.5}px`;
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth * 1.25, columnWidth * 1.25]);
+ // Widen the component to have the width of 3 columns
+ dashboard.style.width = `${columnWidth * 3}px`;
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth, columnWidth, columnWidth]);
+ // Shrink the component to have the width of 1.5 columns
+ dashboard.style.width = `${columnWidth * 1.5}px`;
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth * 1.5]);
+ });
+
+ it('should have one wide column with large maximum column width', () => {
+ // Allow the column to be twice as wide
+ setMaximumColumnWidth(dashboard, columnWidth * 2);
+ // Expect there to only be one column with twice the width
+ expect(getColumnWidths(dashboard)).to.eql([columnWidth * 2]);
+ /* prettier-ignore */
+ expectLayout(dashboard, [
+ [0],
+ [1],
+ ]);
+ });
+ });
+});
diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts
new file mode 100644
index 00000000000..8ce6da27bda
--- /dev/null
+++ b/packages/dashboard/test/helpers.ts
@@ -0,0 +1,52 @@
+/**
+ * Returns the effective column widths of the dashboard as an array of numbers.
+ */
+export function getColumnWidths(dashboard: HTMLElement): number[] {
+ return getComputedStyle(dashboard)
+ .gridTemplateColumns.split(' ')
+ .map((width) => parseFloat(width));
+}
+
+/**
+ * Returns the effective row heights of the dashboard as an array of numbers.
+ */
+export function getRowHeights(dashboard: HTMLElement): number[] {
+ return getComputedStyle(dashboard)
+ .gridTemplateRows.split(' ')
+ .map((height) => parseFloat(height));
+}
+
+/**
+ * Returns the element at the center of the cell at the given row and column index.
+ */
+export function getElementFromCell(dashboard: HTMLElement, rowIndex: number, columnIndex: number): Element | null {
+ const { top, left } = dashboard.getBoundingClientRect();
+ const columnWidths = getColumnWidths(dashboard);
+ const rowHeights = getRowHeights(dashboard);
+
+ const x = left + columnWidths.slice(0, columnIndex).reduce((sum, width) => sum + width, 0);
+ const y = top + rowHeights.slice(0, rowIndex).reduce((sum, height) => sum + height, 0);
+
+ return document.elementFromPoint(x + columnWidths[columnIndex] / 2, y + rowHeights[rowIndex] / 2);
+}
+
+/**
+ * Sets the minimum column width of the dashboard.
+ */
+export function setMinimumColumnWidth(dashboard: HTMLElement, width?: number): void {
+ dashboard.style.setProperty('--vaadin-dashboard-col-min-width', width !== undefined ? `${width}px` : null);
+}
+
+/**
+ * Sets the maximum column width of the dashboard.
+ */
+export function setMaximumColumnWidth(dashboard: HTMLElement, width?: number): void {
+ dashboard.style.setProperty('--vaadin-dashboard-col-max-width', width !== undefined ? `${width}px` : null);
+}
+
+/**
+ * Sets the gap between the cells of the dashboard.
+ */
+export function setGap(dashboard: HTMLElement, gap: number): void {
+ dashboard.style.gap = `${gap}px`;
+}
diff --git a/packages/dashboard/test/typings/dashboard.types.ts b/packages/dashboard/test/typings/dashboard.types.ts
new file mode 100644
index 00000000000..4c775a8093c
--- /dev/null
+++ b/packages/dashboard/test/typings/dashboard.types.ts
@@ -0,0 +1,20 @@
+import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js';
+import type { DashboardLayoutMixinClass } from '../../src/vaadin-dashboard-layout-mixin.js';
+import type { Dashboard } from '../../vaadin-dashboard.js';
+import type { DashboardLayout } from '../../vaadin-dashboard-layout.js';
+
+const assertType = (actual: TExpected) => actual;
+
+/* Dashboard */
+const dashboard = document.createElement('vaadin-dashboard');
+assertType(dashboard);
+
+assertType(dashboard);
+assertType(dashboard);
+
+/* DashboardLayout */
+const layout = document.createElement('vaadin-dashboard-layout');
+assertType(layout);
+
+assertType(layout);
+assertType(layout);
diff --git a/packages/dashboard/theme/lumo/vaadin-dashboard-layout.js b/packages/dashboard/theme/lumo/vaadin-dashboard-layout.js
new file mode 100644
index 00000000000..523bb302e6d
--- /dev/null
+++ b/packages/dashboard/theme/lumo/vaadin-dashboard-layout.js
@@ -0,0 +1 @@
+import '../../src/vaadin-dashboard-layout.js';
diff --git a/packages/dashboard/theme/material/vaadin-dashboard-layout.js b/packages/dashboard/theme/material/vaadin-dashboard-layout.js
new file mode 100644
index 00000000000..523bb302e6d
--- /dev/null
+++ b/packages/dashboard/theme/material/vaadin-dashboard-layout.js
@@ -0,0 +1 @@
+import '../../src/vaadin-dashboard-layout.js';
diff --git a/packages/dashboard/vaadin-dashboard-layout.d.ts b/packages/dashboard/vaadin-dashboard-layout.d.ts
new file mode 100644
index 00000000000..3738a8b0a02
--- /dev/null
+++ b/packages/dashboard/vaadin-dashboard-layout.d.ts
@@ -0,0 +1 @@
+export * from './src/vaadin-dashboard-layout.js';
diff --git a/packages/dashboard/vaadin-dashboard-layout.js b/packages/dashboard/vaadin-dashboard-layout.js
new file mode 100644
index 00000000000..a1dc7807903
--- /dev/null
+++ b/packages/dashboard/vaadin-dashboard-layout.js
@@ -0,0 +1,3 @@
+import './theme/lumo/vaadin-dashboard-layout.js';
+
+export * from './src/vaadin-dashboard-layout.js';