@coinbase/cds-common 8.19.1 → 8.20.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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
|
|
|
8
8
|
|
|
9
9
|
<!-- template-start -->
|
|
10
10
|
|
|
11
|
+
## 8.20.0 (11/7/2025 PST)
|
|
12
|
+
|
|
13
|
+
#### 🚀 Updates
|
|
14
|
+
|
|
15
|
+
- Add useMultiSelect hook. [[#21](https://github.com/coinbase/cds/pull/21)]
|
|
16
|
+
|
|
11
17
|
## 8.19.1 ((11/4/2025, 08:48 AM PST))
|
|
12
18
|
|
|
13
19
|
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.
|
|
3
|
+
"version": "8.20.0",
|
|
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.
|
|
43
|
-
"@coinbase/cds-mcp-server": "^8.
|
|
42
|
+
"@coinbase/cds-illustrations": "^4.26.1",
|
|
43
|
+
"@coinbase/cds-mcp-server": "^8.20.0",
|
|
44
44
|
"@coinbase/cds-utils": "^2.3.4",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.13.1",
|
|
46
46
|
"d3-array": "^3.2.4",
|