Skip to content

Latest commit

 

History

History
864 lines (749 loc) · 24.1 KB

File metadata and controls

864 lines (749 loc) · 24.1 KB

import { ControlsWithNote, DocsHeader, Footer } from '@sb/components'; import { Canvas, Markdown, Meta } from '@storybook/addon-docs/blocks'; import * as ComponentStories from './AnalyticalTable.stories'; import ColumnPropertiesMd from './ColumnProperties.md?raw';


Example

Code

Show shortened Code
const columns = [
  {
    Header: 'Name',
    accessor: 'name'
  },
  {
    Header: 'Age',
    accessor: 'age'
  },
  {
    Header: 'Friend Name',
    accessor: 'friend.name'
  },
  {
    Header: 'Friend Age',
    accessor: 'friend.age'
  }
];

const data = [
  {
    age: 80,
    friend: {
      age: 68,
      name: 'Carver Vance'
    },
    name: 'Allen Best',
    status: 'Positive'
  },
  {
    age: 31,
    friend: {
      age: 70,
      name: 'Strickland Gallegos'
    },
    name: 'Combs Fleming',
    status: 'None'
  }
  // shortened for readability
];

const TableComp = () => {
  return (
    <AnalyticalTable
      columns={columns}
      data={data}
      visibleRows={5}
      onAutoResize={() => {}}
      onColumnsReorder={() => {}}
      onGroup={() => {}}
      onLoadMore={() => {}}
      onRowClick={() => {}}
      onRowExpandChange={() => {}}
      onRowSelect={() => {}}
      onSort={() => {}}
      onTableScroll={() => {}}
    />
  );
};

Properties

Column Properties

{ColumnPropertiesMd}


More Examples


Tree Table

<Canvas of={ComponentStories.TreeTable} sourceState={'none'} />

The data structure of the tree table is as follows:

const data = {
    name: "Greg Miller",
    age: 35,
    friend: {
        name: "Rose Franco",
        age: 32,
    },
    status: "None",
    subRows: [
        {
            name: "Rick DeAngelo",
            age: 25,
            friend: {
                name: "Susanne Franco",
                age: 37,
            },
            status: "None",
            subRows: [...],
        },
    ],
    ...
};

In this example the default key for sub row detection is used (subRows), you can use any key you like by setting the subRowsKey prop.


Infinite Scrolling

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.

Code

Show Code
const InfiniteScrollTable = (props) => {
  const [data, setData] = useState(props.data.slice(0, 50));
  const [loading, setLoading] = useState(false);
  const offset = useRef(50);
  const onLoadMore = () => {
    setLoading(true);
  };
  useEffect(() => {
    if (loading) {
      setTimeout(() => {
        setData((prev) => [...prev, ...props.data.slice(offset.current, offset.current + 50)]);
        setLoading(false);
        offset.current += 50;
      }, 2000);
    }
  }, [loading, props.data, offset.current]);
  return (
    <AnalyticalTable
      data={data}
      columns={props.columns}
      infiniteScroll={true}
      infiniteScrollThreshold={10}
      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.

<ControlsWithNote of={ComponentStories.Subcomponents} include={['renderRowSubComponent', 'subComponentsBehavior']} />

Code

Show Code
const TableWithSubcomponents = (props) => {
  const renderRowSubComponent = (row) => {
    if (row.id === '0') {
      return (
        <FlexBox
          style={{ backgroundColor: 'lightblue', height: '300px' }}
          justifyContent={FlexBoxJustifyContent.Center}
          alignItems={FlexBoxAlignItems.Center}
          direction={FlexBoxDirection.Column}
        >
          <Tag>height: 300px</Tag>
          <Text>This subcomponent will only be displayed below the first row.</Text>
          <hr />
          <Text>
            The button below is rendered with `data-subcomponent-active-element` attribute to ensure consistent focus
            behavior
          </Text>
          <Button data-subcomponent-active-element>Click</Button>
        </FlexBox>
      );
    }
    if (row.id === '1') {
      return (
        <FlexBox
          style={{ backgroundColor: 'lightyellow', height: '100px' }}
          justifyContent={FlexBoxJustifyContent.Center}
          alignItems={FlexBoxAlignItems.Center}
          direction={FlexBoxDirection.Column}
        >
          <Tag>height: 100px</Tag>
          <Text>This subcomponent will only be displayed below the second row.</Text>
        </FlexBox>
      );
    }
    if (row.id === '2') {
      return null;
    }
    return (
      <FlexBox
        style={{ backgroundColor: 'lightgrey', height: '50px' }}
        justifyContent={FlexBoxJustifyContent.Center}
        alignItems={FlexBoxAlignItems.Center}
        direction={FlexBoxDirection.Column}
      >
        <Tag>height: 50px</Tag>
        <Text>This subcomponent will be displayed below all rows except the first, second and third.</Text>
      </FlexBox>
    );
  };
  return (
    <AnalyticalTable
      data={props.data}
      columns={props.columns}
      renderRowSubComponent={renderRowSubComponent}
      subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Expandable} //default value
    />
  );
};

Adjust the number of rows to the container height

By adding the visibleRowCountMode prop and setting it to AnalyticalTableVisibleRowCountMode.Auto the table automatically fills the surrounding container with rows and when setting it to AnalyticalTableVisibleRowCountMode.AutoWithEmptyRows, empty rows fill the container as well, if not enough visible rows are available.

<ControlsWithNote of={ComponentStories.DynamicRowCount} include={['containerHeight', 'visibleRowCountMode']} hideHTMLPropsNote />

Code

const TableComponent = (props) => {
  return (
    <div style={{ height: `${props.containerHeight}px` }}>
      <AnalyticalTable
        data={props.data}
        columns={props.columns}
        visibleRowCountMode={AnalyticalTableVisibleRowCountMode.Auto}
        // visibleRowCountMode={AnalyticalTableVisibleRowCountMode.AutoWithEmptyRows}
        header={`Current height: ${props.containerHeight}px - Change the height in the table above`}
      />
    </div>
  );
};

Responsively display columns on small devices (Pop-In)

<ControlsWithNote of={ComponentStories.ResponsiveColumns} hideHTMLPropsNote include={['adjustTableHeightOnPopIn', 'containerWidth']} />

To responsively hide columns or move content to the first column, you can add the responsiveMinWidth column option. If you want the column to "pop-in" the responsivePopIn has to be set to true, otherwise the column will be hidden when the responsiveMinWidth exceeds the table width. It's also possible to change the header of the pop-in column by setting the PopInHeader option.

Note: It is recommended to offer column options such as filtering, sorting and grouping only for columns that are always displayed.

In the example below you can have a look at this behavior:

  • 800: The content of the "Action" column is moved to the first column (responsiveMinWidth: 801)
  • 600: The content of the "Age" column is moved to the first column (responsiveMinWidth: 601) and receives a custom header.
  • 400: The content of the "Friend Name" column is moved to the first column and the "Friend Age" column is hidden (responsiveMinWidth: 401). The "Friend Name" column also receives a custom header.

Columns Config

const COLUMNS = [
  {
    Header: 'Name',
    accessor: 'name'
  },
  {
    disableSortBy: true,
    responsivePopIn: true,
    responsiveMinWidth: 601,
    PopInHeader: 'Custom Header Text (age)',
    Header: 'Age',
    accessor: 'age'
  },
  {
    disableSortBy: true,
    responsivePopIn: true,
    responsiveMinWidth: 401,
    Header: 'Friend Name',
    PopInHeader: (instance) => {
      return <div style={{ color: 'red' }}>Friend Name (custom)</div>;
    },
    accessor: 'friend.name'
  },
  { disableSortBy: true, responsiveMinWidth: 401, Header: 'Friend Age', accessor: 'friend.age' },
  {
    disableSortBy: true,
    responsivePopIn: true,
    responsiveMinWidth: 801,
    id: 'actions',
    Header: 'Actions',
    width: 100,
    disableResizing: true,
    Cell: (instance) => {
      return (
        <FlexBox>
          <Button icon="edit" />
          <Button icon="delete" />
        </FlexBox>
      );
    }
  },
  {
    id: 'popinDisplay',
    Header: 'PopinDisplay Modes',
    responsivePopIn: true,
    responsiveMinWidth: 801,
    popinDisplay: popinDisplay, // possible values: "Block", "Inline", "WithoutHeader"
    Cell: () => {
      return <Text maxLines={1}>Using popinDisplay: {popinDisplay}</Text>;
    }
  }
];

How to change the content of the pop-in cell?

You can change the content of the pop-in cell without mutating the original cell by using the isPopIn prop of the table instance returned by the Cell column option.

Note: The cell property of the custom Cell renderer, always returns the properties and values of the cell the "popin" cell is rendered into.

const COLUMNS = [
  {
    Header: 'Name',
    accessor: 'name'
  },
  {
    responsivePopIn: true,
    responsiveMinWidth: 600,
    id: 'col',
    Header: 'Column',
    Cell: ({ isPopIn, cell, value }) => {
      if (isPopIn) {
        // this will log the properties of the `name` cell (e.g. `cell.value` is the value of the `name` cell)
        console.log(cell);
        // this will always log the value of this cell (`col` cell)
        console.log(value);
        return 'pop-in content';
      }
      // this will log the properties of this cell (e.g. `cell.value` is the value of the `col` cell)
      console.log(cell);
      // this will always log the value of this cell (`col` cell)
      console.log(value);
      return 'original content';
    }
  }
  // ...
];

Display indicator for navigated rows

To display show the navigation column you need to set withNavigationHighlight to true and to mark a row as "navigated" the markNavigatedRow prop is required. With the markNavigatedRow callback it is possible to define when and how many navigation indicators should be shown.

Click on any of the rows in the example below to display the "navigated" indicator in the navigation-column.

Code

export const TableWithNavigationIndicators = () => {
  const [selectedRow, setSelectedRow] = useState();
  const onRowSelect = (e) => {
    setSelectedRow(e.detail.row);
  };
  const markNavigatedRow = useCallback(
    (row) => {
      return selectedRow?.id === row.id;
    },
    [selectedRow]
  );
  return (
    <AnalyticalTable
      data={data}
      columns={columns}
      withNavigationHighlight
      selectionMode={selectionMode}
      markNavigatedRow={markNavigatedRow}
      onRowSelect={onRowSelect}
    />
  );
};

Custom column filtering

It is possible to define your own filter function and filter component on each column. For this you need to customize the column option filter or add a custom filter type to the reactTableOptions.filterTypes object (for a custom filter function) and the column option Filter (for a custom filter component).

Here you can find an example using a MultiComboBox with multiple values as filter.

Code

Show static code
const filterFn = (rows, accessor, filterValue) => {
  if (filterValue.length > 0) {
    return rows.filter((row) => {
      const rowVal = row.values[accessor];
      if (filterValue.some((item) => rowVal.includes(item))) {
        return true;
      }
      return false;
    });
  }
  return rows;
};
const COLUMNS = [
  {
    Header: 'Name',
    accessor: 'name',
    // either define your filter function here or set is as `reactTableOption` and pass the key as string here (see below)
    filter: filterFn,
    Filter: ({ column }) => {
      const firstNames = ['Carl', 'Dan', 'Rose', 'Susanne'];
      return (
        <MultiComboBox
          onSelectionChange={(e) => {
            column.setFilter(e.detail.items.map((item) => item.getAttribute('text')));
          }}
        >
          {firstNames.map((item) => {
            const isSelected = column?.filterValue?.some((filterVal) => filterVal.includes(item));
            return <MultiComboBoxItem text={item} key={item} selected={isSelected} />;
          })}
        </MultiComboBox>
      );
    }
  },
  {
    Header: 'Age',
    accessor: 'age'
  }
];
const TableComponent = () => {
  return (
    <ThemeProvider>
      <AnalyticalTable
        columns={COLUMNS}
        data={DATA}
        filterable
        // you can also define your function here, then you can just pass the key as string to the `filter` column option
        // reactTableOptions={{
        //   filterTypes: {
        //     multiValueFilter: filterFn
        //   }
        // }}
      />
    </ThemeProvider>
  );
};

Table Without Data

Code

Show static code
function NoDataTable(props) {
  const [selected, setSelected] = useState('noData');
  const [filtered, setFiltered] = useState(false);
  const handleChange: SegmentedButtonPropTypes['onSelectionChange'] = (e) => {
    const { key } = e.detail.selectedItems[0].dataset;
    setSelected(key);
    if (key === 'data') {
      setFiltered(false);
    }
  };
  const handleClick: ToggleButtonPropTypes['onClick'] = (e) => {
    setFiltered(!!e.target.pressed);
  };

  const NoDataComponent: AnalyticalTablePropTypes['NoDataComponent'] =
    selected === 'noData'
      ? undefined
      : (props) => {
          return filtered ? (
            <IllustratedMessage role={props.accessibleRole} name={NoFilterResults} />
          ) : (
            <IllustratedMessage role={props.accessibleRole} name={NoDataIllustration} />
          );
        };
  return (
    <>
      <SegmentedButton onSelectionChange={handleChange} accessibleName="Select data view mode">
        <SegmentedButtonItem selected={selected === 'noData'} data-key="noData">
          Default NoData Component
        </SegmentedButtonItem>
        <SegmentedButtonItem selected={selected === 'illustratedMessage'} data-key="illustratedMessage">
          IllustratedMessage NoData Component
        </SegmentedButtonItem>
        <SegmentedButtonItem selected={selected === 'data'} data-key="data">
          With Data
        </SegmentedButtonItem>
      </SegmentedButton>{' '}
      |{' '}
      <ToggleButton onClick={handleClick} pressed={filtered} disabled={selected === 'data'}>
        Table filtered
      </ToggleButton>
      <AnalyticalTable
        {...props}
        data={selected === 'data' ? props.data : []}
        globalFilterValue={filtered ? 'Non-existing text' : undefined}
        NoDataComponent={NoDataComponent}
      />
    </>
  );
}

Context Menu

The onRowContextMenu callback fires when a row is right-clicked. It provides the row and column (if the click targeted a specific cell) in e.detail. The native browser context menu is not suppressed — call e.preventDefault() in your callback to replace it with a custom menu.

This example shows two tables with products that can be moved between them via buttons or a right-click context menu.

Code

Show Code
const productData = [
  { id: '1', product: 'Laptop Pro 15', category: 'Electronics', price: 1299 },
  { id: '2', product: 'Wireless Mouse', category: 'Accessories', price: 49 },
  // ...
];

type Product = (typeof productData)[number];

const productColumns = [
  { Header: 'Product', accessor: 'product' },
  { Header: 'Category', accessor: 'category' },
  { Header: 'Price', accessor: 'price', hAlign: TextAlign.End },
];

function ContextMenuExample() {
  const [availableProducts, setAvailableProducts] = useState(productData);
  const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
  const [checkedAvailable, setCheckedAvailable] = useState<Product[]>([]);
  const [checkedSelected, setCheckedSelected] = useState<Product[]>([]);
  const [menuOpen, setMenuOpen] = useState(false);
  const [menuTarget, setMenuTarget] = useState<'available' | 'selected'>('available');
  const [contextRow, setContextRow] = useState<Product | null>(null);
  const anchorRef = useRef<HTMLDivElement>(null);
  const rafId = useRef(0);
  useEffect(() => {
    return () => {
      cancelAnimationFrame(rafId.current);
    };
  }, []);

  const moveToSelected = (rows: Product[]) => {
    const ids = new Set(rows.map((r) => r.id));
    setAvailableProducts((prev) => prev.filter((p) => !ids.has(p.id)));
    setSelectedProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]);
    setCheckedAvailable([]);
  };

  const moveToAvailable = (rows: Product[]) => {
    const ids = new Set(rows.map((r) => r.id));
    setSelectedProducts((prev) => prev.filter((p) => !ids.has(p.id)));
    setAvailableProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]);
    setCheckedSelected([]);
  };

  const handleRowSelect: (
    setter: typeof setCheckedAvailable
  ) => AnalyticalTablePropTypes['onRowSelect'] = (setter) => (e) => {
    const rows = Object.values(e.detail.rowsById)
      .filter((r) => e.detail.selectedRowIds[r.id])
      .map((r) => r.original as Product);
    setter(rows);
  };

  const handleContextMenu: (
    target: 'available' | 'selected'
  ) => AnalyticalTablePropTypes['onRowContextMenu'] = (target) => (e) => {
    e.preventDefault();
    setContextRow(e.detail.row.original as Product);
    setMenuTarget(target);
    if (anchorRef.current) {
      anchorRef.current.style.left = `${e.clientX}px`;
      anchorRef.current.style.top = `${e.clientY}px`;
    }
    // Defer open so it runs after the menu's onClose from the previous right-click.
    setMenuOpen(false);
    rafId.current = requestAnimationFrame(() => setMenuOpen(true));
  };

  const handleMenuItemClick = () => {
    if (!contextRow) {
      return;
    }
    if (menuTarget === 'available') {
      moveToSelected([contextRow]);
    } else {
      moveToAvailable([contextRow]);
    }
    setMenuOpen(false);
    setContextRow(null);
  };

  return (
    <>
      <FlexBox alignItems={FlexBoxAlignItems.Start} style={{ gap: '0.5rem' }}>
        <AnalyticalTable
          header="Available Products"
          columns={productColumns}
          data={availableProducts}
          selectionMode="Multiple"
          onRowContextMenu={handleContextMenu('available')}
          onRowSelect={handleRowSelect(setCheckedAvailable)}
          style={{ flex: 1 }}
        />
        <FlexBox
          direction={FlexBoxDirection.Column}
          justifyContent={FlexBoxJustifyContent.Center}
          style={{ alignSelf: 'center' }}
        >
          <Button icon="navigation-right-arrow" onClick={() => moveToSelected(checkedAvailable)} />
          <Button icon="navigation-left-arrow" onClick={() => moveToAvailable(checkedSelected)} />
        </FlexBox>
        <AnalyticalTable
          header="Selected Products"
          columns={productColumns}
          data={selectedProducts}
          selectionMode="Multiple"
          onRowContextMenu={handleContextMenu('selected')}
          onRowSelect={handleRowSelect(setCheckedSelected)}
          style={{ flex: 1 }}
        />
      </FlexBox>
      {/* Hidden anchor for Menu positioning */}
      <div
        ref={anchorRef}
        style={{ position: 'fixed', width: 0, height: 0, pointerEvents: 'none' }}
      />
      {menuOpen && (
        <Menu open opener={anchorRef.current} onClose={() => setMenuOpen(false)} onItemClick={handleMenuItemClick}>
          <MenuItem
            text={`Move to ${menuTarget === 'available' ? 'Selected Products' : 'Available Products'}`}
            icon={menuTarget === 'available' ? 'navigation-right-arrow' : 'navigation-left-arrow'}
          />
        </Menu>
      )}
    </>
  );
}

Kitchen Sink

Code

Show shortened Code
const data = [
  {
    age: 80,
    friend: {
      age: 68,
      name: 'Carver Vance'
    },
    name: 'Allen Best',
    status: 'Positive'
  },
  {
    age: 31,
    friend: {
      age: 70,
      name: 'Strickland Gallegos'
    },
    name: 'Combs Fleming',
    status: 'None'
  }
  // shortened for readability
];

const columns = [
  {
    Header: 'Name',
    accessor: 'name',
    autoResizable: true,
    headerTooltip: 'Full Name'
  },
  {
    Header: 'Age',
    accessor: 'age',
    autoResizable: true,
    className: 'superCustomClass',
    disableFilters: false,
    disableGroupBy: true,
    disableSortBy: false,
    hAlign: 'End'
  },
  {
    Header: 'Friend Name',
    accessor: 'friend.name',
    autoResizable: true
  },
  {
    Filter: () => {},
    Header: () => {},
    accessor: 'friend.age',
    autoResizable: true,
    filter: () => {},
    hAlign: 'End',
    headerLabel: 'Friend Age'
  },
  {
    Cell: () => {},
    Header: 'Actions',
    accessor: '.',
    cellLabel: () => {},
    disableFilters: true,
    disableGroupBy: true,
    disableResizing: true,
    disableSortBy: true,
    id: 'actions',
    minWidth: 100,
    width: 100
  }
];

const TestComp2 = () => {
  return (
    <AnalyticalTable
      data={data}
      columns={columns}
      alternateRowColor
      columnOrder={['friend.name', 'friend.age', 'name']}
      extension={
        <FlexBox justifyContent="End">
          <Button accessibleName="edit" design="Transparent" icon="edit" />
        </FlexBox>
      }
      filterable
      groupable
      header="Table Title"
      headerRowHeight={60}
      highlightField="status"
      infiniteScroll
      infiniteScrollThreshold={20}
      loadingDelay={1000}
      minRows={5}
      noDataText="Custom 'noDataText' message"
      overscanCountHorizontal={5}
      scaleWidthMode="Smart"
      selectedRowIds={{
        3: true
      }}
      selectionBehavior="Row"
      selectionMode="Single"
      sortable
      subRowsKey="subRows"
      visibleRowCountMode="Interactive"
      visibleRows={5}
      withRowHighlight
      onAutoResize={() => {}}
      onColumnsReorder={() => {}}
      onGroup={() => {}}
      onLoadMore={() => {}}
      onRowClick={() => {}}
      onRowExpandChange={() => {}}
      onRowSelect={() => {}}
      onSort={() => {}}
      onTableScroll={() => {}}
    />
  );
};