diff --git a/packages/cypress-commands/TestSetup.mdx b/packages/cypress-commands/TestSetup.mdx index 13ae8ad0b60..1ce508e24c8 100644 --- a/packages/cypress-commands/TestSetup.mdx +++ b/packages/cypress-commands/TestSetup.mdx @@ -15,7 +15,7 @@ UI5 Web Components for React is using [Cypress](https://www.cypress.io/) as pref When launching Cypress the first time you're guided through the setup, which then will create a [configuration file](https://docs.cypress.io/guides/references/configuration) for you. You can use any configuration you like, but since we're heavily relying on web-components, we recommend traversing the shadow DOM per default: ```js -includeShadowDom: true +includeShadowDom: true; ``` [Here](https://docs.cypress.io/guides/component-testing/react/overview) you can find the Cypress Quickstart tutorial for React. diff --git a/packages/main/package.json b/packages/main/package.json index 34ba9433b9e..0357cf391d9 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -49,6 +49,7 @@ "watch:css": "yarn build:css --watch" }, "dependencies": { + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "~3.13.0", "@ui5/webcomponents-react-base": "workspace:~", "clsx": "2.1.1", diff --git a/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.module.css b/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.module.css new file mode 100644 index 00000000000..84cd059e409 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.module.css @@ -0,0 +1,119 @@ +/*todo scroll margin for interactive elements (scroll into view when focused)*/ +/*todo: use will-change: transform?*/ +.sticky { + position: sticky; + z-index: 1; +} + +.cell { + box-sizing: border-box; + display: flex; + overflow: hidden; + /*todo: dev*/ + border-inline: solid 1px black; +} + +/* ============================================================= */ +/* Container */ +/* ============================================================= */ + +.tableContainer { + overflow: auto; + position: relative; + background-color: var(--sapList_Background); + font-size: var(--sapFontSize); + box-sizing: border-box; + overscroll-behavior: none; +} + +/* ============================================================= */ +/* Table */ +/* ============================================================= */ + +.table { + /*todo check if we really require grid here*/ + display: grid; +} + +/* ============================================================= */ +/* RowGroup */ +/* ============================================================= */ + +.headerGroups { + inset-block-start: 0; + font-family: var(--_ui5wcr-AnalyticalTable-HeaderFontFamily); + z-index: 2; + + > [data-component-name='AnalyticalTableV2HeaderRow']:last-child { + /*todo: box shadow or border --> specs*/ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +} + +.topRowsGroup { + inset-block-start: calc(var(--_ui5WcrAnalyticalTableHeaderGroups) * var(--_ui5WcrAnalyticalTableControlledRowHeight)); + height: calc(var(--_ui5WcrAnalyticalTableTopRows) * var(--_ui5WcrAnalyticalTableControlledRowHeight)); + + > [data-component-name='AnalyticalTableV2TopRow']:last-child { + /*todo: box shadow or border --> specs*/ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +} + +.bottomRowsGroup { + inset-block-end: 0; + height: calc(var(--_ui5WcrAnalyticalTableBottomRows) * var(--_ui5WcrAnalyticalTableControlledRowHeight)); + + > [data-component-name='AnalyticalTableV2BottomRow']:first-child { + /*todo: box shadow or border --> specs*/ + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + } +} + +/* ============================================================= */ +/* Row */ +/* ============================================================= */ + +.row { + box-sizing: border-box; + display: flex; + width: 100%; + height: var(--_ui5WcrAnalyticalTableControlledRowHeight); + background-color: var(--sapList_Background); + + &.selectable { + cursor: pointer; + } + &.selected { + border-block-end: 1px solid var(--sapList_SelectionBorderColor); + background-color: var(--sapList_SelectionBackgroundColor); + } +} + +/* ============================================================= */ +/* Header */ +/* ============================================================= */ + +/*.headerCell {*/ +/* display: flex;*/ +/*}*/ + +.headerRow { + background-color: var(--sapList_HeaderBackground); +} + +.headerInteractive { + cursor: pointer; + /* todo:remove*/ + background: lightgrey; +} + +/* ============================================================= */ +/* Body */ +/* ============================================================= */ + +.virtualizedRow { + position: absolute; + inset-inline-start: 0; + inset-block-start: 0; +} diff --git a/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.stories.tsx b/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.stories.tsx new file mode 100644 index 00000000000..441b6239fa1 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/AnalyticalTableV2.stories.tsx @@ -0,0 +1,122 @@ +import dataLarge from '@sb/mockData/Friends500.json'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Button, Input } from '@ui5/webcomponents-react'; +import { Profiler, useReducer } from 'react'; +import { AnalyticalTableV2 } from './index.js'; + +//todo make id mandatory, or take this into account for custom implementations: https://tanstack.com/table/latest/docs/api/core/column-def --> imo id mandatory is the easiest way + +//todo: any +const columns: ColumnDef[] = [ + { + header: 'Person', + id: 'A', + columns: [ + { header: 'Name', accessorKey: 'name', id: 'B' }, + { header: 'Age', accessorKey: 'age', id: 'C' } + ] + }, + { + id: 'D', + header: 'Friend', + columns: [ + { header: 'Friend Name', accessorKey: 'friend.name', id: 'E' }, + { header: 'Friend Age', accessorKey: 'friend.age', id: 'F' } + ] + }, + { + id: 'G', + header: 'Pinnable', + columns: [ + { + maxSize: 100, + header: 'Column Pinned', + id: 'c_pinned', + cell: ({ row }) => { + return 'Pinned'; + } + }, + { + header: 'Pin Row', + id: 'r_pinned', + size: 300, + cell: ({ row }) => { + return ( + <> + + + + + ); + } + }, + { header: 'Input', cell: () => , id: 'input' } + ] + } +]; + +const data = dataLarge.map((item, index) => ({ ...item, friend: { ...item.friend, age: index } })).slice(0); +const data5k = [ + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge, + ...dataLarge +]; +const data20k = [...data5k, ...data5k, ...data5k, ...data5k]; +const data100k = [...data20k, ...data20k, ...data20k, ...data20k, ...data20k]; + +const data500k = [...data100k, ...data100k, ...data100k, ...data100k, ...data100k]; +console.log(data20k.length); +const meta = { + title: 'Data Display / AnalyticalTableV2', + component: AnalyticalTableV2, + args: { + data: data100k, + columns, + visibleRows: 5, + selectionMode: 'Single' + }, + argTypes: { data: { control: { disable: true } }, columns: { control: { disable: true } } } +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render(args) { + const [sortable, toggleSortable] = useReducer((prev) => !prev, true); + return ( + <> +
+ + {/**/} + + {/**/} + + ); + } +}; diff --git a/packages/main/src/components/AnalyticalTableV2/core/Cell.tsx b/packages/main/src/components/AnalyticalTableV2/core/Cell.tsx new file mode 100644 index 00000000000..c16d91fdb42 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/core/Cell.tsx @@ -0,0 +1,98 @@ +import type { Column, CoreCell, CoreHeader } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { clsx } from 'clsx'; +import type { CSSProperties, HTMLAttributes } from 'react'; +import { useId, useState } from 'react'; +import { classNames } from '../AnalyticalTableV2.module.css.js'; +import { ColumnPopover } from './ColumnPopover.js'; + +//todo type +const getCommonPinningStyles = (column: Column): CSSProperties => { + const isPinned = column.getIsPinned(); + const isLastLeftPinnedColumn = isPinned === 'left' && column.getIsLastColumn('left'); + const isFirstRightPinnedColumn = isPinned === 'right' && column.getIsFirstColumn('right'); + + return { + boxShadow: isLastLeftPinnedColumn + ? '-4px 0 4px -4px gray inset' + : isFirstRightPinnedColumn + ? '4px 0 4px -4px gray inset' + : undefined, + insetInlineStart: isPinned === 'left' ? `${column.getStart('left')}px` : undefined, + insetInlineEnd: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined, + position: isPinned ? 'sticky' : 'relative', + width: column.getSize(), + zIndex: isPinned ? 1 : 0 + }; +}; + +interface CellProps { + style?: CSSProperties; + role: HTMLAttributes['role']; + /** + * cell object (e.g. `header`, `cell`) + */ + cell: CoreCell | CoreHeader; + //todo type + renderable: any; + startIndex: number; + isFirstFocusableCell?: boolean; + isSortable?: boolean; + isSelectionCell: boolean; + isSelectableCell?: boolean; +} + +//todo: create own component for header cells or handle this via props? +export function Cell(props: CellProps) { + const { + style = {}, + role, + cell, + renderable, + startIndex, + isFirstFocusableCell, + isSortable, + isSelectionCell, + isSelectableCell, + ...rest + } = props; + const cellContext = cell.getContext(); + const isInteractive = isSortable; + const openerId = `${useId()}-opener`; + + const [popoverOpen, setPopoverOpen] = useState(false); + + const openPopover = () => { + setPopoverOpen(true); + }; + + return ( + <> +
+ {flexRender(renderable, cellContext)} +
+ {/*`id` as opener is simpler than Ref, because we can't add a ref directly as prop (React18)*/} + {popoverOpen && ( + + )} + + ); +} + +Cell.displayName = 'AnalyticalTableV2Cell'; diff --git a/packages/main/src/components/AnalyticalTableV2/core/ColumnPopover.tsx b/packages/main/src/components/AnalyticalTableV2/core/ColumnPopover.tsx new file mode 100644 index 00000000000..5da3c3e2ed8 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/core/ColumnPopover.tsx @@ -0,0 +1,86 @@ +import type { Column } from '@tanstack/react-table'; +import iconDecline from '@ui5/webcomponents-icons/dist/decline.js'; +import iconSortAscending from '@ui5/webcomponents-icons/dist/sort-ascending.js'; +import iconSortDescending from '@ui5/webcomponents-icons/dist/sort-descending.js'; +import { useI18nBundle } from '@ui5/webcomponents-react-base'; +import type { Dispatch, SetStateAction } from 'react'; +import { forwardRef } from 'react'; +import { CLEAR_SORTING, FILTER, GROUP, SORT_ASCENDING, SORT_DESCENDING, UNGROUP } from '../../../i18n/i18n-defaults.js'; +import type { ListPropTypes } from '../../../webComponents/List/index.js'; +import { List } from '../../../webComponents/List/index.js'; +import { ListItemStandard } from '../../../webComponents/ListItemStandard/index.js'; +import type { PopoverDomRef } from '../../../webComponents/Popover/index.js'; +import { Popover } from '../../../webComponents/Popover/index.js'; + +interface ColumnPopoverProps { + isSortable?: boolean; + openerId: string; + //todo: type + column: Column; + setOpen: Dispatch>; +} + +//todo: check if forward ref is still required +export const ColumnPopover = forwardRef((props, ref) => { + const { isSortable, openerId, setOpen, column } = props; + const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); + const clearSortingText = i18nBundle.getText(CLEAR_SORTING); + const sortAscendingText = i18nBundle.getText(SORT_ASCENDING); + const sortDescendingText = i18nBundle.getText(SORT_DESCENDING); + const groupText = i18nBundle.getText(GROUP); + const ungroupText = i18nBundle.getText(UNGROUP); + const filterText = i18nBundle.getText(FILTER); + + const handleClose = () => { + setOpen(false); + }; + + const handleSelectionChange: ListPropTypes['onSelectionChange'] = (e) => { + const { type } = e.detail.targetItem.dataset; + + switch (type) { + case 'asc': + //todo: multi sort? + column.toggleSorting(false); + break; + case 'desc': + column.toggleSorting(true); + break; + case 'clear': + column.clearSorting(); + break; + } + handleClose(); + }; + + const isSorted = column.getIsSorted(); + + return ( + + + {isSorted === 'asc' && ( + + {clearSortingText} + + )} + {isSortable && isSorted !== 'asc' && ( + + {sortAscendingText} + + )} + {isSortable && isSorted !== 'desc' && ( + + {sortDescendingText} + + )} + {isSorted === 'desc' && ( + + {clearSortingText} + + )} + + + ); +}); + +ColumnPopover.displayName = 'ColumnPopover'; diff --git a/packages/main/src/components/AnalyticalTableV2/core/Row.tsx b/packages/main/src/components/AnalyticalTableV2/core/Row.tsx new file mode 100644 index 00000000000..a1afcd6e696 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/core/Row.tsx @@ -0,0 +1,20 @@ +import type { CommonProps } from '@ui5/webcomponents-react'; +import { clsx } from 'clsx'; +import { classNames } from '../AnalyticalTableV2.module.css.js'; + +interface RowProps extends CommonProps { + //todo children type + children: any; + startIndex: number; +} + +export function Row(props: RowProps) { + const { children, className, startIndex, ...rest } = props; + return ( +
+ {children} +
+ ); +} + +Row.displayName = 'AnalyticalTableRow'; diff --git a/packages/main/src/components/AnalyticalTableV2/factories/index.ts b/packages/main/src/components/AnalyticalTableV2/factories/index.ts new file mode 100644 index 00000000000..75d64f7d381 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/factories/index.ts @@ -0,0 +1,55 @@ +import type { Row, RowData } from '@tanstack/react-table'; +import type { HTMLAttributes } from 'react'; +import type { FeaturesList } from '../types/index.js'; +import type { FeatureRowProps } from './rowProps.js'; +import { rowProps } from './rowProps.js'; + +type RowProps = Partial>; + +/** + * Creates an object of (merged) React props by features. + */ +export function createRowProps(features: FeaturesList, row: Row): RowProps { + const propsList: HTMLAttributes[] = features + .map((feature) => rowProps[feature]?.(row)) + .filter(Boolean); + + if (!propsList.length) { + return {}; + } + + if (propsList.length === 1) { + return propsList[0]; + } + + const mergedProps: HTMLAttributes = {}; + const classNames: string[] = []; + + for (const props of propsList) { + for (const prop of Object.keys(props)) { + const next = props[prop]; + const prev = mergedProps[prop]; + if (typeof prev === 'function' && typeof next === 'function') { + // merge handlers of identical event + mergedProps[prop] = (e) => { + prev(e); + next(e); + }; + } else if (typeof next === 'function') { + // single handler + mergedProps[prop] = next; + //todo: extend this for other props if required + } else if (prop === 'className' && typeof next === 'string') { + // add className to merge later + classNames.push(next); + } else { + mergedProps[prop] = next; + } + } + } + + if (classNames.length) { + mergedProps.className = classNames.join(' '); + } + return mergedProps; +} diff --git a/packages/main/src/components/AnalyticalTableV2/factories/rowProps.ts b/packages/main/src/components/AnalyticalTableV2/factories/rowProps.ts new file mode 100644 index 00000000000..229b1059dfe --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/factories/rowProps.ts @@ -0,0 +1,58 @@ +import type { Row, RowData } from '@tanstack/react-table'; +import { clsx } from 'clsx'; +import type { KeyboardEventHandler, MouseEvent, KeyboardEvent, HTMLAttributes } from 'react'; +import { classNames } from './../AnalyticalTableV2.module.css.js'; + +interface SelectionProps extends Pick, 'className' | 'aria-selected' | 'onClick'> { + /** + * ENTER press + */ + onKeyDown: KeyboardEventHandler; + /** + * SPACE release (default prevented) + */ + onKeyUp: KeyboardEventHandler; +} +export interface FeatureRowProps { + selection: (row: Row) => SelectionProps; +} + +function selectionHandler(e: MouseEvent | KeyboardEvent, row: Row) { + if (e.currentTarget.querySelector('[data-selectable-cell]')) { + //todo: check what is better for our use case + row.getToggleSelectedHandler()(e); + // row.toggleSelected() + } +} + +export const rowProps: FeatureRowProps = { + selection: (row) => { + const isSelected = row.getIsSelected?.() ?? false; + return { + className: clsx(classNames.selectable, isSelected && classNames.selected), + 'aria-selected': `${isSelected}`, + onClick: (e) => { + selectionHandler(e, row); + }, + onKeyDown: (e) => { + if (e.key === 'Enter') { + selectionHandler(e, row); + } + }, + onKeyUp: (e) => { + if (e.code === 'Space') { + selectionHandler(e, row); + e.preventDefault(); + } + } + }; + }, + //todo: remove + //@ts-expect-error: will be removed + test: () => ({ + className: 'testClassName', + onClick: () => console.log('test'), + onKeyDown: () => console.log('test'), + onKeyUp: () => console.log('test') + }) +}; diff --git a/packages/main/src/components/AnalyticalTableV2/features/SelectionFeature.ts b/packages/main/src/components/AnalyticalTableV2/features/SelectionFeature.ts new file mode 100644 index 00000000000..8d3221f1554 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/features/SelectionFeature.ts @@ -0,0 +1,32 @@ +//todo remove +/* eslint-disable */ +//@ts-nocheck + +import type { TableFeature, Row, Updater, RowData } from '@tanstack/react-table'; +import { KeyboardEvent } from 'react'; + +//todo: maybe apply handlers directly and come back to this once this PR made it into the table implementation:https://github.com/TanStack/table/pull/5927 +export const SelectionFeature: TableFeature = { + createRow: (row: Row, table) => { + row.selectionBehavior = table.options.selectionBehavior; + + if (table.options.enableRowSelection) { + row.getRowProps = () => ({ + onClick: (e) => { + if (table.options.enableRowSelection) { + if (row.selectionBehavior !== 'RowSelector') { + console.log(e); + row.toggleSelected!(); + } + } + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'Enter' && table.options.enableRowSelection) { + row.toggleSelected!(); + } + }, + 'data-selection-behavior': row.selectionBehavior + }); + } + } +}; diff --git a/packages/main/src/components/AnalyticalTableV2/features/exampleFeature.ts b/packages/main/src/components/AnalyticalTableV2/features/exampleFeature.ts new file mode 100644 index 00000000000..2d2f065908a --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/features/exampleFeature.ts @@ -0,0 +1,131 @@ +/* eslint-disable */ +// @ts-nocheck +// todo: dev + +import type { OnChangeFn, RowData, Table, TableFeature, Updater } from '@tanstack/react-table'; +import { makeStateUpdater } from '@tanstack/react-table'; +import { functionalUpdate } from '@tanstack/react-table'; +import { useEffect } from 'react'; + +// Use declaration merging to add our new feature APIs and state types to TanStack Table's existing types. +declare module '@tanstack/react-table' { + //merge our new feature's state with the existing table state + interface TableState extends DensityTableState {} + //merge our new feature's options with the existing table options + interface TableOptionsResolved extends DensityOptions {} + //merge our new feature's instance APIs with the existing table instance APIs + interface Table extends DensityInstance {} + // if you need to add cell instance APIs... + // interface Cell extends DensityCell + // if you need to add row instance APIs... + // interface Row extends DensityRow + // if you need to add column instance APIs... + // interface Column extends DensityColumn + // if you need to add header instance APIs... + // interface Header extends DensityHeader + + // Note: declaration merging on `ColumnDef` is not possible because it is a type, not an interface. + // But you can still use declaration merging on `ColumnDef.meta` +} + +export type DensityState = 'sm' | 'md' | 'lg'; +export interface DensityTableState { + density: DensityState; +} +// define types for our new feature's table options +export interface DensityOptions { + enableDensity?: boolean; + onDensityChange?: OnChangeFn; +} + +// Define types for our new feature's table APIs +export interface DensityInstance { + setDensity: (updater: Updater) => void; + toggleDensity: (value?: DensityState) => void; +} + +export const DensityFeature: TableFeature = { + // define the new feature's initial state + getInitialState: (state): DensityTableState => { + return { + density: 'md', + ...state + }; + }, + + // define the new feature's default options + getDefaultOptions: (table: Table): DensityOptions => { + return { + enableDensity: true, + onDensityChange: makeStateUpdater('density', table) + } as DensityOptions; + }, + // if you need to add a default column definition... + // getDefaultColumnDef: (): Partial> => { + // return { meta: {} } //use meta instead of directly adding to the columnDef to avoid typescript stuff that's hard to workaround + // }, + + // define the new feature's table instance methods + createTable: (table: Table): void => { + table.setDensity = (updater) => { + const safeUpdater: Updater = (old) => { + const newState = functionalUpdate(updater, old); + return newState; + }; + return table.options.onDensityChange?.(safeUpdater); + }; + table.toggleDensity = (value) => { + table.setDensity((old) => { + if (value) return value; + return old === 'lg' ? 'md' : old === 'md' ? 'sm' : 'lg'; //cycle through the 3 options + }); + }; + } + + // if you need to add row instance APIs... + // createRow: (row, table): void => {}, + // if you need to add cell instance APIs... + // createCell: (cell, column, row, table): void => {}, + // if you need to add column instance APIs... + // createColumn: (column, table): void => {}, + // if you need to add header instance APIs... + // createHeader: (header, table): void => {}, +}; + +export const ColumnModesFeature: TableFeature = { + // define the new feature's initial state + getInitialState: (state): any => { + return { ...state, tableWidth: 0 }; + }, + + // define the new feature's default options + getDefaultOptions: (table: Table): any => { + return { + columnMode: 'Default' + }; + }, + // if you need to add a default column definition... + // getDefaultColumnDef: (): Partial> => { + // return { meta: {} } //use meta instead of directly adding to the columnDef to avoid typescript stuff that's hard to workaround + // }, + + // define the new feature's table instance methods + createTable: (table: Table): void => { + const state = table.getState(); + // console.log(table); + }, + + // if you need to add row instance APIs... + // createRow: (row, table): void => {}, + // if you need to add cell instance APIs... + // createCell: (cell, column, row, table): void => {}, + // if you need to add column instance APIs... + createColumn: (column, table): void => { + // console.log(column); + // console.log(column); + // column.columnDef.size = 2000; + // console.log(column, table.getState()); + } + // if you need to add header instance APIs... + // createHeader: (header, table): void => {}, +}; diff --git a/packages/main/src/components/AnalyticalTableV2/index.tsx b/packages/main/src/components/AnalyticalTableV2/index.tsx new file mode 100644 index 00000000000..62516429a0c --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/index.tsx @@ -0,0 +1,324 @@ +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { CssSizeVariables, useIsRTL, useStylesheet } from '@ui5/webcomponents-react-base'; +import { clsx } from 'clsx'; +import type { CSSProperties, ReactElement } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { classNames, content } from './AnalyticalTableV2.module.css.js'; +import { Cell } from './core/Cell.js'; +import { Row } from './core/Row.js'; +import { createRowProps } from './factories/index.js'; +import { DensityFeature } from './features/exampleFeature.js'; +import type { FeaturesList } from './types/index.js'; +import { useColumnWidths } from './useColumnMode.js'; +import { handleKeyboardNavigation } from './useKeyboardNavigation.js'; +import { useRowSelection } from './useRowSelection.js'; +import { useRowVirtualizer } from './useRowVirtualizer.js'; +import { useSorting } from './useSorting.js'; +import { useTableContainerResizeObserver } from './utils/useTableContainerResizeObserver.js'; + +interface AnalyticalTableV2Props { + data?: any[]; + columns?: any[]; + rowHeight?: number; + visibleRows?: number; + // todo: fka scaleWidthMode + columnMode?: string; + sortable?: boolean; + + //todo: check if this should be controllable, if so add respective checks otherwise the table-option won't do anything + enableRowPinning?: boolean; + enableColumnPinning?: boolean; + + //todo: enum + selectionMode?: 'None' | 'Single' | 'Multiple'; + selectionBehavior?: 'Row' | 'RowOnly' | 'RowSelector'; +} + +interface CSSPropertiesWithVars extends CSSProperties { + '--_ui5WcrAnalyticalTableControlledRowHeight': string; + '--_ui5WcrAnalyticalTableHeaderGroups': number; + '--_ui5WcrAnalyticalTableTopRows': number; + '--_ui5WcrAnalyticalTableBottomRows': number; +} + +const ROW_HEIGHT_VAR = 'var(--_ui5WcrAnalyticalTableControlledRowHeight)'; + +//todo forwardRef or React19 prop? --> prob forwardRef +function AnalyticalTableV2(props: AnalyticalTableV2Props): ReactElement { + const { + columns, + data, + rowHeight, + visibleRows = 15, + enableRowPinning, + enableColumnPinning, + columnMode, + sortable, + selectionMode = 'None', + selectionBehavior = 'Row' + } = props; + useStylesheet(content, AnalyticalTableV2.displayName); + const tableContainerRef = useRef(null); + const isRTL = useIsRTL(tableContainerRef); + const { tableWidth, horizontalScrollbarHeight, verticalScrollbarWidth } = + useTableContainerResizeObserver(tableContainerRef); + const [columnSizing, setColumnSizing] = useState({}); + + //todo: refactor overload + //@ts-expect-error: fix type later + const [sortingOptions, sortingState] = useSorting(sortable); + const [selectionOptions, selectionState] = useRowSelection(selectionMode, selectionBehavior, columns); + + //const setColumnSizing = useColumnWidths(tableWidth, reactTable.getAllLeafColumns().map((item) => item.columnDef) + const reactTable = useReactTable({ + _features: [DensityFeature /*SelectionFeature*/], + data, + columns, + //todo: check feasibility to use only row models that are implementing features used by the implementation + getCoreRowModel: getCoreRowModel(), + ...sortingOptions, + ...selectionOptions, + //todo: remove + // debugTable: true, + columnResizeDirection: isRTL ? 'rtl' : 'ltr', + //todo: `false` doesn't disable pinning --> use `.getCanPin()` + enableColumnPinning, + enableRowPinning, + state: { + columnSizing, + //todo: add types & clarify how to inject types (declare module '@tanstack/react-table' is probably not the best approach - probably we have to cast a lot...) + //ColumnModesFeature + //todo: type + //@ts-expect-error: fix type later + tableWidth, + + //DensityFeature + density: 'md', + ...sortingState, + ...selectionState + }, + initialState: {}, + // column sizing + defaultColumn: { + // need to overwrite default size for dynamic column widths + size: undefined, + minSize: 60 + }, + //ColumnModesFeature + columnMode, + onColumnSizingChange: setColumnSizing + }); + + //todo: remove test + const activeFeatures: FeaturesList = useMemo( + () => [selectionMode !== 'None' ? 'selection' : undefined, 'test'].filter(Boolean), + [selectionMode] + ); + const renderSelectionCell = selectionMode !== 'None' && selectionBehavior !== 'RowOnly'; + // console.log(enableRowPinning, enableColumnPinning); + + //todo: refactor to use getAllLeafColumns directly, or use the `columns` array? + useColumnWidths( + tableWidth, + reactTable.getAllLeafColumns().map((item) => item.columnDef), + setColumnSizing, + verticalScrollbarWidth + ); + + const headerGroups = reactTable.getHeaderGroups(); + const topRows = reactTable.getTopRows(); + const centerRows = reactTable.getCenterRows(); + const bottomRows = reactTable.getBottomRows(); + const bodyRowCount = reactTable.getRowCount(); + const totalRowCount = bodyRowCount + headerGroups.length; + const visibleLeafColumns = reactTable.getVisibleLeafColumns(); + + const rowVirtualizer = useRowVirtualizer(rowHeight, tableContainerRef, { count: centerRows.length }); + + // const { rows } = reactTable.getRowModel(); + return ( + <> +
+
handleKeyboardNavigation(e, totalRowCount, visibleLeafColumns.length, isRTL)} + > +
+ {headerGroups.map((headerGroup, groupIndex) => { + return ( + + {headerGroup.headers.map((header, index) => { + const isSortable = sortable && header.column.getCanSort(); + //todo outsource to factory? + const isSelectionCell = renderSelectionCell && header.id === '_ui5wcr_selection_col'; + return ( + + ); + })} + + ); + })} +
+ {topRows.length > 0 && ( +
+ {topRows.map((row, index) => { + const featureProps = createRowProps(activeFeatures, row); + return ( + + {row.getVisibleCells().map((cell, index) => { + //todo outsource to factory? + const isSelectionCell = renderSelectionCell && cell.id === '_ui5wcr_selection_col'; + const isSelectableCell = + selectionMode !== 'None' && selectionBehavior === 'RowSelector' ? isSelectionCell : true; + + return ( + + ); + })} + + ); + })} +
+ )} +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = centerRows[virtualRow.index]; + const { className, ...featureProps } = createRowProps(activeFeatures, row); + return ( + + {row.getVisibleCells().map((cell, index) => { + //todo outsource to factory? + const isSelectionCell = renderSelectionCell && cell.id === '_ui5wcr_selection_col'; + const isSelectableCell = + selectionMode !== 'None' && selectionBehavior === 'RowSelector' ? isSelectionCell : true; + + return ( + + ); + })} + + ); + })} +
+ {bottomRows.length > 0 && ( +
+ {bottomRows.map((row, index) => { + const featureProps = createRowProps(activeFeatures, row); + return ( + + {row.getVisibleCells().map((cell, index) => { + //todo outsource to factory? + const isSelectionCell = renderSelectionCell && cell.id === '_ui5wcr_selection_col'; + const isSelectableCell = + selectionMode !== 'None' && selectionBehavior === 'RowSelector' ? isSelectionCell : true; + + return ( + + ); + })} + + ); + })} +
+ )} +
+
+ + ); +} + +AnalyticalTableV2.displayName = 'AnalyticalTableV2'; + +export type { AnalyticalTableV2Props }; +export { AnalyticalTableV2 }; diff --git a/packages/main/src/components/AnalyticalTableV2/types/index.ts b/packages/main/src/components/AnalyticalTableV2/types/index.ts new file mode 100644 index 00000000000..f7fd1e9fb24 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/types/index.ts @@ -0,0 +1 @@ +export type FeaturesList = string[]; diff --git a/packages/main/src/components/AnalyticalTableV2/useColumnMode.ts b/packages/main/src/components/AnalyticalTableV2/useColumnMode.ts new file mode 100644 index 00000000000..e32fcca2656 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/useColumnMode.ts @@ -0,0 +1,168 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { useEffect } from 'react'; + +//todo: share function between AT & ATV2 +function calculateDefaultColumnWidths(tableWidth: number, columns: ColumnDef[], verticalScrollbarWidth: number) { + // Columns w/ external size property + const fixed = []; + // Columns w/o external size property + const dynamic = []; + let fixedTotal = 0; + + // Separate fixed and dynamic columns + for (const col of columns) { + const minSize = col.minSize ?? 0; + const maxSize = col.maxSize ?? Infinity; + + // External `size` defined + if (col.size !== undefined) { + let size = col.size; + if (size < minSize) { + size = minSize; + } + if (size > maxSize) { + size = maxSize; + } + fixedTotal += size; + fixed.push({ col, size }); + } else { + dynamic.push({ col, size: 0 }); + } + } + + // Determine remaining size for dynamic columns + const remaining = tableWidth - fixedTotal - verticalScrollbarWidth; + + // Calc total min-width required by dynamic columns + let totalFlexibleMin = 0; + for (const { col } of dynamic) { + totalFlexibleMin += col.minSize ?? 0; + } + + if (remaining < totalFlexibleMin) { + // Not enough space - assign each dynamic column its `minSize` + for (const dc of dynamic) { + dc.size = dc.col.minSize ?? 0; + } + } else if (dynamic.length) { + // Grant same space for each dynamic column + const initialShare = remaining / dynamic.length; + for (const dc of dynamic) { + const minSize = dc.col.minSize ?? 0; + const maxSize = dc.col.maxSize ?? Infinity; + let size = initialShare; + if (size < minSize) { + size = minSize; + } + if (size > maxSize) { + size = maxSize; + } + dc.size = size; + } + + // Calc assigned size and remaining space + let assigned = 0; + for (const { size } of dynamic) { + assigned += size ?? 0; + } + + /** + * - negative: table overflows + * - positive: table has white-space between last column and borderInlineEnd + */ + let remainingSpace = remaining - assigned; + + // Grow or shrink columns that are still dynamic + + // Grow columns + while (remainingSpace > 0) { + let expandableCount = 0; + for (const { col, size } of dynamic) { + if (size < (col.maxSize ?? Infinity)) { + expandableCount++; + } + } + if (expandableCount === 0) { + break; + } + const extra = remainingSpace / expandableCount; + let used = 0; + for (const dc of dynamic) { + const maxSize = dc.col.maxSize ?? Infinity; + if (dc.size < maxSize) { + const potential = dc.size + extra; + if (potential > maxSize) { + used += maxSize - dc.size; + dc.size = maxSize; + } else { + dc.size = potential; + used += extra; + } + } + } + remainingSpace -= used; + if (used === 0) { + break; + } + } + + // Shrink columns + while (remainingSpace < 0) { + let shrinkableCount = 0; + for (const { col, size } of dynamic) { + const min = col.minSize ?? 0; + if (size > min) { + shrinkableCount++; + } + } + if (shrinkableCount === 0) { + break; + } + const reduction = Math.abs(remainingSpace) / shrinkableCount; + let used = 0; + for (const dc of dynamic) { + const min = dc.col.minSize ?? 0; + if (dc.size > min) { + const potential = dc.size - reduction; + if (potential < min) { + used += dc.size - min; + dc.size = min; + } else { + dc.size = potential; + used += reduction; + } + } + } + remainingSpace += used; + if (used === 0) { + break; + } + } + } + + const result = {}; + for (const { col, size } of [...fixed, ...dynamic]) { + // todo: accessorKey sufficient here? + const key = col.id ?? col.accessorKey; + result[key] = size; + } + return result; +} + +//todo types +//todo implement other modes (pass different calc function - check what can be reused) +//todo debounce? +export function useColumnWidths( + tableWidth: number, + columnDefs: ColumnDef[], + setColumnSizing, + verticalScrollbarWidth +) { + useEffect(() => { + if (!tableWidth) { + return; + } + setColumnSizing(calculateDefaultColumnWidths(tableWidth, columnDefs, verticalScrollbarWidth)); + //todo: check deps + }, [tableWidth, columnDefs.length, verticalScrollbarWidth]); +} diff --git a/packages/main/src/components/AnalyticalTableV2/useKeyboardNavigation.ts b/packages/main/src/components/AnalyticalTableV2/useKeyboardNavigation.ts new file mode 100644 index 00000000000..0f3ba846ff1 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/useKeyboardNavigation.ts @@ -0,0 +1,57 @@ +import type { KeyboardEvent } from 'react'; +// todo: rename file +// todo: nav only works when tabbing, not when clicking cell or interactive content inside of it +// todo: verify what the intended behavior of PageUp & PageDown is + +// using currying for now, as this way code splitting is easier +export const handleKeyboardNavigation = ( + e: KeyboardEvent, + totalRowCount: number, + visibleColumnsCount: number, + isRTL: boolean +) => { + const target = e.target as HTMLElement; + if (target.role !== 'columnheader' && target.role !== 'gridcell') { + return; + } + + const dir = isRTL ? -1 : 1; + // IMPORTANT: Since we're relying on `aria-rowindex` and `aria-colindex` here, the index starts at 1. + const rowIndex = parseInt(target.parentElement.ariaRowIndex); + const colIndex = parseInt(target.ariaColIndex); + let newRowIndex = rowIndex; + let newColIndex = colIndex; + switch (e.key) { + case 'ArrowDown': + newRowIndex = Math.min(rowIndex + 1, totalRowCount); + break; + case 'ArrowUp': + newRowIndex = Math.max(rowIndex - 1, 1); + break; + case 'ArrowRight': + newColIndex = Math.min(colIndex + dir, visibleColumnsCount); + break; + case 'ArrowLeft': + newColIndex = Math.max(colIndex - dir, 1); + break; + case 'Home': + newColIndex = 1; + break; + case 'End': + newColIndex = visibleColumnsCount; + break; + default: + return; + } + e.preventDefault(); + + const focusCell: HTMLDivElement = e.currentTarget.querySelector( + `[aria-rowindex="${newRowIndex}"] > [data-cell][aria-colindex="${newColIndex}"]` + ); + + if (focusCell) { + target.removeAttribute('tabindex'); + focusCell.tabIndex = 0; + focusCell.focus(); + } +}; diff --git a/packages/main/src/components/AnalyticalTableV2/useRowSelection.tsx b/packages/main/src/components/AnalyticalTableV2/useRowSelection.tsx new file mode 100644 index 00000000000..559087a9023 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/useRowSelection.tsx @@ -0,0 +1,79 @@ +import type { CellContext, ColumnDef, HeaderContext } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import { CheckBox } from '../../webComponents/CheckBox/index.js'; +import type { AnalyticalTableV2Props } from './index.js'; + +interface SelectionTableOptions { + enableRowSelection: boolean; + enableMultiRowSelection: boolean; + selectionBehavior: AnalyticalTableV2Props['selectionBehavior']; + columns?: ColumnDef[]; +} + +export interface SelectionState { + columnPinning: { + left: ['_ui5wcr_selection_col']; + }; +} + +const SelectionHeader = ({ table }: HeaderContext) => { + if (!table.options.enableMultiRowSelection) { + return null; + } + const isSomeSelected = table.getIsSomeRowsSelected(); + return ; +}; + +const SelectionCell = ({ row, table }: CellContext) => { + if (!table.options.enableMultiRowSelection) { + return null; + } + const someSelected = row.getIsSomeSelected(); + return ( + + ); +}; + +//todo types +const selectionColumn: ColumnDef = { + id: '_ui5wcr_selection_col', + // todo: centralize defaults + minSize: 44, + size: 44, + maxSize: 44, + // todo: how to handle multiple headers? + header: SelectionHeader, + cell: SelectionCell +}; + +//todo type +export function useRowSelection( + mode: AnalyticalTableV2Props['selectionMode'], + behavior: AnalyticalTableV2Props['selectionBehavior'], + userColumns: AnalyticalTableV2Props['columns'] +): [SelectionTableOptions, SelectionState] { + const selectionTableOptions = useMemo(() => { + // todo: fully control selection state? (onRowSelectionChange) + const options: SelectionTableOptions = { + enableRowSelection: mode !== 'None', + enableMultiRowSelection: mode === 'Multiple', + selectionBehavior: behavior + }; + if (mode !== 'None' && behavior !== 'RowOnly') { + options.columns = [selectionColumn as ColumnDef, ...userColumns]; + } + return options; + }, [mode, userColumns, behavior]); + + const selectionState: SelectionState = { + columnPinning: { + left: ['_ui5wcr_selection_col'] + } + }; + + return [selectionTableOptions, selectionState]; +} diff --git a/packages/main/src/components/AnalyticalTableV2/useRowVirtualizer.ts b/packages/main/src/components/AnalyticalTableV2/useRowVirtualizer.ts new file mode 100644 index 00000000000..f17f271515f --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/useRowVirtualizer.ts @@ -0,0 +1,43 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { RefObject } from 'react'; +import { useRef, useEffect } from 'react'; +import { useComputedCssVariable } from './utils/useComputedCssVariable.js'; + +//todo check if overscan and scroll container are alright. +export function useRowVirtualizer( + rowHeight: number | undefined, + containerRef: RefObject, + virtualizerOptions: { + count: number; + overscan?: number; + // paddingStart?: number; + // paddingEnd?: number; + } +) { + const { count, overscan = 5 } = virtualizerOptions; + const computedRowHeight = useComputedCssVariable(containerRef, '--_ui5WcrAnalyticalTableControlledRowHeight'); + const appliedRowHeight = rowHeight ?? computedRowHeight ?? 32; + + const rowVirtualizer = useVirtualizer({ + count, + getScrollElement: () => containerRef.current, + estimateSize: () => appliedRowHeight, + overscan + // paddingStart: options.paddingStart, + // paddingEnd: options.paddingEnd + }); + + const prevAppliedRowHeight = useRef(null); + useEffect(() => { + if (prevAppliedRowHeight.current !== appliedRowHeight) { + if (prevAppliedRowHeight.current !== null) { + // remeasure if rowHeight changes + rowVirtualizer.measure(); + } else { + prevAppliedRowHeight.current = appliedRowHeight; + } + } + }, [appliedRowHeight, rowVirtualizer]); + + return rowVirtualizer; +} diff --git a/packages/main/src/components/AnalyticalTableV2/useSorting.tsx b/packages/main/src/components/AnalyticalTableV2/useSorting.tsx new file mode 100644 index 00000000000..8d8d3441a57 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/useSorting.tsx @@ -0,0 +1,38 @@ +import type { RowModel, SortingState, Table } from '@tanstack/react-table'; +import { getSortedRowModel } from '@tanstack/react-table'; +import type { Dispatch, SetStateAction } from 'react'; +import { useMemo, useState } from 'react'; + +interface SortingTableOptions { + //todo: type + getSortedRowModel: (table: Table) => () => RowModel; + onSortingChange: Dispatch>; +} + +interface SortingTableState { + sorting: SortingState; +} + +type EmptyObject = Record; + +//todo: overload or conditional return type --> overload - only use conditional return if complex generic is required +export function useSorting(sortable: true): [SortingTableOptions, SortingTableState]; +export function useSorting(sortable: false | undefined): [EmptyObject, EmptyObject]; + +export function useSorting(sortable?: boolean): [SortingTableOptions, SortingTableState] | [EmptyObject, EmptyObject] { + const [sorting, setSorting] = useState([]); + + return useMemo(() => { + if (!sortable) { + return [{}, {}] as [EmptyObject, EmptyObject]; + } + + return [ + { + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting + }, + { sorting } + ] as [SortingTableOptions, SortingTableState]; + }, [sortable, sorting]); +} diff --git a/packages/main/src/components/AnalyticalTableV2/utils/useComputedCssVariable.ts b/packages/main/src/components/AnalyticalTableV2/utils/useComputedCssVariable.ts new file mode 100644 index 00000000000..4c8d83b1799 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/utils/useComputedCssVariable.ts @@ -0,0 +1,19 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +export function useComputedCssVariable(ref: RefObject, variableName: string): number | null { + const [value, setValue] = useState(null); + + useEffect(() => { + if (ref.current) { + const computedStyles = getComputedStyle(ref.current); + const variableValue = computedStyles.getPropertyValue(variableName).trim(); + + // Attempt to parse the value as a number + const parsedValue = parseFloat(variableValue); + setValue(isNaN(parsedValue) ? null : parsedValue); + } + }, [variableName, ref]); + + return value; +} diff --git a/packages/main/src/components/AnalyticalTableV2/utils/useTableContainerResizeObserver.ts b/packages/main/src/components/AnalyticalTableV2/utils/useTableContainerResizeObserver.ts new file mode 100644 index 00000000000..e17f67b4898 --- /dev/null +++ b/packages/main/src/components/AnalyticalTableV2/utils/useTableContainerResizeObserver.ts @@ -0,0 +1,50 @@ +import type { RefObject } from 'react'; +import { useState, useEffect } from 'react'; + +export const useTableContainerResizeObserver = (tableContainerRef: RefObject) => { + const [tableWidth, setTableWidth] = useState(0); + const [horizontalScrollbarHeight, setHorizontalScrollbarHeight] = useState(0); + const [verticalScrollbarWidth, setVerticalScrollbarWidth] = useState(0); + + useEffect(() => { + const tableContainer = tableContainerRef.current; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.borderBoxSize) { + const { borderBoxSize, contentBoxSize } = entry; + const borderBoxHeight = borderBoxSize[0].blockSize; + const contentBoxHeight = contentBoxSize[0].blockSize; + const borderBoxWidth = borderBoxSize[0].inlineSize; + const contentBoxWidth = contentBoxSize[0].inlineSize; + + if (borderBoxHeight > contentBoxHeight) { + setHorizontalScrollbarHeight(borderBoxHeight - contentBoxHeight); + } else { + setHorizontalScrollbarHeight(0); + } + + if (borderBoxWidth > contentBoxWidth) { + setVerticalScrollbarWidth(borderBoxWidth - contentBoxWidth); + } else { + setVerticalScrollbarWidth(0); + } + + setTableWidth(borderBoxWidth); + } + } + }); + + if (tableContainer) { + resizeObserver.observe(tableContainer, { box: 'border-box' }); + } + + return () => { + if (tableContainer) { + resizeObserver.unobserve(tableContainer); + } + }; + }, [tableContainerRef]); + + return { tableWidth, horizontalScrollbarHeight, verticalScrollbarWidth }; +}; diff --git a/yarn.lock b/yarn.lock index cd14dfdcb25..7048c1dda95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4988,6 +4988,18 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:^8.21.3": + version: 8.21.3 + resolution: "@tanstack/react-table@npm:8.21.3" + dependencies: + "@tanstack/table-core": "npm:8.21.3" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/85d1d0fcb690ecc011f68a5a61c96f82142e31a0270dcf9cbc699a6f36715b1653fe6ff1518302a6d08b7093351fc4cabefd055a7db3cd8ac01e068956b0f944 + languageName: node + linkType: hard + "@tanstack/react-virtual@npm:~3.13.0": version: 3.13.6 resolution: "@tanstack/react-virtual@npm:3.13.6" @@ -5000,6 +5012,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/table-core@npm:8.21.3": + version: 8.21.3 + resolution: "@tanstack/table-core@npm:8.21.3" + checksum: 10c0/40e3560e6d55e07cc047024aa7f83bd47a9323d21920d4adabba8071fd2d21230c48460b26cedf392588f8265b9edc133abb1b0d6d0adf4dae0970032900a8c9 + languageName: node + linkType: hard + "@tanstack/virtual-core@npm:3.13.6": version: 3.13.6 resolution: "@tanstack/virtual-core@npm:3.13.6" @@ -6430,6 +6449,7 @@ __metadata: version: 0.0.0-use.local resolution: "@ui5/webcomponents-react@workspace:packages/main" dependencies: + "@tanstack/react-table": "npm:^8.21.3" "@tanstack/react-virtual": "npm:~3.13.0" "@ui5/webcomponents-react-base": "workspace:~" clsx: "npm:2.1.1"