diff --git a/src/components/radio-button/radio-button.component.ts b/src/components/radio-button/radio-button.component.ts index 773dd16d..dbd85a75 100644 --- a/src/components/radio-button/radio-button.component.ts +++ b/src/components/radio-button/radio-button.component.ts @@ -125,7 +125,6 @@ export default class SlRadioButton extends ShoelaceElement { aria-disabled=${this.disabled} type="button" value=${ifDefined(this.value)} - tabindex="${this.checked ? '0' : '-1'}" @blur=${this.handleBlur} @focus=${this.handleFocus} @click=${this.handleClick} diff --git a/src/components/radio-group/radio-group.component.ts b/src/components/radio-group/radio-group.component.ts index 5873e334..aecf78b5 100644 --- a/src/components/radio-group/radio-group.component.ts +++ b/src/components/radio-group/radio-group.component.ts @@ -169,7 +169,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor radio.checked = false; if (!this.hasButtonGroup) { - radio.tabIndex = -1; + radio.setAttribute('tabindex', '-1'); } }); @@ -177,7 +177,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor radios[index].checked = true; if (!this.hasButtonGroup) { - radios[index].tabIndex = 0; + radios[index].setAttribute('tabindex', '0'); radios[index].focus(); } else { radios[index].shadowRoot!.querySelector('button')!.focus(); @@ -226,10 +226,10 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor const buttonRadio = radios[0].shadowRoot?.querySelector('button'); if (buttonRadio) { - buttonRadio.tabIndex = 0; + buttonRadio.setAttribute('tabindex', '0'); } } else { - radios[0].tabIndex = 0; + radios[0].setAttribute('tabindex', '0'); } } diff --git a/src/components/tab-group/tab-group.component.ts b/src/components/tab-group/tab-group.component.ts index 1485f31c..cfbd917f 100644 --- a/src/components/tab-group/tab-group.component.ts +++ b/src/components/tab-group/tab-group.component.ts @@ -50,6 +50,7 @@ export default class SlTabGroup extends ShoelaceElement { private mutationObserver: MutationObserver; private resizeObserver: ResizeObserver; private tabs: SlTab[] = []; + private focusableTabs: SlTab[] = []; private panels: SlTabPanel[] = []; @query('.tab-group') tabGroup: HTMLElement; @@ -123,14 +124,10 @@ export default class SlTabGroup extends ShoelaceElement { this.resizeObserver.unobserve(this.nav); } - private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) { + private getAllTabs() { const slot = this.shadowRoot!.querySelector('slot[name="nav"]')!; - return [...(slot.assignedElements() as SlTab[])].filter(el => { - return options.includeDisabled - ? el.tagName.toLowerCase() === 'sl-tab' - : el.tagName.toLowerCase() === 'sl-tab' && !el.disabled; - }); + return slot.assignedElements() as SlTab[]; } private getAllPanels() { @@ -178,48 +175,44 @@ export default class SlTabGroup extends ShoelaceElement { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { const activeEl = this.tabs.find(t => t.matches(':focus')); const isRtl = this.matches(':dir(rtl)'); + let nextTab: null | SlTab = null; if (activeEl?.tagName.toLowerCase() === 'sl-tab') { - let index = this.tabs.indexOf(activeEl); - if (event.key === 'Home') { - index = 0; + nextTab = this.focusableTabs[0]; } else if (event.key === 'End') { - index = this.tabs.length - 1; + nextTab = this.focusableTabs[this.focusableTabs.length - 1]; } else if ( (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')) || (['start', 'end'].includes(this.placement) && event.key === 'ArrowUp') ) { - index--; + const currentIndex = this.tabs.findIndex(el => el === activeEl); + nextTab = this.findNextFocusableTab(currentIndex, 'backward'); } else if ( (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight')) || (['start', 'end'].includes(this.placement) && event.key === 'ArrowDown') ) { - index++; + const currentIndex = this.tabs.findIndex(el => el === activeEl); + nextTab = this.findNextFocusableTab(currentIndex, 'forward'); } - if (index < 0) { - index = this.tabs.length - 1; + if (!nextTab) { + return; } - if (index > this.tabs.length - 1) { - index = 0; - } - - const currentTab = this.tabs[index]; - currentTab.tabIndex = 0; - currentTab.focus({ preventScroll: true }); + nextTab.tabIndex = 0; + nextTab.focus({ preventScroll: true }); if (this.activation === 'auto') { - this.setActiveTab(currentTab, { scrollBehavior: 'smooth' }); + this.setActiveTab(nextTab, { scrollBehavior: 'smooth' }); } else { this.tabs.forEach(tabEl => { - tabEl.tabIndex = tabEl === currentTab ? 0 : -1; + tabEl.tabIndex = tabEl === nextTab ? 0 : -1; }); } if (['top', 'bottom'].includes(this.placement)) { - scrollIntoView(this.tabs[index], this.nav, 'horizontal'); + scrollIntoView(nextTab, this.nav, 'horizontal'); } event.preventDefault(); @@ -334,7 +327,8 @@ export default class SlTabGroup extends ShoelaceElement { // This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times. private syncTabsAndPanels() { - this.tabs = this.getAllTabs({ includeDisabled: false }); + this.tabs = this.getAllTabs(); + this.focusableTabs = this.tabs.filter(el => !el.disabled); this.panels = this.getAllPanels(); this.syncIndicator(); @@ -343,6 +337,34 @@ export default class SlTabGroup extends ShoelaceElement { this.updateComplete.then(() => this.updateScrollControls()); } + private findNextFocusableTab(currentIndex: number, direction: 'forward' | 'backward') { + let nextTab = null; + const iterator = direction === 'forward' ? 1 : -1; + let nextIndex = currentIndex + iterator; + + while (currentIndex < this.tabs.length) { + nextTab = this.tabs[nextIndex] || null; + + if (nextTab === null) { + // This is where wrapping happens. If we're moving forward and get to the end, then we jump to the beginning. If we're moving backward and get to the start, then we jump to the end. + if (direction === 'forward') { + nextTab = this.focusableTabs[0]; + } else { + nextTab = this.focusableTabs[this.focusableTabs.length - 1]; + } + break; + } + + if (!nextTab.disabled) { + break; + } + + nextIndex += iterator; + } + + return nextTab; + } + @watch('noScrollControls', { waitUntilFirstUpdate: true }) updateScrollControls() { if (this.noScrollControls) { diff --git a/src/components/tab/tab.component.ts b/src/components/tab/tab.component.ts index d059f343..0e0b9016 100644 --- a/src/components/tab/tab.component.ts +++ b/src/components/tab/tab.component.ts @@ -50,7 +50,11 @@ export default class SlTab extends ShoelaceElement { /** Disables the tab and prevents selection. */ @property({ type: Boolean, reflect: true }) disabled = false; - tabIndex = -1; + /** + * @internal + * Need to wrap in a `@property()` otherwise CustomElement throws a "The result must not have attributes" runtime error. + */ + @property({ type: Number, reflect: true }) tabIndex = 0; connectedCallback() { super.connectedCallback(); @@ -70,7 +74,12 @@ export default class SlTab extends ShoelaceElement { @watch('disabled') handleDisabledChange() { this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); - this.tabIndex = -1; + + if (this.disabled && !this.active) { + this.tabIndex = -1; + } else { + this.tabIndex = 0; + } } render() { diff --git a/src/components/tab/tab.test.ts b/src/components/tab/tab.test.ts index 362dcd77..63b737fb 100644 --- a/src/components/tab/tab.test.ts +++ b/src/components/tab/tab.test.ts @@ -24,7 +24,7 @@ describe('', () => { expect(el.getAttribute('role')).to.equal('tab'); expect(el.getAttribute('aria-disabled')).to.equal('false'); expect(el.getAttribute('aria-selected')).to.equal('false'); - expect(el.getAttribute('tabindex')).to.equal('-1'); + expect(el.getAttribute('tabindex')).to.equal('0'); expect(base.getAttribute('class')).to.equal(' tab '); expect(el.active).to.equal(false); expect(el.closable).to.equal(false); @@ -50,7 +50,7 @@ describe('', () => { expect(el.active).to.equal(true); expect(el.getAttribute('aria-selected')).to.equal('true'); expect(base.getAttribute('class')).to.equal(' tab tab--active '); - expect(el.getAttribute('tabindex')).to.equal('-1'); + expect(el.getAttribute('tabindex')).to.equal('0'); }); it('should set closable by attribute', async () => {