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`
+
+ tab 1
+ panel 1
+ tab 2
+ panel 2
+ tab 3
+ panel 3
+ tab 4
+ panel 4
+ tab 5
+ panel 5
+ tab 6
+ 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
tab 3
panel 3
+ tab 4
+ 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`
+
+ tab 1
+ panel 1
+ tab 2
+ panel 2
+ tab 3
+ 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`
+
+ tab 1
+ panel 1
+ tab 2
+ panel 2
+ tab 3
+ panel 3
+ tab 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`
+
+ tab 1
+ panel 1
+ tab 2
+ panel 2
+ tab 3
+ panel 3
+ tab 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`
+
+ tab 0
+ panel 0
+ tab 1
+ panel 1
+ tab 2
+ panel 2
+ tab 3
+ 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');
});
});