From f619b2303fb276745f5d0e08ff5dc5f4cadf2b36 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Wed, 16 Oct 2024 10:03:56 -0400 Subject: [PATCH] feat: multiselect support --- .changeset/chilly-dingos-sneeze.md | 5 + .changeset/great-points-hide.md | 5 + .changeset/mean-candles-develop.md | 5 + .changeset/sharp-apples-love.md | 5 + .changeset/sixty-ways-leave.md | 5 + .changeset/wild-forks-unite.md | 5 + .../src/components/Table/index.tsx | 20 +- libs/design-system/src/core/Checkbox.tsx | 24 +- libs/design-system/src/index.ts | 4 + .../src/multi/MultiSelectionMenu.tsx | 65 ++++ .../src/multi/useMultiSelect.spec.tsx | 316 ++++++++++++++++++ .../src/multi/useMultiSelect.tsx | 143 ++++++++ 12 files changed, 589 insertions(+), 13 deletions(-) create mode 100644 .changeset/chilly-dingos-sneeze.md create mode 100644 .changeset/great-points-hide.md create mode 100644 .changeset/mean-candles-develop.md create mode 100644 .changeset/sharp-apples-love.md create mode 100644 .changeset/sixty-ways-leave.md create mode 100644 .changeset/wild-forks-unite.md create mode 100644 libs/design-system/src/multi/MultiSelectionMenu.tsx create mode 100644 libs/design-system/src/multi/useMultiSelect.spec.tsx create mode 100644 libs/design-system/src/multi/useMultiSelect.tsx diff --git a/.changeset/chilly-dingos-sneeze.md b/.changeset/chilly-dingos-sneeze.md new file mode 100644 index 000000000..a331aa457 --- /dev/null +++ b/.changeset/chilly-dingos-sneeze.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Checkbox now supports an indeterminate state. diff --git a/.changeset/great-points-hide.md b/.changeset/great-points-hide.md new file mode 100644 index 000000000..1b7391538 --- /dev/null +++ b/.changeset/great-points-hide.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added useMultiSelect hook that tracks multiselect state. It supports selection, shift-selecting for ranges, deselection, and works across pagination. diff --git a/.changeset/mean-candles-develop.md b/.changeset/mean-candles-develop.md new file mode 100644 index 000000000..1ec5fee11 --- /dev/null +++ b/.changeset/mean-candles-develop.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Added MultiSelectMenu. The component can be used along with useMultiSelect for batch menus. diff --git a/.changeset/sharp-apples-love.md b/.changeset/sharp-apples-love.md new file mode 100644 index 000000000..ce1f3c7c7 --- /dev/null +++ b/.changeset/sharp-apples-love.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Checkbox light mode background color is now white. diff --git a/.changeset/sixty-ways-leave.md b/.changeset/sixty-ways-leave.md new file mode 100644 index 000000000..01dc02e94 --- /dev/null +++ b/.changeset/sixty-ways-leave.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Table column sort icons are now chevrons to differentiate from context menus which often use carets. diff --git a/.changeset/wild-forks-unite.md b/.changeset/wild-forks-unite.md new file mode 100644 index 000000000..1afc6e1cf --- /dev/null +++ b/.changeset/wild-forks-unite.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Table now supports custom column heading components. diff --git a/libs/design-system/src/components/Table/index.tsx b/libs/design-system/src/components/Table/index.tsx index 3af57d12c..851888dca 100644 --- a/libs/design-system/src/components/Table/index.tsx +++ b/libs/design-system/src/components/Table/index.tsx @@ -5,7 +5,7 @@ import { Panel } from '../../core/Panel' import { Text } from '../../core/Text' import { useCallback, useMemo } from 'react' import { cx } from 'class-variance-authority' -import { CaretDown16, CaretUp16 } from '@siafoundation/react-icons' +import { ChevronDown16, ChevronUp16 } from '@siafoundation/react-icons' import { times } from '@technically/lodash' import { DndContext, @@ -42,6 +42,7 @@ export type TableColumn = { id: Columns label: string icon?: React.ReactNode + heading?: React.FC<{ context: Context }> tip?: string size?: number | string cellClassName?: string @@ -208,7 +209,15 @@ export function Table< {columns.map( ( - { id, icon, label, tip, cellClassName, contentClassName }, + { + id, + icon, + heading: Heading, + label, + tip, + cellClassName, + contentClassName, + }, i ) => { const isSortable = @@ -236,6 +245,7 @@ export function Table< isSortable ? 'cursor-pointer' : '' )} > + {Heading ? : null} {sortDirection === 'asc' ? ( - + ) : ( - + )} )} {isSortable && !isSortActive && ( - + )} diff --git a/libs/design-system/src/core/Checkbox.tsx b/libs/design-system/src/core/Checkbox.tsx index d6362a169..b07b85ea5 100644 --- a/libs/design-system/src/core/Checkbox.tsx +++ b/libs/design-system/src/core/Checkbox.tsx @@ -2,7 +2,7 @@ import React from 'react' import * as CheckboxPrimitive from '@radix-ui/react-checkbox' -import { Checkmark16 } from '@siafoundation/react-icons' +import { Checkmark16, Subtract16 } from '@siafoundation/react-icons' import { Text } from './Text' import { cva } from 'class-variance-authority' import { VariantProps } from '../types' @@ -14,7 +14,7 @@ const styles = cva( 'focus:ring ring-blue-500 dark:ring-blue-200', 'border', - 'bg-gray-300 dark:bg-graydark-50', + 'bg-white dark:bg-graydark-50', 'autofill:bg-blue-100 autofill:dark:bg-blue-800', 'border-gray-400 dark:border-graydark-400', 'enabled:hover:border-gray-500 enabled:hover:dark:border-graydark-500', @@ -39,13 +39,21 @@ const styles = cva( export const Checkbox = React.forwardRef< React.ElementRef, VariantProps & CheckboxPrimitive.CheckboxProps ->(({ size, children, ...props }, ref) => ( -
+>(({ size, children, ...props }, ref) => { + const el = ( - + {props.checked === 'indeterminate' ? : } - {children} -
-)) + ) + if (!children) { + return el + } + return ( +
+ {el} + {children} +
+ ) +}) diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts index a2f32d054..741922b55 100644 --- a/libs/design-system/src/index.ts +++ b/libs/design-system/src/index.ts @@ -166,6 +166,10 @@ export * from './hooks/useDatasetEmptyState' export * from './hooks/useSiacoinFiat' export * from './hooks/useOS' +// multi +export * from './multi/useMultiSelect' +export * from './multi/MultiSelectionMenu' + // data export * from './data/webLinks' diff --git a/libs/design-system/src/multi/MultiSelectionMenu.tsx b/libs/design-system/src/multi/MultiSelectionMenu.tsx new file mode 100644 index 000000000..6e12d1594 --- /dev/null +++ b/libs/design-system/src/multi/MultiSelectionMenu.tsx @@ -0,0 +1,65 @@ +'use client' + +import { motion, AnimatePresence } from 'framer-motion' +import { Button } from '../core/Button' +import { Panel } from '../core/Panel' +import { Text } from '../core/Text' +import { pluralize } from '@siafoundation/units' +import { Close16 } from '@siafoundation/react-icons' + +export function MultiSelectionMenu({ + isVisible, + selectionCount, + isPageAllSelected, + deselectAll, + pageCount, + children, + entityWord, + entityWordPlural, +}: { + isVisible: boolean + selectionCount: number + isPageAllSelected: boolean | 'indeterminate' + pageCount: number + children: React.ReactNode + deselectAll: () => void + entityWord: string + entityWordPlural?: string +}) { + return ( +
+ + {isVisible && ( + + + {!!selectionCount && ( + + {pluralize(selectionCount, entityWord, { + plural: entityWordPlural, + })}{' '} + selected + + )} + {isPageAllSelected && selectionCount > pageCount && ( + across multiple pages + )} +
+ {children} + + + + )} + +
+ ) +} diff --git a/libs/design-system/src/multi/useMultiSelect.spec.tsx b/libs/design-system/src/multi/useMultiSelect.spec.tsx new file mode 100644 index 000000000..15b8c7204 --- /dev/null +++ b/libs/design-system/src/multi/useMultiSelect.spec.tsx @@ -0,0 +1,316 @@ +import { renderHook, act } from '@testing-library/react' +import { useMultiSelect } from './useMultiSelect' +import { MouseEvent } from 'react' + +interface Item { + id: string +} + +describe('useMultiSelect hook', () => { + const dataset: Item[] = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, + ] + + test('should select an item when onSelect is called', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('1', mockEvent) + }) + + expect(result.current.selectionMap).toHaveProperty('1') + expect(result.current.selectionCount).toBe(1) + }) + + test('should deselect an item when onSelect is called on a selected item', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('1', mockEvent) + result.current.onSelect('1', mockEvent) + }) + + expect(result.current.selectionMap).not.toHaveProperty('1') + expect(result.current.selectionCount).toBe(0) + }) + + test('should select a range of items when shiftKey is held', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + const firstClickEvent = { + shiftKey: false, + } as MouseEvent + const shiftClickEvent = { + shiftKey: true, + } as MouseEvent + + result.current.onSelect('2', firstClickEvent) + result.current.onSelect('4', shiftClickEvent) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '3', '4']) + expect(result.current.selectionCount).toBe(3) + }) + + test('should select all items on the page when onSelectPage is called', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + result.current.onSelectPage() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.selectionCount).toBe(5) + expect(result.current.isPageAllSelected).toBe(true) + }) + + test('should deselect all items on the page when onSelectPage is called again', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + result.current.onSelectPage() + result.current.onSelectPage() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([]) + expect(result.current.selectionCount).toBe(0) + expect(result.current.isPageAllSelected).toBe(false) + }) + + test('should return indeterminate when some items are selected', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('1', mockEvent) + result.current.onSelect('3', mockEvent) + }) + + expect(result.current.isPageAllSelected).toBe('indeterminate') + }) + + test('should deselect specific items when deselect is called', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + result.current.onSelectPage() + result.current.deselect(['2', '4']) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['1', '3', '5']) + expect(result.current.selectionCount).toBe(3) + }) + + test('should deselect all items when deselectAll is called', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + result.current.onSelectPage() + result.current.deselectAll() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([]) + expect(result.current.selectionCount).toBe(0) + expect(result.current.isPageAllSelected).toBe(false) + }) + + test('should handle shift-click selection upwards', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + + act(() => { + const firstClickEvent = { + shiftKey: false, + } as MouseEvent + const shiftClickEvent = { + shiftKey: true, + } as MouseEvent + + result.current.onSelect('4', firstClickEvent) + result.current.onSelect('2', shiftClickEvent) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '3', '4']) + expect(result.current.selectionCount).toBe(3) + }) +}) + +describe('useMultiSelect hook across pagination', () => { + // Full dataset across all pages. + const fullDataset: Item[] = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, + { id: '6' }, + { id: '7' }, + { id: '8' }, + { id: '9' }, + { id: '10' }, + ] + + // Simulated pages. + const pageSize = 5 + const page1 = fullDataset.slice(0, pageSize) + const page2 = fullDataset.slice(pageSize) + + test('should preserve selections across pages', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { + initialProps: { dataset: page1 }, + } + ) + + // Select items on page 1. + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('2', mockEvent) + result.current.onSelect('4', mockEvent) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4']) + expect(result.current.selectionCount).toBe(2) + expect(result.current.isPageAllSelected).toBe('indeterminate') + + // Move to page 2. + rerender({ dataset: page2 }) + + // Selections from page 1 should persist. + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4']) + expect(result.current.selectionCount).toBe(2) + + // Select items on page 2. + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('7', mockEvent) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4', '7']) + expect(result.current.selectionCount).toBe(3) + expect(result.current.isPageAllSelected).toBe('indeterminate') + }) + + test('onSelectPage should select/deselect items only on the current page', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { + initialProps: { dataset: page1 }, + } + ) + + // Select all items on page 1. + act(() => { + result.current.onSelectPage() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.selectionCount).toBe(5) + expect(result.current.isPageAllSelected).toBe(true) + + // Move to page 2. + rerender({ dataset: page2 }) + + // Page 2 items should not be selected. + expect(result.current.isPageAllSelected).toBe(false) + + // Select all items on page 2. + act(() => { + result.current.onSelectPage() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + ]) + expect(result.current.selectionCount).toBe(10) + expect(result.current.isPageAllSelected).toBe(true) + + // Deselect all items on page 2. + act(() => { + result.current.onSelectPage() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.selectionCount).toBe(5) + expect(result.current.isPageAllSelected).toBe(false) + + // Move back to page 1. + rerender({ dataset: page1 }) + + expect(result.current.isPageAllSelected).toBe(true) + }) + + test('deselectAll should clear selections across all pages', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { + initialProps: { dataset: page1 }, + } + ) + + // Select items on page 1. + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('2', mockEvent) + }) + + // Move to page 2. + rerender({ dataset: page2 }) + + // Select items on page 2. + act(() => { + const mockEvent = { shiftKey: false } as MouseEvent + result.current.onSelect('7', mockEvent) + }) + + expect(Object.keys(result.current.selectionMap)).toEqual(['2', '7']) + expect(result.current.selectionCount).toBe(2) + + // Deselect all selections. + act(() => { + result.current.deselectAll() + }) + + expect(Object.keys(result.current.selectionMap)).toEqual([]) + expect(result.current.selectionCount).toBe(0) + expect(result.current.isPageAllSelected).toBe(false) + + // Move back to page 1 and check selections. + rerender({ dataset: page1 }) + expect(result.current.isPageAllSelected).toBe(false) + }) +}) diff --git a/libs/design-system/src/multi/useMultiSelect.tsx b/libs/design-system/src/multi/useMultiSelect.tsx new file mode 100644 index 000000000..54ba4a182 --- /dev/null +++ b/libs/design-system/src/multi/useMultiSelect.tsx @@ -0,0 +1,143 @@ +'use client' + +import { MouseEvent, useCallback, useMemo, useState } from 'react' + +export function useMultiSelect(dataset?: Item[]) { + const [selectionMap, setSelectionMap] = useState>({}) + const [, setLastSelectedItem] = useState<{ + item: Item + index: number + }>() + + const onSelect = useCallback( + (id: string, e: MouseEvent) => { + if (!dataset) { + return + } + const selectedItem = dataset.find((datum) => datum.id === id) + const selectedIndex = dataset.findIndex((datum) => datum.id === id) + if (!selectedItem || selectedIndex === -1) { + return + } + const selected = { + item: selectedItem, + index: selectedIndex, + } + + setSelectionMap((prevSelectionMap) => { + const newSelection = { ...prevSelectionMap } + setLastSelectedItem((prevSelection) => { + // If shift click, select all items between current and last selection indices. + if (e.shiftKey && prevSelection) { + if (prevSelection.index < selected.index) { + for (let i = prevSelection.index; i <= selected.index; i++) { + const item = dataset[i] + newSelection[item.id] = item + } + } else { + for (let i = selected.index; i <= prevSelection.index; i++) { + const item = dataset[i] + newSelection[item.id] = item + } + } + return selected // Update prevSelection + } + // If no shift click, just select or deselect the current item. + if (newSelection[selected.item.id]) { + delete newSelection[selected.item.id] + return undefined // Reset prevSelection + } else { + newSelection[selected.item.id] = selected.item + return selected // Update prevSelection + } + }) + return newSelection + }) + }, + [dataset] + ) + + const isPageAllSelected = useMemo(() => { + return getIsPageAllSelected({ dataset, selectionMap }) + }, [dataset, selectionMap]) + + const onSelectPage = useCallback(() => { + if (!dataset) { + return + } + setSelectionMap((prevSelectionMap) => { + const newSelection: Record = { + ...prevSelectionMap, + } + const isPageAllSelected = getIsPageAllSelected({ + dataset, + selectionMap: prevSelectionMap, + }) + // If not all items are selected, add all the items. + if ( + isPageAllSelected === false || + isPageAllSelected === 'indeterminate' + ) { + dataset.forEach((datum) => { + newSelection[datum.id] = datum + }) + } else { + // If all items are selected, remove all the items. + dataset.forEach((datum) => { + delete newSelection[datum.id] + }) + } + return newSelection + }) + }, [dataset]) + + const deselect = useCallback((ids: string[]) => { + setSelectionMap((prevSelectionMap) => { + const newSelection: Record = { + ...prevSelectionMap, + } + ids.forEach((id) => { + delete newSelection[id] + }) + return newSelection + }) + }, []) + + const deselectAll = useCallback(() => { + setSelectionMap({}) + }, []) + + const selectionCount = useMemo( + () => Object.keys(selectionMap).length, + [selectionMap] + ) + + return { + onSelect, + onSelectPage, + selectionMap, + isPageAllSelected, + selectionCount, + deselect, + deselectAll, + } +} + +function getIsPageAllSelected({ + dataset, + selectionMap, +}: { + dataset?: Item[] + selectionMap: Record +}) { + if (!dataset) { + return false + } + if (dataset.every((datum) => selectionMap[datum.id])) { + return true + } + if (dataset.some((datum) => selectionMap[datum.id])) { + return 'indeterminate' as const + } + return false +}