2021-03-08 12:51:31 +00:00
|
|
|
import { LitElement, html, internalProperty, property, query, unsafeCSS } from 'lit-element';
|
2021-03-06 17:01:39 +00:00
|
|
|
import { classMap } from 'lit-html/directives/class-map';
|
2021-03-08 12:51:31 +00:00
|
|
|
import { event, EventEmitter, tag, watch } from '../../internal/decorators';
|
2021-02-26 14:09:13 +00:00
|
|
|
import styles from 'sass:./tab-group.scss';
|
|
|
|
import { SlTab, SlTabPanel } from '../../shoelace';
|
|
|
|
import { getOffset } from '../../internal/offset';
|
|
|
|
import { scrollIntoView } from '../../internal/scroll';
|
|
|
|
import { focusVisible } from '../../internal/focus-visible';
|
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.
|
2021-01-10 17:30:27 +00:00
|
|
|
* @part scroll-button - The previous and next scroll buttons that appear when tabs are scrollable.
|
2020-07-15 21:30:37 +00:00
|
|
|
*/
|
2021-03-08 12:51:31 +00:00
|
|
|
@tag('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;
|
|
|
|
@query('.tab-group__active-tab-indicator') activeTabIndicator: 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[] = [];
|
|
|
|
|
|
|
|
@internalProperty() private hasScrollControls = false;
|
2020-08-03 11:40:02 +00:00
|
|
|
|
2020-07-15 21:30:37 +00:00
|
|
|
/** The placement of the tabs. */
|
2021-03-06 17:01:39 +00:00
|
|
|
@property() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2020-10-22 18:00:06 +00:00
|
|
|
/** 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;
|
2020-10-22 18:00:06 +00:00
|
|
|
|
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 }>;
|
|
|
|
|
|
|
|
firstUpdated() {
|
2021-02-26 14:09:13 +00:00
|
|
|
this.syncTabsAndPanels();
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2020-08-02 10:49:47 +00:00
|
|
|
// Set initial tab state when the tabs first become visible
|
|
|
|
const observer = new IntersectionObserver((entries, observer) => {
|
|
|
|
if (entries[0].intersectionRatio > 0) {
|
|
|
|
this.setAriaLabels();
|
2021-02-26 14:09:13 +00:00
|
|
|
this.setActiveTab(this.getActiveTab() || this.tabs[0], false);
|
2020-08-02 10:49:47 +00:00
|
|
|
observer.unobserve(entries[0].target);
|
|
|
|
}
|
|
|
|
});
|
2021-02-26 14:09:13 +00:00
|
|
|
observer.observe(this);
|
2020-08-02 10:49:47 +00:00
|
|
|
|
2020-07-15 21:30:37 +00:00
|
|
|
focusVisible.observe(this.tabGroup);
|
|
|
|
|
2020-10-22 18:00:06 +00:00
|
|
|
this.resizeObserver = new ResizeObserver(() => this.updateScrollControls());
|
2020-08-03 11:40:02 +00:00
|
|
|
this.resizeObserver.observe(this.nav);
|
2020-10-22 18:00:06 +00:00
|
|
|
requestAnimationFrame(() => this.updateScrollControls());
|
2020-08-03 11:40:02 +00:00
|
|
|
|
2020-07-15 21:30:37 +00:00
|
|
|
// Update aria labels if the DOM changes
|
2020-07-30 21:27:43 +00:00
|
|
|
this.mutationObserver = new MutationObserver(mutations => {
|
|
|
|
if (
|
|
|
|
mutations.some(mutation => {
|
2021-02-26 14:09:13 +00:00
|
|
|
return !['aria-labelledby', 'aria-controls'].includes(mutation.attributeName as string);
|
2020-07-30 21:27:43 +00:00
|
|
|
})
|
|
|
|
) {
|
|
|
|
setTimeout(() => this.setAriaLabels());
|
|
|
|
}
|
|
|
|
});
|
2021-02-26 14:09:13 +00:00
|
|
|
this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true });
|
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();
|
2020-08-03 11:40:02 +00:00
|
|
|
focusVisible.unobserve(this.tabGroup);
|
|
|
|
this.resizeObserver.unobserve(this.nav);
|
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) {
|
|
|
|
this.setActiveTab(tab);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
|
|
|
this.setActiveTab(tab);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
|
|
|
this.setActiveTab(tab);
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
this.tabs[index].setFocus({ preventScroll: true });
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-03 11:40:02 +00:00
|
|
|
handleScrollLeft() {
|
|
|
|
this.nav.scroll({
|
|
|
|
left: this.nav.scrollLeft - this.nav.clientWidth,
|
|
|
|
behavior: 'smooth'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
handleScrollRight() {
|
|
|
|
this.nav.scroll({
|
|
|
|
left: this.nav.scrollLeft + this.nav.clientWidth,
|
|
|
|
behavior: 'smooth'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-03-06 19:39:48 +00:00
|
|
|
@watch('noScrollControls')
|
2020-10-22 18:00:06 +00:00
|
|
|
updateScrollControls() {
|
2021-03-11 13:58:30 +00:00
|
|
|
if (this.noScrollControls) {
|
|
|
|
this.hasScrollControls = false;
|
|
|
|
} else {
|
|
|
|
this.hasScrollControls =
|
|
|
|
['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth;
|
2020-10-22 18:00:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
setActiveTab(tab: SlTab, emitEvents = true) {
|
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));
|
2020-07-15 21:30:37 +00:00
|
|
|
this.syncActiveTabIndicator();
|
|
|
|
|
|
|
|
if (['top', 'bottom'].includes(this.placement)) {
|
|
|
|
scrollIntoView(this.activeTab, this.nav, 'horizontal');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Emit events
|
|
|
|
if (emitEvents) {
|
|
|
|
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
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-03-06 19:39:48 +00:00
|
|
|
@watch('placement')
|
2020-07-15 21:30:37 +00:00
|
|
|
syncActiveTabIndicator() {
|
2021-03-08 14:08:29 +00:00
|
|
|
if (this.activeTabIndicator) {
|
|
|
|
const tab = this.getActiveTab();
|
2021-01-18 19:03:59 +00:00
|
|
|
|
2021-03-08 14:08:29 +00:00
|
|
|
if (tab) {
|
|
|
|
this.activeTabIndicator.style.display = 'block';
|
|
|
|
} else {
|
|
|
|
this.activeTabIndicator.style.display = 'none';
|
|
|
|
return;
|
|
|
|
}
|
2021-01-18 19:03:59 +00:00
|
|
|
|
2021-03-08 14:08:29 +00:00
|
|
|
const width = tab.clientWidth;
|
|
|
|
const height = tab.clientHeight;
|
|
|
|
const offset = getOffset(tab, this.nav);
|
|
|
|
const offsetTop = offset.top + this.nav.scrollTop;
|
|
|
|
const offsetLeft = offset.left + this.nav.scrollLeft;
|
|
|
|
|
|
|
|
switch (this.placement) {
|
|
|
|
case 'top':
|
|
|
|
case 'bottom':
|
|
|
|
this.activeTabIndicator.style.width = `${width}px`;
|
|
|
|
(this.activeTabIndicator.style.height as string | undefined) = undefined;
|
|
|
|
this.activeTabIndicator.style.transform = `translateX(${offsetLeft}px)`;
|
|
|
|
break;
|
|
|
|
|
|
|
|
case 'left':
|
|
|
|
case 'right':
|
|
|
|
(this.activeTabIndicator.style.width as string | undefined) = undefined;
|
|
|
|
this.activeTabIndicator.style.height = `${height}px`;
|
|
|
|
this.activeTabIndicator.style.transform = `translateY(${offsetTop}px)`;
|
|
|
|
break;
|
|
|
|
}
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
syncTabsAndPanels() {
|
|
|
|
this.tabs = this.getAllTabs();
|
|
|
|
this.panels = this.getAllPanels();
|
|
|
|
this.syncActiveTabIndicator();
|
|
|
|
}
|
|
|
|
|
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',
|
|
|
|
'tab-group--left': this.placement === 'left',
|
2020-08-03 11:40:02 +00:00
|
|
|
'tab-group--right': this.placement === 'right',
|
2020-10-22 18:00:06 +00:00
|
|
|
'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
|
|
|
>
|
2020-08-03 11:40:02 +00:00
|
|
|
<div class="tab-group__nav-container">
|
2021-02-26 14:09:13 +00:00
|
|
|
${this.hasScrollControls
|
|
|
|
? html`
|
|
|
|
<sl-icon-button
|
|
|
|
class="tab-group__scroll-button tab-group__scroll-button--left"
|
|
|
|
exportparts="base:scroll-button"
|
|
|
|
name="chevron-left"
|
2021-03-06 17:01:39 +00:00
|
|
|
@click=${this.handleScrollLeft}
|
|
|
|
></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-06 17:01:39 +00:00
|
|
|
<div part="active-tab-indicator" class="tab-group__active-tab-indicator"></div>
|
|
|
|
<slot name="nav" @slotchange=${this.syncTabsAndPanels}></slot>
|
2020-08-03 11:40:02 +00:00
|
|
|
</div>
|
2020-07-15 21:30:37 +00:00
|
|
|
</div>
|
2021-02-26 14:09:13 +00:00
|
|
|
|
|
|
|
${this.hasScrollControls
|
|
|
|
? html`
|
|
|
|
<sl-icon-button
|
|
|
|
class="tab-group__scroll-button tab-group__scroll-button--right"
|
|
|
|
exportparts="base:scroll-button"
|
|
|
|
name="chevron-right"
|
2021-03-06 17:01:39 +00:00
|
|
|
@click=${this.handleScrollRight}
|
|
|
|
></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
|
|
|
}
|
|
|
|
}
|