Skip to content

Commit

Permalink
feat(Tabs): ✨ New Tabs Component (#876)
Browse files Browse the repository at this point in the history
  • Loading branch information
Magnusrm authored Oct 5, 2023
1 parent 9482315 commit 6ae19e7
Show file tree
Hide file tree
Showing 19 changed files with 664 additions and 1 deletion.
2 changes: 1 addition & 1 deletion packages/Overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ V1 er klar når følgende komponenter er markert som "✅ Felles":
| Search | 🚸 Ikke påbegynt | Figma - Search | [Github - Search](https://github.com/digdir/designsystem/issues/88) |
| [Switch](/docs/felles-switch--docs) | ✅ Felles | [Figma - Switch](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?type=design&node-id=17755-6024&mode=design&t=ztPUyfbDSQjlpEzv-0) | [Github - Switch](https://github.com/digdir/designsystem/issues/89) |
| [Table](/docs/altinn-table--docs) | 🔵 Altinn | Figma - Table | [Github - Table](https://github.com/digdir/designsystem/issues/90) |
| [Tabs](/docs/avviklet-legacytabs--docs) | 🔵 Altinn | [Figma - Tabs](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?type=design&node-id=9551%3A54208&t=Rlfq5UyNZBL69dFr-1) | [Github - Tabs](https://github.com/digdir/designsystem/issues/91) |
| [Tabs](/docs/felles-tabs--docs) | ✅ Felles | [Figma - Tabs](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?type=design&node-id=9551%3A54208&t=Rlfq5UyNZBL69dFr-1) | [Github - Tabs](https://github.com/digdir/designsystem/issues/91) |
| [Tag](/docs/felles-tag--docs) | ✅ Felles | [Figma - Tag](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=10185%3A59053&t=7Q2N4sUdQGhFZrPh-1) | [Github - Tag](https://github.com/digdir/designsystem/issues/322) |
| [Textarea](/docs/felles-textarea--docs) | ✅ Felles | [Figma - Text area](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A21873&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textarea](https://github.com/digdir/designsystem/issues/323) |
| [Textfield](/docs/felles-textfield--docs) | ✅ Felles | [Figma - Text Field](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A22228&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textfield](https://github.com/digdir/designsystem/issues/92) |
Expand Down
84 changes: 84 additions & 0 deletions packages/react/src/components/Tabs/Tab/Tab.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
.tabItem {
--fdsc-icon-size: var(--fds-sizing-4);
--fdsc-typography-font-family: inherit;
--fdsc-bottom-border-color: transparent;

display: flex;
flex-direction: row;
box-sizing: border-box;
gap: var(--fds-spacing-1);
justify-content: center;
text-align: center;
align-items: center;
padding: var(--fds-spacing-2) var(--fds-spacing-3);
border: none;
border-radius: 0;
background-color: transparent;
cursor: pointer;
color: var(--fds-semantic-text-neutral-subtle);
position: relative;
}

.icon > svg {
display: flex;
height: 1.75em;
width: auto;
}

.tabItem.small {
--fdsc-icon-size: var(--fds-sizing-5);

font: var(--fds-typography-interactive-small);
font-family: var(--fdsc-typography-font-family);
padding: var(--fds-spacing-2) var(--fds-spacing-4);
}

.tabItem.medium {
--fdsc-icon-size: var(--fds-sizing-6);

font: var(--fds-typography-interactive-medium);
font-family: var(--fdsc-typography-font-family);
padding: var(--fds-spacing-3) var(--fds-spacing-5);
}

.tabItem.large {
--fdsc-icon-size: var(--fds-sizing-7);

font: var(--fds-typography-interactive-large);
font-family: var(--fdsc-typography-font-family);
padding: var(--fds-spacing-4) var(--fds-spacing-6);
}

@media (hover: hover) and (pointer: fine) {
.tabItem:hover {
--fdsc-bottom-border-color: var(--fds-semantic-border-neutral-subtle);

color: var(--fds-semantic-text-neutral-default);
}
}

.tabItem.isActive {
--fdsc-bottom-border-color: var(--fds-semantic-border-action-default);

color: var(--fds-semantic-text-action-default);
}

.tabItem:focus-visible {
--fdsc-bottom-border-color: var(--fds-semantic-text-neutral-default);

background: var(--fds-semantic-border-focus-outline);
color: var(--fds-semantic-text-neutral-default);
outline: none;
}

.tabItem::after {
content: '';
display: block;
height: 3px;
width: 100%;
border-radius: var(--fds-border_radius-full);
background-color: var(--fdsc-bottom-border-color);
position: absolute;
bottom: 0;
left: 0;
}
28 changes: 28 additions & 0 deletions packages/react/src/components/Tabs/Tab/Tab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { TabList } from '../TabList';
import { Tabs } from '..';

import { Tab } from '.';

const user = userEvent.setup();

describe('TabItem', () => {
test('item renders with correct aria attributes', async () => {
render(
<Tabs defaultValue='value1'>
<TabList>
<Tab value='value1'>Tab 1</Tab>
<Tab value='value2'>Tab 2</Tab>
</TabList>
</Tabs>,
);

const tab = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab).toHaveAttribute('aria-selected', 'false');
await user.click(tab);
expect(tab).toHaveAttribute('aria-selected', 'true');
});
});
38 changes: 38 additions & 0 deletions packages/react/src/components/Tabs/Tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { HTMLAttributes } from 'react';
import React, { forwardRef } from 'react';
import cn from 'classnames';

import { RovingTabindexItem } from '../../../utility-components/RovingTabIndex';

import classes from './Tab.module.css';
import { useTabItem } from './useTab';

export type TabProps = {
/** Value that will be set in the `Tabs` components state when the tab is activated*/
value: string;
/** Icon to display */
icon?: React.ReactNode;
} & Omit<HTMLAttributes<HTMLButtonElement>, 'value'>;

export const Tab = forwardRef<HTMLButtonElement, TabProps>((props, ref) => {
const { children, className, icon, ...rest } = props;
const { active, size = 'medium', ...useTabRest } = useTabItem(props);

return (
<RovingTabindexItem
{...rest}
{...useTabRest}
as={'button'}
className={cn(
classes.tabItem,
classes[size],
active && classes.isActive,
className,
)}
ref={ref}
>
{icon && <span className={classes.icon}>{icon}</span>}
{children}
</RovingTabindexItem>
);
});
1 change: 1 addition & 0 deletions packages/react/src/components/Tabs/Tab/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Tab';
34 changes: 34 additions & 0 deletions packages/react/src/components/Tabs/Tab/useTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { HTMLAttributes } from 'react';
import { useContext, useId } from 'react';

import { TabsContext } from '../Tabs';

import type { TabProps } from './Tab';

type UseTab = (props: TabProps) => {
active: boolean;
size?: 'small' | 'medium' | 'large';
} & Pick<
HTMLAttributes<HTMLButtonElement>,
'id' | 'aria-selected' | 'role' | 'onClick'
>;

/** Handles props for `Tab` in context with `Tabs` */
export const useTabItem: UseTab = (props: TabProps) => {
const { value, ...rest } = props;
const tabs = useContext(TabsContext);
const active = tabs.value == value;
const buttonId = `tab-${useId()}`;

return {
...rest,
active: active,
size: tabs?.size,
id: buttonId,
'aria-selected': active,
role: 'tab',
onClick: () => {
tabs.onChange?.(value);
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.tabContent {
--fdsc-typography-font-family: inherit;

font-family: var(--fdsc-typography-font-family);
}

.tabContent.small {
padding: var(--fds-spacing-4);
}

.tabContent.medium {
padding: var(--fds-spacing-5);
}

.tabContent.large {
padding: var(--fds-spacing-6);
}

.tabContent.onlyText.small {
font: var(--fds-typography-interactive-small);
font-family: var(--fdsc-typography-font-family);
}

.tabContent.onlyText.medium {
font: var(--fds-typography-interactive-medium);
font-family: var(--fdsc-typography-font-family);
}

.tabContent.onlyText.large {
font: var(--fds-typography-interactive-large);
font-family: var(--fdsc-typography-font-family);
}
21 changes: 21 additions & 0 deletions packages/react/src/components/Tabs/TabContent/TabContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import { Tabs } from '..';

import { TabContent } from '.';

describe('TabContent', () => {
test('renders ReactNodes as children when TabContents value is selected', () => {
render(
<Tabs defaultValue='value1'>
<TabContent value='value1'>
<div>content 1</div>
</TabContent>
</Tabs>,
);

const content = screen.queryByText('content 1');
expect(content).toBeInTheDocument();
});
});
39 changes: 39 additions & 0 deletions packages/react/src/components/Tabs/TabContent/TabContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { HTMLAttributes } from 'react';
import React, { forwardRef, useContext } from 'react';
import cn from 'classnames';

import { TabsContext } from '../Tabs';

import classes from './TabContent.module.css';

export type TabContentProps = {
/** When this value is selected as the current state, render this `TabContent` component*/
value: string;
} & Omit<HTMLAttributes<HTMLDivElement>, 'value'>;

export const TabContent = forwardRef<HTMLDivElement, TabContentProps>(
({ children, value, ...rest }, ref) => {
const { value: tabsValue, size = 'medium' } = useContext(TabsContext);
const active = value == tabsValue;
const onlyText = typeof children === 'string';

return (
<>
{active && (
<div
{...rest}
className={cn(
classes[size],
classes.tabContent,
onlyText && classes.onlyText,
rest.className,
)}
ref={ref}
>
{children}
</div>
)}
</>
);
},
);
1 change: 1 addition & 0 deletions packages/react/src/components/Tabs/TabContent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TabContent';
5 changes: 5 additions & 0 deletions packages/react/src/components/Tabs/TabList/TabList.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.tabItemList {
display: flex;
flex-direction: row;
border-bottom: var(--fds-border_width-default) solid var(--fds-semantic-border-neutral-subtle);
}
29 changes: 29 additions & 0 deletions packages/react/src/components/Tabs/TabList/TabList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { Tab } from '../Tab';

import { TabList } from '.';

const user = userEvent.setup();

describe('TabItemList', () => {
test('can navigate tabs with keyboard', async () => {
render(
<TabList>
<Tab value='value1'>Tab 1</Tab>
<Tab value='value2'>Tab 2</Tab>
</TabList>,
);

const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
await user.tab();
expect(tab1).toHaveFocus();
await user.type(tab1, '{arrowright}');
expect(tab2).toHaveFocus();
await user.type(tab2, '{arrowleft}');
expect(tab1).toHaveFocus();
});
});
23 changes: 23 additions & 0 deletions packages/react/src/components/Tabs/TabList/TabList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { HTMLAttributes } from 'react';
import React, { forwardRef } from 'react';
import cn from 'classnames';

import { RovingTabindexRoot } from '../../../utility-components/RovingTabIndex';

import classes from './TabList.module.css';

export const TabList = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(({ children, ...rest }, ref) => {
return (
<RovingTabindexRoot
{...rest}
role='tablist'
className={cn(classes.tabItemList, rest.className)}
ref={ref}
>
{children}
</RovingTabindexRoot>
);
});
1 change: 1 addition & 0 deletions packages/react/src/components/Tabs/TabList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TabList';
Loading

0 comments on commit 6ae19e7

Please sign in to comment.