diff --git a/README.md b/README.md index f413c28..a5c7de9 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ interface TableProps { focus?: boolean // focus table on mount? (default true) onDoubleClickCell?: (col: number, row: number) => void // double-click handler onError?: (error: Error) => void // error handler + orderBy?: OrderBy; // order by column (if defined, the component order is controlled by the parent) + onOrderByChange?: (orderBy: OrderBy) => void; // orderBy change handler + selection?: Selection; // selection state (if defined, the component selection is controlled by the parent) + onSelectionChange?: (selection: Selection) => void; // selection change handler } ``` @@ -95,6 +99,27 @@ interface DataFrame { } ``` +OrderBy is defined as: + +```typescript +interface OrderBy { + column: string // column name + direction?: "ascending" // sort direction - only ascending is supported +} +``` + +Selection is defined as: + +```typescript +interface Selection { + ranges: Array<{ + start: number // inclusive lower limit, positive integer + end: number // exclusive upper limit, positive integer, strictly greater than start (no zero-length ranges). + }>; // the rows selection is an array of row index ranges (0-based). The values are indexes of the virtual table (sorted rows), and thus depend on the order. + anchor?: number // anchor row used as a reference for shift+click selection. It's a virtual table index (sorted), and thus depends on the order. +} +``` + ## Sortable DataFrame If your data source supports sorting, set the sortable property to true in your DataFrame object. When sorting is enabled, the rows function will receive an additional orderBy parameter, which represents the column name to sort by. diff --git a/package.json b/package.json index 8fbfb2a..f2fa9ff 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.1.2", "@testing-library/react": "16.1.0", + "@testing-library/user-event": "14.5.2", "@types/node": "22.10.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.1", diff --git a/src/HighTable.css b/src/HighTable.css index 1f6578a..3f8162d 100644 --- a/src/HighTable.css +++ b/src/HighTable.css @@ -110,19 +110,27 @@ .table th:first-child input { display: none; } -.selectable th:first-child:hover span, .selectable tr.selected th:first-child span { +/* for selected rows, replace row numbers with checkboxes and highlight the rows */ +tr.selected th:first-child span { display: none; } -.selectable th:first-child:hover input, .selectable tr.selected th:first-child input { +tr.selected th:first-child input { display: inline; - cursor: pointer; } -.selectable tr.selected { +tr.selected { background-color: #fbf7bf; } -.selectable tr.selected th:first-child { +tr.selected th:first-child { background-color: #f1edbb; } +/* if selectable, show checkboxes on hover (and focus) */ +.selectable th:first-child:hover span, .selectable th:first-child:focus span { + display: none; +} +.selectable th:first-child:hover input, .selectable th:first-child:focus input { + display: inline; + cursor: pointer; +} /* cells */ .table th, @@ -226,21 +234,24 @@ display: flex; justify-content: center; } -.selectable .table-corner { - background: #e4e4e6; - cursor: pointer; -} +/* replace corner with checkbox if selection is enabled (read-only or not) */ .table-corner input { display: none; } -.selectable .table-corner span { +.table-corner.show-corner-selection { + background: #e4e4e6; +} +.table-corner.show-corner-selection span { display: none; } -.selectable .table-corner input { +.table-corner.show-corner-selection input { display: inline; - cursor: pointer; text-align: center; } +/* if selectable, show pointer cursor on checkbox */ +.selectable .table-corner, .selectable .table-corner input { + cursor: pointer; +} /* mock row numbers */ .mock-row-label { diff --git a/src/HighTable.tsx b/src/HighTable.tsx index 7f2d95f..21c9bd2 100644 --- a/src/HighTable.tsx +++ b/src/HighTable.tsx @@ -1,76 +1,41 @@ import { ReactNode, useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import { DataFrame, Row, asyncRows } from './dataframe.js' +import { useInputState } from './hooks.js' import { Selection, areAllSelected, extendFromAnchor, isSelected, toggleAll, toggleIndex } from './selection.js' -import TableHeader, { cellStyle } from './TableHeader.js' -export { - AsyncRow, - DataFrame, - ResolvablePromise, - Row, - arrayDataFrame, - asyncRows, - awaitRow, - awaitRows, - resolvablePromise, - resolvableRow, - sortableDataFrame, - wrapPromise, -} from './dataframe.js' +import TableHeader, { OrderBy, cellStyle } from './TableHeader.js' +export { AsyncRow, DataFrame, ResolvablePromise, Row, arrayDataFrame, asyncRows, awaitRow, awaitRows, resolvablePromise, resolvableRow, sortableDataFrame, wrapPromise } from './dataframe.js' export { rowCache } from './rowCache.js' +export { Selection } from './selection.js' +export { OrderBy } from './TableHeader.js' export { HighTable } -const rowHeight = 33 // row height px - -/** - * Mouse event handler for a cell in the table. - * @param event mouse event - * @param col column index - * @param row row index in the data frame - */ -type MouseEventCellHandler = (event: React.MouseEvent, col: number, row: number) => void - -interface TableProps { - data: DataFrame - cacheKey?: string // used to persist column widths - overscan?: number // number of rows to fetch outside of the viewport - padding?: number // number of padding rows to render outside of the viewport - focus?: boolean // focus table on mount? (default true) - tableControl?: TableControl // control the table from outside - selectable?: boolean // enable row selection (default false) - onDoubleClickCell?: MouseEventCellHandler - onMouseDownCell?: MouseEventCellHandler - onError?: (error: Error) => void -} - /** * State of the component */ -type State = { +export type State = { columnWidths: Array // width of each column invalidate: boolean // true if the data must be fetched again hasCompleteRow: boolean // true if at least one row is fully resolved (all of its cells) rows: Row[] // slice of the virtual table rows (sorted rows) to render as HTML. It might contain incomplete rows. Rows are expected to include __index__ if sorted. + rowsOrderBy: OrderBy // order by column of the rows slice. startIndex: number // offset of the slice of sorted rows to render (rows[0] is the startIndex'th sorted row) - orderBy?: string // column name to sort by - selection: Selection // rows selection. The values are indexes of the virtual table (sorted rows), and thus depend on the order. - anchor?: number // anchor row used as a reference for shift+click selection. It's a virtual table index (sorted), and thus depends on the order. + data: DataFrame // data frame used in the last rendering } -type Action = - | { type: 'SET_ROWS', start: number, rows: Row[], hasCompleteRow: boolean } +export type Action = + | { type: 'SET_ROWS', start: number, rows: Row[], rowsOrderBy: OrderBy, hasCompleteRow: boolean } | { type: 'SET_COLUMN_WIDTH', columnIndex: number, columnWidth: number | undefined } | { type: 'SET_COLUMN_WIDTHS', columnWidths: Array } - | { type: 'SET_ORDER', orderBy: string | undefined } - | { type: 'DATA_CHANGED' } - | { type: 'SET_SELECTION', selection: Selection, anchor?: number } + | { type: 'DATA_CHANGED', data: DataFrame } -function reducer(state: State, action: Action): State { +export function reducer(state: State, action: Action): State { switch (action.type) { case 'SET_ROWS': return { ...state, startIndex: action.start, rows: action.rows, + rowsOrderBy: action.rowsOrderBy, invalidate: false, hasCompleteRow: state.hasCompleteRow || action.hasCompleteRow, } @@ -81,30 +46,36 @@ function reducer(state: State, action: Action): State { } case 'SET_COLUMN_WIDTHS': return { ...state, columnWidths: action.columnWidths } - case 'SET_ORDER': { - if (state.orderBy === action.orderBy) { - return state - } else { - // the selection is relative to the order, and must be reset if the order changes - return { ...state, orderBy: action.orderBy, rows: [], selection: [], anchor: undefined } - } - } case 'DATA_CHANGED': - return { ...state, invalidate: true, hasCompleteRow: false, selection: [], anchor: undefined } - case 'SET_SELECTION': - return { ...state, selection: action.selection, anchor: action.anchor } + return { ...state, data: action.data, invalidate: true, hasCompleteRow: false } default: return state } } -const initialState: State = { - columnWidths: [], - startIndex: 0, - rows: [], - invalidate: true, - hasCompleteRow: false, - selection: [], +const rowHeight = 33 // row height px + +/** + * Mouse event handler for a cell in the table. + * @param event mouse event + * @param col column index + * @param row row index in the data frame + */ +type MouseEventCellHandler = (event: React.MouseEvent, col: number, row: number) => void + +export interface TableProps { + data: DataFrame + cacheKey?: string // used to persist column widths + overscan?: number // number of rows to fetch outside of the viewport + padding?: number // number of padding rows to render outside of the viewport + focus?: boolean // focus table on mount? (default true) + onDoubleClickCell?: MouseEventCellHandler + onMouseDownCell?: MouseEventCellHandler + onError?: (error: Error) => void + orderBy?: OrderBy // order by column. If undefined, the table is unordered, the sort elements are hidden and the interactions are disabled. + onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined. + selection?: Selection // selection and anchor rows. If undefined, the selection is hidden and the interactions are disabled. + onSelectionChange?: (selection: Selection) => void // callback to call when a user interaction changes the selection. The interactions are disabled if undefined. } function rowLabel(rowIndex?: number): string { @@ -115,6 +86,11 @@ function rowLabel(rowIndex?: number): string { /** * Render a table with streaming rows on demand from a DataFrame. + * + * orderBy: the column to order by. If set, the component is controlled, and the property cannot be unset (undefined) later. If undefined, the component is uncontrolled (internal state). If the data cannot be sorted, it's ignored. + * onOrderByChange: the callback to call when the order changes. If undefined, the component order is read-only if controlled (orderBy is set), or disabled if not (or if the data cannot be sorted). + * selection: the selected rows and the anchor row. If set, the component is controlled, and the property cannot be unset (undefined) later. If undefined, the component is uncontrolled (internal state). + * onSelectionChange: the callback to call when the selection changes. If undefined, the component selection is read-only if controlled (selection is set), or disabled if not. */ export default function HighTable({ data, @@ -122,12 +98,24 @@ export default function HighTable({ overscan = 20, padding = 20, focus = true, - tableControl, - selectable = false, + orderBy: propOrderBy, + onOrderByChange: propOnOrderByChange, + selection: propSelection, + onSelectionChange: propOnSelectionChange, onDoubleClickCell, onMouseDownCell, onError = console.error, }: TableProps) { + const initialState: State = { + data, + columnWidths: [], + startIndex: 0, + rows: [], + rowsOrderBy: {}, + invalidate: true, + hasCompleteRow: false, + } + const [state, dispatch] = useReducer(reducer, initialState) /** * The component relies on the model of a virtual table which rows are ordered and only the visible rows are fetched and rendered as HTML elements. * We use two reference domains for the rows: @@ -136,9 +124,68 @@ export default function HighTable({ * startIndex lives in the table domain: it's the first virtual row to be rendered in HTML. * data.rows(dataIndex, dataIndex + 1) is the same row as data.rows(tableIndex, tableIndex + 1, orderBy) */ - const [state, dispatch] = useReducer(reducer, initialState) + const { columnWidths, startIndex, rows, rowsOrderBy, invalidate, hasCompleteRow, data: previousData } = state + + // Sorting is disabled if the data is not sortable + const { + value: orderBy, + onChange: onOrderByChange, + enableInteractions: enableOrderByInteractions, + } = useInputState({ + value: propOrderBy, + onChange: propOnOrderByChange, + defaultValue: {}, + disabled: !data.sortable, + }) + + // Selection is disabled if the parent passed no props + const isSelectionDisabled = propSelection === undefined && propOnSelectionChange === undefined + const { + value: selection, + onChange: onSelectionChange, + enableInteractions: enableSelectionInteractions, + isControlled: isSelectionControlled, + } = useInputState({ + value: propSelection, + onChange: propOnSelectionChange, + defaultValue: { ranges: [], anchor: undefined }, + disabled: isSelectionDisabled, + }) + + const showSelection = selection !== undefined + const showSelectionControls = showSelection && enableSelectionInteractions + const showCornerSelection = showSelectionControls || showSelection && areAllSelected({ ranges: selection.ranges, length: data.numRows }) + const getOnSelectAllRows = useCallback(() => { + if (!selection || !onSelectionChange) return + const { ranges } = selection + return () => onSelectionChange({ + ranges: toggleAll({ ranges, length: data.numRows }), + anchor: undefined, + }) + }, [onSelectionChange, data.numRows, selection]) + const getOnSelectRowClick = useCallback((tableIndex: number) => { + if (!selection || !onSelectionChange) return + const { ranges, anchor } = selection + return (event: React.MouseEvent) => { + const useAnchor = event.shiftKey && selection.anchor !== undefined + if (useAnchor) { + onSelectionChange({ ranges: extendFromAnchor({ ranges, anchor, index: tableIndex }), anchor }) + } else { + onSelectionChange({ ranges: toggleIndex({ ranges, index: tableIndex }), anchor: tableIndex }) + } + } + }, [onSelectionChange, selection]) + const allRowsSelected = useMemo(() => { + if (!selection) return false + const { ranges } = selection + return areAllSelected({ ranges, length: data.numRows }) + }, [selection, data.numRows]) + const isRowSelected = useCallback((tableIndex: number) => { + if (!selection) return undefined + const { ranges } = selection + return isSelected({ ranges, index: tableIndex }) + }, [selection]) - const { anchor, columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow, selection } = state const offsetTopRef = useRef(0) const scrollRef = useRef(null) @@ -152,9 +199,13 @@ export default function HighTable({ const scrollHeight = (data.numRows + 1) * rowHeight // invalidate when data changes so that columns will auto-resize - useEffect(() => { - dispatch({ type: 'DATA_CHANGED' }) - }, [data]) + if (data !== previousData) { + dispatch({ type: 'DATA_CHANGED', data }) + // if uncontrolled, reset the selection (otherwise, it's the responsibility of the parent to do it if the data changes) + if (!isSelectionControlled) { + onSelectionChange?.({ ranges: [], anchor: undefined }) + } + } // handle scrolling useEffect(() => { @@ -172,7 +223,7 @@ export default function HighTable({ const end = Math.min(data.numRows, endView + overscan) // Don't update if view is unchanged - if (!invalidate && start === startIndex && rows.length === end - start) { + if (!invalidate && start === startIndex && rows.length === end - start && rowsOrderBy.column === orderBy?.column ) { return } @@ -185,7 +236,7 @@ export default function HighTable({ // Fetch a chunk of rows from the data frame try { const requestId = ++pendingRequest.current - const unwrapped = data.rows(start, end, orderBy) + const unwrapped = data.rows(start, end, orderBy?.column) const rowsChunk = asyncRows(unwrapped, end - start, data.header) const updateRows = throttle(() => { @@ -207,7 +258,7 @@ export default function HighTable({ resolved.push(resolvedRow) } offsetTopRef.current = offsetTop - dispatch({ type: 'SET_ROWS', start, rows: resolved, hasCompleteRow }) + dispatch({ type: 'SET_ROWS', start, rows: resolved, hasCompleteRow, rowsOrderBy: { column: orderBy?.column } }) }, 10) updateRows() // initial update @@ -250,15 +301,7 @@ export default function HighTable({ scroller?.removeEventListener('scroll', handleScroll) window.removeEventListener('resize', handleScroll) } - }, [data, invalidate, orderBy, overscan, padding, rows.length, startIndex, scrollHeight, onError]) - - // handle remote control of the table (e.g. sorting) - useEffect(() => { - tableControl?.publisher.subscribe(dispatch) - return () => { - tableControl?.publisher.unsubscribe(dispatch) - } - }, [tableControl]) + }, [data, invalidate, orderBy?.column, overscan, padding, rows.length, rowsOrderBy.column, startIndex, scrollHeight, onError, dispatch]) /** * Validate row length @@ -289,6 +332,7 @@ export default function HighTable({ if (str.length > 100) title = str } return row === undefined ? console.warn('Cell onDoubleClick is cancelled because row index is undefined') : onDoubleClickCell?.(e, col, row)} @@ -317,7 +361,7 @@ export default function HighTable({ */ const getRowIndexes = useCallback((rowIndex: number): { dataIndex?: number, tableIndex: number } => { const tableIndex = startIndex + rowIndex - const dataIndex = orderBy === undefined + const dataIndex = orderBy?.column === undefined ? tableIndex : rowIndex >= 0 && rowIndex < rows.length && '__index__' in rows[rowIndex] && typeof rows[rowIndex].__index__ === 'number' ? rows[rowIndex].__index__ @@ -325,27 +369,6 @@ export default function HighTable({ return { dataIndex, tableIndex } }, [rows, startIndex, orderBy]) - const onRowNumberClick = useCallback(({ useAnchor, tableIndex }: {useAnchor: boolean, tableIndex: number}) => { - if (!selectable) return false - if (useAnchor) { - const newSelection = extendFromAnchor({ selection, anchor, index: tableIndex }) - // did not throw: we can set the anchor (keep the same) - dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor }) - } else { - const newSelection = toggleIndex({ selection, index: tableIndex }) - // did not throw: we can set the anchor - dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor: tableIndex }) - } - }, [selection, anchor, selectable]) - - const setColumnWidths = useCallback((columnWidths: Array) => { - dispatch({ type: 'SET_COLUMN_WIDTHS', columnWidths }) - }, []) - - const setColumnWidth = useCallback((columnIndex: number, columnWidth: number | undefined) => { - dispatch({ type: 'SET_COLUMN_WIDTH', columnIndex, columnWidth }) - }, []) - // add empty pre and post rows to fill the viewport const prePadding = Array.from({ length: Math.min(padding, startIndex) }, () => []) const postPadding = Array.from({ @@ -356,17 +379,26 @@ export default function HighTable({ const cornerWidth = Math.ceil(Math.log10(data.numRows + 1)) * 4 + 22 const cornerStyle = useMemo(() => cellStyle(cornerWidth), [cornerWidth]) + const setColumnWidths = useCallback((columnWidths: Array) => { + dispatch({ type: 'SET_COLUMN_WIDTHS', columnWidths }) + }, [dispatch]) + + const setColumnWidth = useCallback((columnIndex: number, columnWidth: number | undefined) => { + dispatch({ type: 'SET_COLUMN_WIDTH', columnIndex, columnWidth }) + }, [dispatch]) + // don't render table if header is empty if (!data.header.length) return - return
+ return
data.sortable && dispatch({ type: 'SET_ORDER', orderBy })} /> - + onOrderByChange={onOrderByChange} + /> + {prePadding.map((_, prePaddingIndex) => { const { tableIndex, dataIndex } = getRowIndexes(-prePadding.length + prePaddingIndex) - return - + return + })} {rows.map((row, rowIndex) => { const { tableIndex, dataIndex } = getRowIndexes(rowIndex) - return - {data.header.map((col, colIndex) => Cell(row[col], colIndex, dataIndex) @@ -408,26 +440,25 @@ export default function HighTable({ })} {postPadding.map((_, postPaddingIndex) => { const { tableIndex, dataIndex } = getRowIndexes(rows.length + postPaddingIndex) - return - + return + })}
- { - rowLabel(dataIndex) - } -
{ + rowLabel(dataIndex) + }
onRowNumberClick({ useAnchor: event.shiftKey, tableIndex })}> - { rowLabel(dataIndex) } - + + {rowLabel(dataIndex)} + { showSelection && }
- { - rowLabel(dataIndex) - } -
{ + rowLabel(dataIndex) + }
-
selectable && dispatch({ type: 'SET_SELECTION', selection: toggleAll({ selection, length: rows.length }), anchor: undefined })}> +
  - + { showCornerSelection && }
 
} + /** * Robust stringification of any value, including json and bigints. */ @@ -475,44 +506,3 @@ export function throttle(fn: () => void, wait: number): () => void { } } } - -interface Publisher { - subscribe: (fn: (data: T) => void) => void - unsubscribe: (fn: (data: T) => void) => void -} - -interface PubSub extends Publisher { - publish: (data: T) => void -} - -export interface TableControl { - publisher: Publisher - setOrderBy: (columnName: string) => void -} - -function createPubSub(): PubSub { - const subscribers = new Set<(data: T) => void>() - return { - subscribe(fn: (data: T) => void) { - subscribers.add(fn) - }, - unsubscribe(fn: (data: T) => void) { - subscribers.delete(fn) - }, - publish(data: T) { - for (const fn of subscribers) { - fn(data) - } - }, - } -} - -export function createTableControl(): TableControl { - const publisher = createPubSub() - return { - publisher, - setOrderBy(columnName: string) { - publisher.publish({ type: 'SET_ORDER', orderBy: columnName }) - }, - } -} diff --git a/src/TableHeader.tsx b/src/TableHeader.tsx index 75a381b..964c433 100644 --- a/src/TableHeader.tsx +++ b/src/TableHeader.tsx @@ -1,14 +1,19 @@ -import { RefObject, createRef, useEffect, useMemo, useRef, useState } from 'react' +import { RefObject, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' +export interface OrderBy { + column?: string // column name to sort by. If undefined, the table is unsorted. + direction?: 'ascending' // sort direction. Default: 'ascending' +} + interface TableProps { header: string[] cacheKey?: string // used to persist column widths columnWidths: Array - orderBy?: string | undefined + orderBy?: OrderBy // order by column. If undefined, the table is unordered, the sort elements are hidden and the interactions are disabled. setColumnWidth: (columnIndex: number, columnWidth: number | undefined) => void setColumnWidths: (columnWidths: Array) => void - setOrderBy?: (orderBy: string | undefined) => void + onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined. dataReady: boolean } @@ -34,7 +39,7 @@ export interface ColumnWidth { * Render a resizable header for a table. */ export default function TableHeader({ - header, cacheKey, columnWidths, orderBy, setOrderBy, setColumnWidth, setColumnWidths, dataReady, + header, cacheKey, columnWidths, orderBy, onOrderByChange, setColumnWidth, setColumnWidths, dataReady, }: TableProps) { const [resizing, setResizing] = useState() const headerRefs = useRef(header.map(() => createRef())) @@ -133,28 +138,32 @@ export default function TableHeader({ }, [cacheKey, header, resizing, setColumnWidths, columnWidths, setColumnWidth]) // Function to handle click for changing orderBy - function handleOrderByClick(columnHeader: string, e: React.MouseEvent) { - // Ignore clicks on resize handle - if ((e.target as HTMLElement).tagName === 'SPAN') return - if (orderBy === columnHeader) { - setOrderBy?.(undefined) - } else { - setOrderBy?.(columnHeader) - } - } + const getOnOrderByClick = useCallback((columnHeader: string) => { + if (!onOrderByChange) return undefined + return (e: React.MouseEvent) => { + // Ignore clicks on resize handle + if ((e.target as HTMLElement).tagName === 'SPAN') return + if (orderBy?.column === columnHeader) { + onOrderByChange({}) + } else { + onOrderByChange({ column: columnHeader }) + } + }}, [orderBy, onOrderByChange] + ) const memoizedStyles = useMemo(() => columnWidths.map(cellStyle), [columnWidths]) - return - + return + {header.map((columnHeader, columnIndex) => handleOrderByClick(columnHeader, e)} + onClick={getOnOrderByClick(columnHeader)} ref={headerRefs.current[columnIndex]} style={memoizedStyles[columnIndex]} title={columnHeader}> diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..622350e --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from 'react' + +/** + * Props for the useInputState hook. + * @param value the external value. If undefined, the input is uncontrolled and has a local state. This value cannot be unset (undefined) later if controlled, or set to a value if uncontrolled. + * @param onChange the callback to call when the input changes. If undefined, the input is read-only. + * @param defaultValue the default value for the local state if the input is uncontrolled. + * @param disabled true if the input is disabled. In this case, the value is undefined and the result onChange function does nothing. + */ +interface UseInputStateProps { + value?: T, + onChange?: ((value: T) => void), + defaultValue?: T + disabled?: boolean +} + +/** + * Result of the useInputState hook. + * + * @param value the current input value + * @param onChange the callback to call when the input changes + * @param enableInteractions true if the input can be changed by the user + * @param isControlled true if the input value is controlled by the parent + */ +interface UseInputStateResult { + value: T | undefined + onChange: (value: T) => void + enableInteractions: boolean + isControlled: boolean +} + +/** + * Simulates the state of React components. See https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable + * + * The input state can be: + * - controlled (if value is defined): the parent controls the value. No local state. + * - uncontrolled (if value is undefined): the input controls the value. Local state. + * - disabled: the value is hidden and the user interactions are disabled. No local state. + * + * Note that the onChange prop can be defined or undefined. If undefined in a controlled state, the input is read-only (no interactions), + * else, the input can change but the parent cannot get the value. + */ +export function useInputState( props: UseInputStateProps): UseInputStateResult { + const { value: propValue, onChange: propOnChange, defaultValue, disabled } = props + const [initialValue] = useState(propValue) + const [initialDisabled] = useState(disabled ?? false) + const [localValue, setLocalValue] = useState(defaultValue) + const isControlled = initialValue !== undefined + let enableInteractions = true + let value: T | undefined + if (initialDisabled) { + value = undefined + enableInteractions = false + } else if (isControlled) { + if (propValue === undefined) { + console.warn('The value is controlled (it has no local state) because the property was initially defined. It cannot be set to undefined now (it is set back to the initial value).') + value = initialValue + } else { + value = propValue + } + // read-only if propOnChange is undefined + enableInteractions = propOnChange !== undefined + } else { + if (propValue !== undefined) { + console.warn('The value is uncontrolled (it only has a local state) because the property was initially undefined. It cannot be set to a value now and is ignored.') + } + value = localValue + enableInteractions = true + } + const onChange = useCallback((selection: T) => { + if (!enableInteractions) { + return + } + propOnChange?.(selection) + if (!isControlled) { + setLocalValue(selection) + } + }, [propOnChange, isControlled, enableInteractions]) + return { value, onChange, enableInteractions, isControlled } +} diff --git a/src/selection.ts b/src/selection.ts index 8e82d58..06d1157 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -1,8 +1,13 @@ /** - * A selection is an array of ordered and non-overlapping ranges. + * A selection is modelled as an array of ordered and non-overlapping ranges. * The ranges are separated, ie. the end of one range is strictly less than the start of the next range. */ -export type Selection = Array +export type Ranges = Array + +export interface Selection { + ranges: Ranges // rows selection. The values are indexes of the virtual table (sorted rows), and thus depend on the order. + anchor?: number // anchor row used as a reference for shift+click selection. It's a virtual table index (sorted), and thus depends on the order. +} interface Range { start: number // inclusive lower limit, positive integer @@ -19,123 +24,123 @@ export function isValidRange(range: Range): boolean { && range.end > range.start } -export function isValidSelection(selection: Selection): boolean { - if (selection.length === 0) { +export function areValidRanges(ranges: Ranges): boolean { + if (ranges.length === 0) { return true } - if (selection.some(range => !isValidRange(range))) { + if (ranges.some(range => !isValidRange(range))) { return false } - for (let i = 0; i < selection.length - 1; i++) { - if (selection[i].end >= selection[i + 1].start) { + for (let i = 0; i < ranges.length - 1; i++) { + if (ranges[i].end >= ranges[i + 1].start) { return false } } return true } -export function isSelected({ selection, index }: { selection: Selection, index: number }): boolean { +export function isSelected({ ranges, index }: { ranges: Ranges, index: number }): boolean { if (!isValidIndex(index)) { throw new Error('Invalid index') } - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } - return selection.some(range => range.start <= index && index < range.end) + return ranges.some(range => range.start <= index && index < range.end) } -export function areAllSelected({ selection, length }: { selection: Selection, length: number }): boolean { - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') +export function areAllSelected({ ranges, length }: { ranges: Ranges, length: number }): boolean { + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } if (length && !isValidIndex(length)) { throw new Error('Invalid length') } - return selection.length === 1 && selection[0].start === 0 && selection[0].end === length + return ranges.length === 1 && ranges[0].start === 0 && ranges[0].end === length } -export function toggleAll({ selection, length }: { selection: Selection, length: number }): Selection { - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') +export function toggleAll({ ranges, length }: { ranges: Ranges, length: number }): Ranges { + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } if (length && !isValidIndex(length)) { throw new Error('Invalid length') } - if (areAllSelected({ selection, length })) { + if (areAllSelected({ ranges, length })) { return [] } return [{ start: 0, end: length }] } -export function selectRange({ selection, range }: { selection: Selection, range: Range }): Selection { - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') +export function selectRange({ ranges, range }: { ranges: Ranges, range: Range }): Ranges { + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } if (!isValidRange(range)) { throw new Error('Invalid range') } - const newSelection: Selection = [] + const newRanges: Ranges = [] const { start, end } = range let rangeIndex = 0 // copy the ranges before the new range - while (rangeIndex < selection.length && selection[rangeIndex].end < start) { - newSelection.push({ ...selection[rangeIndex] }) + while (rangeIndex < ranges.length && ranges[rangeIndex].end < start) { + newRanges.push({ ...ranges[rangeIndex] }) rangeIndex++ } // merge with the new range - while (rangeIndex < selection.length && selection[rangeIndex].start <= end) { - range.start = Math.min(range.start, selection[rangeIndex].start) - range.end = Math.max(range.end, selection[rangeIndex].end) + while (rangeIndex < ranges.length && ranges[rangeIndex].start <= end) { + range.start = Math.min(range.start, ranges[rangeIndex].start) + range.end = Math.max(range.end, ranges[rangeIndex].end) rangeIndex++ } - newSelection.push(range) + newRanges.push(range) // copy the remaining ranges - while (rangeIndex < selection.length) { - newSelection.push({ ...selection[rangeIndex] }) + while (rangeIndex < ranges.length) { + newRanges.push({ ...ranges[rangeIndex] }) rangeIndex++ } - return newSelection + return newRanges } -export function unselectRange({ selection, range }: { selection: Selection, range: Range }): Selection { - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') +export function unselectRange({ ranges, range }: { ranges: Ranges, range: Range }): Ranges { + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } if (!isValidRange(range)) { throw new Error('Invalid range') } - const newSelection: Selection = [] + const newRanges: Ranges = [] const { start, end } = range let rangeIndex = 0 // copy the ranges before the new range - while (rangeIndex < selection.length && selection[rangeIndex].end < start) { - newSelection.push({ ...selection[rangeIndex] }) + while (rangeIndex < ranges.length && ranges[rangeIndex].end < start) { + newRanges.push({ ...ranges[rangeIndex] }) rangeIndex++ } // split the ranges intersecting with the new range - while (rangeIndex < selection.length && selection[rangeIndex].start < end) { - if (selection[rangeIndex].start < start) { - newSelection.push({ start: selection[rangeIndex].start, end: start }) + while (rangeIndex < ranges.length && ranges[rangeIndex].start < end) { + if (ranges[rangeIndex].start < start) { + newRanges.push({ start: ranges[rangeIndex].start, end: start }) } - if (selection[rangeIndex].end > end) { - newSelection.push({ start: end, end: selection[rangeIndex].end }) + if (ranges[rangeIndex].end > end) { + newRanges.push({ start: end, end: ranges[rangeIndex].end }) } rangeIndex++ } // copy the remaining ranges - while (rangeIndex < selection.length) { - newSelection.push({ ...selection[rangeIndex] }) + while (rangeIndex < ranges.length) { + newRanges.push({ ...ranges[rangeIndex] }) rangeIndex++ } - return newSelection + return newRanges } /** @@ -143,38 +148,38 @@ export function unselectRange({ selection, range }: { selection: Selection, rang * Both bounds are inclusive. * It will handle the shift+click behavior. anchor is the first index clicked, index is the last index clicked. */ -export function extendFromAnchor({ selection, anchor, index }: { selection: Selection, anchor?: number, index: number }): Selection { - if (!isValidSelection(selection)) { - throw new Error('Invalid selection') +export function extendFromAnchor({ ranges, anchor, index }: { ranges: Ranges, anchor?: number, index: number }): Ranges { + if (!areValidRanges(ranges)) { + throw new Error('Invalid ranges') } if (anchor === undefined) { // no anchor to start the range, no operation - return selection + return ranges } if (!isValidIndex(anchor) || !isValidIndex(index)) { throw new Error('Invalid index') } if (anchor === index) { // no operation - return selection + return ranges } const range = anchor < index ? { start: anchor, end: index + 1 } : { start: index, end: anchor + 1 } if (!isValidRange(range)) { throw new Error('Invalid range') } - if (isSelected({ selection, index: anchor })) { + if (isSelected({ ranges, index: anchor })) { // select the rest of the range - return selectRange({ selection, range }) + return selectRange({ ranges, range }) } else { // unselect the rest of the range - return unselectRange({ selection, range }) + return unselectRange({ ranges, range }) } } -export function toggleIndex({ selection, index }: { selection: Selection, index: number }): Selection { +export function toggleIndex({ ranges, index }: { ranges: Ranges, index: number }): Ranges { if (!isValidIndex(index)) { throw new Error('Invalid index') } const range = { start: index, end: index + 1 } - return isSelected({ selection, index }) ? unselectRange({ selection, range }) : selectRange({ selection, range }) + return isSelected({ ranges, index }) ? unselectRange({ ranges, range }) : selectRange({ ranges, range }) } diff --git a/test/HighTable.test.tsx b/test/HighTable.test.tsx index 9ca010d..55f7be2 100644 --- a/test/HighTable.test.tsx +++ b/test/HighTable.test.tsx @@ -1,8 +1,30 @@ -import { fireEvent, render, waitFor, within } from '@testing-library/react' -import { act } from 'react' +import { act, fireEvent, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import HighTable from '../src/HighTable.js' import { sortableDataFrame } from '../src/dataframe.js' +import HighTable from '../src/HighTable.js' +import { render } from './userEvent.js' + +const data = { + header: ['ID', 'Count'], + numRows: 1000, + rows: (start: number, end: number) => Promise.resolve( + Array.from({ length: end - start }, (_, index) => ({ + ID: 'row ' + (index + start), + Count: 1000 - start - index, + })) + ), +} + +const otherData = { + header: ['ID', 'Count'], + numRows: 1000, + rows: (start: number, end: number) => Promise.resolve( + Array.from({ length: end - start }, (_, index) => ({ + ID: 'other ' + (index + start), + Count: 1000 - start - index, + })) + ), +} describe('HighTable', () => { const mockData = { @@ -39,6 +61,13 @@ describe('HighTable', () => { }) }) + it('creates the rows after having fetched the data', async () => { + const { findByRole, queryByText, queryByRole } = render() + expect(queryByRole('cell', { name: 'Name 0' })).toBeNull() + // await because we have to wait for the data to be fetched first + await findByRole('cell', { name: 'Name 0' }) + }) + it('handles scroll to load more rows', async () => { const { container } = render() const scrollDiv = container.querySelector('.table-scroll') @@ -49,6 +78,8 @@ describe('HighTable', () => { }) act(() => { + // not using userEvent because it doesn't support scroll events + // https://github.com/testing-library/user-event/issues/475 fireEvent.scroll(scrollDiv, { target: { scrollTop: 500 } }) }) @@ -59,20 +90,20 @@ describe('HighTable', () => { it('correctly handles double click on cell', async () => { const mockDoubleClick = vi.fn() - const { findByText } = render() + const { user, findByText } = render() const cell = await findByText('Name 0') - fireEvent.doubleClick(cell) + await user.dblClick(cell) expect(mockDoubleClick).toHaveBeenCalledWith(expect.anything(), 1, 0) }) it('correctly handles middle click on cell', async () => { const mockMiddleClick = vi.fn() - const { findByText } = render() + const { user, findByText } = render() const cell = await findByText('Name 0') - fireEvent.mouseDown(cell, { button: 1 }) + await user.pointer({ keys: '[MouseMiddle>]', target: cell }) // press the middle mouse button without releasing it expect(mockMiddleClick).toHaveBeenCalledWith(expect.anything(), 1, 0) }) @@ -93,16 +124,6 @@ describe('HighTable', () => { describe('When sorted, HighTable', () => { - const data = { - header: ['ID', 'Count'], - numRows: 1000, - rows: (start: number, end: number) => Promise.resolve( - Array.from({ length: end - start }, (_, index) => ({ - ID: 'row ' + (index + start), - Count: 1000 - start - index, - })) - ), - } function checkRowContents(row: HTMLElement, rowNumber: string, ID: string, Count: string) { const selectionCell = within(row).getByRole('rowheader') @@ -116,7 +137,7 @@ describe('When sorted, HighTable', () => { } it('shows the rows in the right order', async () => { - const { findByRole, getByRole, findAllByRole } = render() + const { user, findByRole, getByRole, findAllByRole } = render() expect(getByRole('columnheader', { name: 'ID' })).toBeDefined() await findByRole('cell', { name: 'row 0' }) @@ -129,7 +150,7 @@ describe('When sorted, HighTable', () => { // Click on the Count header to sort by Count const countHeader = getByRole('columnheader', { name: 'Count' }) - fireEvent.click(countHeader) + await user.click(countHeader) await findAllByRole('cell', { name: 'row 999' }) rows = within(within(getByRole('grid')).getAllByRole('rowgroup')[1]).getAllByRole('row') @@ -138,22 +159,358 @@ describe('When sorted, HighTable', () => { it('provides the double click callback with the right row index', async () => { const mockDoubleClick = vi.fn() - const { findByRole, getByRole } = render() + const { user, findByRole, getByRole } = render() const cell0 = await findByRole('cell', { name: 'row 0' }) - fireEvent.doubleClick(cell0) + await user.dblClick(cell0) expect(mockDoubleClick).toHaveBeenCalledWith(expect.anything(), 0, 0) vi.clearAllMocks() // Click on the Count header to sort by Count const countHeader = getByRole('columnheader', { name: 'Count' }) - fireEvent.click(countHeader) + await user.click(countHeader) const cell999 = await findByRole('cell', { name: 'row 999' }) - fireEvent.doubleClick(cell999) + await user.dblClick(cell999) expect(mockDoubleClick).toHaveBeenCalledWith(expect.anything(), 0, 999) }) }) + +describe('in controlled selection state (selection and onSelection props), ', () => { + + + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('HighTable shows the selection if passed', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + const { findByRole, getAllByRole } = render() + // await because we have to wait for the data to be fetched first + const row = await findByRole('row', { selected: true }) + expect(row.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + expect(getAllByRole('row', { selected: true })).toHaveLength(1) + }) + + it('the table is marked as multiselectable', () => { + const selection = { ranges: [] } + const onSelectionChange = vi.fn() + const { getByRole } = render() + const table = getByRole('grid') + expect(table.getAttribute('aria-multiselectable')).toBe('true') + }) + + it('HighTable shows the new selection if updated, and onSelectionChange is not called', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + const { getAllByRole, findByRole, rerender } = render() + // await because we have to wait for the data to be fetched first + await findByRole('row', { selected: true }) + expect(onSelectionChange).not.toHaveBeenCalled() + onSelectionChange.mockClear() + + const other = 5 + const newSelection = { ranges: [{ start: other, end: other + 1 }], anchor: other } + rerender() + // no need to await because the data is already fetched + const selectedRows = getAllByRole('row', { selected: true }) + expect(selectedRows).toHaveLength(1) + expect(selectedRows[0].getAttribute('aria-rowindex')).toBe(`${other + 2}`) + expect(onSelectionChange).not.toHaveBeenCalled() + }) + + it('removing selection prop is ignored and a warning is printed in the console', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + console.warn = vi.fn() + + const { queryByRole, findByRole, rerender } = render() + // await because we have to wait for the data to be fetched first + await findByRole('row', { selected: true }) + expect(console.warn).not.toHaveBeenCalled() + + const newSelection = undefined + rerender() + // no need to await because the data is already fetched + expect(queryByRole('row', { selected: true })).toBeDefined() + expect(console.warn).toHaveBeenNthCalledWith(1, expect.stringMatching(/cannot be set to undefined/)) + }) + + it('on data change, onSelection is not called and the selection stays the same', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + const { rerender, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + const row = cell.closest('[role="row"]') + expect(row?.getAttribute('aria-selected')).toBe('true') + expect(row?.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + expect(onSelectionChange).not.toHaveBeenCalled() + onSelectionChange.mockClear() + + rerender() + // await again, since we have to wait for the new data to be fetched + const other = await findByRole('cell', { name: 'other 2' }) + expect(queryByRole('cell', { name: 'row 2' })).toBeNull() + const otherRow = other.closest('[role="row"]') + expect(otherRow?.getAttribute('aria-selected')).toBe('true') + expect(otherRow?.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + expect(onSelectionChange).not.toHaveBeenCalled() + }) + + it('click on a row number cell calls onSelection with the row selected, but changing nothing to the DOM', async () => { + const start = 2 + const selection = { ranges: [] } + const onSelectionChange = vi.fn() + const { user, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + expect(onSelectionChange).not.toHaveBeenCalled() + onSelectionChange.mockClear() + + const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [{ start, end: start + 1 }], anchor: start }) + expect(queryByRole('row', { selected: true })).toBeNull() + }) + + it('click on a selected row number cell calls unselects the row', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + const { user, findByRole } = render() + // await because we have to wait for the data to be fetched first + const row = await findByRole('row', { selected: true }) + onSelectionChange.mockClear() + + const rowHeader = row.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [], anchor: start }) + }) + + it('shift+click expands the selection', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const onSelectionChange = vi.fn() + const { user, findByRole } = render() + // await because we have to wait for the data to be fetched first + const other = 5 + const cell = await findByRole('cell', { name: `row ${other}` }) + onSelectionChange.mockClear() + const otherRowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(otherRowHeader).not.toBeNull() + await act(async () => { + // see https://testing-library.com/docs/user-event/setup/#starting-a-session-per-setup + await user.keyboard('[ShiftLeft>]') // Press Shift (without releasing it) + otherRowHeader && await user.click(otherRowHeader) // Perform a click with `shiftKey: true` + }) + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [{ start: start, end: other + 1 }], anchor: start }) + }) +}) + +describe('in controlled selection state, read-only (selection prop), ', () => { + + + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('HighTable shows the selection if passed', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const { findByRole, getAllByRole } = render() + // await because we have to wait for the data to be fetched first + const row = await findByRole('row', { selected: true }) + expect(row.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + expect(getAllByRole('row', { selected: true })).toHaveLength(1) + }) + + it('the table is not marked as multiselectable', () => { + const selection = { ranges: [] } + const { getByRole } = render() + const table = getByRole('grid') + expect(table.getAttribute('aria-multiselectable')).toBe('false') + }) + + it('HighTable shows the new selection if updated', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const { getAllByRole, findByRole, rerender } = render() + // await because we have to wait for the data to be fetched first + await findByRole('row', { selected: true }) + + const other = 5 + const newSelection = { ranges: [{ start: other, end: other + 1 }], anchor: other } + rerender() + // no need to await because the data is already fetched + const selectedRows = getAllByRole('row', { selected: true }) + expect(selectedRows).toHaveLength(1) + expect(selectedRows[0].getAttribute('aria-rowindex')).toBe(`${other + 2}`) + }) + + it('on data change, the selection stays the same', async () => { + const start = 2 + const selection = { ranges: [{ start, end: start + 1 }], anchor: start } + const { rerender, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + const row = cell.closest('[role="row"]') + expect(row?.getAttribute('aria-selected')).toBe('true') + expect(row?.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + + rerender() + // await again, since we have to wait for the new data to be fetched + const other = await findByRole('cell', { name: 'other 2' }) + expect(queryByRole('cell', { name: 'row 2' })).toBeNull() + const otherRow = other.closest('[role="row"]') + expect(otherRow?.getAttribute('aria-selected')).toBe('true') + expect(otherRow?.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + }) + + it('click on a row number cell does nothing', async () => { + const start = 2 + const selection = { ranges: [] } + const { user, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + + const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(queryByRole('row', { selected: true })).toBeNull() + }) +}) + +describe('in uncontrolled selection state (onSelection prop), ', () => { + + + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('HighTable shows no selection initially and onSelectionChange is not called', async () => { + const start = 2 + const onSelectionChange = vi.fn() + const { findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + await findByRole('cell', { name: 'row 2' }) + expect(queryByRole('row', { selected: true })).toBeNull() + expect(onSelectionChange).not.toHaveBeenCalled() + }) + + it('the table is marked as multiselectable', () => { + const onSelectionChange = vi.fn() + const { getByRole } = render() + const table = getByRole('grid') + expect(table.getAttribute('aria-multiselectable')).toBe('true') + }) + + it('click on a row number cell calls onSelection with the row selected, and changes the DOM to select the row', async () => { + const start = 2 + const onSelectionChange = vi.fn() + const { user, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + expect(onSelectionChange).not.toHaveBeenCalled() + onSelectionChange.mockClear() + + const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [{ start, end: start + 1 }], anchor: start }) + expect(queryByRole('row', { selected: true })?.getAttribute('aria-rowindex')).toBe(`${start + 2}`) + }) + + it('on data change, onSelection is called with an empty selection and the DOM is updated to unselect the rows', async () => { + const start = 2 + const onSelectionChange = vi.fn() + const { user, rerender, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + expect(onSelectionChange).not.toHaveBeenCalled() + onSelectionChange.mockClear() + + // select a row + const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [{ start, end: start + 1 }], anchor: start }) + + rerender() + // await again, since we have to wait for the new data to be fetched + const other = await findByRole('cell', { name: 'other 2' }) + expect(queryByRole('cell', { name: 'row 2' })).toBeNull() + expect(queryByRole('row', { selected: true })).toBeNull() + expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [] }) + }) + + it('passing the selection prop is ignored and a warning is printed in the console', async () => { + const start = 2 + const selection = undefined + const onSelectionChange = vi.fn() + console.warn = vi.fn() + + const { queryByRole, findByRole, rerender } = render() + // await because we have to wait for the data to be fetched first + await findByRole('cell', { name: 'row 2' }) + expect(queryByRole('row', { selected: true })).toBeNull() + expect(console.warn).not.toHaveBeenCalled() + + const newSelection = { ranges: [{ start, end: start + 1 }], anchor: start } + rerender() + // no need to await because the data is already fetched + expect(queryByRole('row', { selected: true })).toBeNull() + expect(console.warn).toHaveBeenNthCalledWith(1, expect.stringMatching(/cannot be set to a value/)) + }) +}) + +describe('in disabled selection state (neither selection nor onSelection props), ', () => { + + + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('the table is not marked as multiselectable', () => { + const { getByRole } = render() + const table = getByRole('grid') + expect(table.getAttribute('aria-multiselectable')).toBe('false') + }) + + it('click on a row number cell does nothing', async () => { + const { user, findByRole, queryByRole } = render() + // await because we have to wait for the data to be fetched first + const cell = await findByRole('cell', { name: 'row 2' }) + + const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') + expect(rowHeader).not.toBeNull() + await act(async () => { + rowHeader && await user.click(rowHeader) + }) + expect(queryByRole('row', { selected: true })).toBeNull() + }) +}) diff --git a/test/TableHeader.test.tsx b/test/TableHeader.test.tsx index fec1973..92bb8b6 100644 --- a/test/TableHeader.test.tsx +++ b/test/TableHeader.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, waitFor } from '@testing-library/react' +import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TableHeader, { ColumnWidth, cellStyle, saveColumnWidth } from '../src/TableHeader.js' +import { render } from './userEvent.js' vi.stubGlobal('localStorage', (() => { let store: Record = {} @@ -76,9 +77,9 @@ describe('TableHeader', () => { expect(setColumnWidths).toHaveBeenCalledWith([150, 250, undefined]) }) - it('handles double click to auto resize', () => { + it('handles double click to auto resize', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const { getByTitle } = render( + const { user, getByTitle } = render(
{ const resizeHandle = firstHeader.querySelector('span') if (!resizeHandle) throw new Error('Resize handle not found') - fireEvent.doubleClick(resizeHandle) + await user.dblClick(resizeHandle) expect(columnWidths).toEqual([100, 200, undefined]) expect(setColumnWidth).toHaveBeenCalledTimes(2) expect(setColumnWidths).toHaveBeenCalledTimes(2) @@ -100,7 +101,7 @@ describe('TableHeader', () => { it('handles mouse click and drag to resize', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const { getByTitle } = render(
+ const { user, getByTitle } = render(
{ const resizeHandle = firstHeader.querySelector('span') if (!resizeHandle) throw new Error('Resize handle not found') - fireEvent.mouseDown(resizeHandle, { clientX: 150 }) - fireEvent.mouseMove(window, { clientX: 160 }) - fireEvent.mouseUp(window) + await user.pointer([ + // press the left button on the resize handle, at x=150 + { keys: '[MouseLeft>]', target: resizeHandle, coords: { x: 150, y: 0 } }, + // move the pointer to x=160 + { coords: { x: 160, y: 0 } }, + // release the left button + { keys: '[/MouseLeft]' }, + ]) expect(setColumnWidth).toHaveBeenCalledWith(0, 110) @@ -129,75 +135,75 @@ describe('TableHeader', () => { }) }) - it('sets orderBy to the column name when a header is clicked', () => { + it('sets orderBy to the column name when a header is clicked', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const setOrderBy = vi.fn() - const { getByTitle } = render(
+ const onOrderByChange = vi.fn() + const { user, getByTitle } = render(
) const ageHeader = getByTitle('Age') - fireEvent.click(ageHeader) + await user.click(ageHeader) - expect(setOrderBy).toHaveBeenCalledWith('Age') + expect(onOrderByChange).toHaveBeenCalledWith({ column: 'Age' }) }) - it('toggles orderBy to undefined when the same header is clicked again', () => { + it('toggles orderBy to undefined when the same header is clicked again', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const setOrderBy = vi.fn() - const { getByTitle } = render( + const onOrderByChange = vi.fn() + const { user, getByTitle } = render(
) const ageHeader = getByTitle('Age') - fireEvent.click(ageHeader) + await user.click(ageHeader) - expect(setOrderBy).toHaveBeenCalledWith(undefined) + expect(onOrderByChange).toHaveBeenCalledWith({}) }) - it('changes orderBy to a new column when a different header is clicked', () => { + it('changes orderBy to a new column when a different header is clicked', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const setOrderBy = vi.fn() - const { getByTitle } = render( + const onOrderByChange = vi.fn() + const { user, getByTitle } = render(
) const addressHeader = getByTitle('Address') - fireEvent.click(addressHeader) + await user.click(addressHeader) - expect(setOrderBy).toHaveBeenCalledWith('Address') + expect(onOrderByChange).toHaveBeenCalledWith({ column: 'Address' }) }) - it('does not change orderBy when clicking on the resize handle', () => { + it('does not change orderBy when clicking on the resize handle', async () => { const { columnWidths, setColumnWidth, setColumnWidths } = mockColumnWidths() - const setOrderBy = vi.fn() - const { getByTitle } = render( + const onOrderByChange = vi.fn() + const { user, getByTitle } = render(
) @@ -205,9 +211,9 @@ describe('TableHeader', () => { const resizeHandle = nameHeader.querySelector('span') if (!resizeHandle) throw new Error('Resize handle not found') - fireEvent.click(resizeHandle) + await user.click(resizeHandle) - expect(setOrderBy).not.toHaveBeenCalled() + expect(onOrderByChange).not.toHaveBeenCalled() }) it('reloads column widths when cacheKey changes', () => { diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 0000000..1582579 --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,128 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInputState } from '../src/hooks.js' + +describe('in controlled mode (value is defined), ', () => { + const value: string = 'value' + const onChange = vi.fn() + const defaultValue = 'default' + const newValue = 'new value' + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('the input is controlled', () => { + const { result } = renderHook(() => useInputState({ value, onChange })) + expect(result.current.isControlled).toBe(true) + }) + + it('the interactions are enabled', () => { + const { result } = renderHook(() => useInputState({ value, onChange })) + expect(result.current.enableInteractions).toBe(true) + }) + + it('the initial value is value, not defaultValue', () => { + const { result } = renderHook(() => useInputState({ value, onChange, defaultValue })) + expect(result.current.value).toBe(value) + }) + + it('the onChange prop is called on input change and the value remains to the prop value', () => { + const { result } = renderHook(() => useInputState({ value, onChange })) + act(() => { + result.current.onChange(newValue) + }) + expect(onChange).toHaveBeenCalledWith(newValue) + expect(result.current.value).toBe(value) + }) + + it('if the onChange prop is undefined, the value remains to the prop value on input change, and the interactions are disabled', () => { + const { result } = renderHook(() => useInputState({ value })) + expect(result.current.enableInteractions).toBe(false) + act(() => { + result.current.onChange(newValue) + }) + expect(result.current.value).toBe(value) + }) + + it('the value is disabled if the "disabled" option is true: value is undefined and props.onChange is not called on input change', () => { + const { result } = renderHook(() => useInputState({ value, onChange, disabled: true })) + expect(result.current.value).toBe(undefined) + expect(result.current.enableInteractions).toBe(false) + expect(result.current.isControlled).toBe(true) + act(() => { + result.current.onChange(newValue) + }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('the prop value cannot be set to undefined afterwards', () => { + const { result, rerender } = renderHook(() => useInputState({ value, onChange })) + act(() => { + rerender({ value: undefined, onChange }) + }) + expect(onChange).not.toHaveBeenCalled() + expect(result.current.value).toBe(value) + }) +}) + +describe('in uncontrolled mode (value is undefined), ', () => { + const onChange = vi.fn((_: string) => {}) + const value = 'value' + const defaultValue = 'default' + const newValue = 'new value' + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('the input is uncontrolled', () => { + const { result } = renderHook(() => useInputState({ onChange })) + expect(result.current.isControlled).toBe(false) + }) + + it('the interactions are enabled', () => { + const { result } = renderHook(() => useInputState({ onChange })) + expect(result.current.enableInteractions).toBe(true) + }) + + it('the initial value is defaultValue', () => { + const { result } = renderHook(() => useInputState({ onChange, defaultValue })) + expect(result.current.value).toBe(defaultValue) + }) + + it('the initial value, defaultValue, can be undefined', () => { + const { result } = renderHook(() => useInputState({ onChange })) + expect(result.current.value).toBe(undefined) + }) + + it('the prop onChange function is called on input change and the value is set to the new value', () => { + const { result } = renderHook(() => useInputState({ onChange })) + act(() => { + result.current.onChange(newValue) + }) + expect(onChange).toHaveBeenCalledWith(newValue) + expect(result.current.value).toBe(newValue) + }) + + it('the value is disabled if the "disabled" option is true: value is undefined and props.onChange is not called on input change', () => { + const { result } = renderHook(() => useInputState({ onChange, disabled: true })) + expect(result.current.value).toBe(undefined) + expect(result.current.enableInteractions).toBe(false) + expect(result.current.isControlled).toBe(false) + act(() => { + result.current.onChange(newValue) + }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('the prop value cannot be defined afterwards', () => { + const { result, rerender } = renderHook(() => useInputState({ onChange })) + act(() => { + rerender({ value, onChange }) + }) + expect(onChange).not.toHaveBeenCalled() + expect(result.current.value).toBe(undefined) + }) +}) + diff --git a/test/selection.test.ts b/test/selection.test.ts index 3f739c8..0dea270 100644 --- a/test/selection.test.ts +++ b/test/selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { areAllSelected, extendFromAnchor, isSelected, isValidIndex, isValidRange, isValidSelection, selectRange, toggleAll, toggleIndex, unselectRange } from '../src/selection.js' +import { areAllSelected, areValidRanges, extendFromAnchor, isSelected, isValidIndex, isValidRange, selectRange, toggleAll, toggleIndex, unselectRange } from '../src/selection.js' describe('an index', () => { test('is a positive integer', () => { @@ -31,180 +31,180 @@ describe('a range', () => { }) }) -describe('a selection', () => { +describe('selection ranges', () => { test('can be empty', () => { - expect(isValidSelection([])).toBe(true) + expect(areValidRanges([])).toBe(true) }) - test('has valid ranges', () => { - expect(isValidSelection([{ start: 0, end: 1 }])).toBe(true) - expect(isValidSelection([{ start: 1, end: 0 }])).toBe(false) - expect(isValidSelection([{ start: -1, end: 1 }])).toBe(false) - expect(isValidSelection([{ start: NaN, end: 1 }])).toBe(false) - expect(isValidSelection([{ start: 0, end: Infinity }])).toBe(false) + test('have valid ranges', () => { + expect(areValidRanges([{ start: 0, end: 1 }])).toBe(true) + expect(areValidRanges([{ start: 1, end: 0 }])).toBe(false) + expect(areValidRanges([{ start: -1, end: 1 }])).toBe(false) + expect(areValidRanges([{ start: NaN, end: 1 }])).toBe(false) + expect(areValidRanges([{ start: 0, end: Infinity }])).toBe(false) }) - test('has ordered ranges', () => { - expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: 3 }])).toBe(true) - expect(isValidSelection([{ start: 2, end: 3 }, { start: 0, end: 1 }])).toBe(false) + test('have ordered ranges', () => { + expect(areValidRanges([{ start: 0, end: 1 }, { start: 2, end: 3 }])).toBe(true) + expect(areValidRanges([{ start: 2, end: 3 }, { start: 0, end: 1 }])).toBe(false) }) - test('has non-overlapping, separated ranges', () => { - expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: 3 }])).toBe(true) - expect(isValidSelection([{ start: 0, end: 1 }, { start: 0, end: 1 }])).toBe(false) - expect(isValidSelection([{ start: 0, end: 2 }, { start: 1, end: 3 }])).toBe(false) - expect(isValidSelection([{ start: 0, end: 2 }, { start: 2, end: 3 }])).toBe(false) + test('have non-overlapping, separated ranges', () => { + expect(areValidRanges([{ start: 0, end: 1 }, { start: 2, end: 3 }])).toBe(true) + expect(areValidRanges([{ start: 0, end: 1 }, { start: 0, end: 1 }])).toBe(false) + expect(areValidRanges([{ start: 0, end: 2 }, { start: 1, end: 3 }])).toBe(false) + expect(areValidRanges([{ start: 0, end: 2 }, { start: 2, end: 3 }])).toBe(false) }) test('can contain any number of ranges', () => { - expect(isValidSelection([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }])).toBe(true) + expect(areValidRanges([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }])).toBe(true) }) }) describe('toggling an index', () => { test('should throw an error if the index is invalid', () => { - expect(() => toggleIndex({ selection: [], index: -1 })).toThrow('Invalid index') + expect(() => toggleIndex({ ranges: [], index: -1 })).toThrow('Invalid index') }) test('should throw an error if the selection is invalid', () => { - expect(() => toggleIndex({ selection: [{ start: 1, end: 0 }], index: 0 })).toThrow('Invalid selection') + expect(() => toggleIndex({ ranges: [{ start: 1, end: 0 }], index: 0 })).toThrow('Invalid ranges') }) test('should add a new range if outside and separated from existing ranges', () => { - expect(toggleIndex({ selection: [], index: 0 })).toEqual([{ start: 0, end: 1 }]) - expect(toggleIndex({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], index: 2 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) + expect(toggleIndex({ ranges: [], index: 0 })).toEqual([{ start: 0, end: 1 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 1 }, { start: 4, end: 5 }], index: 2 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) }) test('should merge with the previous and/or following ranges if adjacent', () => { - expect(toggleIndex({ selection: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 2 }]) - expect(toggleIndex({ selection: [{ start: 1, end: 2 }], index: 0 })).toEqual([{ start: 0, end: 2 }]) - expect(toggleIndex({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 3 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 2 }]) + expect(toggleIndex({ ranges: [{ start: 1, end: 2 }], index: 0 })).toEqual([{ start: 0, end: 2 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 1 }, { start: 2, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 3 }]) }) test('should split a range if the index is inside', () => { - expect(toggleIndex({ selection: [{ start: 0, end: 2 }], index: 1 })).toEqual([{ start: 0, end: 1 }]) - expect(toggleIndex({ selection: [{ start: 0, end: 2 }], index: 0 })).toEqual([{ start: 1, end: 2 }]) - expect(toggleIndex({ selection: [{ start: 0, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 2 }], index: 1 })).toEqual([{ start: 0, end: 1 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 2 }], index: 0 })).toEqual([{ start: 1, end: 2 }]) + expect(toggleIndex({ ranges: [{ start: 0, end: 3 }], index: 1 })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }]) }) test('should remove a range if it\'s only the index', () => { - expect(toggleIndex({ selection: [{ start: 0, end: 1 }], index: 0 })).toEqual([]) + expect(toggleIndex({ ranges: [{ start: 0, end: 1 }], index: 0 })).toEqual([]) }) test('twice should be idempotent', () => { - const a = toggleIndex({ selection: [], index: 0 }) - const b = toggleIndex({ selection: a, index: 0 }) + const a = toggleIndex({ ranges: [], index: 0 }) + const b = toggleIndex({ ranges: a, index: 0 }) expect(b).toEqual([]) }) }) describe('isSelected', () => { test('should return true if the index is selected', () => { - expect(isSelected({ selection: [{ start: 0, end: 1 }], index: 0 })).toBe(true) - expect(isSelected({ selection: [{ start: 0, end: 2 }], index: 1 })).toBe(true) - expect(isSelected({ selection: [{ start: 0, end: 1 }], index: 1 })).toBe(false) + expect(isSelected({ ranges: [{ start: 0, end: 1 }], index: 0 })).toBe(true) + expect(isSelected({ ranges: [{ start: 0, end: 2 }], index: 1 })).toBe(true) + expect(isSelected({ ranges: [{ start: 0, end: 1 }], index: 1 })).toBe(false) }) test('should throw an error if the index is invalid', () => { - expect(() => isSelected({ selection: [], index: -1 })).toThrow('Invalid index') + expect(() => isSelected({ ranges: [], index: -1 })).toThrow('Invalid index') }) test('should throw an error if the selection is invalid', () => { - expect(() => isSelected({ selection: [{ start: 1, end: 0 }], index: 0 })).toThrow('Invalid selection') + expect(() => isSelected({ ranges: [{ start: 1, end: 0 }], index: 0 })).toThrow('Invalid ranges') }) }) describe('areAllSelected', () => { test('should return true if all indices are selected', () => { - expect(areAllSelected({ selection: [{ start: 0, end: 3 }], length: 3 })).toBe(true) - expect(areAllSelected({ selection: [{ start: 0, end: 1 }], length: 3 })).toBe(false) - expect(areAllSelected({ selection: [{ start: 1, end: 3 }], length: 3 })).toBe(false) + expect(areAllSelected({ ranges: [{ start: 0, end: 3 }], length: 3 })).toBe(true) + expect(areAllSelected({ ranges: [{ start: 0, end: 1 }], length: 3 })).toBe(false) + expect(areAllSelected({ ranges: [{ start: 1, end: 3 }], length: 3 })).toBe(false) }) test('should throw an error if the selection is invalid', () => { - expect(() => areAllSelected({ selection: [{ start: 1, end: 0 }], length: 0 })).toThrow('Invalid selection') + expect(() => areAllSelected({ ranges: [{ start: 1, end: 0 }], length: 0 })).toThrow('Invalid ranges') }) test('should throw an error if the length is invalid', () => { - expect(() => areAllSelected({ selection: [], length: -1 })).toThrow('Invalid length') + expect(() => areAllSelected({ ranges: [], length: -1 })).toThrow('Invalid length') }) }) describe('toggleAll', () => { test('should return an empty selection if all indices are selected', () => { - expect(toggleAll({ selection: [{ start: 0, end: 3 }], length: 3 })).toEqual([]) + expect(toggleAll({ ranges: [{ start: 0, end: 3 }], length: 3 })).toEqual([]) }) test('should return a selection with all indices if none are selected', () => { - expect(toggleAll({ selection: [], length: 3 })).toEqual([{ start: 0, end: 3 }]) + expect(toggleAll({ ranges: [], length: 3 })).toEqual([{ start: 0, end: 3 }]) }) test('should return a selection with all indices if some are selected', () => { - expect(toggleAll({ selection: [{ start: 0, end: 1 }], length: 3 })).toEqual([{ start: 0, end: 3 }]) + expect(toggleAll({ ranges: [{ start: 0, end: 1 }], length: 3 })).toEqual([{ start: 0, end: 3 }]) }) test('should throw an error if the selection is invalid', () => { - expect(() => toggleAll({ selection: [{ start: 1, end: 0 }], length: 0 })).toThrow('Invalid selection') + expect(() => toggleAll({ ranges: [{ start: 1, end: 0 }], length: 0 })).toThrow('Invalid ranges') }) test('should throw an error if the length is invalid', () => { - expect(() => toggleAll({ selection: [], length: -1 })).toThrow('Invalid length') + expect(() => toggleAll({ ranges: [], length: -1 })).toThrow('Invalid length') }) }) describe('selectRange', () => { test('should throw an error if the range is invalid', () => { - expect(() => selectRange({ selection: [], range: { start: -1, end: 0 } })).toThrow('Invalid range') + expect(() => selectRange({ ranges: [], range: { start: -1, end: 0 } })).toThrow('Invalid range') }) test('should throw an error if the selection is invalid', () => { - expect(() => selectRange({ selection: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid selection') + expect(() => selectRange({ ranges: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid ranges') }) test('should add a new range if outside and separated from existing ranges', () => { - expect(selectRange({ selection: [], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 1 }]) - expect(selectRange({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) + expect(selectRange({ ranges: [], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 1 }]) + expect(selectRange({ ranges: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) }) test('should merge with the previous and/or following ranges if adjacent', () => { - expect(selectRange({ selection: [{ start: 0, end: 1 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 2 }]) - expect(selectRange({ selection: [{ start: 1, end: 2 }], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 2 }]) - expect(selectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 3 }]) + expect(selectRange({ ranges: [{ start: 0, end: 1 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 2 }]) + expect(selectRange({ ranges: [{ start: 1, end: 2 }], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 2 }]) + expect(selectRange({ ranges: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 3 }]) }) }) describe('unselectRange', () => { test('should throw an error if the range is invalid', () => { - expect(() => unselectRange({ selection: [], range: { start: -1, end: 0 } })).toThrow('Invalid range') + expect(() => unselectRange({ ranges: [], range: { start: -1, end: 0 } })).toThrow('Invalid range') }) test('should throw an error if the selection is invalid', () => { - expect(() => unselectRange({ selection: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid selection') + expect(() => unselectRange({ ranges: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid ranges') }) test('should remove the range if it exists', () => { - expect(unselectRange({ selection: [{ start: 0, end: 1 }], range: { start: 0, end: 1 } })).toEqual([]) - expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 0, end: 1 } })).toEqual([{ start: 2, end: 3 }]) - expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }]) + expect(unselectRange({ ranges: [{ start: 0, end: 1 }], range: { start: 0, end: 1 } })).toEqual([]) + expect(unselectRange({ ranges: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 0, end: 1 } })).toEqual([{ start: 2, end: 3 }]) + expect(unselectRange({ ranges: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }]) }) test('should split the range if it is inside', () => { - expect(unselectRange({ selection: [{ start: 0, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }]) + expect(unselectRange({ ranges: [{ start: 0, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }]) }) test('should do nothing if the range does not intersect with the selection', () => { - expect(unselectRange({ selection: [{ start: 0, end: 1 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }]) - expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 4, end: 5 }]) + expect(unselectRange({ ranges: [{ start: 0, end: 1 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }]) + expect(unselectRange({ ranges: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 4, end: 5 }]) }) }) describe('extendFromAnchor', () => { test('should throw an error if the selection is invalid', () => { - expect(() => extendFromAnchor({ selection: [{ start: 1, end: 0 }], anchor: 0, index: 1 })).toThrow('Invalid selection') + expect(() => extendFromAnchor({ ranges: [{ start: 1, end: 0 }], anchor: 0, index: 1 })).toThrow('Invalid ranges') }) test('does nothing if the anchor is undefined', () => { - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 1 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 1 }]) }) test('does nothing if the anchor and the index are the same', () => { - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 0, index: 0 })).toEqual([{ start: 0, end: 1 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }], anchor: 0, index: 0 })).toEqual([{ start: 0, end: 1 }]) }) test('should throw an error if the anchor or the index are invalid', () => { - expect(() => extendFromAnchor({ selection: [], anchor: -1, index: 0 })).toThrow('Invalid index') - expect(() => extendFromAnchor({ selection: [], anchor: 0, index: -1 })).toThrow('Invalid index') + expect(() => extendFromAnchor({ ranges: [], anchor: -1, index: 0 })).toThrow('Invalid index') + expect(() => extendFromAnchor({ ranges: [], anchor: 0, index: -1 })).toThrow('Invalid index') }) test('should select the range between the bounds (inclusive) if anchor was selected', () => { - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 0, index: 1 })).toEqual([{ start: 0, end: 2 }]) - expect(extendFromAnchor({ selection: [{ start: 1, end: 2 }], anchor: 1, index: 0 })).toEqual([{ start: 0, end: 2 }]) - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 0, index: 5 })).toEqual([{ start: 0, end: 6 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }], anchor: 0, index: 1 })).toEqual([{ start: 0, end: 2 }]) + expect(extendFromAnchor({ ranges: [{ start: 1, end: 2 }], anchor: 1, index: 0 })).toEqual([{ start: 0, end: 2 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 0, index: 5 })).toEqual([{ start: 0, end: 6 }]) }) test('should unselect the range between the bounds (inclusive) if anchor was not selected', () => { - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }]) - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 2, index: 0 })).toEqual([]) - expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }], anchor: 2, index: 0 })).toEqual([]) + expect(extendFromAnchor({ ranges: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }]) }) }) diff --git a/test/tableControl.test.ts b/test/tableControl.test.ts deleted file mode 100644 index 662b2cd..0000000 --- a/test/tableControl.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTableControl } from '../src/HighTable.js' - -describe('createTableControl', () => { - it('returns an object with a publisher and setOrderBy function', () => { - const tableControl = createTableControl() - const subscriber = vi.fn() - expect(tableControl).toHaveProperty('publisher') - expect(tableControl).toHaveProperty('setOrderBy') - expect(typeof tableControl.publisher.subscribe).toBe('function') - expect(typeof tableControl.publisher.unsubscribe).toBe('function') - expect(typeof tableControl.setOrderBy).toBe('function') - }) - - it('publishes SET_ORDER action with correct orderBy when setOrderBy is called', () => { - const tableControl = createTableControl() - const subscriber = vi.fn() - tableControl.publisher.subscribe(subscriber) - tableControl.setOrderBy('name') - - expect(subscriber).toHaveBeenCalledTimes(1) - expect(subscriber).toHaveBeenCalledWith({ - type: 'SET_ORDER', - orderBy: 'name', - }) - }) - - it('does not call unsubscribed subscriber', () => { - const tableControl = createTableControl() - const subscriber = vi.fn() - tableControl.publisher.subscribe(subscriber) - tableControl.publisher.unsubscribe(subscriber) - tableControl.setOrderBy('price') - - expect(subscriber).not.toHaveBeenCalled() - }) - - it('allows multiple subscribers to receive the published action', () => { - const tableControl = createTableControl() - const subscriber = vi.fn() - const anotherSubscriber = vi.fn() - tableControl.publisher.subscribe(subscriber) - tableControl.publisher.subscribe(anotherSubscriber) - tableControl.setOrderBy('quantity') - - expect(subscriber).toHaveBeenCalledWith({ - type: 'SET_ORDER', - orderBy: 'quantity', - }) - expect(anotherSubscriber).toHaveBeenCalledWith({ - type: 'SET_ORDER', - orderBy: 'quantity', - }) - }) -}) diff --git a/test/userEvent.ts b/test/userEvent.ts new file mode 100644 index 0000000..f35bd64 --- /dev/null +++ b/test/userEvent.ts @@ -0,0 +1,14 @@ +import { render as reactRender } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { ReactNode } from 'react' + +// setup function +export function render(jsx: ReactNode) { + return { + // Setup userEvent + user: userEvent.setup(), + // Import `render` from React + // See https://testing-library.com/docs/dom-testing-library/install#wrappers + ...reactRender(jsx), + } +}