diff --git a/change/@fluentui-react-table-f0050981-47d5-4725-9d63-99142fda10f9.json b/change/@fluentui-react-table-f0050981-47d5-4725-9d63-99142fda10f9.json new file mode 100644 index 00000000000000..9d4f6e74b7934a --- /dev/null +++ b/change/@fluentui-react-table-f0050981-47d5-4725-9d63-99142fda10f9.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: autocontrolled `useTable` hook", + "packageName": "@fluentui/react-table", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-table/etc/react-table.api.md b/packages/react-components/react-table/etc/react-table.api.md index f80ec68a76cd58..97d76626592cfa 100644 --- a/packages/react-components/react-table/etc/react-table.api.md +++ b/packages/react-components/react-table/etc/react-table.api.md @@ -67,31 +67,9 @@ export interface RowState { rowId: RowId; } -// @public (undocumented) -export interface SelectionState { - allRowsSelected: boolean; - clearRows: () => void; - deselectRow: (rowId: RowId) => void; - isRowSelected: (rowId: RowId) => boolean; - selectedRows: RowId[]; - selectRow: (rowId: RowId) => void; - someRowsSelected: boolean; - toggleAllRows: () => void; - toggleRow: (rowId: RowId) => void; -} - // @public (undocumented) export type SortDirection = 'ascending' | 'descending'; -// @public (undocumented) -export interface SortState { - getSortDirection: (columnId: ColumnId) => SortDirection | undefined; - setColumnSort: (columnId: ColumnId, sortDirection: SortDirection) => void; - sortColumn: ColumnId | undefined; - sortDirection: SortDirection; - toggleColumnSort: (columnId: ColumnId) => void; -} - // @public export const Table: ForwardRefComponent; @@ -240,13 +218,7 @@ export type TablePrimaryCellSlots = { export type TablePrimaryCellState = ComponentState; // @public -export type TableProps = ComponentProps & {} & Partial & { - onSortColumnChange?: (e: React_2.MouseEvent | React_2.KeyboardEvent, data: { - sortState: SortState_2; - }) => void; - sortState?: SortState_2; - defaultSortState?: SortState_2; -}; +export type TableProps = ComponentProps & Partial; // @public export const TableRow: ForwardRefComponent; @@ -291,11 +263,33 @@ export type TableSelectionCellSlots = { // @public export type TableSelectionCellState = ComponentState & Pick & Pick, 'type' | 'checked'>; +// @public (undocumented) +export interface TableSelectionState { + allRowsSelected: boolean; + clearRows: () => void; + deselectRow: (rowId: RowId) => void; + isRowSelected: (rowId: RowId) => boolean; + selectedRows: RowId[]; + selectRow: (rowId: RowId) => void; + someRowsSelected: boolean; + toggleAllRows: () => void; + toggleRow: (rowId: RowId) => void; +} + // @public (undocumented) export type TableSlots = { root: Slot<'table', 'div'>; }; +// @public (undocumented) +export interface TableSortState { + getSortDirection: (columnId: ColumnId) => SortDirection | undefined; + setColumnSort: (columnId: ColumnId, sortDirection: SortDirection) => void; + sortColumn: ColumnId | undefined; + sortDirection: SortDirection; + toggleColumnSort: (columnId: ColumnId) => void; +} + // @public export type TableState = ComponentState & Pick, 'size' | 'noNativeElements'> & TableContextValue; @@ -342,14 +336,20 @@ export const useTableHeaderStyles_unstable: (state: TableHeaderState) => TableHe export interface UseTableOptions = RowState> { // (undocumented) columns: ColumnDefinition[]; + defaultSelectedRows?: Set; + defaultSortState?: SortState; // (undocumented) getRowId?: (item: TItem) => RowId; // (undocumented) items: TItem[]; + onSelectionChange?: OnSelectionChangeCallback; + onSortChange?: OnSortChangeCallback; // (undocumented) rowEnhancer?: RowEnhancer; + selectedRows?: Set; // (undocumented) selectionMode?: SelectionMode_2; + sortState?: SortState; } // @public diff --git a/packages/react-components/react-table/src/components/Table/Table.types.ts b/packages/react-components/react-table/src/components/Table/Table.types.ts index d80c4b10f9d87a..eb5cd0b2231e8c 100644 --- a/packages/react-components/react-table/src/components/Table/Table.types.ts +++ b/packages/react-components/react-table/src/components/Table/Table.types.ts @@ -1,5 +1,4 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import * as React from 'react'; export type TableSlots = { root: Slot<'table', 'div'>; @@ -14,10 +13,6 @@ export type TableContextValue = { }; export type SortDirection = 'ascending' | 'descending'; -export type SortState = { - sortColumn: string | undefined; - sortDirection: 'ascending' | 'descending'; -}; export type TableContextValues = { table: TableContextValue; @@ -26,19 +21,7 @@ export type TableContextValues = { /** * Table Props */ -export type TableProps = ComponentProps & {} & Partial & { - /** - * Called when the sorted column changes - */ - onSortColumnChange?: ( - e: React.MouseEvent | React.KeyboardEvent, - data: { sortState: SortState }, - ) => void; - - sortState?: SortState; - - defaultSortState?: SortState; - }; +export type TableProps = ComponentProps & Partial; /** * State used in rendering Table diff --git a/packages/react-components/react-table/src/hooks/selectionManager.ts b/packages/react-components/react-table/src/hooks/selectionManager.ts index 87fa5a9b2c40ca..88ececf1230f51 100644 --- a/packages/react-components/react-table/src/hooks/selectionManager.ts +++ b/packages/react-components/react-table/src/hooks/selectionManager.ts @@ -3,12 +3,12 @@ import { SelectionMode } from './types'; type OnSelectionChangeCallback = (selectedItems: Set) => void; export interface SelectionManager { - toggleItem(id: SelectionItemId): void; - selectItem(id: SelectionItemId): void; - deselectItem(id: SelectionItemId): void; + toggleItem(id: SelectionItemId, selectedItems: Set): void; + selectItem(id: SelectionItemId, selectedItems: Set): void; + deselectItem(id: SelectionItemId, selectedItems: Set): void; clearItems(): void; - isSelected(id: SelectionItemId): boolean; - toggleAllItems(itemIds: SelectionItemId[]): void; + isSelected(id: SelectionItemId, selectedItems: Set): boolean; + toggleAllItems(itemIds: SelectionItemId[], selectedItems: Set): void; } export type SelectionItemId = string | number; @@ -23,8 +23,7 @@ export function createSelectionManager( } function createMultipleSelectionManager(onSelectionChange: OnSelectionChangeCallback): SelectionManager { - const selectedItems = new Set(); - const toggleAllItems = (itemIds: SelectionItemId[]) => { + const toggleAllItems = (itemIds: SelectionItemId[], selectedItems: Set) => { const allItemsSelected = itemIds.every(itemId => selectedItems.has(itemId)); if (allItemsSelected) { @@ -36,7 +35,7 @@ function createMultipleSelectionManager(onSelectionChange: OnSelectionChangeCall onSelectionChange(new Set(selectedItems)); }; - const toggleItem = (itemId: SelectionItemId) => { + const toggleItem = (itemId: SelectionItemId, selectedItems: Set) => { if (selectedItems.has(itemId)) { selectedItems.delete(itemId); } else { @@ -46,22 +45,21 @@ function createMultipleSelectionManager(onSelectionChange: OnSelectionChangeCall onSelectionChange(new Set(selectedItems)); }; - const selectItem = (itemId: SelectionItemId) => { + const selectItem = (itemId: SelectionItemId, selectedItems: Set) => { selectedItems.add(itemId); onSelectionChange(new Set(selectedItems)); }; - const deselectItem = (itemId: SelectionItemId) => { + const deselectItem = (itemId: SelectionItemId, selectedItems: Set) => { selectedItems.delete(itemId); onSelectionChange(new Set(selectedItems)); }; const clearItems = () => { - selectedItems.clear(); - onSelectionChange(new Set(selectedItems)); + onSelectionChange(new Set()); }; - const isSelected = (itemId: SelectionItemId) => { + const isSelected = (itemId: SelectionItemId, selectedItems: Set) => { return selectedItems.has(itemId); }; @@ -76,24 +74,20 @@ function createMultipleSelectionManager(onSelectionChange: OnSelectionChangeCall } function createSingleSelectionManager(onSelectionChange: OnSelectionChangeCallback): SelectionManager { - let selectedItem: SelectionItemId | undefined = undefined; const toggleItem = (itemId: SelectionItemId) => { - selectedItem = itemId; - onSelectionChange(new Set([selectedItem])); + onSelectionChange(new Set([itemId])); }; const clearItems = () => { - selectedItem = undefined; onSelectionChange(new Set()); }; - const isSelected = (itemId: SelectionItemId) => { - return selectedItem === itemId; + const isSelected = (itemId: SelectionItemId, selectedItems: Set) => { + return selectedItems.has(itemId); }; const selectItem = (itemId: SelectionItemId) => { - selectedItem = itemId; - onSelectionChange(new Set([selectedItem])); + onSelectionChange(new Set([itemId])); }; return { diff --git a/packages/react-components/react-table/src/hooks/types.ts b/packages/react-components/react-table/src/hooks/types.ts index 324ec6950eed79..bd5f581722b31a 100644 --- a/packages/react-components/react-table/src/hooks/types.ts +++ b/packages/react-components/react-table/src/hooks/types.ts @@ -4,6 +4,13 @@ export type RowId = string | number; export type ColumnId = string | number; export type GetRowIdInternal = (rowId: TItem, index: number) => RowId; export type SelectionMode = 'single' | 'multiselect'; +export type OnSelectionChangeCallback = (selectedItems: Set) => void; +export type OnSortChangeCallback = (state: { sortColumn: ColumnId | undefined; sortDirection: SortDirection }) => void; + +export interface SortState { + sortColumn: ColumnId | undefined; + sortDirection: SortDirection; +} export interface ColumnDefinition { columnId: ColumnId; @@ -12,10 +19,10 @@ export interface ColumnDefinition { export type RowEnhancer = RowState> = ( row: RowState, - state: { selection: SelectionState; sort: SortState }, + state: { selection: TableSelectionState; sort: TableSortState }, ) => TRowState; -export interface SortStateInternal { +export interface TableSortStateInternal { sortDirection: SortDirection; sortColumn: ColumnId | undefined; setColumnSort: (columnId: ColumnId, sortDirection: SortDirection) => void; @@ -31,11 +38,35 @@ export interface UseTableOptions = RowS columns: ColumnDefinition[]; items: TItem[]; selectionMode?: SelectionMode; + /** + * Used in uncontrolled mode to set initial selected rows on mount + */ + defaultSelectedRows?: Set; + /** + * Used to control row selection + */ + selectedRows?: Set; + /** + * Called when selection changes + */ + onSelectionChange?: OnSelectionChangeCallback; + /** + * Used to control sorting + */ + sortState?: SortState; + /** + * Used in uncontrolled mode to set initial sort column and direction on mount + */ + defaultSortState?: SortState; + /** + * Called when sort changes + */ + onSortChange?: OnSortChangeCallback; getRowId?: (item: TItem) => RowId; rowEnhancer?: RowEnhancer; } -export interface SelectionStateInternal { +export interface TableSelectionStateInternal { clearRows: () => void; deselectRow: (rowId: RowId) => void; selectRow: (rowId: RowId) => void; @@ -47,7 +78,7 @@ export interface SelectionStateInternal { someRowsSelected: boolean; } -export interface SortState { +export interface TableSortState { /** * Current sort direction */ @@ -71,7 +102,7 @@ export interface SortState { getSortDirection: (columnId: ColumnId) => SortDirection | undefined; } -export interface SelectionState { +export interface TableSelectionState { /** * Clears all selected rows */ @@ -130,9 +161,9 @@ export interface TableState = RowState< /** * State and actions to manage row selection */ - selection: SelectionState; + selection: TableSelectionState; /** * State and actions to manage row sorting */ - sort: SortState; + sort: TableSortState; } diff --git a/packages/react-components/react-table/src/hooks/useSelection.test.ts b/packages/react-components/react-table/src/hooks/useSelection.test.ts index 768a8d318b6ff0..379100bbbf5332 100644 --- a/packages/react-components/react-table/src/hooks/useSelection.test.ts +++ b/packages/react-components/react-table/src/hooks/useSelection.test.ts @@ -6,9 +6,31 @@ describe('useSelection', () => { const getRowId = (item: {}, index: number) => index; + it('should use default selected state', () => { + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, defaultSelectedItems: new Set([1]) }), + ); + + expect(Array.from(result.current.selectedRows)).toEqual([1]); + }); + + it('should use user selected state', () => { + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, selectedItems: new Set([1]) }), + ); + + expect(Array.from(result.current.selectedRows)).toEqual([1]); + }); + describe('multiselect', () => { it('should use custom row id', () => { - const { result } = renderHook(() => useSelection('multiselect', items, (item: { value: string }) => item.value)); + const { result } = renderHook(() => + useSelection({ + selectionMode: 'multiselect', + items, + getRowId: (item: { value: string }) => item.value, + }), + ); act(() => { result.current.toggleAllRows(); }); @@ -18,17 +40,25 @@ describe('useSelection', () => { describe('toggleAllRows', () => { it('should select all rows', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleAllRows(); }); expect(result.current.selectedRows.size).toBe(items.length); expect(Array.from(result.current.selectedRows)).toEqual(items.map((_, i) => i)); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(new Set([0, 1, 2, 3])); }); it('should deselect all rows', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleAllRows(); @@ -39,11 +69,16 @@ describe('useSelection', () => { }); expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); }); describe('clearRows', () => { it('should clear selection', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleAllRows(); @@ -53,22 +88,32 @@ describe('useSelection', () => { }); expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); }); describe('selectRow', () => { it('should select row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); }); expect(result.current.selectedRows.has(1)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); it('should select multiple rows', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); @@ -81,12 +126,17 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(2); expect(result.current.selectedRows.has(1)).toBe(true); expect(result.current.selectedRows.has(2)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([1, 2])); }); }); describe('deselectRow', () => { it('should make row unselected', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); @@ -97,12 +147,17 @@ describe('useSelection', () => { }); expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); }); describe('toggleRow', () => { it('should select unselected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -110,10 +165,15 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(1); expect(result.current.selectedRows.has(1)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); it('should deselect selected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -125,10 +185,15 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(0); expect(result.current.selectedRows.has(1)).toBe(false); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); it('should select another unselected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -141,12 +206,14 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(2); expect(result.current.selectedRows.has(1)).toBe(true); expect(result.current.selectedRows.has(2)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([1, 2])); }); }); describe('allRowsSelected', () => { it('should return true if all rows are selected', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); act(() => { result.current.toggleAllRows(); @@ -156,14 +223,14 @@ describe('useSelection', () => { }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); expect(result.current.selectedRows.size).toBe(0); expect(result.current.allRowsSelected).toBe(false); }); it('should return false if not all rows are selected', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); act(() => { result.current.toggleAllRows(); @@ -180,7 +247,7 @@ describe('useSelection', () => { describe('someRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); act(() => { result.current.selectRow(1); @@ -191,7 +258,7 @@ describe('useSelection', () => { }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); expect(result.current.selectedRows.size).toBe(0); expect(result.current.someRowsSelected).toBe(false); @@ -202,26 +269,37 @@ describe('useSelection', () => { describe('single select', () => { describe('toggleAllRows', () => { it('should throw when not in production', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); expect(result.current.toggleAllRows).toThrowErrorMatchingInlineSnapshot( `"[react-table]: \`toggleAllItems\` should not be used in single selection mode"`, ); + expect(onSelectionChange).toHaveBeenCalledTimes(0); }); it('should be a noop in production', () => { const nodeEnv = (process.env.NODE_ENV = 'production'); - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); result.current.toggleAllRows; expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(0); process.env.NODE_ENV = nodeEnv; }); }); describe('clearRows', () => { it('should clear selection', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); @@ -231,22 +309,32 @@ describe('useSelection', () => { }); expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); }); describe('selectRow', () => { it('should select row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); }); expect(result.current.selectedRows.has(1)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); it('should select another row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); @@ -258,12 +346,17 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(1); expect(result.current.selectedRows.has(2)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); }); describe('deselectRow', () => { it('should make row unselected', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.selectRow(1); @@ -274,12 +367,17 @@ describe('useSelection', () => { }); expect(result.current.selectedRows.size).toBe(0); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); }); describe('toggleRow', () => { it('should select unselected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -287,10 +385,15 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(1); expect(result.current.selectedRows.has(1)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); it('should deselect selected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -302,10 +405,15 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(1); expect(result.current.selectedRows.has(1)).toBe(false); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); it('should select another unselected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + ); act(() => { result.current.toggleRow(1); @@ -318,12 +426,14 @@ describe('useSelection', () => { expect(result.current.selectedRows.size).toBe(1); expect(result.current.selectedRows.has(1)).toBe(false); expect(result.current.selectedRows.has(2)).toBe(true); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); }); describe('allRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); act(() => { result.current.selectRow(1); @@ -334,7 +444,7 @@ describe('useSelection', () => { }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); expect(result.current.selectedRows.size).toBe(0); expect(result.current.allRowsSelected).toBe(false); @@ -343,7 +453,7 @@ describe('useSelection', () => { describe('someRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); act(() => { result.current.selectRow(1); @@ -354,7 +464,7 @@ describe('useSelection', () => { }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection('single', items, getRowId)); + const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); expect(result.current.selectedRows.size).toBe(0); expect(result.current.someRowsSelected).toBe(false); diff --git a/packages/react-components/react-table/src/hooks/useSelection.ts b/packages/react-components/react-table/src/hooks/useSelection.ts index cff6a47e6d2fc3..d83d59470d637a 100644 --- a/packages/react-components/react-table/src/hooks/useSelection.ts +++ b/packages/react-components/react-table/src/hooks/useSelection.ts @@ -1,38 +1,72 @@ import * as React from 'react'; -import { useEventCallback, usePrevious } from '@fluentui/react-utilities'; +import { useControllableState, useEventCallback } from '@fluentui/react-utilities'; import { createSelectionManager } from './selectionManager'; -import { GetRowIdInternal, RowId, SelectionMode, SelectionStateInternal } from './types'; - -export function useSelection( - selectionMode: SelectionMode, - items: TItem[], - getRowId: GetRowIdInternal, -): SelectionStateInternal { - const prevSelectionMode = usePrevious(selectionMode); - const [selected, setSelected] = React.useState(() => new Set()); - const [selectionManager, setSelectionManager] = React.useState(() => - createSelectionManager(selectionMode, setSelected), - ); +import type { + GetRowIdInternal, + OnSelectionChangeCallback, + RowId, + SelectionMode, + TableSelectionStateInternal, +} from './types'; + +interface UseSelectionOptions { + selectionMode: SelectionMode; + items: TItem[]; + getRowId: GetRowIdInternal; + defaultSelectedItems?: Set; + selectedItems?: Set; + onSelectionChange?: OnSelectionChangeCallback; +} - React.useEffect(() => { - if (prevSelectionMode !== selectionMode) { - setSelectionManager(createSelectionManager(selectionMode, setSelected)); - } - }, [selectionMode, prevSelectionMode]); +export function useSelection(options: UseSelectionOptions): TableSelectionStateInternal { + const { selectionMode, items, getRowId, defaultSelectedItems, selectedItems, onSelectionChange } = options; - const toggleAllRows: SelectionStateInternal['toggleAllRows'] = useEventCallback(() => { - selectionManager.toggleAllItems(items.map((item, i) => getRowId(item, i))); + const [selected, setSelected] = useControllableState({ + initialState: new Set(), + defaultState: defaultSelectedItems, + state: selectedItems, }); + const selectionManager = React.useMemo(() => { + return createSelectionManager(selectionMode, newSelectedItems => { + setSelected(() => { + onSelectionChange?.(newSelectedItems); + return newSelectedItems; + }); + }); + }, [onSelectionChange, selectionMode, setSelected]); + + const toggleAllRows: TableSelectionStateInternal['toggleAllRows'] = useEventCallback(() => { + selectionManager.toggleAllItems( + items.map((item, i) => getRowId(item, i)), + selected, + ); + }); + + const toggleRow: TableSelectionStateInternal['toggleRow'] = useEventCallback((rowId: RowId) => + selectionManager.toggleItem(rowId, selected), + ); + + const deselectRow: TableSelectionStateInternal['deselectRow'] = useEventCallback((rowId: RowId) => + selectionManager.deselectItem(rowId, selected), + ); + + const selectRow: TableSelectionStateInternal['selectRow'] = useEventCallback((rowId: RowId) => + selectionManager.selectItem(rowId, selected), + ); + + const isRowSelected: TableSelectionStateInternal['isRowSelected'] = (rowId: RowId) => + selectionManager.isSelected(rowId, selected); + return { someRowsSelected: selected.size > 0, allRowsSelected: selectionMode === 'single' ? selected.size > 0 : selected.size === items.length, selectedRows: selected, - toggleRow: selectionManager.toggleItem, + toggleRow, toggleAllRows, clearRows: selectionManager.clearItems, - deselectRow: selectionManager.deselectItem, - selectRow: selectionManager.selectItem, - isRowSelected: selectionManager.isSelected, + deselectRow, + selectRow, + isRowSelected, }; } diff --git a/packages/react-components/react-table/src/hooks/useSort.test.ts b/packages/react-components/react-table/src/hooks/useSort.test.ts index 045a1bd311656d..a28b06e5db8b92 100644 --- a/packages/react-components/react-table/src/hooks/useSort.test.ts +++ b/packages/react-components/react-table/src/hooks/useSort.test.ts @@ -3,21 +3,45 @@ import { ColumnDefinition } from './types'; import { useSort } from './useSort'; describe('useSort', () => { + it('should use default sort state', () => { + const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; + const { result } = renderHook(() => + useSort({ columns: columnDefinition, defaultSortState: { sortColumn: 2, sortDirection: 'descending' } }), + ); + + expect(result.current.getSortDirection(2)).toBe('descending'); + expect(result.current.sortColumn).toBe(2); + }); + + it('should use user sort state', () => { + const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; + const { result } = renderHook(() => + useSort({ columns: columnDefinition, sortState: { sortColumn: 2, sortDirection: 'descending' } }), + ); + + expect(result.current.getSortDirection(2)).toBe('descending'); + expect(result.current.sortColumn).toBe(2); + }); + describe('toggleColumnSort', () => { it('should sort a new column in ascending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const onSortChange = jest.fn(); + const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); act(() => { result.current.toggleColumnSort(1); }); expect(result.current.sortColumn).toBe(1); expect(result.current.sortDirection).toBe('ascending'); + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'ascending' }); }); it('should toggle sort direction on a column', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const onSortChange = jest.fn(); + const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); act(() => { result.current.toggleColumnSort(1); }); @@ -28,30 +52,38 @@ describe('useSort', () => { expect(result.current.sortColumn).toBe(1); expect(result.current.sortDirection).toBe('descending'); + expect(onSortChange).toHaveBeenCalledTimes(2); + expect(onSortChange).toHaveBeenNthCalledWith(2, { sortColumn: 1, sortDirection: 'descending' }); }); }); describe('setColumnSort', () => { it('should sort a column in ascending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const onSortChange = jest.fn(); + const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); act(() => { result.current.setColumnSort(1, 'ascending'); }); expect(result.current.sortColumn).toBe(1); expect(result.current.sortDirection).toBe('ascending'); + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'ascending' }); }); it('should sort a column in descending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const onSortChange = jest.fn(); + const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); act(() => { result.current.setColumnSort(1, 'descending'); }); expect(result.current.sortColumn).toBe(1); expect(result.current.sortDirection).toBe('descending'); + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'descending' }); }); }); @@ -65,7 +97,7 @@ describe('useSort', () => { { columnId: 3, compare: createMockCompare() }, ]; - const { result } = renderHook(() => useSort(columnDefinition)); + const { result } = renderHook(() => useSort({ columns: columnDefinition })); act(() => { result.current.toggleColumnSort(2); }); @@ -79,7 +111,7 @@ describe('useSort', () => { { columnId: 1, compare: (a, b) => a.value - b.value }, ]; - const { result } = renderHook(() => useSort(columnDefinition)); + const { result } = renderHook(() => useSort({ columns: columnDefinition })); act(() => { result.current.toggleColumnSort(1); }); @@ -93,7 +125,7 @@ describe('useSort', () => { { columnId: 1, compare: (a, b) => a.value - b.value }, ]; - const { result } = renderHook(() => useSort(columnDefinition)); + const { result } = renderHook(() => useSort({ columns: columnDefinition })); act(() => { result.current.toggleColumnSort(1); }); @@ -110,7 +142,7 @@ describe('useSort', () => { it('should return sort direction for the sorted column', () => { const columnDefinition: ColumnDefinition<{ value: number }>[] = [{ columnId: 1 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const { result } = renderHook(() => useSort({ columns: columnDefinition })); act(() => { result.current.setColumnSort(1, 'descending'); }); @@ -121,7 +153,7 @@ describe('useSort', () => { it('should return undefined for unsorted column', () => { const columnDefinition: ColumnDefinition<{ value: number }>[] = [{ columnId: 1 }, { columnId: 2 }]; - const { result } = renderHook(() => useSort(columnDefinition)); + const { result } = renderHook(() => useSort({ columns: columnDefinition })); act(() => { result.current.setColumnSort(1, 'descending'); }); diff --git a/packages/react-components/react-table/src/hooks/useSort.ts b/packages/react-components/react-table/src/hooks/useSort.ts index df79f559d9e27b..f4dec5aaf97601 100644 --- a/packages/react-components/react-table/src/hooks/useSort.ts +++ b/packages/react-components/react-table/src/hooks/useSort.ts @@ -1,11 +1,23 @@ -import * as React from 'react'; -import { SortDirection } from '../components/Table/Table.types'; -import type { ColumnDefinition, ColumnId, SortStateInternal } from './types'; - -export function useSort(columns: ColumnDefinition[]): SortStateInternal { - const [sorted, setSorted] = React.useState({ - sortDirection: 'ascending' as SortDirection, - sortColumn: undefined as ColumnId | undefined, +import { useControllableState } from '@fluentui/react-utilities'; +import type { ColumnDefinition, ColumnId, OnSortChangeCallback, SortState, TableSortStateInternal } from './types'; + +interface UseSortOptions { + columns: ColumnDefinition[]; + sortState?: SortState; + defaultSortState?: SortState; + onSortChange?: OnSortChangeCallback; +} + +export function useSort(options: UseSortOptions): TableSortStateInternal { + const { columns, sortState, defaultSortState, onSortChange } = options; + + const [sorted, setSorted] = useControllableState({ + initialState: { + sortDirection: 'ascending' as const, + sortColumn: undefined, + }, + defaultState: defaultSortState, + state: sortState, }); const { sortColumn, sortDirection } = sorted; @@ -19,12 +31,15 @@ export function useSort(columns: ColumnDefinition[]): SortStateInt newState.sortDirection = 'ascending'; } + onSortChange?.(newState); return newState; }); }; - const setColumnSort: SortStateInternal['setColumnSort'] = (nextSortColumn, nextSortDirection) => { - setSorted({ sortColumn: nextSortColumn, sortDirection: nextSortDirection }); + const setColumnSort: TableSortStateInternal['setColumnSort'] = (nextSortColumn, nextSortDirection) => { + const newState = { sortColumn: nextSortColumn, sortDirection: nextSortDirection }; + onSortChange?.(newState); + setSorted(newState); }; const sort = (items: TItem[]) => @@ -38,7 +53,7 @@ export function useSort(columns: ColumnDefinition[]): SortStateInt return sortColumnDef.compare(a, b) * mod; }); - const getSortDirection: SortStateInternal['getSortDirection'] = (columnId: ColumnId) => { + const getSortDirection: TableSortStateInternal['getSortDirection'] = (columnId: ColumnId) => { return sortColumn === columnId ? sortDirection : undefined; }; diff --git a/packages/react-components/react-table/src/hooks/useTable.ts b/packages/react-components/react-table/src/hooks/useTable.ts index e9ddf646181129..40be03b1cc9c3c 100644 --- a/packages/react-components/react-table/src/hooks/useTable.ts +++ b/packages/react-components/react-table/src/hooks/useTable.ts @@ -1,5 +1,12 @@ import * as React from 'react'; -import type { UseTableOptions, TableState, RowState, SelectionState, SortState } from './types'; +import type { + UseTableOptions, + TableState, + RowState, + TableSelectionState, + TableSortState, + GetRowIdInternal, +} from './types'; import { useSelection } from './useSelection'; import { useSort } from './useSort'; @@ -12,11 +19,25 @@ export function useTable = RowState undefined, selectionMode = 'multiselect', rowEnhancer = (row: RowState) => row as TRowState, + defaultSelectedRows, + selectedRows: userSelectedRows, + onSelectionChange, + sortState: userSortState, + defaultSortState, + onSortChange, } = options; - const getRowId = React.useCallback((item: TItem, index: number) => getUserRowId(item) ?? index, [getUserRowId]); - const { sortColumn, sortDirection, toggleColumnSort, setColumnSort, getSortDirection, sort } = useSort(columns); - const sortState: SortState = React.useMemo( + const getRowId: GetRowIdInternal = React.useCallback( + (item: TItem, index: number) => getUserRowId(item) ?? index, + [getUserRowId], + ); + const { sortColumn, sortDirection, toggleColumnSort, setColumnSort, getSortDirection, sort } = useSort({ + columns, + sortState: userSortState, + defaultSortState, + onSortChange, + }); + const sortState: TableSortState = React.useMemo( () => ({ sortColumn, sortDirection, @@ -37,9 +58,16 @@ export function useTable = RowState ({ isRowSelected, clearRows, diff --git a/packages/react-components/react-table/src/index.ts b/packages/react-components/react-table/src/index.ts index 7887435d5d6714..bf062106c3fcdb 100644 --- a/packages/react-components/react-table/src/index.ts +++ b/packages/react-components/react-table/src/index.ts @@ -1,5 +1,13 @@ export { useTable } from './hooks'; -export type { UseTableOptions, SelectionState, SortState, ColumnDefinition, RowState, RowId, ColumnId } from './hooks'; +export type { + UseTableOptions, + TableSelectionState, + TableSortState, + ColumnDefinition, + RowState, + RowId, + ColumnId, +} from './hooks'; export { TableCell, diff --git a/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx b/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx index ea99ea84b03b27..95cc79b9f02f4e 100644 --- a/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx @@ -101,6 +101,7 @@ export const MultipleSelect = () => { } = useTable({ columns, items, + defaultSelectedRows: new Set([0, 1]), rowEnhancer: (row, { selection }) => ({ ...row, onClick: () => selection.toggleRow(row.rowId), diff --git a/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx new file mode 100644 index 00000000000000..052fe68b0b88eb --- /dev/null +++ b/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { + FolderRegular, + EditRegular, + OpenRegular, + DocumentRegular, + PeopleRegular, + DocumentPdfRegular, + VideoRegular, +} from '@fluentui/react-icons'; +import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; +import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell, TableSelectionCell } from '../..'; +import { useTable, ColumnDefinition, RowId } from '../../hooks'; +import { useNavigationMode } from '../../navigationModes/useNavigationMode'; + +type FileCell = { + label: string; + icon: JSX.Element; +}; + +type LastUpdatedCell = { + label: string; + timestamp: number; +}; + +type LastUpdateCell = { + label: string; + icon: JSX.Element; +}; + +type AuthorCell = { + label: string; + status: PresenceBadgeStatus; +}; + +type Item = { + file: FileCell; + author: AuthorCell; + lastUpdated: LastUpdatedCell; + lastUpdate: LastUpdateCell; +}; + +const items: Item[] = [ + { + file: { label: 'Meeting notes', icon: }, + author: { label: 'Max Mustermann', status: 'available' }, + lastUpdated: { label: '7h ago', timestamp: 3 }, + lastUpdate: { + label: 'You edited this', + icon: , + }, + }, + { + file: { label: 'Thursday presentation', icon: }, + author: { label: 'Erika Mustermann', status: 'busy' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Training recording', icon: }, + author: { label: 'John Doe', status: 'away' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Purchase order', icon: }, + author: { label: 'Jane Doe', status: 'offline' }, + lastUpdated: { label: 'Tue at 9:30 AM', timestamp: 1 }, + lastUpdate: { + label: 'You shared this in a Teams chat', + icon: , + }, + }, +]; + +const columns: ColumnDefinition[] = [ + { + columnId: 'file', + }, + { + columnId: 'author', + }, + { + columnId: 'lastUpdated', + }, + { + columnId: 'lastUpdate', + }, +]; + +export const MultipleSelectControlled = () => { + const [selectedRows, setSelectedRows] = React.useState(new Set()); + const { + rows, + selection: { allRowsSelected, someRowsSelected, toggleAllRows }, + } = useTable({ + columns, + items, + selectedRows, + onSelectionChange: setSelectedRows, + rowEnhancer: (row, { selection }) => ({ + ...row, + onClick: () => selection.toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + selection.toggleRow(row.rowId); + } + }, + selected: selection.isRowSelected(row.rowId), + }), + }); + + // eslint-disable-next-line deprecation/deprecation + const ref = useNavigationMode('row'); + + return ( + + + + + File + Author + Last updated + Last update + + + + {rows.map(({ item, selected, onClick, onKeyDown }) => ( + + + {item.file.label} + }>{item.author.label} + {item.lastUpdated.label} + {item.lastUpdate.label} + + ))} + +
+ ); +}; diff --git a/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx b/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx index d6497811a57061..8f9f98fb7a73d1 100644 --- a/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx @@ -99,6 +99,7 @@ export const SingleSelect = () => { columns, items, selectionMode: 'single', + defaultSelectedRows: new Set([1]), rowEnhancer: (row, { selection }) => ({ ...row, selected: selection.isRowSelected(row.rowId), diff --git a/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx new file mode 100644 index 00000000000000..6f90817f93c367 --- /dev/null +++ b/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { + FolderRegular, + EditRegular, + OpenRegular, + DocumentRegular, + PeopleRegular, + DocumentPdfRegular, + VideoRegular, +} from '@fluentui/react-icons'; +import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; +import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell, TableSelectionCell } from '../..'; +import { useTable, ColumnDefinition, RowId } from '../../hooks'; +import { useNavigationMode } from '../../navigationModes/useNavigationMode'; + +type FileCell = { + label: string; + icon: JSX.Element; +}; + +type LastUpdatedCell = { + label: string; + timestamp: number; +}; + +type LastUpdateCell = { + label: string; + icon: JSX.Element; +}; + +type AuthorCell = { + label: string; + status: PresenceBadgeStatus; +}; + +type Item = { + file: FileCell; + author: AuthorCell; + lastUpdated: LastUpdatedCell; + lastUpdate: LastUpdateCell; +}; + +const items: Item[] = [ + { + file: { label: 'Meeting notes', icon: }, + author: { label: 'Max Mustermann', status: 'available' }, + lastUpdated: { label: '7h ago', timestamp: 3 }, + lastUpdate: { + label: 'You edited this', + icon: , + }, + }, + { + file: { label: 'Thursday presentation', icon: }, + author: { label: 'Erika Mustermann', status: 'busy' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Training recording', icon: }, + author: { label: 'John Doe', status: 'away' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Purchase order', icon: }, + author: { label: 'Jane Doe', status: 'offline' }, + lastUpdated: { label: 'Tue at 9:30 AM', timestamp: 1 }, + lastUpdate: { + label: 'You shared this in a Teams chat', + icon: , + }, + }, +]; + +const columns: ColumnDefinition[] = [ + { + columnId: 'file', + }, + { + columnId: 'author', + }, + { + columnId: 'lastUpdated', + }, + { + columnId: 'lastUpdate', + }, +]; + +export const SingleSelectControlled = () => { + const [selectedRows, setSelectedRows] = React.useState(new Set()); + const { rows } = useTable({ + columns, + items, + selectionMode: 'single', + selectedRows, + onSelectionChange: setSelectedRows, + rowEnhancer: (row, { selection }) => ({ + ...row, + selected: selection.isRowSelected(row.rowId), + onClick: () => selection.toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + selection.toggleRow(row.rowId); + } + }, + }), + }); + // eslint-disable-next-line deprecation/deprecation + const ref = useNavigationMode('row'); + + return ( + + + + + File + Author + Last updated + Last update + + + + {rows.map(({ item, selected, onClick, onKeyDown }) => ( + + + {item.file.label} + }>{item.author.label} + {item.lastUpdated.label} + {item.lastUpdate.label} + + ))} + +
+ ); +}; diff --git a/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx b/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx index 4196abd903ba77..61134b2301487a 100644 --- a/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx @@ -109,10 +109,12 @@ export const Sort = () => { const { rows, sort: { getSortDirection, toggleColumnSort }, - } = useTable({ columns, items }); + } = useTable({ columns, items, defaultSortState: { sortColumn: 'file', sortDirection: 'ascending' } }); - const headerSortProps = (columnId: ColumnId) => () => ({ - onClick: () => toggleColumnSort(columnId), + const headerSortProps = (columnId: ColumnId) => ({ + onClick: () => { + toggleColumnSort(columnId); + }, sortDirection: getSortDirection(columnId), }); diff --git a/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx new file mode 100644 index 00000000000000..a2eb8791b7b864 --- /dev/null +++ b/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { + FolderRegular, + EditRegular, + OpenRegular, + DocumentRegular, + PeopleRegular, + DocumentPdfRegular, + VideoRegular, +} from '@fluentui/react-icons'; +import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; +import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell } from '../..'; +import { useTable, ColumnDefinition, ColumnId, SortState } from '../../hooks'; + +type FileCell = { + label: string; + icon: JSX.Element; +}; + +type LastUpdatedCell = { + label: string; + timestamp: number; +}; + +type LastUpdateCell = { + label: string; + icon: JSX.Element; +}; + +type AuthorCell = { + label: string; + status: PresenceBadgeStatus; +}; + +type Item = { + file: FileCell; + author: AuthorCell; + lastUpdated: LastUpdatedCell; + lastUpdate: LastUpdateCell; +}; + +const items: Item[] = [ + { + file: { label: 'Meeting notes', icon: }, + author: { label: 'Max Mustermann', status: 'available' }, + lastUpdated: { label: '7h ago', timestamp: 3 }, + lastUpdate: { + label: 'You edited this', + icon: , + }, + }, + { + file: { label: 'Thursday presentation', icon: }, + author: { label: 'Erika Mustermann', status: 'busy' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Training recording', icon: }, + author: { label: 'John Doe', status: 'away' }, + lastUpdated: { label: 'Yesterday at 1:45 PM', timestamp: 2 }, + lastUpdate: { + label: 'You recently opened this', + icon: , + }, + }, + { + file: { label: 'Purchase order', icon: }, + author: { label: 'Jane Doe', status: 'offline' }, + lastUpdated: { label: 'Tue at 9:30 AM', timestamp: 1 }, + lastUpdate: { + label: 'You shared this in a Teams chat', + icon: , + }, + }, +]; + +const columns: ColumnDefinition[] = [ + { + columnId: 'file', + compare: (a, b) => { + return a.file.label.localeCompare(b.file.label); + }, + }, + { + columnId: 'author', + compare: (a, b) => { + return a.author.label.localeCompare(b.author.label); + }, + }, + { + columnId: 'lastUpdated', + compare: (a, b) => { + return a.lastUpdated.timestamp - b.lastUpdated.timestamp; + }, + }, + { + columnId: 'lastUpdate', + compare: (a, b) => { + return a.lastUpdate.label.localeCompare(b.lastUpdate.label); + }, + }, +]; + +export const SortControlled = () => { + const [sortState, setSortState] = React.useState({ + sortDirection: 'ascending' as const, + sortColumn: 'file', + }); + + const { + rows, + sort: { getSortDirection, toggleColumnSort }, + } = useTable({ columns, items, sortState, onSortChange: setSortState }); + + const headerSortProps = (columnId: ColumnId) => ({ + onClick: () => toggleColumnSort(columnId), + sortDirection: getSortDirection(columnId), + }); + + return ( + + + + File + Author + Last updated + Last update + + + + {rows.map(({ item }) => ( + + {item.file.label} + }>{item.author.label} + {item.lastUpdated.label} + {item.lastUpdate.label} + + ))} + +
+ ); +}; diff --git a/packages/react-components/react-table/src/stories/Table/index.stories.tsx b/packages/react-components/react-table/src/stories/Table/index.stories.tsx index e4081a8ded3a3e..5ca28f8f253ecb 100644 --- a/packages/react-components/react-table/src/stories/Table/index.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/index.stories.tsx @@ -2,6 +2,7 @@ import { Table } from '../..'; export { Default } from './Default.stories'; export { Sort } from './Sort.stories'; +export { SortControlled } from './SortControlled.stories'; export { CellActions } from './CellActions.stories'; export { PrimaryCell } from './PrimaryCell.stories'; export { SizeSmall } from './SizeSmall.stories'; @@ -9,6 +10,8 @@ export { SizeSmaller } from './SizeSmaller.stories'; export { NonNativeElements } from './NonNativeElements.stories'; export { MultipleSelect } from './MultipleSelect.stories'; export { SingleSelect } from './SingleSelect.stories'; +export { MultipleSelectControlled } from './MultipleSelectControlled.stories'; +export { SingleSelectControlled } from './SingleSelectControlled.stories'; export { CellNavigationMode } from './CellNavigationMode.stories'; export { RowNavigationMode } from './RowNavigationMode.stories'; export { CompositeNavigationMode } from './CompositeNavigationMode.stories';