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';