Skip to content

Commit

Permalink
Use StudioContentMenu in ContentLibrary
Browse files Browse the repository at this point in the history
  • Loading branch information
standeren committed Oct 28, 2024
1 parent acda247 commit 3962016
Show file tree
Hide file tree
Showing 33 changed files with 742 additions and 115 deletions.
3 changes: 2 additions & 1 deletion frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
"app_content_library.images.page_name": "Bilder",
"app_content_library.info_box.title": "En kort beskrivelse om bruk av og hensikt med ressursen i bibliotket.",
"app_content_library.landing_page.description": "Når du utvikler skjemaer, er det nyttig å samle ulike filer og ressurser på ett sted. I biblioteket kan du laste opp ting andre har laget som du har bruk for, eller selv lage det du trenger til de tjenestene du utvikler.",
"app_content_library.landing_page.page_name": "Bibliotek",
"app_content_library.landing_page.page_name": "Om biblioteket",
"app_content_library.landing_page.title": "Med biblioteket kan du effektivt utvikle mer konsekvente tjenester",
"app_content_library.library_heading": "Bibliotek",
"app_create_release.build_version": "Bygg versjon",
"app_create_release.check_status": "Sjekker status på appen din...",
"app_create_release.loading": "Laster...",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.buttonTab {
all: unset;
display: var(--tab-display);
align-items: var(--tab-align-items);
gap: var(--tab-icon-title-gap);
cursor: var(--tab-cursor);
width: var(--tab-width);
}

.icon {
display: flex;
align-items: center;
font-size: var(--fds-spacing-8);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import type { StudioButtonTabType } from '../types/StudioMenuTabType';
import { StudioMenuTab } from '../StudioMenuTab';
import { StudioMenuTabContainer } from '../StudioMenuTabContainer';
import { useStudioContentMenuContext } from '../context/StudioContentMenuContext';
import classes from './StudioButtonTab.module.css';

type StudioButtonTabProps<TabId extends string> = {
contentTab: StudioButtonTabType<TabId>;
};

export function StudioButtonTab<TabId extends string>({
contentTab,
}: StudioButtonTabProps<TabId>): React.ReactElement {
const { selectedTabId, onChangeTab } = useStudioContentMenuContext();

return (
<StudioMenuTabContainer
contentTab={contentTab}
isTabSelected={selectedTabId === contentTab.tabId}
onClick={() => onChangeTab(contentTab.tabId)}
>
<button className={classes.buttonTab} tabIndex={-1}>
<div className={classes.icon}>{contentTab.icon}</div>
<StudioMenuTab contentTab={contentTab} />
</button>
</StudioMenuTabContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StudioButtonTab } from './StudioButtonTab';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.tabsContainer {
background-color: var(--fds-semantic-surface-action-second-subtle);
height: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { BookIcon, VideoIcon, QuestionmarkDiamondIcon, ExternalLinkIcon } from '@studio/icons';
import type { StudioContentMenuWrapperProps } from './StudioContentMenuWrapper';
import { StudioContentMenuWrapper } from './StudioContentMenuWrapper';

type Story = StoryFn<StudioContentMenuWrapperProps<StudioMenuTabName>>;

const meta: Meta<StudioContentMenuWrapperProps<StudioMenuTabName>> = {
title: 'Components/StudioContentMenu',
component: StudioContentMenuWrapper,
argTypes: {
buttonTabs: {
control: 'object',
description: 'Array of button menu tabs with icons, names, and ids.',
table: {
type: { summary: 'StudioButtonTabType<TabId>[]' },
},
},
linkTabs: {
control: 'object',
description:
'Array of link menu tabs with icons, names, and ids. Prop `to` defines the url to navigate to.',
table: {
type: { summary: 'StudioLinkTabType<TabId>[]' },
},
},
selectedTabId: {
table: { disable: true },
},
onChangeTab: {
table: { disable: true },
},
},
};

export default meta;

type StudioMenuTabName = 'booksTab' | 'videosTab' | 'tabWithVeryLongTabName' | 'tabAsLink';

export const Preview: Story = (args: StudioContentMenuWrapperProps<StudioMenuTabName>) => (
<StudioContentMenuWrapper {...args} />
);

Preview.args = {
buttonTabs: [
{
tabId: 'booksTab',
tabName: 'Bøker',
icon: <BookIcon />,
},
{
tabId: 'videosTab',
tabName: 'Filmer',
icon: <VideoIcon />,
},
{
tabId: 'tabWithVeryLongTabName',
tabName: 'LoremIpsumLoremIpsumLoremIpsum',
icon: <QuestionmarkDiamondIcon />,
},
],
linkTabs: [
{
tabId: 'tabAsLink',
tabName: 'Gå til Designsystemet',
icon: <ExternalLinkIcon />,
to: 'https://next.storybook.designsystemet.no',
},
],
onChangeTab: () => {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { StudioContentMenu } from './';
import type { StudioMenuTabType } from './types/StudioMenuTabType';
import type { StudioContentMenuWrapperProps } from './StudioContentMenuWrapper';

type StudioMenuTabName = 'tab1' | 'tab2' | 'tab3';

const onChangeTabMock = jest.fn();

const tab1Name = 'My tab';
const tab1Id: StudioMenuTabName = 'tab1';
const tab1: StudioMenuTabType<StudioMenuTabName> = {
tabName: tab1Name,
tabId: tab1Id,
icon: <svg />,
};
const tab2Name = 'My second tab';
const tab2Id: StudioMenuTabName = 'tab2';
const tab2: StudioMenuTabType<StudioMenuTabName> = {
tabName: tab2Name,
tabId: tab2Id,
icon: <svg />,
};

describe('StudioContentMenu', () => {
afterEach(jest.clearAllMocks);

it('renders an empty contentMenu when there is no provided tabs', () => {
renderStudioContentMenu({ buttonTabs: [] });
const emptyMenu = screen.getByRole('tablist');
expect(emptyMenu).toBeInTheDocument();
});

it('renders first tab as selected if selectedTab is not provided', () => {
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const firstTab = screen.getByRole('tab', { name: tab1Name });
expect(firstTab).toHaveClass('selectedTab');
});

it('renders the title and icon of a given menu tab', () => {
const iconTitle = 'My icon';
renderStudioContentMenu({
buttonTabs: [
{
...tab1,
icon: <svg data-testid={iconTitle}></svg>,
},
],
});
const menuTab = screen.getByRole('tab', { name: tab1Name });
const menuIcon = screen.getByTestId(iconTitle);
expect(menuTab).toBeInTheDocument();
expect(menuIcon).toBeInTheDocument();
});

it('renders a tab with "to" prop as a link element', () => {
const link = 'url-link';
renderStudioContentMenu({
linkTabs: [
{
...tab1,
to: link,
},
],
});
const menuTab = screen.getByRole('tab', { name: tab1Name });
const linkTab = screen.getByRole('link', { name: tab1Name });
expect(menuTab).toBeInTheDocument();
expect(linkTab).toBeInTheDocument();
expect(linkTab).toHaveAttribute('href', link);
});

it('allows changing focus to next tab using keyboard', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const tab1Element = screen.getByRole('tab', { name: tab1Name });
await user.click(tab1Element);
const tab2Element = screen.getByRole('tab', { name: tab2Name });
expect(tab2Element).not.toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(tab2Element).toHaveFocus();
});

it('keeps focus on current tab if pressing keyDown when focus is on last tab in menu', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const tab2Element = screen.getByRole('tab', { name: tab2Name });
await user.click(tab2Element);
expect(tab2Element).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(tab2Element).toHaveFocus();
});

it('allows changing focus to previous tab using keyboard', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const tab2Element = screen.getByRole('tab', { name: tab2Name });
await user.click(tab2Element);
const tab1Element = screen.getByRole('tab', { name: tab1Name });
expect(tab1Element).not.toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(tab1Element).toHaveFocus();
});

it('keeps focus on current tab if pressing keyUp when focus is on first tab in menu', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const tab1Element = screen.getByRole('tab', { name: tab1Name });
await user.click(tab1Element);
expect(tab1Element).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(tab1Element).toHaveFocus();
});

it('calls onChangeTab when clicking enter on a tab with focus', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1, tab2],
});
const tab1Element = screen.getByRole('tab', { name: tab1Name });
await user.click(tab1Element);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onChangeTabMock).toHaveBeenCalledTimes(2);
expect(onChangeTabMock).toHaveBeenNthCalledWith(1, tab1Id);
expect(onChangeTabMock).toHaveBeenNthCalledWith(2, tab2Id);
});

it('calls onChangeTab when clicking on a menu tab', async () => {
const user = userEvent.setup();
renderStudioContentMenu({
buttonTabs: [tab1],
});
const menuTab = screen.getByRole('tab', { name: tab1Name });
await user.click(menuTab);
expect(onChangeTabMock).toHaveBeenCalledTimes(1);
expect(onChangeTabMock).toHaveBeenCalledWith(tab1Id);
});

it('calls onChangeTab when clicking on a menu tab with link', async () => {
const link = 'url-link';
const user = userEvent.setup();
renderStudioContentMenu({
linkTabs: [
{
...tab1,
to: link,
},
],
});
const menuTab = screen.getByRole('tab', { name: tab1Name });
await user.click(menuTab);
expect(onChangeTabMock).toHaveBeenCalledTimes(1);
expect(onChangeTabMock).toHaveBeenCalledWith(tab1Id);
});
});

const renderStudioContentMenu = ({
buttonTabs = [],
linkTabs = [],
}: Partial<StudioContentMenuWrapperProps<StudioMenuTabName>> = {}) => {
render(
<StudioContentMenu selectedTabId={undefined} onChangeTab={onChangeTabMock}>
{buttonTabs.map((buttonTab) => (
<StudioContentMenu.ButtonTab key={buttonTab.tabId} contentTab={buttonTab} />
))}
{linkTabs.map((linkTab) => (
<StudioContentMenu.LinkTab key={linkTab.tabId} contentTab={linkTab} />
))}
</StudioContentMenu>,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { Children, forwardRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import classes from './StudioContentMenu.module.css';
import { StudioContentMenuContextProvider } from './context/StudioContentMenuContext';

export type StudioContentMenuProps<TabId extends string> = {
children: ReactNode;
selectedTabId: TabId;
onChangeTab: (tabId: TabId) => void;
};

function StudioContentMenuForwarded<TabId extends string>(
{ children, selectedTabId, onChangeTab }: StudioContentMenuProps<TabId>,
ref: React.Ref<HTMLDivElement>,
): ReactElement {
const firstTabId = getFirstTabId(children);
const [selectedTab, setSelectedTab] = useState<string>(selectedTabId ?? firstTabId);
const handleChangeTab = (tabId: TabId) => {
onChangeTab(tabId);
setSelectedTab(tabId);
};

return (
<div ref={ref} className={classes.tabsContainer} role='tablist'>
<StudioContentMenuContextProvider selectedTabId={selectedTab} onChangeTab={handleChangeTab}>
{children}
</StudioContentMenuContextProvider>
</div>
);
}

export const StudioContentMenu = forwardRef<HTMLDivElement, StudioContentMenuProps<string>>(
StudioContentMenuForwarded,
);

const getFirstTabId = (children: ReactNode) => {
return Children.toArray(children).filter((child): child is ReactElement =>
React.isValidElement(child),
)[0]?.props.contentTab.tabId;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.contentMenuWrapper {
height: 300px;
width: 200px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { StudioContentMenu } from './';
import classes from './StudioContentMenuWrapper.module.css';
import type { StudioButtonTabType, StudioLinkTabType } from './types/StudioMenuTabType';

export type StudioContentMenuWrapperProps<TabId extends string> = {
buttonTabs: StudioButtonTabType<TabId>[];
linkTabs: StudioLinkTabType<TabId>[];
selectedTabId: TabId;
onChangeTab: (tabId: TabId) => void;
};

export function StudioContentMenuWrapper<TabId extends string>({
selectedTabId,
onChangeTab,
buttonTabs,
linkTabs,
}: StudioContentMenuWrapperProps<TabId>) {
return (
<div className={classes.contentMenuWrapper}>
<StudioContentMenu selectedTabId={selectedTabId} onChangeTab={onChangeTab}>
<StudioContentMenu.ButtonTab contentTab={buttonTabs[0]} />
<StudioContentMenu.ButtonTab contentTab={buttonTabs[1]} />
<StudioContentMenu.ButtonTab contentTab={buttonTabs[2]} />
<StudioContentMenu.LinkTab contentTab={linkTabs[0]} />
</StudioContentMenu>
</div>
);
}
Loading

0 comments on commit 3962016

Please sign in to comment.