diff --git a/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.module.css b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.module.css new file mode 100644 index 00000000000..a0c0eef112a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.module.css @@ -0,0 +1,77 @@ +.radioGroupContainer { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-4); +} + +.radioGroup { + display: flex; + gap: var(--fds-spacing-4); + flex-wrap: wrap; +} + +.radioButton { + --radio-button-width: 240px; + --border-width: 3px; + + display: flex; + flex-direction: column; + padding-block: var(--fds-spacing-2); + padding-inline: var(--fds-spacing-4); + border-radius: var(--fds-sizing-2); + cursor: pointer; + position: relative; + width: var(--radio-button-width); + border: var(--border-width) solid transparent; +} + +.input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.radioButton:has(input:checked) { + border-color: var(--fds-semantic-border-action-default); +} + +.textContent { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-4); +} + +.title { + font-weight: bold; + text-align: left; +} + +.text { + word-wrap: break-word; + word-break: break-word; + white-space: normal; +} + +.success { + background-color: var(--fds-semantic-surface-success-subtle); +} + +.success:hover { + background-color: var(--fds-semantic-surface-success-subtle-hover); +} + +.info { + background-color: var(--fds-semantic-surface-action-first-subtle); +} + +.info:hover { + background-color: var(--fds-semantic-surface-action-first-subtle-hover); +} + +.warning { + background-color: var(--fds-semantic-surface-warning-subtle); +} + +.warning:hover { + background-color: var(--fds-semantic-surface-warning-subtle-hover); +} diff --git a/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.stories.tsx b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.stories.tsx new file mode 100644 index 00000000000..649ba246afa --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.stories.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { StudioStatusRadioGroup, type StudioStatusRadioGroupProps } from './StudioStatusRadioGroup'; + +const options: StudioStatusRadioGroupProps['options'] = [ + { + value: 'value1', + title: 'Miljø 1', + text: 'Sist publisert 11.06.2023 kl 14:03', + color: 'success', + }, + { + value: 'value2', + title: 'Miljø 2', + text: 'Sist publisert 11.06.2023 kl 14:03', + color: 'success', + }, + { value: 'value3', title: 'Miljø 3', text: 'Forløpig ingen publiseringer', color: 'info' }, + { + value: 'value4', + title: 'Miljø 4', + text: 'Applikasjonen er utilgjengelig i miljø', + color: 'warning', + }, +]; + +type Story = StoryFn; + +const meta: Meta = { + title: 'Components/StudioStatusRadioGroup', + component: StudioStatusRadioGroup, + argTypes: {}, +}; + +export const Preview: Story = () => { + const [selectedValue, setSelectedValue] = useState(); + + return ( + setSelectedValue(value)} + defaultValue={selectedValue} + /> + ); +}; + +export default meta; diff --git a/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.test.tsx b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.test.tsx new file mode 100644 index 00000000000..60771f8e28e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StudioStatusRadioGroup, type StudioStatusRadioGroupProps } from './StudioStatusRadioGroup'; + +const mockTitle1: string = 'Success'; +const mockTitle2: string = 'Info'; +const mockTitle3: string = 'Warning'; + +const mockText1: string = 'Success text'; +const mockText2: string = 'Info text'; +const mockText3: string = 'Warning text'; + +const mockValue1: string = 'success'; +const mockValue2: string = 'info'; +const mockValue3: string = 'warning'; + +const mockOption1: StudioStatusRadioGroupProps['options'][number] = { + title: mockTitle1, + text: mockText1, + color: 'success', + value: mockValue1, +}; +const mockOption2: StudioStatusRadioGroupProps['options'][number] = { + title: mockTitle2, + text: mockText2, + color: 'info', + value: mockValue2, +}; +const mockOption3: StudioStatusRadioGroupProps['options'][number] = { + title: mockTitle3, + text: mockText3, + color: 'warning', + value: mockValue3, +}; + +const mockOptions: StudioStatusRadioGroupProps['options'] = [mockOption1, mockOption2, mockOption3]; +const mockGroupTitle: string = 'Status group'; +const mockOnChange = jest.fn(); + +const defaultProps: StudioStatusRadioGroupProps = { + options: mockOptions, + title: mockGroupTitle, + onChange: mockOnChange, +}; + +describe('StudioStatusRadioGroup', () => { + it('renders radio buttons with titles and descriptions', () => { + renderStudioStatusRadioGroup(); + + expect(screen.getByText(mockGroupTitle)).toBeInTheDocument(); + expect(screen.getByText(mockTitle1)).toBeInTheDocument(); + expect(screen.getByText(mockText1)).toBeInTheDocument(); + expect(screen.getByText(mockTitle2)).toBeInTheDocument(); + expect(screen.getByText(mockText2)).toBeInTheDocument(); + expect(screen.getByText(mockTitle3)).toBeInTheDocument(); + expect(screen.getByText(mockText3)).toBeInTheDocument(); + }); + + it('allows selecting a radio button', async () => { + const user = userEvent.setup(); + renderStudioStatusRadioGroup(); + + const successRadioButton = screen.getByRole('radio', { name: `${mockTitle1} ${mockText1}` }); // Any way to do it without having both title and text? + const infoRadioButton = screen.getByRole('radio', { name: `${mockTitle2} ${mockText2}` }); + const warningRadioButton = screen.getByRole('radio', { name: `${mockTitle3} ${mockText3}` }); + + // Initially, no button should be selected + expect(successRadioButton).not.toBeChecked(); + expect(infoRadioButton).not.toBeChecked(); + expect(warningRadioButton).not.toBeChecked(); + + await user.click(infoRadioButton); + + expect(infoRadioButton).toBeChecked(); + expect(successRadioButton).not.toBeChecked(); + expect(warningRadioButton).not.toBeChecked(); + }); + + it('calls onChange with correct value when a radio button is selected', async () => { + const user = userEvent.setup(); + renderStudioStatusRadioGroup(); + + const infoRadioButton = screen.getByRole('radio', { name: `${mockTitle2} ${mockText2}` }); + await user.click(infoRadioButton); + + expect(mockOnChange).toHaveBeenCalledWith(mockValue2); + expect(mockOnChange).toHaveBeenCalledTimes(2); // Why is this being called twice? + }); + + it('renders with default value selected', () => { + renderStudioStatusRadioGroup({ defaultValue: mockValue2 }); + + const successRadioButton = screen.getByRole('radio', { name: `${mockTitle1} ${mockText1}` }); + const infoRadioButton = screen.getByRole('radio', { name: `${mockTitle2} ${mockText2}` }); + + expect(infoRadioButton).toBeChecked(); + expect(successRadioButton).not.toBeChecked(); + }); + + it('applies correct aria attributes for accessibility', () => { + renderStudioStatusRadioGroup(); + + const successRadioButton = screen.getByRole('radio', { name: `${mockTitle1} ${mockText1}` }); + const inputId = `${mockGroupTitle}-${mockValue1}`; + + expect(successRadioButton).toHaveAttribute('aria-labelledby', `${inputId}-title`); + expect(successRadioButton).toHaveAttribute('aria-describedby', `${inputId}-text`); + }); + + it('focuses on the radio button when clicked', async () => { + const user = userEvent.setup(); + renderStudioStatusRadioGroup(); + + const infoRadioButton = screen.getByRole('radio', { name: `${mockTitle2} ${mockText2}` }); + await user.click(infoRadioButton); + + expect(infoRadioButton).toHaveFocus(); + }); +}); + +const renderStudioStatusRadioGroup = (props: Partial = {}) => { + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.tsx b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.tsx new file mode 100644 index 00000000000..1290050579e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/StudioStatusRadioGroup.tsx @@ -0,0 +1,70 @@ +import React, { type ChangeEvent, type ReactElement } from 'react'; +import classes from './StudioStatusRadioGroup.module.css'; +import cn from 'classnames'; +import { StudioLabelAsParagraph } from '../StudioLabelAsParagraph'; +import { StudioParagraph } from '../StudioParagraph'; + +type StudioStatusRadioButtonColor = 'success' | 'info' | 'warning'; + +export type StudioStatusRadioButtonItem = { + title: string; + text: string; + color: StudioStatusRadioButtonColor; + value: string; +}; + +export type StudioStatusRadioGroupProps = { + options: StudioStatusRadioButtonItem[]; + title: string; + defaultValue?: string; + onChange?: (value: string) => void; +}; + +export const StudioStatusRadioGroup = ({ + options, + title: name, + defaultValue, + onChange, +}: StudioStatusRadioGroupProps): ReactElement => { + const handleChange = (event: ChangeEvent) => { + if (onChange) { + onChange(event.target.value); + } + }; + + return ( +
+ {name} +
+ {options.map(({ title, text, color, value }: StudioStatusRadioButtonItem) => { + const inputId = `${name}-${value}`; + const inputTitleId = `${inputId}-title`; + const inputTextId = `${inputId}-text`; + return ( + + ); + })} +
+
+ ); +}; diff --git a/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/index.ts b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/index.ts new file mode 100644 index 00000000000..2d839ffa159 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioStatusRadioGroup/index.ts @@ -0,0 +1 @@ +export { StudioStatusRadioGroup } from './StudioStatusRadioGroup'; diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index 292f091f9df..1d951fa9850 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -45,6 +45,7 @@ export * from './StudioPageSpinner'; export * from './StudioParagraph'; export * from './StudioPopover'; export * from './StudioProperty'; +export * from './StudioStatusRadioGroup'; export * from './StudioRecommendedNextAction'; export * from './StudioRedirectBox'; export * from './StudioResizableLayout';