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: Create basic input table component #13719

Merged
merged 5 commits into from
Oct 8, 2024
Merged
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
@@ -0,0 +1,23 @@
import type { ForwardedRef, ForwardRefRenderFunction, PropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import type { InputCellComponent } from '../types/InputCellComponent';
import type { HTMLCellInputElement } from '../types/HTMLCellInputElement';

export abstract class BaseInputCell<Element extends HTMLCellInputElement, Props extends {}> {
displayName: string;

constructor(displayName: string) {
this.displayName = displayName;
}

component(): InputCellComponent<Props, Element> {
const component = forwardRef<Element, Props>(this.render);
component.displayName = this.displayName;
return component;
}

protected abstract render(
props: PropsWithoutRef<Props>,
ref: ForwardedRef<Element>,
): ReturnType<ForwardRefRenderFunction<Element, Props>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.textfieldCell,
.textareaCell {
padding: var(--fds-spacing-1) 0;
font-size: var(--studio-input-table-font-size);
}

.buttonCell {
text-align: center;
}

.buttonCell button {
display: inline-flex;
}

.textfieldCell:not(:hover) input:not(:hover):not(:active):not(:focus),
.textareaCell:not(:hover) textarea:not(:hover):not(:active):not(:focus) {
background-color: transparent;
border-color: transparent;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { StudioTable } from '../../StudioTable';

export const Cell = StudioTable.Cell;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StudioTable } from '../../StudioTable';
import type { ForwardedRef, ReactElement } from 'react';
import React from 'react';
import classes from './Cell.module.css';
import type { StudioButtonProps } from '../../StudioButton';
import { StudioButton } from '../../StudioButton';
import { BaseInputCell } from './BaseInputCell';
import cn from 'classnames';

export type CellButtonProps = StudioButtonProps;

export class CellButton extends BaseInputCell<HTMLButtonElement, CellButtonProps> {
render(
{ className: givenClass, ...rest }: StudioButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
): ReactElement {
const className = cn(classes.buttonCell, givenClass);
return (
<StudioTable.Cell className={className}>
<StudioButton ref={ref} variant='secondary' {...rest} />
</StudioTable.Cell>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { StudioTable } from '../../StudioTable';
import type { ForwardedRef, ReactElement } from 'react';
import React from 'react';
import type { StudioCheckboxProps } from '../../StudioCheckbox';
import { StudioCheckbox } from '../../StudioCheckbox';
import { BaseInputCell } from './BaseInputCell';

export type CellCheckboxProps = StudioCheckboxProps;

export class CellCheckbox extends BaseInputCell<HTMLInputElement, CellCheckboxProps> {
render(
{ className, ...rest }: CellCheckboxProps,
ref: ForwardedRef<HTMLInputElement>,
): ReactElement {
return (
<StudioTable.Cell className={className}>
<StudioCheckbox ref={ref} {...rest} />
</StudioTable.Cell>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StudioTable } from '../../StudioTable';
import type { ForwardedRef, ReactElement } from 'react';
import React from 'react';
import classes from './Cell.module.css';
import type { StudioTextareaProps } from '../../StudioTextarea';
import { StudioTextarea } from '../../StudioTextarea';
import { BaseInputCell } from './BaseInputCell';
import cn from 'classnames';

export type CellTextareaProps = StudioTextareaProps;

export class CellTextarea extends BaseInputCell<HTMLTextAreaElement, CellTextareaProps> {
render(
{ className: givenClass, ...rest }: CellTextareaProps,
ref: ForwardedRef<HTMLTextAreaElement>,
): ReactElement {
const className = cn(classes.textareaCell, givenClass);
return (
<StudioTable.Cell className={className}>
<StudioTextarea hideLabel ref={ref} size='small' {...rest} />
</StudioTable.Cell>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StudioTable } from '../../StudioTable';
import type { ForwardedRef, ReactElement } from 'react';
import React from 'react';
import type { StudioTextfieldProps } from '../../StudioTextfield';
import { StudioTextfield } from '../../StudioTextfield';
import classes from './Cell.module.css';
import { BaseInputCell } from './BaseInputCell';
import cn from 'classnames';

export type CellTextfieldProps = StudioTextfieldProps;

export class CellTextfield extends BaseInputCell<HTMLInputElement, CellTextfieldProps> {
render(
{ className: givenClass, ...rest }: CellTextfieldProps,
ref: ForwardedRef<HTMLInputElement>,
): ReactElement {
const className = cn(classes.textfieldCell, givenClass);
return (
<StudioTable.Cell className={className}>
<StudioTextfield hideLabel ref={ref} size='small' {...rest} />
</StudioTable.Cell>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { CellTextfieldProps } from './CellTextfield';
import { CellTextfield } from './CellTextfield';
import type { CellTextareaProps } from './CellTextarea';
import { CellTextarea } from './CellTextarea';
import type { CellButtonProps } from './CellButton';
import { CellButton } from './CellButton';
import { Cell } from './Cell';
import type { CellCheckboxProps } from './CellCheckbox';
import { CellCheckbox } from './CellCheckbox';
import type { InputCellComponent } from '../types/InputCellComponent';

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

export const StudioInputTableCell = Cell as CellComponent;

StudioInputTableCell.Textfield = new CellTextfield('StudioInputTable.Cell.Textfield').component();
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.displayName = 'StudioInputTable.Cell';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { StudioTable } from '../../StudioTable';

export const HeaderCell = StudioTable.HeaderCell;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { StudioTable } from '../../StudioTable';
import React, { forwardRef, useId } from 'react';
import type { StudioCheckboxProps } from '../../StudioCheckbox';
import { StudioCheckbox } from '../../StudioCheckbox';

export type HeaderCellCheckboxProps = Omit<StudioCheckboxProps, 'value'> & { value?: string };

export const HeaderCellCheckbox = forwardRef<HTMLInputElement, HeaderCellCheckboxProps>(
({ value: givenValue, className, ...rest }, ref) => {
const defaultValue = useId();
const value = givenValue ?? defaultValue;
return (
<StudioTable.HeaderCell className={className}>
<StudioCheckbox ref={ref} value={value} {...rest} />
</StudioTable.HeaderCell>
);
},
);

HeaderCellCheckbox.displayName = 'HeaderCell.Checkbox';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { HeaderCell } from './HeaderCell';
import { HeaderCellCheckbox } from './HeaderCellCheckbox';

type HeaderCellComponent = typeof HeaderCell & {
Checkbox: typeof HeaderCellCheckbox;
};

export const StudioInputTableHeaderCell = HeaderCell as HeaderCellComponent;

StudioInputTableHeaderCell.Checkbox = HeaderCellCheckbox;

StudioInputTableHeaderCell.displayName = 'StudioInputTableHeaderCell.HeaderCell';
StudioInputTableHeaderCell.Checkbox.displayName = 'StudioInputTableHeaderCell.Checkbox';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ComponentProps } from 'react';
import React, { forwardRef } from 'react';
import { StudioTable } from '../../StudioTable';

type RowProps = ComponentProps<typeof StudioTable.Row>;

export const Row = forwardRef<HTMLTableRowElement, RowProps>(({ children, ...rest }, ref) => (
<StudioTable.Row ref={ref} {...rest}>
{children}
</StudioTable.Row>
));

Row.displayName = 'StudioInputTable.Row';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Row } from './Row';

export const StudioInputTableRow = Row;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.inputTable {
--studio-input-table-font-size: --fds-font-size-f0;
font-size: var(--studio-input-table-font-size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { TestTable } from './test-data/TestTable';
import type { StudioInputTableProps } from './StudioInputTable';

type Story = StoryFn<typeof TestTable>;

export function render(props: StudioInputTableProps): ReactElement {
return <TestTable {...props} />;
}

const meta: Meta<Story> = {
title: 'Components/StudioInputTable',
component: TestTable,
render,
};
export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { ForwardedRef, ReactNode } from 'react';
import React from 'react';
import { StudioInputTable } from './';
import type { RenderResult } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { TestTable } from './test-data/TestTable';
import type { StudioInputTableProps } from './StudioInputTable';
import { expect } from '@storybook/test';
import { testRefForwarding } from '../../test-utils/testRefForwarding';
import { testRootClassNameAppending } from '../../test-utils/testRootClassNameAppending';
import { testCustomAttributes } from '../../test-utils/testCustomAttributes';

describe('StudioInputTable', () => {
it('Renders a table', () => {
renderStudioInputTable();
expect(getTable()).toBeInTheDocument();
});

it('Forwards the ref if provided', () => {
const renderTable = (ref: ForwardedRef<HTMLTableElement>) =>
render(
<StudioInputTable ref={ref}>
<StudioInputTable.Body>
<StudioInputTable.Row>
<StudioInputTable.Cell />
</StudioInputTable.Row>
</StudioInputTable.Body>
</StudioInputTable>,
);
testRefForwarding<HTMLTableElement>(renderTable, getTable);
});

it('Appends the given class to the table', () => {
testRootClassNameAppending((className) => renderStudioInputTable({ className }));
});

it('Applies the given props to the table', () => {
testCustomAttributes<HTMLTableElement>(renderStudioInputTable, getTable);
});

it('Renders all headers', () => {
render(<TestTable />);
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(expectedNumberOfColumns);
});

it('Renders all rows', () => {
render(<TestTable />);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(expectedNumberOfRows);
});

describe('Forwards the refs to the input elements', () => {
type TestCase<Element extends HTMLElement = HTMLElement> = {
render: (ref: ForwardedRef<Element>) => RenderResult;
getElement: () => Element;
};
const testLabel = 'test';
const testCases: {
checkbox: TestCase<HTMLInputElement>;
textfield: TestCase<HTMLInputElement>;
textarea: TestCase<HTMLTextAreaElement>;
button: TestCase<HTMLButtonElement>;
} = {
checkbox: {
render: (ref) =>
render(
<SingleRow>
<StudioInputTable.Cell.Checkbox value='test' aria-label={testLabel} ref={ref} />
</SingleRow>,
),
getElement: () => getCheckbox(testLabel),
},
textfield: {
render: (ref) =>
render(
<SingleRow>
<StudioInputTable.Cell.Textfield label={testLabel} ref={ref} />
</SingleRow>,
),
getElement: () => getTextbox(testLabel) as HTMLInputElement,
},
textarea: {
render: (ref) =>
render(
<SingleRow>
<StudioInputTable.Cell.Textarea label={testLabel} ref={ref} />
</SingleRow>,
),
getElement: () => getTextbox(testLabel) as HTMLTextAreaElement,
},
button: {
render: (ref) =>
render(
<SingleRow>
<StudioInputTable.Cell.Button ref={ref}>{testLabel}</StudioInputTable.Cell.Button>
</SingleRow>,
),
getElement: () => getButton(testLabel),
},
};

it.each(Object.keys(testCases))('%s', (key) => {
const { render: renderComponent, getElement } = testCases[key];
testRefForwarding(renderComponent, getElement);
});
});
});

const renderStudioInputTable = (props: StudioInputTableProps = {}) =>
render(<TestTable {...props} />);

const getTable = (): HTMLTableElement => screen.getByRole('table');
const getCheckbox = (name: string): HTMLInputElement =>
screen.getByRole('checkbox', { name }) as HTMLInputElement;
const getTextbox = (name: string) => screen.getByRole('textbox', { name });
const getButton = (name: string): HTMLButtonElement =>
screen.getByRole('button', { name }) as HTMLButtonElement;

const expectedNumberOfColumns = 5;
const expectedNumberOfHeaderRows = 1;
const expectedNumberOfBodyRows = 3;
const expectedNumberOfRows = expectedNumberOfBodyRows + expectedNumberOfHeaderRows;

function SingleRow({ children }: { children: ReactNode }) {
return (
<StudioInputTable>
<StudioInputTable.Body>
<StudioInputTable.Row>{children}</StudioInputTable.Row>
</StudioInputTable.Body>
</StudioInputTable>
);
}
Loading
Loading