import React, { forwardRef, useState, useCallback, useRef, useEffect, useLayoutEffect, useImperativeHandle, useMemo, type ComponentProps, type ReactNode, type KeyboardEvent, type UIEvent, } from 'react'; import { useStore } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; import { type CSSProperties } from 'glamor'; import { AvoidRefocusScrollProvider, useProperFocus, } from '../hooks/useProperFocus'; import { useSelectedItems } from '../hooks/useSelected'; import AnimatedLoading from '../icons/AnimatedLoading'; import DeleteIcon from '../icons/v0/Delete'; import ExpandArrow from '../icons/v0/ExpandArrow'; import Checkmark from '../icons/v1/Checkmark'; import { styles, theme } from '../style'; import Button from './common/Button'; import Input from './common/Input'; import Menu from './common/Menu'; import Text from './common/Text'; import View from './common/View'; import FixedSizeList from './FixedSizeList'; import { KeyHandlers } from './KeyHandlers'; import { ConditionalPrivacyFilter, mergeConditionalPrivacyFilterProps, type ConditionalPrivacyFilterProps, } from './PrivacyFilter'; import { type Binding } from './spreadsheet'; import format from './spreadsheet/format'; import useSheetValue from './spreadsheet/useSheetValue'; import { Tooltip, IntersectionBoundary } from './tooltips'; export const ROW_HEIGHT = 32; function fireBlur(onBlur, e) { if (document.hasFocus()) { // We only fire the blur event if the app is still focused // because the blur event is fired when the app goes into // the background and we want to ignore that onBlur?.(e); } else { // Otherwise, stop React from bubbling this event and swallow it e.stopPropagation(); } } type FieldProps = ComponentProps<typeof View> & { width: number | 'flex'; name?: string; truncate?: boolean; contentStyle?: CSSProperties; }; export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field( { width, name, truncate = true, children, style, contentStyle, ...props }, ref, ) { return ( <View innerRef={ref} {...props} style={[ width === 'flex' ? { flex: 1, flexBasis: 0 } : { width }, { borderTopWidth: 1, borderBottomWidth: 1, borderColor: theme.tableBorder, }, styles.smallText, style, ]} data-testid={name} > {/* This is wrapped so that the padding is not taken into account with the flex width (which aligns it with the Cell component) */} <View style={[ { flex: 1, padding: '0 5px', justifyContent: 'center', }, contentStyle, ]} > {truncate ? ( <Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }} > {children} </Text> ) : ( children )} </View> </View> ); }); export function UnexposedCellContent({ value, formatter, }: Pick<CellProps, 'value' | 'formatter'>) { return ( <Text style={{ flexGrow: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }} > {formatter ? formatter(value) : value} </Text> ); } type CellProps = Omit<ComponentProps<typeof View>, 'children' | 'value'> & { formatter?: (value: string, type?: unknown) => string; focused?: boolean; textAlign?: string; alignItems?: string; plain?: boolean; exposed?: boolean; children?: ReactNode | (() => ReactNode); unexposedContent?: ReactNode; value?: string; valueStyle?: CSSProperties; onExpose?: (name: string) => void; privacyFilter?: ConditionalPrivacyFilterProps['privacyFilter']; }; export function Cell({ width, name, exposed, focused, value, formatter, textAlign, alignItems, onExpose, children, plain, style, valueStyle, unexposedContent, privacyFilter, ...viewProps }: CellProps) { let mouseCoords = useRef(null); let viewRef = useRef(null); useProperFocus(viewRef, focused !== undefined ? focused : exposed); const widthStyle = width === 'flex' ? { flex: 1, flexBasis: 0 } : { width }; const cellStyle = { position: 'relative', textAlign: textAlign || 'left', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: theme.tableBorder, alignItems: alignItems, }; let conditionalPrivacyFilter = useMemo( () => ( <ConditionalPrivacyFilter privacyFilter={mergeConditionalPrivacyFilterProps( { activationFilters: [!focused, !exposed], style: { position: 'absolute', width: '100%', height: '100%', }, }, privacyFilter, )} > {plain ? ( children ) : exposed ? ( // @ts-expect-error Missing props refinement children() ) : ( <View style={[ { flexDirection: 'row', flex: 1, padding: '0 5px', alignItems: 'center', }, styles.smallText, valueStyle, ]} // Can't use click because we only want to expose the cell if // the user does a direct click, not if they also drag the // mouse to select something onMouseDown={e => (mouseCoords.current = [e.clientX, e.clientY])} // When testing, allow the click handler to be used instead onClick={ global.IS_TESTING ? () => onExpose?.(name) : e => { if ( mouseCoords.current && Math.abs(e.clientX - mouseCoords.current[0]) < 5 && Math.abs(e.clientY - mouseCoords.current[1]) < 5 ) { onExpose?.(name); } } } > {unexposedContent || ( <UnexposedCellContent value={value} formatter={formatter} /> )} </View> )} </ConditionalPrivacyFilter> ), [ privacyFilter, focused, exposed, children, plain, exposed, valueStyle, onExpose, name, unexposedContent, value, formatter, ], ); return ( <View innerRef={viewRef} style={[widthStyle, cellStyle, style]} {...viewProps} data-testid={name} > {conditionalPrivacyFilter} </View> ); } type RowProps = ComponentProps<typeof View> & { inset?: number; collapsed?: boolean; }; export function Row({ inset = 0, collapsed, children, height, style, ...nativeProps }: RowProps) { return ( <View style={[ { flexDirection: 'row', height: height || ROW_HEIGHT, flex: '0 0 ' + (height || ROW_HEIGHT) + 'px', userSelect: 'text', }, collapsed && { marginTop: -1 }, style, ]} data-testid="row" {...nativeProps} > {inset !== 0 && <Field width={inset} />} {children} {inset !== 0 && <Field width={inset} />} </View> ); } const inputCellStyle = { padding: '5px 3px', margin: '0 1px', }; const readonlyInputStyle = { backgroundColor: 'transparent', '::selection': { backgroundColor: theme.formInputTextReadOnlySelection }, }; type InputValueProps = ComponentProps<typeof Input> & { value: string; }; function InputValue({ value: defaultValue, onUpdate, onBlur, ...props }: InputValueProps) { let [value, setValue] = useState(defaultValue); function onBlur_(e) { onUpdate?.(value); if (onBlur) { fireBlur(onBlur, e); } } function onKeyDown(e) { // Only enter and tab to escape (which allows the user to move // around) if (e.key !== 'Enter' && e.key !== 'Tab') { e.stopPropagation(); } if (e.key === 'Escape') { if (value !== defaultValue) { setValue(defaultValue); } } else if (shouldSaveFromKey(e)) { onUpdate?.(value); } } return ( <Input {...props} value={value} onUpdate={text => setValue(text)} onBlur={onBlur_} onKeyDown={onKeyDown} style={[ inputCellStyle, props.readOnly ? readonlyInputStyle : null, props.style, ]} /> ); } type InputCellProps = ComponentProps<typeof Cell> & { inputProps: ComponentProps<typeof InputValue>; onUpdate: ComponentProps<typeof InputValue>['onUpdate']; onBlur: ComponentProps<typeof InputValue>['onBlur']; textAlign?: string; error?: ReactNode; }; export function InputCell({ inputProps, onUpdate, onBlur, textAlign, error, ...props }: InputCellProps) { return ( <Cell textAlign={textAlign} {...props}> {() => ( <> <InputValue value={props.value} onUpdate={onUpdate} onBlur={onBlur} style={[{ textAlign }, inputProps && inputProps.style]} {...inputProps} /> {error && ( <Tooltip key="error" targetHeight={ROW_HEIGHT} width={180} position="bottom-left" > {error} </Tooltip> )} </> )} </Cell> ); } function shouldSaveFromKey(e) { switch (e.key) { case 'Tab': case 'Enter': e.preventDefault(); return true; default: } } type CustomCellRenderProps = { onBlur: (ev: UIEvent<unknown>) => void; onKeyDown: (ev: KeyboardEvent<unknown>) => void; onUpdate: (value: string) => void; onSave: (value: string) => void; shouldSaveFromKey: (ev: KeyboardEvent<unknown>) => boolean; inputStyle: CSSProperties; }; type CustomCellProps = Omit<ComponentProps<typeof Cell>, 'children'> & { children: (props: CustomCellRenderProps) => ReactNode; onUpdate: (value: string) => void; onBlur: (ev: UIEvent<unknown>) => void; }; export function CustomCell({ value: defaultValue, children, onUpdate, onBlur, ...props }: CustomCellProps) { let [value, setValue] = useState(defaultValue); let [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue); if (prevDefaultValue !== defaultValue) { setValue(defaultValue); setPrevDefaultValue(defaultValue); } function onBlur_(e) { // Only save on blur if the app is focused. Blur events fire when // the app unfocuses, and it's unintuitive to save the value since // the input will be focused again when the app regains focus if (document.hasFocus()) { onUpdate?.(value); fireBlur(onBlur, e); } } function onKeyDown(e) { if (shouldSaveFromKey(e)) { onUpdate?.(value); } } return ( <Cell {...props} value={defaultValue}> {() => children({ onBlur: onBlur_, onKeyDown, onUpdate: val => setValue(val), onSave: val => { setValue(val); onUpdate?.(val); }, shouldSaveFromKey, inputStyle: inputCellStyle, }) } </Cell> ); } type DeleteCellProps = Omit<ComponentProps<typeof Cell>, 'children'> & { onDelete?: () => void; }; export function DeleteCell({ onDelete, style, ...props }: DeleteCellProps) { return ( <Cell {...props} name="delete" width={20} style={[{ alignItems: 'center', userSelect: 'none' }, style]} onClick={e => { e.stopPropagation(); onDelete?.(); }} > {() => <DeleteIcon width={7} height={7} />} </Cell> ); } type CellButtonProps = { style?: CSSProperties; primary?: boolean; bare?: boolean; disabled?: boolean; clickBehavior?: string; onSelect?: (e) => void; onEdit?: () => void; children: ReactNode; className?: string; }; export const CellButton = forwardRef<HTMLDivElement, CellButtonProps>( ( { style, primary, bare, disabled, clickBehavior, onSelect, onEdit, children, className, }, ref, ) => { // This represents a cell that acts like a button: it's clickable, // focusable, etc. The reason we don't use a button is because the // full behavior is undesirable: we really don't want stuff like // "click is fired when enter is pressed". We have very custom // controls and focus/active states. // // Important behavior: // * X/SPACE/etc keys select the button _on key down_ and not on key // up. This means it instantly selects and if you hold it down it // will repeatedly select. // * The cell begins editing on focus. That means if the user does a // mouse down, but moves out of the element and then does mouse // up, it will properly still receive focus & being editing return ( <View innerRef={ref} className={className} tabIndex={0} onKeyDown={e => { if (e.key === 'x' || e.key === ' ') { e.preventDefault(); if (!disabled) { onSelect?.(e); } } }} style={[ { flexDirection: 'row', alignItems: 'center', cursor: 'default', transition: 'box-shadow .15s', backgroundColor: bare ? 'transparent' : disabled // always use disabled before primary since we can have a disabled primary button ? theme.buttonNormalDisabledBackground : primary ? theme.buttonPrimaryBackground : theme.buttonNormalBackground, border: bare ? 'none' : '1px solid ' + (disabled ? theme.buttonNormalDisabledBorder : primary ? theme.buttonPrimaryBorder : theme.buttonNormalBorder), color: bare ? 'inherit' : disabled ? theme.buttonNormalDisabledText : primary ? theme.buttonPrimaryText : theme.buttonNormalText, ':focus': bare ? null : { outline: 0, boxShadow: `1px 1px 2px ${theme.buttonNormalShadow}`, }, }, style, ]} onFocus={() => onEdit && onEdit()} data-testid="cell-button" onClick={ clickBehavior === 'none' ? null : e => { if (!disabled) { onSelect?.(e); onEdit?.(); } } } > {children} </View> ); }, ); type SelectCellProps = Omit<ComponentProps<typeof Cell>, 'children'> & { partial?: boolean; onEdit?: () => void; onSelect?: (e) => void; buttonProps?: Partial<CellButtonProps>; }; export function SelectCell({ focused, selected, style, onSelect, onEdit, buttonProps = {}, ...props }: SelectCellProps) { return ( <Cell {...props} focused={focused} name="select" width={20} style={[{ alignItems: 'center', userSelect: 'none' }, style]} onClick={e => { e.stopPropagation(); onSelect?.(e); onEdit?.(); }} > {() => ( <CellButton style={{ width: 12, height: 12, justifyContent: 'center', alignItems: 'center', borderRadius: 3, border: selected ? '1px solid ' + theme.altFormInputBorderSelected : '1px solid ' + theme.formInputBorder, color: theme.tableBackground, backgroundColor: selected ? theme.tableTextEditingBackground : theme.tableBackground, ':focus': { border: '1px solid ' + theme.altFormInputBorderSelected, boxShadow: '0 1px 2px ' + theme.altFormInputShadowSelected, }, }} onEdit={onEdit} onSelect={onSelect} clickBehavior="none" {...buttonProps} > {selected && <Checkmark width={6} height={6} />} </CellButton> )} </Cell> ); } type SheetCellValueProps = { binding: Binding; type: string; getValueStyle?: (value: unknown) => CSSProperties; formatExpr?: (value) => string; unformatExpr?: (value: string) => unknown; privacyFilter?: ConditionalPrivacyFilterProps['privacyFilter']; }; type SheetCellProps = ComponentProps<typeof Cell> & { valueProps: SheetCellValueProps; inputProps?: Omit<ComponentProps<typeof InputValue>, 'value' | 'onUpdate'>; onSave?: (value) => void; }; export function SheetCell({ valueProps, valueStyle, inputProps, textAlign, onSave, ...props }: SheetCellProps) { const { binding, type, getValueStyle, formatExpr, unformatExpr, privacyFilter, } = valueProps; let sheetValue = useSheetValue(binding, e => { // "close" the cell if it's editing if (props.exposed && inputProps && inputProps.onBlur) { inputProps.onBlur(e); } }); return ( <Cell valueStyle={ getValueStyle ? [valueStyle, getValueStyle(sheetValue)] : valueStyle } textAlign={textAlign} {...props} value={sheetValue} formatter={value => props.formatter ? props.formatter(value, type) : format(value, type) } privacyFilter={ privacyFilter != null ? privacyFilter : type === 'financial' ? true : undefined } data-cellname={sheetValue} > {() => { return ( <InputValue value={formatExpr ? formatExpr(sheetValue) : sheetValue} onUpdate={value => { onSave(unformatExpr ? unformatExpr(value) : value); }} {...inputProps} style={{ textAlign, ...(inputProps?.style || {}) }} /> ); }} </Cell> ); } type TableHeaderProps = ComponentProps<typeof Row> & { headers?: Array<ComponentProps<typeof Cell>>; version?: string; }; export function TableHeader({ headers, children, version, ...rowProps }: TableHeaderProps) { return ( <View style={{ borderRadius: '6px 6px 0 0', overflow: 'hidden', flexShrink: 0, }} > <Row collapsed={true} {...rowProps} style={{ color: theme.tableHeaderText, backgroundColor: theme.tableHeaderBackground, zIndex: 200, fontWeight: 500, ...rowProps.style, }} > {headers ? headers.map(header => { return ( <Cell key={header.name} value={header.name} width={header.width} style={header.style} valueStyle={header.valueStyle} /> ); }) : children} </Row> </View> ); } export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) { let selectedItems = useSelectedItems(); let [menuOpen, setMenuOpen] = useState(null); if (selectedItems.size === 0) { return null; } return ( <View style={{ marginLeft: 10, flexShrink: 0 }}> <KeyHandlers keys={keyHandlers || {}} /> <Button type="bare" style={{ color: theme.pageTextPositive }} onClick={() => setMenuOpen(true)} > <ExpandArrow width={8} height={8} style={{ marginRight: 5, color: theme.pageText }} /> {selectedItems.size} {name} </Button> {menuOpen && ( <Tooltip position="bottom-right" width={200} style={{ padding: 0, backgroundColor: theme.menuBackground }} onClose={() => setMenuOpen(false)} > <Menu onMenuSelect={name => { onSelect(name, [...selectedItems]); setMenuOpen(false); }} items={items} /> </Tooltip> )} </View> ); } let rowStyle = { position: 'absolute', willChange: 'transform', width: '100%' }; type TableHandleRef = { scrollTo: (id: number, alignment?: string) => void; scrollToTop: () => void; getScrolledItem: () => number; setRowAnimation: (flag) => void; edit(id: number, field, shouldScroll): void; anchor(): void; unanchor(): void; isAnchored(): boolean; }; type TableWithNavigatorProps = TableProps & { fields; }; export const TableWithNavigator = forwardRef< TableHandleRef, TableWithNavigatorProps >(({ fields, ...props }, ref) => { let navigator = useTableNavigator(props.items, fields); return <Table {...props} navigator={navigator} />; }); type TableItem = { id: number }; type TableProps = { items: TableItem[]; count?: number; headers?: ReactNode | TableHeaderProps['headers']; contentHeader: ReactNode; loading: boolean; rowHeight?: number; backgroundColor?: string; renderItem: (arg: { item: TableItem; editing: boolean; focusedField: unknown; onEdit: (id, field) => void; index: number; position: number; }) => ReactNode; renderEmpty?: ReactNode | (() => ReactNode); getItemKey: (index: number) => TableItem['id']; loadMore?: () => void; style?: CSSProperties; navigator: ReturnType<typeof useTableNavigator>; listRef; onScroll: () => void; version?: string; allowPopupsEscape?: boolean; isSelected?: (id: TableItem['id']) => boolean; saveScrollWidth: (parent, child) => void; }; export const Table = forwardRef<TableHandleRef, TableProps>( ( { items, count, headers, contentHeader, loading, rowHeight = ROW_HEIGHT, backgroundColor = theme.tableHeaderBackground, renderItem, renderEmpty, getItemKey, loadMore, style, navigator, listRef, onScroll, version = 'v1', allowPopupsEscape, isSelected, saveScrollWidth, ...props }, ref, ) => { if (!navigator) { navigator = { onEdit: () => {}, editingId: null, focusedField: null, getNavigatorProps: props => props, }; } let { onEdit, editingId, focusedField, getNavigatorProps } = navigator; let list = useRef(null); let listContainer = useRef(null); let scrollContainer = useRef(null); let initialScrollTo = useRef(null); let listInitialized = useRef(false); useImperativeHandle(ref, () => ({ scrollTo: (id, alignment = 'smart') => { let index = items.findIndex(item => item.id === id); if (index !== -1) { if (!list.current) { // If the table hasn't been laid out yet, we need to wait for // that to happen before we can scroll to something initialScrollTo.current = index; } else { list.current.scrollToItem(index, alignment); } } }, scrollToTop: () => { list.current?.scrollTo(0); }, getScrolledItem: () => { if (scrollContainer.current) { let offset = scrollContainer.current.scrollTop; let index = list.current.getStartIndexForOffset(offset); return items[index].id; } return 0; }, setRowAnimation: flag => { list.current?.setRowAnimation(flag); }, edit(id, field, shouldScroll) { onEdit(id, field); if (id && shouldScroll) { // @ts-expect-error this should not be possible ref.scrollTo(id); } }, anchor() { list.current?.anchor(); }, unanchor() { list.current?.unanchor(); }, isAnchored() { return list.current && list.current.isAnchored(); }, })); useLayoutEffect(() => { // We wait for the list to mount because AutoSizer needs to run // before it's mounted if (!listInitialized.current && listContainer.current) { // Animation is on by default list.current?.setRowAnimation(true); listInitialized.current = true; } if (scrollContainer.current && saveScrollWidth) { saveScrollWidth( scrollContainer.current.offsetParent ? scrollContainer.current.offsetParent.clientWidth : 0, scrollContainer.current ? scrollContainer.current.clientWidth : 0, ); } }); function renderRow({ index, style, key }) { let item = items[index]; let editing = editingId === item.id; let selected = isSelected && isSelected(item.id); let row = renderItem({ item, editing, focusedField: editing && focusedField, onEdit, index, position: style.top, }); // TODO: Need to also apply zIndex if item is selected // * Port over ListAnimation to Table // * Move highlighted functionality into here return ( <View key={key} style={[ rowStyle, { zIndex: editing || selected ? 101 : 'auto', transform: 'translateY(var(--pos))', }, ]} // @ts-expect-error not a recognised style attribute nativeStyle={{ '--pos': `${style.top - 1}px` }} data-focus-key={item.id} > {row} </View> ); } function getScrollOffset(height, index) { return ( index * (rowHeight - 1) + (rowHeight - 1) / 2 - height / 2 + (rowHeight - 1) * 2 ); } function onItemsRendered({ overscanStartIndex, overscanStopIndex }) { if (loadMore && overscanStopIndex > items.length - 100) { loadMore(); } } function getEmptyContent(empty) { if (empty == null) { return null; } else if (typeof empty === 'function') { return empty(); } return ( <View style={{ justifyContent: 'center', alignItems: 'center', fontStyle: 'italic', color: theme.tableText, flex: 1, }} > {empty} </View> ); } if (loading) { return ( <View style={[ { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor, }, ]} > <AnimatedLoading width={25} color={theme.tableText} /> </View> ); } let isEmpty = (count || items.length) === 0; return ( <View style={[ { flex: 1, outline: 'none', }, style, ]} tabIndex="1" {...getNavigatorProps(props)} data-testid="table" > {headers && ( <TableHeader height={rowHeight} {...(Array.isArray(headers) ? { headers } : { children: headers })} /> )} <View style={{ flex: 1, backgroundColor }}> {isEmpty ? ( getEmptyContent(renderEmpty) ) : ( <AutoSizer> {({ width, height }) => { if (width === 0 || height === 0) { return null; } return ( <IntersectionBoundary.Provider value={!allowPopupsEscape && listContainer} > <AvoidRefocusScrollProvider> <FixedSizeList ref={list} header={contentHeader} innerRef={listContainer} outerRef={scrollContainer} width={width} height={height} renderRow={renderRow} itemCount={count || items.length} itemSize={rowHeight - 1} itemKey={ getItemKey || ((index, data) => items[index].id) } indexForKey={key => items.findIndex(item => item.id === key) } initialScrollOffset={ initialScrollTo.current ? getScrollOffset(height, initialScrollTo.current) : 0 } overscanCount={5} onItemsRendered={onItemsRendered} onScroll={onScroll} /> </AvoidRefocusScrollProvider> </IntersectionBoundary.Provider> ); }} </AutoSizer> )} </View> </View> ); }, ); export function useTableNavigator(data, fields) { let getFields = typeof fields !== 'function' ? () => fields : fields; let [editingId, setEditingId] = useState(null); let [focusedField, setFocusedField] = useState(null); let containerRef = useRef<HTMLDivElement>(); // See `onBlur` for why we need this let store = useStore(); let modalStackLength = useRef(0); // onEdit is passed to children, so make sure it maintains identity let onEdit = useCallback((id, field?) => { setEditingId(id); setFocusedField(id ? field : null); }, []); useEffect(() => { modalStackLength.current = store.getState().modals.modalStack.length; }, []); function flashInput() { // Force the container to be focused which suppresses the "space // pages down" behavior. If we don't do this and the user presses // up + space down quickly while nothing is focused, it would page // down. containerRef.current.focus(); // Not ideal, but works for now. Let the UI show the input // go away, and then bring it back on the same row/field onEdit(null); setTimeout(() => { onEdit(editingId, focusedField); }, 100); } function onFocusPrevious() { let idx = data.findIndex(item => item.id === editingId); if (idx > 0) { let item = data[idx - 1]; let fields = getFields(item); onEdit(item.id, fields[fields.length - 1]); } else { flashInput(); } } function onFocusNext() { let idx = data.findIndex(item => item.id === editingId); if (idx < data.length - 1) { let item = data[idx + 1]; let fields = getFields(item); onEdit(item.id, fields[0]); } else { flashInput(); } } function moveHorizontally(dir) { if (editingId) { let fields = getFields(data.find(item => item.id === editingId)); let idx = fields.indexOf(focusedField) + dir; if (idx < 0) { onFocusPrevious(); } else if (idx >= fields.length) { onFocusNext(); } else { setFocusedField(fields[idx]); } } } function moveVertically(dir) { if (editingId) { let idx = data.findIndex(item => item.id === editingId); let nextIdx = idx; while (true) { nextIdx = nextIdx + dir; if (nextIdx >= 0 && nextIdx < data.length) { const next = data[nextIdx]; if (getFields(next).includes(focusedField)) { onEdit(next.id, focusedField); break; } } else { flashInput(); break; } } } } function onMove(dir) { switch (dir) { case 'left': moveHorizontally(-1); break; case 'right': moveHorizontally(1); break; case 'up': moveVertically(-1); break; case 'down': moveVertically(1); break; default: throw new Error('Unknown direction: ' + dir); } } function getNavigatorProps(userProps) { return { ...userProps, innerRef: containerRef, onKeyDown: e => { userProps?.onKeyDown?.(e); if (e.isPropagationStopped()) { return; } switch (e.key) { case 'ArrowUp': case 'k': if (e.target.tagName !== 'INPUT') { onMove('up'); } break; case 'ArrowDown': case 'j': if (e.target.tagName !== 'INPUT') { onMove('down'); } break; case 'Enter': case 'Tab': e.preventDefault(); e.stopPropagation(); onMove( e.key === 'Enter' ? e.shiftKey ? 'up' : 'down' : e.shiftKey ? 'left' : 'right', ); break; default: } }, onBlur: e => { // We want to hide the editing field if the user clicked away // from the table. We use `relatedTarget` to figure out where // the focus is going, and if it's nothing (the user clicked // somewhere that doesn't have an editable field) or if it's // anything outside of the table, clear editing. // // Also important: only do this if the app is focused. The // blur event is fired when the app loses focus and we don't // want to hide the input. // The last tricky edge case: we don't want to blur if a new // modal just opened. This way the field still shows an // input, and it will be refocused when the modal closes. let prevNumModals = modalStackLength.current; let numModals = store.getState().modals.modalStack.length; if ( document.hasFocus() && (e.relatedTarget == null || !containerRef.current.contains(e.relatedTarget) || containerRef.current === e.relatedTarget) && prevNumModals === numModals ) { onEdit(null); } }, }; } return { onEdit, editingId, focusedField, getNavigatorProps }; }