@boxcustodia/library 2.0.0-alpha.22 → 2.0.0-alpha.24
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/dist/components/calendar/calendar.cjs.js +1 -1
- package/dist/components/calendar/calendar.es.js +43 -44
- package/dist/components/date-picker/date-input.cjs.js +1 -1
- package/dist/components/date-picker/date-input.es.js +160 -140
- package/dist/components/pagination/pagination.cjs.js +1 -1
- package/dist/components/pagination/pagination.es.js +37 -35
- package/dist/components/popover/popover.cjs.js +1 -1
- package/dist/components/popover/popover.es.js +1 -1
- package/dist/components/scroll-area/scroll-area.cjs.js +1 -1
- package/dist/components/scroll-area/scroll-area.es.js +4 -4
- package/dist/components/select/select.cjs.js +1 -1
- package/dist/components/select/select.es.js +94 -90
- package/dist/components/tag/tag.cjs.js +1 -1
- package/dist/components/tag/tag.es.js +37 -18
- package/dist/hooks/use-action/use-action.cjs.js +1 -0
- package/dist/hooks/use-action/use-action.es.js +41 -0
- package/dist/hooks/use-pagination/use-pagination.cjs.js +1 -1
- package/dist/hooks/use-pagination/use-pagination.es.js +77 -32
- package/dist/hooks/use-range-pagination/use-range-pagination.cjs.js +1 -1
- package/dist/hooks/use-range-pagination/use-range-pagination.es.js +8 -5
- package/dist/hooks/use-selection/use-selection.cjs.js +1 -1
- package/dist/hooks/use-selection/use-selection.es.js +95 -33
- package/dist/hooks/use-session-storage/use-session-storage.cjs.js +1 -0
- package/dist/hooks/use-session-storage/use-session-storage.es.js +57 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +61 -63
- package/dist/src/components/select/select.d.ts +9 -2
- package/dist/src/components/tag/tag.d.ts +2 -1
- package/dist/src/hooks/index.d.ts +2 -3
- package/dist/src/hooks/internal/index.d.ts +1 -0
- package/dist/src/hooks/internal/serializer.d.ts +4 -0
- package/dist/src/hooks/use-action/index.d.ts +1 -0
- package/dist/src/hooks/use-action/use-action.d.ts +22 -0
- package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +2 -4
- package/dist/src/hooks/use-pagination/use-pagination.d.ts +47 -32
- package/dist/src/hooks/use-range-pagination/use-range-pagination.d.ts +16 -10
- package/dist/src/hooks/use-selection/use-selection.d.ts +39 -45
- package/dist/src/hooks/use-session-storage/index.d.ts +1 -0
- package/dist/src/hooks/use-session-storage/use-session-storage.d.ts +11 -0
- package/package.json +1 -1
- package/src/components/calendar/calendar.tsx +10 -8
- package/src/components/combobox/combobox.stories.tsx +16 -0
- package/src/components/date-picker/date-input.tsx +23 -2
- package/src/components/form/form.tsx +3 -2
- package/src/components/pagination/pagination.tsx +5 -3
- package/src/components/popover/popover.tsx +1 -1
- package/src/components/scroll-area/scroll-area.tsx +2 -2
- package/src/components/select/select.tsx +14 -3
- package/src/components/tag/tag.stories.tsx +47 -2
- package/src/components/tag/tag.tsx +28 -6
- package/src/hooks/index.ts +2 -3
- package/src/hooks/internal/index.ts +1 -0
- package/src/hooks/internal/serializer.ts +4 -0
- package/src/hooks/use-action/index.ts +1 -0
- package/src/hooks/{use-mutation/use-mutation.stories.tsx → use-action/use-action.stories.tsx} +34 -34
- package/src/hooks/{use-mutation/use-mutation.test.ts → use-action/use-action.test.ts} +53 -53
- package/src/hooks/{use-mutation/use-mutation.ts → use-action/use-action.ts} +20 -20
- package/src/hooks/use-click-outside/use-click-outside.stories.tsx +0 -1
- package/src/hooks/use-clipboard/use-clipboard.stories.tsx +0 -1
- package/src/hooks/use-document-title/use-document-title.stories.tsx +0 -1
- package/src/hooks/use-is-visible/use-is-visible.test.tsx +1 -1
- package/src/hooks/use-local-storage/use-local-storage.stories.tsx +0 -1
- package/src/hooks/use-local-storage/use-local-storage.ts +2 -5
- package/src/hooks/use-media-query/use-media-query.stories.tsx +0 -1
- package/src/hooks/use-pagination/use-pagination.stories.tsx +720 -57
- package/src/hooks/use-pagination/use-pagination.test.tsx +560 -48
- package/src/hooks/use-pagination/use-pagination.ts +266 -0
- package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +0 -1
- package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +2 -2
- package/src/hooks/use-range-pagination/use-range-pagination.tsx +24 -21
- package/src/hooks/use-selection/use-selection.stories.tsx +339 -84
- package/src/hooks/use-selection/use-selection.test.tsx +417 -2
- package/src/hooks/use-selection/use-selection.ts +212 -102
- package/src/hooks/use-session-storage/index.ts +1 -0
- package/src/hooks/use-session-storage/use-session-storage.stories.tsx +122 -0
- package/src/hooks/use-session-storage/use-session-storage.test.ts +164 -0
- package/src/hooks/use-session-storage/use-session-storage.ts +115 -0
- package/dist/hooks/use-async/use-async.cjs.js +0 -1
- package/dist/hooks/use-async/use-async.es.js +0 -57
- package/dist/hooks/use-focus-trap/scope-tab.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/scope-tab.es.js +0 -21
- package/dist/hooks/use-focus-trap/tabbable.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/tabbable.es.js +0 -38
- package/dist/hooks/use-focus-trap/use-focus-trap.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/use-focus-trap.es.js +0 -34
- package/dist/hooks/use-mutation/use-mutation.cjs.js +0 -1
- package/dist/hooks/use-mutation/use-mutation.es.js +0 -41
- package/dist/src/hooks/use-async/index.d.ts +0 -1
- package/dist/src/hooks/use-async/use-async.d.ts +0 -21
- package/dist/src/hooks/use-focus-trap/index.d.ts +0 -1
- package/dist/src/hooks/use-focus-trap/scope-tab.d.ts +0 -1
- package/dist/src/hooks/use-focus-trap/tabbable.d.ts +0 -4
- package/dist/src/hooks/use-focus-trap/use-focus-trap.d.ts +0 -1
- package/dist/src/hooks/use-mutation/index.d.ts +0 -1
- package/dist/src/hooks/use-mutation/use-mutation.d.ts +0 -22
- package/dist/src/hooks/use-mutation/use-mutation.test.d.ts +0 -1
- package/src/hooks/use-async/index.ts +0 -1
- package/src/hooks/use-async/use-async.stories.tsx +0 -272
- package/src/hooks/use-async/use-async.test.ts +0 -397
- package/src/hooks/use-async/use-async.ts +0 -135
- package/src/hooks/use-focus-trap/index.ts +0 -1
- package/src/hooks/use-focus-trap/scope-tab.ts +0 -38
- package/src/hooks/use-focus-trap/tabbable.ts +0 -70
- package/src/hooks/use-focus-trap/use-focus-trap.stories.tsx +0 -37
- package/src/hooks/use-focus-trap/use-focus-trap.test.ts +0 -355
- package/src/hooks/use-focus-trap/use-focus-trap.ts +0 -78
- package/src/hooks/use-mutation/index.ts +0 -1
- package/src/hooks/use-pagination/use-pagination.tsx +0 -84
- /package/dist/src/hooks/{use-async/use-async.test.d.ts → use-action/use-action.test.d.ts} +0 -0
- /package/dist/src/hooks/{use-focus-trap/use-focus-trap.test.d.ts → use-session-storage/use-session-storage.test.d.ts} +0 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import {
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
2
|
+
import { useState } from "react";
|
|
4
3
|
import { Button, Checkbox } from "../../components";
|
|
5
4
|
import { cn } from "../../lib";
|
|
6
5
|
import { useSelection } from "../use-selection";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* `useSelection` manages item selection in a list. v2 adds key-based identity,
|
|
9
|
+
* `defaultSelected`, `onChange`, `selectedKeys`, `selectAll`, `invert`, and
|
|
10
|
+
* `selectRange` — while keeping all v1 actions stable across renders.
|
|
12
11
|
*/
|
|
13
12
|
const meta: Meta = {
|
|
14
13
|
title: "hooks/useSelection",
|
|
@@ -18,122 +17,378 @@ const meta: Meta = {
|
|
|
18
17
|
export default meta;
|
|
19
18
|
type Story = StoryObj<typeof meta>;
|
|
20
19
|
|
|
20
|
+
// ─── Default ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Basic string list showcasing select/toggle/clear/toggleAll/selectAll.
|
|
24
|
+
* All flags (`isAllSelected`, `isSomeSelected`, `isNoneSelected`) are shown live.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fruits = ["Apple", "Banana", "Cherry", "Dragonfruit", "Elderberry"];
|
|
21
28
|
export const Default: Story = {
|
|
22
29
|
render: () => {
|
|
23
|
-
const items = useMemo(() => ["🍆", "🍅", "🥑", "🥬"], []);
|
|
24
30
|
const {
|
|
25
31
|
selected,
|
|
32
|
+
isSelected,
|
|
26
33
|
toggle,
|
|
27
|
-
toggleAll,
|
|
28
34
|
clear,
|
|
29
|
-
|
|
35
|
+
toggleAll,
|
|
36
|
+
selectAll,
|
|
30
37
|
isAllSelected,
|
|
38
|
+
isSomeSelected,
|
|
31
39
|
isNoneSelected,
|
|
32
|
-
|
|
33
|
-
} = useSelection(items);
|
|
40
|
+
} = useSelection(fruits);
|
|
34
41
|
|
|
35
42
|
return (
|
|
36
|
-
<div className="space-y-4">
|
|
37
|
-
<div className="
|
|
43
|
+
<div className="space-y-4 p-4">
|
|
44
|
+
<div className="space-y-1">
|
|
45
|
+
{fruits.map((fruit) => (
|
|
46
|
+
<div key={fruit} className="flex items-center gap-2">
|
|
47
|
+
<Checkbox
|
|
48
|
+
name={fruit}
|
|
49
|
+
checked={isSelected(fruit)}
|
|
50
|
+
onCheckedChange={() => toggle(fruit)}
|
|
51
|
+
/>
|
|
52
|
+
<span className="text-sm">{fruit}</span>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="flex flex-wrap gap-2">
|
|
58
|
+
<Button size="sm" variant="outline" onClick={toggleAll}>
|
|
59
|
+
Toggle all
|
|
60
|
+
</Button>
|
|
61
|
+
<Button size="sm" variant="outline" onClick={selectAll}>
|
|
62
|
+
Select all
|
|
63
|
+
</Button>
|
|
64
|
+
<Button size="sm" variant="outline" onClick={clear}>
|
|
65
|
+
Clear
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
70
|
+
<code>
|
|
71
|
+
{JSON.stringify(
|
|
72
|
+
{ selected, isAllSelected, isSomeSelected, isNoneSelected },
|
|
73
|
+
null,
|
|
74
|
+
2,
|
|
75
|
+
)}
|
|
76
|
+
</code>
|
|
77
|
+
</pre>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ─── WithKeyFn ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Demonstrates key-based identity. Clicking "Refetch" replaces the items array
|
|
87
|
+
* with structurally-equal but referentially-new objects. With `keyFn`, selection
|
|
88
|
+
* survives the re-creation; without it, selection would be silently lost.
|
|
89
|
+
*/
|
|
90
|
+
export const WithKeyFn: Story = {
|
|
91
|
+
render: () => {
|
|
92
|
+
const [items, setItems] = useState([
|
|
93
|
+
{ id: 1, name: "Alice" },
|
|
94
|
+
{ id: 2, name: "Bob" },
|
|
95
|
+
{ id: 3, name: "Carol" },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const { isSelected, toggle, selected, selectedKeys } = useSelection(items, {
|
|
99
|
+
keyFn: (item) => item.id,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const handleRefetch = () => {
|
|
103
|
+
// Simulate a re-fetch: same data, brand-new object references
|
|
104
|
+
setItems((prev) => prev.map((item) => ({ ...item })));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="space-y-4 p-4">
|
|
109
|
+
<p className="text-sm text-slate-500">
|
|
110
|
+
Click "Refetch" to replace the array with new object references.
|
|
111
|
+
Selection persists because <code>keyFn</code> uses stable IDs.
|
|
112
|
+
</p>
|
|
113
|
+
|
|
114
|
+
<div className="space-y-1">
|
|
38
115
|
{items.map((item) => (
|
|
116
|
+
<div key={item.id} className="flex items-center gap-2">
|
|
117
|
+
<Checkbox
|
|
118
|
+
name={String(item.id)}
|
|
119
|
+
checked={isSelected(item)}
|
|
120
|
+
onCheckedChange={() => toggle(item)}
|
|
121
|
+
/>
|
|
122
|
+
<span className="text-sm">{item.name}</span>
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<Button size="sm" variant="outline" onClick={handleRefetch}>
|
|
128
|
+
Refetch (re-create object references)
|
|
129
|
+
</Button>
|
|
130
|
+
|
|
131
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
132
|
+
<code>
|
|
133
|
+
{JSON.stringify(
|
|
134
|
+
{
|
|
135
|
+
selected: selected.map((i) => i.name),
|
|
136
|
+
selectedKeys: [...selectedKeys],
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
)}
|
|
141
|
+
</code>
|
|
142
|
+
</pre>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ─── SelectRange ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Shift-click range selection. Click a row to set the anchor, then
|
|
152
|
+
* Shift+click another row to select the inclusive range between them.
|
|
153
|
+
*/
|
|
154
|
+
export const SelectRange: Story = {
|
|
155
|
+
render: () => {
|
|
156
|
+
const items = [
|
|
157
|
+
"Document 1",
|
|
158
|
+
"Document 2",
|
|
159
|
+
"Document 3",
|
|
160
|
+
"Document 4",
|
|
161
|
+
"Document 5",
|
|
162
|
+
"Document 6",
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const [anchor, setAnchor] = useState<number | null>(null);
|
|
166
|
+
const { isSelected, toggle, selectRange, selected, clear } =
|
|
167
|
+
useSelection(items);
|
|
168
|
+
|
|
169
|
+
const handleClick = (index: number, shiftKey: boolean) => {
|
|
170
|
+
if (shiftKey && anchor !== null) {
|
|
171
|
+
selectRange(anchor, index);
|
|
172
|
+
} else {
|
|
173
|
+
toggle(items[index]);
|
|
174
|
+
setAnchor(index);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="space-y-4 p-4">
|
|
180
|
+
<p className="text-sm text-slate-500">
|
|
181
|
+
Click to toggle. Hold Shift and click to select a range from the last
|
|
182
|
+
clicked row.
|
|
183
|
+
</p>
|
|
184
|
+
|
|
185
|
+
<div className="rounded-md border">
|
|
186
|
+
{items.map((item, index) => (
|
|
39
187
|
<div
|
|
40
188
|
key={item}
|
|
41
|
-
onClick={() => toggle(item)}
|
|
42
189
|
className={cn(
|
|
43
|
-
"
|
|
44
|
-
|
|
190
|
+
"flex cursor-pointer select-none items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0",
|
|
191
|
+
isSelected(item) ? "bg-blue-50" : "hover:bg-slate-50",
|
|
45
192
|
)}
|
|
193
|
+
onClick={(e) => handleClick(index, e.shiftKey)}
|
|
46
194
|
>
|
|
47
|
-
|
|
195
|
+
<Checkbox
|
|
196
|
+
name={item}
|
|
197
|
+
checked={isSelected(item)}
|
|
198
|
+
onCheckedChange={() => handleClick(index, false)}
|
|
199
|
+
/>
|
|
200
|
+
<span>{item}</span>
|
|
201
|
+
{anchor === index && (
|
|
202
|
+
<span className="ml-auto text-xs text-slate-400">anchor</span>
|
|
203
|
+
)}
|
|
48
204
|
</div>
|
|
49
205
|
))}
|
|
50
206
|
</div>
|
|
51
207
|
|
|
52
|
-
<div className="flex gap-
|
|
53
|
-
<Button onClick={clear}>
|
|
54
|
-
|
|
55
|
-
|
|
208
|
+
<div className="flex gap-2">
|
|
209
|
+
<Button size="sm" variant="outline" onClick={clear}>
|
|
210
|
+
Clear
|
|
211
|
+
</Button>
|
|
56
212
|
</div>
|
|
57
213
|
|
|
58
|
-
<pre className="
|
|
59
|
-
<code
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
214
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
215
|
+
<code>{JSON.stringify({ selected }, null, 2)}</code>
|
|
216
|
+
</pre>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ─── Invert ───────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Shows `invert()` which flips the current selection relative to `items`.
|
|
226
|
+
* None → All, All → None, Partial → complement.
|
|
227
|
+
*/
|
|
228
|
+
export const Invert: Story = {
|
|
229
|
+
render: () => {
|
|
230
|
+
const tags = ["React", "TypeScript", "Tailwind", "Vite", "Biome", "Vitest"];
|
|
231
|
+
const { isSelected, toggle, invert, selectAll, clear, selected } =
|
|
232
|
+
useSelection(tags);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div className="space-y-4 p-4">
|
|
236
|
+
<div className="flex flex-wrap gap-2">
|
|
237
|
+
{tags.map((tag) => (
|
|
238
|
+
<button
|
|
239
|
+
key={tag}
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={() => toggle(tag)}
|
|
242
|
+
className={cn(
|
|
243
|
+
"rounded-full border px-3 py-1 text-sm transition-colors",
|
|
244
|
+
isSelected(tag)
|
|
245
|
+
? "border-blue-500 bg-blue-500 text-white"
|
|
246
|
+
: "border-slate-300 bg-white text-slate-700 hover:border-slate-400",
|
|
247
|
+
)}
|
|
75
248
|
>
|
|
76
|
-
{
|
|
77
|
-
</
|
|
78
|
-
|
|
249
|
+
{tag}
|
|
250
|
+
</button>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="flex flex-wrap gap-2">
|
|
255
|
+
<Button size="sm" variant="outline" onClick={invert}>
|
|
256
|
+
Invert selection
|
|
257
|
+
</Button>
|
|
258
|
+
<Button size="sm" variant="outline" onClick={selectAll}>
|
|
259
|
+
Select all
|
|
260
|
+
</Button>
|
|
261
|
+
<Button size="sm" variant="outline" onClick={clear}>
|
|
262
|
+
Clear
|
|
263
|
+
</Button>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
267
|
+
<code>{JSON.stringify({ selected }, null, 2)}</code>
|
|
79
268
|
</pre>
|
|
80
269
|
</div>
|
|
81
270
|
);
|
|
82
271
|
},
|
|
83
272
|
};
|
|
84
273
|
|
|
85
|
-
|
|
86
|
-
|
|
274
|
+
// ─── DefaultSelected ──────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Pre-initializes selection via `defaultSelected`. The hook seeds the
|
|
278
|
+
* selection once on mount and ignores further changes to the option.
|
|
279
|
+
*/
|
|
280
|
+
export const DefaultSelected: Story = {
|
|
87
281
|
render: () => {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
{ id: 3, name: "Item 3" },
|
|
93
|
-
]);
|
|
282
|
+
const items = ["Option A", "Option B", "Option C", "Option D"];
|
|
283
|
+
const { isSelected, toggle, selected } = useSelection(items, {
|
|
284
|
+
defaultSelected: ["Option B", "Option D"],
|
|
285
|
+
});
|
|
94
286
|
|
|
95
|
-
|
|
287
|
+
return (
|
|
288
|
+
<div className="space-y-4 p-4">
|
|
289
|
+
<p className="text-sm text-slate-500">
|
|
290
|
+
Options B and D are pre-selected via <code>defaultSelected</code>.
|
|
291
|
+
</p>
|
|
96
292
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
293
|
+
<div className="space-y-1">
|
|
294
|
+
{items.map((item) => (
|
|
295
|
+
<div key={item} className="flex items-center gap-2">
|
|
296
|
+
<Checkbox
|
|
297
|
+
name={item}
|
|
298
|
+
checked={isSelected(item)}
|
|
299
|
+
onCheckedChange={() => toggle(item)}
|
|
300
|
+
/>
|
|
301
|
+
<span className="text-sm">{item}</span>
|
|
302
|
+
</div>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
101
305
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
306
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
307
|
+
<code>{JSON.stringify({ selected }, null, 2)}</code>
|
|
308
|
+
</pre>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ─── Controlled ───────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* External state drives selection via `setSelected`. `onChange` fires after
|
|
318
|
+
* each change and logs the derived `selected` items (orphaned keys excluded).
|
|
319
|
+
*/
|
|
320
|
+
export const Controlled: Story = {
|
|
321
|
+
render: () => {
|
|
322
|
+
const items = [
|
|
323
|
+
{ id: 1, label: "Task 1" },
|
|
324
|
+
{ id: 2, label: "Task 2" },
|
|
325
|
+
{ id: 3, label: "Task 3" },
|
|
326
|
+
{ id: 4, label: "Task 4" },
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
const [log, setLog] = useState<string[]>([]);
|
|
330
|
+
|
|
331
|
+
const { isSelected, toggle, setSelected, selected, clear } = useSelection(
|
|
332
|
+
items,
|
|
333
|
+
{
|
|
334
|
+
keyFn: (item) => item.id,
|
|
335
|
+
onChange: (newSelected) => {
|
|
336
|
+
setLog((prev) => [
|
|
337
|
+
`onChange → [${newSelected.map((i) => i.label).join(", ")}]`,
|
|
338
|
+
...prev.slice(0, 4),
|
|
339
|
+
]);
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
);
|
|
106
343
|
|
|
107
344
|
return (
|
|
108
|
-
<div>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
345
|
+
<div className="space-y-4 p-4">
|
|
346
|
+
<div className="space-y-1">
|
|
347
|
+
{items.map((item) => (
|
|
348
|
+
<div key={item.id} className="flex items-center gap-2">
|
|
349
|
+
<Checkbox
|
|
350
|
+
name={String(item.id)}
|
|
351
|
+
checked={isSelected(item)}
|
|
352
|
+
onCheckedChange={() => toggle(item)}
|
|
353
|
+
/>
|
|
354
|
+
<span className="text-sm">{item.label}</span>
|
|
355
|
+
</div>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div className="flex flex-wrap gap-2">
|
|
360
|
+
<Button
|
|
361
|
+
size="sm"
|
|
362
|
+
variant="outline"
|
|
363
|
+
onClick={() => setSelected([items[0], items[2]])}
|
|
364
|
+
>
|
|
365
|
+
Set [Task 1, Task 3]
|
|
366
|
+
</Button>
|
|
367
|
+
<Button size="sm" variant="outline" onClick={clear}>
|
|
368
|
+
Clear
|
|
369
|
+
</Button>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<pre className="rounded-md bg-slate-950 p-4 text-xs text-white">
|
|
373
|
+
<code>
|
|
374
|
+
{JSON.stringify(
|
|
375
|
+
{ selected: selected.map((i) => i.label) },
|
|
376
|
+
null,
|
|
377
|
+
2,
|
|
378
|
+
)}
|
|
135
379
|
</code>
|
|
136
380
|
</pre>
|
|
381
|
+
|
|
382
|
+
{log.length > 0 && (
|
|
383
|
+
<div className="space-y-1">
|
|
384
|
+
<p className="text-xs font-medium text-slate-500">onChange log:</p>
|
|
385
|
+
{log.map((entry, i) => (
|
|
386
|
+
<p key={i} className="font-mono text-xs text-slate-600">
|
|
387
|
+
{entry}
|
|
388
|
+
</p>
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
137
392
|
</div>
|
|
138
393
|
);
|
|
139
394
|
},
|