Merge pull request #1573 from jrobind/fix/disabledTabs

fix(tabs): prevent selection of disabled tabs.
This commit is contained in:
Joren Broekema 2021-12-13 12:06:08 +01:00 committed by GitHub
commit 1340c1bd61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 212 additions and 29 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/tabs': patch
---
Select first not-disabled tab if the first one is disabled.

View file

@ -0,0 +1,5 @@
---
'@lion/tabs': minor
---
Ensures that disabled tab elements are skipped when navigating a tab list with the keyboard.

View file

@ -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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab" disabled>tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab" disabled>tab 3</button>
<div slot="panel">panel 3</div>
<button slot="tab">tab 4</button>
<div slot="panel">panel 4</div>
<button slot="tab">tab 5</button>
<div slot="panel">panel 5</div>
<button slot="tab" disabled>tab 6</button>
<div slot="panel">panel 6</div>
</lion-tabs>
`;
```
## Nesting tabs
You can include tabs within tabs

View file

@ -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);
}

View file

@ -15,6 +15,8 @@ const basicTabs = html`
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
<button slot="tab">tab 4</button>
<div slot="panel">panel 4</div>
</lion-tabs>
`;
@ -216,7 +218,7 @@ describe('<lion-tabs>', () => {
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('<lion-tabs>', () => {
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`
<lion-tabs>
<button slot="tab" disabled>tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`)
);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab" disabled>tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
<button slot="tab" disabled>tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`)
);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab" disabled>tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
<button slot="tab" disabled>tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`)
);
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`
<lion-tabs>
<button slot="tab" disabled>tab 0</button>
<div slot="panel">panel 0</div>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab" disabled>tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`)
);
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('<lion-tabs>', () => {
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');
});
});