Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement roving tabindex for tabs #2041

Merged
merged 13 commits into from
May 30, 2024
20 changes: 0 additions & 20 deletions docs/pages/components/tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,6 @@ meta:
layout: component
---

```html:preview
<sl-tab>Tab</sl-tab>
<sl-tab active>Active</sl-tab>
<sl-tab closable>Closable</sl-tab>
<sl-tab disabled>Disabled</sl-tab>
```

```jsx:react
import SlTab from '@shoelace-style/shoelace/dist/react/tab';

const App = () => (
<>
<SlTab>Tab</SlTab>
<SlTab active>Active</SlTab>
<SlTab closable>Closable</SlTab>
<SlTab disabled>Disabled</SlTab>
</>
);
```

:::tip
Additional demonstrations can be found in the [tab group examples](/components/tab-group).
:::
17 changes: 16 additions & 1 deletion src/components/tab-group/tab-group.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,15 @@ export default class SlTabGroup extends ShoelaceElement {
index = 0;
}

this.tabs[index].tabIndex = 0;
this.tabs[index].focus({ preventScroll: true });

if (this.activation === 'auto') {
this.setActiveTab(this.tabs[index], { scrollBehavior: 'smooth' });
}

this.syncTabsAndPanels();

if (['top', 'bottom'].includes(this.placement)) {
scrollIntoView(this.tabs[index], this.nav, 'horizontal');
}
Expand Down Expand Up @@ -253,7 +256,10 @@ export default class SlTabGroup extends ShoelaceElement {
this.activeTab = tab;

// Sync active tab and panel
this.tabs.forEach(el => (el.active = el === this.activeTab));
this.tabs.forEach(el => {
el.active = el === this.activeTab;
el.tabIndex = el === this.activeTab ? 0 : -1;
});
this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel));
this.syncIndicator();

Expand Down Expand Up @@ -326,11 +332,20 @@ 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.panels = this.getAllPanels();
this.syncIndicator();

// After updating, show or hide scroll controls as needed
this.updateComplete.then(() => this.updateScrollControls());

this.panels = this.getAllPanels();

this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel));
this.syncIndicator();

// After updating, show or hide scroll controls as needed
this.updateComplete.then(() => this.updateScrollControls());
}

@watch('noScrollControls', { waitUntilFirstUpdate: true })
Expand Down
4 changes: 3 additions & 1 deletion src/components/tab-group/tab-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,16 @@ describe('<sl-tab-group>', () => {
expect(tabGroup).to.be.visible;
});

it('is accessible', async () => {
it.only('is accessible', async () => {
KonnorRogers marked this conversation as resolved.
Show resolved Hide resolved
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);

await tabGroup.updateComplete;

await expect(tabGroup).to.be.accessible();
});

Expand Down
16 changes: 4 additions & 12 deletions src/components/tab/tab.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ export default class SlTab extends ShoelaceElement {
@property({ type: Boolean, reflect: true }) active = false;

/** Makes the tab closable and shows a close button. */
@property({ type: Boolean }) closable = false;
@property({ type: Boolean, reflect: true }) closable = false;

/** Disables the tab and prevents selection. */
@property({ type: Boolean, reflect: true }) disabled = false;

tabIndex = -1;

connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'tab');
Expand All @@ -68,16 +70,7 @@ export default class SlTab extends ShoelaceElement {
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}

/** Sets focus to the tab. */
focus(options?: FocusOptions) {
this.tab.focus(options);
}

/** Removes focus from the tab. */
blur() {
this.tab.blur();
this.tabIndex = -1;
}

render() {
Expand All @@ -93,7 +86,6 @@ export default class SlTab extends ShoelaceElement {
'tab--closable': this.closable,
'tab--disabled': this.disabled
})}
tabindex=${this.disabled ? '-1' : '0'}
>
<slot></slot>
${this.closable
Expand Down
8 changes: 4 additions & 4 deletions src/components/tab/tab.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export default css`
color: var(--sl-color-primary-600);
}

.tab:focus {
outline: none;
:host(:focus) {
outline: transparent;
}

.tab:focus-visible:not(.tab--disabled) {
:host:focus-visible:not([disabled]) {
color: var(--sl-color-primary-600);
}

.tab:focus-visible {
:host(:focus-visible) {
outline: var(--sl-focus-ring);
outline-offset: calc(-1 * var(--sl-focus-ring-width) - var(--sl-focus-ring-offset));
}
Expand Down
23 changes: 12 additions & 11 deletions src/components/tab/tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ describe('<sl-tab>', () => {
<sl-tab slot="nav">Test</sl-tab>
</sl-tab-group>
`);
await expect(el).to.be.accessible();

await expect(el).to.be.accessible()
});

it('should render default tab', async () => {
Expand All @@ -23,7 +24,7 @@ describe('<sl-tab>', () => {
expect(el.getAttribute('role')).to.equal('tab');
expect(el.getAttribute('aria-disabled')).to.equal('false');
expect(el.getAttribute('aria-selected')).to.equal('false');
expect(base.getAttribute('tabindex')).to.equal('0');
expect(el.getAttribute('tabindex')).to.equal('-1');
expect(base.getAttribute('class')).to.equal(' tab ');
expect(el.active).to.equal(false);
expect(el.closable).to.equal(false);
Expand All @@ -38,7 +39,7 @@ describe('<sl-tab>', () => {
expect(el.disabled).to.equal(true);
expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' tab tab--disabled ');
expect(base.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('tabindex')).to.equal('-1');
});

it('should set active tab by attribute', async () => {
Expand All @@ -49,7 +50,7 @@ describe('<sl-tab>', () => {
expect(el.active).to.equal(true);
expect(el.getAttribute('aria-selected')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' tab tab--active ');
expect(base.getAttribute('tabindex')).to.equal('0');
expect(el.getAttribute('tabindex')).to.equal('-1');
});

it('should set closable by attribute', async () => {
Expand All @@ -59,34 +60,34 @@ describe('<sl-tab>', () => {
const closeButton = el.shadowRoot!.querySelector('[part~="close-button"]');

expect(el.closable).to.equal(true);
expect(base.getAttribute('class')).to.equal(' tab tab--closable ');
expect(base.getAttribute('class')).to.match(/tab tab--closable/);
expect(closeButton).not.to.be.null;
});

describe('focus', () => {
it('should focus inner div', async () => {
it('should focus itself div', async () => {
const el = await fixture<SlTab>(html` <sl-tab>Test</sl-tab> `);

const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;

el.focus();
await el.updateComplete;

expect(el.shadowRoot!.activeElement).to.equal(base);
expect(document.activeElement).to.equal(el);
});
});

describe('blur', () => {
it('should blur inner div', async () => {
it('should blur itself', async () => {
const el = await fixture<SlTab>(html` <sl-tab>Test</sl-tab> `);

el.focus();
await el.updateComplete;

expect(document.activeElement).to.equal(el);

el.blur();
await el.updateComplete;

expect(el.shadowRoot!.activeElement).to.equal(null);
expect(document.activeElement).to.not.equal(el);
});
});

Expand Down
Loading