From c76362af6bac194c96a9d97a860fa10aa7d7b1e8 Mon Sep 17 00:00:00 2001 From: a7591 Date: Fri, 23 May 2025 00:39:00 +0900 Subject: [PATCH 1/5] [@mantine/hooks] feat: Init useSelection hook --- .../src/mdx/data/mdx-hooks-data.ts | 1 + apps/mantine.dev/src/mdx/mdx-nav-data.ts | 1 + .../src/pages/hooks/use-selection.mdx | 30 ++++ packages/@docs/demos/src/demos/hooks/index.ts | 1 + .../demos/hooks/use-selection.demo.usage.tsx | 155 ++++++++++++++++++ packages/@mantine/hooks/src/index.ts | 2 + .../src/use-selection/use-selection.test.ts | 139 ++++++++++++++++ .../hooks/src/use-selection/use-selection.ts | 62 +++++++ 8 files changed, 391 insertions(+) create mode 100644 apps/mantine.dev/src/pages/hooks/use-selection.mdx create mode 100644 packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx create mode 100644 packages/@mantine/hooks/src/use-selection/use-selection.test.ts create mode 100644 packages/@mantine/hooks/src/use-selection/use-selection.ts diff --git a/apps/mantine.dev/src/mdx/data/mdx-hooks-data.ts b/apps/mantine.dev/src/mdx/data/mdx-hooks-data.ts index 7af0332e9bc..8c8e2cd4e0c 100644 --- a/apps/mantine.dev/src/mdx/data/mdx-hooks-data.ts +++ b/apps/mantine.dev/src/mdx/data/mdx-hooks-data.ts @@ -188,4 +188,5 @@ export const MDX_HOOKS_DATA: Record = { 'Track scroll position and detect which heading is currently in the viewport, can be used for table of contents' ), useFileDialog: hDocs('useFileDialog', 'Capture one or more files from the user'), + useSelection: hDocs('useSelection', 'Manages selection state of given dataset'), }; diff --git a/apps/mantine.dev/src/mdx/mdx-nav-data.ts b/apps/mantine.dev/src/mdx/mdx-nav-data.ts index 043a18c585c..1556d2064fe 100644 --- a/apps/mantine.dev/src/mdx/mdx-nav-data.ts +++ b/apps/mantine.dev/src/mdx/mdx-nav-data.ts @@ -182,6 +182,7 @@ const HOOKS_PAGES_GROUP: MdxPagesCategory[] = sortCategoriesPages([ MDX_DATA.useStateHistory, MDX_DATA.useMap, MDX_DATA.useSet, + MDX_DATA.useSelection, ], }, diff --git a/apps/mantine.dev/src/pages/hooks/use-selection.mdx b/apps/mantine.dev/src/pages/hooks/use-selection.mdx new file mode 100644 index 00000000000..409171218a7 --- /dev/null +++ b/apps/mantine.dev/src/pages/hooks/use-selection.mdx @@ -0,0 +1,30 @@ +import { HooksDemos } from '@docs/demos'; +import { Layout } from '@/layout'; +import { MDX_DATA } from '@/mdx'; + +export default Layout(MDX_DATA.useSelection); + +## Usage + +`use-selection` hook manages **selection state** for a list of items. +It provides a flexible API to `select`, `deselect`, or `toggle` individual items, +and offers convenient flags like `isAllSelected` and `isSomeSelected` to reflect the current selection status. +This hook is ideal for implementing features like selectable lists, data grid row selection, or multi-select components. + + + +## Definition + +```tsx +function useSelection( + dataset: T[], +): readonly [T[], { + select: (selection: T) => void, + deselect: (selection: T) => void, + toggle: (selection: T) => void, + isAllSelected: boolean, + isSomeSelected: boolean, + setSelection: (selection: T[]) => void, + resetSelection: () => void +}]; +``` diff --git a/packages/@docs/demos/src/demos/hooks/index.ts b/packages/@docs/demos/src/demos/hooks/index.ts index 413e91197b1..5949173eb1e 100644 --- a/packages/@docs/demos/src/demos/hooks/index.ts +++ b/packages/@docs/demos/src/demos/hooks/index.ts @@ -69,3 +69,4 @@ export { useRadialMoveUsage } from './use-radial-move.demo.usage'; export { useScrollSpyUsage } from './use-scroll-spy.demo.usage'; export { useScrollSpySelector } from './use-scroll-spy.demo.selector'; export { useFileDialogUsage } from './use-file-dialog.demo'; +export { useSelectionDemo } from './use-selection.demo.usage'; diff --git a/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx b/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx new file mode 100644 index 00000000000..663b76d9fb8 --- /dev/null +++ b/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx @@ -0,0 +1,155 @@ +import { Checkbox, Table } from '@mantine/core'; +import { useSelection } from '@mantine/hooks'; +import { MantineDemo } from '@mantinex/demo'; + +const code = ` +import { Checkbox, Table } from '@mantine/core'; +import { useSelection } from '@mantine/hooks'; + +const elements = [ + { position: 6, mass: 12.011, symbol: 'C', name: 'Carbon' }, + { position: 7, mass: 14.007, symbol: 'N', name: 'Nitrogen' }, + { position: 39, mass: 88.906, symbol: 'Y', name: 'Yttrium' }, + { position: 56, mass: 137.33, symbol: 'Ba', name: 'Barium' }, + { position: 58, mass: 140.12, symbol: 'Ce', name: 'Cerium' } +]; + +function Demo() { + const [selection, handlers] = useSelection(elements) + + const rows = elements.map((element) => { + const isSelected = selection.includes(element) + return ( + + + { + if (event.target.checked) { + handlers.select(element) + } else { + handlers.deselect(element) + } + }} + /> + + {element.position} + {element.name} + {element.symbol} + {element.mass} + + ) + }); + + return ( + + + + + { + if (handlers.isSomeSelected) { + handlers.resetSelection() + } else if (event.target.checked) { + handlers.setSelection(elements) + } else { + handlers.resetSelection() + } + }} + /> + + Element position + Element name + Symbol + Atomic mass + + + {rows} +
+ ); +} +` + + +const elements = [ + { position: 6, mass: 12.011, symbol: 'C', name: 'Carbon' }, + { position: 7, mass: 14.007, symbol: 'N', name: 'Nitrogen' }, + { position: 39, mass: 88.906, symbol: 'Y', name: 'Yttrium' }, + { position: 56, mass: 137.33, symbol: 'Ba', name: 'Barium' }, + { position: 58, mass: 140.12, symbol: 'Ce', name: 'Cerium' }, +]; + +function Demo() { + const [selection, handlers] = useSelection(elements) + + const rows = elements.map((element) => { + const isSelected = selection.includes(element) + return ( + + + { + if (event.target.checked) { + handlers.select(element) + } else { + handlers.deselect(element) + } + }} + /> + + {element.position} + {element.name} + {element.symbol} + {element.mass} + + ) + }); + + return ( + + + + + { + if (handlers.isSomeSelected) { + handlers.resetSelection() + } else if (event.target.checked) { + handlers.setSelection(elements) + } else { + handlers.resetSelection() + } + }} + /> + + Element position + Element name + Symbol + Atomic mass + + + {rows} +
+ ); +} + +export const useSelectionDemo: MantineDemo = { + type: 'code', + component: Demo, + code, +} diff --git a/packages/@mantine/hooks/src/index.ts b/packages/@mantine/hooks/src/index.ts index b4356fd73f0..69a725aa481 100644 --- a/packages/@mantine/hooks/src/index.ts +++ b/packages/@mantine/hooks/src/index.ts @@ -75,6 +75,7 @@ export { useFetch } from './use-fetch/use-fetch'; export { useRadialMove, normalizeRadialValue } from './use-radial-move/use-radial-move'; export { useScrollSpy } from './use-scroll-spy/use-scroll-spy'; export { useFileDialog } from './use-file-dialog/use-file-dialog'; +export { useSelection } from './use-selection/use-selection'; export type { UseMovePosition } from './use-move/use-move'; export type { OS } from './use-os/use-os'; @@ -89,3 +90,4 @@ export type { UseScrollSpyHeadingData, UseScrollSpyReturnType, } from './use-scroll-spy/use-scroll-spy'; + diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts new file mode 100644 index 00000000000..950c32ec44f --- /dev/null +++ b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts @@ -0,0 +1,139 @@ +import { act, renderHook } from '@testing-library/react'; +import { useSelection } from './use-selection'; // Assuming your hook is in use-selection.ts + +describe('@mantine/hooks/use-selection', () => { + + // Test case for initial state with an empty data array + it('correctly returns initial state for an empty data array', () => { + const data: number[] = []; + const { result } = renderHook(() => useSelection(data)); + + expect(result.current[0]).toEqual([]); // selection should be empty + expect(result.current[1].isAllSelected).toBe(true); // all 0 items selected from 0 + expect(result.current[1].isSomeSelected).toBe(false); // no items selected + }); + + // Test case for initial state with a non-empty data array + it('correctly returns initial state for a non-empty data array', () => { + const data = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data)); + + expect(result.current[0]).toEqual([]); // selection should be empty + expect(result.current[1].isAllSelected).toBe(false); // not all selected + expect(result.current[1].isSomeSelected).toBe(false); // no items selected + }); + + // Test case for 'select' functionality + it('correctly selects an item', () => { + const data = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data)); + + act(() => result.current[1].select(1)); + expect(result.current[0]).toEqual([1]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + + // Attempt to select the same item again, state should not change + act(() => result.current[1].select(1)); + expect(result.current[0]).toEqual([1]); + }); + + // Test case for 'deselect' functionality + it('correctly deselects an item', () => { + const data = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data)); + + // First select some items + act(() => { + result.current[1].select(1); + result.current[1].select(2); + }); + expect(result.current[0]).toEqual([1, 2]); + + // Deselect an item + act(() => result.current[1].deselect(1)); + expect(result.current[0]).toEqual([2]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + + // Attempt to deselect an item not currently selected, state should not change + act(() => result.current[1].deselect(1)); + expect(result.current[0]).toEqual([2]); + }); + + // Test case for 'toggle' functionality + it('correctly toggles an item', () => { + const data = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data)); + + // Toggle to select + act(() => result.current[1].toggle(1)); + expect(result.current[0]).toEqual([1]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + + // Toggle to deselect + act(() => result.current[1].toggle(1)); + expect(result.current[0]).toEqual([]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); + + // Toggle another item to select + act(() => result.current[1].toggle(2)); + expect(result.current[0]).toEqual([2]); + }); + + // Test case for 'resetSelection' functionality + it('correctly resets the selection', () => { + const data = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data)); + + act(() => { + result.current[1].select(1); + result.current[1].select(2); + }); + expect(result.current[0]).toEqual([1, 2]); + expect(result.current[1].isSomeSelected).toBe(true); + + act(() => result.current[1].resetSelection()); + expect(result.current[0]).toEqual([]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); + }); + + // Test case for isAllSelected when all items are selected + it('isAllSelected is true when all items are selected', () => { + const data = ['a', 'b', 'c']; // Using strings to show generic T + const { result } = renderHook(() => useSelection(data)); + + act(() => { + result.current[1].select('a'); + result.current[1].select('b'); + result.current[1].select('c'); + }); + expect(result.current[0]).toEqual(['a', 'b', 'c']); + expect(result.current[1].isAllSelected).toBe(true); + expect(result.current[1].isSomeSelected).toBe(true); + }); + + // Test case for setSelection directly + it('allows direct setting of selection via setSelection', () => { + const data = [10, 20, 30]; + const { result } = renderHook(() => useSelection(data)); + + act(() => result.current[1].setSelection([10, 30])); + expect(result.current[0]).toEqual([10, 30]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + + act(() => result.current[1].setSelection([])); + expect(result.current[0]).toEqual([]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); + + act(() => result.current[1].setSelection([10, 20, 30])); + expect(result.current[0]).toEqual([10, 20, 30]); + expect(result.current[1].isAllSelected).toBe(true); + expect(result.current[1].isSomeSelected).toBe(true); + }); +}); diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.ts b/packages/@mantine/hooks/src/use-selection/use-selection.ts new file mode 100644 index 00000000000..906b0154975 --- /dev/null +++ b/packages/@mantine/hooks/src/use-selection/use-selection.ts @@ -0,0 +1,62 @@ +import {useCallback, useMemo, useState} from "react"; + +export const useSelection = (data: Readonly) => { + const [selection, setSelection] = useState>([]) + + const select = useCallback((selected: T) => { + setSelection(state => { + if (!state.includes(selected)) { + return [...state, selected] + } + + return state + }) + }, [setSelection]) + + const deselect = useCallback((deselected: T) => { + setSelection(state => { + if (state.includes(deselected)) { + return state.filter(v => v !== deselected) + } + + return state + }) + }, [setSelection]) + + const toggle = useCallback((toggled: T) => { + setSelection(state => { + if (!state.includes(toggled)) { + return [...state, toggled] + } + + return state.filter(value => value !== toggled) + }) + }, [setSelection]) + + const resetSelection = useCallback(() => { + setSelection([]) + }, [setSelection]) + + const isAllSelected = useMemo(() => { + if (data.length === 0) { + return false + } + + return selection.length === data.length + }, [selection, data]) + + const isSomeSelected = useMemo(() => { + + return selection.length > 0 + }, [selection]) + + return [selection, { + select, + deselect, + toggle, + isAllSelected, + isSomeSelected, + setSelection, + resetSelection + }] as const +} From 073a058cb8ad5479f02d041ef32e314d78505b3b Mon Sep 17 00:00:00 2001 From: a7591 Date: Fri, 23 May 2025 08:29:55 +0900 Subject: [PATCH 2/5] [@mantine/hooks] fix formating --- .../demos/hooks/use-selection.demo.usage.tsx | 26 ++-- packages/@mantine/hooks/src/index.ts | 1 - .../src/use-selection/use-selection.test.ts | 1 - .../hooks/src/use-selection/use-selection.ts | 111 ++++++++++-------- 4 files changed, 72 insertions(+), 67 deletions(-) diff --git a/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx b/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx index 663b76d9fb8..2e5aba5e920 100644 --- a/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx +++ b/packages/@docs/demos/src/demos/hooks/use-selection.demo.usage.tsx @@ -75,8 +75,7 @@ function Demo() { ); } -` - +`; const elements = [ { position: 6, mass: 12.011, symbol: 'C', name: 'Carbon' }, @@ -87,24 +86,21 @@ const elements = [ ]; function Demo() { - const [selection, handlers] = useSelection(elements) + const [selection, handlers] = useSelection(elements); const rows = elements.map((element) => { - const isSelected = selection.includes(element) + const isSelected = selection.includes(element); return ( - + { if (event.target.checked) { - handlers.select(element) + handlers.select(element); } else { - handlers.deselect(element) + handlers.deselect(element); } }} /> @@ -114,7 +110,7 @@ function Demo() { {element.symbol} {element.mass} - ) + ); }); return ( @@ -128,11 +124,11 @@ function Demo() { checked={handlers.isAllSelected} onChange={(event) => { if (handlers.isSomeSelected) { - handlers.resetSelection() + handlers.resetSelection(); } else if (event.target.checked) { - handlers.setSelection(elements) + handlers.setSelection(elements); } else { - handlers.resetSelection() + handlers.resetSelection(); } }} /> @@ -152,4 +148,4 @@ export const useSelectionDemo: MantineDemo = { type: 'code', component: Demo, code, -} +}; diff --git a/packages/@mantine/hooks/src/index.ts b/packages/@mantine/hooks/src/index.ts index 69a725aa481..af208d4af06 100644 --- a/packages/@mantine/hooks/src/index.ts +++ b/packages/@mantine/hooks/src/index.ts @@ -90,4 +90,3 @@ export type { UseScrollSpyHeadingData, UseScrollSpyReturnType, } from './use-scroll-spy/use-scroll-spy'; - diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts index 950c32ec44f..4f409e2679e 100644 --- a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts +++ b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts @@ -2,7 +2,6 @@ import { act, renderHook } from '@testing-library/react'; import { useSelection } from './use-selection'; // Assuming your hook is in use-selection.ts describe('@mantine/hooks/use-selection', () => { - // Test case for initial state with an empty data array it('correctly returns initial state for an empty data array', () => { const data: number[] = []; diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.ts b/packages/@mantine/hooks/src/use-selection/use-selection.ts index 906b0154975..87ceec316ef 100644 --- a/packages/@mantine/hooks/src/use-selection/use-selection.ts +++ b/packages/@mantine/hooks/src/use-selection/use-selection.ts @@ -1,62 +1,73 @@ -import {useCallback, useMemo, useState} from "react"; +import { useCallback, useMemo, useState } from 'react'; export const useSelection = (data: Readonly) => { - const [selection, setSelection] = useState>([]) - - const select = useCallback((selected: T) => { - setSelection(state => { - if (!state.includes(selected)) { - return [...state, selected] - } - - return state - }) - }, [setSelection]) - - const deselect = useCallback((deselected: T) => { - setSelection(state => { - if (state.includes(deselected)) { - return state.filter(v => v !== deselected) - } - - return state - }) - }, [setSelection]) - - const toggle = useCallback((toggled: T) => { - setSelection(state => { - if (!state.includes(toggled)) { - return [...state, toggled] - } - - return state.filter(value => value !== toggled) - }) - }, [setSelection]) + const [selection, setSelection] = useState>([]); + + const select = useCallback( + (selected: T) => { + setSelection((state) => { + if (!state.includes(selected)) { + return [...state, selected]; + } + + return state; + }); + }, + [setSelection] + ); + + const deselect = useCallback( + (deselected: T) => { + setSelection((state) => { + if (state.includes(deselected)) { + return state.filter((v) => v !== deselected); + } + + return state; + }); + }, + [setSelection] + ); + + const toggle = useCallback( + (toggled: T) => { + setSelection((state) => { + if (!state.includes(toggled)) { + return [...state, toggled]; + } + + return state.filter((value) => value !== toggled); + }); + }, + [setSelection] + ); const resetSelection = useCallback(() => { - setSelection([]) - }, [setSelection]) + setSelection([]); + }, [setSelection]); const isAllSelected = useMemo(() => { if (data.length === 0) { - return false + return false; } - return selection.length === data.length - }, [selection, data]) + return selection.length === data.length; + }, [selection, data]); const isSomeSelected = useMemo(() => { + return selection.length > 0; + }, [selection]); - return selection.length > 0 - }, [selection]) - - return [selection, { - select, - deselect, - toggle, - isAllSelected, - isSomeSelected, - setSelection, - resetSelection - }] as const -} + return [ + selection, + { + select, + deselect, + toggle, + isAllSelected, + isSomeSelected, + setSelection, + resetSelection, + }, + ] as const; +}; From 6972fec4f2d865be6f0d9c642cd8c81930ec6e44 Mon Sep 17 00:00:00 2001 From: a7591 Date: Fri, 23 May 2025 09:01:38 +0900 Subject: [PATCH 3/5] [@mantine/hooks] fix use-selection.test.ts --- .../@mantine/hooks/src/use-selection/use-selection.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts index 4f409e2679e..4ce0b9dfd81 100644 --- a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts +++ b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts @@ -7,9 +7,9 @@ describe('@mantine/hooks/use-selection', () => { const data: number[] = []; const { result } = renderHook(() => useSelection(data)); - expect(result.current[0]).toEqual([]); // selection should be empty - expect(result.current[1].isAllSelected).toBe(true); // all 0 items selected from 0 - expect(result.current[1].isSomeSelected).toBe(false); // no items selected + expect(result.current[0]).toEqual([]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); }); // Test case for initial state with a non-empty data array From c5789720db7dd47e05a3489f794b6d05febe477e Mon Sep 17 00:00:00 2001 From: a7591 Date: Fri, 23 May 2025 09:08:15 +0900 Subject: [PATCH 4/5] [@mantine/hooks] added storybook demo for useSelection --- packages/@docs/demos/src/demos/hooks/Hooks.demos.story.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@docs/demos/src/demos/hooks/Hooks.demos.story.tsx b/packages/@docs/demos/src/demos/hooks/Hooks.demos.story.tsx index 2416359a29f..10360723adc 100644 --- a/packages/@docs/demos/src/demos/hooks/Hooks.demos.story.tsx +++ b/packages/@docs/demos/src/demos/hooks/Hooks.demos.story.tsx @@ -347,3 +347,8 @@ export const Demo_useFileDialogUsage = { name: '⭐ Demo: useFileDialogUsage', render: renderDemo(demos.useFileDialogUsage), }; + +export const Demo_useSelectionDemo = { + name: '⭐ Demo: useSelectionDemo', + render: renderDemo(demos.useSelectionDemo), +}; From f82dd92a574448da155b0625e48a0233935ea9d3 Mon Sep 17 00:00:00 2001 From: a7591 Date: Fri, 23 May 2025 09:29:31 +0900 Subject: [PATCH 5/5] [@mantine/hooks] feat: added defaultSelection --- .../src/pages/hooks/use-selection.mdx | 6 +- .../src/use-selection/use-selection.test.ts | 133 ++++++++++++++++-- .../hooks/src/use-selection/use-selection.ts | 4 +- 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/apps/mantine.dev/src/pages/hooks/use-selection.mdx b/apps/mantine.dev/src/pages/hooks/use-selection.mdx index 409171218a7..1c178f93967 100644 --- a/apps/mantine.dev/src/pages/hooks/use-selection.mdx +++ b/apps/mantine.dev/src/pages/hooks/use-selection.mdx @@ -7,8 +7,9 @@ export default Layout(MDX_DATA.useSelection); ## Usage `use-selection` hook manages **selection state** for a list of items. -It provides a flexible API to `select`, `deselect`, or `toggle` individual items, -and offers convenient flags like `isAllSelected` and `isSomeSelected` to reflect the current selection status. +It provides a flexible API to `select`, `deselect` or `toggle` individual items, +and offers `reset` and `setSelection` to manipulate selection state, +additionally offers convenient flags like `isAllSelected` and `isSomeSelected` to reflect the current selection status. This hook is ideal for implementing features like selectable lists, data grid row selection, or multi-select components. @@ -18,6 +19,7 @@ This hook is ideal for implementing features like selectable lists, data grid ro ```tsx function useSelection( dataset: T[], + defaultSelection?: T[] ): readonly [T[], { select: (selection: T) => void, deselect: (selection: T) => void, diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts index 4ce0b9dfd81..f363970bd60 100644 --- a/packages/@mantine/hooks/src/use-selection/use-selection.test.ts +++ b/packages/@mantine/hooks/src/use-selection/use-selection.test.ts @@ -1,20 +1,20 @@ import { act, renderHook } from '@testing-library/react'; import { useSelection } from './use-selection'; // Assuming your hook is in use-selection.ts -describe('@mantine/hooks/use-selection', () => { +describe('useSelection', () => { // Test case for initial state with an empty data array it('correctly returns initial state for an empty data array', () => { const data: number[] = []; const { result } = renderHook(() => useSelection(data)); - expect(result.current[0]).toEqual([]); - expect(result.current[1].isAllSelected).toBe(false); - expect(result.current[1].isSomeSelected).toBe(false); + expect(result.current[0]).toEqual([]); // selection should be empty + expect(result.current[1].isAllSelected).toBe(false); // all 0 items selected from 0 (UX-focused) + expect(result.current[1].isSomeSelected).toBe(false); // no items selected }); // Test case for initial state with a non-empty data array it('correctly returns initial state for a non-empty data array', () => { - const data = [1, 2, 3]; + const data: number[] = [1, 2, 3]; const { result } = renderHook(() => useSelection(data)); expect(result.current[0]).toEqual([]); // selection should be empty @@ -22,11 +22,47 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[1].isSomeSelected).toBe(false); // no items selected }); + // Test Case: Initial state with defaultSelection + it('correctly initializes with defaultSelection', () => { + const data = [1, 2, 3, 4, 5]; + const defaultSelected = [1, 3]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual(defaultSelected); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + }); + + // Test Case: Initial state with defaultSelection covering all items + it('correctly initializes with defaultSelection covering all items', () => { + const data = [1, 2, 3]; + const defaultSelected = [1, 2, 3]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual(defaultSelected); + expect(result.current[1].isAllSelected).toBe(true); + expect(result.current[1].isSomeSelected).toBe(true); + }); + + // Test Case: Initial state with defaultSelection containing items not in data + it('correctly initializes with defaultSelection containing items not present in data (selection only concerns itself with what was given)', () => { + const data = [1, 2]; + const defaultSelected = [1, 3, 4]; // 3 and 4 are not in data + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + // The selection array directly reflects what was passed, the hook doesn't filter based on `data` + expect(result.current[0]).toEqual(defaultSelected); + expect(result.current[1].isAllSelected).toBe(false); // Because data.length (2) !== selection.length (3) + expect(result.current[1].isSomeSelected).toBe(true); + }); + // Test case for 'select' functionality it('correctly selects an item', () => { const data = [1, 2, 3]; const { result } = renderHook(() => useSelection(data)); + expect(result.current[0]).toEqual([]); // Initial state + act(() => result.current[1].select(1)); expect(result.current[0]).toEqual([1]); expect(result.current[1].isAllSelected).toBe(false); @@ -37,6 +73,20 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[0]).toEqual([1]); }); + // Test Case: Select an item when defaultSelection is active + it('correctly selects an item when defaultSelection is active', () => { + const data = [1, 2, 3]; + const defaultSelected = [1]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual([1]); + + act(() => result.current[1].select(2)); + expect(result.current[0]).toEqual([1, 2]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + }); + // Test case for 'deselect' functionality it('correctly deselects an item', () => { const data = [1, 2, 3]; @@ -47,7 +97,7 @@ describe('@mantine/hooks/use-selection', () => { result.current[1].select(1); result.current[1].select(2); }); - expect(result.current[0]).toEqual([1, 2]); + expect(result.current[0]).toEqual([1, 2]); // Check initial selection before deselect // Deselect an item act(() => result.current[1].deselect(1)); @@ -60,11 +110,27 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[0]).toEqual([2]); }); + // Test Case: Deselect an item from defaultSelection + it('correctly deselects an item from defaultSelection', () => { + const data = [1, 2, 3]; + const defaultSelected = [1, 2]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual([1, 2]); + + act(() => result.current[1].deselect(1)); + expect(result.current[0]).toEqual([2]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + }); + // Test case for 'toggle' functionality it('correctly toggles an item', () => { const data = [1, 2, 3]; const { result } = renderHook(() => useSelection(data)); + expect(result.current[0]).toEqual([]); // Initial state + // Toggle to select act(() => result.current[1].toggle(1)); expect(result.current[0]).toEqual([1]); @@ -82,6 +148,25 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[0]).toEqual([2]); }); + // Test Case: Toggle an item from defaultSelection + it('correctly toggles an item when defaultSelection is active', () => { + const data = [1, 2, 3]; + const defaultSelected = [1]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual([1]); + + // Toggle to deselect + act(() => result.current[1].toggle(1)); + expect(result.current[0]).toEqual([]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); + + // Toggle to re-select + act(() => result.current[1].toggle(1)); + expect(result.current[0]).toEqual([1]); + }); + // Test case for 'resetSelection' functionality it('correctly resets the selection', () => { const data = [1, 2, 3]; @@ -100,11 +185,27 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[1].isSomeSelected).toBe(false); }); + // Test Case: resetSelection with defaultSelection + it('resetSelection correctly resets to an empty array, ignoring defaultSelection', () => { + const data = [1, 2, 3]; + const defaultSelected = [1, 2]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual(defaultSelected); // Starts with default + + act(() => result.current[1].resetSelection()); + expect(result.current[0]).toEqual([]); // Resets to empty + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(false); + }); + // Test case for isAllSelected when all items are selected it('isAllSelected is true when all items are selected', () => { - const data = ['a', 'b', 'c']; // Using strings to show generic T + const data: string[] = ['a', 'b', 'c']; const { result } = renderHook(() => useSelection(data)); + expect(result.current[0]).toEqual([]); // Initial state + act(() => { result.current[1].select('a'); result.current[1].select('b'); @@ -117,9 +218,11 @@ describe('@mantine/hooks/use-selection', () => { // Test case for setSelection directly it('allows direct setting of selection via setSelection', () => { - const data = [10, 20, 30]; + const data: number[] = [10, 20, 30]; const { result } = renderHook(() => useSelection(data)); + expect(result.current[0]).toEqual([]); // Initial state + act(() => result.current[1].setSelection([10, 30])); expect(result.current[0]).toEqual([10, 30]); expect(result.current[1].isAllSelected).toBe(false); @@ -135,4 +238,18 @@ describe('@mantine/hooks/use-selection', () => { expect(result.current[1].isAllSelected).toBe(true); expect(result.current[1].isSomeSelected).toBe(true); }); + + // Test Case: setSelection directly when defaultSelection was active + it('allows direct setting of selection via setSelection, overriding defaultSelection', () => { + const data: number[] = [10, 20, 30]; + const defaultSelected = [10]; + const { result } = renderHook(() => useSelection(data, defaultSelected)); + + expect(result.current[0]).toEqual([10]); // Initial state from defaultSelection + + act(() => result.current[1].setSelection([20, 30])); + expect(result.current[0]).toEqual([20, 30]); + expect(result.current[1].isAllSelected).toBe(false); + expect(result.current[1].isSomeSelected).toBe(true); + }); }); diff --git a/packages/@mantine/hooks/src/use-selection/use-selection.ts b/packages/@mantine/hooks/src/use-selection/use-selection.ts index 87ceec316ef..23be923f8bd 100644 --- a/packages/@mantine/hooks/src/use-selection/use-selection.ts +++ b/packages/@mantine/hooks/src/use-selection/use-selection.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; -export const useSelection = (data: Readonly) => { - const [selection, setSelection] = useState>([]); +export const useSelection = (data: Readonly, defaultSelection: T[] = []) => { + const [selection, setSelection] = useState>(defaultSelection); const select = useCallback( (selected: T) => {