From 9bc1ebc5fe3a4706f4fb25f0f7ed964499d5648f Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Thu, 22 May 2025 00:13:03 +0300 Subject: [PATCH 1/7] 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 | 73 +++++++++++++ .../dashboard/test/dashboard-section.test.ts | 100 +++++++++++++++++- .../dashboard/test/dashboard-widget.test.ts | 25 ++--- packages/dashboard/test/dashboard.test.ts | 12 +-- packages/dashboard/test/helpers.ts | 4 + 13 files changed, 293 insertions(+), 76 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 eccf708bc84..82794b3e97f 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'; import { dashboardWidgetAndSectionStyles } from './vaadin-dashboard-styles.js'; const DEFAULT_I18N = { @@ -58,6 +58,11 @@ export const DashboardItemMixin = (superClass) => type: Object, }, + /** @protected */ + _rootHeadingLevel: { + type: Number, + }, + /** @private */ __selected: { type: Boolean, @@ -297,6 +302,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 10d77fd3bcb..160da594858 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -109,6 +109,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 3bd88d1df49..d7f47da6ca9 100644 --- a/packages/dashboard/src/vaadin-dashboard-section.js +++ b/packages/dashboard/src/vaadin-dashboard-section.js @@ -155,11 +155,6 @@ class DashboardSection extends DashboardItemMixin(ElementMixin(ThemableMixin(Pol value: '', }, - /* @private */ - __rootHeadingLevel: { - type: Number, - }, - /** @private */ __childCount: { type: Number, @@ -178,7 +173,7 @@ class DashboardSection extends DashboardItemMixin(ElementMixin(ThemableMixin(Pol
${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 d793d5ea2fc..8b2f39e1db9 100644 --- a/packages/dashboard/src/vaadin-dashboard-widget.js +++ b/packages/dashboard/src/vaadin-dashboard-widget.js @@ -199,11 +199,6 @@ class DashboardWidget extends DashboardItemMixin(ElementMixin(ThemableMixin(Poly value: '', }, - /* @private */ - __rootHeadingLevel: { - type: Number, - }, - /* @private */ __isNestedWidget: { type: Boolean, @@ -245,7 +240,6 @@ class DashboardWidget extends DashboardItemMixin(ElementMixin(ThemableMixin(Poly this.toggleAttribute(attr, !!wrapper[attr]); }); this.__i18n = wrapper.i18n; - this.__rootHeadingLevel = wrapper.__rootHeadingLevel; } this.__updateNestedState(); @@ -267,7 +261,7 @@ class DashboardWidget extends DashboardItemMixin(ElementMixin(ThemableMixin(Poly /** @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 e8250c4fb14..eadfcf88129 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 4d795c8d588..036090ebc6e 100644 --- a/packages/dashboard/src/vaadin-dashboard.js +++ b/packages/dashboard/src/vaadin-dashboard.js @@ -162,20 +162,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, @@ -185,7 +171,7 @@ class Dashboard extends DashboardLayoutMixin( } static get observers() { - return ['__itemsOrRendererChanged(items, renderer, editable, __effectiveI18n, rootHeadingLevel)']; + return ['__itemsOrRendererChanged(items, renderer, editable, __effectiveI18n)']; } /** @@ -271,7 +257,6 @@ class Dashboard extends DashboardLayoutMixin( wrapper.firstElementChild.toggleAttribute(attr, !!wrapper[attr]); }); wrapper.firstElementChild.__i18n = this.__effectiveI18n; - wrapper.firstElementChild.__rootHeadingLevel = this.rootHeadingLevel; } }); } @@ -315,7 +300,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; @@ -440,7 +424,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 a881ab2c15a..e67362d52b4 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -2,10 +2,13 @@ import { expect } from '@vaadin/chai-plugins'; import { fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers'; import '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; +import '../vaadin-dashboard-widget.js'; import '@vaadin/vaadin-lumo-styles/spacing.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, @@ -613,3 +616,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 99a22c6bfb8..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 '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; -import type { DashboardSection } from '../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 ce9aa31b86a..1f6a72cf0cc 100644 --- a/packages/dashboard/test/dashboard-widget.test.ts +++ b/packages/dashboard/test/dashboard-widget.test.ts @@ -4,6 +4,7 @@ import '../vaadin-dashboard-widget.js'; import { DashboardSection } from '../vaadin-dashboard-section.js'; import { DashboardWidget } from '../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 67bd7386d0c..940dc412783 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 39d7bcc523c95ad6c1c9e167778f5de79925b5d2 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:23:21 +0300 Subject: [PATCH 2/7] 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 d41d2cf86b1fcb1de3829f7c4b23f16d4b9ddf91 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:23:44 +0300 Subject: [PATCH 3/7] 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 e67362d52b4..445cc835ebb 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -655,14 +655,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 e9590b456b8c5b713b46759a73334aea44880715 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Wed, 4 Jun 2025 09:30:04 +0300 Subject: [PATCH 4/7] 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 445cc835ebb..8cadc843ba3 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -4,9 +4,9 @@ import '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; import '../vaadin-dashboard-widget.js'; import '@vaadin/vaadin-lumo-styles/spacing.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, @@ -618,25 +618,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) { @@ -650,31 +655,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 f042ab4576a869c18f16e23eea8231d12f186746 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 5/7] 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 8cadc843ba3..cb8b30d84c6 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -626,16 +626,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 99f447426353ab3cab2518793ca4c935c2e0ea1a 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 6/7] 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 cb8b30d84c6..5c7a39ed2fc 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -711,13 +711,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 b3ecb342cfba88a3764512e684772b4e66960e89 Mon Sep 17 00:00:00 2001 From: ugur-vaadin Date: Fri, 6 Jun 2025 11:24:06 +0300 Subject: [PATCH 7/7] 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 82794b3e97f..a25abba7099 100644 --- a/packages/dashboard/src/vaadin-dashboard-item-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-item-mixin.js @@ -293,6 +293,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 160da594858..12c5f3c2b62 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -123,6 +123,7 @@ export const DashboardLayoutMixin = (superClass) => value: 2, sync: true, reflectToAttribute: true, + observer: '__rootHeadingLevelChanged', }, }; } @@ -156,4 +157,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 } }), + ); + } };