diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index cde096957..025cb6a6e 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -94,6 +94,7 @@ export class UUITabGroupElement extends LitElement { dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical'; #tabElements: HTMLElement[] = []; + #tabPanelElements: HTMLElement[] = []; #hiddenTabElements: UUITabElement[] = []; #hiddenTabElementsMap: Map = new Map(); @@ -105,15 +106,38 @@ export class UUITabGroupElement extends LitElement { this.#onResize.bind(this) ); + #mutationObserver: MutationObserver = new MutationObserver( + this.#onSlotChange.bind(this) + ); + connectedCallback() { super.connectedCallback(); this.#resizeObserver.observe(this); if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist'); + + this.#mutationObserver = new MutationObserver(mutations => { + // Update aria labels when the DOM changes + if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) { + setTimeout(() => this.setAriaLabels()); + } + + // Sync tabs when disabled states change + if (mutations.some(m => m.attributeName === 'disabled')) { + this.#syncTabsAndPanels(); + } + }); + + // After the first update... + this.updateComplete.then(() => { + this.#syncTabsAndPanels(); + this.#mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); + }); } disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver.unobserve(this); + this.#mutationObserver.disconnect(); } #onResize(entries: ResizeObserverEntry[]) { @@ -126,6 +150,7 @@ export class UUITabGroupElement extends LitElement { }); this.#setTabArray(); + this.#syncTabsAndPanels(); this.#tabElements.forEach(el => { el.addEventListener('click', this.#onTabClicked); @@ -262,10 +287,30 @@ export class UUITabGroupElement extends LitElement { this.#calculateBreakPoints(); } + // This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times. + #syncTabsAndPanels() { + this.#tabElements = this._slottedNodes ? this._slottedNodes : []; + this.#tabPanelElements = []; + + // After updating, show or hide scroll controls as needed + //this.updateComplete.then(() => this.updateScrollControls()); + } + #isElementTabLike(el: any): el is UUITabElement { return el instanceof UUITabElement || 'active' in el; } + private setAriaLabels() { + // Link each tab with its corresponding panel + this.#tabElements.forEach(tab => { + const panel = this.#tabPanelElements.find(el => el.getAttribute("name") === tab.getAttribute("panel")); + if (panel) { + tab.setAttribute('aria-controls', panel.getAttribute('id')!); + panel.setAttribute('aria-labelledby', tab.getAttribute('id')!); + } + }); + } + render() { return html` diff --git a/packages/uui-tabs/lib/uui-tab-panel.element.ts b/packages/uui-tabs/lib/uui-tab-panel.element.ts index 40622d757..1253c8eef 100644 --- a/packages/uui-tabs/lib/uui-tab-panel.element.ts +++ b/packages/uui-tabs/lib/uui-tab-panel.element.ts @@ -16,9 +16,10 @@ export class UUITabPanelElement extends LitElement { static styles = [ css` :host { - --uui-tab-panel-padding: 0; + --uui-tab-panel-padding: 1rem 0; display: none; + width: 100%; } :host([active]) { @@ -35,8 +36,6 @@ export class UUITabPanelElement extends LitElement { private readonly attrId = ++id; private readonly componentId = `uui-tab-panel-${this.attrId}`; - private _slottedNodes?: HTMLElement[]; - /** * The tab panel's name. */ @@ -66,6 +65,10 @@ export class UUITabPanelElement extends LitElement { //this.#resizeObserver.unobserve(this); } + handleActiveChange() { + this.setAttribute('aria-hidden', this.active ? 'false' : 'true'); + } + render() { return html` `, * @element uui-tabs @@ -145,6 +147,18 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { `, ]; + private readonly attrId = ++id; + private readonly componentId = `uui-tab-${this.attrId}`; + + /** + * Reflects the name of the tab panel this tab is associated with. The panel must be located in the same tab group. + * @type {string} + * @attr + * @default false + */ + @property({ type: String, reflect: true }) + public panel: string = ''; + /** * Reflects the disabled state of the element. True if tab is disabled. Change this to switch the state programmatically. * @type {boolean} @@ -194,6 +208,10 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { } render() { + + // If the user didn't provide an ID, we'll set one so we can link tabs and tab panels with aria labels + this.id = this.id.length > 0 ? this.id : this.componentId; + return this.href ? html` html` Advanced Settings - This is the general tab panel. + This is the general tab panel. This is the custom tab panel. This is the advanced tab panel. This is a disabled tab panel.