diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js index b6151992d8d..c2445a8f255 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,30 @@ 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 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.is === 'vaadin-dashboard' || el.constructor.is === 'vaadin-dashboard-layout') + ); + }); +} diff --git a/packages/dashboard/src/vaadin-dashboard-item-mixin.js b/packages/dashboard/src/vaadin-dashboard-item-mixin.js index eccf708bc84..a25abba7099 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, @@ -288,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 */ @@ -297,6 +303,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 +396,36 @@ 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.__rootHeadingLevelListenerTarget = parentLayout; + parentLayout.addEventListener( + 'dashboard-root-heading-level-changed', + this.__boundRootHeadingLevelChangedListener, + ); + } + } + + /** @private */ + __removeHeadingLevelObserver() { + if (this.__rootHeadingLevelListenerTarget) { + this.__rootHeadingLevelListenerTarget.removeEventListener( + 'dashboard-root-heading-level-changed', + this.__boundRootHeadingLevelChangedListener, + ); + this.__rootHeadingLevelListenerTarget = 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..12c5f3c2b62 100644 --- a/packages/dashboard/src/vaadin-dashboard-layout-mixin.js +++ b/packages/dashboard/src/vaadin-dashboard-layout-mixin.js @@ -109,6 +109,22 @@ 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, + observer: '__rootHeadingLevelChanged', + }, }; } @@ -141,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 } }), + ); + } }; 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..5c7a39ed2fc 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 { DashboardLayout } from '../vaadin-dashboard-layout.js'; +import { DashboardSection } from '../vaadin-dashboard-section.js'; +import { DashboardWidget } from '../vaadin-dashboard-widget.js'; import { + assertHeadingLevel, expectLayout, getColumnWidths, getRowHeights, @@ -613,3 +616,114 @@ describe('dashboard layout', () => { }); }); }); + +describe('root heading level', () => { + let dashboardLayout: DashboardLayout; + let newDashboardLayout: DashboardLayout; + let section: DashboardSection; + let widget: DashboardWidget; + let nestedWidget: DashboardWidget; + + beforeEach(async () => { + const container = fixtureSync(` +
+ + + + + + + +
+ `); + await nextFrame(); + 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) { + 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 () => { + dashboardLayout.rootHeadingLevel = 4; + await nextFrame(); + assertHeadingLevels(4); + }); + + it('should revert to default title heading level (2) when set to null', async () => { + dashboardLayout.rootHeadingLevel = 4; + await nextFrame(); + dashboardLayout.rootHeadingLevel = null; + await nextFrame(); + assertHeadingLevels(2); + }); + + it('should update heading levels for newly added components', async () => { + dashboardLayout.rootHeadingLevel = 3; + await nextFrame(); + const newWidget = document.createElement('vaadin-dashboard-widget'); + dashboardLayout.appendChild(newWidget); + const newSection = document.createElement('vaadin-dashboard-section'); + const nestedInNewSection = document.createElement('vaadin-dashboard-widget'); + newSection.appendChild(nestedInNewSection); + 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 99a22c6bfb8..437cb443e42 100644 --- a/packages/dashboard/test/dashboard-section.test.ts +++ b/packages/dashboard/test/dashboard-section.test.ts @@ -1,6 +1,8 @@ import { expect } from '@vaadin/chai-plugins'; import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import '../vaadin-dashboard-layout.js'; import '../vaadin-dashboard-section.js'; +import '../vaadin-dashboard-widget.js'; import type { DashboardSection } from '../vaadin-dashboard-section.js'; import { getDraggable, 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()); +}