shoelace/src/components/tab-group/tab-group.ts

399 wiersze
12 KiB
TypeScript
Czysty Zwykły widok Historia

import { LitElement, html, unsafeCSS } from 'lit';
2021-05-27 21:00:43 +00:00
import { customElement, property, query, state } from 'lit/decorators.js';
2021-03-06 17:01:39 +00:00
import { classMap } from 'lit-html/directives/class-map';
2021-03-18 13:04:23 +00:00
import { event, EventEmitter, watch } from '../../internal/decorators';
2021-03-11 14:20:54 +00:00
import { focusVisible } from '../../internal/focus-visible';
2021-02-26 14:09:13 +00:00
import { getOffset } from '../../internal/offset';
import { scrollIntoView } from '../../internal/scroll';
2021-05-27 20:29:10 +00:00
import type SlTab from '../tab/tab';
import type SlTabPanel from '../tab-panel/tab-panel';
2021-05-03 19:08:17 +00:00
import styles from 'sass:./tab-group.scss';
2020-07-15 21:30:37 +00:00
/**
2020-07-17 10:09:10 +00:00
* @since 2.0
2020-07-15 21:30:37 +00:00
* @status stable
*
2021-02-26 14:09:13 +00:00
* @dependency sl-icon-button
*
2020-07-15 21:30:37 +00:00
* @slot nav - Used for grouping tabs in the tab group.
* @slot - Used for grouping tab panels in the tab group.
*
* @part base - The component's base wrapper.
* @part nav - The tab group navigation container.
* @part tabs - The container that wraps the slotted tabs.
* @part active-tab-indicator - An element that displays the currently selected tab. This is a child of the tabs container.
* @part body - The tab group body where tab panels are slotted in.
* @part scroll-button - The previous and next scroll buttons that appear when tabs are scrollable.
*
* @customProperty --tabs-border-color - The color of the border that separates tabs.
2020-07-15 21:30:37 +00:00
*/
2021-03-18 13:04:23 +00:00
@customElement('sl-tab-group')
2021-03-09 00:14:32 +00:00
export default class SlTabGroup extends LitElement {
2021-03-06 17:01:39 +00:00
static styles = unsafeCSS(styles);
@query('.tab-group') tabGroup: HTMLElement;
@query('.tab-group__body') body: HTMLElement;
@query('.tab-group__nav') nav: HTMLElement;
2021-03-11 14:20:54 +00:00
@query('.tab-group__indicator') indicator: HTMLElement;
2021-02-26 14:09:13 +00:00
private activeTab: SlTab;
private mutationObserver: MutationObserver;
private resizeObserver: ResizeObserver;
private tabs: SlTab[] = [];
2021-03-06 17:01:39 +00:00
private panels: SlTabPanel[] = [];
@state() private hasScrollControls = false;
2020-07-15 21:30:37 +00:00
/** The placement of the tabs. */
2021-05-26 11:32:51 +00:00
@property() placement: 'top' | 'bottom' | 'start' | 'end' = 'top';
2020-07-15 21:30:37 +00:00
2021-04-05 20:58:33 +00:00
/**
* When set to auto, navigating tabs with the arrow keys will instantly show the corresponding tab panel. When set to
* manual, the tab will receive focus but will not show until the user presses spacebar or enter.
*/
@property() activation: 'auto' | 'manual' = 'auto';
/** Disables the scroll arrows that appear when tabs overflow. */
2021-03-06 20:34:33 +00:00
@property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false;
2021-03-06 17:01:39 +00:00
/** Emitted when a tab is shown. */
@event('sl-tab-show') slTabShow: EventEmitter<{ tab: string }>;
/** Emitted when a tab is hidden. */
@event('sl-tab-hide') slTabHide: EventEmitter<{ tab: string }>;
2021-06-02 12:47:55 +00:00
connectedCallback() {
super.connectedCallback();
2020-07-15 21:30:37 +00:00
this.resizeObserver = new ResizeObserver(() => {
2021-03-11 14:20:54 +00:00
this.preventIndicatorTransition();
this.repositionIndicator();
this.updateScrollControls();
});
this.mutationObserver = new MutationObserver(mutations => {
2021-04-07 21:05:38 +00:00
// Update aria labels when the DOM changes
2021-06-02 12:47:55 +00:00
if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName as string))) {
setTimeout(() => this.setAriaLabels());
}
2021-04-07 21:05:38 +00:00
// Sync tabs when disabled states change
2021-06-02 12:47:55 +00:00
if (mutations.some(m => m.attributeName === 'disabled')) {
2021-04-07 21:05:38 +00:00
this.syncTabsAndPanels();
}
});
2021-06-02 12:47:55 +00:00
this.updateComplete.then(() => {
this.syncTabsAndPanels();
this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true });
this.resizeObserver.observe(this.nav);
focusVisible.observe(this.tabGroup);
// Set initial tab state when the tabs first become visible
const intersectionObserver = new IntersectionObserver((entries, observer) => {
if (entries[0].intersectionRatio > 0) {
this.setAriaLabels();
this.setActiveTab(this.getActiveTab() || this.tabs[0], { emitEvents: false });
observer.unobserve(entries[0].target);
}
});
intersectionObserver.observe(this.tabGroup);
});
2020-07-15 21:30:37 +00:00
}
2021-03-06 17:01:39 +00:00
disconnectedCallback() {
2020-07-15 21:30:37 +00:00
this.mutationObserver.disconnect();
this.resizeObserver.unobserve(this.nav);
2021-06-02 12:47:55 +00:00
focusVisible.unobserve(this.tabGroup);
2020-07-15 21:30:37 +00:00
}
/** Shows the specified tab panel. */
2021-02-26 14:09:13 +00:00
show(panel: string) {
const tab = this.tabs.find(el => el.panel === panel) as SlTab;
2020-07-15 21:30:37 +00:00
if (tab) {
2021-06-02 12:47:55 +00:00
this.setActiveTab(tab, { scrollBehavior: 'smooth' });
2020-07-15 21:30:37 +00:00
}
}
getAllTabs(includeDisabled = false) {
2021-02-26 14:09:13 +00:00
const slot = this.shadowRoot!.querySelector('slot[name="nav"]') as HTMLSlotElement;
2021-01-05 13:41:56 +00:00
2020-07-15 21:30:37 +00:00
return [...slot.assignedElements()].filter((el: any) => {
return includeDisabled
? el.tagName.toLowerCase() === 'sl-tab'
: el.tagName.toLowerCase() === 'sl-tab' && !el.disabled;
2021-02-26 14:09:13 +00:00
}) as SlTab[];
2020-07-15 21:30:37 +00:00
}
getAllPanels() {
2021-02-26 14:09:13 +00:00
const slot = this.body.querySelector('slot')!;
2020-07-15 21:30:37 +00:00
return [...slot.assignedElements()].filter((el: any) => el.tagName.toLowerCase() === 'sl-tab-panel') as [
2021-02-26 14:09:13 +00:00
SlTabPanel
2020-07-15 21:30:37 +00:00
];
}
getActiveTab() {
2021-02-26 14:09:13 +00:00
return this.tabs.find(el => el.active);
2020-07-15 21:30:37 +00:00
}
2020-08-03 11:40:53 +00:00
handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
2021-02-26 14:09:13 +00:00
const tab = target.closest('sl-tab') as SlTab;
2021-01-18 18:34:41 +00:00
const tabGroup = tab?.closest('sl-tab-group');
2021-01-05 13:41:56 +00:00
// Ensure the target tab is in this tab group
2021-02-26 14:09:13 +00:00
if (tabGroup !== this) {
return;
2021-01-05 13:41:56 +00:00
}
2020-08-03 11:40:53 +00:00
if (tab) {
2021-06-02 12:47:55 +00:00
this.setActiveTab(tab, { scrollBehavior: 'smooth' });
2020-08-03 11:40:53 +00:00
}
}
handleKeyDown(event: KeyboardEvent) {
2021-01-05 13:41:56 +00:00
const target = event.target as HTMLElement;
2021-02-26 14:09:13 +00:00
const tab = target.closest('sl-tab') as SlTab;
2021-01-18 18:34:41 +00:00
const tabGroup = tab?.closest('sl-tab-group');
2021-01-05 13:41:56 +00:00
// Ensure the target tab is in this tab group
2021-02-26 14:09:13 +00:00
if (tabGroup !== this) {
return;
2021-01-05 13:41:56 +00:00
}
2020-08-03 11:40:53 +00:00
// Activate a tab
if (['Enter', ' '].includes(event.key)) {
if (tab) {
2021-06-02 12:47:55 +00:00
this.setActiveTab(tab, { scrollBehavior: 'smooth' });
2020-08-03 11:40:53 +00:00
event.preventDefault();
}
}
// Move focus left or right
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const activeEl = document.activeElement as any;
if (activeEl && activeEl.tagName.toLowerCase() === 'sl-tab') {
2021-02-26 14:09:13 +00:00
let index = this.tabs.indexOf(activeEl);
2020-08-03 11:40:53 +00:00
if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
2021-02-26 14:09:13 +00:00
index = this.tabs.length - 1;
2020-08-03 11:40:53 +00:00
} else if (event.key === 'ArrowLeft') {
index = Math.max(0, index - 1);
} else if (event.key === 'ArrowRight') {
2021-02-26 14:09:13 +00:00
index = Math.min(this.tabs.length - 1, index + 1);
2020-08-03 11:40:53 +00:00
}
this.tabs[index].focus({ preventScroll: true });
2020-08-03 11:40:53 +00:00
2021-04-05 20:58:33 +00:00
if (this.activation === 'auto') {
2021-06-02 12:47:55 +00:00
this.setActiveTab(this.tabs[index], { scrollBehavior: 'smooth' });
2021-04-05 20:58:33 +00:00
}
2020-08-03 11:40:53 +00:00
if (['top', 'bottom'].includes(this.placement)) {
2021-02-26 14:09:13 +00:00
scrollIntoView(this.tabs[index], this.nav, 'horizontal');
2020-08-03 11:40:53 +00:00
}
event.preventDefault();
}
}
}
2021-06-01 12:21:10 +00:00
handleScrollToStart() {
this.nav.scroll({
left: this.nav.scrollLeft - this.nav.clientWidth,
behavior: 'smooth'
});
}
2021-06-01 12:21:10 +00:00
handleScrollToEnd() {
this.nav.scroll({
left: this.nav.scrollLeft + this.nav.clientWidth,
behavior: 'smooth'
});
}
@watch('noScrollControls')
updateScrollControls() {
if (this.nav) {
if (this.noScrollControls) {
this.hasScrollControls = false;
} else {
this.hasScrollControls =
['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth;
}
}
}
2021-06-02 12:47:55 +00:00
setActiveTab(tab: SlTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) {
options = Object.assign(
{
emitEvents: true,
scrollBehavior: 'auto'
},
options
);
2020-07-15 21:30:37 +00:00
if (tab && tab !== this.activeTab && !tab.disabled) {
const previousTab = this.activeTab;
this.activeTab = tab;
2021-02-26 14:09:13 +00:00
// Sync active tab and panel
this.tabs.map(el => (el.active = el === this.activeTab));
this.panels.map(el => (el.active = el.name === this.activeTab.panel));
2021-03-11 14:20:54 +00:00
this.syncIndicator();
2020-07-15 21:30:37 +00:00
if (['top', 'bottom'].includes(this.placement)) {
2021-06-02 12:47:55 +00:00
scrollIntoView(this.activeTab, this.nav, 'horizontal', options.scrollBehavior);
2020-07-15 21:30:37 +00:00
}
// Emit events
2021-06-02 12:47:55 +00:00
if (options.emitEvents) {
2020-07-15 21:30:37 +00:00
if (previousTab) {
2021-03-06 17:01:39 +00:00
this.slTabHide.emit({ detail: { name: previousTab.panel } });
2020-07-15 21:30:37 +00:00
}
2021-03-06 17:01:39 +00:00
this.slTabShow.emit({ detail: { name: this.activeTab.panel } });
2020-07-15 21:30:37 +00:00
}
}
}
setAriaLabels() {
// Link each tab with its corresponding panel
2021-02-26 14:09:13 +00:00
this.tabs.map(tab => {
const panel = this.panels.find(el => el.name === tab.panel) as SlTabPanel;
2020-07-15 21:30:37 +00:00
if (panel) {
2021-02-26 14:09:13 +00:00
tab.setAttribute('aria-controls', panel.getAttribute('id') as string);
panel.setAttribute('aria-labelledby', tab.getAttribute('id') as string);
2020-07-15 21:30:37 +00:00
}
});
}
@watch('placement')
2021-03-11 14:20:54 +00:00
syncIndicator() {
if (this.indicator) {
2021-03-08 14:08:29 +00:00
const tab = this.getActiveTab();
2021-01-18 19:03:59 +00:00
2021-03-08 14:08:29 +00:00
if (tab) {
2021-03-11 14:20:54 +00:00
this.indicator.style.display = 'block';
this.repositionIndicator();
2021-03-08 14:08:29 +00:00
} else {
2021-03-11 14:20:54 +00:00
this.indicator.style.display = 'none';
2021-03-08 14:08:29 +00:00
return;
}
}
}
2021-03-11 14:20:54 +00:00
repositionIndicator() {
const currentTab = this.getActiveTab();
2021-03-11 14:20:54 +00:00
if (!currentTab) {
return;
2020-07-15 21:30:37 +00:00
}
const width = currentTab.clientWidth;
const height = currentTab.clientHeight;
const offset = getOffset(currentTab, this.nav);
const offsetTop = offset.top + this.nav.scrollTop;
const offsetLeft = offset.left + this.nav.scrollLeft;
switch (this.placement) {
case 'top':
case 'bottom':
2021-03-11 14:20:54 +00:00
this.indicator.style.width = `${width}px`;
2021-03-22 21:26:56 +00:00
this.indicator.style.height = 'auto';
2021-03-11 14:20:54 +00:00
this.indicator.style.transform = `translateX(${offsetLeft}px)`;
break;
2021-05-26 11:32:51 +00:00
case 'start':
case 'end':
2021-03-22 21:26:56 +00:00
this.indicator.style.width = 'auto';
2021-03-11 14:20:54 +00:00
this.indicator.style.height = `${height}px`;
this.indicator.style.transform = `translateY(${offsetTop}px)`;
break;
}
}
2021-03-11 14:20:54 +00:00
// In some orientations, when the component is resized, the indicator's position will change causing it to animate
// while you resize. Calling this method will prevent the transition from running on resize, which feels more natural.
preventIndicatorTransition() {
const transitionValue = this.indicator.style.transition;
this.indicator.style.transition = 'none';
requestAnimationFrame(() => {
2021-03-11 14:20:54 +00:00
this.indicator.style.transition = transitionValue;
});
2020-07-15 21:30:37 +00:00
}
2021-04-07 21:05:38 +00:00
// This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times.
2021-02-26 14:09:13 +00:00
syncTabsAndPanels() {
this.tabs = this.getAllTabs();
this.panels = this.getAllPanels();
2021-03-11 14:20:54 +00:00
this.syncIndicator();
2021-02-26 14:09:13 +00:00
}
2020-07-15 21:30:37 +00:00
render() {
2021-02-26 14:09:13 +00:00
return html`
2020-07-15 21:30:37 +00:00
<div
part="base"
2021-02-26 14:09:13 +00:00
class=${classMap({
2020-07-15 21:30:37 +00:00
'tab-group': true,
'tab-group--top': this.placement === 'top',
'tab-group--bottom': this.placement === 'bottom',
2021-05-26 11:32:51 +00:00
'tab-group--start': this.placement === 'start',
'tab-group--end': this.placement === 'end',
'tab-group--has-scroll-controls': this.hasScrollControls
2021-02-26 14:09:13 +00:00
})}
2021-03-06 17:01:39 +00:00
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
2020-07-15 21:30:37 +00:00
>
<div class="tab-group__nav-container">
2021-02-26 14:09:13 +00:00
${this.hasScrollControls
? html`
<sl-icon-button
2021-06-01 12:21:10 +00:00
class="tab-group__scroll-button tab-group__scroll-button--start"
2021-02-26 14:09:13 +00:00
exportparts="base:scroll-button"
name="chevron-left"
library="system"
2021-06-01 12:21:10 +00:00
@click=${this.handleScrollToStart}
2021-03-06 17:01:39 +00:00
></sl-icon-button>
2021-02-26 14:09:13 +00:00
`
: ''}
2021-03-06 17:01:39 +00:00
<div part="nav" class="tab-group__nav">
2021-02-26 14:09:13 +00:00
<div part="tabs" class="tab-group__tabs" role="tablist">
2021-03-11 14:20:54 +00:00
<div part="active-tab-indicator" class="tab-group__indicator"></div>
2021-03-06 17:01:39 +00:00
<slot name="nav" @slotchange=${this.syncTabsAndPanels}></slot>
</div>
2020-07-15 21:30:37 +00:00
</div>
2021-02-26 14:09:13 +00:00
${this.hasScrollControls
? html`
<sl-icon-button
2021-06-01 12:21:10 +00:00
class="tab-group__scroll-button tab-group__scroll-button--end"
2021-02-26 14:09:13 +00:00
exportparts="base:scroll-button"
name="chevron-right"
library="system"
2021-06-01 12:21:10 +00:00
@click=${this.handleScrollToEnd}
2021-03-06 17:01:39 +00:00
></sl-icon-button>
2021-02-26 14:09:13 +00:00
`
: ''}
2020-07-15 21:30:37 +00:00
</div>
2021-03-06 17:01:39 +00:00
<div part="body" class="tab-group__body">
<slot @slotchange=${this.syncTabsAndPanels}></slot>
2020-07-15 21:30:37 +00:00
</div>
</div>
2021-02-26 14:09:13 +00:00
`;
2020-07-15 21:30:37 +00:00
}
}
2021-03-12 14:09:08 +00:00
declare global {
interface HTMLElementTagNameMap {
'sl-tab-group': SlTabGroup;
}
}