Skip to content

Commit

Permalink
feat: Add keyboard navigation to input table
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng committed Oct 8, 2024
1 parent e061605 commit 25e0011
Show file tree
Hide file tree
Showing 14 changed files with 859 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,23 +1,67 @@
import type { ForwardedRef, ForwardRefRenderFunction, PropsWithoutRef } from 'react';
import type { ForwardedRef, ForwardRefRenderFunction, KeyboardEvent, PropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import type { InputCellComponent } from '../types/InputCellComponent';
import type { HTMLCellInputElement } from '../types/HTMLCellInputElement';
import { getNextInputElement } from '../dom-utils/getNextInputElement';

export abstract class BaseInputCell<Element extends HTMLCellInputElement, Props extends {}> {
type DefaultProps<Element extends HTMLCellInputElement> = {
onKeyDown?: (event: KeyboardEvent<Element>) => void;
tabIndex?: number;
};

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

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

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

private renderWithDefaultProps: ForwardRefRenderFunction<Element, PropsWithoutRef<Props>> = (
props,
ref,
) => this.render({ ...props, ...this.defaultProps }, ref);

protected abstract render(
props: PropsWithoutRef<Props>,
ref: ForwardedRef<Element>,
): ReturnType<ForwardRefRenderFunction<Element, Props>>;

private defaultProps: Required<DefaultProps<Element>> = {
onKeyDown: (event) => this.handleKeyDown(event),
tabIndex: -1,
};

private handleKeyDown(event: KeyboardEvent<Element>): void {
if (this.shouldMoveFocusOnArrowKey(event)) {
this.moveFocus(event);
}
}

protected abstract shouldMoveFocusOnArrowKey(event: KeyboardEvent<Element>): boolean;

private moveFocus(event: KeyboardEvent<Element>) {
const nextElement = this.getNextElement(event);
if (nextElement) {
event.preventDefault();
nextElement.tabIndex = 0;
nextElement.focus();
event.currentTarget.tabIndex = -1;
}
}

private getNextElement({
key,
currentTarget,
}: KeyboardEvent<Element>): HTMLCellInputElement | null {
return getNextInputElement(currentTarget, key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ export class CellButton extends BaseInputCell<HTMLButtonElement, CellButtonProps
</StudioTable.Cell>
);
}

shouldMoveFocusOnArrowKey = () => true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ export class CellCheckbox extends BaseInputCell<HTMLInputElement, CellCheckboxPr
</StudioTable.Cell>
);
}

shouldMoveFocusOnArrowKey = () => true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StudioTextareaProps } from '../../StudioTextarea';
import { StudioTextarea } from '../../StudioTextarea';
import { BaseInputCell } from './BaseInputCell';
import cn from 'classnames';
import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils';

export type CellTextareaProps = StudioTextareaProps;

Expand All @@ -17,8 +18,30 @@ export class CellTextarea extends BaseInputCell<HTMLTextAreaElement, CellTextare
const className = cn(classes.textareaCell, givenClass);
return (
<StudioTable.Cell className={className}>
<StudioTextarea hideLabel ref={ref} size='small' {...rest} />
<StudioTextarea
hideLabel
onFocus={(event) => event.currentTarget.select()}
ref={ref}
size='small'
{...rest}
/>
</StudioTable.Cell>
);
}

shouldMoveFocusOnArrowKey({ key, currentTarget }) {
if (isSomethingSelected(currentTarget)) return false;
switch (key) {
case 'ArrowUp':
return isCaretAtStart(currentTarget);
case 'ArrowDown':
return isCaretAtEnd(currentTarget);
case 'ArrowLeft':
return isCaretAtStart(currentTarget);
case 'ArrowRight':
return isCaretAtEnd(currentTarget);
default:
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StudioTextfield } from '../../StudioTextfield';
import classes from './Cell.module.css';
import { BaseInputCell } from './BaseInputCell';
import cn from 'classnames';
import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils';

export type CellTextfieldProps = StudioTextfieldProps;

Expand All @@ -17,8 +18,30 @@ export class CellTextfield extends BaseInputCell<HTMLInputElement, CellTextfield
const className = cn(classes.textfieldCell, givenClass);
return (
<StudioTable.Cell className={className}>
<StudioTextfield hideLabel ref={ref} size='small' {...rest} />
<StudioTextfield
hideLabel
onFocus={(event) => event.currentTarget.select()}
ref={ref}
size='small'
{...rest}
/>
</StudioTable.Cell>
);
}

shouldMoveFocusOnArrowKey({ key, currentTarget }) {
if (isSomethingSelected(currentTarget)) return false;
switch (key) {
case 'ArrowUp':
return isCaretAtStart(currentTarget);
case 'ArrowDown':
return isCaretAtEnd(currentTarget);
case 'ArrowLeft':
return isCaretAtStart(currentTarget);
case 'ArrowRight':
return isCaretAtEnd(currentTarget);
default:
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { StudioTable } from '../../StudioTable';
import React, { forwardRef, useId } from 'react';
import type { StudioCheckboxProps } from '../../StudioCheckbox';
import { StudioCheckbox } from '../../StudioCheckbox';
import type { HTMLCellInputElement } from '../types/HTMLCellInputElement';
import { getNextInputElement } from '../dom-utils/getNextInputElement';

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

Expand All @@ -11,10 +13,38 @@ export const HeaderCellCheckbox = forwardRef<HTMLInputElement, HeaderCellCheckbo
const value = givenValue ?? defaultValue;
return (
<StudioTable.HeaderCell className={className}>
<StudioCheckbox ref={ref} value={value} {...rest} />
<StudioCheckbox
onKeyDown={handleKeyDown}
ref={ref}
size='small'
tabIndex={-1}
value={value}
{...rest}
/>
</StudioTable.HeaderCell>
);
},
);

HeaderCellCheckbox.displayName = 'HeaderCell.Checkbox';

function handleKeyDown(event: React.KeyboardEvent<HTMLCellInputElement>): void {
moveFocus(event);
}

function moveFocus(event: React.KeyboardEvent<HTMLCellInputElement>) {
const nextElement = getNextElement(event);
if (nextElement) {
event.preventDefault();
nextElement.tabIndex = 0;
nextElement.focus();
event.currentTarget.tabIndex = -1;
}
}

function getNextElement({
key,
currentTarget,
}: React.KeyboardEvent<HTMLCellInputElement>): HTMLCellInputElement | null {
return getNextInputElement(currentTarget, key);
}
Loading

0 comments on commit 25e0011

Please sign in to comment.