diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css index de30179c56d..4c8d0739351 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css @@ -1,5 +1,6 @@ .textfieldCell, -.textareaCell { +.textareaCell, +.textResourceCell { padding: var(--fds-spacing-1) 0; font-size: var(--studio-input-table-font-size); } @@ -12,8 +13,24 @@ display: inline-flex; } -.textfieldCell:not(:hover) input:not(:hover):not(:active):not(:focus), -.textareaCell:not(:hover) textarea:not(:hover):not(:active):not(:focus) { +:is( + .textfieldCell:not(:hover) input, + .textareaCell:not(:hover) textarea, + .textResourceCell:not(:hover) input + ):not(:hover):not(:active):not(:focus), +.textResourceCell:not(:hover) div:has(input:not(:hover):not(:active):not(:focus)) { background-color: transparent; border-color: transparent; } + +.textResourceCell:not(:hover) .textInput div:has(input:not(:hover):not(:active):not(:focus)) svg { + visibility: hidden; +} + +.textResourceCell:not(:hover):not(:focus-within) .toggle { + visibility: hidden; +} + +.textResourceCell .currentTextId { + padding-inline: var(--fds-spacing-2); +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx new file mode 100644 index 00000000000..4716ae74c49 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx @@ -0,0 +1,65 @@ +import { StudioTable } from '../../StudioTable'; +import { type FocusEvent, ForwardedRef, ReactElement, useCallback } from 'react'; +import React from 'react'; +import { BaseInputCell } from './BaseInputCell'; +import { + StudioTextResourceInput, + StudioTextResourceInputProps, +} from '../../StudioTextResourceInput/StudioTextResourceInput'; +import cn from 'classnames'; +import classes from './Cell.module.css'; +import { useEventProps } from './useEventProps'; +import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils'; +import { isCombobox } from '../dom-utils/isCombobox'; + +export type CellTextResourceInputProps = StudioTextResourceInputProps & { + className?: string; +}; + +export class CellTextResource extends BaseInputCell { + render( + { className: givenClass, onFocus, ...rest }: CellTextResourceInputProps, + ref: ForwardedRef, + ): ReactElement { + const handleFocus = useCallback( + (event: FocusEvent): void => { + onFocus?.(event); + event.currentTarget.select(); + }, + [onFocus], + ); + + const eventProps = useEventProps({ onFocus: handleFocus, ...rest }); + + const className = cn(classes.textResourceCell, givenClass); + + return ( + + + + ); + } + + shouldMoveFocusOnArrowKey({ key, currentTarget }) { + if (isSomethingSelected(currentTarget)) return false; + switch (key) { + case 'ArrowUp': + return isCaretAtStart(currentTarget) && !isCombobox(currentTarget); + case 'ArrowDown': + return isCaretAtEnd(currentTarget) && !isCombobox(currentTarget); + case 'ArrowLeft': + return isCaretAtStart(currentTarget); + case 'ArrowRight': + return isCaretAtEnd(currentTarget); + } + } + + shouldMoveFocusOnEnterKey = () => false; +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts index 1af726ee49a..a4fd98dd819 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts @@ -8,12 +8,14 @@ import { Cell } from './Cell'; import type { CellCheckboxProps } from './CellCheckbox'; import { CellCheckbox } from './CellCheckbox'; import type { InputCellComponent } from '../types/InputCellComponent'; +import { CellTextResource, CellTextResourceInputProps } from './CellTextResource'; type CellComponent = typeof Cell & { Textfield: InputCellComponent; Textarea: InputCellComponent; Button: InputCellComponent; Checkbox: InputCellComponent; + TextResource: InputCellComponent; }; export const StudioInputTableCell = Cell as CellComponent; @@ -22,5 +24,8 @@ StudioInputTableCell.Textfield = new CellTextfield('StudioInputTable.Cell.Textfi StudioInputTableCell.Textarea = new CellTextarea('StudioInputTable.Cell.Textarea').component(); StudioInputTableCell.Button = new CellButton('StudioInputTable.Cell.Button').component(); StudioInputTableCell.Checkbox = new CellCheckbox('StudioInputTable.Cell.Checkbox').component(); +StudioInputTableCell.TextResource = new CellTextResource( + 'StudioInputTable.Cell.TextResource', +).component(); StudioInputTableCell.displayName = 'StudioInputTable.Cell'; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx index a6c99c36970..54076e4aff3 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx @@ -15,6 +15,8 @@ import { headerCheckboxLabel, textareaLabel, textfieldLabel, + textResourceProps, + textResourceValueLabel, } from './test-data/testTableData'; import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; @@ -27,13 +29,15 @@ import type { EventName } from './types/EventName'; import type { EventProps } from './types/EventProps'; import type { EventPropName } from './types/EventPropName'; import { StringUtils } from '@studio/pure-functions'; +import { CellTextResourceInputProps } from './Cell/CellTextResource'; -type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button'; +type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button' | 'textResource'; type NativeElement = { checkbox: HTMLInputElement; textfield: HTMLInputElement; textarea: HTMLTextAreaElement; button: HTMLButtonElement; + textResource: HTMLInputElement; }[Name]; // Test data: @@ -108,18 +112,23 @@ describe('StudioInputTable', () => { expect(getTextfieldInRow(2)).toHaveFocus(); await user.keyboard('{ArrowRight}'); // Move right to textarea 2 expect(getTextareaInRow(2)).toHaveFocus(); + await user.keyboard('{ArrowRight}'); // Move right to text resource 2 + expect(getTextResourceValueInRow(2)).toHaveFocus(); + await user.keyboard('{ArrowRight}'); // Unselect text in text resource 2 + expect(getTextResourceValueInRow(2)).toHaveFocus(); await user.keyboard('{ArrowRight}'); // Move right to button 2 expect(getButtonInRow(2)).toHaveFocus(); await user.keyboard('{ArrowUp}'); // Move up to button 1 expect(getButtonInRow(1)).toHaveFocus(); - await user.keyboard('{ArrowLeft}'); // Move left to textarea 1 - expect(getTextareaInRow(1)).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); // Move left to text resource 1 + expect(getTextResourceValueInRow(1)).toHaveFocus(); }); type TextboxTestCase = () => HTMLInputElement | HTMLTextAreaElement; const textboxTestCases: { [key: string]: TextboxTestCase } = { textfield: () => getTextfieldInRow(2), textarea: () => getTextareaInRow(2), + textResource: () => getTextResourceValueInRow(2), }; type TextboxTestCaseName = keyof typeof textboxTestCases; const textboxTestCaseNames: TextboxTestCaseName[] = Object.keys(textboxTestCases); @@ -147,8 +156,8 @@ describe('StudioInputTable', () => { render(); const textbox = textboxTestCases[key](); await user.type(textbox, 'test'); - await user.keyboard('{ArrowRight}'); // Move focus out - await user.keyboard('{ArrowLeft}'); // Move focus back in - now the text should be selected + await user.click(document.body); // Move focus out + await user.click(textbox); // Move focus back in - now the text should be selected expect(textbox.selectionStart).toBe(0); expect(textbox.selectionEnd).toBe(4); }); @@ -246,6 +255,10 @@ describe('StudioInputTable', () => { render: (ref) => renderSingleButtonCell({ children: testLabel }, ref), getElement: () => getButton(testLabel), }, + textResource: { + render: (ref) => renderSingleTextResourceCell(textResourceProps(0), ref), + getElement: () => getTextbox(textResourceValueLabel(0)) as HTMLInputElement, + }, }; test.each(Object.keys(testCases))('%s', (key) => { @@ -332,6 +345,23 @@ describe('StudioInputTable', () => { }, }, }, + textResource: { + change: { + render: (onChange) => renderSingleTextResourceCell({ ...textResourceProps(0), onChange }), + action: (user) => user.type(screen.getByRole('textbox'), 'a'), + }, + focus: { + render: (onFocus) => renderSingleTextResourceCell({ ...textResourceProps(0), onFocus }), + action: (user) => user.click(screen.getByRole('textbox')), + }, + blur: { + render: (onBlur) => renderSingleTextResourceCell({ ...textResourceProps(0), onBlur }), + action: async (user) => { + await user.click(screen.getByRole('textbox')); + await user.tab(); + }, + }, + }, }; describe.each(Object.keys(testCases))('%s', (key) => { @@ -401,6 +431,16 @@ const renderSingleCheckboxCell = ( , ); +const renderSingleTextResourceCell = ( + props: CellTextResourceInputProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); + const getTable = (): HTMLTableElement => screen.getByRole('table'); const getCheckbox = (name: string): HTMLInputElement => screen.getByRole('checkbox', { name }) as HTMLInputElement; @@ -415,6 +455,8 @@ const getButton = (name: string): HTMLButtonElement => screen.getByRole('button', { name }) as HTMLButtonElement; const getButtonInRow = (rowNumber: number): HTMLButtonElement => getButton(buttonLabel(rowNumber)) as HTMLButtonElement; +const getTextResourceValueInRow = (rowNumber: number): HTMLInputElement => + getTextbox(textResourceValueLabel(rowNumber)) as HTMLInputElement; const expectCaretPosition = ( element: HTMLInputElement | HTMLTextAreaElement, @@ -438,7 +480,7 @@ const placeCaretAtPosition = ( position: number, ): void => element.setSelectionRange(position, position); -const expectedNumberOfColumns = 5; +const expectedNumberOfColumns = 6; const expectedNumberOfHeaderRows = 1; const expectedNumberOfBodyRows = 3; const expectedNumberOfRows = expectedNumberOfBodyRows + expectedNumberOfHeaderRows; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx new file mode 100644 index 00000000000..0e1a3403381 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx @@ -0,0 +1,17 @@ +import { isCombobox } from './isCombobox'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +describe('isCombobox', () => { + it('Returns true when the element is a combobox', () => { + render(); + const element = screen.getByRole('combobox'); + expect(isCombobox(element)).toBe(true); + }); + + it('Returns false when the element is not a combobox', () => { + render(); + const element = screen.getByRole('textbox'); + expect(isCombobox(element)).toBe(false); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts new file mode 100644 index 00000000000..0daebff689a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts @@ -0,0 +1,3 @@ +export function isCombobox(element: HTMLInputElement): boolean { + return element.getAttribute('role') === 'combobox'; +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx index f9555904705..8a8799bfc34 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx @@ -9,7 +9,9 @@ import { textareaHeader, textfieldHeader, textHeader, + textResourceHeader, } from './testTableData'; +import { textResourcesMock } from '../../../test-data/textResourcesMock'; export function TestTable(props: StudioInputTableProps): ReactElement { return ( @@ -21,6 +23,7 @@ export function TestTable(props: StudioInputTableProps): ReactElement { {textfieldHeader} {textareaHeader} {buttonHeader} + {textResourceHeader} @@ -53,6 +56,20 @@ function TestRow({ rowNumber: rn }: TestRowProps): ReactElement { name={testData.textareaName(rn)} label={testData.textareaLabel(rn)} /> + {}} + onChangeTextResource={() => {}} + texts={{ + editValue: 'Rediger verdi', + emptyResourceList: 'Fant ingen tekstressurser', + idLabel: 'ID:', + search: 'Søk', + textResourcePickerLabel: testData.textResourcePickerLabel(rn), + valueLabel: testData.textResourceValueLabel(rn), + }} + /> {testData.buttonLabel(rn)} ); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts index 4597ada6041..73bb0e623dc 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts +++ b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts @@ -1,8 +1,13 @@ +import { CellTextResourceInputProps } from '../Cell/CellTextResource'; +import { TextResourceInputTexts } from '../../StudioTextResourceInput/types/TextResourceInputTexts'; +import { textResourcesMock } from '../../../test-data/textResourcesMock'; + export const headerCheckboxLabel = 'Select all'; export const textHeader = 'Text'; export const textfieldHeader = 'Textfield'; export const textareaHeader = 'Textarea'; export const buttonHeader = 'Button'; +export const textResourceHeader = 'Text Resource'; export const checkboxValue = (rowNumber: number) => `checkboxValue${rowNumber}`; export const checkboxName = (rowNumber: number) => `checkboxName${rowNumber}`; export const checkboxLabel = (rowNumber: number) => `Checkbox ${rowNumber}`; @@ -12,3 +17,22 @@ export const textfieldLabel = (rowNumber: number) => `Textfield ${rowNumber}`; export const textareaName = (rowNumber: number) => `textarea${rowNumber}`; export const textareaLabel = (rowNumber: number) => `Textarea ${rowNumber}`; export const buttonLabel = (rowNumber: number) => `Button ${rowNumber}`; +export const textResourcePickerLabel = (rowNumber: number) => `Text resource ${rowNumber}`; +export const textResourceValueLabel = (rowNumber: number) => `Text value ${rowNumber}`; + +export const textResourceProps = (rowNumber: number): CellTextResourceInputProps => ({ + textResources: textResourcesMock, + texts: textResourceTexts(rowNumber), + currentId: 'land.NO', + onChangeCurrentId: jest.fn(), + onChangeTextResource: jest.fn(), +}); + +const textResourceTexts = (rowNumber: number): TextResourceInputTexts => ({ + editValue: 'Rediger verdi', + emptyResourceList: 'Fant ingen tekstressurser', + idLabel: 'ID:', + search: 'Søk', + textResourcePickerLabel: textResourcePickerLabel(rowNumber), + valueLabel: textResourceValueLabel(rowNumber), +}); diff --git a/frontend/libs/studio-components/src/components/StudioTextResourceInput/StudioTextResourceInput.tsx b/frontend/libs/studio-components/src/components/StudioTextResourceInput/StudioTextResourceInput.tsx index 74635162a16..6dfe5dd0e1a 100644 --- a/frontend/libs/studio-components/src/components/StudioTextResourceInput/StudioTextResourceInput.tsx +++ b/frontend/libs/studio-components/src/components/StudioTextResourceInput/StudioTextResourceInput.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, ReactElement } from 'react'; +import { ChangeEvent, forwardRef, HTMLAttributes, ReactElement } from 'react'; import React, { useState } from 'react'; import type { TextResource } from '../../types/TextResource'; import { StudioTextResourcePicker } from '../StudioTextResourcePicker'; @@ -6,137 +6,183 @@ import { StudioCodeFragment } from '../StudioCodeFragment'; import { ToggleGroup } from '@digdir/designsystemet-react'; import { PencilIcon, MagnifyingGlassIcon } from '@studio/icons'; import classes from './StudioTextResourceInput.module.css'; -import { StudioTextfield } from '../StudioTextfield'; +import { StudioTextfield, StudioTextfieldProps } from '../StudioTextfield'; import { changeTextResourceInList, editTextResourceValue, getTextResourceById } from './utils'; import { usePropState } from '@studio/hooks'; import type { TextResourceInputTexts } from './types/TextResourceInputTexts'; +import cn from 'classnames'; -export type StudioTextResourceInputProps = { +export type StudioTextResourceInputProps = TextResourceInputPropsBase & + HTMLAttributes; + +type TextResourceInputPropsBase = { currentId: string; + currentIdClass?: string; + inputClass?: string; onChangeCurrentId: (id: string) => void; onChangeTextResource: (textResource: TextResource) => void; textResources: TextResource[]; texts: TextResourceInputTexts; + toggleClass?: string; }; -export function StudioTextResourceInput({ - currentId: givenCurrentId, - onChangeTextResource, - onChangeCurrentId, - textResources: givenTextResources, - texts, -}: StudioTextResourceInputProps): ReactElement { - const [inputMode, setInputMode] = useState(InputMode.EditValue); - const [currentId, setCurrentId] = usePropState(givenCurrentId); - const [textResources, setTextResources] = usePropState(givenTextResources); - - const handleChangeCurrentId = (id: string) => { - setCurrentId(id); - onChangeCurrentId(id); - }; - - const handleTextResourceChange = (newTextResource: TextResource) => { - const newList = changeTextResourceInList(textResources, newTextResource); - setTextResources(newList); - onChangeTextResource(newTextResource); - }; - - return ( -
- - - -
- ); -} - -enum InputMode { +export const StudioTextResourceInput = forwardRef( + ( + { + currentId: givenCurrentId, + currentIdClass, + inputClass, + onChangeTextResource, + onChangeCurrentId, + onKeyDown, + textResources: givenTextResources, + texts, + toggleClass, + ...rest + }, + ref, + ): ReactElement => { + const [mode, setMode] = useState(Mode.EditValue); + const [currentId, setCurrentId] = usePropState(givenCurrentId); + const [textResources, setTextResources] = usePropState(givenTextResources); + + const handleChangeCurrentId = (id: string) => { + setCurrentId(id); + onChangeCurrentId(id); + }; + + const handleTextResourceChange = (newTextResource: TextResource) => { + const newList = changeTextResourceInList(textResources, newTextResource); + setTextResources(newList); + onChangeTextResource(newTextResource); + }; + + return ( +
+ + + +
+ ); + }, +); + +enum Mode { EditValue = 'editValue', Search = 'search', } type InputBoxProps = StudioTextResourceInputProps & { - inputMode: InputMode; + mode: Mode; }; -function InputBox({ - currentId, - inputMode, - onChangeCurrentId, - onChangeTextResource, - textResources, - texts, -}: InputBoxProps): ReactElement { - const currentTextResource = getTextResourceById(textResources, currentId); - - switch (inputMode) { - case InputMode.EditValue: - return ( - - ); - case InputMode.Search: - return ( - - ); - } -} +const InputBox = forwardRef( + ( + { + currentId, + inputClass, + mode, + onChangeCurrentId, + onChangeTextResource, + onKeyDown, + textResources, + texts, + ...rest + }, + ref, + ): ReactElement => { + const currentTextResource = getTextResourceById(textResources, currentId); + const className = cn(inputClass, classes.inputbox); + + switch (mode) { + case Mode.EditValue: + return ( + + ); + case Mode.Search: + return ( + + ); + } + }, +); type ValueFieldProps = { - label: string; textResource: TextResource; - onChange: (textResource: TextResource) => void; -}; - -function ValueField({ textResource, onChange, label }: ValueFieldProps): ReactElement { - const handleChange = (event: ChangeEvent) => { - const { value } = event.target; - const newTextResource = editTextResourceValue(textResource, value); - onChange(newTextResource); - }; - - return ( - - ); -} + onChangeTextResource: (textResource: TextResource) => void; +} & StudioTextfieldProps; + +const ValueField = forwardRef( + ({ textResource, onChange, onChangeTextResource, ...rest }, ref): ReactElement => { + const handleChange = (event: ChangeEvent) => { + const { value } = event.target; + const newTextResource = editTextResourceValue(textResource, value); + onChangeTextResource(newTextResource); + onChange?.(event); + }; + + return ( + + ); + }, +); type InputModeToggleProps = { - inputMode: InputMode; - onToggle: (mode: InputMode) => void; + className?: string; + inputMode: Mode; + onToggle: (mode: Mode) => void; texts: TextResourceInputTexts; }; -function InputModeToggle({ inputMode, onToggle, texts }: InputModeToggleProps): ReactElement { +function ModeToggle({ + className: givenClass, + inputMode, + onToggle, + texts, +}: InputModeToggleProps): ReactElement { + const className = cn(givenClass, classes.toggle); return ( - - + + - + @@ -144,13 +190,15 @@ function InputModeToggle({ inputMode, onToggle, texts }: InputModeToggleProps): } type CurrentIdProps = { + className?: string; currentId: string; label: string; }; -function CurrentId({ currentId, label }: CurrentIdProps): ReactElement { +function CurrentId({ className: givenClass, currentId, label }: CurrentIdProps): ReactElement { + const className = cn(givenClass, classes.id); return ( -
+
{label} {currentId}
diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx index 7b2c906b2d4..9778302a4c2 100644 --- a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx @@ -19,7 +19,17 @@ type AdditionalProps = { }; export const StudioTextResourcePicker = forwardRef( - ({ textResources, onSelect, onValueChange, emptyListText, value, ...rest }, ref) => { + ( + { + textResources, + onSelect, + onValueChange, + emptyListText, + value, + ...rest + }: StudioTextResourcePickerProps, + ref, + ): ReactElement => { const handleValueChange = useCallback(([id]: string[]) => onValueChange(id), [onValueChange]); return ( diff --git a/frontend/libs/studio-hooks/src/hooks/usePropState.ts b/frontend/libs/studio-hooks/src/hooks/usePropState.ts index 6df9aba30bd..552a7c7f9f6 100644 --- a/frontend/libs/studio-hooks/src/hooks/usePropState.ts +++ b/frontend/libs/studio-hooks/src/hooks/usePropState.ts @@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useState } from 'react'; export function usePropState(prop: T): [T, Dispatch>] { - const [state, setState] = useState(prop); + const [state, setState] = useState(prop); useEffect(() => { setState(prop);