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: Add text resource picker to input table #14217

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.textfieldCell,
.textareaCell {
.textareaCell,
.textResourceCell {
padding: var(--fds-spacing-1) 0;
font-size: var(--studio-input-table-font-size);
}
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { StudioTable } from '../../StudioTable';
import type { ForwardedRef, ReactElement, FocusEvent } from 'react';
import React, { useCallback } from 'react';
import { BaseInputCell } from './BaseInputCell';
import type { StudioTextResourceInputProps } from '../../StudioTextResourceInput/StudioTextResourceInput';
import { StudioTextResourceInput } 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<HTMLInputElement, CellTextResourceInputProps> {
render(
{ className: givenClass, onFocus, ...rest }: CellTextResourceInputProps,
ref: ForwardedRef<HTMLInputElement>,
): ReactElement {
/* eslint-disable react-hooks/rules-of-hooks */
/* Eslint misinterprets this as a class component, while it's really just a functional component within a class */

const handleFocus = useCallback(
(event: FocusEvent<HTMLInputElement>): void => {
onFocus?.(event);
event.currentTarget.select();
},
[onFocus],
);

const eventProps = useEventProps<HTMLInputElement>({ onFocus: handleFocus, ...rest });

const className = cn(classes.textResourceCell, givenClass);

return (
<StudioTable.Cell className={className}>
<StudioTextResourceInput
currentIdClass={classes.currentTextId}
inputClass={classes.textInput}
toggleClass={classes.toggle}
{...rest}
{...eventProps}
ref={ref}
/>
</StudioTable.Cell>
);
}

shouldMoveFocusOnArrowKey({ key, currentTarget }): boolean {
if (isSomethingSelected(currentTarget)) return false;
switch (key) {
case 'ArrowUp':
return this.shouldMoveFocusOnArrowUpKey(currentTarget);
case 'ArrowDown':
return this.shouldMoveFocusOnArrowDownKey(currentTarget);
case 'ArrowLeft':
return this.shouldMoveFocusOnArrowLeftKey(currentTarget);
case 'ArrowRight':
return this.shouldMoveFocusOnArrowRightKey(currentTarget);
}
}

private shouldMoveFocusOnArrowUpKey = (element: HTMLInputElement): boolean =>
!isCombobox(element) && isCaretAtStart(element);

private shouldMoveFocusOnArrowDownKey = (element: HTMLInputElement): boolean =>
!isCombobox(element) && isCaretAtEnd(element);

private shouldMoveFocusOnArrowLeftKey = (element: HTMLInputElement): boolean =>
isCaretAtStart(element);

private shouldMoveFocusOnArrowRightKey = (element: HTMLInputElement): boolean =>
isCaretAtEnd(element);

shouldMoveFocusOnEnterKey = ({ currentTarget }): boolean => !isCombobox(currentTarget);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import { Cell } from './Cell';
import type { CellCheckboxProps } from './CellCheckbox';
import { CellCheckbox } from './CellCheckbox';
import type { InputCellComponent } from '../types/InputCellComponent';
import type { CellTextResourceInputProps } from './CellTextResource';
import { CellTextResource } from './CellTextResource';

type CellComponent = typeof Cell & {
Textfield: InputCellComponent<CellTextfieldProps, HTMLInputElement>;
Textarea: InputCellComponent<CellTextareaProps, HTMLTextAreaElement>;
Button: InputCellComponent<CellButtonProps, HTMLButtonElement>;
Checkbox: InputCellComponent<CellCheckboxProps, HTMLInputElement>;
TextResource: InputCellComponent<CellTextResourceInputProps, HTMLInputElement>;
};

export const StudioInputTableCell = Cell as CellComponent;
Expand All @@ -22,5 +25,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';
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
headerCheckboxLabel,
textareaLabel,
textfieldLabel,
textResourcePickerLabel,
textResourceProps,
textResourceSearchLabel,
textResourceValueLabel,
} from './test-data/testTableData';
import type { UserEvent } from '@testing-library/user-event';
import userEvent from '@testing-library/user-event';
Expand All @@ -27,13 +31,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 type { CellTextResourceInputProps } from './Cell/CellTextResource';

type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button';
type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button' | 'textResource';
type NativeElement<Name extends ElementName> = {
checkbox: HTMLInputElement;
textfield: HTMLInputElement;
textarea: HTMLTextAreaElement;
button: HTMLButtonElement;
textResource: HTMLInputElement;
}[Name];

// Test data:
Expand Down Expand Up @@ -108,18 +114,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);
Expand Down Expand Up @@ -147,8 +158,8 @@ describe('StudioInputTable', () => {
render(<TestTable />);
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);
});
Expand Down Expand Up @@ -221,6 +232,21 @@ describe('StudioInputTable', () => {
},
);

const keysThatShouldNotMoveFocusInSearchMode: MovementKey[] = ['ArrowUp', 'ArrowDown', 'Enter'];

it.each(keysThatShouldNotMoveFocusInSearchMode)(
'Does not move focus when the user presses the %s key in a text resource input element in search mode',
async (key) => {
const user = userEvent.setup();
render(<TestTable />);
await user.click(getSearchButtonInRow(2));
const combobox = getTextResourceComboboxInRow(2);
await user.click(combobox);
await user.keyboard(`{${key}}`);
expect(combobox).toHaveFocus();
},
);

describe('Forwards the refs to the input elements', () => {
type TestCase<Element extends HTMLElement = HTMLElement> = {
render: (ref: ForwardedRef<Element>) => RenderResult;
Expand All @@ -246,6 +272,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) => {
Expand Down Expand Up @@ -332,6 +362,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) => {
Expand All @@ -357,6 +404,7 @@ describe('StudioInputTable', () => {
});

type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight';
type MovementKey = ArrowKey | 'Enter' | 'Tab';

const renderStudioInputTable = (props: StudioInputTableProps = {}): RenderResult =>
render(<TestTable {...defaultProps} {...props} />);
Expand Down Expand Up @@ -401,6 +449,16 @@ const renderSingleCheckboxCell = (
</SingleRow>,
);

const renderSingleTextResourceCell = (
props: CellTextResourceInputProps,
ref?: ForwardedRef<HTMLInputElement>,
): RenderResult =>
render(
<SingleRow>
<StudioInputTable.Cell.TextResource {...props} ref={ref} />
</SingleRow>,
);

const getTable = (): HTMLTableElement => screen.getByRole('table');
const getCheckbox = (name: string): HTMLInputElement =>
screen.getByRole('checkbox', { name }) as HTMLInputElement;
Expand All @@ -415,6 +473,12 @@ 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 getTextResourceComboboxInRow = (rowNumber: number): HTMLInputElement =>
screen.getByRole('combobox', { name: textResourcePickerLabel(rowNumber) }) as HTMLInputElement;
const getSearchButtonInRow = (rowNumber: number): HTMLButtonElement =>
screen.getByRole('radio', { name: textResourceSearchLabel(rowNumber) }) as HTMLButtonElement;

const expectCaretPosition = (
element: HTMLInputElement | HTMLTextAreaElement,
Expand All @@ -438,7 +502,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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<input type='text' role='combobox' />);
const element: HTMLInputElement = screen.getByRole('combobox');
expect(isCombobox(element)).toBe(true);
});

it('Returns false when the element is not a combobox', () => {
render(<input type='text' />);
const element: HTMLInputElement = screen.getByRole('textbox');
expect(isCombobox(element)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isCombobox(element: HTMLInputElement): boolean {
return element.getAttribute('role') === 'combobox';
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
textareaHeader,
textfieldHeader,
textHeader,
textResourceHeader,
textResourceTexts,
} from './testTableData';
import { textResourcesMock } from '../../../test-data/textResourcesMock';

export function TestTable(props: StudioInputTableProps): ReactElement {
return (
Expand All @@ -20,6 +23,7 @@ export function TestTable(props: StudioInputTableProps): ReactElement {
<StudioInputTable.HeaderCell>{textHeader}</StudioInputTable.HeaderCell>
<StudioInputTable.HeaderCell>{textfieldHeader}</StudioInputTable.HeaderCell>
<StudioInputTable.HeaderCell>{textareaHeader}</StudioInputTable.HeaderCell>
<StudioInputTable.HeaderCell>{textResourceHeader}</StudioInputTable.HeaderCell>
<StudioInputTable.HeaderCell>{buttonHeader}</StudioInputTable.HeaderCell>
</StudioInputTable.Row>
</StudioInputTable.Head>
Expand Down Expand Up @@ -53,6 +57,13 @@ function TestRow({ rowNumber: rn }: TestRowProps): ReactElement {
name={testData.textareaName(rn)}
label={testData.textareaLabel(rn)}
/>
<StudioInputTable.Cell.TextResource
textResources={textResourcesMock}
currentId='land.NO'
onChangeCurrentId={() => {}}
onChangeTextResource={() => {}}
texts={textResourceTexts(rn)}
/>
<StudioInputTable.Cell.Button>{testData.buttonLabel(rn)}</StudioInputTable.Cell.Button>
</StudioInputTable.Row>
);
Expand Down
Loading
Loading