@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
162 lines (136 loc) • 5.41 kB
text/typescript
import { PktElementWithSlot } from '@/base-elements/element-with-slot'
import { slotContent } from '@/directives/slot-content'
import { html, PropertyValues } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { provide } from '@lit/context'
import { tabsContext, type TabsContext } from './tabs-context'
export interface IPktTabs {
arrowNav?: boolean
disableArrowNav?: boolean
separatorIconName?: string
separatorIconPath?: string
}
export class PktTabs extends PktElementWithSlot<IPktTabs> implements IPktTabs {
arrowNav: boolean = true
disableArrowNav: boolean = false
separatorIconName: string = ''
separatorIconPath: string = ''
private tabRefs: Array<HTMLAnchorElement | HTMLButtonElement | null> = []
private disabledMap: Record<number, boolean> = {}
private tabCount: number = 0
private get useArrowNav(): boolean {
return this.arrowNav && !this.disableArrowNav
}
// Provide context to child tab items
private context: TabsContext = {
useArrowNav: this.useArrowNav,
registerTab: this.registerTab.bind(this),
handleClick: this.handleClick.bind(this),
handleKeyUp: this.handleKeyUp.bind(this),
}
// Update context when properties change
updated(changedProperties: PropertyValues) {
if (changedProperties.has('arrowNav') || changedProperties.has('disableArrowNav')) {
this.context = {
...this.context,
useArrowNav: this.useArrowNav,
}
}
this.updateComplete.then(() => this.syncSeparators())
}
private syncSeparators() {
// Slotted tab items require post-render DOM sync for decorative separators.
const list = this.querySelector('.pkt-tabs__list')
if (!list) return
list.querySelectorAll('.pkt-tabs__separator').forEach((separator) => separator.remove())
const hasSeparator = !!(this.separatorIconName || this.separatorIconPath)
if (!hasSeparator) return
const items = Array.from(list.children).filter((child) => !child.classList.contains('pkt-tabs__separator'))
for (let i = 0; i < items.length - 1; i++) {
const separator = document.createElement('span')
separator.className = 'pkt-tabs__separator'
separator.setAttribute('aria-hidden', 'true')
separator.setAttribute('role', 'presentation')
if (this.separatorIconPath) {
const img = document.createElement('img')
img.setAttribute('src', this.separatorIconPath)
img.setAttribute('alt', '')
img.setAttribute('aria-hidden', 'true')
separator.appendChild(img)
} else if (this.separatorIconName) {
const icon = document.createElement('pkt-icon')
icon.setAttribute('name', this.separatorIconName)
icon.classList.add('pkt-tabs__separator-icon')
separator.appendChild(icon)
}
items[i].after(separator)
}
}
private registerTab(element: HTMLElement, index: number, disabled = false) {
this.tabRefs[index] = element as HTMLAnchorElement | HTMLButtonElement
this.disabledMap[index] = disabled
this.tabCount = Math.max(this.tabCount, index + 1)
}
private isTabDisabled(index: number): boolean {
return !!this.disabledMap[index]
}
private findEnabledIndex(startIndex: number, direction: -1 | 1): number | null {
let current = startIndex + direction
while (current >= 0 && current < this.tabCount) {
if (!this.isTabDisabled(current)) return current
current += direction
}
return null
}
private handleClick(index: number) {
if (this.isTabDisabled(index)) return
this.dispatchEvent(
new CustomEvent('tab-selected', {
detail: { index },
bubbles: true,
composed: true,
}),
)
}
private handleKeyUp(keyEvent: KeyboardEvent, index: number) {
if (!this.useArrowNav) return
if (keyEvent.key === 'ArrowLeft') {
keyEvent.preventDefault()
const previousEnabled = this.findEnabledIndex(index, -1)
if (previousEnabled !== null) this.tabRefs[previousEnabled]?.focus()
}
if (keyEvent.key === 'ArrowRight') {
keyEvent.preventDefault()
const nextEnabled = this.findEnabledIndex(index, 1)
if (nextEnabled !== null) this.tabRefs[nextEnabled]?.focus()
}
if (
keyEvent.key === 'Enter' ||
keyEvent.key === ' ' ||
keyEvent.key === 'Spacebar' ||
keyEvent.key === 'ArrowDown'
) {
keyEvent.preventDefault()
this.handleClick(index)
}
}
render() {
const role = this.useArrowNav ? 'tablist' : 'navigation'
const hasSeparator = !!(this.separatorIconName || this.separatorIconPath)
const tabsClass = hasSeparator ? 'pkt-tabs pkt-tabs--with-separator' : 'pkt-tabs'
return html`
<div class=${tabsClass}>
<div class="pkt-tabs__list" role=${role}>${slotContent(this)}</div>
</div>
`
}
}
export default PktTabs
try {
customElement('pkt-tabs')(PktTabs)
} catch (e) {
console.warn('Forsøker å definere <pkt-tabs>, men den er allerede definert')
}