Skip to content

Commit

Permalink
Add text resource picker to input table
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng committed Nov 29, 2024
1 parent 844b3b1 commit 9a852e1
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 10 deletions.
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

0 comments on commit 9a852e1

Please sign in to comment.