feat: add Tabs component with Tab, TabItem, and TabContent for improved navigation

This commit is contained in:
LOUIS POTEVIN
2026-06-04 11:36:32 +02:00
parent dd48a02e3b
commit c585ec3266
6 changed files with 178 additions and 2 deletions
+33
View File
@@ -0,0 +1,33 @@
@use '../../styles/tokens/typography' as *;
.tab {
display: flex;
align-items: flex-start;
gap: var(--nds-spacing-xs);
justify-content: center;
width: fit-content;
&__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: fit-content;
cursor: pointer;
padding: var(--nds-spacing-2xs);
@include text-base;
&--active {
color: var(--nds-primary);
&::after {
content: '';
display: block;
width: 120%;
height: 1px;
background-color: var(--nds-primary);
border-radius: var(--nds-radius-sm);
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
---
export interface Props {
id: string;
defaultActive?: string;
}
const { id, defaultActive } = Astro.props;
---
<div class="tab" data-tab-group id={id} data-default-active={defaultActive}>
<slot />
</div>
<script>
document.querySelectorAll<HTMLElement>('[data-tab-group]').forEach((group) => {
const defaultActive = group.dataset.defaultActive;
function setActive(itemId: string) {
const groupId = group.id;
group.querySelectorAll<HTMLElement>('[data-tab-item]').forEach((item) => {
item.classList.toggle('tab__item--active', item.dataset.tabItem === itemId);
item.setAttribute('aria-selected', String(item.dataset.tabItem === itemId));
});
document.querySelectorAll<HTMLElement>(`[data-tab-content][data-tab-id="${groupId}"]`).forEach((content) => {
content.hidden = content.dataset.tabItemId !== itemId;
});
group.dispatchEvent(new CustomEvent('tab:change', {
detail: { itemId },
bubbles: true,
}));
}
const firstItem = group.querySelector<HTMLElement>('[data-tab-item]');
const initialId = defaultActive ?? firstItem?.dataset.tabItem ?? '';
if (initialId) setActive(initialId);
group.addEventListener('tab-item:click', (e: Event) => {
setActive((e as CustomEvent<{ itemId: string }>).detail.itemId);
});
group.addEventListener('tab:set', (e: Event) => {
setActive((e as CustomEvent<{ itemId: string }>).detail.itemId);
});
(group as any).setActiveTab = (itemId: string) => setActive(itemId);
});
</script>
<style lang="scss">
@use './tab';
</style>
+21
View File
@@ -0,0 +1,21 @@
---
export interface Props {
id: string;
itemId: string;
}
const { id, itemId } = Astro.props;
---
<div
data-tab-content
data-tab-id={id}
data-tab-item-id={itemId}
hidden
>
<slot />
</div>
<style lang="scss">
@use './tab';
</style>
+41
View File
@@ -0,0 +1,41 @@
---
export interface Props {
id: string;
}
const { id } = Astro.props;
---
<div
class="tab__item"
data-tab-item={id}
role="tab"
aria-selected="false"
tabindex="0"
>
<slot />
</div>
<script>
document.querySelectorAll<HTMLElement>('[data-tab-item]').forEach((item) => {
function notify() {
const group = item.closest('[data-tab-group]');
if (!group) return;
group.dispatchEvent(new CustomEvent('tab-item:click', {
detail: { itemId: item.dataset.tabItem },
}));
}
item.addEventListener('click', notify);
item.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
notify();
}
});
});
</script>
<style lang="scss">
@use './tab';
</style>
+3
View File
@@ -3,3 +3,6 @@
export * from './Icons/index.ts'; export * from './Icons/index.ts';
export { default as Notification } from './Notifications/notification.astro'; export { default as Notification } from './Notifications/notification.astro';
export { default as Toggle } from './Toggle/toggle.astro'; export { default as Toggle } from './Toggle/toggle.astro';
export { default as Tab } from './Tabs/tab.astro';
export { default as TabItem } from './Tabs/tabItem.astro';
export { default as TabContent } from './Tabs/tabContent.astro';
+25 -1
View File
@@ -1,6 +1,10 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Notification from '../components/Notifications/notification.astro';
import Toggle from '../components/Toggle/toggle.astro'; import Toggle from '../components/Toggle/toggle.astro';
import Tab from '../components/Tabs/tab.astro';
import TabItem from '../components/Tabs/tabItem.astro';
import TabContent from '../components/Tabs/tabContent.astro';
import { import {
Arrow2Icon, BinIcon, BurgerIcon, CalendarIcon, CheckIcon, CloseIcon, Arrow2Icon, BinIcon, BurgerIcon, CalendarIcon, CheckIcon, CloseIcon,
CodeIcon, CubeIcon, DownloadIcon, FilterIcon, HelpIcon, HomeIcon, CodeIcon, CubeIcon, DownloadIcon, FilterIcon, HelpIcon, HomeIcon,
@@ -8,7 +12,6 @@ import {
SearchIcon, SettingsIcon, ShareIcon, ShieldIcon, SortIcon, StatsIcon, SearchIcon, SettingsIcon, ShareIcon, ShieldIcon, SortIcon, StatsIcon,
UploadIcon, UploadIcon,
} from '../components/Icons/index.ts'; } from '../components/Icons/index.ts';
import Notification from '../components/Notifications/notification.astro';
const initialChecked = true; const initialChecked = true;
--- ---
@@ -16,6 +19,27 @@ const initialChecked = true;
<Layout> <Layout>
<main> <main>
<h1>Nova Design System</h1> <h1>Nova Design System</h1>
<section>
<h2>Tabs</h2>
<div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);">
<Tab id="my-tab" defaultActive="images">
<TabItem id="images">images</TabItem>
<TabItem id="videos">Videos</TabItem>
<TabItem id="docs">Documents</TabItem>
</Tab>
<TabContent id="my-tab" itemId="images">
<p>my images</p>
</TabContent>
<TabContent id="my-tab" itemId="videos">
<p>Video player here</p>
</TabContent>
<TabContent id="my-tab" itemId="docs">
<p>Document list here</p>
</TabContent>
</div>
</section>
<section> <section>
<h2>Toggle</h2> <h2>Toggle</h2>
<div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);"> <div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);">