From ccc4aed1fd88f316eddb47eed24d9161a32ba08f Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Thu, 22 May 2025 00:13:03 +0300 Subject: [PATCH 01/10] feat: add root level heading support to dashboard layout --- .../dashboard/src/vaadin-dashboard-helpers.js | 37 +++++-- .../src/vaadin-dashboard-item-mixin.js | 49 ++++++++- .../src/vaadin-dashboard-layout-mixin.d.ts | 10 ++ .../src/vaadin-dashboard-layout-mixin.js | 15 +++ .../dashboard/src/vaadin-dashboard-section.js | 7 +- .../dashboard/src/vaadin-dashboard-widget.js | 8 +- packages/dashboard/src/vaadin-dashboard.d.ts | 10 -- packages/dashboard/src/vaadin-dashboard.js | 19 +--- .../dashboard/test/dashboard-layout.test.ts | 81 +++++++++++++- .../dashboard/test/dashboard-section.test.ts | 102 +++++++++++++++++- .../dashboard/test/dashboard-widget.test.ts | 25 ++--- packages/dashboard/test/dashboard.test.ts | 12 +-- packages/dashboard/test/helpers.ts | 4 + 13 files changed, 298 insertions(+), 81 deletions(-) diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js index b6151992d8d..25cecaef3b9 100644 --- a/packages/dashboard/src/vaadin-dashboard-helpers.js +++ b/packages/dashboard/src/vaadin-dashboard-helpers.js @@ -102,16 +102,10 @@ export function fireRemove(element) { element.dispatchEvent(new CustomEvent('item-remove', { bubbles: true })); } -/** - * Walks up the DOM tree starting from `node`, returning the first ancestor which is an instance of the given `baseClass`. - * - * @param {Node} node - starting node - * @param {Function} baseClass - constructor, e.g. `Dashboard` - * @returns {HTMLElement | null} - */ -export function findAncestorInstance(node, baseClass) { +/** @private */ +function __findFilteredAncestorInstance(node, elementFilter) { while (node) { - if (node instanceof baseClass) { + if (elementFilter(node)) { return node; } if (node instanceof ShadowRoot) { @@ -129,3 +123,28 @@ export function findAncestorInstance(node, baseClass) { } return null; } + +/** + * Walks up the DOM tree starting from `node`, returning the first ancestor which is an instance of the given `baseClass`. + * + * @param {Node} node - starting node + * @param {Function} baseClass - constructor, e.g. `Dashboard` + * @returns {HTMLElement | null} + */ +export function findAncestorInstance(node, baseClass) { + return __findFilteredAncestorInstance(node, (el) => { + return el instanceof baseClass; + }); +} + +/** + * Walks up the DOM tree starting from `node`, returning the first ancestor which extends 'DashboardLayoutMixin'. + * + * @param {Node} node - starting node + * @returns {HTMLElement | null} + */ +export function getParentLayout(node) { + return __findFilteredAncestorInstance(node, (el) => { + return el.constructor && el.constructor.toString().includes('DashboardLayoutMixin'); + }); +} diff --git a/packages/dashboard/src/vaadin-dashboard-item-mixin.js b/packages/dashboard/src/vaadin-dashboard-item-mixin.js index 04adeb48aa6..869b17a4947 100644 --- a/packages/dashboard/src/vaadin-dashboard-item-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-item-mixin.js @@ -13,7 +13,7 @@ import { html } from 'lit'; import { FocusTrapController } from '@vaadin/a11y-base/src/focus-trap-controller.js'; import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { KeyboardController } from './keyboard-controller.js'; -import { fireMove, fireRemove, fireResize } from './vaadin-dashboard-helpers.js'; +import { fireMove, fireRemove, fireResize, getParentLayout } from './vaadin-dashboard-helpers.js'; const DEFAULT_I18N = { selectWidget: 'Select widget for editing', @@ -53,6 +53,11 @@ export const DashboardItemMixin = (superClass) => type: Object, }, + /** @protected */ + _rootHeadingLevel: { + type: Number, + }, + /** @private */ __selected: { type: Boolean, @@ -292,6 +297,19 @@ export const DashboardItemMixin = (superClass) => this.addController(this.__focusTrapController); } + /** @protected */ + connectedCallback() { + super.connectedCallback(); + this.__updateRootHeadingLevel(); + this.__setupHeadingLevelObserver(); + } + + /** @protected */ + disconnectedCallback() { + super.disconnectedCallback(); + this.__removeHeadingLevelObserver(); + } + /** @private */ __selectedChanged(selected, oldSelected) { if (!!selected === !!oldSelected) { @@ -377,4 +395,33 @@ export const DashboardItemMixin = (superClass) => } this.dispatchEvent(new CustomEvent('item-resize-mode-changed', { bubbles: true, detail: { value: resizeMode } })); } + + /** @private */ + __setupHeadingLevelObserver() { + this.__removeHeadingLevelObserver(); + const parentLayout = getParentLayout(this); + if (parentLayout) { + this.__headingLevelObserver = new MutationObserver(() => this.__updateRootHeadingLevel()); + this.__headingLevelObserver.observe(parentLayout, { + attributes: true, + attributeFilter: ['root-heading-level'], + }); + } + } + + /** @private */ + __removeHeadingLevelObserver() { + if (this.__headingLevelObserver) { + this.__headingLevelObserver.disconnect(); + this.__headingLevelObserver = null; + } + } + + /** @private */ + __updateRootHeadingLevel() { + const parentLayout = getParentLayout(this); + if (parentLayout) { + this._rootHeadingLevel = parentLayout.rootHeadingLevel; + } + } }; diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts b/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts index 1fd3424a145..5fc892f73a2 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.d.ts @@ -25,4 +25,14 @@ export declare class DashboardLayoutMixinClass { * @attr {boolean} dense-layout */ denseLayout: boolean; + + /** + * Root heading level for sections and widgets. Defaults to 2. + * + * If changed to e.g. 1: + * - sections will have the attribute `aria-level` with value 1 + * - non-nested widgets will have the attribute `aria-level` with value 1 + * - nested widgets will have the attribute `aria-level` with value 2 + */ + rootHeadingLevel: number | null | undefined; } diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js index 2049fe73417..18d9e7427ce 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -31,6 +31,21 @@ export const DashboardLayoutMixin = (superClass) => value: false, reflectToAttribute: true, }, + + /** + * Root heading level for sections and widgets. Defaults to 2. + * + * If changed to e.g. 1: + * - sections will have the attribute `aria-level` with value 1 + * - non-nested widgets will have the attribute `aria-level` with value 1 + * - nested widgets will have the attribute `aria-level` with value 2 + */ + rootHeadingLevel: { + type: Number, + value: 2, + sync: true, + reflectToAttribute: true, + }, }; } diff --git a/packages/dashboard/src/vaadin-dashboard-section.js b/packages/dashboard/src/vaadin-dashboard-section.js index 24d46a5b0a5..1d48dca6dd7 100644 --- a/packages/dashboard/src/vaadin-dashboard-section.js +++ b/packages/dashboard/src/vaadin-dashboard-section.js @@ -126,11 +126,6 @@ class DashboardSection extends DashboardItemMixin( value: '', }, - /* @private */ - __rootHeadingLevel: { - type: Number, - }, - /** @private */ __childCount: { type: Number, @@ -149,7 +144,7 @@ class DashboardSection extends DashboardItemMixin(
${this.__renderDragHandle()} -
${this.sectionTitle}
${this.__renderRemoveButton()} diff --git a/packages/dashboard/src/vaadin-dashboard-widget.js b/packages/dashboard/src/vaadin-dashboard-widget.js index 71cc11efdfe..b4a581d6ec4 100644 --- a/packages/dashboard/src/vaadin-dashboard-widget.js +++ b/packages/dashboard/src/vaadin-dashboard-widget.js @@ -166,11 +166,6 @@ class DashboardWidget extends DashboardItemMixin( value: '', }, - /* @private */ - __rootHeadingLevel: { - type: Number, - }, - /* @private */ __isNestedWidget: { type: Boolean, @@ -212,7 +207,6 @@ class DashboardWidget extends DashboardItemMixin( this.toggleAttribute(attr, !!wrapper[attr]); }); this.__i18n = wrapper.i18n; - this.__rootHeadingLevel = wrapper.__rootHeadingLevel; } this.__updateNestedState(); @@ -234,7 +228,7 @@ class DashboardWidget extends DashboardItemMixin( /** @private */ __renderWidgetTitle() { - let effectiveHeadingLevel = this.__rootHeadingLevel; + let effectiveHeadingLevel = this._rootHeadingLevel; // Default to 2 if not defined if (effectiveHeadingLevel == null) { effectiveHeadingLevel = 2; diff --git a/packages/dashboard/src/vaadin-dashboard.d.ts b/packages/dashboard/src/vaadin-dashboard.d.ts index 83a3d61f8c4..72665eb9c60 100644 --- a/packages/dashboard/src/vaadin-dashboard.d.ts +++ b/packages/dashboard/src/vaadin-dashboard.d.ts @@ -243,16 +243,6 @@ declare class Dashboard extends Das */ editable: boolean; - /** - * Root heading level for sections and widgets. Defaults to 2. - * - * If changed to e.g. 1: - * - sections will have the attribute `aria-level` with value 1 - * - non-nested widgets will have the attribute `aria-level` with value 1 - * - nested widgets will have the attribute `aria-level` with value 2 - */ - rootHeadingLevel: number | null | undefined; - /** * The object used to localize this component. To change the default * localization, replace this with an object that provides all properties, or diff --git a/packages/dashboard/src/vaadin-dashboard.js b/packages/dashboard/src/vaadin-dashboard.js index 0c857c029c3..aaa70dc6de1 100644 --- a/packages/dashboard/src/vaadin-dashboard.js +++ b/packages/dashboard/src/vaadin-dashboard.js @@ -160,20 +160,6 @@ class Dashboard extends DashboardLayoutMixin( type: Boolean, }, - /** - * Root heading level for sections and widgets. Defaults to 2. - * - * If changed to e.g. 1: - * - sections will have the attribute `aria-level` with value 1 - * - non-nested widgets will have the attribute `aria-level` with value 1 - * - nested widgets will have the attribute `aria-level` with value 2 - */ - rootHeadingLevel: { - type: Number, - value: 2, - sync: true, - }, - /** @private */ __childCount: { type: Number, @@ -183,7 +169,7 @@ class Dashboard extends DashboardLayoutMixin( } static get observers() { - return ['__itemsOrRendererChanged(items, renderer, editable, __effectiveI18n, rootHeadingLevel)']; + return ['__itemsOrRendererChanged(items, renderer, editable, __effectiveI18n)']; } /** @@ -269,7 +255,6 @@ class Dashboard extends DashboardLayoutMixin( wrapper.firstElementChild.toggleAttribute(attr, !!wrapper[attr]); }); wrapper.firstElementChild.__i18n = this.__effectiveI18n; - wrapper.firstElementChild.__rootHeadingLevel = this.rootHeadingLevel; } }); } @@ -313,7 +298,6 @@ class Dashboard extends DashboardLayoutMixin( SYNCHRONIZED_ATTRIBUTES.forEach((attr) => section.toggleAttribute(attr, !!wrapper[attr])); section.__i18n = this.__effectiveI18n; - section.__rootHeadingLevel = this.rootHeadingLevel; // Render the subitems section.__childCount = item.items.length; @@ -438,7 +422,6 @@ class Dashboard extends DashboardLayoutMixin( wrapper['first-child'] = item === getItemsArrayOfItem(item, this.items)[0]; wrapper['last-child'] = item === getItemsArrayOfItem(item, this.items).slice(-1)[0]; wrapper.i18n = this.__effectiveI18n; - wrapper.__rootHeadingLevel = this.rootHeadingLevel; } /** @private */ diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index 42c2bce5e43..a6e1ef72654 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -1,10 +1,13 @@ import { expect } from '@vaadin/chai-plugins'; import { fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers'; -import '../src/vaadin-dashboard-layout.js'; -import '../src/vaadin-dashboard-section.js'; -import type { DashboardLayout } from '../src/vaadin-dashboard-layout.js'; -import type { DashboardSection } from '../src/vaadin-dashboard-section.js'; +import '../vaadin-dashboard-layout.js'; +import '../vaadin-dashboard-section.js'; +import '../vaadin-dashboard-widget.js'; +import type { DashboardLayout } from '../vaadin-dashboard-layout.js'; +import type { DashboardSection } from '../vaadin-dashboard-section.js'; +import type { DashboardWidget } from '../vaadin-dashboard-widget.js'; import { + assertHeadingLevel, expectLayout, getColumnWidths, getRowHeights, @@ -616,3 +619,73 @@ describe('dashboard layout', () => { }); }); }); + +describe('root heading level', () => { + let dashboard: DashboardLayout; + let section: DashboardSection; + let widget: DashboardWidget; + let nestedWidget: DashboardWidget; + + beforeEach(async () => { + dashboard = fixtureSync(` + + + + + + + `); + await nextFrame(); + await nextResize(dashboard); + widget = dashboard.querySelector('vaadin-dashboard-widget') as DashboardWidget; + section = dashboard.querySelector('vaadin-dashboard-section') as DashboardSection; + nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; + }); + + function assertHeadingLevels(expectedHeadingLevel: number) { + assertHeadingLevel(widget, expectedHeadingLevel); + assertHeadingLevel(section, expectedHeadingLevel); + assertHeadingLevel(nestedWidget, expectedHeadingLevel + 1); + } + + it('should use default title heading level (2) when not explicitly set', () => { + assertHeadingLevels(2); + }); + + it('should use custom title heading level when set on dashboard layout', async () => { + dashboard.rootHeadingLevel = 4; + await nextFrame(); + assertHeadingLevels(4); + }); + + it('should update title heading level when changed on dashboard layout', async () => { + dashboard.rootHeadingLevel = 3; + await nextFrame(); + dashboard.rootHeadingLevel = 1; + await nextFrame(); + assertHeadingLevels(1); + }); + + it('should revert to default title heading level (2) when set to null', async () => { + dashboard.rootHeadingLevel = 4; + await nextFrame(); + dashboard.rootHeadingLevel = null; + await nextFrame(); + assertHeadingLevels(2); + }); + + it('should update heading levels for newly added components', async () => { + dashboard.rootHeadingLevel = 3; + await nextFrame(); + const newWidget = document.createElement('vaadin-dashboard-widget'); + dashboard.appendChild(newWidget); + const newSection = document.createElement('vaadin-dashboard-section'); + const nestedInNewSection = document.createElement('vaadin-dashboard-widget'); + newSection.appendChild(nestedInNewSection); + dashboard.appendChild(newSection); + await nextFrame(); + assertHeadingLevel(newWidget, 3); + assertHeadingLevel(newSection, 3); + assertHeadingLevel(nestedInNewSection, 4); + }); +}); diff --git a/packages/dashboard/test/dashboard-section.test.ts b/packages/dashboard/test/dashboard-section.test.ts index c9df43ff2e2..fff7cfe6df0 100644 --- a/packages/dashboard/test/dashboard-section.test.ts +++ b/packages/dashboard/test/dashboard-section.test.ts @@ -1,8 +1,13 @@ import { expect } from '@vaadin/chai-plugins'; import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; -import '../src/vaadin-dashboard-section.js'; -import type { DashboardSection } from '../src/vaadin-dashboard-section.js'; +import '../vaadin-dashboard-layout.js'; +import '../vaadin-dashboard-section.js'; +import '../vaadin-dashboard-widget.js'; +import type { DashboardLayout } from '../vaadin-dashboard-layout.js'; +import { DashboardSection } from '../vaadin-dashboard-section.js'; +import type { DashboardWidget } from '../vaadin-dashboard-widget.js'; import { + assertHeadingLevel, getDraggable, getMoveApplyButton, getMoveBackwardButton, @@ -112,4 +117,97 @@ describe('dashboard section', () => { expect(getMoveBackwardButton(section)?.getAttribute('title')).to.eql('baz'); }); }); + + describe('title heading level', () => { + describe('with dashboard layout parent', () => { + let layout: DashboardLayout; + let section: DashboardSection; + let nestedWidget: DashboardWidget; + + beforeEach(async () => { + layout = fixtureSync(` + + + + + + `); + section = layout.querySelector('vaadin-dashboard-section') as DashboardSection; + nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; + await nextFrame(); + }); + + it('should have title heading level (2) by default', () => { + assertHeadingLevel(section, 2); + }); + + it('should update heading level when parent dashboard layout changes', async () => { + layout.rootHeadingLevel = 4; + await nextFrame(); + assertHeadingLevel(section, 4); + assertHeadingLevel(nestedWidget, 5); + }); + }); + + describe('moving between parents', () => { + let newLayout: DashboardLayout; + let section: DashboardSection; + let nestedWidget: DashboardWidget; + + beforeEach(async () => { + const container = fixtureSync(` +
+ + + + + + +
+ `); + const initialLayout = container.querySelector('#layout1') as DashboardLayout; + newLayout = container.querySelector('#layout2') as DashboardLayout; + section = initialLayout.querySelector('vaadin-dashboard-section') as DashboardSection; + nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; + await nextFrame(); + }); + + it('should update heading level when moved to another dashboard layout', async () => { + newLayout.appendChild(section); + await nextFrame(); + assertHeadingLevel(section, 3); + assertHeadingLevel(nestedWidget, 4); + }); + + it('should update heading level when a new widget is added', async () => { + const newWidget = document.createElement('vaadin-dashboard-widget'); + newWidget.widgetTitle = 'New Widget'; + section.appendChild(newWidget); + await nextFrame(); + assertHeadingLevel(newWidget, 2); + newLayout.appendChild(section); + await nextFrame(); + assertHeadingLevel(newWidget, 4); + }); + }); + + describe('custom section', () => { + it('should update heading level when a custom section is used', async () => { + class CustomSection extends DashboardSection {} + customElements.define('custom-dashboard-section', CustomSection); + const layout = fixtureSync(` + + + + + + `) as DashboardLayout; + await nextFrame(); + const customSection = layout.querySelector('custom-dashboard-section') as DashboardSection; + const widget = customSection.querySelector('vaadin-dashboard-widget') as DashboardWidget; + assertHeadingLevel(customSection, 5); + assertHeadingLevel(widget, 6); + }); + }); + }); }); diff --git a/packages/dashboard/test/dashboard-widget.test.ts b/packages/dashboard/test/dashboard-widget.test.ts index 6b4b8a8c161..cc9995f4011 100644 --- a/packages/dashboard/test/dashboard-widget.test.ts +++ b/packages/dashboard/test/dashboard-widget.test.ts @@ -4,6 +4,7 @@ import '../src/vaadin-dashboard-widget.js'; import { DashboardSection } from '../src/vaadin-dashboard-section.js'; import { DashboardWidget } from '../src/vaadin-dashboard-widget.js'; import { + assertHeadingLevel, getDraggable, getMoveApplyButton, getMoveBackwardButton, @@ -156,16 +157,14 @@ describe('widget title level', () => { const widget = fixtureSync(``); await nextFrame(); - const title = getTitleElement(widget as DashboardWidget); - expect(title?.getAttribute('aria-level')).to.equal('2'); + assertHeadingLevel(widget as DashboardWidget, 2); }); it('should have title heading level 2 by default on the section', async () => { const section = fixtureSync(``); await nextFrame(); - const title = getTitleElement(section as DashboardSection); - expect(title?.getAttribute('aria-level')).to.equal('2'); + assertHeadingLevel(section as DashboardSection, 2); }); it('should have title heading level 3 when rendered inside a section', async () => { @@ -176,8 +175,7 @@ describe('widget title level', () => { `).querySelector('vaadin-dashboard-widget')!; await nextFrame(); - const title = getTitleElement(widget); - expect(title?.getAttribute('aria-level')).to.equal('3'); + assertHeadingLevel(widget, 3); }); it('should have title heading level 2 after moving out of a section', async () => { @@ -194,8 +192,7 @@ describe('widget title level', () => { wrapper.appendChild(widget); await nextFrame(); - const title = getTitleElement(widget); - expect(title?.getAttribute('aria-level')).to.equal('2'); + assertHeadingLevel(widget, 2); }); it('should have title heading level 3 after moving into a section', async () => { @@ -211,8 +208,7 @@ describe('widget title level', () => { section.appendChild(widget); await nextFrame(); - const title = getTitleElement(widget); - expect(title?.getAttribute('aria-level')).to.equal('3'); + assertHeadingLevel(widget, 3); }); it('should have title heading level 3 after defining parent section', async () => { @@ -227,8 +223,7 @@ describe('widget title level', () => { customElements.define('my-custom-section', MyCustomSection); await nextFrame(); - const title = getTitleElement(widget); - expect(title?.getAttribute('aria-level')).to.equal('3'); + assertHeadingLevel(widget, 3); }); it('should have title heading level 3 after defining the widget', async () => { @@ -243,8 +238,7 @@ describe('widget title level', () => { customElements.define('my-custom-widget', MyCustomWidget); await nextFrame(); - const title = getTitleElement(widget as DashboardWidget); - expect(title?.getAttribute('aria-level')).to.equal('3'); + assertHeadingLevel(widget as DashboardWidget, 3); }); it('should have title heading level 3 after moving a wrapped widget into a section', async () => { @@ -263,7 +257,6 @@ describe('widget title level', () => { section.appendChild(wrapper); await nextFrame(); - const title = getTitleElement(widget); - expect(title?.getAttribute('aria-level')).to.equal('3'); + assertHeadingLevel(widget, 3); }); }); diff --git a/packages/dashboard/test/dashboard.test.ts b/packages/dashboard/test/dashboard.test.ts index a0d91ed50af..f449ab9ce12 100644 --- a/packages/dashboard/test/dashboard.test.ts +++ b/packages/dashboard/test/dashboard.test.ts @@ -7,6 +7,7 @@ import type { DashboardSection } from '../src/vaadin-dashboard-section.js'; import type { DashboardWidget } from '../src/vaadin-dashboard-widget.js'; import type { Dashboard, DashboardI18n, DashboardItem, DashboardSectionItem } from '../vaadin-dashboard.js'; import { + assertHeadingLevel, expectLayout, getColumnWidths, getDraggable, @@ -15,7 +16,6 @@ import { getRemoveButton, getResizeHandle, getScrollingContainer, - getTitleElement, setMaximumColumnWidth, setMinimumColumnWidth, setMinimumRowHeight, @@ -775,17 +775,13 @@ describe('dashboard', () => { function assertHeadingLevels(expectedRootHeadingLevel: number) { const nonNestedWidget = getElementFromCell(dashboard, 0, 0) as DashboardWidget; - expect(getTitleElement(nonNestedWidget)?.getAttribute('aria-level')).to.equal( - expectedRootHeadingLevel.toString(), - ); + assertHeadingLevel(nonNestedWidget, expectedRootHeadingLevel); const nestedWidget = getElementFromCell(dashboard, 1, 0) as DashboardWidget; - expect(getTitleElement(nestedWidget)?.getAttribute('aria-level')).to.equal( - (expectedRootHeadingLevel + 1).toString(), - ); + assertHeadingLevel(nestedWidget, expectedRootHeadingLevel + 1); const section = nestedWidget?.closest('vaadin-dashboard-section') as DashboardSection; - expect(getTitleElement(section)?.getAttribute('aria-level')).to.equal(expectedRootHeadingLevel.toString()); + assertHeadingLevel(section, expectedRootHeadingLevel); } it('should use custom title heading level when set on dashboard', () => { diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts index 9a98886f9ad..73c0bd7f445 100644 --- a/packages/dashboard/test/helpers.ts +++ b/packages/dashboard/test/helpers.ts @@ -368,3 +368,7 @@ export async function updateComplete(dashboard: HTMLElement): Promise { await nextFrame(); await aTimeout(0); } + +export function assertHeadingLevel(item: DashboardWidget | DashboardSection, expectedHeadingLevel: number): void { + expect(getTitleElement(item).getAttribute('aria-level')).to.equal(expectedHeadingLevel.toString()); +} From 44f3665a52ea0d05213b262ae10dd7c94a28b51b Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:23:21 +0300 Subject: [PATCH 02/10] refactor: use is to determine whether the element is a dashboard --- packages/dashboard/src/vaadin-dashboard-helpers.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js index 25cecaef3b9..c2445a8f255 100644 --- a/packages/dashboard/src/vaadin-dashboard-helpers.js +++ b/packages/dashboard/src/vaadin-dashboard-helpers.js @@ -138,13 +138,15 @@ export function findAncestorInstance(node, baseClass) { } /** - * Walks up the DOM tree starting from `node`, returning the first ancestor which extends 'DashboardLayoutMixin'. + * Walks up the DOM tree starting from `node`, returning the first ancestor which is a `Dashboard` or `DashboardLayout`. * * @param {Node} node - starting node * @returns {HTMLElement | null} */ export function getParentLayout(node) { return __findFilteredAncestorInstance(node, (el) => { - return el.constructor && el.constructor.toString().includes('DashboardLayoutMixin'); + return ( + el.constructor && (el.constructor.is === 'vaadin-dashboard' || el.constructor.is === 'vaadin-dashboard-layout') + ); }); } From 4306c53676fbc845671b2b7d63bbc44bc3cfaea2 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:23:44 +0300 Subject: [PATCH 03/10] test: remove duplicate test --- packages/dashboard/test/dashboard-layout.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index a6e1ef72654..bf0edba29ed 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -658,14 +658,6 @@ describe('root heading level', () => { assertHeadingLevels(4); }); - it('should update title heading level when changed on dashboard layout', async () => { - dashboard.rootHeadingLevel = 3; - await nextFrame(); - dashboard.rootHeadingLevel = 1; - await nextFrame(); - assertHeadingLevels(1); - }); - it('should revert to default title heading level (2) when set to null', async () => { dashboard.rootHeadingLevel = 4; await nextFrame(); From fa4ac18b31fe15804b7638788cac53ebbe31bd9e Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:30:04 +0300 Subject: [PATCH 04/10] test: move section tests, remove duplicates, make all elements custom --- .../dashboard/test/dashboard-layout.test.ts | 91 +++++++++++++---- .../dashboard/test/dashboard-section.test.ts | 98 +------------------ 2 files changed, 71 insertions(+), 118 deletions(-) diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index bf0edba29ed..74ffef4d8ff 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -3,9 +3,9 @@ import { fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers'; import '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; import '../vaadin-dashboard-widget.js'; -import type { DashboardLayout } from '../vaadin-dashboard-layout.js'; -import type { DashboardSection } from '../vaadin-dashboard-section.js'; -import type { DashboardWidget } from '../vaadin-dashboard-widget.js'; +import { DashboardLayout } from '../vaadin-dashboard-layout.js'; +import { DashboardSection } from '../vaadin-dashboard-section.js'; +import { DashboardWidget } from '../vaadin-dashboard-widget.js'; import { assertHeadingLevel, expectLayout, @@ -621,25 +621,30 @@ describe('dashboard layout', () => { }); describe('root heading level', () => { - let dashboard: DashboardLayout; + let dashboardLayout: DashboardLayout; + let newDashboardLayout: DashboardLayout; let section: DashboardSection; let widget: DashboardWidget; let nestedWidget: DashboardWidget; beforeEach(async () => { - dashboard = fixtureSync(` - - - - - - - `); + const container = fixtureSync(` +
+ + + + + + + +
+ `); await nextFrame(); - await nextResize(dashboard); - widget = dashboard.querySelector('vaadin-dashboard-widget') as DashboardWidget; - section = dashboard.querySelector('vaadin-dashboard-section') as DashboardSection; + dashboardLayout = container.querySelector('#layout1') as DashboardLayout; + widget = dashboardLayout.querySelector('vaadin-dashboard-widget') as DashboardWidget; + section = dashboardLayout.querySelector('vaadin-dashboard-section') as DashboardSection; nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; + newDashboardLayout = container.querySelector('#layout2') as DashboardLayout; }); function assertHeadingLevels(expectedHeadingLevel: number) { @@ -653,31 +658,75 @@ describe('root heading level', () => { }); it('should use custom title heading level when set on dashboard layout', async () => { - dashboard.rootHeadingLevel = 4; + dashboardLayout.rootHeadingLevel = 4; await nextFrame(); assertHeadingLevels(4); }); it('should revert to default title heading level (2) when set to null', async () => { - dashboard.rootHeadingLevel = 4; + dashboardLayout.rootHeadingLevel = 4; await nextFrame(); - dashboard.rootHeadingLevel = null; + dashboardLayout.rootHeadingLevel = null; await nextFrame(); assertHeadingLevels(2); }); it('should update heading levels for newly added components', async () => { - dashboard.rootHeadingLevel = 3; + dashboardLayout.rootHeadingLevel = 3; await nextFrame(); const newWidget = document.createElement('vaadin-dashboard-widget'); - dashboard.appendChild(newWidget); + dashboardLayout.appendChild(newWidget); const newSection = document.createElement('vaadin-dashboard-section'); const nestedInNewSection = document.createElement('vaadin-dashboard-widget'); newSection.appendChild(nestedInNewSection); - dashboard.appendChild(newSection); + dashboardLayout.appendChild(newSection); await nextFrame(); assertHeadingLevel(newWidget, 3); assertHeadingLevel(newSection, 3); assertHeadingLevel(nestedInNewSection, 4); }); + + describe('moving between parents', () => { + it('should update heading level when moved to another dashboard layout', async () => { + newDashboardLayout.appendChild(section); + await nextFrame(); + assertHeadingLevel(section, 3); + assertHeadingLevel(nestedWidget, 4); + }); + + it('should update heading level when a new widget is added', async () => { + const newWidget = document.createElement('vaadin-dashboard-widget'); + newWidget.widgetTitle = 'New Widget'; + section.appendChild(newWidget); + await nextFrame(); + assertHeadingLevel(newWidget, 3); + newDashboardLayout.appendChild(section); + await nextFrame(); + assertHeadingLevel(newWidget, 4); + }); + }); + + it('should update heading level when custom elements are used', async () => { + class CustomLayout extends DashboardLayout {} + customElements.define('custom-dashboard-layout', CustomLayout); + class CustomSection extends DashboardSection {} + customElements.define('custom-dashboard-section', CustomSection); + class CustomWidget extends DashboardWidget {} + customElements.define('custom-dashboard-widget', CustomWidget); + const customLayout = fixtureSync(` + + + + + + + `) as CustomLayout; + await nextFrame(); + const widget = customLayout.querySelector('custom-dashboard-widget') as CustomWidget; + const section = customLayout.querySelector('custom-dashboard-section') as CustomSection; + const nestedWidget = section.querySelector('custom-dashboard-widget') as CustomWidget; + assertHeadingLevel(widget, 5); + assertHeadingLevel(section, 5); + assertHeadingLevel(nestedWidget, 6); + }); }); diff --git a/packages/dashboard/test/dashboard-section.test.ts b/packages/dashboard/test/dashboard-section.test.ts index fff7cfe6df0..437cb443e42 100644 --- a/packages/dashboard/test/dashboard-section.test.ts +++ b/packages/dashboard/test/dashboard-section.test.ts @@ -3,11 +3,8 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; import '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; import '../vaadin-dashboard-widget.js'; -import type { DashboardLayout } from '../vaadin-dashboard-layout.js'; -import { DashboardSection } from '../vaadin-dashboard-section.js'; -import type { DashboardWidget } from '../vaadin-dashboard-widget.js'; +import type { DashboardSection } from '../vaadin-dashboard-section.js'; import { - assertHeadingLevel, getDraggable, getMoveApplyButton, getMoveBackwardButton, @@ -117,97 +114,4 @@ describe('dashboard section', () => { expect(getMoveBackwardButton(section)?.getAttribute('title')).to.eql('baz'); }); }); - - describe('title heading level', () => { - describe('with dashboard layout parent', () => { - let layout: DashboardLayout; - let section: DashboardSection; - let nestedWidget: DashboardWidget; - - beforeEach(async () => { - layout = fixtureSync(` - - - - - - `); - section = layout.querySelector('vaadin-dashboard-section') as DashboardSection; - nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; - await nextFrame(); - }); - - it('should have title heading level (2) by default', () => { - assertHeadingLevel(section, 2); - }); - - it('should update heading level when parent dashboard layout changes', async () => { - layout.rootHeadingLevel = 4; - await nextFrame(); - assertHeadingLevel(section, 4); - assertHeadingLevel(nestedWidget, 5); - }); - }); - - describe('moving between parents', () => { - let newLayout: DashboardLayout; - let section: DashboardSection; - let nestedWidget: DashboardWidget; - - beforeEach(async () => { - const container = fixtureSync(` -
- - - - - - -
- `); - const initialLayout = container.querySelector('#layout1') as DashboardLayout; - newLayout = container.querySelector('#layout2') as DashboardLayout; - section = initialLayout.querySelector('vaadin-dashboard-section') as DashboardSection; - nestedWidget = section.querySelector('vaadin-dashboard-widget') as DashboardWidget; - await nextFrame(); - }); - - it('should update heading level when moved to another dashboard layout', async () => { - newLayout.appendChild(section); - await nextFrame(); - assertHeadingLevel(section, 3); - assertHeadingLevel(nestedWidget, 4); - }); - - it('should update heading level when a new widget is added', async () => { - const newWidget = document.createElement('vaadin-dashboard-widget'); - newWidget.widgetTitle = 'New Widget'; - section.appendChild(newWidget); - await nextFrame(); - assertHeadingLevel(newWidget, 2); - newLayout.appendChild(section); - await nextFrame(); - assertHeadingLevel(newWidget, 4); - }); - }); - - describe('custom section', () => { - it('should update heading level when a custom section is used', async () => { - class CustomSection extends DashboardSection {} - customElements.define('custom-dashboard-section', CustomSection); - const layout = fixtureSync(` - - - - - - `) as DashboardLayout; - await nextFrame(); - const customSection = layout.querySelector('custom-dashboard-section') as DashboardSection; - const widget = customSection.querySelector('vaadin-dashboard-widget') as DashboardWidget; - assertHeadingLevel(customSection, 5); - assertHeadingLevel(widget, 6); - }); - }); - }); }); From 3ecf981644b0aa7221add0e39ebeaec1c9a9d532 Mon Sep 17 00:00:00 2001 From: Ugur Saglam <106508695+ugur-vaadin@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:54:21 +0300 Subject: [PATCH 05/10] Update packages/dashboard/test/dashboard-layout.test.ts Co-authored-by: Serhii Kulykov --- .../dashboard/test/dashboard-layout.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index 74ffef4d8ff..94fe6638f89 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -629,16 +629,16 @@ describe('root heading level', () => { beforeEach(async () => { const container = fixtureSync(` -
- - - - - - - -
- `); +
+ + + + + + + +
+ `); await nextFrame(); dashboardLayout = container.querySelector('#layout1') as DashboardLayout; widget = dashboardLayout.querySelector('vaadin-dashboard-widget') as DashboardWidget; From bb0dba0b55cd1319e8c95237a1ebd2096b73d0da Mon Sep 17 00:00:00 2001 From: Ugur Saglam <106508695+ugur-vaadin@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:54:35 +0300 Subject: [PATCH 06/10] Update packages/dashboard/test/dashboard-layout.test.ts Co-authored-by: Serhii Kulykov --- packages/dashboard/test/dashboard-layout.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index 94fe6638f89..52c869cbe91 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -714,13 +714,13 @@ describe('root heading level', () => { class CustomWidget extends DashboardWidget {} customElements.define('custom-dashboard-widget', CustomWidget); const customLayout = fixtureSync(` - - - - - - - `) as CustomLayout; + + + + + + + `) as CustomLayout; await nextFrame(); const widget = customLayout.querySelector('custom-dashboard-widget') as CustomWidget; const section = customLayout.querySelector('custom-dashboard-section') as CustomSection; From a3e82fb499b15647c9da38bb030cd0ff84f7b786 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Fri, 6 Jun 2025 11:24:06 +0300 Subject: [PATCH 07/10] refactor: use event instead of mutation observer --- .../src/vaadin-dashboard-item-mixin.js | 20 +++++++++++-------- .../src/vaadin-dashboard-layout-mixin.js | 8 ++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/src/vaadin-dashboard-item-mixin.js b/packages/dashboard/src/vaadin-dashboard-item-mixin.js index 869b17a4947..dada92bc350 100644 --- a/packages/dashboard/src/vaadin-dashboard-item-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-item-mixin.js @@ -288,6 +288,7 @@ export const DashboardItemMixin = (superClass) => super(); this.__keyboardController = new KeyboardController(this); this.__focusTrapController = new FocusTrapController(this); + this.__boundRootHeadingLevelChangedListener = this.__updateRootHeadingLevel.bind(this); } /** @protected */ @@ -401,19 +402,22 @@ export const DashboardItemMixin = (superClass) => this.__removeHeadingLevelObserver(); const parentLayout = getParentLayout(this); if (parentLayout) { - this.__headingLevelObserver = new MutationObserver(() => this.__updateRootHeadingLevel()); - this.__headingLevelObserver.observe(parentLayout, { - attributes: true, - attributeFilter: ['root-heading-level'], - }); + this.__rootHeadingLevelListenerTarget = parentLayout; + parentLayout.addEventListener( + 'dashboard-root-heading-level-changed', + this.__boundRootHeadingLevelChangedListener, + ); } } /** @private */ __removeHeadingLevelObserver() { - if (this.__headingLevelObserver) { - this.__headingLevelObserver.disconnect(); - this.__headingLevelObserver = null; + if (this.__rootHeadingLevelListenerTarget) { + this.__rootHeadingLevelListenerTarget.removeEventListener( + 'dashboard-root-heading-level-changed', + this.__boundRootHeadingLevelChangedListener, + ); + this.__rootHeadingLevelListenerTarget = null; } } diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js index 18d9e7427ce..616dd2fc435 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -45,6 +45,7 @@ export const DashboardLayoutMixin = (superClass) => value: 2, sync: true, reflectToAttribute: true, + observer: '__rootHeadingLevelChanged', }, }; } @@ -78,4 +79,11 @@ export const DashboardLayoutMixin = (superClass) => // ...and set it as the new value this.$.grid.style.setProperty('--_col-count', columnCount); } + + /** @private */ + __rootHeadingLevelChanged(rootHeadingLevel) { + this.dispatchEvent( + new CustomEvent('dashboard-root-heading-level-changed', { detail: { value: rootHeadingLevel } }), + ); + } }; From 3e7fc80182074d7b845689fb03698ff37e240551 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Thu, 25 Sep 2025 09:17:42 +0300 Subject: [PATCH 08/10] test: update snapshot tests --- .../test/dom/__snapshots__/dashboard-layout.test.snap.js | 2 +- .../dashboard/test/dom/__snapshots__/dashboard.test.snap.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/test/dom/__snapshots__/dashboard-layout.test.snap.js b/packages/dashboard/test/dom/__snapshots__/dashboard-layout.test.snap.js index 64d90c0def4..36acffc6bd6 100644 --- a/packages/dashboard/test/dom/__snapshots__/dashboard-layout.test.snap.js +++ b/packages/dashboard/test/dom/__snapshots__/dashboard-layout.test.snap.js @@ -2,7 +2,7 @@ export const snapshots = {}; snapshots["vaadin-dashboard-layout host"] = -` +` `; /* end snapshot vaadin-dashboard-layout host */ diff --git a/packages/dashboard/test/dom/__snapshots__/dashboard.test.snap.js b/packages/dashboard/test/dom/__snapshots__/dashboard.test.snap.js index 597b347a0cb..c04f93c81b9 100644 --- a/packages/dashboard/test/dom/__snapshots__/dashboard.test.snap.js +++ b/packages/dashboard/test/dom/__snapshots__/dashboard.test.snap.js @@ -2,7 +2,10 @@ export const snapshots = {}; snapshots["vaadin-dashboard host"] = -` +` Date: Mon, 6 Oct 2025 21:01:21 +0300 Subject: [PATCH 09/10] refactor: add private property for detecting mixin --- packages/dashboard/src/vaadin-dashboard-helpers.js | 6 +----- .../dashboard/src/vaadin-dashboard-layout-mixin.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js index c2445a8f255..8b6b9726511 100644 --- a/packages/dashboard/src/vaadin-dashboard-helpers.js +++ b/packages/dashboard/src/vaadin-dashboard-helpers.js @@ -144,9 +144,5 @@ export function findAncestorInstance(node, baseClass) { * @returns {HTMLElement | null} */ export function getParentLayout(node) { - return __findFilteredAncestorInstance(node, (el) => { - return ( - el.constructor && (el.constructor.is === 'vaadin-dashboard' || el.constructor.is === 'vaadin-dashboard-layout') - ); - }); + return __findFilteredAncestorInstance(node, (el) => el.__hasVaadinDashboardLayoutMixin); } diff --git a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js index 616dd2fc435..f15e3e5c3c9 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -20,6 +20,16 @@ export const DashboardLayoutMixin = (superClass) => class DashboardLayoutMixinClass extends ResizeMixin(superClass) { static get properties() { return { + /** + * Used for mixin detection because `instanceof` does not work with mixins. + * + * @private + * @type {boolean} + */ + __hasVaadinDashboardLayoutMixin: { + value: true, + }, + /** * Whether the dashboard layout is dense. * From 3512498a2f10e62709cd228bb7cacf6fed58075d Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Tue, 7 Oct 2025 22:40:18 +0300 Subject: [PATCH 10/10] chore: remove private marking --- packages/dashboard/src/vaadin-dashboard-helpers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js index 8b6b9726511..711aee1b06f 100644 --- a/packages/dashboard/src/vaadin-dashboard-helpers.js +++ b/packages/dashboard/src/vaadin-dashboard-helpers.js @@ -102,7 +102,6 @@ export function fireRemove(element) { element.dispatchEvent(new CustomEvent('item-remove', { bubbles: true })); } -/** @private */ function __findFilteredAncestorInstance(node, elementFilter) { while (node) { if (elementFilter(node)) {