Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/lib/holocene/tabs-primitive/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext } from 'svelte';

export interface TabsContextType<T extends string = string> {
selectedTab: T;
tabs: T[];
getButtonIdForTab: (tab: string) => string;
getPanelIdForTab: (tab: string) => string;
setSelectedTab: (tab: string) => void;
}

const [getCtx, setCtx] = createContext<TabsContextType>();

export function getTabsContext<T extends string>(): TabsContextType<T> {
return getCtx() as TabsContextType<T>;
}

export function setTabsContext<T extends string>(
ctx: TabsContextType<T>,
): void {
setCtx(ctx);
}
192 changes: 192 additions & 0 deletions src/lib/holocene/tabs-primitive/tab-button-list.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<script lang="ts" generics="T extends string">
import type { HTMLAttributes } from 'svelte/elements';

import type { Snippet } from 'svelte';

import { composeEventHandlers } from '$lib/utilities/event-handlers';

import { getTabsContext } from './context';

type Orientation = 'horizontal' | 'vertical';
type Activation = 'automatic' | 'manual';

type TabButtonProps = HTMLAttributes<HTMLElement> & { class?: string };
type GetTabButtonProps = (overrides?: TabButtonProps) => TabButtonProps;

interface Props extends Omit<
HTMLAttributes<HTMLDivElement>,
'role' | 'children' | 'aria-orientation' | 'onkeydown' | 'onfocusout'
> {
'aria-label': string;
orientation?: Orientation;
activation?: Activation;
/**
* Renders each tab. Spread the first arg (the prop-getter) onto the
* element of your choice. A `<button>` or `<a>` is recommended for native
* semantics, but non-interactive elements (e.g. `div`/`span`) are also
* supported: the prop-getter applies the correct ARIA roles and roving
* tabindex, and wires Enter/Space keyboard activation for them.
*/
tabButtonSnippet: Snippet<
[
GetTabButtonProps,
{ tab: T; isSelected: boolean; setSelectedTab: (tab: T) => void },
]
>;
}

const {
tabButtonSnippet,
orientation = 'horizontal',
activation = 'automatic',
...props
}: Props = $props();

const context = getTabsContext<T>();

let listEl = $state<HTMLDivElement>();
let focusedTab = $state<T>();

const activeTab = $derived(focusedTab ?? context.selectedTab);

function getTabButtonProps(
tab: T,
isSelected: boolean,
overrides: TabButtonProps = {},
): TabButtonProps {
const {
class: className,
onclick,
onfocus,
onkeydown,
...rest
} = overrides;
return {
...rest,
id: context.getButtonIdForTab(tab),
role: 'tab',
'aria-selected': isSelected,
'aria-controls': context.getPanelIdForTab(tab),
tabindex: tab === activeTab ? 0 : -1,
class: className,
onkeydown: composeEventHandlers(
onkeydown,
(e: KeyboardEvent & { currentTarget: HTMLElement }) => {
const element = e.currentTarget;

if (
element instanceof HTMLAnchorElement ||
element instanceof HTMLButtonElement
) {
// if <a> or <button> let click handler handle
return;
}

if (isDisabled(element)) {
return;
}

if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
context.setSelectedTab(tab);
}
},
),
onclick: composeEventHandlers(
onclick,
(e: MouseEvent & { currentTarget: HTMLElement }) => {
const element = e.currentTarget;

if (isDisabled(element)) {
return;
}

context.setSelectedTab(tab);
},
),
onfocus: composeEventHandlers(onfocus, () => (focusedTab = tab)),
};
}

function tabElements(): HTMLElement[] {
return Array.from(
listEl?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [],
);
}

function isDisabled(element: HTMLElement): boolean {
return (
element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true'
);
}

function onkeydown(event: KeyboardEvent) {
const elements = tabElements();
const count = elements.length;
if (count === 0) return;

const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

let from = elements.findIndex(
(element) => element === document.activeElement,
);
let step: number;
switch (event.key) {
case nextKey:
step = 1;
break;
case prevKey:
step = -1;
break;
case 'Home':
from = -1;
step = 1;
break;
case 'End':
from = count;
step = -1;
break;
default:
return;
}

event.preventDefault();

let next = from;
for (let i = 0; i < count; i += 1) {
next = (next + step + count) % count;
if (!isDisabled(elements[next])) break;
}
if (isDisabled(elements[next])) return;

elements[next].focus();
if (activation === 'automatic') {
context.setSelectedTab(context.tabs[next]);
}
}

function onfocusout(event: FocusEvent) {
if (!listEl?.contains(event.relatedTarget as Node | null)) {
focusedTab = undefined;
}
}
</script>

<div
bind:this={listEl}
{...props}
role="tablist"
aria-orientation={orientation}
{onkeydown}
{onfocusout}
>
{#each context.tabs as tab (tab)}
{@const isSelected = context.selectedTab === tab}
{@render tabButtonSnippet?.(
(overrides) => getTabButtonProps(tab, isSelected, overrides),
{ tab, isSelected, setSelectedTab: context.setSelectedTab },
)}
{/each}
</div>
47 changes: 47 additions & 0 deletions src/lib/holocene/tabs-primitive/tab-panels.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts" generics="T extends string">
import type { HTMLAttributes } from 'svelte/elements';

import type { Snippet } from 'svelte';

import { getTabsContext } from './context';

type TabPanelProps = HTMLAttributes<HTMLElement>;
type GetTabPanelProps = (overrides?: TabPanelProps) => TabPanelProps;

interface Props {
tabPanelSnippet: Snippet<
[
GetTabPanelProps,
{ tab: T; isSelected: boolean; setSelectedTab: (tab: T) => void },
]
>;
}

const { tabPanelSnippet }: Props = $props();
const context = getTabsContext<T>();

function getTabPanelProps(
tab: T,
isSelected: boolean,
overrides: TabPanelProps = {},
): TabPanelProps {
const { class: className, ...rest } = overrides;
return {
...rest,
id: context.getPanelIdForTab(tab),
role: 'tabpanel',
'aria-labelledby': context.getButtonIdForTab(tab),
tabindex: 0,
hidden: !isSelected,
class: className,
};
}
</script>

{#each context.tabs as tab (tab)}
{@const isSelected = context.selectedTab === tab}
{@render tabPanelSnippet?.(
(overrides) => getTabPanelProps(tab, isSelected, overrides),
{ tab, isSelected, setSelectedTab: context.setSelectedTab },
)}
{/each}
84 changes: 84 additions & 0 deletions src/lib/holocene/tabs-primitive/tabs.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<svelte:options runes />

<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';

import Tabs from '$lib/holocene/tabs-primitive/tabs.svelte';

const { Story } = defineMeta({
title: 'Tabs (Primitive)',
component: Tabs,
argTypes: {
tabs: { table: { disable: true } },
selectedTab: { table: { disable: true } },
children: { table: { disable: true } },
onSelectedTabChange: { table: { disable: true } },
},
});
</script>

<script lang="ts">
import TabButtonList from './tab-button-list.svelte';
import TabPanels from './tab-panels.svelte';

const tabs = ['tab-a', 'tab-b', 'tab-c'];
</script>

{#snippet example(
orientation: 'horizontal' | 'vertical',
activation: 'automatic' | 'manual',
)}
<Tabs {tabs}>
<TabButtonList
class={orientation === 'vertical' ? 'flex flex-col gap-2' : 'flex gap-2'}
aria-label="Example tabs"
{orientation}
{activation}
>
{#snippet tabButtonSnippet(getAttributes, { tab, isSelected })}
<button
type="button"
{...getAttributes({
class: `cursor-pointer rounded-md border px-3 py-1.5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
isSelected
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'
}`,
})}
>
{tab}
</button>
{/snippet}
</TabButtonList>
<TabPanels>
{#snippet tabPanelSnippet(getAttributes, { tab })}
<div
{...getAttributes({
class:
'mt-4 rounded-md border border-slate-200 p-4 text-sm text-slate-700',
})}
>
{tab} content
</div>
{/snippet}
</TabPanels>
</Tabs>
{/snippet}

<Story name="Default">
{#snippet template()}
{@render example('horizontal', 'automatic')}
{/snippet}
</Story>

<Story name="Vertical">
{#snippet template()}
{@render example('vertical', 'automatic')}
{/snippet}
</Story>

<Story name="Manual Activation">
{#snippet template()}
{@render example('horizontal', 'manual')}
{/snippet}
</Story>
46 changes: 46 additions & 0 deletions src/lib/holocene/tabs-primitive/tabs.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts" generics="T extends string = string">
import { type Snippet } from 'svelte';

import { setTabsContext } from './context';

interface Props {
selectedTab?: T;
tabs: T[];
children: Snippet<[]>;
onSelectedTabChange?: (selected: T) => void;
}

let {
children,
tabs,
selectedTab = $bindable(tabs[0]),
onSelectedTabChange,
}: Props = $props();

const id = $props.id();

setTabsContext<T>({
get selectedTab() {
return selectedTab;
},

get tabs() {
return tabs;
},

getButtonIdForTab(tab: string) {
return `${id}-button-${tab}`;
},

getPanelIdForTab(tab: string) {
return `${id}-panel-${tab}`;
},

setSelectedTab(selected: string) {
selectedTab = selected as T;
onSelectedTabChange?.(selected as T);
},
});
</script>

{@render children?.()}
Loading