@usefy/use-selection 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">@usefy/use-selection</h1>
6
+
7
+ <p align="center">
8
+ <strong>Multi/single selection state for lists and tables — Set-based, checkbox-ready</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@usefy/use-selection"><img src="https://img.shields.io/npm/v/@usefy/use-selection.svg?style=flat-square&color=007acc" alt="npm version" /></a>
13
+ <a href="https://www.npmjs.com/package/@usefy/use-selection"><img src="https://img.shields.io/npm/dm/@usefy/use-selection.svg?style=flat-square&color=007acc" alt="npm downloads" /></a>
14
+ <a href="https://bundlephobia.com/package/@usefy/use-selection"><img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-selection?style=flat-square&color=007acc" alt="bundle size" /></a>
15
+ <a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@usefy/use-selection.svg?style=flat-square&color=007acc" alt="license" /></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="#installation">Installation</a> •
20
+ <a href="#quick-start">Quick Start</a> •
21
+ <a href="#api">API</a> •
22
+ <a href="#testing">Testing</a> •
23
+ <a href="#license">License</a>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-useselection--docs" target="_blank" rel="noopener noreferrer">
28
+ <strong>📚 View Storybook Demo</strong>
29
+ </a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## Overview
35
+
36
+ `useSelection` is part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It manages which items from a list or table are selected, backed by a `Set` of keys, with everything a checkbox UI needs: per-row selection, a header "select all" with indeterminate state, and an optional single-selection mode.
37
+
38
+ ## Features
39
+
40
+ - **Set-backed** — stores **keys** (via `getKey`, default identity), so a selection survives new item identities across renders
41
+ - **Ergonomic surface** — `selected` items, `isSelected`, `toggle`, `select`, `deselect`, `selectAll`, `clear`
42
+ - **Header-checkbox ready** — `isAllSelected` / `isPartiallySelected` / `isNoneSelected` for the indeterminate state
43
+ - **Single-selection mode** — `multiple: false` replaces the selection on select (radio-like)
44
+ - **Auto-reconciled** — item-facing values derive from the current `items`; removed rows drop out automatically
45
+ - **Stable & efficient** — stable action identities; no-op `selectAll`/`clear` skip re-renders; immutable Set updates
46
+ - **SSR-safe & StrictMode-safe** — pure state, no mutation of prior state
47
+ - **TypeScript-first** — full type inference and exported types
48
+ - **Tiny & tree-shakeable** — zero dependencies, published as its own package
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ # npm
54
+ npm install @usefy/use-selection
55
+
56
+ # yarn
57
+ yarn add @usefy/use-selection
58
+
59
+ # pnpm
60
+ pnpm add @usefy/use-selection
61
+ ```
62
+
63
+ Requires React 18 or 19 (`peerDependencies: "react": "^18.0.0 || ^19.0.0"`).
64
+
65
+ ## Quick Start
66
+
67
+ ```tsx
68
+ import { useSelection } from "@usefy/use-selection";
69
+
70
+ type User = { id: number; name: string };
71
+
72
+ function UserTable({ users }: { users: User[] }) {
73
+ const {
74
+ selected,
75
+ isSelected,
76
+ toggle,
77
+ selectAll,
78
+ clear,
79
+ isAllSelected,
80
+ isPartiallySelected,
81
+ } = useSelection(users, { getKey: (u) => u.id });
82
+
83
+ return (
84
+ <table>
85
+ <thead>
86
+ <tr>
87
+ <th>
88
+ <input
89
+ type="checkbox"
90
+ checked={isAllSelected}
91
+ ref={(el) => {
92
+ if (el) el.indeterminate = isPartiallySelected;
93
+ }}
94
+ onChange={() => (isAllSelected ? clear() : selectAll())}
95
+ />
96
+ </th>
97
+ <th>Name ({selected.length} selected)</th>
98
+ </tr>
99
+ </thead>
100
+ <tbody>
101
+ {users.map((user) => (
102
+ <tr key={user.id}>
103
+ <td>
104
+ <input
105
+ type="checkbox"
106
+ checked={isSelected(user)}
107
+ onChange={() => toggle(user)}
108
+ />
109
+ </td>
110
+ <td>{user.name}</td>
111
+ </tr>
112
+ ))}
113
+ </tbody>
114
+ </table>
115
+ );
116
+ }
117
+ ```
118
+
119
+ ## API
120
+
121
+ ```ts
122
+ const result = useSelection<T>(items: T[], options?: UseSelectionOptions<T>);
123
+ ```
124
+
125
+ ### Options — `UseSelectionOptions<T>`
126
+
127
+ | Option | Type | Default | Description |
128
+ | --- | --- | --- | --- |
129
+ | `getKey` | `(item: T) => string \| number` | identity (`(item) => item`) | Derives the selection key for an item. The `Set` stores these keys, not items, so selection survives new object references. **Provide this for object items** (e.g. `(row) => row.id`); the identity default is only correct for primitive items. |
130
+ | `multiple` | `boolean` | `true` | `true` = multi-selection; `false` = single-selection (selecting replaces the current choice, and `selectAll` is a no-op). |
131
+
132
+ ### Return — `UseSelectionReturn<T>`
133
+
134
+ | Property | Type | Description |
135
+ | --- | --- | --- |
136
+ | `selected` | `T[]` | The selected items, in `items` order. Derived as `items ∩ selectedKeys` — items removed from `items` drop out automatically. |
137
+ | `selectedKeys` | `ReadonlySet<string \| number>` | The raw set of selected keys (source of truth). May contain keys for items no longer in `items`; those are invisible in the derived values. |
138
+ | `isSelected` | `(item: T) => boolean` | Whether an item is selected (by key). Stable. |
139
+ | `toggle` | `(item: T) => void` | Flip an item's selection. In single mode, selecting a new item replaces the previous one. Stable. |
140
+ | `select` | `(item: T) => void` | Select an item (idempotent). In single mode, replaces the selection. Stable. |
141
+ | `deselect` | `(item: T) => void` | Deselect an item (idempotent). Stable. |
142
+ | `selectAll` | `() => void` | Select every item in `items`. No-op when all are already selected, or in single mode. Stable. |
143
+ | `clear` | `() => void` | Clear the whole selection (deselect all). No-op when already empty. Stable. |
144
+ | `isAllSelected` | `boolean` | `true` when every item is selected. **Always `false` for an empty `items` array.** |
145
+ | `isPartiallySelected` | `boolean` | `true` when some — but not all — items are selected (drives an indeterminate header checkbox). `false` for empty `items`. |
146
+ | `isNoneSelected` | `boolean` | `true` when no items are selected (including when `items` is empty). |
147
+
148
+ ### Behavior notes
149
+
150
+ - **Selection key.** The `Set` stores **keys**, never items. This is what makes a selection stable when `items` is rebuilt with fresh object references each render — as long as `getKey` maps the same logical item to the same key. For primitive `items` (`string`/`number`) the identity default just works.
151
+ - **Items-change reconciliation.** `selectedKeys` is the source of truth; every item-facing value is **derived from the current `items`**. Removing a selected row from `items` makes it disappear from `selected` and recomputes `isAllSelected`/`isPartiallySelected` automatically — no manual pruning needed. The removed key stays in `selectedKeys` (harmless and invisible) so it re-appears if the item comes back.
152
+ - **Empty list.** `isAllSelected` is `false` for an empty `items` array (there is nothing to have "all" selected); `isNoneSelected` is `true`.
153
+ - **Single-selection mode.** With `multiple: false`, `select`/`toggle` of a new item replace the current selection; toggling the already-selected item deselects it. `selectAll` is a no-op (you cannot hold more than one selection).
154
+ - **StrictMode / concurrency.** Updates are immutable (a fresh `Set` on every change) and no user callback runs inside a `setState` updater, so it is safe under StrictMode and concurrent rendering.
155
+
156
+ ## Testing
157
+
158
+ 📊 <a href="https://mirunamu00.github.io/usefy/coverage/use-selection/src/index.html" target="_blank" rel="noopener noreferrer"><strong>View Detailed Coverage Report</strong></a> (GitHub Pages) — **24 tests**, 100% statement coverage.
159
+
160
+ ## License
161
+
162
+ MIT © [mirunamu](https://github.com/mirunamu00)
163
+
164
+ This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The primitive key a selection is tracked by. Items may be objects, so
3
+ * {@link useSelection} never stores items directly — it stores a stable
4
+ * key derived from each item (see {@link UseSelectionOptions.getKey}). Keys are
5
+ * `string | number` so they compare by value inside the backing `Set`, which is
6
+ * what makes a selection survive new object identities across renders.
7
+ */
8
+ type SelectionKey = string | number;
9
+ /**
10
+ * Options for {@link useSelection}.
11
+ *
12
+ * @template T - The item type.
13
+ */
14
+ interface UseSelectionOptions<T> {
15
+ /**
16
+ * Derive the selection key for an item. The backing `Set` stores these keys,
17
+ * not the items themselves, so selection is stable even when the `items`
18
+ * array is rebuilt with new object references each render (as long as
19
+ * `getKey` returns the same key for the same logical item).
20
+ *
21
+ * Defaults to identity (`(item) => item`), which is correct when `items` are
22
+ * primitives (`string`/`number`). For object items you should provide a
23
+ * `getKey` such as `(row) => row.id`.
24
+ */
25
+ getKey?: (item: T) => SelectionKey;
26
+ /**
27
+ * Whether more than one item can be selected at a time.
28
+ *
29
+ * - `true` (default) — multi-selection. `select`/`toggle` add to the set.
30
+ * - `false` — single-selection. Selecting an item replaces the current
31
+ * selection; toggling the already-selected item deselects it. `selectAll`
32
+ * is a no-op in this mode (you cannot hold more than one selection).
33
+ *
34
+ * @defaultValue true
35
+ */
36
+ multiple?: boolean;
37
+ }
38
+ /**
39
+ * Return value of {@link useSelection}.
40
+ *
41
+ * The backing `Set` of keys is the source of truth, but every item-facing value
42
+ * (`selected`, `isAllSelected`, …) is **derived from the current `items`**. This
43
+ * means keys for items no longer present in `items` simply stop appearing in
44
+ * `selected` and stop counting toward `isAllSelected` — stale selections never
45
+ * linger in the item-facing surface.
46
+ *
47
+ * @template T - The item type.
48
+ */
49
+ interface UseSelectionReturn<T> {
50
+ /**
51
+ * The currently selected items, in `items` order. Derived as
52
+ * `items` ∩ `selectedKeys`, so items removed from `items` drop out
53
+ * automatically. Ideal for rendering a "selected" summary.
54
+ *
55
+ * Read-only: it is a freshly derived array — use the actions to change the
56
+ * selection rather than mutating this in place.
57
+ */
58
+ selected: readonly T[];
59
+ /**
60
+ * The raw set of selected keys — the source of truth. Read-only: use the
61
+ * actions to mutate. May contain keys for items not currently in `items`
62
+ * (those are simply invisible in the derived, item-facing values).
63
+ */
64
+ selectedKeys: ReadonlySet<SelectionKey>;
65
+ /**
66
+ * Whether a given item is currently selected (compared by key). Stable across
67
+ * renders and always reflects the latest state.
68
+ */
69
+ isSelected: (item: T) => boolean;
70
+ /**
71
+ * Toggle a single item's selection. If it is selected it becomes deselected;
72
+ * otherwise it becomes selected. In single-selection mode, selecting a new
73
+ * item replaces the previous selection. Stable identity.
74
+ */
75
+ toggle: (item: T) => void;
76
+ /**
77
+ * Explicitly select an item (idempotent — selecting an already-selected item
78
+ * is a no-op). In single-selection mode this replaces the current selection.
79
+ * Stable identity.
80
+ */
81
+ select: (item: T) => void;
82
+ /**
83
+ * Explicitly deselect an item (idempotent — deselecting an item that is not
84
+ * selected is a no-op). Stable identity.
85
+ */
86
+ deselect: (item: T) => void;
87
+ /**
88
+ * Select every item currently in `items`. No-op when everything is already
89
+ * selected (no re-render). No-op in single-selection mode. Stable identity.
90
+ */
91
+ selectAll: () => void;
92
+ /**
93
+ * Clear the entire selection (a.k.a. deselect all). No-op when the selection
94
+ * is already empty (no re-render). Stable identity.
95
+ */
96
+ clear: () => void;
97
+ /**
98
+ * `true` when every item in `items` is selected. Always `false` for an empty
99
+ * `items` array (there is nothing to have "all" selected).
100
+ */
101
+ isAllSelected: boolean;
102
+ /**
103
+ * `true` when some — but not all — of the current `items` are selected. Useful
104
+ * to drive a header checkbox's `indeterminate` state. `false` for an empty
105
+ * `items` array.
106
+ */
107
+ isPartiallySelected: boolean;
108
+ /**
109
+ * `true` when none of the current `items` are selected (including when `items`
110
+ * is empty).
111
+ */
112
+ isNoneSelected: boolean;
113
+ }
114
+
115
+ /**
116
+ * Manage multi- or single-selection state for a list or table, backed by a
117
+ * `Set` of keys.
118
+ *
119
+ * The `Set` stores **keys** (see the `getKey` option), never the items
120
+ * themselves, so a selection survives new object identities across renders —
121
+ * rebuilding the `items` array every render will not lose the selection as long
122
+ * as `getKey` maps the same logical item to the same key. Every item-facing
123
+ * value (`selected`, `isAllSelected`, `isPartiallySelected`, …) is **derived
124
+ * from the current `items`**, so removing a selected row from `items` makes it
125
+ * disappear from `selected` and recomputes the aggregate flags automatically —
126
+ * no manual reconciliation needed.
127
+ *
128
+ * Features:
129
+ * - `Set`-backed, immutable updates (a fresh `Set` on every change)
130
+ * - No-op skipping — `selectAll` when all are already selected, and `clear`
131
+ * when already empty, bail out without a re-render
132
+ * - Single-selection mode (`multiple: false`) that replaces the selection
133
+ * - Stable action identities (`toggle`/`select`/`deselect`/`selectAll`/`clear`
134
+ * and `isSelected`) — safe as effect dependencies
135
+ * - `isPartiallySelected` for a header checkbox's indeterminate state
136
+ * - SSR-safe (pure state) and StrictMode/concurrent-safe (no mutation of prior
137
+ * state, callbacks never fire from inside a `setState` updater)
138
+ *
139
+ * @template T - The item type.
140
+ * @param items - The current list of items the selection is scoped to.
141
+ * @param options - {@link UseSelectionOptions} (`getKey`, `multiple`).
142
+ * @returns {@link UseSelectionReturn}
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * type User = { id: number; name: string };
147
+ *
148
+ * function UserTable({ users }: { users: User[] }) {
149
+ * const {
150
+ * selected,
151
+ * isSelected,
152
+ * toggle,
153
+ * selectAll,
154
+ * clear,
155
+ * isAllSelected,
156
+ * isPartiallySelected,
157
+ * } = useSelection(users, { getKey: (u) => u.id });
158
+ *
159
+ * return (
160
+ * <table>
161
+ * <thead>
162
+ * <tr>
163
+ * <th>
164
+ * <input
165
+ * type="checkbox"
166
+ * checked={isAllSelected}
167
+ * ref={(el) => {
168
+ * if (el) el.indeterminate = isPartiallySelected;
169
+ * }}
170
+ * onChange={() => (isAllSelected ? clear() : selectAll())}
171
+ * />
172
+ * </th>
173
+ * <th>Name ({selected.length} selected)</th>
174
+ * </tr>
175
+ * </thead>
176
+ * <tbody>
177
+ * {users.map((user) => (
178
+ * <tr key={user.id}>
179
+ * <td>
180
+ * <input
181
+ * type="checkbox"
182
+ * checked={isSelected(user)}
183
+ * onChange={() => toggle(user)}
184
+ * />
185
+ * </td>
186
+ * <td>{user.name}</td>
187
+ * </tr>
188
+ * ))}
189
+ * </tbody>
190
+ * </table>
191
+ * );
192
+ * }
193
+ * ```
194
+ *
195
+ * @example
196
+ * ```tsx
197
+ * // Single-selection mode (radio-like): selecting replaces the previous choice.
198
+ * const { selected, isSelected, select } = useSelection(options, {
199
+ * multiple: false,
200
+ * });
201
+ * ```
202
+ */
203
+ declare function useSelection<T>(items: T[], options?: UseSelectionOptions<T>): UseSelectionReturn<T>;
204
+
205
+ export { type SelectionKey, type UseSelectionOptions, type UseSelectionReturn, useSelection };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The primitive key a selection is tracked by. Items may be objects, so
3
+ * {@link useSelection} never stores items directly — it stores a stable
4
+ * key derived from each item (see {@link UseSelectionOptions.getKey}). Keys are
5
+ * `string | number` so they compare by value inside the backing `Set`, which is
6
+ * what makes a selection survive new object identities across renders.
7
+ */
8
+ type SelectionKey = string | number;
9
+ /**
10
+ * Options for {@link useSelection}.
11
+ *
12
+ * @template T - The item type.
13
+ */
14
+ interface UseSelectionOptions<T> {
15
+ /**
16
+ * Derive the selection key for an item. The backing `Set` stores these keys,
17
+ * not the items themselves, so selection is stable even when the `items`
18
+ * array is rebuilt with new object references each render (as long as
19
+ * `getKey` returns the same key for the same logical item).
20
+ *
21
+ * Defaults to identity (`(item) => item`), which is correct when `items` are
22
+ * primitives (`string`/`number`). For object items you should provide a
23
+ * `getKey` such as `(row) => row.id`.
24
+ */
25
+ getKey?: (item: T) => SelectionKey;
26
+ /**
27
+ * Whether more than one item can be selected at a time.
28
+ *
29
+ * - `true` (default) — multi-selection. `select`/`toggle` add to the set.
30
+ * - `false` — single-selection. Selecting an item replaces the current
31
+ * selection; toggling the already-selected item deselects it. `selectAll`
32
+ * is a no-op in this mode (you cannot hold more than one selection).
33
+ *
34
+ * @defaultValue true
35
+ */
36
+ multiple?: boolean;
37
+ }
38
+ /**
39
+ * Return value of {@link useSelection}.
40
+ *
41
+ * The backing `Set` of keys is the source of truth, but every item-facing value
42
+ * (`selected`, `isAllSelected`, …) is **derived from the current `items`**. This
43
+ * means keys for items no longer present in `items` simply stop appearing in
44
+ * `selected` and stop counting toward `isAllSelected` — stale selections never
45
+ * linger in the item-facing surface.
46
+ *
47
+ * @template T - The item type.
48
+ */
49
+ interface UseSelectionReturn<T> {
50
+ /**
51
+ * The currently selected items, in `items` order. Derived as
52
+ * `items` ∩ `selectedKeys`, so items removed from `items` drop out
53
+ * automatically. Ideal for rendering a "selected" summary.
54
+ *
55
+ * Read-only: it is a freshly derived array — use the actions to change the
56
+ * selection rather than mutating this in place.
57
+ */
58
+ selected: readonly T[];
59
+ /**
60
+ * The raw set of selected keys — the source of truth. Read-only: use the
61
+ * actions to mutate. May contain keys for items not currently in `items`
62
+ * (those are simply invisible in the derived, item-facing values).
63
+ */
64
+ selectedKeys: ReadonlySet<SelectionKey>;
65
+ /**
66
+ * Whether a given item is currently selected (compared by key). Stable across
67
+ * renders and always reflects the latest state.
68
+ */
69
+ isSelected: (item: T) => boolean;
70
+ /**
71
+ * Toggle a single item's selection. If it is selected it becomes deselected;
72
+ * otherwise it becomes selected. In single-selection mode, selecting a new
73
+ * item replaces the previous selection. Stable identity.
74
+ */
75
+ toggle: (item: T) => void;
76
+ /**
77
+ * Explicitly select an item (idempotent — selecting an already-selected item
78
+ * is a no-op). In single-selection mode this replaces the current selection.
79
+ * Stable identity.
80
+ */
81
+ select: (item: T) => void;
82
+ /**
83
+ * Explicitly deselect an item (idempotent — deselecting an item that is not
84
+ * selected is a no-op). Stable identity.
85
+ */
86
+ deselect: (item: T) => void;
87
+ /**
88
+ * Select every item currently in `items`. No-op when everything is already
89
+ * selected (no re-render). No-op in single-selection mode. Stable identity.
90
+ */
91
+ selectAll: () => void;
92
+ /**
93
+ * Clear the entire selection (a.k.a. deselect all). No-op when the selection
94
+ * is already empty (no re-render). Stable identity.
95
+ */
96
+ clear: () => void;
97
+ /**
98
+ * `true` when every item in `items` is selected. Always `false` for an empty
99
+ * `items` array (there is nothing to have "all" selected).
100
+ */
101
+ isAllSelected: boolean;
102
+ /**
103
+ * `true` when some — but not all — of the current `items` are selected. Useful
104
+ * to drive a header checkbox's `indeterminate` state. `false` for an empty
105
+ * `items` array.
106
+ */
107
+ isPartiallySelected: boolean;
108
+ /**
109
+ * `true` when none of the current `items` are selected (including when `items`
110
+ * is empty).
111
+ */
112
+ isNoneSelected: boolean;
113
+ }
114
+
115
+ /**
116
+ * Manage multi- or single-selection state for a list or table, backed by a
117
+ * `Set` of keys.
118
+ *
119
+ * The `Set` stores **keys** (see the `getKey` option), never the items
120
+ * themselves, so a selection survives new object identities across renders —
121
+ * rebuilding the `items` array every render will not lose the selection as long
122
+ * as `getKey` maps the same logical item to the same key. Every item-facing
123
+ * value (`selected`, `isAllSelected`, `isPartiallySelected`, …) is **derived
124
+ * from the current `items`**, so removing a selected row from `items` makes it
125
+ * disappear from `selected` and recomputes the aggregate flags automatically —
126
+ * no manual reconciliation needed.
127
+ *
128
+ * Features:
129
+ * - `Set`-backed, immutable updates (a fresh `Set` on every change)
130
+ * - No-op skipping — `selectAll` when all are already selected, and `clear`
131
+ * when already empty, bail out without a re-render
132
+ * - Single-selection mode (`multiple: false`) that replaces the selection
133
+ * - Stable action identities (`toggle`/`select`/`deselect`/`selectAll`/`clear`
134
+ * and `isSelected`) — safe as effect dependencies
135
+ * - `isPartiallySelected` for a header checkbox's indeterminate state
136
+ * - SSR-safe (pure state) and StrictMode/concurrent-safe (no mutation of prior
137
+ * state, callbacks never fire from inside a `setState` updater)
138
+ *
139
+ * @template T - The item type.
140
+ * @param items - The current list of items the selection is scoped to.
141
+ * @param options - {@link UseSelectionOptions} (`getKey`, `multiple`).
142
+ * @returns {@link UseSelectionReturn}
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * type User = { id: number; name: string };
147
+ *
148
+ * function UserTable({ users }: { users: User[] }) {
149
+ * const {
150
+ * selected,
151
+ * isSelected,
152
+ * toggle,
153
+ * selectAll,
154
+ * clear,
155
+ * isAllSelected,
156
+ * isPartiallySelected,
157
+ * } = useSelection(users, { getKey: (u) => u.id });
158
+ *
159
+ * return (
160
+ * <table>
161
+ * <thead>
162
+ * <tr>
163
+ * <th>
164
+ * <input
165
+ * type="checkbox"
166
+ * checked={isAllSelected}
167
+ * ref={(el) => {
168
+ * if (el) el.indeterminate = isPartiallySelected;
169
+ * }}
170
+ * onChange={() => (isAllSelected ? clear() : selectAll())}
171
+ * />
172
+ * </th>
173
+ * <th>Name ({selected.length} selected)</th>
174
+ * </tr>
175
+ * </thead>
176
+ * <tbody>
177
+ * {users.map((user) => (
178
+ * <tr key={user.id}>
179
+ * <td>
180
+ * <input
181
+ * type="checkbox"
182
+ * checked={isSelected(user)}
183
+ * onChange={() => toggle(user)}
184
+ * />
185
+ * </td>
186
+ * <td>{user.name}</td>
187
+ * </tr>
188
+ * ))}
189
+ * </tbody>
190
+ * </table>
191
+ * );
192
+ * }
193
+ * ```
194
+ *
195
+ * @example
196
+ * ```tsx
197
+ * // Single-selection mode (radio-like): selecting replaces the previous choice.
198
+ * const { selected, isSelected, select } = useSelection(options, {
199
+ * multiple: false,
200
+ * });
201
+ * ```
202
+ */
203
+ declare function useSelection<T>(items: T[], options?: UseSelectionOptions<T>): UseSelectionReturn<T>;
204
+
205
+ export { type SelectionKey, type UseSelectionOptions, type UseSelectionReturn, useSelection };
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ useSelection: () => useSelection
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/useSelection.ts
28
+ var import_react = require("react");
29
+ function useSelection(items, options = {}) {
30
+ const { multiple = true } = options;
31
+ const [selectedKeys, setSelectedKeys] = (0, import_react.useState)(
32
+ () => /* @__PURE__ */ new Set()
33
+ );
34
+ const getKeyOptionRef = (0, import_react.useRef)(options.getKey);
35
+ getKeyOptionRef.current = options.getKey;
36
+ const itemsRef = (0, import_react.useRef)(items);
37
+ itemsRef.current = items;
38
+ const multipleRef = (0, import_react.useRef)(multiple);
39
+ multipleRef.current = multiple;
40
+ const selectedKeysRef = (0, import_react.useRef)(selectedKeys);
41
+ selectedKeysRef.current = selectedKeys;
42
+ const getKey = (0, import_react.useCallback)((item) => {
43
+ const fn = getKeyOptionRef.current;
44
+ return fn ? fn(item) : item;
45
+ }, []);
46
+ const select = (0, import_react.useCallback)(
47
+ (item) => {
48
+ const key = getKey(item);
49
+ setSelectedKeys((prev) => {
50
+ if (multipleRef.current) {
51
+ if (prev.has(key)) {
52
+ return prev;
53
+ }
54
+ const next = new Set(prev);
55
+ next.add(key);
56
+ return next;
57
+ }
58
+ if (prev.size === 1 && prev.has(key)) {
59
+ return prev;
60
+ }
61
+ return /* @__PURE__ */ new Set([key]);
62
+ });
63
+ },
64
+ [getKey]
65
+ );
66
+ const deselect = (0, import_react.useCallback)(
67
+ (item) => {
68
+ const key = getKey(item);
69
+ setSelectedKeys((prev) => {
70
+ if (!prev.has(key)) {
71
+ return prev;
72
+ }
73
+ const next = new Set(prev);
74
+ next.delete(key);
75
+ return next;
76
+ });
77
+ },
78
+ [getKey]
79
+ );
80
+ const toggle = (0, import_react.useCallback)(
81
+ (item) => {
82
+ const key = getKey(item);
83
+ setSelectedKeys((prev) => {
84
+ if (prev.has(key)) {
85
+ const next = new Set(prev);
86
+ next.delete(key);
87
+ return next;
88
+ }
89
+ if (multipleRef.current) {
90
+ const next = new Set(prev);
91
+ next.add(key);
92
+ return next;
93
+ }
94
+ return /* @__PURE__ */ new Set([key]);
95
+ });
96
+ },
97
+ [getKey]
98
+ );
99
+ const selectAll = (0, import_react.useCallback)(() => {
100
+ if (!multipleRef.current) {
101
+ return;
102
+ }
103
+ setSelectedKeys((prev) => {
104
+ const currentItems = itemsRef.current;
105
+ if (currentItems.length === 0) {
106
+ return prev;
107
+ }
108
+ let changed = false;
109
+ const next = new Set(prev);
110
+ for (const item of currentItems) {
111
+ const key = getKey(item);
112
+ if (!next.has(key)) {
113
+ next.add(key);
114
+ changed = true;
115
+ }
116
+ }
117
+ return changed ? next : prev;
118
+ });
119
+ }, [getKey]);
120
+ const clear = (0, import_react.useCallback)(() => {
121
+ setSelectedKeys(
122
+ (prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set()
123
+ );
124
+ }, []);
125
+ const isSelected = (0, import_react.useCallback)(
126
+ (item) => selectedKeysRef.current.has(getKey(item)),
127
+ [getKey]
128
+ );
129
+ const { selected, isAllSelected, isPartiallySelected, isNoneSelected } = (0, import_react.useMemo)(() => {
130
+ const sel = [];
131
+ for (const item of items) {
132
+ if (selectedKeys.has(getKey(item))) {
133
+ sel.push(item);
134
+ }
135
+ }
136
+ const count = sel.length;
137
+ const total = items.length;
138
+ return {
139
+ selected: sel,
140
+ isAllSelected: total > 0 && count === total,
141
+ isPartiallySelected: count > 0 && count < total,
142
+ isNoneSelected: count === 0
143
+ };
144
+ }, [items, selectedKeys, getKey]);
145
+ return {
146
+ selected,
147
+ selectedKeys,
148
+ isSelected,
149
+ toggle,
150
+ select,
151
+ deselect,
152
+ selectAll,
153
+ clear,
154
+ isAllSelected,
155
+ isPartiallySelected,
156
+ isNoneSelected
157
+ };
158
+ }
159
+ // Annotate the CommonJS export names for ESM import in node:
160
+ 0 && (module.exports = {
161
+ useSelection
162
+ });
163
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/useSelection.ts"],"sourcesContent":["export { useSelection } from \"./useSelection\";\nexport type {\n SelectionKey,\n UseSelectionOptions,\n UseSelectionReturn,\n} from \"./types\";\n","import { useCallback, useMemo, useRef, useState } from \"react\";\nimport type {\n SelectionKey,\n UseSelectionOptions,\n UseSelectionReturn,\n} from \"./types\";\n\n/**\n * Manage multi- or single-selection state for a list or table, backed by a\n * `Set` of keys.\n *\n * The `Set` stores **keys** (see the `getKey` option), never the items\n * themselves, so a selection survives new object identities across renders —\n * rebuilding the `items` array every render will not lose the selection as long\n * as `getKey` maps the same logical item to the same key. Every item-facing\n * value (`selected`, `isAllSelected`, `isPartiallySelected`, …) is **derived\n * from the current `items`**, so removing a selected row from `items` makes it\n * disappear from `selected` and recomputes the aggregate flags automatically —\n * no manual reconciliation needed.\n *\n * Features:\n * - `Set`-backed, immutable updates (a fresh `Set` on every change)\n * - No-op skipping — `selectAll` when all are already selected, and `clear`\n * when already empty, bail out without a re-render\n * - Single-selection mode (`multiple: false`) that replaces the selection\n * - Stable action identities (`toggle`/`select`/`deselect`/`selectAll`/`clear`\n * and `isSelected`) — safe as effect dependencies\n * - `isPartiallySelected` for a header checkbox's indeterminate state\n * - SSR-safe (pure state) and StrictMode/concurrent-safe (no mutation of prior\n * state, callbacks never fire from inside a `setState` updater)\n *\n * @template T - The item type.\n * @param items - The current list of items the selection is scoped to.\n * @param options - {@link UseSelectionOptions} (`getKey`, `multiple`).\n * @returns {@link UseSelectionReturn}\n *\n * @example\n * ```tsx\n * type User = { id: number; name: string };\n *\n * function UserTable({ users }: { users: User[] }) {\n * const {\n * selected,\n * isSelected,\n * toggle,\n * selectAll,\n * clear,\n * isAllSelected,\n * isPartiallySelected,\n * } = useSelection(users, { getKey: (u) => u.id });\n *\n * return (\n * <table>\n * <thead>\n * <tr>\n * <th>\n * <input\n * type=\"checkbox\"\n * checked={isAllSelected}\n * ref={(el) => {\n * if (el) el.indeterminate = isPartiallySelected;\n * }}\n * onChange={() => (isAllSelected ? clear() : selectAll())}\n * />\n * </th>\n * <th>Name ({selected.length} selected)</th>\n * </tr>\n * </thead>\n * <tbody>\n * {users.map((user) => (\n * <tr key={user.id}>\n * <td>\n * <input\n * type=\"checkbox\"\n * checked={isSelected(user)}\n * onChange={() => toggle(user)}\n * />\n * </td>\n * <td>{user.name}</td>\n * </tr>\n * ))}\n * </tbody>\n * </table>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Single-selection mode (radio-like): selecting replaces the previous choice.\n * const { selected, isSelected, select } = useSelection(options, {\n * multiple: false,\n * });\n * ```\n */\nexport function useSelection<T>(\n items: T[],\n options: UseSelectionOptions<T> = {}\n): UseSelectionReturn<T> {\n const { multiple = true } = options;\n\n const [selectedKeys, setSelectedKeys] = useState<Set<SelectionKey>>(\n () => new Set()\n );\n\n // Mirror the latest inputs so the stable actions can read fresh values\n // without being recreated each render.\n const getKeyOptionRef = useRef(options.getKey);\n getKeyOptionRef.current = options.getKey;\n\n const itemsRef = useRef(items);\n itemsRef.current = items;\n\n const multipleRef = useRef(multiple);\n multipleRef.current = multiple;\n\n const selectedKeysRef = useRef(selectedKeys);\n selectedKeysRef.current = selectedKeys;\n\n // A stable key resolver that always uses the latest `getKey`. Falls back to\n // identity (works for primitive items) when no `getKey` is provided.\n const getKey = useCallback((item: T): SelectionKey => {\n const fn = getKeyOptionRef.current;\n return fn ? fn(item) : (item as unknown as SelectionKey);\n }, []);\n\n const select = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (multipleRef.current) {\n if (prev.has(key)) {\n return prev; // already selected — no-op\n }\n const next = new Set(prev);\n next.add(key);\n return next;\n }\n // single-selection: replace with just this key\n if (prev.size === 1 && prev.has(key)) {\n return prev; // already the sole selection — no-op\n }\n return new Set<SelectionKey>([key]);\n });\n },\n [getKey]\n );\n\n const deselect = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (!prev.has(key)) {\n return prev; // not selected — no-op\n }\n const next = new Set(prev);\n next.delete(key);\n return next;\n });\n },\n [getKey]\n );\n\n const toggle = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (prev.has(key)) {\n const next = new Set(prev);\n next.delete(key);\n return next;\n }\n if (multipleRef.current) {\n const next = new Set(prev);\n next.add(key);\n return next;\n }\n // single-selection: replace with just this key\n return new Set<SelectionKey>([key]);\n });\n },\n [getKey]\n );\n\n const selectAll = useCallback(() => {\n // Selecting \"all\" is a multi-selection concept.\n if (!multipleRef.current) {\n return;\n }\n setSelectedKeys((prev) => {\n const currentItems = itemsRef.current;\n if (currentItems.length === 0) {\n return prev; // nothing to select — no-op\n }\n let changed = false;\n const next = new Set(prev);\n for (const item of currentItems) {\n const key = getKey(item);\n if (!next.has(key)) {\n next.add(key);\n changed = true;\n }\n }\n return changed ? next : prev; // no-op when all already selected\n });\n }, [getKey]);\n\n const clear = useCallback(() => {\n setSelectedKeys((prev) =>\n prev.size === 0 ? prev : new Set<SelectionKey>()\n );\n }, []);\n\n const isSelected = useCallback(\n (item: T) => selectedKeysRef.current.has(getKey(item)),\n [getKey]\n );\n\n // Derive item-facing values from the current items ∩ selected keys. This is\n // what drops stale keys and keeps the aggregate flags in sync when `items`\n // changes. `getKey` is stable, so this recomputes only when `items` or the\n // selection changes.\n const { selected, isAllSelected, isPartiallySelected, isNoneSelected } =\n useMemo(() => {\n const sel: T[] = [];\n for (const item of items) {\n if (selectedKeys.has(getKey(item))) {\n sel.push(item);\n }\n }\n const count = sel.length;\n const total = items.length;\n return {\n selected: sel,\n isAllSelected: total > 0 && count === total,\n isPartiallySelected: count > 0 && count < total,\n isNoneSelected: count === 0,\n };\n }, [items, selectedKeys, getKey]);\n\n return {\n selected,\n selectedKeys,\n isSelected,\n toggle,\n select,\n deselect,\n selectAll,\n clear,\n isAllSelected,\n isPartiallySelected,\n isNoneSelected,\n };\n}\n"],"mappings":"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","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // src/useSelection.ts
2
+ import { useCallback, useMemo, useRef, useState } from "react";
3
+ function useSelection(items, options = {}) {
4
+ const { multiple = true } = options;
5
+ const [selectedKeys, setSelectedKeys] = useState(
6
+ () => /* @__PURE__ */ new Set()
7
+ );
8
+ const getKeyOptionRef = useRef(options.getKey);
9
+ getKeyOptionRef.current = options.getKey;
10
+ const itemsRef = useRef(items);
11
+ itemsRef.current = items;
12
+ const multipleRef = useRef(multiple);
13
+ multipleRef.current = multiple;
14
+ const selectedKeysRef = useRef(selectedKeys);
15
+ selectedKeysRef.current = selectedKeys;
16
+ const getKey = useCallback((item) => {
17
+ const fn = getKeyOptionRef.current;
18
+ return fn ? fn(item) : item;
19
+ }, []);
20
+ const select = useCallback(
21
+ (item) => {
22
+ const key = getKey(item);
23
+ setSelectedKeys((prev) => {
24
+ if (multipleRef.current) {
25
+ if (prev.has(key)) {
26
+ return prev;
27
+ }
28
+ const next = new Set(prev);
29
+ next.add(key);
30
+ return next;
31
+ }
32
+ if (prev.size === 1 && prev.has(key)) {
33
+ return prev;
34
+ }
35
+ return /* @__PURE__ */ new Set([key]);
36
+ });
37
+ },
38
+ [getKey]
39
+ );
40
+ const deselect = useCallback(
41
+ (item) => {
42
+ const key = getKey(item);
43
+ setSelectedKeys((prev) => {
44
+ if (!prev.has(key)) {
45
+ return prev;
46
+ }
47
+ const next = new Set(prev);
48
+ next.delete(key);
49
+ return next;
50
+ });
51
+ },
52
+ [getKey]
53
+ );
54
+ const toggle = useCallback(
55
+ (item) => {
56
+ const key = getKey(item);
57
+ setSelectedKeys((prev) => {
58
+ if (prev.has(key)) {
59
+ const next = new Set(prev);
60
+ next.delete(key);
61
+ return next;
62
+ }
63
+ if (multipleRef.current) {
64
+ const next = new Set(prev);
65
+ next.add(key);
66
+ return next;
67
+ }
68
+ return /* @__PURE__ */ new Set([key]);
69
+ });
70
+ },
71
+ [getKey]
72
+ );
73
+ const selectAll = useCallback(() => {
74
+ if (!multipleRef.current) {
75
+ return;
76
+ }
77
+ setSelectedKeys((prev) => {
78
+ const currentItems = itemsRef.current;
79
+ if (currentItems.length === 0) {
80
+ return prev;
81
+ }
82
+ let changed = false;
83
+ const next = new Set(prev);
84
+ for (const item of currentItems) {
85
+ const key = getKey(item);
86
+ if (!next.has(key)) {
87
+ next.add(key);
88
+ changed = true;
89
+ }
90
+ }
91
+ return changed ? next : prev;
92
+ });
93
+ }, [getKey]);
94
+ const clear = useCallback(() => {
95
+ setSelectedKeys(
96
+ (prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set()
97
+ );
98
+ }, []);
99
+ const isSelected = useCallback(
100
+ (item) => selectedKeysRef.current.has(getKey(item)),
101
+ [getKey]
102
+ );
103
+ const { selected, isAllSelected, isPartiallySelected, isNoneSelected } = useMemo(() => {
104
+ const sel = [];
105
+ for (const item of items) {
106
+ if (selectedKeys.has(getKey(item))) {
107
+ sel.push(item);
108
+ }
109
+ }
110
+ const count = sel.length;
111
+ const total = items.length;
112
+ return {
113
+ selected: sel,
114
+ isAllSelected: total > 0 && count === total,
115
+ isPartiallySelected: count > 0 && count < total,
116
+ isNoneSelected: count === 0
117
+ };
118
+ }, [items, selectedKeys, getKey]);
119
+ return {
120
+ selected,
121
+ selectedKeys,
122
+ isSelected,
123
+ toggle,
124
+ select,
125
+ deselect,
126
+ selectAll,
127
+ clear,
128
+ isAllSelected,
129
+ isPartiallySelected,
130
+ isNoneSelected
131
+ };
132
+ }
133
+ export {
134
+ useSelection
135
+ };
136
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useSelection.ts"],"sourcesContent":["import { useCallback, useMemo, useRef, useState } from \"react\";\nimport type {\n SelectionKey,\n UseSelectionOptions,\n UseSelectionReturn,\n} from \"./types\";\n\n/**\n * Manage multi- or single-selection state for a list or table, backed by a\n * `Set` of keys.\n *\n * The `Set` stores **keys** (see the `getKey` option), never the items\n * themselves, so a selection survives new object identities across renders —\n * rebuilding the `items` array every render will not lose the selection as long\n * as `getKey` maps the same logical item to the same key. Every item-facing\n * value (`selected`, `isAllSelected`, `isPartiallySelected`, …) is **derived\n * from the current `items`**, so removing a selected row from `items` makes it\n * disappear from `selected` and recomputes the aggregate flags automatically —\n * no manual reconciliation needed.\n *\n * Features:\n * - `Set`-backed, immutable updates (a fresh `Set` on every change)\n * - No-op skipping — `selectAll` when all are already selected, and `clear`\n * when already empty, bail out without a re-render\n * - Single-selection mode (`multiple: false`) that replaces the selection\n * - Stable action identities (`toggle`/`select`/`deselect`/`selectAll`/`clear`\n * and `isSelected`) — safe as effect dependencies\n * - `isPartiallySelected` for a header checkbox's indeterminate state\n * - SSR-safe (pure state) and StrictMode/concurrent-safe (no mutation of prior\n * state, callbacks never fire from inside a `setState` updater)\n *\n * @template T - The item type.\n * @param items - The current list of items the selection is scoped to.\n * @param options - {@link UseSelectionOptions} (`getKey`, `multiple`).\n * @returns {@link UseSelectionReturn}\n *\n * @example\n * ```tsx\n * type User = { id: number; name: string };\n *\n * function UserTable({ users }: { users: User[] }) {\n * const {\n * selected,\n * isSelected,\n * toggle,\n * selectAll,\n * clear,\n * isAllSelected,\n * isPartiallySelected,\n * } = useSelection(users, { getKey: (u) => u.id });\n *\n * return (\n * <table>\n * <thead>\n * <tr>\n * <th>\n * <input\n * type=\"checkbox\"\n * checked={isAllSelected}\n * ref={(el) => {\n * if (el) el.indeterminate = isPartiallySelected;\n * }}\n * onChange={() => (isAllSelected ? clear() : selectAll())}\n * />\n * </th>\n * <th>Name ({selected.length} selected)</th>\n * </tr>\n * </thead>\n * <tbody>\n * {users.map((user) => (\n * <tr key={user.id}>\n * <td>\n * <input\n * type=\"checkbox\"\n * checked={isSelected(user)}\n * onChange={() => toggle(user)}\n * />\n * </td>\n * <td>{user.name}</td>\n * </tr>\n * ))}\n * </tbody>\n * </table>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Single-selection mode (radio-like): selecting replaces the previous choice.\n * const { selected, isSelected, select } = useSelection(options, {\n * multiple: false,\n * });\n * ```\n */\nexport function useSelection<T>(\n items: T[],\n options: UseSelectionOptions<T> = {}\n): UseSelectionReturn<T> {\n const { multiple = true } = options;\n\n const [selectedKeys, setSelectedKeys] = useState<Set<SelectionKey>>(\n () => new Set()\n );\n\n // Mirror the latest inputs so the stable actions can read fresh values\n // without being recreated each render.\n const getKeyOptionRef = useRef(options.getKey);\n getKeyOptionRef.current = options.getKey;\n\n const itemsRef = useRef(items);\n itemsRef.current = items;\n\n const multipleRef = useRef(multiple);\n multipleRef.current = multiple;\n\n const selectedKeysRef = useRef(selectedKeys);\n selectedKeysRef.current = selectedKeys;\n\n // A stable key resolver that always uses the latest `getKey`. Falls back to\n // identity (works for primitive items) when no `getKey` is provided.\n const getKey = useCallback((item: T): SelectionKey => {\n const fn = getKeyOptionRef.current;\n return fn ? fn(item) : (item as unknown as SelectionKey);\n }, []);\n\n const select = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (multipleRef.current) {\n if (prev.has(key)) {\n return prev; // already selected — no-op\n }\n const next = new Set(prev);\n next.add(key);\n return next;\n }\n // single-selection: replace with just this key\n if (prev.size === 1 && prev.has(key)) {\n return prev; // already the sole selection — no-op\n }\n return new Set<SelectionKey>([key]);\n });\n },\n [getKey]\n );\n\n const deselect = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (!prev.has(key)) {\n return prev; // not selected — no-op\n }\n const next = new Set(prev);\n next.delete(key);\n return next;\n });\n },\n [getKey]\n );\n\n const toggle = useCallback(\n (item: T) => {\n const key = getKey(item);\n setSelectedKeys((prev) => {\n if (prev.has(key)) {\n const next = new Set(prev);\n next.delete(key);\n return next;\n }\n if (multipleRef.current) {\n const next = new Set(prev);\n next.add(key);\n return next;\n }\n // single-selection: replace with just this key\n return new Set<SelectionKey>([key]);\n });\n },\n [getKey]\n );\n\n const selectAll = useCallback(() => {\n // Selecting \"all\" is a multi-selection concept.\n if (!multipleRef.current) {\n return;\n }\n setSelectedKeys((prev) => {\n const currentItems = itemsRef.current;\n if (currentItems.length === 0) {\n return prev; // nothing to select — no-op\n }\n let changed = false;\n const next = new Set(prev);\n for (const item of currentItems) {\n const key = getKey(item);\n if (!next.has(key)) {\n next.add(key);\n changed = true;\n }\n }\n return changed ? next : prev; // no-op when all already selected\n });\n }, [getKey]);\n\n const clear = useCallback(() => {\n setSelectedKeys((prev) =>\n prev.size === 0 ? prev : new Set<SelectionKey>()\n );\n }, []);\n\n const isSelected = useCallback(\n (item: T) => selectedKeysRef.current.has(getKey(item)),\n [getKey]\n );\n\n // Derive item-facing values from the current items ∩ selected keys. This is\n // what drops stale keys and keeps the aggregate flags in sync when `items`\n // changes. `getKey` is stable, so this recomputes only when `items` or the\n // selection changes.\n const { selected, isAllSelected, isPartiallySelected, isNoneSelected } =\n useMemo(() => {\n const sel: T[] = [];\n for (const item of items) {\n if (selectedKeys.has(getKey(item))) {\n sel.push(item);\n }\n }\n const count = sel.length;\n const total = items.length;\n return {\n selected: sel,\n isAllSelected: total > 0 && count === total,\n isPartiallySelected: count > 0 && count < total,\n isNoneSelected: count === 0,\n };\n }, [items, selectedKeys, getKey]);\n\n return {\n selected,\n selectedKeys,\n isSelected,\n toggle,\n select,\n deselect,\n selectAll,\n clear,\n isAllSelected,\n isPartiallySelected,\n isNoneSelected,\n };\n}\n"],"mappings":";AAAA,SAAS,aAAa,SAAS,QAAQ,gBAAgB;AA+FhD,SAAS,aACd,OACA,UAAkC,CAAC,GACZ;AACvB,QAAM,EAAE,WAAW,KAAK,IAAI;AAE5B,QAAM,CAAC,cAAc,eAAe,IAAI;AAAA,IACtC,MAAM,oBAAI,IAAI;AAAA,EAChB;AAIA,QAAM,kBAAkB,OAAO,QAAQ,MAAM;AAC7C,kBAAgB,UAAU,QAAQ;AAElC,QAAM,WAAW,OAAO,KAAK;AAC7B,WAAS,UAAU;AAEnB,QAAM,cAAc,OAAO,QAAQ;AACnC,cAAY,UAAU;AAEtB,QAAM,kBAAkB,OAAO,YAAY;AAC3C,kBAAgB,UAAU;AAI1B,QAAM,SAAS,YAAY,CAAC,SAA0B;AACpD,UAAM,KAAK,gBAAgB;AAC3B,WAAO,KAAK,GAAG,IAAI,IAAK;AAAA,EAC1B,GAAG,CAAC,CAAC;AAEL,QAAM,SAAS;AAAA,IACb,CAAC,SAAY;AACX,YAAM,MAAM,OAAO,IAAI;AACvB,sBAAgB,CAAC,SAAS;AACxB,YAAI,YAAY,SAAS;AACvB,cAAI,KAAK,IAAI,GAAG,GAAG;AACjB,mBAAO;AAAA,UACT;AACA,gBAAM,OAAO,IAAI,IAAI,IAAI;AACzB,eAAK,IAAI,GAAG;AACZ,iBAAO;AAAA,QACT;AAEA,YAAI,KAAK,SAAS,KAAK,KAAK,IAAI,GAAG,GAAG;AACpC,iBAAO;AAAA,QACT;AACA,eAAO,oBAAI,IAAkB,CAAC,GAAG,CAAC;AAAA,MACpC,CAAC;AAAA,IACH;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,QAAM,WAAW;AAAA,IACf,CAAC,SAAY;AACX,YAAM,MAAM,OAAO,IAAI;AACvB,sBAAgB,CAAC,SAAS;AACxB,YAAI,CAAC,KAAK,IAAI,GAAG,GAAG;AAClB,iBAAO;AAAA,QACT;AACA,cAAM,OAAO,IAAI,IAAI,IAAI;AACzB,aAAK,OAAO,GAAG;AACf,eAAO;AAAA,MACT,CAAC;AAAA,IACH;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,QAAM,SAAS;AAAA,IACb,CAAC,SAAY;AACX,YAAM,MAAM,OAAO,IAAI;AACvB,sBAAgB,CAAC,SAAS;AACxB,YAAI,KAAK,IAAI,GAAG,GAAG;AACjB,gBAAM,OAAO,IAAI,IAAI,IAAI;AACzB,eAAK,OAAO,GAAG;AACf,iBAAO;AAAA,QACT;AACA,YAAI,YAAY,SAAS;AACvB,gBAAM,OAAO,IAAI,IAAI,IAAI;AACzB,eAAK,IAAI,GAAG;AACZ,iBAAO;AAAA,QACT;AAEA,eAAO,oBAAI,IAAkB,CAAC,GAAG,CAAC;AAAA,MACpC,CAAC;AAAA,IACH;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,QAAM,YAAY,YAAY,MAAM;AAElC,QAAI,CAAC,YAAY,SAAS;AACxB;AAAA,IACF;AACA,oBAAgB,CAAC,SAAS;AACxB,YAAM,eAAe,SAAS;AAC9B,UAAI,aAAa,WAAW,GAAG;AAC7B,eAAO;AAAA,MACT;AACA,UAAI,UAAU;AACd,YAAM,OAAO,IAAI,IAAI,IAAI;AACzB,iBAAW,QAAQ,cAAc;AAC/B,cAAM,MAAM,OAAO,IAAI;AACvB,YAAI,CAAC,KAAK,IAAI,GAAG,GAAG;AAClB,eAAK,IAAI,GAAG;AACZ,oBAAU;AAAA,QACZ;AAAA,MACF;AACA,aAAO,UAAU,OAAO;AAAA,IAC1B,CAAC;AAAA,EACH,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,QAAQ,YAAY,MAAM;AAC9B;AAAA,MAAgB,CAAC,SACf,KAAK,SAAS,IAAI,OAAO,oBAAI,IAAkB;AAAA,IACjD;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,aAAa;AAAA,IACjB,CAAC,SAAY,gBAAgB,QAAQ,IAAI,OAAO,IAAI,CAAC;AAAA,IACrD,CAAC,MAAM;AAAA,EACT;AAMA,QAAM,EAAE,UAAU,eAAe,qBAAqB,eAAe,IACnE,QAAQ,MAAM;AACZ,UAAM,MAAW,CAAC;AAClB,eAAW,QAAQ,OAAO;AACxB,UAAI,aAAa,IAAI,OAAO,IAAI,CAAC,GAAG;AAClC,YAAI,KAAK,IAAI;AAAA,MACf;AAAA,IACF;AACA,UAAM,QAAQ,IAAI;AAClB,UAAM,QAAQ,MAAM;AACpB,WAAO;AAAA,MACL,UAAU;AAAA,MACV,eAAe,QAAQ,KAAK,UAAU;AAAA,MACtC,qBAAqB,QAAQ,KAAK,QAAQ;AAAA,MAC1C,gBAAgB,UAAU;AAAA,IAC5B;AAAA,EACF,GAAG,CAAC,OAAO,cAAc,MAAM,CAAC;AAElC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@usefy/use-selection",
3
+ "version": "0.19.0",
4
+ "description": "A React hook for managing multi/single selection state for lists and tables, backed by a Set",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "peerDependencies": {
20
+ "react": "^18.0.0 || ^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@testing-library/jest-dom": "^6.9.1",
24
+ "@testing-library/react": "^16.3.1",
25
+ "@testing-library/user-event": "^14.6.1",
26
+ "@types/react": "^19.0.0",
27
+ "jsdom": "^27.3.0",
28
+ "react": "^19.0.0",
29
+ "rimraf": "^6.0.1",
30
+ "tsup": "^8.0.0",
31
+ "typescript": "^5.0.0",
32
+ "vitest": "^4.0.16"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/mirunamu00/usefy.git",
40
+ "directory": "packages/hooks/use-selection"
41
+ },
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "react",
45
+ "hooks",
46
+ "selection",
47
+ "select",
48
+ "multi-select",
49
+ "checkbox",
50
+ "table",
51
+ "list",
52
+ "set",
53
+ "state",
54
+ "useSelection"
55
+ ],
56
+ "scripts": {
57
+ "build": "tsup",
58
+ "dev": "tsup --watch",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "typecheck": "tsc --noEmit",
62
+ "clean": "rimraf dist"
63
+ }
64
+ }