Skip to content

Commit

Permalink
fix(ffe-tabs-react): Legger til piltastnavigering på tabgroup
Browse files Browse the repository at this point in the history
  • Loading branch information
tuva-odegard committed May 29, 2024
1 parent c629354 commit c004c55
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 21 deletions.
34 changes: 19 additions & 15 deletions packages/ffe-tabs-react/src/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ export interface TabProps extends React.ComponentPropsWithoutRef<'button'> {
selected?: boolean;
}

export const Tab: React.FC<TabProps> = ({ className, selected, ...rest }) => {
return (
<button
type="button"
role="tab"
aria-selected={selected}
className={classNames(
'ffe-tab',
{ 'ffe-tab--selected': selected },
className,
)}
{...rest}
/>
);
};
export const Tab = React.forwardRef<HTMLButtonElement, TabProps>(
({ className, selected, ...rest }, ref) => {
return (
<button
type="button"
role="tab"
aria-selected={selected ? 'true' : 'false'}
ref={ref}
tabIndex={selected ? 0 : -1}
className={classNames(
'ffe-tab',
{ 'ffe-tab--selected': selected },
className,
)}
{...rest}
/>
);
},
);
56 changes: 53 additions & 3 deletions packages/ffe-tabs-react/src/TabGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { TabGroup } from './TabGroup';
import { Tab } from './Tab';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';

describe('<TabGroup/>', () => {
it('renders a tab group', () => {
Expand Down Expand Up @@ -33,7 +33,8 @@ describe('<TabGroup/>', () => {
it('should apply noBreak modifier class when the noBreak prop is true', () => {
render(
<TabGroup noBreak={true}>
<Tab aria-controls="div">En tab</Tab>
<Tab aria-controls="div3">En tab</Tab>
<Tab aria-controls="div4">En tab til</Tab>
</TabGroup>,
);
const tabGroup = screen.getByRole('tablist');
Expand All @@ -45,12 +46,61 @@ describe('<TabGroup/>', () => {
it('should accept custom classes', () => {
render(
<TabGroup className="some-custom-class">
<Tab aria-controls="div">En tab</Tab>
<Tab aria-controls="div5">En tab</Tab>
<Tab aria-controls="div6">En tab til </Tab>
</TabGroup>,
);
const tabGroup = screen.getByRole('tablist');

expect(tabGroup.classList.contains('ffe-tab-group')).toBe(true);
expect(tabGroup.classList.contains('some-custom-class')).toBe(true);
});

it('should set tabindex of selected tab to 0 and others to -1', () => {
render(
<TabGroup>
<Tab aria-controls="div7" selected={true}>
Tab 1
</Tab>
<Tab aria-controls="div8">Tab 2</Tab>
</TabGroup>,
);

const tabGroup = screen.getByRole('tablist');
tabGroup.focus();

const [selectedTab, otherTab] = screen.getAllByRole('tab');

expect(selectedTab.getAttribute('aria-selected')).toBe('true');
expect(selectedTab.getAttribute('tabindex')).toBe('0');
expect(otherTab.getAttribute('aria-selected')).toBe('false');
expect(otherTab.getAttribute('tabindex')).toBe('-1');
});

it('should navigate left and right with arrow keys', () => {
render(
<TabGroup>
<Tab aria-controls="div9" selected={true}>
Tab 1
</Tab>
<Tab aria-controls="div10">Tab 2</Tab>
<Tab aria-controls="div11">Tab 3</Tab>
</TabGroup>,
);

const tabGroup = screen.getByRole('tablist');
const tabs = screen.getAllByRole('tab');

fireEvent.keyDown(tabGroup, { key: 'ArrowRight' });
expect(tabs[1]).toHaveFocus();

fireEvent.keyDown(tabGroup, { key: 'ArrowRight' });
expect(tabs[2]).toHaveFocus();

fireEvent.keyDown(tabGroup, { key: 'ArrowRight' });
expect(tabs[0]).toHaveFocus();

fireEvent.keyDown(tabGroup, { key: 'ArrowLeft' });
expect(tabs[2]).toHaveFocus();
});
});
53 changes: 50 additions & 3 deletions packages/ffe-tabs-react/src/TabGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,72 @@
import React from 'react';
import React, { RefObject, useState } from 'react';
import classNames from 'classnames';
import { mergeRefs } from './mergeRefs';
import { TabProps } from './Tab';

export interface TabGroupProps extends React.ComponentPropsWithoutRef<'div'> {
export interface TabGroupProps
extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
/** Applies the noBreak modifier to avoid it collapsing over multiple lines */
noBreak?: boolean;
/** The children of the TabGroup component */
children: React.ReactElement<TabProps>[];
}

export const TabGroup: React.FC<TabGroupProps> = ({
className,
noBreak,
children,
...rest
}) => {
const [tabs] = useState<RefObject<HTMLButtonElement>[] | null | undefined>(
React.Children.map(children, () => React.createRef()) ?? [],
);

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const currentFocus = document.activeElement;
const currentTab = tabs?.find(tab => tab?.current === currentFocus);
const currentIndex = currentTab ? tabs?.indexOf(currentTab) : 0;

if (e.key === 'ArrowRight') {
const nextTab =
tabs?.[((currentIndex ?? 0) + 1) % (tabs?.length || 1)]
?.current;
nextTab?.focus();
} else if (e.key === 'ArrowLeft') {
const previousTab =
tabs?.[
((currentIndex ?? 0) - 1 + (tabs?.length || 1)) %
(tabs?.length || 1)
]?.current;
previousTab?.focus();
}
}
};

return (
//Es-lint klager på at hvis man har onkeydown så må man også ha tabindex for å
//gjøre komponenten reachable by keyboard, men onkeydown håndterer dette og det skal være
//trygt å ignorere denne regelen.
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
<div
className={classNames(
'ffe-tab-group',
{ 'ffe-tab-group--no-break': noBreak },
className,
)}
role="tablist"
onKeyDown={handleKeyDown}
{...rest}
/>
>
{React.Children.map(
children,
(child, index) =>
React.isValidElement(child) &&
React.cloneElement(child, {
...child.props,
ref: mergeRefs([child.props.ref, tabs?.[index]]),
}),
)}
</div>
);
};
18 changes: 18 additions & 0 deletions packages/ffe-tabs-react/src/mergeRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

export function mergeRefs<T = any>(
refs: Array<
React.MutableRefObject<T> | React.LegacyRef<T> | undefined | null
>,
): React.RefCallback<T> {
return value => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null) {
// eslint-disable-next-line no-param-reassign
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}

0 comments on commit c004c55

Please sign in to comment.