<module-tabgroup>
<div role="tablist">
<button
type="button"
role="tab"
id="trigger1"
aria-controls="panel1"
aria-selected="true"
tabindex="0"
>
Tab 1
</button>
<button
type="button"
role="tab"
id="trigger2"
aria-controls="panel2"
aria-selected="false"
tabindex="-1"
>
Tab 2
</button>
<button
type="button"
role="tab"
id="trigger3"
aria-controls="panel3"
aria-selected="false"
tabindex="-1"
>
Tab 3
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="trigger1">
Tab 1 content
</div>
<div role="tabpanel" id="panel2" aria-labelledby="trigger2" hidden>
Tab 2 content
</div>
<div role="tabpanel" id="panel3" aria-labelledby="trigger3" hidden>
Tab 3 content
</div>
</module-tabgroup>
module-tabgroup {
display: block;
margin-bottom: var(--space-l);
> [role="tablist"] {
display: flex;
border-bottom: 1px solid var(--color-border);
padding: 0;
margin-bottom: 0;
> [role="tab"] {
border: 0;
border-top: 2px solid transparent;
border-bottom-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
padding: var(--space-s) var(--space-m);
color: var(--color-text-soft);
background-color: var(--color-secondary);
cursor: pointer;
transition: all var(--transition-short) var(--easing-inout);
&:hover,
&:focus {
color: var(--color-text);
background-color: var(--color-secondary-hover);
}
&:focus {
z-index: 1;
}
&:active {
color: var(--color-text);
background-color: var(--color-secondary-active);
}
&[aria-selected="true"] {
color: var(--color-primary-active);
border-top: 3px solid var(--color-primary);
background-color: var(--color-background);
margin-bottom: -1px;
}
}
}
> [role="tabpanel"] {
font-family: sans-serif;
font-size: var(--font-size-m);
background: var(--color-background);
margin-block: var(--space-l);
}
}
import {
type Component,
component,
focus,
fromEvents,
fromSelector,
setProperty,
show,
} from '../../..'
import { requireDescendant } from '../../../src/core/dom'
export type ModuleTabgroupProps = {
tabs: HTMLButtonElement[]
selected: string
}
const getAriaControls = (target: HTMLElement) =>
target?.getAttribute('aria-controls') ?? ''
const getSelected = (
elements: HTMLElement[],
isCurrent: (element: HTMLElement) => boolean,
offset = 0,
) =>
getAriaControls(
elements[
Math.min(
Math.max(elements.findIndex(isCurrent) + offset, 0),
elements.length - 1,
)
],
)
const handleClick = ({ target }) => getAriaControls(target)
const handleKeyup = ({ event, host, target }) => {
const key = event.key
if (
[
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
].includes(key)
) {
event.preventDefault()
event.stopPropagation()
return getSelected(
host.tabs,
tab => tab === target,
key === 'Home'
? -host.tabs.length
: key === 'End'
? host.tabs.length
: key === 'ArrowLeft' || key === 'ArrowUp'
? -1
: 1,
)
}
}
export default component(
'module-tabgroup',
{
tabs: fromSelector<HTMLButtonElement>('[role="tab"]'),
selected: fromEvents<
string,
HTMLButtonElement,
HTMLElement & { tabs: HTMLButtonElement[] }
>(
(el: HTMLElement & { tabs: HTMLButtonElement[] }) =>
getSelected(el.tabs, tab => tab.ariaSelected === 'true'),
'[role="tab"]',
{
click: handleClick,
keyup: handleKeyup,
},
),
},
(el, { all }) => {
requireDescendant(el, '[role="tablist"]')
requireDescendant(el, '[role="tab"]')
requireDescendant(el, '[role="tabpanel"]')
const isCurrentTab = (tab: HTMLButtonElement) =>
el.selected === getAriaControls(tab)
return [
all<HTMLButtonElement>(
'[role="tab"]',
setProperty('ariaSelected', target =>
String(isCurrentTab(target)),
),
setProperty('tabIndex', target =>
isCurrentTab(target) ? 0 : -1,
),
focus(isCurrentTab),
),
all(
'[role="tabpanel"]',
show(target => el.selected === target.id),
),
]
},
)
declare global {
interface HTMLElementTagNameMap {
'module-tabgroup': Component<ModuleTabgroupProps>
}
}