Skip to content

Commit

Permalink
feat(tabs): implement tabs using slots
Browse files Browse the repository at this point in the history
  • Loading branch information
eTallang committed Dec 4, 2024
1 parent 1e802e7 commit 407c901
Show file tree
Hide file tree
Showing 10 changed files with 132 additions and 55 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions dist/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"url": "git+https://github.com/computas/designsystem.git"
},
"dependencies": {
"@lit/context": "^1.1.3",
"@lit/react": "^1.0.6",
"lit": "^3.2.1"
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"create-icon-registry": "bun --filter '@computas/designsystem-icon' create-icon-registry"
},
"dependencies": {
"@lit/context": "^1.1.3",
"@lit/react": "^1.0.6",
"lit": "^3.2.1"
},
Expand Down
1 change: 1 addition & 0 deletions src/components/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './tab';
export * from './tab-content';
export * from './tab-group';
8 changes: 7 additions & 1 deletion src/components/tabs/react.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createComponent } from '@lit/react';
import * as React from 'react';

import { Tab, TabGroup } from './index';
import { Tab, TabContent, TabGroup } from './index';

export const CxTabGroup = createComponent({
tagName: 'cx-tab-group',
Expand All @@ -14,3 +14,9 @@ export const CxTab = createComponent({
elementClass: Tab,
react: React,
});

export const CxTabContent = createComponent({
tagName: 'cx-tab-content',
elementClass: TabContent,
react: React,
});
49 changes: 49 additions & 0 deletions src/components/tabs/tab-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { consume } from '@lit/context';
import { LitElement, css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { activeIndexContext } from './tab-context';

@customElement('cx-tab-content')
export class TabContent extends LitElement {
static styles = css`
div {
transition: opacity 300ms ease, translate 700ms var(--ease-spring-5), visibility 300ms;
}
.tab-content-before {
visibility: hidden;
opacity: 0;
translate: -10px 0;
}
.tab-content-after {
visibility: hidden;
opacity: 0;
translate: 10px 0;
}
`;

@consume({ context: activeIndexContext, subscribe: true })
activeTabIndex!: number;

@state()
index = -1;

render() {
return html`
<div class=${classMap({
'tab-content-before': this.index < this.activeTabIndex,
'tab-content-after': this.index > this.activeTabIndex,
})}>
<slot></slot>
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'cx-tab-content': TabContent;
}
}
3 changes: 3 additions & 0 deletions src/components/tabs/tab-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from '@lit/context';

export const activeIndexContext = createContext<number>('activeIndexContext');
67 changes: 24 additions & 43 deletions src/components/tabs/tab-group.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { LitElement, css, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';

import { provide } from '@lit/context';
import a11yStyles from '../../global-css/a11y.css?inline';
import type { Tab } from './tab';
import type { TabContent } from './tab-content';
import { activeIndexContext } from './tab-context';
import tabStyles from './tab-link.css?inline';

@customElement('cx-tab-group')
Expand All @@ -15,11 +19,6 @@ export class TabGroup extends LitElement {
display: block;
}
header {
display: flex;
gap: var(---cx-spacing-1);
}
.cx-tab-link {
cursor: pointer;
}
Expand All @@ -28,26 +27,10 @@ export class TabGroup extends LitElement {
display: grid;
padding-block: var(--cx-spacing-2);
> * {
&::slotted(*) {
grid-area: 1 / 1;
}
}
.tab-content {
transition: opacity 300ms ease, translate 700ms var(--ease-spring-5), visibility 300ms;
}
.tab-content.before {
visibility: hidden;
opacity: 0;
translate: -10px 0;
}
.tab-content.after {
visibility: hidden;
opacity: 0;
translate: 10px 0;
}
`,
];

Expand All @@ -56,14 +39,23 @@ export class TabGroup extends LitElement {
* @default 0
*/
@property({ type: Number, reflect: true })
@provide({ context: activeIndexContext })
activeTabIndex = 0;

@state()
private tabs: Tab[] = [];
private tabHeaders: Tab[] = [];

private registerTabNames(e: Event) {
const slot = e.target as HTMLSlotElement;
this.tabHeaders = slot.assignedElements({ flatten: true }) as Tab[];
}

private onSlotChange(e: Event) {
private setTabContentIndexes(e: Event) {
const slot = e.target as HTMLSlotElement;
this.tabs = slot.assignedElements({ flatten: true }) as Tab[];
const tabContent = slot.assignedElements({ flatten: true }) as TabContent[];
tabContent.forEach((tabContent, index) => {
tabContent.index = index;
});
}

private setActiveTabIndex(newTabIndex: number) {
Expand All @@ -74,8 +66,8 @@ export class TabGroup extends LitElement {
return html`
<section class="tabs-container">
<header role="tablist">
${this.tabs.map(
(tab, index) => html`
${this.tabHeaders.map(
(tabHeader, index) => html`
<label
class=${classMap({ 'cx-tab-link': true, 'cx-outline-on-focus-within': true, 'cx-tab-link--active': this.activeTabIndex === index })}
>
Expand All @@ -89,33 +81,22 @@ export class TabGroup extends LitElement {
aria-selected=${this.activeTabIndex === index}
@click=${() => this.setActiveTabIndex(index)}
?checked=${this.activeTabIndex === index} />
${tab.header}
</label>
`,
${tabHeader.headerContent}
</label>
`,
)}
<slot name="header" @slotchange=${this.registerTabNames}></slot>
</header>
<div
class="content-container"
role="tabpanel"
id=${`tabpanel-${this.activeTabIndex}`}
aria-labelledby=${`tab-${this.activeTabIndex}`}
>
${this.tabs.map((tab, index) => {
return html`
<div class=${classMap({
'tab-content': true,
before: index < this.activeTabIndex,
after: index > this.activeTabIndex,
})}>
${tab.content}
</div>
`;
})}
<slot class="content-container" @slotchange=${this.setTabContentIndexes}></slot>
</div>
</section>
<slot @slotchange=${this.onSlotChange}></slot>
`;
}
}
Expand Down
14 changes: 9 additions & 5 deletions src/components/tabs/tab.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('cx-tab')
export class Tab extends LitElement {
Expand All @@ -12,14 +12,18 @@ export class Tab extends LitElement {
/**
* @description Provides the tab header
*/
@property()
header = '';
@property({ type: String, reflect: true })
forContent = '';

content: Element[] = [];
@state()
headerContent = '';

private onSlotChange(e: Event) {
const slot = e.target as HTMLSlotElement;
this.content = slot.assignedElements({ flatten: true }).map((e) => e.cloneNode(true) as Element);
this.headerContent = slot
.assignedNodes({ flatten: true })
.map((node) => node.textContent)
.join(', ');
}

render() {
Expand Down
43 changes: 37 additions & 6 deletions src/components/tabs/tabs.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';

import './tab';
import './tab-content';
import './tab-group';

export default {
Expand All @@ -27,16 +28,19 @@ export const TabLink: StoryObj = {
export const WebComponent: StoryObj = {
render: () => html`
<cx-tab-group>
<cx-tab header="General">
<cx-tab slot="header" forContent="general">General</cx-tab>
<cx-tab-content name="general">
<h2 class="cx-headline-jumbo">Heading</h2>
<p>This is the content of the first tab</p>
</cx-tab>
</cx-tab-content>
<cx-tab header="Advanced settings">
<cx-tab slot="header" forContent="advanced">Advanced settings</cx-tab>
<cx-tab-content name="advanced">
<button class="cx-btn__primary">This is the content of the second tab</button>
</cx-tab>
</cx-tab-content>
<cx-tab header="Form submission">
<cx-tab slot="header" forContent="form">Form submission</cx-tab>
<cx-tab-content name="form">
<label class="cx-form-field">
<div class="cx-form-field__label">E-mail</div>
<div class="cx-form-field__input-container">
Expand All @@ -46,7 +50,34 @@ export const WebComponent: StoryObj = {
Please provide a valid e-mail
</div>
</label>
</cx-tab>
</cx-tab-content>
</cx-tab-group>
<cx-tab-group>
<cx-tab slot="header" forContent="general">General</cx-tab>
<cx-tab-content name="general">
<h2 class="cx-headline-jumbo">Heading</h2>
<p>This is the content of the first tab</p>
</cx-tab-content>
<cx-tab slot="header" forContent="advanced">Advanced settings</cx-tab>
<cx-tab-content name="advanced">
<button class="cx-btn__primary">This is the content of the second tab</button>
</cx-tab-content>
<cx-tab slot="header" forContent="form">Form submission</cx-tab>
<cx-tab-content name="form">
<label class="cx-form-field">
<div class="cx-form-field__label">E-mail</div>
<div class="cx-form-field__input-container">
<input aria-describedby="error-text" type="email" />
</div>
<div class="cx-form-field__error" aria-live="polite" id="error-text">
Please provide a valid e-mail
</div>
</label>
</cx-tab-content>
</cx-tab-group>
`,
};

0 comments on commit 407c901

Please sign in to comment.