(null)
+ const { keyDown } = useFocusWithArrows({
+ ref: ref,
+ rotating: rotate,
+ })
+ return (
+
+
+
+
+
+ )
+ }
+
+ afterEach(() => {
+ onFocusOneMock.mockReset()
+ onFocusTwoMock.mockReset()
+ onFocusThreeMock.mockReset()
+ })
+
+ it('sets focus when using arrow keys', () => {
+ const Component = getComponent()
+ const { container } = render()
+ const firstChild = container.firstChild as HTMLElement
+ expect(onFocusOneMock).not.toHaveBeenCalled()
+
+ // 4 times, so we can check if there are no other elements focused after reaching the last one
+ Array.from(Array(4).keys()).forEach(() => {
+ fireEvent.keyDown(firstChild, {
+ key: KeyboardKeys.ArrowDown,
+ })
+ })
+
+ expect(onFocusOneMock).toHaveBeenCalledTimes(1)
+ expect(onFocusTwoMock).toHaveBeenCalledTimes(1)
+ expect(onFocusThreeMock).toHaveBeenCalledTimes(1)
+
+ // Same here
+ Array.from(Array(4).keys()).forEach(() => {
+ fireEvent.keyDown(firstChild, {
+ key: KeyboardKeys.ArrowUp,
+ })
+ })
+ expect(onFocusTwoMock).toHaveBeenCalledTimes(2)
+ expect(onFocusOneMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('rotates focused elements', () => {
+ const Component = getComponent(true)
+ const { container } = render()
+ const firstChild = container.firstChild as HTMLElement
+
+ Array.from(Array(9).keys()).forEach(() => {
+ fireEvent.keyDown(firstChild, {
+ key: KeyboardKeys.ArrowDown,
+ })
+ })
+
+ expect(onFocusOneMock).toHaveBeenCalledTimes(3)
+
+ Array.from(Array(9).keys()).forEach(() => {
+ fireEvent.keyDown(firstChild, {
+ key: KeyboardKeys.ArrowUp,
+ })
+ })
+ expect(onFocusOneMock).toHaveBeenCalledTimes(6)
+ })
+})
diff --git a/packages/react/src/common/useFocusWithArrows.tsx b/packages/react/src/common/useFocusWithArrows.tsx
new file mode 100644
index 0000000000..4e619313a8
--- /dev/null
+++ b/packages/react/src/common/useFocusWithArrows.tsx
@@ -0,0 +1,114 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import type { KeyboardEvent, RefObject } from 'react'
+
+export const KeyboardKeys = {
+ ArrowUp: 'ArrowUp',
+ ArrowDown: 'ArrowDown',
+ ArrowRight: 'ArrowRight',
+ ArrowLeft: 'ArrowLeft',
+ Home: 'Home',
+ End: 'End',
+}
+
+const FOCUSABLE_ELEMENTS = [
+ 'a[href]:not([disabled])',
+ 'button:not([disabled])',
+ 'textarea:not([disabled])',
+ 'input[type="text"]:not([disabled])',
+ 'input[type="radio"]:not([disabled])',
+ 'input[type="checkbox"]:not([disabled])',
+ 'select:not([disabled])',
+]
+
+type FocusWithArrowsOptions = {
+ ref: RefObject
+ rotating?: boolean
+ directChildrenOnly?: boolean
+ horizontally?: boolean
+}
+
+const useFocusWithArrows = ({
+ ref,
+ rotating = false,
+ directChildrenOnly = false,
+ horizontally = false,
+}: FocusWithArrowsOptions) => {
+ const next = horizontally ? KeyboardKeys.ArrowRight : KeyboardKeys.ArrowDown
+ const previous = horizontally ? KeyboardKeys.ArrowLeft : KeyboardKeys.ArrowUp
+ const keyDown = (e: KeyboardEvent) => {
+ if (ref.current) {
+ const element = ref.current
+
+ const { activeElement } = window.document
+ const directChildSelector = directChildrenOnly ? ':scope > ' : ''
+ const focusableEls: Array = Array.from(
+ element.querySelectorAll(`${directChildSelector}${FOCUSABLE_ELEMENTS.join(`, ${directChildSelector}`)}`),
+ )
+
+ 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
+ }
+
+ case KeyboardKeys.Home: {
+ el = focusableEls[0]
+ break
+ }
+
+ case KeyboardKeys.End: {
+ el = focusableEls[focusableEls.length - 1]
+ break
+ }
+
+ default:
+ }
+
+ if (
+ (e.key === KeyboardKeys.ArrowDown ||
+ e.key === KeyboardKeys.ArrowUp ||
+ e.key === KeyboardKeys.ArrowLeft ||
+ e.key === KeyboardKeys.ArrowRight ||
+ e.key === KeyboardKeys.Home ||
+ e.key === KeyboardKeys.End) &&
+ el instanceof HTMLElement
+ ) {
+ el.focus()
+ e.preventDefault()
+ }
+ }
+ }
+
+ return { keyDown }
+}
+
+export default useFocusWithArrows
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 4842ff7820..c2c19ca6f6 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@
*/
/* Append here */
+export * from './Tabs'
export * from './Column'
export * from './Fieldset'
export * from './LinkList'
diff --git a/proprietary/tokens/src/components/amsterdam/tabs.tokens.json b/proprietary/tokens/src/components/amsterdam/tabs.tokens.json
new file mode 100644
index 0000000000..8dc150bff9
--- /dev/null
+++ b/proprietary/tokens/src/components/amsterdam/tabs.tokens.json
@@ -0,0 +1,34 @@
+{
+ "amsterdam": {
+ "tabs": {
+ "list": {
+ "border-bottom": { "value": ".125rem solid {amsterdam.color.primary-blue}" }
+ },
+ "button": {
+ "background-color": { "value": "transparent" },
+ "border": { "value": "none" },
+ "color": { "value": "{amsterdam.color.primary-blue}" },
+ "cursor": { "value": "{amsterdam.action.activate.cursor}" },
+ "font-family": { "value": "{amsterdam.typography.font-family}" },
+ "font-weight": { "value": "{amsterdam.typography.font-weight.normal}" },
+ "font-size": { "value": "{amsterdam.typography.text-level.5.font-size}" },
+ "line-height": { "value": "{amsterdam.typography.text-level.5.line-height}" },
+ "outline-offset": { "value": "-0.25rem" },
+ "padding-block": { "value": ".5rem" },
+ "padding-inline": { "value": "1rem" },
+ "hover": {
+ "color": { "value": "{amsterdam.color.dark-blue}" },
+ "box-shadow": { "value": "inset 0 -0.125rem 0 0 {amsterdam.color.dark-blue}" }
+ },
+ "selected": {
+ "background-color": { "value": "{amsterdam.color.primary-blue}" },
+ "color": { "value": "{amsterdam.color.primary-white}" }
+ },
+ "disabled": {
+ "color": { "value": "{amsterdam.color.neutral-grey2}" },
+ "cursor": { "value": "{amsterdam.action.disabled.cursor}" }
+ }
+ }
+ }
+ }
+}
diff --git a/storybook/src/components/Tabs/Tabs.docs.mdx b/storybook/src/components/Tabs/Tabs.docs.mdx
new file mode 100644
index 0000000000..e086fd4abc
--- /dev/null
+++ b/storybook/src/components/Tabs/Tabs.docs.mdx
@@ -0,0 +1,13 @@
+import { Canvas, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as TabsStories from "./Tabs.stories.tsx";
+import README from "../../../../packages/css/src/components/tabs/README.md?raw";
+
+
+
+{README}
+
+
+
+## Tab
+
+
diff --git a/storybook/src/components/Tabs/Tabs.stories.tsx b/storybook/src/components/Tabs/Tabs.stories.tsx
new file mode 100644
index 0000000000..28205d3f91
--- /dev/null
+++ b/storybook/src/components/Tabs/Tabs.stories.tsx
@@ -0,0 +1,100 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { Heading, Paragraph, Tabs } from '@amsterdam/design-system-react'
+import { Meta, StoryObj } from '@storybook/react'
+import { PropsWithChildren } from 'react'
+import { exampleParagraph } from '../shared/exampleContent'
+
+const SlowPanel = ({ children }: PropsWithChildren) => {
+ console.log('[ARTIFICIALLY SLOW] Adding a 1000ms delay')
+
+ let startTime = performance.now()
+ while (performance.now() - startTime < 1000) {
+ // Do nothing for 1000 ms to emulate extremely slow code
+ }
+
+ return children
+}
+
+const meta = {
+ title: 'Components/Containers/Tabs',
+ component: Tabs,
+} satisfies Meta
+
+export default meta
+
+const tabMeta = {
+ component: Tabs.Button,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+type Story = StoryObj
+type TabStory = StoryObj
+
+export const Default: Story = {
+ args: {
+ children: [
+
+ Gegevens
+ Aanslagen
+ Documenten
+ Acties
+ ,
+
+
+
Gegevens
+
{exampleParagraph()}
+
+ ,
+
+
+
Aanslagen
+
{exampleParagraph()}
+
+ ,
+
+
+
Documenten
+
(This tab panel simulates a load time of 500 milliseconds.)
+
+
+ ,
+
+
+
Acties
+
{exampleParagraph()}
+
+ ,
+ ],
+ },
+}
+
+export const Tab: TabStory = {
+ args: {
+ children: 'Gegevens',
+ tab: 0,
+ disabled: false,
+ },
+ argTypes: {
+ children: {
+ table: { disable: false },
+ },
+ tab: {
+ control: {
+ type: 'number',
+ min: 0,
+ max: 9,
+ },
+ },
+ },
+ render: ({ children, ...args }) => {children},
+}