diff --git a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.stories.tsx b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.stories.tsx index c8077409958..6419743b02f 100644 --- a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.stories.tsx +++ b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Meta, StoryFn } from '@storybook/react'; import { StudioCombobox } from './index'; +import { StudioModal } from '../StudioModal'; type Story = StoryFn; @@ -36,4 +37,20 @@ Preview.args = { multiple: true, }; +export const InModal: Story = (args) => { + return ( + + Open modal + + + Empty + Ole + Dole + Doffen + + + + ); +}; + export default meta; diff --git a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.test.tsx b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.test.tsx index d27ed7d962b..5ea48a9c1da 100644 --- a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.test.tsx @@ -1,7 +1,10 @@ +import type { ForwardedRef, PropsWithChildren } from 'react'; import React from 'react'; +import type { RenderOptions, RenderResult } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react'; import { StudioCombobox, type StudioComboboxProps } from './index'; import userEvent from '@testing-library/user-event'; +import { testRefForwarding } from '../../test-utils/testRefForwarding'; const options = { ole: 'Ole', @@ -135,15 +138,63 @@ describe('StudioCombobox', () => { await user.click(clearButton); await verifyOnValueChange({ onValueChange, expectedNumberOfCalls: 1, expectedValue: [] }); }); + + it('Renders the list box in portal mode by default', async () => { + const wrapperTestId = 'wrapper'; + const wrapper = ({ children }: PropsWithChildren) => ( +
{children}
+ ); + renderTestCombobox({}, { wrapper }); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); + expect(screen.getByTestId(wrapperTestId)).not.toContainElement(screen.getByRole('listbox')); + }); + + it('Renders the list box within the wrapper element when portal is set to false', async () => { + const wrapperTestId = 'wrapper'; + const wrapper = ({ children }: PropsWithChildren) => ( +
{children}
+ ); + renderTestCombobox({ portal: false }, { wrapper }); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); + expect(screen.getByTestId(wrapperTestId)).toContainElement(screen.getByRole('listbox')); + }); + + it('Renders the list box within the dialog element when used inside a dialog', async () => { + const user = userEvent.setup(); + const wrapper = ({ children }: PropsWithChildren) => {children}; + renderTestCombobox({}, { wrapper }); + await user.click(screen.getByRole('combobox')); + expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('listbox')); + }); + + it('Forwards the ref to the combobox element', () => { + testRefForwarding( + (ref) => renderTestCombobox({}, undefined, ref), + () => screen.getByRole('combobox'), + ); + }); + + it('Sets the ref to null when unmounted', () => { + const ref = React.createRef(); + const { unmount } = renderTestCombobox({}, undefined, ref); + unmount(); + expect(ref.current).toBeNull(); + }); }); -const renderTestCombobox = (props?: StudioComboboxProps) => { +const renderTestCombobox = ( + props?: StudioComboboxProps, + renderOptions?: RenderOptions, + ref?: ForwardedRef, +): RenderResult => render( - + {noResults} {options.ole} {options.dole} {options.doffen} , + renderOptions, ); -}; diff --git a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.tsx b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.tsx index f8018b03863..606f52bb0ac 100644 --- a/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.tsx +++ b/frontend/libs/studio-components/src/components/StudioCombobox/StudioCombobox.tsx @@ -1,15 +1,32 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useCallback, useState } from 'react'; import { Combobox } from '@digdir/designsystemet-react'; //TODO: Update import path when v1 of the Design system has been updated to export it from index: https://github.com/Altinn/altinn-studio/issues/13531 import type { ComboboxProps } from '@digdir/designsystemet-react/dist/types/components/form/Combobox/Combobox'; import type { WithoutAsChild } from '../../types/WithoutAsChild'; +import { useForwardedRef } from '@studio/hooks'; +import { isWithinDialog } from './isWithinDialog'; export type StudioComboboxProps = WithoutAsChild; export const StudioCombobox = forwardRef( - ({ children, size = 'sm', ...rest }, ref): JSX.Element => { + ({ children, size = 'sm', portal: givenPortal = true, ...rest }, ref): JSX.Element => { + const forwardedRef = useForwardedRef(ref); + const [portal, setPortal] = useState(givenPortal); + + const removePortalIfInDialog = useCallback((node: HTMLInputElement | null): void => { + if (node && isWithinDialog(node)) setPortal(false); + }, []); + + const internalRef = useCallback( + (node: HTMLInputElement | null): void => { + forwardedRef.current = node; + removePortalIfInDialog(node); + }, + [forwardedRef, removePortalIfInDialog], + ); + return ( - + {children} ); diff --git a/frontend/libs/studio-components/src/components/StudioCombobox/isWithinDialog.test.tsx b/frontend/libs/studio-components/src/components/StudioCombobox/isWithinDialog.test.tsx new file mode 100644 index 00000000000..5033790e35e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCombobox/isWithinDialog.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { isWithinDialog } from './isWithinDialog'; + +describe('isWithinDialog', () => { + it('Returns true when the element is inside a dialog', () => { + render( + + , + ); + const button = screen.getByRole('button'); + expect(isWithinDialog(button)).toBe(true); + }); + + it('Returns false when the element is not inside a dialog', () => { + render(