@coinbase/cds-common 8.19.1 → 8.20.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file.
8
8
 
9
9
  <!-- template-start -->
10
10
 
11
+ ## 8.20.1 ((11/7/2025, 01:20 PM PST))
12
+
13
+ This is an artificial version bump with no new change.
14
+
15
+ ## 8.20.0 (11/7/2025 PST)
16
+
17
+ #### 🚀 Updates
18
+
19
+ - Add useMultiSelect hook. [[#21](https://github.com/coinbase/cds/pull/21)]
20
+
11
21
  ## 8.19.1 ((11/4/2025, 08:48 AM PST))
12
22
 
13
23
  This is an artificial version bump with no new change.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Options for configuring the useMultiSelect hook
3
+ */
4
+ export type MultiSelectOptions = {
5
+ /** Initial array of selected values */
6
+ initialValue: string[] | null;
7
+ };
8
+ /**
9
+ * API returned by the useMultiSelect hook for managing multi-select state
10
+ */
11
+ export type MultiSelectApi<T extends string> = {
12
+ /** Current array of selected values */
13
+ value: T[];
14
+ /** Handler for toggling selection of one or more values.
15
+ * When a single value is passed, it will be added to the selection if it is not already selected.
16
+ * If the value is already selected, it will be removed from the selection.
17
+ * When an array of values is passed, all values will be added to the selection.
18
+ * When null is passed, all values will be removed from the selection.
19
+ */
20
+ onChange: (value: string | string[] | null) => void;
21
+ /** Add one or more values to the selection */
22
+ addSelection: (value: string | string[]) => void;
23
+ /** Remove one or more values from the selection */
24
+ removeSelection: (value: string | string[]) => void;
25
+ /** Clear all selected values */
26
+ resetSelection: () => void;
27
+ };
28
+ /**
29
+ * Hook for managing multi-select state with convenient API methods
30
+ * @param options - Configuration options including initial value
31
+ * @returns API object for managing multi-select state
32
+ */
33
+ export declare const useMultiSelect: <T extends string>({
34
+ initialValue,
35
+ }: MultiSelectOptions) => MultiSelectApi<T>;
36
+ //# sourceMappingURL=useMultiSelect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMultiSelect.d.ts","sourceRoot":"","sources":["../../src/select/useMultiSelect.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,uCAAuC;IACvC,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI;IAC7C,uCAAuC;IACvC,KAAK,EAAE,CAAC,EAAE,CAAC;IACX;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACpD,8CAA8C;IAC9C,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,IAAI,CAAC;IACjD,mDAAmD;IACnD,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,IAAI,CAAC;IACpD,gCAAgC;IAChC,cAAc,EAAE,MAAM,IAAI,CAAC;CAC5B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,EAAE,mBAE9C,kBAAkB,KAAG,cAAc,CAAC,CAAC,CAmDvC,CAAC"}
@@ -0,0 +1,67 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ /**
4
+ * Options for configuring the useMultiSelect hook
5
+ */
6
+
7
+ /**
8
+ * API returned by the useMultiSelect hook for managing multi-select state
9
+ */
10
+
11
+ /**
12
+ * Hook for managing multi-select state with convenient API methods
13
+ * @param options - Configuration options including initial value
14
+ * @returns API object for managing multi-select state
15
+ */
16
+ export const useMultiSelect = _ref => {
17
+ let {
18
+ initialValue
19
+ } = _ref;
20
+ const [value, setValue] = useState(initialValue !== null && initialValue !== void 0 ? initialValue : []);
21
+ const onChange = useCallback(value => {
22
+ if (value === null) return setValue([]);
23
+ setValue(prev => {
24
+ if (Array.isArray(value)) {
25
+ const valuesToKeep = prev.filter(v => !value.includes(v));
26
+ const valuesToAdd = value.filter(v => !prev.includes(v));
27
+ return [...valuesToKeep, ...valuesToAdd];
28
+ }
29
+ if (!prev.includes(value)) return [...prev, value];
30
+ return prev.filter(v => v !== value);
31
+ });
32
+ }, []);
33
+ const addSelection = useCallback(value => {
34
+ setValue(prev => {
35
+ if (Array.isArray(value)) {
36
+ const newValue = [...prev];
37
+ for (const v of value) {
38
+ if (!newValue.includes(v)) newValue.push(v);
39
+ }
40
+ return newValue;
41
+ }
42
+ if (prev.includes(value)) return prev;
43
+ return [...prev, value];
44
+ });
45
+ }, []);
46
+ const removeSelection = useCallback(value => {
47
+ setValue(prev => {
48
+ if (Array.isArray(value)) return prev.filter(v => !value.includes(v));
49
+ if (!prev.includes(value)) return prev;
50
+ return prev.filter(v => v !== value);
51
+ });
52
+ }, []);
53
+ const resetSelection = useCallback(() => {
54
+ setValue(prev => {
55
+ if (prev.length === 0) return prev;
56
+ return [];
57
+ });
58
+ }, []);
59
+ const api = useMemo(() => ({
60
+ value,
61
+ onChange,
62
+ addSelection,
63
+ removeSelection,
64
+ resetSelection
65
+ }), [value, onChange, addSelection, removeSelection, resetSelection]);
66
+ return api;
67
+ };
@@ -0,0 +1,280 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useMultiSelect } from './useMultiSelect';
3
+ describe('useMultiSelect', () => {
4
+ describe('initialization', () => {
5
+ it('should initialize with empty array when initialValue is null', () => {
6
+ const {
7
+ result
8
+ } = renderHook(() => useMultiSelect({
9
+ initialValue: null
10
+ }));
11
+ expect(result.current.value).toEqual([]);
12
+ });
13
+ it('should initialize with provided initialValue', () => {
14
+ const initialValue = ['option1', 'option2'];
15
+ const {
16
+ result
17
+ } = renderHook(() => useMultiSelect({
18
+ initialValue
19
+ }));
20
+ expect(result.current.value).toEqual(initialValue);
21
+ });
22
+ it('should return the correct API shape', () => {
23
+ const {
24
+ result
25
+ } = renderHook(() => useMultiSelect({
26
+ initialValue: null
27
+ }));
28
+ expect(result.current).toHaveProperty('value');
29
+ expect(result.current).toHaveProperty('onChange');
30
+ expect(result.current).toHaveProperty('addSelection');
31
+ expect(result.current).toHaveProperty('removeSelection');
32
+ expect(result.current).toHaveProperty('resetSelection');
33
+ });
34
+ });
35
+ describe('onChange', () => {
36
+ it('should add a single value when it does not exist', () => {
37
+ const {
38
+ result
39
+ } = renderHook(() => useMultiSelect({
40
+ initialValue: ['option1']
41
+ }));
42
+ act(() => {
43
+ result.current.onChange('option2');
44
+ });
45
+ expect(result.current.value).toEqual(['option1', 'option2']);
46
+ });
47
+ it('should remove a single value when it already exists', () => {
48
+ const {
49
+ result
50
+ } = renderHook(() => useMultiSelect({
51
+ initialValue: ['option1', 'option2']
52
+ }));
53
+ act(() => {
54
+ result.current.onChange('option2');
55
+ });
56
+ expect(result.current.value).toEqual(['option1']);
57
+ });
58
+ it('should add multiple values from an array', () => {
59
+ const {
60
+ result
61
+ } = renderHook(() => useMultiSelect({
62
+ initialValue: ['option1']
63
+ }));
64
+ act(() => {
65
+ result.current.onChange(['option2', 'option3']);
66
+ });
67
+ expect(result.current.value).toEqual(['option1', 'option2', 'option3']);
68
+ });
69
+ it('should add new values when array contains existing values and remove existing values', () => {
70
+ const {
71
+ result
72
+ } = renderHook(() => useMultiSelect({
73
+ initialValue: ['option1', 'option2']
74
+ }));
75
+ act(() => {
76
+ result.current.onChange(['option2', 'option3']);
77
+ });
78
+ expect(result.current.value).toEqual(['option1', 'option3']);
79
+ });
80
+ it('should clear value when null is passed', () => {
81
+ const {
82
+ result
83
+ } = renderHook(() => useMultiSelect({
84
+ initialValue: ['option1', 'option2']
85
+ }));
86
+ act(() => {
87
+ result.current.onChange(null);
88
+ });
89
+ expect(result.current.value).toEqual([]);
90
+ });
91
+ });
92
+ describe('addSelection', () => {
93
+ it('should add a single value when it does not exist', () => {
94
+ const {
95
+ result
96
+ } = renderHook(() => useMultiSelect({
97
+ initialValue: ['option1']
98
+ }));
99
+ act(() => {
100
+ result.current.addSelection('option2');
101
+ });
102
+ expect(result.current.value).toEqual(['option1', 'option2']);
103
+ });
104
+ it('should not add a single value when it already exists', () => {
105
+ const {
106
+ result
107
+ } = renderHook(() => useMultiSelect({
108
+ initialValue: ['option1', 'option2']
109
+ }));
110
+ act(() => {
111
+ result.current.addSelection('option2');
112
+ });
113
+ expect(result.current.value).toEqual(['option1', 'option2']);
114
+ });
115
+ it('should add multiple values from an array', () => {
116
+ const {
117
+ result
118
+ } = renderHook(() => useMultiSelect({
119
+ initialValue: ['option1']
120
+ }));
121
+ act(() => {
122
+ result.current.addSelection(['option2', 'option3']);
123
+ });
124
+ expect(result.current.value).toEqual(['option1', 'option2', 'option3']);
125
+ });
126
+ it('should not add duplicate values when adding multiple times', () => {
127
+ const {
128
+ result
129
+ } = renderHook(() => useMultiSelect({
130
+ initialValue: ['option1', 'option2']
131
+ }));
132
+ act(() => {
133
+ result.current.addSelection(['option2', 'option3']);
134
+ });
135
+ expect(result.current.value).toEqual(['option1', 'option2', 'option3']);
136
+ });
137
+ it('should handle adding single value to empty selection', () => {
138
+ const {
139
+ result
140
+ } = renderHook(() => useMultiSelect({
141
+ initialValue: []
142
+ }));
143
+ act(() => {
144
+ result.current.addSelection(['option1']);
145
+ });
146
+ expect(result.current.value).toEqual(['option1']);
147
+ });
148
+ });
149
+ describe('removeSelection', () => {
150
+ it('should remove a single value when it exists', () => {
151
+ const {
152
+ result
153
+ } = renderHook(() => useMultiSelect({
154
+ initialValue: ['option1', 'option2']
155
+ }));
156
+ act(() => {
157
+ result.current.removeSelection('option2');
158
+ });
159
+ expect(result.current.value).toEqual(['option1']);
160
+ });
161
+ it('should not change value when removing non-existent value', () => {
162
+ const {
163
+ result
164
+ } = renderHook(() => useMultiSelect({
165
+ initialValue: ['option1']
166
+ }));
167
+ act(() => {
168
+ result.current.removeSelection('option2');
169
+ });
170
+ expect(result.current.value).toEqual(['option1']);
171
+ });
172
+ it('should remove multiple values sequentially', () => {
173
+ const {
174
+ result
175
+ } = renderHook(() => useMultiSelect({
176
+ initialValue: ['option1', 'option2', 'option3']
177
+ }));
178
+ act(() => {
179
+ result.current.removeSelection(['option1', 'option3']);
180
+ });
181
+ expect(result.current.value).toEqual(['option2']);
182
+ });
183
+ it('should handle removal of non-existent values', () => {
184
+ const {
185
+ result
186
+ } = renderHook(() => useMultiSelect({
187
+ initialValue: ['option1', 'option2']
188
+ }));
189
+ act(() => {
190
+ result.current.removeSelection(['option3', 'option4']);
191
+ });
192
+ expect(result.current.value).toEqual(['option1', 'option2']);
193
+ });
194
+ it('should handle removal from empty selection', () => {
195
+ const {
196
+ result
197
+ } = renderHook(() => useMultiSelect({
198
+ initialValue: []
199
+ }));
200
+ act(() => {
201
+ result.current.removeSelection(['option1']);
202
+ });
203
+ expect(result.current.value).toEqual([]);
204
+ });
205
+ });
206
+ describe('resetSelection', () => {
207
+ it('should clear all values', () => {
208
+ const {
209
+ result
210
+ } = renderHook(() => useMultiSelect({
211
+ initialValue: ['option1', 'option2']
212
+ }));
213
+ act(() => {
214
+ result.current.resetSelection();
215
+ });
216
+ expect(result.current.value).toEqual([]);
217
+ });
218
+ it('should handle reset when already empty', () => {
219
+ const {
220
+ result
221
+ } = renderHook(() => useMultiSelect({
222
+ initialValue: []
223
+ }));
224
+ act(() => {
225
+ result.current.resetSelection();
226
+ });
227
+ expect(result.current.value).toEqual([]);
228
+ });
229
+ });
230
+ describe('complex scenarios', () => {
231
+ it('should handle multiple operations in sequence', () => {
232
+ const {
233
+ result
234
+ } = renderHook(() => useMultiSelect({
235
+ initialValue: ['option1']
236
+ }));
237
+ act(() => {
238
+ result.current.addSelection('option2');
239
+ });
240
+ expect(result.current.value).toEqual(['option1', 'option2']);
241
+ act(() => {
242
+ result.current.onChange('option3');
243
+ });
244
+ expect(result.current.value).toEqual(['option1', 'option2', 'option3']);
245
+ act(() => {
246
+ result.current.removeSelection('option1');
247
+ result.current.removeSelection('option3');
248
+ });
249
+ expect(result.current.value).toEqual(['option2']);
250
+ act(() => {
251
+ result.current.addSelection('option4');
252
+ result.current.addSelection('option5');
253
+ });
254
+ expect(result.current.value).toEqual(['option2', 'option4', 'option5']);
255
+ act(() => {
256
+ result.current.resetSelection();
257
+ });
258
+ expect(result.current.value).toEqual([]);
259
+ });
260
+ it('should handle toggling the same value multiple times', () => {
261
+ const {
262
+ result
263
+ } = renderHook(() => useMultiSelect({
264
+ initialValue: []
265
+ }));
266
+ act(() => {
267
+ result.current.onChange('option1');
268
+ });
269
+ expect(result.current.value).toEqual(['option1']);
270
+ act(() => {
271
+ result.current.onChange('option1');
272
+ });
273
+ expect(result.current.value).toEqual([]);
274
+ act(() => {
275
+ result.current.onChange('option1');
276
+ });
277
+ expect(result.current.value).toEqual(['option1']);
278
+ });
279
+ });
280
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinbase/cds-common",
3
- "version": "8.19.1",
3
+ "version": "8.20.1",
4
4
  "description": "Coinbase Design System - Common",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,8 +39,8 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@coinbase/cds-icons": "^5.5.1",
42
- "@coinbase/cds-illustrations": "^4.26.0",
43
- "@coinbase/cds-mcp-server": "^8.19.1",
42
+ "@coinbase/cds-illustrations": "^4.26.1",
43
+ "@coinbase/cds-mcp-server": "^8.20.1",
44
44
  "@coinbase/cds-utils": "^2.3.4",
45
45
  "@modelcontextprotocol/sdk": "^1.13.1",
46
46
  "d3-array": "^3.2.4",