diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx index 886ba09bfd1..ed54d627ee3 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx @@ -99,6 +99,8 @@ const TableComp = () => { ## Tree Table +Set `isTreeTable` to `true` and provide nested data via the `subRows` key (configurable with `subRowsKey`). Each row with `subRows` becomes expandable. + The `data` structure of the tree table is as follows: @@ -136,7 +138,7 @@ In this example the default key for sub row detection is used (`subRows`), you c The table initially contains 50 rows, when the last 10 rows are reached the table will load more data. -**Note:** To prevent the table state from resetting when the data is updated, please see [this recipe](?path=/docs/data-display-analyticaltable-recipes--docs#how-to-stop-the-table-state-from-automatically-resetting-when-the-data-changes). +**Note:** To prevent the table state from resetting when the data is updated, please see [this faq entry](?path=/docs/data-display-analyticaltable-faq--docs#how-to-stop-the-table-state-from-automatically-resetting-when-the-data-changes). @@ -172,91 +174,7 @@ const InfiniteScrollTable = (props) => { header="Scroll to load more data" onLoadMore={onLoadMore} loading={loading} - reactTableOptions: {{ autoResetSelectedRows: false }} - /> - ); -}; -``` - - - -## AnalyticalTable with subcomponents - -Adding custom subcomponents below table rows can be achieved by setting the `renderRowSubComponent` prop. -The prop expects a function with an optional parameter containing the `row` instance, there you can control which row should display subcomponents. If you want to display the subcomponent at the bottom of the row without an expandable container, you can set `subComponentsBehavior` prop to `"Visible"` or to `"IncludeHeight"`. "Visible" simply adds the subcomponent to the row without including its height in the initial calculation of the table body, whereas "IncludeHeight" does. - -### Notes - -- When `renderRowSubComponent` is set, `grouping` is disabled. -- When rendering active elements inside the subcomponent, make sure to add the `data-subcomponent-active-element' attribute, otherwise focus behavior won't be consistent. -- When `AnalyticalTableSubComponentsBehavior.IncludeHeight` or `AnalyticalTableSubComponentsBehavior.IncludeHeightExpandable` is used, `AnalyticalTableVisibleRowCountMode.Interactive` is not supported. - - - - - -### Code - - - -Show Code - -```jsx -const TableWithSubcomponents = (props) => { - const renderRowSubComponent = (row) => { - if (row.id === '0') { - return ( - - height: 300px - This subcomponent will only be displayed below the first row. - - - The button below is rendered with `data-subcomponent-active-element` attribute to ensure consistent focus - behavior - - Click - - ); - } - if (row.id === '1') { - return ( - - height: 100px - This subcomponent will only be displayed below the second row. - - ); - } - if (row.id === '2') { - return null; - } - return ( - - height: 50px - This subcomponent will be displayed below all rows except the first, second and third. - - ); - }; - return ( - ); }; @@ -366,9 +284,9 @@ const COLUMNS = [ Header: 'PopinDisplay Modes', responsivePopIn: true, responsiveMinWidth: 801, - popinDisplay: popinDisplay, // possible values: "Block", "Inline", "WithoutHeader" + popinDisplay: AnalyticalTablePopinDisplay.Block, // possible values: "Block", "Inline", "WithoutHeader" Cell: () => { - return Using popinDisplay: {popinDisplay}; + return Using popinDisplay: Block; } } ]; @@ -438,7 +356,7 @@ export const TableWithNavigationIndicators = () => { data={data} columns={columns} withNavigationHighlight - selectionMode={selectionMode} + selectionMode={AnalyticalTableSelectionMode.Multiple} markNavigatedRow={markNavigatedRow} onRowSelect={onRowSelect} /> @@ -523,6 +441,8 @@ const TableComponent = () => { ## Table Without Data +Use the `NoDataComponent` prop to customize the empty state. By default, a simple text message is shown. You can pass a custom component (e.g. an `IllustratedMessage`) to display a richer empty state for different scenarios like "no data" vs. "no filter results". The component receives an `accessibleRole` prop that should be forwarded for accessibility. + ### Code @@ -586,8 +506,94 @@ function NoDataTable(props) { +## AnalyticalTable with subcomponents + +Adding custom subcomponents below table rows can be achieved by setting the `renderRowSubComponent` prop. +The prop expects a function with an optional parameter containing the `row` instance, there you can control which row should display subcomponents. Use the `subComponentsBehavior` control below to switch between `"Expandable"` (default, click to expand), `"Visible"` (always shown), `"IncludeHeight"` (always shown, height included in body calculation), and `"IncludeHeightExpandable"` (expandable, body height adjusts on toggle). + +**Notes:** + +- When `renderRowSubComponent` is set, `grouping` is disabled. +- When rendering active elements inside the subcomponent, make sure to add the `data-subcomponent-active-element` attribute, otherwise focus behavior won't be consistent. +- When `AnalyticalTableSubComponentsBehavior.IncludeHeight` or `AnalyticalTableSubComponentsBehavior.IncludeHeightExpandable` is used, `AnalyticalTableVisibleRowCountMode.Interactive` is not supported. + + + + + +### Code + + + +Show Code + +```jsx +const TableWithSubcomponents = (props) => { + const renderRowSubComponent = (row) => { + if (row.id === '0') { + return ( + + height: 300px + This subcomponent will only be displayed below the first row. + + + The button below is rendered with `data-subcomponent-active-element` attribute to ensure consistent focus + behavior + + Click + + ); + } + if (row.id === '1') { + return ( + + height: 100px + This subcomponent will only be displayed below the second row. + + ); + } + if (row.id === '2') { + return null; + } + return ( + + height: 50px + This subcomponent will be displayed below all rows except the first, second and third. + + ); + }; + return ( + + ); +}; +``` + + + ## Kitchen Sink +A comprehensive example combining many AnalyticalTable features: sorting, filtering, grouping, custom cells, row and navigation highlighting, infinite scrolling, column reordering, vertical alignment, `scaleWidthModeOptions` for custom renderers, `retainColumnWidth`, `sortDescFirst`, and more. + ### Code @@ -634,12 +640,14 @@ const columns = [ disableFilters: false, disableGroupBy: true, disableSortBy: false, - hAlign: 'End' + hAlign: 'End', + sortDescFirst: true }, { Header: 'Friend Name', accessor: 'friend.name', - autoResizable: true + autoResizable: true, + vAlign: VerticalAlign.Middle }, { Filter: () => {}, @@ -648,7 +656,14 @@ const columns = [ autoResizable: true, filter: () => {}, hAlign: 'End', - headerLabel: 'Friend Age' + headerLabel: 'Friend Age', + scaleWidthModeOptions: { headerString: 'Friend Age' } + }, + { + Header: 'Status', + id: 'os', + Cell: () => {}, + scaleWidthModeOptions: { cellString: 'Negative' } }, { Cell: () => {}, @@ -656,9 +671,11 @@ const columns = [ accessor: '.', cellLabel: () => {}, disableFilters: true, + disableGlobalFilter: true, disableGroupBy: true, disableResizing: true, disableSortBy: true, + disableDragAndDrop: true, id: 'actions', minWidth: 100, width: 100 @@ -688,6 +705,7 @@ const TestComp2 = () => { minRows={5} noDataText="Custom 'noDataText' message" overscanCountHorizontal={5} + retainColumnWidth scaleWidthMode="Smart" selectedRowIds={{ 3: true @@ -698,6 +716,7 @@ const TestComp2 = () => { subRowsKey="subRows" visibleRowCountMode="Interactive" visibleRows={5} + withNavigationHighlight withRowHighlight onAutoResize={() => {}} onColumnsReorder={() => {}} diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx index 94ad5488146..629c6f50f5d 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx @@ -3,7 +3,6 @@ import dataTree from '@sb/mockData/FriendsTree.json'; import type { Meta, StoryObj } from '@storybook/react-vite'; import '@ui5/webcomponents-icons/dist/delete.js'; import '@ui5/webcomponents-icons/dist/edit.js'; -import '@ui5/webcomponents-icons/dist/settings.js'; import NoDataIllustration from '@ui5/webcomponents-fiori/dist/illustrations/NoData.js'; import NoFilterResults from '@ui5/webcomponents-fiori/dist/illustrations/NoFilterResults.js'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; @@ -12,11 +11,13 @@ import { AnalyticalTableScaleWidthMode, AnalyticalTableSelectionBehavior, AnalyticalTableSelectionMode, + AnalyticalTableSubComponentsBehavior, AnalyticalTableVisibleRowCountMode, FlexBoxAlignItems, FlexBoxDirection, FlexBoxJustifyContent, TextAlign, + VerticalAlign, } from '../../../enums/index.js'; import { Button } from '../../../webComponents/Button/index.js'; import { IllustratedMessage } from '../../../webComponents/IllustratedMessage/index.js'; @@ -57,12 +58,14 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { disableSortBy: false, disableFilters: false, className: 'superCustomClass', + sortDescFirst: true, }, { Header: 'Friend Name', accessor: 'friend.name', width: 300, autoResizable: true, + vAlign: VerticalAlign.Middle, }, { Header: () => Friend Age, @@ -70,6 +73,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { accessor: 'friend.age', autoResizable: true, hAlign: TextAlign.End, + scaleWidthModeOptions: { headerString: 'Friend Age' }, filter: (rows, accessor, filterValue) => { if (filterValue === 'all') { return rows; @@ -102,6 +106,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { const state = instance.row.index % 2 === 0 ? 'Positive' : 'Negative'; return {state}; }, + scaleWidthModeOptions: { cellString: 'Negative' }, }, { id: 'actions', @@ -113,6 +118,8 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { disableGroupBy: true, disableFilters: true, disableSortBy: true, + disableGlobalFilter: true, + disableDragAndDrop: true, Cell: (instance) => { const { _cell, _row, webComponentsReactProperties } = instance; const { loading, showOverlay } = webComponentsReactProperties; @@ -149,6 +156,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { minRows: 5, noDataText: "Custom 'noDataText' message", overscanCountHorizontal: 5, + retainColumnWidth: true, scaleWidthMode: AnalyticalTableScaleWidthMode.Smart, selectedRowIds: { 3: true }, selectionBehavior: AnalyticalTableSelectionBehavior.Row, @@ -157,6 +165,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { subRowsKey: 'subRows', visibleRowCountMode: AnalyticalTableVisibleRowCountMode.Interactive, visibleRows: 5, + withNavigationHighlight: true, withRowHighlight: true, // sb actions has a huge impact on performance here. onTableScroll: undefined, @@ -275,64 +284,6 @@ export const InfiniteScrolling: Story = { }, }; -export const Subcomponents: Story = { - render: (args, context) => { - const renderRowSubComponent = (row) => { - if (row.id === '0') { - return ( - - height: 300px - This subcomponent will only be displayed below the first row. - - - The button below is rendered with `data-subcomponent-active-element` attribute to ensure consistent focus - behavior - - Click - - ); - } - if (row.id === '1') { - return ( - - height: 100px - This subcomponent will only be displayed below the second row. - - ); - } - if (row.id === '2') { - return null; - } - return ( - - height: 50px - This subcomponent will be displayed below all rows except the first, second and third. - - ); - }; - return context.viewMode === 'story' ? ( - - ) : ( - - ); - }, -}; - export const DynamicRowCount = { args: { visibleRowCountMode: AnalyticalTableVisibleRowCountMode.Auto, containerHeight: 250 } as unknown, argTypes: { @@ -664,6 +615,72 @@ export const NoData: Story = { }, }; +export const Subcomponents: Story = { + args: { + subComponentsBehavior: AnalyticalTableSubComponentsBehavior.Expandable, + }, + render: (args, context) => { + const renderRowSubComponent = useCallback((row) => { + if (row.id === '0') { + return ( + + height: 300px + This subcomponent will only be displayed below the first row. + + + The button below is rendered with `data-subcomponent-active-element` attribute to ensure consistent focus + behavior + + Click + + ); + } + if (row.id === '1') { + return ( + + height: 100px + This subcomponent will only be displayed below the second row. + + ); + } + if (row.id === '2') { + return null; + } + return ( + + height: 50px + This subcomponent will be displayed below all rows except the first, second and third. + + ); + }, []); + + return context.viewMode === 'story' ? ( + + ) : ( + + ); + }, +}; + export const KitchenSink: Story = { args: kitchenSinkArgs, render(args, context) { @@ -671,7 +688,7 @@ export const KitchenSink: Story = { }, }; -// ===================== Not displayed in sidebar & tags popover ===================== +// ===================== Not displayed in sidebar ===================== export const EllipsisExamples: Story = { tags: ['excludeFromSidebar'], @@ -709,3 +726,192 @@ export const EllipsisExamples: Story = { style: { width: 'min(100%, 300px)' }, }, }; + +// Bug #3: Keyboard navigation with End/Home fails on virtualized-out columns. +// Steps: 1) Click any cell in the first row. 2) Press End — focus should jump to the last column. +export const BugKeyboardNavVirtualizedColumns: Story = { + tags: ['excludeFromSidebar'], + render(args) { + const manyColumns = useMemo( + () => + Array.from({ length: 30 }, (_, i) => ({ + Header: `Col ${i}`, + id: `col_${i}`, + accessor: 'name', + width: 120, + Cell: ({ value }) => `${value} (${i})`, + })), + [], + ); + const data = useMemo(() => dataLarge.slice(0, 50), []); + return ( + + ); + }, +}; + +// Bug #13: Shift+Arrow keyboard resize produces NaN column width. +// Steps: 1) Focus any column header. 2) Press Shift+ArrowRight — column should grow by 16px. +export const BugKeyboardResizeNaN: Story = { + tags: ['excludeFromSidebar'], + args: { + data: dataLarge.slice(0, 10), + columns: [ + { Header: 'Name', accessor: 'name', width: 200 }, + { Header: 'Age', accessor: 'age', width: 150 }, + { Header: 'Friend Name', accessor: 'friend.name', width: 200 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 150 }, + ], + visibleRows: 5, + header: 'Bug #13: Focus a column header, press Shift+ArrowRight to resize', + }, +}; + +// Performance: selectedFlatRows useMemo regression test. +// +// 10,000 rows with selection enabled. onTableScroll updates parent state on every +// scroll frame, forcing re-renders where rows/selectedRowIds DON'T change. +// Without useMemo: each re-render iterates all 10k rows. With useMemo: O(1) cache hit. +// +// How to measure: +// 1. Open Chrome DevTools → Performance tab. +// 2. Click Record, scroll the table for ~3 seconds, stop recording. +// 3. Look at individual "render" flames in the "Main" thread. +// Without useMemo: useInstance calls show ~2-5ms of getRowIsSelected iteration. +// With useMemo: useInstance calls are <0.1ms (memoized). +// 4. Alternatively: DevTools → Performance → check "CPU 6x slowdown" to exaggerate. +// +// To toggle the fix: in useRowSelect.ts, remove/restore useMemo around selectedFlatRows. +export const PerfSelectedFlatRowsMemo: Story = { + tags: ['excludeFromSidebar'], + render(args) { + const ROW_COUNT = 10_000; + + const columns = useMemo( + () => [ + { Header: 'Row', accessor: 'id' }, + { Header: 'Name', accessor: 'name' }, + { Header: 'Value A', accessor: 'a' }, + { Header: 'Value B', accessor: 'b' }, + { Header: 'Value C', accessor: 'c' }, + ], + [], + ); + + const data = useMemo( + () => + Array.from({ length: ROW_COUNT }, (_, i) => ({ + id: i, + name: `Row ${i}`, + a: Math.round(Math.random() * 1000), + b: Math.round(Math.random() * 1000), + c: Math.round(Math.random() * 1000), + })), + [], + ); + + // Updating state on every scroll forces a parent re-render → AnalyticalTable + // re-render → useInstance runs. rows and selectedRowIds haven't changed, + // so this isolates the useMemo vs. no-useMemo cost difference. + const [scrollEvents, setScrollEvents] = useState(0); + const onTableScroll = useCallback(() => { + setScrollEvents((prev) => prev + 1); + }, []); + + return ( + + ); + }, +}; + +// Bug #14: tableColResized gate permanently disables dynamic width recalculation. +// Steps: 1) Drag any column resizer to resize a column. 2) Click "Switch to Column Set B". +// Expected: New columns distribute evenly across the table width. +// Actual: All columns are stuck at 150px (decorateColumn default) because tableColResized blocks adjustColumnWidths. +export const BugRetainColumnWidthBlocksRecalculation: Story = { + tags: ['excludeFromSidebar'], + render(args) { + const columnsA = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age' }, + { Header: 'Status', accessor: 'status' }, + ], + [], + ); + + const columnsB = useMemo( + () => [ + { Header: 'Product', accessor: 'product' }, + { Header: 'Price', accessor: 'price' }, + { Header: 'Quantity', accessor: 'qty' }, + { Header: 'Category', accessor: 'category' }, + ], + [], + ); + + const dataA = useMemo( + () => + Array.from({ length: 20 }, (_, i) => ({ + name: `Person ${i}`, + age: 20 + (i % 40), + status: i % 2 === 0 ? 'Active' : 'Inactive', + })), + [], + ); + + const dataB = useMemo( + () => + Array.from({ length: 20 }, (_, i) => ({ + product: `Product ${i}`, + price: `$${(Math.random() * 100).toFixed(2)}`, + qty: Math.floor(Math.random() * 500), + category: ['Electronics', 'Clothing', 'Food'][i % 3], + })), + [], + ); + + const [useSetB, setUseSetB] = useState(false); + + return ( + + + Bug: With retainColumnWidth, resize any column, then switch column set. New columns get 150px instead of + recalculating. + + { + setUseSetB((prev) => !prev); + }} + > + {useSetB ? 'Switch to Column Set A (3 cols)' : 'Switch to Column Set B (4 cols)'} + + + + ); + }, +}; diff --git a/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/AnalyticalTableFeatureExamples.stories.tsx b/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/AnalyticalTableFeatureExamples.stories.tsx new file mode 100644 index 00000000000..bc47bbab462 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/AnalyticalTableFeatureExamples.stories.tsx @@ -0,0 +1,1149 @@ +import dataSmall from '@sb/mockData/Friends50.json'; +import dataLarge from '@sb/mockData/Friends500.json'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import '@ui5/webcomponents-icons/dist/sort-ascending.js'; +import '@ui5/webcomponents-icons/dist/sort-descending.js'; +import '@ui5/webcomponents-icons/dist/reset.js'; +import { memo, useCallback, useMemo, useReducer, useRef, useState } from 'react'; +import { + AnalyticalTableScaleWidthMode, + AnalyticalTableSelectionBehavior, + AnalyticalTableSelectionMode, + FlexBoxAlignItems, + FlexBoxDirection, + FlexBoxWrap, + IndicationColor, + TextAlign, +} from '../../../../enums/index.js'; +import { Button } from '../../../../webComponents/Button/index.js'; +import { Dialog } from '../../../../webComponents/Dialog/index.js'; +import { Input } from '../../../../webComponents/Input/index.js'; +import { Option } from '../../../../webComponents/Option/index.js'; +import { Popover } from '../../../../webComponents/Popover/index.js'; +import { Select } from '../../../../webComponents/Select/index.js'; +import { Tag } from '../../../../webComponents/Tag/index.js'; +import { Text } from '../../../../webComponents/Text/index.js'; +import { ToggleButton } from '../../../../webComponents/ToggleButton/index.js'; +import { FlexBox } from '../../../FlexBox/index.js'; +import { ObjectStatus } from '../../../ObjectStatus/index.js'; +import type { AnalyticalTableColumnDefinition } from '../../index.js'; +import { AnalyticalTable } from '../../index.js'; +import meta from '../AnalyticalTable.stories.js'; + +const recipesMeta = { + ...meta, + title: 'Data Display / AnalyticalTable / Feature Examples', + tags: ['package:@ui5/webcomponents-react'], +} satisfies Meta; +export default recipesMeta; +type Story = StoryObj; + +export const ScaleWidthModeComparison: Story = { + render(args) { + const statuses = [ + 'Available', + 'Out of Office', + 'Do Not Disturb', + 'Away', + 'Temporarily Unavailable — On Extended Leave Until Further Notice', + ]; + const data = useMemo( + () => + Array.from({ length: 10 }, (_, i) => ({ + id: i, + name: [ + 'Al', + 'Bob', + 'Jonathan Wetherby Longnamington the Third of Blackcastle', + 'Di', + 'Eve Martinez de la Cruz', + ][i % 5], + age: 20 + i * 3, + score: Math.round(50 + Math.sin(i) * 40), + status: statuses[i % 5], + tiny: 'X', + })), + [], + ); + const columns = useMemo( + () => [ + { Header: 'ID', accessor: 'id', hAlign: TextAlign.End }, + { Header: 'Name', accessor: 'name' }, + { + Header: 'Status', + accessor: 'status', + Cell: ({ value }) => {value}, + // Smart/Grow can't measure custom cells — provide the longest expected text + scaleWidthModeOptions: { cellString: 'Temporarily Unavailable — On Extended Leave Until Further Notice' }, + }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { + accessor: 'score', + hAlign: TextAlign.End, + Header: ({ webComponentsReactProperties }) => ( + + Average Quarterly Employee Satisfaction Score + + ), + scaleWidthModeOptions: { headerString: 'Average Quarterly Employee Satisfaction Score' }, + }, + { Header: 'T', accessor: 'tiny' }, + ], + [], + ); + + return ( + + {(['Default', 'Smart', 'Grow'] as const).map((mode) => ( + + ))} + + ); + }, +}; + +export const Grouping: Story = { + args: { + data: dataLarge, + groupable: true, + visibleRows: 8, + }, + render(args) { + const [groupBy, setGroupBy] = useState([]); + const [lastGroupEvent, setLastGroupEvent] = useState(''); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', disableGroupBy: true }, + { + Header: 'Age', + accessor: 'age', + hAlign: TextAlign.End, + aggregate: 'average', + Aggregated: (instance) => `Avg: ${Math.round(instance.value)}`, + }, + { + Header: 'Status', + accessor: 'status', + aggregate: (leafValues) => { + const severity = ['Negative', 'Critical', 'Positive', 'Information', 'None']; + return leafValues.reduce( + (highest, val) => (severity.indexOf(val) < severity.indexOf(highest) ? val : highest), + 'None', + ); + }, + Aggregated: (instance) => `Max: ${instance.value}`, + }, + { Header: 'Friend Name', accessor: 'friend.name', disableGroupBy: true }, + ], + [], + ); + + const onGroup = (e) => { + const { column, groupedColumns, isGrouped } = e.detail; + setLastGroupEvent( + `Column "${column.Header}" ${isGrouped ? 'grouped' : 'ungrouped'}. Active groups: [${groupedColumns.join(', ')}]`, + ); + }; + + return ( + <> + + setGroupBy(['status'])}>Group by Status + setGroupBy(['age'])}>Group by Age + setGroupBy(['status', 'age'])}>Group by Status + Age + setGroupBy([])}>Clear Groups + + + + {lastGroupEvent || 'Group columns via the column header menu or the buttons above.'} + + > + ); + }, +}; + +export const ServerSideOperations: Story = { + render(args) { + type Row = { name: string; age: number; department: string }; + + const allData = useMemo( + () => + Array.from({ length: 50 }, (_, i) => ({ + name: + ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack'][i % 10] + ` #${i}`, + age: 20 + (i % 50), + department: ['Engineering', 'Sales', 'Marketing', 'Support', 'HR'][i % 5], + })), + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', disableGroupBy: true }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End, disableGroupBy: true }, + { Header: 'Department', accessor: 'department' }, + ], + [], + ); + + const [data, setData] = useState[]>(allData); + const [loading, setLoading] = useState(false); + + // opsRef + applyOps simulate a backend that receives all active parameters in one request. + const opsRef = useRef<{ + sort?: { columnId: string; direction: string }; + filter?: { columnId: string; value: string }; + groupedColumns: string[]; + }>({ groupedColumns: [] }); + + const applyOps = useCallback( + (ops: typeof opsRef.current) => { + setLoading(true); + setTimeout(() => { + let rows: Row[] = allData; + + if (ops.filter?.value) { + const { columnId, value } = ops.filter; + rows = rows.filter((row) => String(row[columnId]).toLowerCase().includes(value.toLowerCase())); + } + + if (ops.sort && ops.sort.direction !== 'none') { + const { columnId, direction } = ops.sort; + rows = [...rows].sort((a, b) => { + const aVal = a[columnId]; + const bVal = b[columnId]; + if (typeof aVal === 'number') return direction === 'asc' ? aVal - bVal : bVal - aVal; + return direction === 'asc' + ? String(aVal).localeCompare(String(bVal)) + : String(bVal).localeCompare(String(aVal)); + }); + } + + if (ops.groupedColumns.length > 0) { + const groupCol = ops.groupedColumns[0]; + const groups = new Map(); + for (const row of rows) { + const key = String(row[groupCol]); + const existing = groups.get(key); + if (existing) { + existing.push(row); + } else { + groups.set(key, [row]); + } + } + setData( + Array.from(groups.entries()).map(([key, children]) => ({ + [groupCol]: key, + subRows: children, + })), + ); + } else { + setData(rows); + } + + setLoading(false); + }, 500); + }, + [allData], + ); + + const onSort = useCallback( + (e) => { + const { column, sortDirection } = e.detail; + opsRef.current.sort = { columnId: column.id, direction: sortDirection }; + applyOps(opsRef.current); + }, + [applyOps], + ); + + const onFilter = useCallback( + (e) => { + const { columnId, value } = e; + opsRef.current.filter = value ? { columnId: String(columnId), value } : undefined; + applyOps(opsRef.current); + }, + [applyOps], + ); + + const onGroup = useCallback( + (e) => { + const { groupedColumns } = e.detail; + opsRef.current.groupedColumns = groupedColumns; + applyOps(opsRef.current); + }, + [applyOps], + ); + + const reactTableOptions = useMemo( + () => ({ + manualSortBy: true, + manualFilters: true, + manualGroupBy: true, + autoResetGroupBy: false, + autoResetExpanded: false, + }), + [], + ); + + return ( + + ); + }, +}; + +export const LoadingStates: Story = { + render(args) { + const [hasData, setHasData] = useState(true); + const [loading, setLoading] = useState(false); + const [showOverlay, setShowOverlay] = useState(false); + const [alwaysShowBusyIndicator, setAlwaysShowBusyIndicator] = useState(false); + + const data = useMemo(() => (hasData ? dataSmall : []), [hasData]); + + return ( + <> + + setLoading((prev) => !prev)}> + loading + + setShowOverlay((prev) => !prev)}> + showOverlay + + setAlwaysShowBusyIndicator((prev) => !prev)}> + alwaysShowBusyIndicator + + setHasData((prev) => !prev)}> + Empty data + + + + > + ); + }, +}; + +export const ControlledSelection: Story = { + render(args) { + const [selectedRowIds, setSelectedRowIds] = useState>({}); + const data = useMemo(() => dataSmall.slice(0, 20), []); + + const onRowSelect = useCallback((e) => { + setSelectedRowIds({ ...e.detail.selectedRowIds }); + }, []); + + const selectedCount = Object.keys(selectedRowIds).length; + + return ( + <> + + { + const all: Record = {}; + data.forEach((_, i) => { + all[i] = true; + }); + setSelectedRowIds(all); + }} + > + Select All ({data.length}) + + setSelectedRowIds({})}>Clear All + { + const first5: Record = {}; + for (let i = 0; i < 5; i++) first5[i] = true; + setSelectedRowIds(first5); + }} + > + Select First 5 + + + + + Selected IDs: {selectedCount > 0 ? `[${Object.keys(selectedRowIds).join(', ')}]` : 'none'} + + > + ); + }, +}; + +export const RowHighlighting: Story = { + render() { + const [useFunction, setUseFunction] = useState(false); + + const data = useMemo( + () => [ + { name: 'Alice', age: 28, status: 'Positive' }, + { name: 'Bob', age: 35, status: 'Negative' }, + { name: 'Charlie', age: 42, status: 'Critical' }, + { name: 'Diana', age: 31, status: 'Information' }, + { name: 'Eve', age: 26, status: 'None' }, + { name: 'Frank', age: 39, status: IndicationColor.Indication01 }, + { name: 'Grace', age: 45, status: IndicationColor.Indication04 }, + { name: 'Hank', age: 33, status: IndicationColor.Indication05 }, + { name: 'Iris', age: 29, status: IndicationColor.Indication07 }, + { name: 'Jack', age: 50, status: IndicationColor.Indication08 }, + ], + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Status Value', accessor: 'status' }, + ], + [], + ); + + const highlightFn = useCallback((original) => { + return original.age > 40 ? IndicationColor.Indication03 : IndicationColor.Indication07; + }, []); + + return ( + <> + setUseFunction((prev) => !prev)} + style={{ marginBlockEnd: '0.5rem' }} + > + {useFunction + ? 'highlightField: function (age > 40 = Indication03)' + : 'highlightField: "status" (string accessor)'} + + 40 = Indication03)' + : 'String-based highlighting (status field)' + } + /> + > + ); + }, +}; + +export const ColumnDragAndDrop: Story = { + render(args) { + const defaultOrder = useMemo(() => ['name', 'age', 'friend.name', 'friend.age'], []); + const [columnOrder, setColumnOrder] = useState(defaultOrder); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age (fixed)', accessor: 'friend.age', hAlign: TextAlign.End, disableDragAndDrop: true }, + ], + [], + ); + + const data = useMemo(() => dataSmall.slice(0, 20), []); + + const onColumnsReorder = useCallback((e) => { + const newOrder = e.detail.columnsNewOrder.map((col) => col.id); + setColumnOrder(newOrder); + }, []); + + return ( + <> + + setColumnOrder(defaultOrder)}>Reset Order + Current order: [{columnOrder.join(', ')}] + + + > + ); + }, +}; + +export const GlobalFilter: Story = { + render(args) { + const [filterValue, setFilterValue] = useState(''); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End, disableGlobalFilter: true }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age', hAlign: TextAlign.End }, + ], + [], + ); + + return ( + <> + + setFilterValue(e.target.value)} + showClearIcon + style={{ width: '300px' }} + /> + Age column has disableGlobalFilter: true + + + > + ); + }, +}; + +export const ProgrammaticTableControl: Story = { + render(args) { + const tableInstanceRef = useRef(null); + const [stateInfo, setStateInfo] = useState(''); + const [useAltData, setUseAltData] = useState(false); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age', hAlign: TextAlign.End }, + ], + [], + ); + + const dataA = useMemo(() => dataSmall.slice(0, 30), []); + const dataB = useMemo(() => dataSmall.slice(10, 40), []); + const data = useAltData ? dataB : dataA; + + const updateInfo = useCallback(() => { + const instance = tableInstanceRef.current; + if (!instance) return; + const sorts = instance.state.sortBy.map((s) => `${s.id} (${s.desc ? 'desc' : 'asc'})`).join(', ') || 'none'; + const filters = instance.state.filters.map((f) => `${f.id}="${f.value}"`).join(', ') || 'none'; + const hidden = instance.state.hiddenColumns?.join(', ') || 'none'; + setStateInfo(`Sorts: [${sorts}] | Filters: [${filters}] | Hidden: [${hidden}]`); + }, []); + + const reactTableOptions = useMemo( + () => ({ + autoResetSortBy: false, + autoResetFilters: false, + autoResetHiddenColumns: false, + autoResetExpanded: false, + autoResetGroupBy: false, + }), + [], + ); + + return ( + <> + + { + tableInstanceRef.current?.setSortBy([{ id: 'name', desc: false }]); + setTimeout(updateInfo, 50); + }} + > + Sort Name (asc) + + { + tableInstanceRef.current?.setSortBy([]); + setTimeout(updateInfo, 50); + }} + > + Clear Sort + + { + tableInstanceRef.current?.setFilter('age', '3'); + setTimeout(updateInfo, 50); + }} + > + Filter Age contains "3" + + { + tableInstanceRef.current?.setAllFilters([]); + setTimeout(updateInfo, 50); + }} + > + Clear Filters + + { + tableInstanceRef.current?.toggleHideColumn('friend.name'); + setTimeout(updateInfo, 50); + }} + > + Toggle "Friend Name" visibility + + { + tableInstanceRef.current?.setHiddenColumns([]); + setTimeout(updateInfo, 50); + }} + > + Show All Columns + + { + tableInstanceRef.current?.resetResizing(); + }} + > + Reset Column Widths + + setUseAltData((prev) => !prev)}> + Toggle Data Set ({useAltData ? 'B' : 'A'}) + + + + + {stateInfo || 'Click a button to see table state.'} + + > + ); + }, +}; + +export const InitialTableState: Story = { + render() { + const [key, resetKey] = useReducer((k) => k + 1, 0); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age', hAlign: TextAlign.End }, + ], + [], + ); + + const data = useMemo(() => dataSmall.slice(0, 30), []); + + const reactTableOptions = useMemo( + () => ({ + autoResetSortBy: false, + autoResetFilters: false, + autoResetHiddenColumns: false, + initialState: { + sortBy: [{ id: 'age', desc: true }], + filters: [{ id: 'name', value: 'a' }], + hiddenColumns: ['friend.age'], + selectedRowIds: { 0: true, 2: true }, + }, + }), + [], + ); + + return ( + <> + + Reset to Initial State (remount) + + + > + ); + }, +}; + +// Consider memo() for complex or expensive cells — not needed for simple ones +const FriendComparisonCell = memo(({ row }) => { + const { age, friend, status } = row.original; + const ageDiff = friend.age - age; + return ( + + {friend.name} + = 0 ? '5' : '3'}> + {ageDiff >= 0 ? '+' : ''} + {ageDiff} yrs + + + ); +}); +FriendComparisonCell.displayName = 'FriendComparisonCell'; + +export const CustomCellRenderers: Story = { + render(args) { + const [dialogRow, setDialogRow] = useState | null>(null); + + const data = useMemo( + () => + dataSmall.slice(0, 20).map((row, i) => ({ + ...row, + status: (['Positive', 'Negative', 'Critical', 'Information', 'None'] as const)[i % 5], + description: `This is a longer description for row ${i} that should be truncated with an ellipsis when the column is too narrow.`, + })), + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { + Header: 'Age', + accessor: 'age', + hAlign: TextAlign.End, + // Lightweight cells don't need memo() + Cell: ({ value }) => 40 ? 'Critical' : 'Positive'}>{value}, + }, + { + Header: 'Friend Comparison', + id: 'friendComparison', + Cell: FriendComparisonCell, + }, + { + Header: 'Description', + accessor: 'description', + // Use the textEllipsis class when custom elements should truncate + Cell: ({ value, webComponentsReactProperties }) => ( + + {value} + + ), + }, + { + Header: 'Notes', + id: 'notes', + // Stop horizontal arrow key propagation so caret movement works + Cell: () => ( + { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.stopPropagation(); + } + }} + /> + ), + }, + { + Header: 'Details', + id: 'details', + // Prefer mounting dialogs outside the table over rendering one per cell + Cell: ({ row }) => setDialogRow(row.original)}>Show, + }, + ], + [], + ); + + return ( + <> + + setDialogRow(null)} headerText={dialogRow?.name ?? ''}> + + Age: {dialogRow?.age} + Friend: {dialogRow?.friend?.name} + Status: {dialogRow?.status} + + + > + ); + }, +}; + +export const CustomHeaderPopover: Story = { + render(args) { + const [sortInfo, setSortInfo] = useState(''); + const [filterVal, setFilterVal] = useState(''); + + const columns = useMemo( + () => [ + { + Header: 'Name (Custom Popover)', + accessor: 'name', + Popover: (instance) => { + const { setOpen, openerId } = instance.popoverProps; + + return ( + setOpen(false)} + // Prevent clicks inside the popover from propagating to the column header, + // which would re-open the popover immediately. + onClick={(e) => e.stopPropagation()} + headerText="Custom Column Menu" + > + + { + instance.setSortBy([{ id: 'name', desc: false }]); + setSortInfo('Name: ascending'); + setOpen(false); + }} + > + Sort A → Z + + { + instance.setSortBy([{ id: 'name', desc: true }]); + setSortInfo('Name: descending'); + setOpen(false); + }} + > + Sort Z → A + + { + const val = e.target.value; + setFilterVal(val); + instance.setFilter('name', val || undefined); + }} + /> + { + instance.setSortBy([]); + instance.setAllFilters([]); + setSortInfo(''); + setFilterVal(''); + setOpen(false); + }} + > + Reset All + + + + ); + }, + }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Friend Name', accessor: 'friend.name' }, + ], + [filterVal], + ); + + const data = useMemo(() => dataSmall.slice(0, 30), []); + + const reactTableOptions = useMemo(() => ({ autoResetSortBy: false, autoResetFilters: false }), []); + + return ( + <> + + + {sortInfo ? `Sort: ${sortInfo}` : 'Click the "Name (Custom Popover)" column header.'} + + > + ); + }, +}; + +export const SelectionModes: Story = { + render(args) { + const [selectionMode, setSelectionMode] = useState(AnalyticalTableSelectionMode.Multiple); + const [selectionBehavior, setSelectionBehavior] = useState(AnalyticalTableSelectionBehavior.Row); + const [lastEvent, setLastEvent] = useState(''); + + const data = useMemo(() => dataSmall.slice(0, 20), []); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End }, + { Header: 'Friend Name', accessor: 'friend.name' }, + ], + [], + ); + + const onRowSelect = useCallback((e) => { + const { row, isSelected, allRowsSelected, selectedRowIds } = e.detail; + const selectedCount = Object.keys(selectedRowIds).length; + if (row) { + setLastEvent( + `Row "${row.original.name}" ${isSelected ? 'selected' : 'deselected'}. Total selected: ${selectedCount}`, + ); + } else { + setLastEvent(`Select All toggled. All selected: ${allRowsSelected}. Total selected: ${selectedCount}`); + } + }, []); + + return ( + <> + + + selectionMode + + setSelectionMode(e.detail.selectedOption.getAttribute('value') as AnalyticalTableSelectionMode) + } + > + {Object.values(AnalyticalTableSelectionMode).map((mode) => ( + + {mode} + + ))} + + + + selectionBehavior + + setSelectionBehavior(e.detail.selectedOption.getAttribute('value') as AnalyticalTableSelectionBehavior) + } + > + {Object.values(AnalyticalTableSelectionBehavior).map((behavior) => ( + + {behavior} + + ))} + + + + + + {lastEvent || 'Click a row or selection checkbox to see event details.'} + + > + ); + }, +}; + +export const MultiSort: Story = { + render(args) { + const tableInstanceRef = useRef(null); + const [sortBy, setSortByState] = useState<{ id: string; desc: boolean }[]>([]); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', enableMultiSort: true }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End, enableMultiSort: true }, + { Header: 'Friend Name', accessor: 'friend.name', enableMultiSort: true }, + { Header: 'Friend Age', accessor: 'friend.age', hAlign: TextAlign.End, enableMultiSort: true }, + ], + [], + ); + + const data = useMemo(() => dataSmall.slice(0, 30), []); + + const reactTableOptions = useMemo(() => ({ autoResetSortBy: false }), []); + + const onSort = useCallback((e) => { + const { column, sortDirection } = e.detail; + setSortByState((prev) => { + if (sortDirection === 'clear') { + return prev.filter((s) => s.id !== column.id); + } + const desc = sortDirection === 'desc'; + const idx = prev.findIndex((s) => s.id === column.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = { id: column.id, desc }; + return next; + } + return [...prev, { id: column.id, desc }]; + }); + }, []); + + return ( + <> + + { + tableInstanceRef.current?.setSortBy([ + { id: 'age', desc: false }, + { id: 'name', desc: false }, + ]); + setSortByState([ + { id: 'age', desc: false }, + { id: 'name', desc: false }, + ]); + }} + > + Sort: Age ↑, Name ↑ + + { + tableInstanceRef.current?.setSortBy([ + { id: 'friend.name', desc: true }, + { id: 'friend.age', desc: false }, + ]); + setSortByState([ + { id: 'friend.name', desc: true }, + { id: 'friend.age', desc: false }, + ]); + }} + > + Sort: Friend Name ↓, Friend Age ↑ + + { + tableInstanceRef.current?.setSortBy([]); + setSortByState([]); + }} + > + Clear Sort + + + + + {sortBy.length > 0 + ? `Sort priorities: ${sortBy.map((s, i) => `${i + 1}. ${s.id} (${s.desc ? 'desc' : 'asc'})`).join(' → ')}` + : 'No active sort. Use the column header menu or the buttons above.'} + + > + ); + }, +}; + +export const AutoResizeColumns: Story = { + render(args) { + const [lastResize, setLastResize] = useState(''); + + const data = useMemo( + () => [ + { name: 'A very long name that will exceed the column width', age: 28, description: 'Short' }, + { name: 'Bob', age: 3500, description: 'A medium length description for this row' }, + { + name: 'Charlie Brown', + age: 42, + description: 'Another description that is quite long and should cause the column to expand when auto-resized', + }, + { name: 'Di', age: 9, description: 'Tiny' }, + { name: 'Eve Martinez de la Cruz', age: 26123, description: 'Medium text here' }, + ], + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', autoResizable: true }, + { Header: 'Age', accessor: 'age', hAlign: TextAlign.End, autoResizable: true }, + { Header: 'Description', accessor: 'description', autoResizable: true }, + ], + [], + ); + + const onAutoResize = useCallback((e) => { + setLastResize(`Column "${e.detail.columnId}" auto-resized to ${Math.round(e.detail.width)}px`); + }, []); + + return ( + <> + + + {lastResize || 'Double-click a column border to auto-resize.'} + + > + ); + }, +}; diff --git a/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/FeatureExamples.mdx b/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/FeatureExamples.mdx new file mode 100644 index 00000000000..d01d68359a4 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/docs/FeatureExamples/FeatureExamples.mdx @@ -0,0 +1,876 @@ +import { Footer, TableOfContent } from '@sb/components'; +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import * as FeatureStories from './AnalyticalTableFeatureExamples.stories'; + + + +# Feature Examples + +Advanced usage patterns and configuration examples for the AnalyticalTable. +For basic prop configuration, use the [Default story controls](?path=/story/data-display-analyticaltable--default). +For common questions, see the [FAQ](?path=/docs/data-display-analyticaltable-faq--docs). + + + + + +## Column Width Scaling Modes + +The `scaleWidthMode` prop controls how column widths are calculated. `Default` distributes available space evenly across columns without a fixed width. `Smart` ensures every column has enough space for its full header text, then distributes remaining space to columns with longer content. `Grow` sizes columns to fit both their full header text and full cell content. The three tables below show the same data with each mode applied. + +`Smart` and `Grow` calculate widths from **text content only** — custom `Cell` or `Header` renderers are ignored. Use the `scaleWidthModeOptions` column option to provide a representative string (`cellString` / `headerString`) for the width calculation. See the "Status" and "Score" columns below for examples. + + + +### Code + + + +Show Code + +```jsx +const columns = [ + // ... + { + Header: 'Status', + accessor: 'status', + Cell: ({ value }) => {value}, + scaleWidthModeOptions: { cellString: 'Temporarily Unavailable — On Extended Leave Until Further Notice' }, + }, + { + accessor: 'score', + Header: ({ webComponentsReactProperties }) => ( + + Average Quarterly Employee Satisfaction Score + + ), + scaleWidthModeOptions: { headerString: 'Average Quarterly Employee Satisfaction Score' }, + }, + // ... +]; + + + + +``` + + + +## Grouping with Aggregation + +Enable column grouping via `groupable={true}`. Columns can define `aggregate` functions (e.g. `"average"`, `"count"`, `"sum"` or a custom function) and custom `Aggregated` cell renderers to display summary values for each group. Use the `groupBy` prop for programmatic grouping or let users group via the column header menu. + + + +### Code + + + +Show Code + +```jsx +const GroupingTable = () => { + const [groupBy, setGroupBy] = useState([]); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', disableGroupBy: true }, + { + Header: 'Age', + accessor: 'age', + aggregate: 'average', + Aggregated: (instance) => `Avg: ${Math.round(instance.value)}` + }, + { + Header: 'Status', + accessor: 'status', + aggregate: (leafValues) => { + const severity = ['Negative', 'Critical', 'Positive', 'Information', 'None']; + return leafValues.reduce( + (highest, val) => (severity.indexOf(val) < severity.indexOf(highest) ? val : highest), + 'None' + ); + }, + Aggregated: (instance) => `Max: ${instance.value}` + }, + { Header: 'Friend Name', accessor: 'friend.name', disableGroupBy: true } + ], + [] + ); + + const handleGroup = (e) => { + const { column, groupedColumns, isGrouped } = e.detail; + console.log(column.Header, isGrouped, groupedColumns); + }; + + return ( + <> + setGroupBy(['status'])}>Group by Status + setGroupBy(['age'])}>Group by Age + setGroupBy([])}>Clear Groups + + > + ); +}; +``` + + + +## Server-Side Sorting, Filtering and Grouping + +For backend-driven tables, set `reactTableOptions={{ manualSortBy: true, manualFilters: true, manualGroupBy: true }}` to disable client-side operations. Use the `onSort`, `onFilter` and `onGroup` callbacks to fetch data from your server. For `manualGroupBy`, the server must return pre-grouped data with child rows via `subRows`. This example simulates a 500ms server delay. + + + +### Code + + + +Show Code + +```jsx +const ServerSideTable = () => { + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(false); + + // Track current operations so they compose (filter → sort → group). + const opsRef = useRef({ groupedColumns: [] }); + + const reactTableOptions = useMemo( + () => ({ + manualSortBy: true, + manualFilters: true, + manualGroupBy: true, + autoResetGroupBy: false, + autoResetExpanded: false + }), + [] + ); + + // Apply all active operations as a pipeline. + // In production, send opsRef.current to your server instead. + const applyOps = useCallback( + (ops) => { + setLoading(true); + fetchData(ops).then((result) => { + setData(result); + setLoading(false); + }); + }, + [] + ); + + const onSort = (e) => { + const { column, sortDirection } = e.detail; + opsRef.current.sort = sortDirection ? { columnId: column.id, direction: sortDirection } : undefined; + applyOps(opsRef.current); + }; + + // onFilter receives a plain object, not a CustomEvent. + const onFilter = (e) => { + const { columnId, value } = e; + opsRef.current.filter = value ? { columnId, value } : undefined; + applyOps(opsRef.current); + }; + + // For manualGroupBy, the server must return pre-grouped data + // with child rows in `subRows`. + const onGroup = (e) => { + opsRef.current.groupedColumns = e.detail.groupedColumns; + applyOps(opsRef.current); + }; + + return ( + + ); +}; +``` + + + + + +Show Grouped Data Structure + +```json +// Flat data (before grouping) +[ + { "name": "Alice #0", "age": 20, "department": "Engineering" }, + { "name": "Bob #1", "age": 21, "department": "Sales" }, + { "name": "Eve #4", "age": 24, "department": "HR" } +] + +// Grouped by department (server response) +[ + { + "department": "Engineering", + "subRows": [ + { "name": "Alice #0", "age": 20, "department": "Engineering" }, + { "name": "Frank #5", "age": 25, "department": "Engineering" } + ] + }, + { + "department": "Sales", + "subRows": [ + { "name": "Bob #1", "age": 21, "department": "Sales" }, + { "name": "Grace #6", "age": 26, "department": "Sales" } + ] + } +] +``` + + + +## Loading States + +The table supports multiple loading indicators: a skeleton placeholder (when `loading` is true and data is empty), a `BusyIndicator` overlay (when `loading` is true and data exists), and `showOverlay` which displays a semi-transparent overlay without a loading indicator. When `loading` is active, user interactions are blocked as well. Set `alwaysShowBusyIndicator` to always show the BusyIndicator instead of the skeleton. + +**Recommended:** Set `alwaysShowBusyIndicator` to `true` in most cases. The default skeleton loading indicator is only sufficient when loading times exceed 1 second. See [Fiori Skeleton Loading](https://www.sap.com/design-system/fiori-design-ios/ui-elements/patterns/skeleton-loading/?external) for design guidance. + + + +### Code + +```jsx +// Skeleton placeholder (loading with no data) + + +// BusyIndicator overlay (loading with existing data) + + +// Always show BusyIndicator instead of skeleton (recommended) + + +// Overlay without loading indicator + +``` + +## Controlled Selection + +Control selected rows externally via the `selectedRowIds` prop. This object maps row indices to `true` (e.g. `{ 0: true, 2: true }`). Combine with `onRowSelect` to keep your external state in sync with user interactions. + + + +### Code + + + +Show Code + +```jsx +const ControlledSelectionTable = () => { + const [selectedRowIds, setSelectedRowIds] = useState({}); + + const onRowSelect = (e) => { + setSelectedRowIds({ ...e.detail.selectedRowIds }); + }; + + return ( + <> + { + const all = {}; + data.forEach((_, i) => { all[i] = true; }); + setSelectedRowIds(all); + }} + > + Select All + + setSelectedRowIds({})}>Clear All + + > + ); +}; +``` + + + +## Row Highlighting + +Enable `withRowHighlight` to display a colored indicator strip at the start of each row. The `highlightField` prop can be a string pointing to a data field containing `ValueState` values (`Positive`, `Negative`, `Critical`, `Information`, `None`) or `IndicationColor` values (`Indication01`-`Indication20`). It can also be a memoized function that receives the row and returns the color. + + + +### Code + + + +Show Code + +```jsx +// Option 1: String-based - reads from a data field +const data = [ + { name: 'Alice', age: 28, status: 'Positive' }, + { name: 'Bob', age: 35, status: 'Negative' }, + { name: 'Charlie', age: 42, status: 'Indication01' } +]; + + + +// Option 2: Function-based - must be memoized with useCallback +const highlightFn = useCallback((original) => { + return original.age > 40 ? 'Indication03' : 'Indication07'; +}, []); + + +``` + + + +## Column Drag and Drop + +Columns can be reordered by dragging their headers. Use `onColumnsReorder` to persist the new order and `columnOrder` for controlled ordering. Individual columns can opt out via `disableDragAndDrop: true`. + + + +### Code + + + +Show Code + +```jsx +const DragAndDropTable = () => { + const [columnOrder, setColumnOrder] = useState(['name', 'age', 'friend.name', 'friend.age']); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age' }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age', disableDragAndDrop: true } + ], + [] + ); + + const onColumnsReorder = (e) => { + setColumnOrder(e.detail.columnsNewOrder.map((col) => col.id)); + }; + + return ( + + ); +}; +``` + + + +## Global Filter + +The `globalFilterValue` prop applies a text filter across all columns simultaneously. Combine with an external `Input` or search field for a search-bar pattern. Individual columns can be excluded from global filtering via `disableGlobalFilter: true`. + + + +### Code + + + +Show Code + +```jsx +const GlobalFilterTable = () => { + const [filterValue, setFilterValue] = useState(''); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age', disableGlobalFilter: true }, + { Header: 'Friend Name', accessor: 'friend.name' } + ], + [] + ); + + return ( + <> + setFilterValue(e.target.value)} + showClearIcon + /> + + > + ); +}; +``` + + + +## Programmatic Table Control + +The `tableInstance` ref exposes the internal react-table instance, giving access to methods like `setSortBy`, `setFilter`, `setAllFilters`, `toggleHideColumn`, `setHiddenColumns`, and `resetResizing`. Use `reactTableOptions` with `autoReset*: false` to prevent the table from resetting state when data changes. Toggle the data set to see that the `autoReset*` options preserve table state across data updates. + + + +### Code + + + +Show Code + +```jsx +const ProgrammaticTable = () => { + const tableInstanceRef = useRef(null); + + // Memoize reactTableOptions — the autoReset options preserve table state + // across data changes. An inline object would recreate on every render. + const reactTableOptions = useMemo( + () => ({ + autoResetSortBy: false, + autoResetFilters: false, + autoResetHiddenColumns: false, + autoResetExpanded: false, + autoResetGroupBy: false + }), + [] + ); + + return ( + <> + tableInstanceRef.current?.setSortBy([{ id: 'name', desc: false }])}> + Sort Name (asc) + + tableInstanceRef.current?.setSortBy([])}>Clear Sort + tableInstanceRef.current?.setFilter('age', '3')}> + Filter Age contains "3" + + tableInstanceRef.current?.setAllFilters([])}>Clear Filters + tableInstanceRef.current?.toggleHideColumn('friend.name')}> + Toggle "Friend Name" + + tableInstanceRef.current?.resetResizing()}> + Reset Column Widths + + + > + ); +}; +``` + + + +## Initial Table State + +Set initial sorting, filtering, selection, and hidden columns via `reactTableOptions.initialState`. This is useful for dashboards where the table should appear pre-configured. The example starts with Age sorted descending, Name filtered by "a", Friend Age hidden, and rows 0 and 2 selected. + + + +### Code + + + +Show Code + +```jsx +const InitialStateTable = () => { + const [key, resetKey] = useReducer((k) => k + 1, 0); + + // Memoize reactTableOptions — an inline object creates a new reference on + // every render, causing the table to re-initialize its state. + const reactTableOptions = useMemo( + () => ({ + autoResetSortBy: false, + autoResetFilters: false, + autoResetHiddenColumns: false, + initialState: { + sortBy: [{ id: 'age', desc: true }], + filters: [{ id: 'name', value: 'a' }], + hiddenColumns: ['friend.age'], + selectedRowIds: { 0: true, 2: true } + } + }), + [] + ); + + return ( + <> + Reset to Initial State + + > + ); +}; +``` + + + +## Custom Cell Renderers + +Use the `Cell` column property to render custom content in cells. Lightweight cells don't need memoization — consider `memo()` for complex or expensive cells. For interactive elements like inputs, stop horizontal arrow key propagation so caret movement works. Use the `textEllipsis` class when custom elements should truncate. Prefer mounting dialogs outside the table over rendering one per cell. For more details, see the [FAQ](?path=/docs/data-display-analyticaltable-faq--docs). + + + +### Code + + + +Show Code + +```jsx +// Consider memo() for complex or expensive cells — not needed for simple ones +const FriendComparisonCell = memo(({ row }) => { + const { age, friend, status } = row.original; + const ageDiff = friend.age - age; + return ( + + {friend.name} + = 0 ? '5' : '3'}> + {ageDiff >= 0 ? '+' : ''}{ageDiff} yrs + + + ); +}); + +const CustomCellTable = () => { + const [dialogRow, setDialogRow] = useState(null); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name' }, + { + Header: 'Age', + accessor: 'age', + // Lightweight cells don't need memo() + Cell: ({ value }) => ( + 40 ? 'Critical' : 'Positive'}>{value} + ) + }, + { + Header: 'Friend Comparison', + id: 'friendComparison', + Cell: FriendComparisonCell + }, + { + Header: 'Description', + accessor: 'description', + // Use the textEllipsis class when custom elements should truncate + Cell: ({ value, webComponentsReactProperties }) => ( + + {value} + + ) + }, + { + Header: 'Notes', + id: 'notes', + // Stop horizontal arrow key propagation so caret movement works + Cell: () => ( + { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.stopPropagation(); + } + }} + /> + ) + }, + { + Header: 'Details', + id: 'details', + // Prefer mounting dialogs outside the table over rendering one per cell + Cell: ({ row }) => ( + setDialogRow(row.original)}>Show + ) + } + ], + [] + ); + + return ( + <> + + setDialogRow(null)} + headerText={dialogRow?.name ?? ''} + > + Age: {dialogRow?.age} + + > + ); +}; +``` + + + +## Custom Header Popover + +Replace the default column header popover with a fully custom UI using the `Popover` column property. The component receives the table instance with `popoverProps` (containing `openerId`, `setOpen`, and `id`) plus all table methods like `setSortBy` and `setFilter`. Use the `openerId` to position a `Popover` via its `opener` prop. + + + +### Code + + + +Show Code + +```jsx +const columns = [ + { + Header: 'Name (Custom Popover)', + accessor: 'name', + Popover: (instance) => { + const { setOpen, openerId } = instance.popoverProps; + + return ( + setOpen(false)} + // Stop click propagation so the column header doesn't re-open the popover. + onClick={(e) => e.stopPropagation()} + headerText="Custom Column Menu" + > + + { + instance.setSortBy([{ id: 'name', desc: false }]); + setOpen(false); + }} + > + Sort A → Z + + { + instance.setSortBy([{ id: 'name', desc: true }]); + setOpen(false); + }} + > + Sort Z → A + + { + setFilterVal(e.target.value); + instance.setFilter('name', e.target.value || undefined); + }} + /> + { + instance.setSortBy([]); + instance.setAllFilters([]); + setOpen(false); + }} + > + Reset All + + + + ); + } + }, + { Header: 'Age', accessor: 'age' } +]; + + +``` + + + +## Selection Modes + +The `selectionMode` prop defines whether rows can be selected (`None`, `Single`, `Multiple`) and `selectionBehavior` controls how selection is triggered (`Row` = checkbox + row click, `RowOnly` = row click without checkbox column, `RowSelector` = checkbox only). Use the dropdowns to switch between modes and observe the `onRowSelect` event details. + + + +### Code + +```jsx + { + const { row, isSelected, allRowsSelected, selectedRowIds } = e.detail; + console.log(row?.original.name, isSelected, Object.keys(selectedRowIds).length); + }} +/> +``` + +## Multi-Column Sorting + +Enable multi-column sorting by setting `enableMultiSort: true` on each column. Users can then hold Ctrl/Cmd and click additional column headers via the column menu to add sort levels. Without `enableMultiSort`, sorting a new column clears the previous sort. Use `tableInstance.setSortBy()` for programmatic control and `tableInstance.state.sortBy` to read the current sort priorities. + + + +### Code + + + +Show Code + +```jsx +const MultiSortExample = () => { + const tableInstanceRef = useRef(null); + const [sortBy, setSortByState] = useState([]); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', enableMultiSort: true }, + { Header: 'Age', accessor: 'age', enableMultiSort: true }, + { Header: 'Friend Name', accessor: 'friend.name', enableMultiSort: true }, + ], + [], + ); + + // Derive expected state from event detail — instance.state.sortBy is not yet committed when onSort fires + const onSort = useCallback((e) => { + const { column, sortDirection } = e.detail; + setSortByState((prev) => { + if (sortDirection === 'clear') { + return prev.filter((s) => s.id !== column.id); + } + const desc = sortDirection === 'desc'; + const idx = prev.findIndex((s) => s.id === column.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = { id: column.id, desc }; + return next; + } + return [...prev, { id: column.id, desc }]; + }); + }, []); + + return ( + <> + {/* Programmatic sort control */} + { + tableInstanceRef.current?.setSortBy([ + { id: 'age', desc: false }, + { id: 'name', desc: false }, + ]); + setSortByState([ + { id: 'age', desc: false }, + { id: 'name', desc: false }, + ]); + }} + > + Sort: Age ↑, Name ↑ + + { tableInstanceRef.current?.setSortBy([]); setSortByState([]); }}> + Clear Sort + + + + + {/* Display current sort priorities */} + + {sortBy.map((s, i) => `${i + 1}. ${s.id} (${s.desc ? 'desc' : 'asc'})`).join(' → ')} + + > + ); +}; +``` + + + +## Auto-Resize Columns + +Set `autoResizable: true` on a column to enable auto-fit on double-click of the column resizer. The column width will adjust to fit the content. The `onAutoResize` callback fires with the column ID and the new width. + + + +### Code + +```jsx +const columns = [ + { Header: 'Name', accessor: 'name', autoResizable: true }, + { Header: 'Age', accessor: 'age', autoResizable: true }, + { Header: 'Description', accessor: 'description', autoResizable: true } +]; + + { + const { columnId, width } = e.detail; + console.log(`Column "${columnId}" resized to ${Math.round(width)}px`); + }} +/> +``` + + diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 569b94a6440..6dbe66b784a 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -404,7 +404,7 @@ interface ScaleWidthModeOptions { /** * Defines the string used for internal width calculation of custom header cells (e.g. `Header: () => Click me!`). * - * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells). + * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-faq--docs#how-to-scale-custom-cells). * * __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`. */ @@ -412,7 +412,7 @@ interface ScaleWidthModeOptions { /** * Defines the string used for internal width calculation of the longest cell inside the body of the table (e.g. `Cell: () => Click me!`). * - * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells). + * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-faq--docs#how-to-scale-custom-cells). * * __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`. */ @@ -498,7 +498,7 @@ export interface AnalyticalTableColumnDefinition { * * __Note:__ * - Using a custom component __can impact performance__! If you pass a component, __memoizing it is strongly recommended__, especially for complex components or large datasets. - * - For custom elements, text truncation needs to be applied manually. [Here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-recipes--docs) you can find out more. + * - For custom elements, text truncation needs to be applied manually. [Here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-faq--docs) you can find out more. */ Cell?: string | ComponentType; /** @@ -640,7 +640,7 @@ export interface AnalyticalTableColumnDefinition { /** * Allows passing a custom string for the internal width calculation of custom cells for `scaleWidthMode` `Grow` and `Smart`. * - * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells). + * You can find out more about it [here](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-faq--docs#how-to-scale-custom-cells). * * __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`. * @@ -878,7 +878,7 @@ export interface AnalyticalTablePropTypes extends Omit { * * @default "status" */ - highlightField?: string | ((row: RowType) => HighlightColor); + highlightField?: string | ((original: Record) => HighlightColor); /** * Defines whether columns are filterable. * @@ -959,7 +959,7 @@ export interface AnalyticalTablePropTypes extends Omit { * * __Note:__ It is not recommended to use this prop in combination with a grouped table, as there is no concept for this configuration. * - * __Note:__ To prevent the table state from resetting when the data is updated, please see [this recipe](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-stop-the-table-state-from-automatically-resetting-when-the-data-changes). + * __Note:__ To prevent the table state from resetting when the data is updated, please see [this faq entry](https://ui5.github.io/webcomponents-react/v2/?path=/docs/data-display-analyticaltable-faq--docs#how-to-stop-the-table-state-from-automatically-resetting-when-the-data-changes). */ infiniteScroll?: boolean; /**