Skip to content

Commit 63a88ca

Browse files
feat: add string based title and default role (#8822)
* feat: add string based title and default role * refactor: set role on ready * refactor: use separate observers * refactor: make title property notify * Update packages/card/src/vaadin-card.js Co-authored-by: Serhii Kulykov <iamkulykov@gmail.com> * refactor: remove validation for title heading level * refactor: reflect title heading level to attribute * refactor: rename title to card title * fix: add attribute annotations * chore: remove unnecessary type annotations * fix: change attribute type --------- Co-authored-by: Serhii Kulykov <iamkulykov@gmail.com>
1 parent e67f022 commit 63a88ca

File tree

8 files changed

+261
-7
lines changed

8 files changed

+261
-7
lines changed

packages/card/src/vaadin-card.d.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix
99
/**
1010
* `<vaadin-card>` is a versatile container for grouping related content and actions.
1111
* It presents information in a structured and visually appealing manner, with
12-
* customization options to fit various design requirements.
12+
* customization options to fit various design requirements. The default ARIA role is `region`.
1313
*
1414
* ```html
15-
* <vaadin-card theme="outlined cover-media">
15+
* <vaadin-card role="region" theme="outlined cover-media">
1616
* <img slot="media" width="200" src="..." alt="">
1717
* <div slot="title">Lapland</div>
1818
* <div slot="subtitle">The Exotic North</div>
@@ -41,7 +41,19 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix
4141
*
4242
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
4343
*/
44-
declare class Card extends ElementMixin(ThemableMixin(HTMLElement)) {}
44+
declare class Card extends ElementMixin(ThemableMixin(HTMLElement)) {
45+
/**
46+
* The title of the card. When set, any custom slotted title is removed and this string-based title is used instead. If this title is used, an `aria-labelledby` attribute that points to the generated title element is set.
47+
* @attr {string} card-title
48+
*/
49+
cardTitle: string;
50+
51+
/**
52+
* Sets the heading level (`aria-level`) for the string-based title. If not set, the level defaults to 2. Setting values outside the range [1, 6] can cause accessibility issues.
53+
* @attr {number} title-heading-level
54+
*/
55+
titleHeadingLevel: number | null | undefined;
56+
}
4557

4658
declare global {
4759
interface HTMLElementTagNameMap {

packages/card/src/vaadin-card.js

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import { css, html, LitElement } from 'lit';
77
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
88
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
99
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
10+
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
1011
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
1112

1213
/**
1314
* `<vaadin-card>` is a versatile container for grouping related content and actions.
1415
* It presents information in a structured and visually appealing manner, with
15-
* customization options to fit various design requirements.
16+
* customization options to fit various design requirements. The default ARIA role is `region`.
1617
*
1718
* ```html
18-
* <vaadin-card theme="outlined cover-media">
19+
* <vaadin-card role="region" theme="outlined cover-media">
1920
* <img slot="media" width="200" src="..." alt="">
2021
* <div slot="title">Lapland</div>
2122
* <div slot="subtitle">The Exotic North</div>
@@ -279,10 +280,47 @@ class Card extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
279280
`;
280281
}
281282

283+
static get properties() {
284+
return {
285+
/**
286+
* The title of the card. When set, any custom slotted title is removed and this string-based title is used instead. If this title is used, an `aria-labelledby` attribute that points to the generated title element is set.
287+
*
288+
* @attr {string} card-title
289+
*/
290+
cardTitle: {
291+
type: String,
292+
notify: true,
293+
observer: '__cardTitleChanged',
294+
},
295+
296+
/**
297+
* Sets the heading level (`aria-level`) for the string-based title. If not set, the level defaults to 2. Setting values outside the range [1, 6] can cause accessibility issues.
298+
*
299+
* @attr {number} title-heading-level
300+
*/
301+
titleHeadingLevel: {
302+
type: Number,
303+
reflectToAttribute: true,
304+
observer: '__titleHeadingLevelChanged',
305+
},
306+
};
307+
}
308+
282309
static get experimental() {
283310
return true;
284311
}
285312

313+
/** @protected */
314+
ready() {
315+
super.ready();
316+
317+
// By default, if the user hasn't provided a custom role,
318+
// the role attribute is set to "region".
319+
if (!this.hasAttribute('role')) {
320+
this.setAttribute('role', 'region');
321+
}
322+
}
323+
286324
/** @protected */
287325
render() {
288326
return html`
@@ -322,6 +360,79 @@ class Card extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
322360
this.toggleAttribute('_hs', this.querySelector(':scope > [slot="header-suffix"]'));
323361
this.toggleAttribute('_c', this.querySelector(':scope > :not([slot])'));
324362
this.toggleAttribute('_f', this.querySelector(':scope > [slot="footer"]'));
363+
if (this.__getCustomTitleElement()) {
364+
this.__clearStringTitle();
365+
}
366+
}
367+
368+
/** @private */
369+
__clearStringTitle() {
370+
const stringTitleElement = this.__getStringTitleElement();
371+
if (stringTitleElement) {
372+
this.removeChild(stringTitleElement);
373+
}
374+
const ariaLabelledby = this.getAttribute('aria-labelledby');
375+
if (ariaLabelledby && ariaLabelledby.startsWith('card-title-')) {
376+
this.removeAttribute('aria-labelledby');
377+
}
378+
if (this.cardTitle) {
379+
this.cardTitle = '';
380+
}
381+
}
382+
383+
/** @private */
384+
__getCustomTitleElement() {
385+
return Array.from(this.querySelectorAll('[slot="title"]')).find((el) => {
386+
return !el.hasAttribute('card-string-title');
387+
});
388+
}
389+
390+
/** @private */
391+
__cardTitleChanged(title) {
392+
if (!title) {
393+
this.__clearStringTitle();
394+
return;
395+
}
396+
const customTitleElement = this.__getCustomTitleElement();
397+
if (customTitleElement) {
398+
this.removeChild(customTitleElement);
399+
}
400+
let stringTitleElement = this.__getStringTitleElement();
401+
if (!stringTitleElement) {
402+
stringTitleElement = this.__createStringTitleElement();
403+
this.appendChild(stringTitleElement);
404+
this.setAttribute('aria-labelledby', stringTitleElement.id);
405+
}
406+
stringTitleElement.textContent = title;
407+
}
408+
409+
/** @private */
410+
__createStringTitleElement() {
411+
const stringTitleElement = document.createElement('div');
412+
stringTitleElement.setAttribute('slot', 'title');
413+
stringTitleElement.setAttribute('role', 'heading');
414+
this.__setTitleHeadingLevel(stringTitleElement, this.titleHeadingLevel);
415+
stringTitleElement.setAttribute('card-string-title', '');
416+
stringTitleElement.id = `card-title-${generateUniqueId()}`;
417+
return stringTitleElement;
418+
}
419+
420+
/** @private */
421+
__titleHeadingLevelChanged(titleHeadingLevel) {
422+
const stringTitleElement = this.__getStringTitleElement();
423+
if (stringTitleElement) {
424+
this.__setTitleHeadingLevel(stringTitleElement, titleHeadingLevel);
425+
}
426+
}
427+
428+
/** @private */
429+
__setTitleHeadingLevel(stringTitleElement, titleHeadingLevel) {
430+
stringTitleElement.setAttribute('aria-level', titleHeadingLevel || 2);
431+
}
432+
433+
/** @private */
434+
__getStringTitleElement() {
435+
return this.querySelector('[slot="title"][card-string-title]');
325436
}
326437

327438
/**
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { fixtureSync, nextRender, nextUpdate } from '@vaadin/testing-helpers';
3+
import '../vaadin-card.js';
4+
5+
window.Vaadin.featureFlags ||= {};
6+
window.Vaadin.featureFlags.cardComponent = true;
7+
8+
describe('accessibility', () => {
9+
let card;
10+
11+
beforeEach(async () => {
12+
card = fixtureSync('<vaadin-card></vaadin-card>');
13+
await nextRender(card);
14+
});
15+
16+
describe('ARIA roles', () => {
17+
it('should set "region" role by default on card', () => {
18+
expect(card.getAttribute('role')).to.equal('region');
19+
});
20+
});
21+
22+
describe('label', () => {
23+
it('should not have aria-labelledby attribute by default', () => {
24+
expect(card.hasAttribute('aria-labelledby')).to.be.false;
25+
});
26+
27+
it('should have aria-labelledby attribute when title property is set', async () => {
28+
card.cardTitle = 'Some title';
29+
await nextUpdate(card);
30+
expect(card.hasAttribute('aria-labelledby')).to.be.true;
31+
});
32+
33+
it('should remove aria-labelledby attribute when custom title element is set', async () => {
34+
card.cardTitle = 'Some title';
35+
await nextUpdate(card);
36+
const customTitleElement = fixtureSync('<span slot="title">Custom title element</span>');
37+
card.appendChild(customTitleElement);
38+
await nextRender(card);
39+
expect(card.hasAttribute('aria-labelledby')).to.be.false;
40+
});
41+
});
42+
});

packages/card/test/card.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { fixtureSync } from '@vaadin/testing-helpers';
2+
import { fixtureSync, nextRender } from '@vaadin/testing-helpers';
33
import '../vaadin-card.js';
44
import type { Card } from '../vaadin-card.js';
55

@@ -9,11 +9,14 @@ window.Vaadin.featureFlags.cardComponent = true;
99
describe('vaadin-card', () => {
1010
let card: Card;
1111

12+
beforeEach(() => {
13+
card = fixtureSync('<vaadin-card></vaadin-card>');
14+
});
15+
1216
describe('custom element definition', () => {
1317
let tagName: string;
1418

1519
beforeEach(() => {
16-
card = fixtureSync('<vaadin-card></vaadin-card>');
1720
tagName = card.tagName.toLowerCase();
1821
});
1922

@@ -25,4 +28,78 @@ describe('vaadin-card', () => {
2528
expect((customElements.get(tagName) as any).is).to.equal(tagName);
2629
});
2730
});
31+
32+
describe('title', () => {
33+
function getStringTitleElement() {
34+
return card.querySelector('[slot="title"][card-string-title]') as HTMLElement;
35+
}
36+
37+
function getCustomTitleElement() {
38+
return Array.from(card.querySelectorAll('[slot="title"]')).find((el) => {
39+
return !el.hasAttribute('card-string-title');
40+
});
41+
}
42+
43+
it('should create title element with default aria-level when title property is set', async () => {
44+
const stringTitle = 'Some title';
45+
card.cardTitle = stringTitle;
46+
await nextRender(card);
47+
const stringTitleElement = getStringTitleElement();
48+
expect(stringTitleElement).to.exist;
49+
expect(stringTitleElement.getAttribute('aria-level')).to.equal('2');
50+
expect(stringTitleElement.textContent).to.equal(stringTitle);
51+
});
52+
53+
it('should update aria-level when heading level changes', async () => {
54+
card.cardTitle = 'Some title';
55+
card.titleHeadingLevel = 3;
56+
await nextRender(card);
57+
const stringTitleElement = getStringTitleElement();
58+
expect(stringTitleElement.getAttribute('aria-level')).to.equal('3');
59+
});
60+
61+
it('should use default heading level when set to null', async () => {
62+
card.titleHeadingLevel = 3;
63+
card.cardTitle = 'Some title';
64+
card.titleHeadingLevel = null;
65+
await nextRender(card);
66+
expect(getStringTitleElement().getAttribute('aria-level')).to.equal('2');
67+
});
68+
69+
it('should clear string title when custom title element is used', async () => {
70+
card.cardTitle = 'Some title';
71+
await nextRender(card);
72+
const customTitleElement = fixtureSync('<span slot="title">Custom title element</span>');
73+
card.appendChild(customTitleElement);
74+
await nextRender(card);
75+
expect(card.cardTitle).to.be.not.ok;
76+
expect(getStringTitleElement()).to.not.exist;
77+
});
78+
79+
it('should clear string title when empty string title is set', async () => {
80+
card.cardTitle = 'Some title';
81+
await nextRender(card);
82+
card.cardTitle = '';
83+
await nextRender(card);
84+
expect(getStringTitleElement()).to.not.exist;
85+
});
86+
87+
it('should clear custom title element when string title is set', async () => {
88+
const customTitleElement = fixtureSync('<span slot="title">Custom title element</span>');
89+
card.appendChild(customTitleElement);
90+
await nextRender(card);
91+
card.cardTitle = 'Some title';
92+
await nextRender(card);
93+
expect(getCustomTitleElement()).to.not.exist;
94+
});
95+
96+
it('should not clear custom title element when empty string title is set', async () => {
97+
const customTitleElement = fixtureSync('<span slot="title">Custom title element</span>');
98+
card.appendChild(customTitleElement);
99+
await nextRender(card);
100+
card.cardTitle = '';
101+
await nextRender(card);
102+
expect(getCustomTitleElement()).to.exist;
103+
});
104+
});
28105
});

packages/card/test/visual/lumo/card.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ describe('card', () => {
6060
await visualDiff(div, 'slot-title');
6161
});
6262

63+
it('string title', async () => {
64+
element = cardFixture();
65+
element.cardTitle = 'Title lorem ipsum';
66+
await visualDiff(div, 'string-title');
67+
});
68+
6369
it('subtitle', async () => {
6470
element = cardFixture(subTitle);
6571
await visualDiff(div, 'slot-subtitle');
2.43 KB
Loading

packages/card/test/visual/material/card.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ describe('card', () => {
6161
await visualDiff(div, 'slot-title');
6262
});
6363

64+
it('string title', async () => {
65+
element = cardFixture();
66+
element.cardTitle = 'Title lorem ipsum';
67+
await visualDiff(div, 'string-title');
68+
});
69+
6470
it('subtitle', async () => {
6571
element = cardFixture(subTitle);
6672
await visualDiff(div, 'slot-subtitle');
2.25 KB
Loading

0 commit comments

Comments
 (0)