Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Use StudioContentMenu in ContentLibrary #13927

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,12 @@
.menuContainer {
height: 100%;
background-color: var(--fds-semantic-surface-action-second-subtle);
}

.tabsContainer {
display: flex;
flex-direction: column;
gap: var(--fds-border_width-default);
background-color: var(--fds-semantic-border-action-second-subtle);
padding: var(--fds-border_width-default);
}
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: 'StudioContentMenuButtonTabProps<TabId>[]' },
},
},
linkTabs: {
control: 'object',
description:
'Array of link menu tabs with icons, names, and ids. Provide an optional link-element to return `props` in renderTab.',
table: {
type: { summary: 'StudioContentMenuLinkTabProps<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 />,
renderTab: (props) => <a href={'https://next.storybook.designsystemet.no'} {...props} />,
},
],
onChangeTab: () => {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { StudioContentMenu } from './';
import type { StudioContentMenuWrapperProps } from './StudioContentMenuWrapper';
import type { StudioContentMenuButtonTabProps } from './StudioContentMenuButtonTab';

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

const onChangeTabMock = jest.fn();

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

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

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('selected');
});

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

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,
renderTab: (props) => <a href={link} {...props} />,
},
],
});
const linkTab = screen.getByRole('tab', { name: tab1Name });
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);
});
});

const renderStudioContentMenu = ({
buttonTabs = [],
linkTabs = [],
}: Partial<StudioContentMenuWrapperProps<StudioMenuTabName>> = {}) => {
render(
<StudioContentMenu selectedTabId={undefined} onChangeTab={onChangeTabMock}>
{buttonTabs.map((buttonTab) => (
<StudioContentMenu.ButtonTab
key={buttonTab.tabId}
icon={buttonTab.icon}
tabId={buttonTab.tabId}
tabName={buttonTab.tabName}
/>
))}
{linkTabs.map((linkTab) => (
<StudioContentMenu.LinkTab
key={linkTab.tabId}
icon={linkTab.icon}
tabId={linkTab.tabId}
tabName={linkTab.tabName}
renderTab={linkTab.renderTab}
/>
))}
</StudioContentMenu>,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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<TabId>(selectedTabId ?? firstTabId);

const handleChangeTab = (tabId: TabId) => {
onChangeTab(tabId);
setSelectedTab(tabId);
};

const isTabSelected = (tabId: TabId) => selectedTab === tabId;

return (
<div ref={ref} className={classes.menuContainer}>
<div ref={ref} className={classes.tabsContainer} role='tablist'>
<StudioContentMenuContextProvider
isTabSelected={isTabSelected}
onChangeTab={handleChangeTab}
>
{children}
</StudioContentMenuContextProvider>
</div>
</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.tabId;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.buttonTab {
all: unset;
display: var(--tab-display);
align-items: var(--tab-align-items);
width: var(--tab-width);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import React from 'react';
import { useTabProps } from '../hooks/useTabProps';

export type StudioContentMenuButtonTabProps<TabId extends string> = {
icon: ReactNode;
tabName: string;
tabId: TabId;
};

export function StudioContentMenuButtonTab<TabId extends string>({
icon,
tabName,
tabId,
}: StudioContentMenuButtonTabProps<TabId>): React.ReactElement {
const props = useTabProps(icon, tabName, tabId);

return <button {...props} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StudioContentMenuButtonTab } from './StudioContentMenuButtonTab';
export type { StudioContentMenuButtonTabProps } from './StudioContentMenuButtonTab';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import type React from 'react';
import { useTabProps } from '../hooks/useTabProps';

export type StudioContentMenuLinkTabProps<TabId extends string> = {
icon: ReactNode;
tabName: string;
tabId: TabId;
renderTab: (props: React.HTMLAttributes<HTMLAnchorElement>) => React.ReactElement;
};

export function StudioContentMenuLinkTab<TabId extends string>({
icon,
tabName,
tabId,
renderTab,
}: StudioContentMenuLinkTabProps<TabId>): React.ReactElement {
const props = useTabProps(icon, tabName, tabId);

return renderTab(props);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StudioContentMenuLinkTab } from './StudioContentMenuLinkTab';
export type { StudioContentMenuLinkTabProps } from './StudioContentMenuLinkTab';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.contentMenuWrapper {
height: 300px;
width: 20vw;
}
Loading
Loading