Skip to content

Commit

Permalink
Keyboard navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
dlnr committed Feb 8, 2024
1 parent ab8462e commit 6fc764c
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 7 deletions.
18 changes: 16 additions & 2 deletions packages/react/src/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
*/

import clsx from 'clsx'
import { forwardRef, useState } from 'react'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import type { ForwardedRef, ForwardRefExoticComponent, HTMLAttributes, PropsWithChildren, RefAttributes } from 'react'
import { TabsButton } from './TabsButton'
import TabsContext from './TabsContext'
import { TabsList } from './TabsList'
import { TabsPanel } from './TabsPanel'
import useFocusWithArrows from './useFocusWithArrows'

export type TabsProps = {} & PropsWithChildren<HTMLAttributes<HTMLDivElement>>

Expand All @@ -22,13 +23,26 @@ type TabsComponent = {
export const Tabs = forwardRef(
({ children, className, ...restProps }: TabsProps, ref: ForwardedRef<HTMLDivElement>) => {
const [activeTab, setActiveTab] = useState(0)
const innerRef = useRef<HTMLDivElement>(null)

const updateTab = (tab: number) => {
setActiveTab(tab)
}

// use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)

const { keyDown } = useFocusWithArrows(innerRef, true)

return (
<TabsContext.Provider value={{ activeTab, updateTab }}>
<div {...restProps} ref={ref} className={clsx('amsterdam-tabs', className)}>
<div
{...restProps}
role="tabs"
ref={innerRef}
onKeyDown={keyDown}
className={clsx('amsterdam-tabs', className)}
>
{children}
</div>
</TabsContext.Provider>
Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/Tabs/TabsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
import clsx from 'clsx'
import { forwardRef, startTransition, useContext } from 'react'
import type { ButtonHTMLAttributes, ForwardedRef, PropsWithChildren } from 'react'
import { TabsContext } from './Tabs'
// import TabsContext from './TabsContext'
import TabsContext from './TabsContext'

export type TabsButtonProps = {
label: string
Expand All @@ -22,6 +21,9 @@ export const TabsButton = forwardRef(
return (
<button
{...restProps}
role="tab"
aria-controls={`panel-${tab}`}
tabIndex={activeTab === tab ? 0 : -1}
ref={ref}
onClick={() => {
startTransition(() => {
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/Tabs/TabsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const defaultValues: TabsContextValue = {
updateTab: () => {},
}

// eslint-disable-next-line no-unused-vars
const TabsContext = createContext(defaultValues)

export default TabsContext
2 changes: 1 addition & 1 deletion packages/react/src/Tabs/TabsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type TabsListProps = {} & PropsWithChildren<HTMLAttributes<HTMLDivElement

export const TabsList = forwardRef(
({ children, className, ...restProps }: TabsListProps, ref: ForwardedRef<HTMLDivElement>) => (
<div {...restProps} ref={ref} className={clsx('amsterdam-tabs__list', className)}>
<div {...restProps} role="tablist" ref={ref} className={clsx('amsterdam-tabs__list', className)}>
{children}
</div>
),
Expand Down
9 changes: 8 additions & 1 deletion packages/react/src/Tabs/TabsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ export const TabsPanel = forwardRef(
}

return (
<div {...restProps} ref={ref} className={clsx('amsterdam-tabs__panel', className)}>
<div
{...restProps}
role="tabpanel"
id={`panel-${tab}`}
tabIndex={0}
ref={ref}
className={clsx('amsterdam-tabs__panel', className)}
>
{children}
</div>
)
Expand Down
67 changes: 67 additions & 0 deletions packages/react/src/Tabs/useFocusWithArrows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @license EUPL-1.2+
* Copyright (c) 2023 Gemeente Amsterdam
*/

import type { KeyboardEvent, RefObject } from 'react'

export const KeyboardKeys = {
ArrowRight: 'ArrowRight',
ArrowLeft: 'ArrowLeft',
}

const useFocusWithArrows = (ref: RefObject<HTMLDivElement>, rotating = false) => {
const next = KeyboardKeys.ArrowRight
const previous = KeyboardKeys.ArrowLeft
const keyDown = (e: KeyboardEvent) => {
if (ref.current) {
const element = ref.current

const { activeElement } = window.document
const focusableEls: Array<Element> = Array.from(element.querySelectorAll('.amsterdam-tabs__button'))

const getIndex = (el: Element | null) => {
return el && focusableEls.includes(el) ? focusableEls.indexOf(el) : 0
}

let el

switch (e.key) {
case next: {
if (getIndex(activeElement) !== focusableEls.length - 1) {
el = focusableEls[getIndex(activeElement) + 1]
// If there is nothing focused yet, set the focus on the first element
if (activeElement && !focusableEls.includes(activeElement)) {
el = focusableEls[0]
}
} else if (rotating) {
el = focusableEls[0]
}

break
}

case previous: {
if (getIndex(activeElement) !== 0) {
el = focusableEls[getIndex(activeElement) - 1]
} else if (rotating) {
el = focusableEls[focusableEls.length - 1]
}

break
}

default:
}

if ((e.key === KeyboardKeys.ArrowLeft || e.key === KeyboardKeys.ArrowRight) && el instanceof HTMLElement) {
el.focus()
e.preventDefault()
}
}
}

return { keyDown }
}

export default useFocusWithArrows

0 comments on commit 6fc764c

Please sign in to comment.