diff --git a/labs/aria/tabs/tablist.ts b/labs/aria/tabs/tablist.ts index 5637d67947..f56719c02c 100644 --- a/labs/aria/tabs/tablist.ts +++ b/labs/aria/tabs/tablist.ts @@ -107,7 +107,20 @@ export class AriaTablistElement extends baseClass { * Orientation of the tablist ('horizontal' or 'vertical'). */ @property({type: String, reflect: true}) - orientation: 'horizontal' | 'vertical' = 'horizontal'; + get orientation(): 'horizontal' | 'vertical' { + return (this[internals].ariaOrientation || 'horizontal') as + | 'horizontal' + | 'vertical'; + } + set orientation(value: 'horizontal' | 'vertical') { + const isVertical = value === 'vertical'; + this[internals].ariaOrientation = isVertical ? 'vertical' : 'horizontal'; + // Update focusGroup to match tablist orientation. + this.setAttribute( + 'focusgroup', + `tablist ${isVertical ? 'block' : 'inline'}`, + ); + } @query('slot:not([name])') private readonly slotElement!: HTMLSlotElement | null; @@ -120,10 +133,11 @@ export class AriaTablistElement extends baseClass { super(); if (isServer) return; this[internals].role = 'tablist'; - setupDispatchHooks(this, 'click', 'keydown'); + // Set initial orientation and focusgroup. + this.orientation = 'horizontal'; + setupDispatchHooks(this, 'click', 'focusin'); this.addEventListener('click', this.handleClick.bind(this)); - this.addEventListener('keydown', this.handleKeydown.bind(this)); - this.addEventListener('focusout', this.handleFocusout.bind(this)); + this.addEventListener('focusin', this.handleFocusin.bind(this)); } protected override render() { @@ -176,17 +190,9 @@ export class AriaTablistElement extends baseClass { for (const tab of tabs) { this.setTabSelected(tab, tab === tabToSelect); } - - this.updateFocusableTab(tabToSelect); - } - - protected updateFocusableTab(focusableTab: HTMLElement) { - for (const tab of this.tabs) { - tab.tabIndex = tab === focusableTab ? 0 : -1; - } } - private async handleClick(event: Event) { + private handleClick(event: Event) { // event.composedPath() needs to be called before dispatch completes. const tab = event .composedPath() @@ -206,6 +212,12 @@ export class AriaTablistElement extends baseClass { }); } + private handleFocusin(event: FocusEvent) { + if (this.autoSelect) { + this.handleClick(event); + } + } + protected handleSlotChange() { const tabToSelect = this.selectedTab ?? this.tabs[0]; if (tabToSelect) { @@ -215,81 +227,6 @@ export class AriaTablistElement extends baseClass { this.updateSelectedTab(tabToSelect); } } - - // focus item on keydown and optionally select it - private handleKeydown(event: KeyboardEvent) { - // Allow event to bubble. - afterDispatch(event, () => { - const isLeft = event.key === 'ArrowLeft'; - const isRight = event.key === 'ArrowRight'; - const isUp = event.key === 'ArrowUp'; - const isDown = event.key === 'ArrowDown'; - const isHome = event.key === 'Home'; - const isEnd = event.key === 'End'; - const isVertical = this.orientation === 'vertical'; - const isDirectionKey = isVertical ? isUp || isDown : isLeft || isRight; - // Ignore non-navigation keys - if (event.defaultPrevented || (!isDirectionKey && !isHome && !isEnd)) { - return; - } - - const {tabs} = this; - // Don't try to select another tab if there aren't any. - if (tabs.length < 2) { - return; - } - - // Prevent default interactions, such as scrolling. - event.preventDefault(); - - let indexToFocus: number; - if (isHome || isEnd) { - indexToFocus = isHome ? 0 : tabs.length - 1; - } else { - // Check if moving forwards or backwards - const isRtl = getComputedStyle(this).direction === 'rtl'; - const forwards = isVertical ? isDown : isRtl ? isLeft : isRight; - const {focusedTab} = this; - if (!focusedTab) { - // If there is not already a tab focused, select the first or last tab - // based on the direction we're traveling. - indexToFocus = forwards ? 0 : tabs.length - 1; - } else { - const focusedIndex = this.tabs.indexOf(focusedTab); - indexToFocus = forwards ? focusedIndex + 1 : focusedIndex - 1; - if (indexToFocus >= tabs.length) { - // Return to start if moving past the last item. - indexToFocus = 0; - } else if (indexToFocus < 0) { - // Go to end if moving before the first item. - indexToFocus = tabs.length - 1; - } - } - } - - const tabToFocus = tabs[indexToFocus]; - tabToFocus.focus(); - if (this.autoSelect) { - const previousTab = this.selectedTab; - this.updateSelectedTab(tabToFocus); - this.onTabChange(previousTab); - } else { - this.updateFocusableTab(tabToFocus); - } - }); - } - - private handleFocusout() { - // restore focus to selected item when blurring the tab bar. - if (this.matches(':focus-within')) { - return; - } - - const {selectedTab} = this; - if (selectedTab) { - this.updateFocusableTab(selectedTab); - } - } } interface AriaTabLike extends HTMLElement { diff --git a/labs/aria/tabs/tablist_test.ts b/labs/aria/tabs/tablist_test.ts index e59a21d0bf..a5a0b6ef01 100644 --- a/labs/aria/tabs/tablist_test.ts +++ b/labs/aria/tabs/tablist_test.ts @@ -14,7 +14,6 @@ import {html} from 'lit'; import {Environment} from '../../../testing/environment.js'; import {hasState} from '../../behaviors/custom-state-set.js'; import {internals} from '../../behaviors/element-internals.js'; -import {AriaTabElement} from './tab.js'; describe('md-aria-tablist', () => { const env = new Environment(); @@ -51,6 +50,12 @@ describe('md-aria-tablist', () => { expect(tablist[internals].role).toBe('tablist'); }); + + it('defaults focusgroup attribute to "tablist inline"', async () => { + const {tablist} = await setupTest(); + + expect(tablist.getAttribute('focusgroup')).toBe('tablist inline'); + }); }); describe('Initial selection and syncing', () => { @@ -66,13 +71,6 @@ describe('md-aria-tablist', () => { expect(tabs[1][hasState]('selected')).toBeFalse(); }); - it('configures roving tabIndex for selected and unselected tabs on initialization', async () => { - const {tabs} = await setupTest(); - - expect(tabs[0].tabIndex).toBe(0); - expect(tabs[1].tabIndex).toBe(-1); - }); - it('shows only the active tabpanel on initialization', async () => { const {panels} = await setupTest(); @@ -221,6 +219,25 @@ describe('md-aria-tablist', () => { const {tablist} = await setupTest(); expect(tablist.orientation).toBe('horizontal'); + expect(tablist[internals].ariaOrientation).toBe('horizontal'); + }); + + it('updates ariaOrientation and focusgroup attribute when orientation is set', async () => { + const {tablist} = await setupTest(); + + tablist.orientation = 'vertical'; + await env.waitForStability(); + + expect(tablist.orientation).toBe('vertical'); + expect(tablist[internals].ariaOrientation).toBe('vertical'); + expect(tablist.getAttribute('focusgroup')).toBe('tablist block'); + + tablist.orientation = 'horizontal'; + await env.waitForStability(); + + expect(tablist.orientation).toBe('horizontal'); + expect(tablist[internals].ariaOrientation).toBe('horizontal'); + expect(tablist.getAttribute('focusgroup')).toBe('tablist inline'); }); }); @@ -273,7 +290,7 @@ describe('md-aria-tablist', () => { expect(event.target).toBe(tablist); }); - it('dispatches "change" event on tablist when tab selection changes via keydown in automatic selection mode', async () => { + it('dispatches "change" event on tablist when tab selection changes via focus in automatic selection mode', async () => { const {tablist, tabs} = await setupTest(html` Tab 1 @@ -285,10 +302,7 @@ describe('md-aria-tablist', () => { const changeListener = jasmine.createSpy('changeListener'); tablist.addEventListener('change', changeListener); - tabs[0].focus(); - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); + tabs[1].focus(); await env.waitForStability(); expect(changeListener).toHaveBeenCalledTimes(1); @@ -317,9 +331,9 @@ describe('md-aria-tablist', () => { }); }); - describe('Keyboard navigation - Automatic activation (autoSelect = true)', () => { - it('selects next tab on ArrowRight key press in automatic selection mode', async () => { - const {tablist, tabs, panels} = await setupTest(html` + describe('Focus activation - Automatic activation (autoSelect = true)', () => { + it('selects tab on focus in automatic selection mode', async () => { + const {tabs, panels} = await setupTest(html` Tab 1 Tab 2 @@ -331,376 +345,29 @@ describe('md-aria-tablist', () => { Panel 3 `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[1].selected).toBeTrue(); - expect(panels[1].hidden).toBeFalse(); - }); - - it('selects previous tab on ArrowLeft key press in automatic selection mode', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - Tab 3 - - Panel 1 - Panel 2 - - Panel 3 - - `); tabs[1].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(panels[0].hidden).toBeFalse(); - }); - - it('wraps to last tab on ArrowLeft from first tab in automatic selection mode', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - Tab 3 - - Panel 1 - Panel 2 - - Panel 3 - - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[2].selected).toBeTrue(); - expect(panels[2].hidden).toBeFalse(); - }); - - it('wraps to first tab on ArrowRight from last tab in automatic selection mode', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - Tab 3 - - Panel 1 - Panel 2 - - Panel 3 - - `); - tabs[2].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(panels[0].hidden).toBeFalse(); - }); - - it('selects last tab on End key press', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - Tab 3 - - Panel 1 - Panel 2 - - Panel 3 - - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'End', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[2].selected).toBeTrue(); - expect(panels[2].hidden).toBeFalse(); - }); - - it('selects first tab on Home key press', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - Tab 3 - - Panel 1 - Panel 2 - - Panel 3 - - `); - tabs[2].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'Home', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(panels[0].hidden).toBeFalse(); - }); - - it('selects next tab on ArrowDown key press in vertical orientation', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), - ); await env.waitForStability(); expect(tabs[1].selected).toBeTrue(); expect(panels[1].hidden).toBeFalse(); }); - - it('selects previous tab on ArrowUp key press in vertical orientation', async () => { - const {tablist, tabs, panels} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[1].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowUp', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(panels[0].hidden).toBeFalse(); - }); - - it('ignores ArrowDown key press in horizontal orientation', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(tabs[1].selected).toBeFalse(); - }); - - it('moves forward on ArrowLeft in RTL layout', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[1].selected).toBeTrue(); - }); - - it('moves backward on ArrowRight in RTL layout', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[1].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - }); - - it('moves forward on ArrowDown in vertical RTL layout', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[1].selected).toBeTrue(); - }); - - it('moves backward on ArrowUp in vertical RTL layout', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[1].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowUp', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - }); - - it('ignores navigation keydown when event default is prevented', async () => { - const {tablist, tabs} = await setupTest(html` - - Tab 1 - Tab 2 - - Panel 1 - Panel 2 - `); - tabs[0].focus(); - - const event = new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }); - event.preventDefault(); - tablist.dispatchEvent(event); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - expect(tabs[1].selected).toBeFalse(); - }); - - it('does not navigate on arrow key press if tablist contains only one tab', async () => { - const {root} = await setupTest(html` - - Tab 1 - - `); - const tablist = root.querySelector('md-aria-tablist')!; - const tabs = tablist.tabs as AriaTabElement[]; - - tabs[0].focus(); - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[0].selected).toBeTrue(); - }); - - it('focuses first tab on ArrowRight when no tab is focused', async () => { - const {tablist, tabs} = await setupTest(); - (document.activeElement as HTMLElement | null)?.blur(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(document.activeElement).toBe(tabs[0]); - }); - - it('focuses last tab on ArrowLeft when no tab is focused', async () => { - const {tablist, tabs} = await setupTest(); - (document.activeElement as HTMLElement | null)?.blur(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true}), - ); - await env.waitForStability(); - - expect(document.activeElement).toBe(tabs[tabs.length - 1]); - }); }); - describe('Keyboard navigation - Manual activation (autoSelect = false)', () => { - it('moves focus without changing tab selection on arrow key press in manual selection mode', async () => { - const {tablist, tabs, panels} = await setupTest(); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); + describe('Focus activation - Manual activation (autoSelect = false)', () => { + it('does not change selection when tab is focused in manual selection mode', async () => { + const {tabs, panels} = await setupTest(); + tabs[1].focus(); await env.waitForStability(); expect(tabs[0].selected).toBeTrue(); expect(tabs[1].selected).toBeFalse(); expect(panels[0].hidden).toBeFalse(); expect(panels[1].hidden).toBeTrue(); - expect(tabs[1].tabIndex).toBe(0); }); it('selects focused tab when Enter key is pressed', async () => { - const {tablist, tabs, panels} = await setupTest(); - tabs[0].focus(); - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); + const {tabs, panels} = await setupTest(); + tabs[1].focus(); await env.waitForStability(); tabs[1].dispatchEvent( @@ -715,11 +382,8 @@ describe('md-aria-tablist', () => { }); it('selects focused tab when Space key is pressed', async () => { - const {tablist, tabs, panels} = await setupTest(); - tabs[0].focus(); - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); + const {tabs, panels} = await setupTest(); + tabs[1].focus(); await env.waitForStability(); tabs[1].dispatchEvent( @@ -733,27 +397,6 @@ describe('md-aria-tablist', () => { }); }); - describe('Focusout behavior', () => { - it('restores roving tabIndex to selected tab on focusout', async () => { - const {tablist, tabs} = await setupTest(); - tabs[0].focus(); - - tablist.dispatchEvent( - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), - ); - await env.waitForStability(); - - expect(tabs[1].tabIndex).toBe(0); - expect(tabs[0].tabIndex).toBe(-1); - - tabs[1].blur(); - await env.waitForStability(); - - expect(tabs[0].tabIndex).toBe(0); - expect(tabs[1].tabIndex).toBe(-1); - }); - }); - describe('Dynamic DOM mutations (slot change)', () => { it('selects first remaining tab when currently selected tab is removed from DOM', async () => { const {tablist, tabs, panels} = await setupTest();