Skip to content

Commit

Permalink
fix(tab-list): size indicator on font load, click/focus ring management
Browse files Browse the repository at this point in the history
  • Loading branch information
Westbrook committed Apr 9, 2020
1 parent 7e0ef49 commit 254815b
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 13 deletions.
87 changes: 74 additions & 13 deletions packages/tab-list/src/tab-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ export class TabList extends Focusable {
}

protected manageAutoFocus(): void {
const tabs = [...this.querySelectorAll('[role="tab"]')] as Tab[];
const tabUpdateCompletes = tabs.map((tab) => tab.updateComplete);
const tabs = [...this.children] as Tab[];
const tabUpdateCompletes = tabs.map((tab) => {
if (typeof tab.updateComplete !== 'undefined')
return tab.updateComplete;
});
Promise.all(tabUpdateCompletes).then(() => super.manageAutoFocus());
}

Expand All @@ -97,8 +100,8 @@ export class TabList extends Focusable {
protected firstUpdated(changes: PropertyValues): void {
super.firstUpdated(changes);
this.setAttribute('role', 'tablist');
this.addEventListener('mousedown', this.manageFocusinType);
this.addEventListener('focusin', this.startListeningToKeyboard);
this.addEventListener('focusout', this.stopListeningToKeyboard);
}

protected updated(changes: PropertyValues): void {
Expand All @@ -112,16 +115,34 @@ export class TabList extends Focusable {
}
}

private isListeningToKeyboard = false;
/**
* This will force apply the focus visible styling.
* It should always do so when this styling is already applied.
*/
public shouldApplyFocusVisible = false;

public startListeningToKeyboard = (): void => {
this.addEventListener('keydown', this.handleKeydown);
this.isListeningToKeyboard = true;
private manageFocusinType = (): void => {
if (this.shouldApplyFocusVisible) {
return;
}

const handleFocusin = (): void => {
this.shouldApplyFocusVisible = false;
this.removeEventListener('focusin', handleFocusin);
};
this.addEventListener('focusin', handleFocusin);
};

public stopListeningToKeyboard = (): void => {
this.isListeningToKeyboard = false;
this.removeEventListener('keydown', this.handleKeydown);
public startListeningToKeyboard = (): void => {
this.addEventListener('keydown', this.handleKeydown);
this.shouldApplyFocusVisible = true;

const stopListeningToKeyboard = (): void => {
this.removeEventListener('keydown', this.handleKeydown);
this.shouldApplyFocusVisible = false;
this.removeEventListener('focusout', stopListeningToKeyboard);
};
this.addEventListener('focusout', stopListeningToKeyboard);
};

public handleKeydown(event: KeyboardEvent): void {
Expand All @@ -142,7 +163,7 @@ export class TabList extends Focusable {
private onClick(event: Event): void {
const target = event.target as HTMLElement;
this.selectTarget(target);
if (this.isListeningToKeyboard) {
if (this.shouldApplyFocusVisible) {
/* Trick :focus-visible polyfill into thinking keyboard based focus */
this.dispatchEvent(
new KeyboardEvent('keydown', {
Expand Down Expand Up @@ -211,12 +232,16 @@ export class TabList extends Focusable {
this.updateSelectionIndicator();
}

private async updateSelectionIndicator(): Promise<void> {
private updateSelectionIndicator = async (): Promise<void> => {
const selectedElement = this.querySelector('[selected]') as Tab;
if (!selectedElement) {
return;
}
await selectedElement.updateComplete;
await Promise.all([
await selectedElement.updateComplete,
await ((document as unknown) as { fonts: { ready: Promise<void> } })
.fonts.ready,
]);
const tabBoundingClientRect = selectedElement.getBoundingClientRect();
const parentBoundingClientRect = this.getBoundingClientRect();

Expand All @@ -233,5 +258,41 @@ export class TabList extends Focusable {

this.selectionIndicatorStyle = `transform: translateY(${offset}px) scaleY(${height});`;
}
};

public connectedCallback(): void {
super.connectedCallback();
/* istanbul ignore else */
if ('fonts' in document) {
((document as unknown) as {
fonts: {
addEventListener: (
name: string,
callback: () => void
) => void;
};
}).fonts.addEventListener(
'loadingdone',
this.updateSelectionIndicator
);
}
}

public disconnectedCallback(): void {
/* istanbul ignore else */
if ('fonts' in document) {
((document as unknown) as {
fonts: {
removeEventListener: (
name: string,
callback: () => void
) => void;
};
}).fonts.removeEventListener(
'loadingdone',
this.updateSelectionIndicator
);
}
super.disconnectedCallback();
}
}
101 changes: 101 additions & 0 deletions packages/tab-list/test/tab-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import '../';
import { TabList } from '../';
import '../../tab';
import { Tab } from '../../tab';
import '../../icon';
import { fixture, elementUpdated, html, expect } from '@open-wc/testing';
import { LitElement } from 'lit-element';
import { TemplateResult } from 'lit-html';
import { waitForPredicate } from '../../../test/testing-helpers';

const keyboardEvent = (code: string): KeyboardEvent =>
new KeyboardEvent('keydown', {
Expand Down Expand Up @@ -89,6 +91,24 @@ describe('TabList', () => {
expect(tabList.selected).to.equal(thirdTab.value);
});

it('autofocuses', async () => {
const tabList = await fixture<TabList>(
html`
<sp-tab-list selected="second" autofocus>
<sp-tab label="Tab 1" value="first"></sp-tab>
<sp-tab label="Tab 2" value="second"></sp-tab>
<sp-tab label="Tab 3" value="third"></sp-tab>
</sp-tab-list>
`
);

await elementUpdated(tabList);

const autoElement = tabList.querySelector('[label="Tab 2"]') as Tab;

expect(document.activeElement).to.equal(autoElement);
});

it('forces only one tab to be selected', async () => {
const tabList = await createTabList();

Expand Down Expand Up @@ -291,6 +311,87 @@ describe('TabList', () => {
expect(el.selected).to.be.equal('first');
});

it('manages the focus ring between `click` and tab `focus`', async () => {
const tabList = await createTabList();
const otherThing = document.createElement('button');
document.body.append(otherThing);

await waitForPredicate(() => !!window.applyFocusVisiblePolyfill);
await elementUpdated(tabList);
const tab1 = tabList.querySelector('sp-tab:nth-child(1)') as Tab;
const tab2 = tabList.querySelector('sp-tab:nth-child(2)') as Tab;
const tab3 = tabList.querySelector('sp-tab:nth-child(3)') as Tab;
expect(tab1.classList.contains('focus-visible')).to.be.false;
expect(tab2.classList.contains('focus-visible')).to.be.false;
expect(tab3.classList.contains('focus-visible')).to.be.false;

tab1.dispatchEvent(
new KeyboardEvent('keydown', {
code: 'Tab',
})
);
tab1.focus();
await elementUpdated(tab1);
expect(document.activeElement, 'first tab is focused').to.equal(tab1);
expect(
tab1.classList.contains('focus-visible'),
'`focus()` sets the ring'
).to.be.true;

tab2.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
composed: true,
})
);
tab2.focus();
tab2.click();
await elementUpdated(tab2);
expect(document.activeElement, 'second tab is focused').to.equal(tab2);
expect(
tab2.classList.contains('focus-visible'),
'`click()` should persist'
).to.be.true;

tab2.dispatchEvent(
new FocusEvent('focusout', {
bubbles: true,
composed: true,
})
);
otherThing.focus();
await elementUpdated(tab2);
expect(
document.activeElement,
'second tab is not focused'
).to.not.equal(tab2);
expect(
tab2.classList.contains('focus-visible'),
'`blur()` clears the ring'
).to.be.false;

tab3.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
composed: true,
})
);
tab3.focus();
tab3.dispatchEvent(
new FocusEvent('click', {
bubbles: true,
composed: true,
})
);
await elementUpdated(tab3);
expect(document.activeElement, 'third tab is focused').to.equal(tab3);
expect(
tab3.classList.contains('focus-visible'),
'`click()` does not set the ring'
).to.be.false;
otherThing.remove();
});

it('accepts keyboard based selection through shadow DOM', async () => {
class TabTestEl extends LitElement {
protected render(): TemplateResult {
Expand Down

0 comments on commit 254815b

Please sign in to comment.