Skip to content

Add tab panel component #600

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

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/uui-tabs/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './uui-tab.element';
export * from './uui-tab-group.element';
export * from './uui-tab-panel.element';
export * from './UUITabEvent';
export * from './UUITabGroupEvent';
45 changes: 45 additions & 0 deletions packages/uui-tabs/lib/uui-tab-group.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class UUITabGroupElement extends LitElement {
dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical';

#tabElements: HTMLElement[] = [];
#tabPanelElements: HTMLElement[] = [];

#hiddenTabElements: UUITabElement[] = [];
#hiddenTabElementsMap: Map<UUITabElement, UUITabElement> = new Map();
Expand All @@ -50,15 +51,38 @@ export class UUITabGroupElement extends LitElement {
this.#onResize.bind(this)
);

#mutationObserver: MutationObserver = new MutationObserver(
this.#onSlotChange.bind(this)
);

connectedCallback() {
super.connectedCallback();
this.#resizeObserver.observe(this);
if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist');

this.#mutationObserver = new MutationObserver(mutations => {
// Update aria labels when the DOM changes
if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) {
setTimeout(() => this.setAriaLabels());
}

// Sync tabs when disabled states change
if (mutations.some(m => m.attributeName === 'disabled')) {
this.#syncTabsAndPanels();
}
});

// After the first update...
this.updateComplete.then(() => {
this.#syncTabsAndPanels();
this.#mutationObserver.observe(this, { attributes: true, childList: true, subtree: true });
});
}

disconnectedCallback() {
super.disconnectedCallback();
this.#resizeObserver.unobserve(this);
this.#mutationObserver.disconnect();
}

#onResize(entries: ResizeObserverEntry[]) {
Expand All @@ -71,6 +95,7 @@ export class UUITabGroupElement extends LitElement {
});

this.#setTabArray();
this.#syncTabsAndPanels();

this.#tabElements.forEach(el => {
el.addEventListener('click', this.#onTabClicked);
Expand Down Expand Up @@ -207,10 +232,30 @@ export class UUITabGroupElement extends LitElement {
this.#calculateBreakPoints();
}

// This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times.
#syncTabsAndPanels() {
this.#tabElements = this._slottedNodes ? this._slottedNodes : [];
this.#tabPanelElements = [];

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

#isElementTabLike(el: any): el is UUITabElement {
return el instanceof UUITabElement || 'active' in el;
}

private setAriaLabels() {
// Link each tab with its corresponding panel
this.#tabElements.forEach(tab => {
const panel = this.#tabPanelElements.find(el => el.getAttribute("name") === tab.getAttribute("panel"));
if (panel) {
tab.setAttribute('aria-controls', panel.getAttribute('id')!);
panel.setAttribute('aria-labelledby', tab.getAttribute('id')!);
}
});
}

render() {
return html`
<slot @slotchange=${this.#onSlotChange}></slot>
Expand Down
88 changes: 88 additions & 0 deletions packages/uui-tabs/lib/uui-tab-panel.element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
import { classMap } from 'lit/directives/class-map.js';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';

let id = 0;

/**
* A single tab panel. Should be put into `<uui-tab-group>`,
* @element uui-tab-panel
* @slot - Default slot for the tab panel
* @cssprop --uui-tab-panel-padding - Define the tab panel padding
*/
@defineElement('uui-tab-panel')
export class UUITabPanelElement extends LitElement {
static styles = [
css`
:host {
--uui-tab-panel-padding: 1rem 0;

display: none;
width: 100%;
}

:host([active]) {
display: block;
}

.tab-panel {
display: block;
padding: var(--uui-tab-panel-padding);
}
`,
];

private readonly attrId = ++id;
private readonly componentId = `uui-tab-panel-${this.attrId}`;

/**
* The tab panel's name.
*/
@property({ reflect: true }) name = '';

/**
* When true, the tab panel will be shown.
* @type {Boolean}
* @attr
* @default false
*/
@property({ type: Boolean, reflect: true }) active = false;

/*#resizeObserver: ResizeObserver = new ResizeObserver(
this.#onResize.bind(this)
);*/

connectedCallback() {
super.connectedCallback();
//this.#resizeObserver.observe(this);
this.id = this.id.length > 0 ? this.id : this.componentId;
if (!this.hasAttribute('role')) this.setAttribute('role', 'tabpanel');
}

disconnectedCallback() {
super.disconnectedCallback();
//this.#resizeObserver.unobserve(this);
}

handleActiveChange() {
this.setAttribute('aria-hidden', this.active ? 'false' : 'true');
}

render() {
return html`
<slot
part="base"
class=${classMap({
'tab-panel': true,
'tab-panel--active': this.active
})}
></slot>`;
}
}

declare global {
interface HTMLElementTagNameMap {
'uui-tab-panel': UUITabPanelElement;
}
}
19 changes: 19 additions & 0 deletions packages/uui-tabs/lib/uui-tab.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';

let id = 0;

/**
* A single tab. Should be put into `<uui-tab-group>`,
* @element uui-tabs
Expand All @@ -20,6 +22,19 @@ import { ifDefined } from 'lit/directives/if-defined.js';
*/
@defineElement('uui-tab')
export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) {

private readonly attrId = ++id;
private readonly componentId = `uui-tab-${this.attrId}`;

/**
* Reflects the name of the tab panel this tab is associated with. The panel must be located in the same tab group.
* @type {string}
* @attr
* @default false
*/
@property({ type: String, reflect: true })
public panel: string = '';

/**
* Reflects the disabled state of the element. True if tab is disabled. Change this to switch the state programmatically.
* @type {boolean}
Expand Down Expand Up @@ -69,6 +84,10 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) {
}

render() {

// If the user didn't provide an ID, we'll set one so we can link tabs and tab panels with aria labels
this.id = this.id.length > 0 ? this.id : this.componentId;

return this.href
? html`
<a
Expand Down
15 changes: 15 additions & 0 deletions packages/uui-tabs/lib/uui-tabs.story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,18 @@ WithIcons.parameters = {
},
},
};

export const WithPanels: Story = props => html`
<h3>Tabs with Panels</h3>
<uui-tab-group>
<uui-tab panel="general" active>General</uui-tab>
<uui-tab panel="custom">Custom</uui-tab>
<uui-tab panel="advanced">Advanced</uui-tab>
<uui-tab panel="settings" disabled>Settings</uui-tab>

<uui-tab-panel name="general" active>This is the general tab panel.</uui-tab-panel>
<uui-tab-panel name="custom">This is the custom tab panel.</uui-tab-panel>
<uui-tab-panel name="advanced">This is the advanced tab panel.</uui-tab-panel>
<uui-tab-panel name="settings">This is a disabled tab panel.</uui-tab-panel>
</uui-tab-group>
`;