diff --git a/frontend/dashboard/components/Resources/Resources.tsx b/frontend/dashboard/components/Resources/Resources.tsx index e74d6b57bca..fbc8f8fe173 100644 --- a/frontend/dashboard/components/Resources/Resources.tsx +++ b/frontend/dashboard/components/Resources/Resources.tsx @@ -64,9 +64,9 @@ const resources: Resource[] = [ ), }, { - label: 'dashboard.resource_slack_label', - description: 'dashboard.resource_slack_description', - url: 'https://altinn.slack.com', + label: 'dashboard.resource_contact_label', + description: 'dashboard.resource_contact_description', + url: 'https://altinn.studio/contact', icon: ( >; + +const meta: Meta> = { + title: 'Components/StudioContentMenu', + component: StudioContentMenuStoryExample, + argTypes: { + buttonTabs: { + control: 'object', + description: 'Array of button menu tabs with icons, names, and ids.', + table: { + type: { summary: 'StudioContentMenuButtonTabProps[]' }, + }, + }, + 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[]' }, + }, + }, + selectedTabId: { + table: { disable: true }, + }, + onChangeTab: { + table: { disable: true }, + }, + }, +}; + +export default meta; + +type StudioMenuTabName = 'booksTab' | 'videosTab' | 'tabWithVeryLongTabName' | 'tabAsLink'; + +export const Preview: Story = (args: StudioContentMenuStoryExampleProps) => ( + +); + +Preview.args = { + buttonTabs: [ + { + tabId: 'booksTab', + tabName: 'Bøker', + icon: , + }, + { + tabId: 'videosTab', + tabName: 'Filmer', + icon: , + }, + { + tabId: 'tabWithVeryLongTabName', + tabName: 'LoremIpsumLoremIpsumLoremIpsum', + icon: , + }, + ], + linkTabs: [ + { + tabId: 'tabAsLink', + tabName: 'Gå til Designsystemet', + icon: , + renderTab: (props) => , + }, + ], + onChangeTab: () => {}, +}; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.test.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.test.tsx new file mode 100644 index 00000000000..5133feddaf4 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { StudioContentMenu, type StudioContentMenuLinkTabProps } from './'; +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 = { + tabName: tab1Name, + tabId: tab1Id, + icon: , +}; +const tab2Name = 'My second tab'; +const tab2Id: StudioMenuTabName = 'tab2'; +const tab2: StudioContentMenuButtonTabProps = { + tabName: tab2Name, + tabId: tab2Id, + icon: , +}; + +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 a not selected tab without selected style', () => { + renderStudioContentMenu({ + buttonTabs: [tab1, tab2], + }); + const notSelectedTab = screen.getByRole('tab', { name: tab2Name }); + expect(notSelectedTab).not.toHaveClass('selected'); + }); + + it('renders a selected tab with tabIndex 0', () => { + renderStudioContentMenu({ + buttonTabs: [tab1], + }); + const menuTab = screen.getByRole('tab', { name: tab1Name }); + expect(menuTab).toHaveAttribute('tabIndex', '0'); + }); + + it('renders a not selected tab with tabIndex -1', () => { + renderStudioContentMenu({ + buttonTabs: [tab1, tab2], + }); + const notSelectedTab = screen.getByRole('tab', { name: tab2Name }); + expect(notSelectedTab).toHaveAttribute('tabIndex', '-1'); + }); + + 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: , + }, + ], + }); + const menuTab = screen.getByRole('tab', { name: tab1Name }); + const menuTabTitle = screen.getByTitle(tab1Name); + const menuIcon = screen.getByTestId(iconTitle); + expect(menuTab).toBeInTheDocument(); + expect(menuTabTitle).toBeInTheDocument(); + expect(menuIcon).toBeInTheDocument(); + }); + + it('renders a linkTab as a link element', () => { + const link = 'url-link'; + renderStudioContentMenu({ + linkTabs: [ + { + ...tab1, + renderTab: (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); + }); +}); + +type RenderStudioContentMenuProps = { + buttonTabs: StudioContentMenuButtonTabProps[]; + linkTabs: StudioContentMenuLinkTabProps[]; +}; + +const renderStudioContentMenu = ({ + buttonTabs = [], + linkTabs = [], +}: Partial> = {}) => { + render( + + {buttonTabs.map((buttonTab) => ( + + ))} + {linkTabs.map((linkTab) => ( + + ))} + , + ); +}; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.tsx new file mode 100644 index 00000000000..deb4ceef71d --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenu.tsx @@ -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 = { + children: ReactNode; + selectedTabId: TabId; + onChangeTab: (tabId: TabId) => void; +}; + +function StudioContentMenuForwarded( + { children, selectedTabId, onChangeTab }: StudioContentMenuProps, + ref: React.Ref, +): ReactElement { + const firstTabId = getFirstTabId(children); + const [selectedTab, setSelectedTab] = useState(selectedTabId ?? firstTabId); + + const handleChangeTab = (tabId: TabId) => { + onChangeTab(tabId); + setSelectedTab(tabId); + }; + + const isTabSelected = (tabId: TabId) => selectedTab === tabId; + + return ( +
+
+ + {children} + +
+
+ ); +} + +export const StudioContentMenu = forwardRef>( + StudioContentMenuForwarded, +); + +const getFirstTabId = (children: ReactNode) => { + return Children.toArray(children).filter((child): child is ReactElement => + React.isValidElement(child), + )[0]?.props.tabId; +}; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/StudioContentMenuButtonTab.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/StudioContentMenuButtonTab.tsx new file mode 100644 index 00000000000..b08f457d793 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/StudioContentMenuButtonTab.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { useTabProps } from '../hooks/useTabProps'; +import { StudioButton } from '@studio/components'; + +export type StudioContentMenuButtonTabProps = { + icon: ReactNode; + tabName: string; + tabId: TabId; +}; + +export function StudioContentMenuButtonTab({ + icon, + tabName, + tabId, +}: StudioContentMenuButtonTabProps): React.ReactElement { + const props = useTabProps(icon, tabName, tabId); + + return ; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/index.ts b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/index.ts new file mode 100644 index 00000000000..4487fc8824d --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuButtonTab/index.ts @@ -0,0 +1,2 @@ +export { StudioContentMenuButtonTab } from './StudioContentMenuButtonTab'; +export type { StudioContentMenuButtonTabProps } from './StudioContentMenuButtonTab'; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/StudioContentMenuLinkTab.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/StudioContentMenuLinkTab.tsx new file mode 100644 index 00000000000..7d4d2df1190 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/StudioContentMenuLinkTab.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { useTabProps } from '../hooks/useTabProps'; +import { StudioButton } from '@studio/components'; + +export type StudioContentMenuLinkTabProps = { + icon: ReactNode; + tabName: string; + tabId: TabId; + renderTab: (props: React.HTMLAttributes) => React.ReactElement; +}; + +export function StudioContentMenuLinkTab({ + icon, + tabName, + tabId, + renderTab, +}: StudioContentMenuLinkTabProps): React.ReactElement { + const props = useTabProps(icon, tabName, tabId); + + return {renderTab(props)}; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/index.ts b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/index.ts new file mode 100644 index 00000000000..49029d77d44 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuLinkTab/index.ts @@ -0,0 +1,2 @@ +export { StudioContentMenuLinkTab } from './StudioContentMenuLinkTab'; +export type { StudioContentMenuLinkTabProps } from './StudioContentMenuLinkTab'; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.module.css b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.module.css new file mode 100644 index 00000000000..ef296e30ece --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.module.css @@ -0,0 +1,4 @@ +.contentMenuWrapper { + height: 300px; + width: 20vw; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.tsx new file mode 100644 index 00000000000..9be82a755ed --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioContentMenuStoryExample.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { StudioContentMenu } from './'; +import classes from './StudioContentMenuStoryExample.module.css'; +import type { StudioContentMenuButtonTabProps } from './StudioContentMenuButtonTab'; +import type { StudioContentMenuLinkTabProps } from './StudioContentMenuLinkTab'; + +export type StudioContentMenuStoryExampleProps = { + buttonTabs: StudioContentMenuButtonTabProps[]; + linkTabs: StudioContentMenuLinkTabProps[]; + selectedTabId: TabId; + onChangeTab: (tabId: TabId) => void; +}; + +export function StudioContentMenuStoryExample({ + selectedTabId, + onChangeTab, + buttonTabs, + linkTabs, +}: StudioContentMenuStoryExampleProps) { + return ( +
+ + + + + + +
+ ); +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.module.css b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.module.css new file mode 100644 index 00000000000..f48508dd446 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.module.css @@ -0,0 +1,12 @@ +.tabIcon { + display: flex; + align-items: center; + font-size: var(--fds-spacing-8); + color: var(--fds-semantic-text-neutral-default); +} + +.tabTitle { + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.tsx new file mode 100644 index 00000000000..842952acba5 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/StudioMenuTab.tsx @@ -0,0 +1,20 @@ +import classes from './StudioMenuTab.module.css'; +import { StudioParagraph } from '@studio/components'; +import type { ReactNode } from 'react'; +import React from 'react'; + +type StudioMenuTabProps = { + icon: ReactNode; + tabName: string; +}; + +export function StudioMenuTab({ icon, tabName }: StudioMenuTabProps) { + return ( + <> +
{icon}
+ + {tabName} + + + ); +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/index.ts b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/index.ts new file mode 100644 index 00000000000..b78f5ac6cea --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/StudioMenuTab/index.ts @@ -0,0 +1 @@ +export { StudioMenuTab } from './StudioMenuTab'; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.test.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.test.tsx new file mode 100644 index 00000000000..a10d9ee81e7 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { + StudioContentMenuContextProvider, + useStudioContentMenuContext, +} from './StudioContentMenuContext'; + +describe('StudioContentMenuContext', () => { + it('should render children', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'My button' })).toBeInTheDocument(); + }); + + it('should provide a useStudioContentMenuContext hook', () => { + const TestComponent = () => { + const {} = useStudioContentMenuContext(); + return
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('context')).toHaveTextContent(''); + }); + + it('should throw an error when useStudioContentMenuContext is used outside of a StudioContentMenuContextProvider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const TestComponent = () => { + useStudioContentMenuContext(); + return
Test
; + }; + + expect(() => render()).toThrow( + 'useStudioContentMenuContext must be used within a StudioContentMenuContextProvider', + ); + expect(consoleError).toHaveBeenCalled(); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.tsx new file mode 100644 index 00000000000..450a52bd27a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/context/StudioContentMenuContext.tsx @@ -0,0 +1,39 @@ +import React, { type ReactNode, createContext, useContext } from 'react'; + +export type StudioContentMenuContextProps = { + isTabSelected: (tabId: TabId) => boolean; + onChangeTab: (tabId: TabId) => void; +}; + +const StudioContentMenuContext = + createContext>>(undefined); + +export type StudioContentMenuContextProviderProps = { + children: ReactNode; +} & StudioContentMenuContextProps; + +export function StudioContentMenuContextProvider({ + children, + isTabSelected, + onChangeTab, +}: Partial>) { + return ( + + {children} + + ); +} + +export const useStudioContentMenuContext = (): Partial< + StudioContentMenuContextProps +> => { + const context = useContext(StudioContentMenuContext) as Partial< + StudioContentMenuContextProps + >; + if (context === undefined) { + throw new Error( + 'useStudioContentMenuContext must be used within a StudioContentMenuContextProvider', + ); + } + return context; +}; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.module.css b/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.module.css new file mode 100644 index 00000000000..316350e2078 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.module.css @@ -0,0 +1,28 @@ +.tab { + display: flex; + align-items: center; + justify-content: flex-start; + box-sizing: border-box; + gap: var(--fds-spacing-2); + padding: var(--fds-spacing-3); + width: 100%; + background-color: var(--fds-semantic-surface-action-second-subtle); + cursor: pointer; + border-left: var(--fds-sizing-1) solid transparent; + border-width: 0 0 0 var(--fds-sizing-1); + border-radius: 0; +} + +.tab.selected { + border-left: var(--fds-sizing-1) solid var(--semantic-surface-action-default); + background-color: white; +} + +.tab:hover, +.selectedTab:hover { + background-color: var(--fds-semantic-surface-action-no_fill-hover); +} + +.tab:focus { + z-index: 1; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.tsx new file mode 100644 index 00000000000..d1c6b6df440 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/hooks/useTabProps.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { useStudioContentMenuContext } from '../context/StudioContentMenuContext'; +import type { HTMLTabElement } from '../utils/dom-utils'; +import { moveFocus } from '../utils/dom-utils'; +import classes from './useTabProps.module.css'; +import { StudioMenuTab } from '../StudioMenuTab'; +import cn from 'classnames'; + +export function useTabProps(icon: ReactNode, tabName: string, tabId: TabId) { + const { isTabSelected, onChangeTab } = useStudioContentMenuContext(); + + const handleKeyDown = (event: React.KeyboardEvent) => { + moveFocus(event); + }; + + const props: React.HTMLAttributes = { + className: cn(classes.tab, isTabSelected(tabId) ? classes.selected : null), + role: 'tab', + tabIndex: isTabSelected(tabId) ? 0 : -1, + onClick: () => onChangeTab(tabId), + onKeyDown: handleKeyDown, + children: , + title: tabName, + }; + + return props; +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/index.ts b/frontend/libs/studio-components/src/components/StudioContentMenu/index.ts new file mode 100644 index 00000000000..de949b46705 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/index.ts @@ -0,0 +1,15 @@ +export type { StudioContentMenuButtonTabProps } from './StudioContentMenuButtonTab'; +export type { StudioContentMenuLinkTabProps } from './StudioContentMenuLinkTab'; +import { StudioContentMenu as StudioContentMenuRoot } from './StudioContentMenu'; +import { StudioContentMenuButtonTab } from './StudioContentMenuButtonTab'; +import { StudioContentMenuLinkTab } from './StudioContentMenuLinkTab'; + +type StudioContentMenuComponent = typeof StudioContentMenuRoot & { + ButtonTab: typeof StudioContentMenuButtonTab; + LinkTab: typeof StudioContentMenuLinkTab; +}; + +export const StudioContentMenu = StudioContentMenuRoot as StudioContentMenuComponent; + +StudioContentMenu.ButtonTab = StudioContentMenuButtonTab; +StudioContentMenu.LinkTab = StudioContentMenuLinkTab; diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.test.tsx b/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.test.tsx new file mode 100644 index 00000000000..d2eedddce3e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { HTMLTabElement } from './dom-utils'; +import { moveFocus } from './dom-utils'; + +describe('moveFocus', () => { + it('moves focus to the next tab when ArrowDown is pressed', () => { + renderTabList(); + simulateFocusMove('ArrowDown', 'tab-1', 'tab-2'); + simulateFocusMove('ArrowDown', 'tab-2', 'tab-3'); + }); + + it('moves focus to the previous tab when ArrowUp is pressed', () => { + renderTabList(); + simulateFocusMove('ArrowUp', 'tab-3', 'tab-2'); + simulateFocusMove('ArrowUp', 'tab-2', 'tab-1'); + }); +}); + +const renderTabList = () => { + render( +
+ + + +
, + ); +}; + +function simulateFocusMove(key: string, initialTabId: string, expectedFocusedTabId: string) { + const initialTab = screen.getByTestId(initialTabId); + initialTab.focus(); + const event = { + key, + currentTarget: initialTab, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + moveFocus(event); + expect(screen.getByTestId(expectedFocusedTabId)).toHaveFocus(); +} diff --git a/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.ts b/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.ts new file mode 100644 index 00000000000..133f833a0ee --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioContentMenu/utils/dom-utils.ts @@ -0,0 +1,50 @@ +import type React from 'react'; + +export type HTMLTabElement = HTMLAnchorElement | HTMLButtonElement; + +export function moveFocus(event: React.KeyboardEvent) { + const nextTab = getNextTab(event); + if (nextTab) { + event.preventDefault(); + nextTab.tabIndex = 0; + nextTab.focus(); + event.currentTarget.tabIndex = -1; + } +} + +function getNextTab({ key, currentTarget }: React.KeyboardEvent) { + const tablist = getParentTablist(currentTarget); + const tabs = getTabs(tablist); + switch (key) { + case 'ArrowUp': + return getTabElementAbove(tabs, currentTarget); + case 'ArrowDown': + return getTabElementBelow(tabs, currentTarget); + default: + return null; + } +} + +function getTabElementAbove(tabs: HTMLTabElement[], currentTab: HTMLTabElement) { + const currentIndex = tabs.indexOf(currentTab); + if (currentIndex > 0) { + return tabs[currentIndex - 1]; + } + return null; +} + +function getTabElementBelow(tabs: HTMLTabElement[], currentTab: HTMLTabElement) { + const currentIndex = tabs.indexOf(currentTab); + if (currentIndex < tabs.length - 1) { + return tabs[currentIndex + 1] as HTMLTabElement; + } + return null; +} + +function getTabs(tablist: HTMLDivElement): HTMLTabElement[] { + return Array.from(tablist.querySelectorAll('[role="tab"]')) as HTMLTabElement[]; +} + +function getParentTablist(element: HTMLTabElement): HTMLDivElement | null { + return element.closest('[role="tablist"]'); +} diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index ecb388eda43..033e84e0424 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -12,6 +12,7 @@ export * from './StudioCheckbox'; export * from './StudioCodeFragment'; export * from './StudioCodelistEditor'; export * from './StudioCombobox'; +export * from './StudioContentMenu'; export * from './StudioDecimalInput'; export * from './StudioDeleteButton'; export * from './StudioDisplayTile';