diff --git a/.changeset/clever-tables-bow.md b/.changeset/clever-tables-bow.md new file mode 100644 index 000000000..6a590d6f1 --- /dev/null +++ b/.changeset/clever-tables-bow.md @@ -0,0 +1,5 @@ +--- +'@lion/tabs': patch +--- + +Select first not-disabled tab if the first one is disabled. diff --git a/.changeset/fair-adults-carry.md b/.changeset/fair-adults-carry.md new file mode 100644 index 000000000..9a30a60b5 --- /dev/null +++ b/.changeset/fair-adults-carry.md @@ -0,0 +1,5 @@ +--- +'@lion/tabs': minor +--- + +Ensures that disabled tab elements are skipped when navigating a tab list with the keyboard. diff --git a/docs/components/content/tabs/features.md b/docs/components/content/tabs/features.md index d4ef4821e..ef34fb835 100644 --- a/docs/components/content/tabs/features.md +++ b/docs/components/content/tabs/features.md @@ -38,6 +38,31 @@ export const slotsOrder = () => html` `; ``` +## Disabled tabs + +You can disable specific tabs by adding the `disabled` attribute to a tab button. + +When using the arrow keys to switch tabs, it will skip disabled tabs, but it will not cycle from last to first or vice versa. + +```js preview-story +export const tabsDisabled = () => html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+ +
panel 4
+ +
panel 5
+ +
panel 6
+
+`; +``` + ## Nesting tabs You can include tabs within tabs diff --git a/packages/tabs/src/LionTabs.js b/packages/tabs/src/LionTabs.js index f2ab0309a..c3d31391e 100644 --- a/packages/tabs/src/LionTabs.js +++ b/packages/tabs/src/LionTabs.js @@ -164,6 +164,21 @@ export class LionTabs extends LitElement { firstUpdated(changedProperties) { super.firstUpdated(changedProperties); this.__setupSlots(); + if (this.tabs[0].disabled) { + this.selectedIndex = this.tabs.findIndex(tab => !tab.disabled); + } + } + + get tabs() { + return /** @type {HTMLButtonElement[]} */ (Array.from(this.children)).filter( + child => child.slot === 'tab', + ); + } + + get panels() { + return /** @type {HTMLElement[]} */ (Array.from(this.children)).filter( + child => child.slot === 'panel', + ); } /** @private */ @@ -186,22 +201,16 @@ export class LionTabs extends LitElement { __setupStore() { /** @type {StoreEntry[]} */ this.__store = []; - const buttons = /** @type {HTMLElement[]} */ (Array.from(this.children)).filter( - child => child.slot === 'tab', - ); - const panels = /** @type {HTMLElement[]} */ (Array.from(this.children)).filter( - child => child.slot === 'panel', - ); - if (buttons.length !== panels.length) { + if (this.tabs.length !== this.panels.length) { // eslint-disable-next-line no-console console.warn( - `The amount of tabs (${buttons.length}) doesn't match the amount of panels (${panels.length}).`, + `The amount of tabs (${this.tabs.length}) doesn't match the amount of panels (${this.panels.length}).`, ); } - buttons.forEach((button, index) => { + this.tabs.forEach((button, index) => { const uid = uuid(); - const panel = panels[index]; + const panel = this.panels[index]; /** @type {StoreEntry} */ const entry = { @@ -235,6 +244,56 @@ export class LionTabs extends LitElement { this.__store = []; } + /** + * @param {HTMLButtonElement[]} tabs + * @param {HTMLButtonElement} currentTab + * @param {'right' | 'left'} dir + * @returns {HTMLButtonElement|undefined} + * @private + */ + __getNextNotDisabledTab(tabs, currentTab, dir) { + let orderedNotDisabledTabs = /** @type {HTMLButtonElement[]} */ ([]); + const nextNotDisabledTabs = tabs.filter((tab, i) => !tab.disabled && i > this.selectedIndex); + const prevNotDisabledTabs = tabs.filter((tab, i) => !tab.disabled && i < this.selectedIndex); + if (dir === 'right') { + orderedNotDisabledTabs = [...nextNotDisabledTabs, ...prevNotDisabledTabs]; + } else { + orderedNotDisabledTabs = [...prevNotDisabledTabs.reverse(), ...nextNotDisabledTabs.reverse()]; + } + return orderedNotDisabledTabs[0]; + } + + /** + * @param {number} newIndex + * @param {string} direction + * @returns {number} + * @private + */ + __getNextAvailableIndex(newIndex, direction) { + const currentTab = this.tabs[this.selectedIndex]; + if (this.tabs.every(tab => !tab.disabled)) { + return newIndex; + } + if (direction === 'ArrowRight' || direction === 'ArrowDown') { + const nextNotDisabledTab = this.__getNextNotDisabledTab(this.tabs, currentTab, 'right'); + return this.tabs.findIndex(tab => nextNotDisabledTab === tab); + } + if (direction === 'ArrowLeft' || direction === 'ArrowUp') { + const nextNotDisabledTab = this.__getNextNotDisabledTab(this.tabs, currentTab, 'left'); + return this.tabs.findIndex(tab => nextNotDisabledTab === tab); + } + if (direction === 'Home') { + return this.tabs.findIndex(tab => !tab.disabled); + } + if (direction === 'End') { + const notDisabledTabs = this.tabs + .map((tab, i) => ({ disabled: tab.disabled, index: i })) + .filter(tab => !tab.disabled); + return notDisabledTabs[notDisabledTabs.length - 1].index; + } + return -1; + } + /** * @param {number} index * @returns {(event: Event) => unknown} @@ -257,24 +316,32 @@ export class LionTabs extends LitElement { case 'ArrowDown': case 'ArrowRight': if (this.selectedIndex + 1 >= this._pairCount) { - this._setSelectedIndexWithFocus(0); + this._setSelectedIndexWithFocus(this.__getNextAvailableIndex(0, _ev.key)); } else { - this._setSelectedIndexWithFocus(this.selectedIndex + 1); + this._setSelectedIndexWithFocus( + this.__getNextAvailableIndex(this.selectedIndex + 1, _ev.key), + ); } break; case 'ArrowUp': case 'ArrowLeft': if (this.selectedIndex <= 0) { - this._setSelectedIndexWithFocus(this._pairCount - 1); + this._setSelectedIndexWithFocus( + this.__getNextAvailableIndex(this._pairCount - 1, _ev.key), + ); } else { - this._setSelectedIndexWithFocus(this.selectedIndex - 1); + this._setSelectedIndexWithFocus( + this.__getNextAvailableIndex(this.selectedIndex - 1, _ev.key), + ); } break; case 'Home': - this._setSelectedIndexWithFocus(0); + this._setSelectedIndexWithFocus(this.__getNextAvailableIndex(0, _ev.key)); break; case 'End': - this._setSelectedIndexWithFocus(this._pairCount - 1); + this._setSelectedIndexWithFocus( + this.__getNextAvailableIndex(this._pairCount - 1, _ev.key), + ); break; /* no default */ } @@ -297,6 +364,9 @@ export class LionTabs extends LitElement { * @protected */ _setSelectedIndexWithFocus(value) { + if (value === -1) { + return; + } const stale = this.__selectedIndex; this.__selectedIndex = value; this.__updateSelected(true); @@ -323,16 +393,8 @@ export class LionTabs extends LitElement { ) { return; } - const previousButton = /** @type {HTMLElement} */ ( - Array.from(this.children).find( - child => child.slot === 'tab' && child.hasAttribute('selected'), - ) - ); - const previousPanel = /** @type {HTMLElement} */ ( - Array.from(this.children).find( - child => child.slot === 'panel' && child.hasAttribute('selected'), - ) - ); + const previousButton = this.tabs.find(child => child.hasAttribute('selected')); + const previousPanel = this.panels.find(child => child.hasAttribute('selected')); if (previousButton) { deselectButton(previousButton); } diff --git a/packages/tabs/test/lion-tabs.test.js b/packages/tabs/test/lion-tabs.test.js index 7ea9d6d3f..99584f952 100644 --- a/packages/tabs/test/lion-tabs.test.js +++ b/packages/tabs/test/lion-tabs.test.js @@ -15,6 +15,8 @@ const basicTabs = html`
panel 2
panel 3
+ +
panel 4
`; @@ -216,7 +218,7 @@ describe('', () => { const el = /** @type {LionTabs} */ (await fixture(basicTabs)); const tabs = el.querySelectorAll('[slot=tab]'); tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); - expect(el.selectedIndex).to.equal(2); + expect(el.selectedIndex).to.equal(3); }); it('selects first tab on [arrow-right] if on last tab', async () => { @@ -254,6 +256,90 @@ describe('', () => { tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); expect(el.selectedIndex).to.equal(2); }); + + it('selects next available not disabled tab if first tab is disabled', async () => { + const el = /** @type {LionTabs} */ ( + await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `) + ); + expect(el.selectedIndex).to.equal(1); + }); + + it('selects next available not disabled tab on [arrow-right] and [arrow-down]', async () => { + const el = /** @type {LionTabs} */ ( + await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+ +
panel 3
+
+ `) + ); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + expect(el.selectedIndex).to.equal(2); + }); + + it('selects next available not disabled tab on [arrow-left] and [arrow-up]', async () => { + const el = /** @type {LionTabs} */ ( + await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+ +
panel 3
+
+ `) + ); + el.selectedIndex = 2; + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + expect(el.selectedIndex).to.equal(0); + }); + + it('cycles through tabs even if the last or first tabs are disabled', async () => { + const el = /** @type {LionTabs} */ ( + await fixture(html` + + +
panel 0
+ +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `) + ); + const tabs = el.querySelectorAll('[slot=tab]'); + el.selectedIndex = 1; + + // 0 is disabled, same for 3, so should go to 2 + tabs[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + expect(el.selectedIndex).to.equal(2); + + // 3 is disabled, same for 0, so should go to 1 + tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + expect(el.selectedIndex).to.equal(1); + }); }); describe('Content distribution', () => { @@ -276,12 +362,12 @@ describe('', () => { const selectedTab = Array.from(el.children).find( child => child.slot === 'tab' && child.hasAttribute('selected'), ); - expect(selectedTab && selectedTab.textContent).to.equal('tab 5'); + expect(selectedTab && selectedTab.textContent).to.equal('tab 6'); const selectedPanel = Array.from(el.children).find( child => child.slot === 'panel' && child.hasAttribute('selected'), ); - expect(selectedPanel && selectedPanel.textContent).to.equal('panel 5'); + expect(selectedPanel && selectedPanel.textContent).to.equal('panel 6'); }); });