@khanacademy/wonder-blocks-dropdown 5.2.1 → 5.3.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 +26 -0
- package/dist/components/listbox.d.ts +85 -0
- package/dist/components/multi-select.d.ts +2 -2
- package/dist/components/option-item.d.ts +22 -0
- package/dist/es/index.js +313 -50
- package/dist/hooks/use-listbox.d.ts +73 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +312 -48
- package/dist/util/selection.d.ts +2 -0
- package/dist/util/types.d.ts +7 -1
- package/package.json +11 -11
- package/src/components/__tests__/listbox.test.tsx +425 -0
- package/src/components/listbox.tsx +176 -0
- package/src/components/multi-select.tsx +16 -22
- package/src/components/option-item.tsx +127 -15
- package/src/hooks/use-listbox.tsx +224 -0
- package/src/index.ts +2 -0
- package/src/util/__tests__/selection.test.ts +50 -0
- package/src/util/selection.ts +16 -0
- package/src/util/types.ts +12 -3
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -5,7 +5,12 @@ import {DetailCell} from "@khanacademy/wonder-blocks-cell";
|
|
|
5
5
|
import {mix, color, spacing} from "@khanacademy/wonder-blocks-tokens";
|
|
6
6
|
import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
addStyle,
|
|
10
|
+
AriaProps,
|
|
11
|
+
StyleType,
|
|
12
|
+
View,
|
|
13
|
+
} from "@khanacademy/wonder-blocks-core";
|
|
9
14
|
|
|
10
15
|
import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
11
16
|
import Check from "./check";
|
|
@@ -50,6 +55,13 @@ type OptionProps = AriaProps & {
|
|
|
50
55
|
* @ignore
|
|
51
56
|
*/
|
|
52
57
|
selected: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Whether this item is focused. Auto-populated by listbox in combination of
|
|
60
|
+
* aria-activedescendant.
|
|
61
|
+
* @ignore
|
|
62
|
+
*/
|
|
63
|
+
focused: boolean;
|
|
64
|
+
|
|
53
65
|
/**
|
|
54
66
|
* Aria role to use, defaults to "option".
|
|
55
67
|
*/
|
|
@@ -70,6 +82,21 @@ type OptionProps = AriaProps & {
|
|
|
70
82
|
* @ignore
|
|
71
83
|
*/
|
|
72
84
|
style?: StyleType;
|
|
85
|
+
/**
|
|
86
|
+
* Injected by the parent component to determine how we are going to handle
|
|
87
|
+
* the component states (hovered, focused, selected, etc.)
|
|
88
|
+
* Defaults to "dropdown".
|
|
89
|
+
* @ignore
|
|
90
|
+
*/
|
|
91
|
+
parentComponent?: "dropdown" | "listbox";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The unique identifier of the option item.
|
|
95
|
+
*
|
|
96
|
+
* This is used to identify the option item in the listbox so that it can be
|
|
97
|
+
* focused programmatically (e.g. when the user presses the arrow keys).
|
|
98
|
+
*/
|
|
99
|
+
id?: string;
|
|
73
100
|
|
|
74
101
|
/**
|
|
75
102
|
* Inherited from WB Cell.
|
|
@@ -104,12 +131,15 @@ type OptionProps = AriaProps & {
|
|
|
104
131
|
|
|
105
132
|
type DefaultProps = {
|
|
106
133
|
disabled: OptionProps["disabled"];
|
|
134
|
+
focused: OptionProps["focused"];
|
|
107
135
|
horizontalRule: OptionProps["horizontalRule"];
|
|
108
136
|
onToggle: OptionProps["onToggle"];
|
|
109
137
|
role: OptionProps["role"];
|
|
110
138
|
selected: OptionProps["selected"];
|
|
111
139
|
};
|
|
112
140
|
|
|
141
|
+
const StyledListItem = addStyle("li");
|
|
142
|
+
|
|
113
143
|
/**
|
|
114
144
|
* For option items that can be selected in a dropdown, selection denoted either
|
|
115
145
|
* with a check ✔️ or a checkbox ☑️. Use as children in SingleSelect or
|
|
@@ -122,6 +152,7 @@ export default class OptionItem extends React.Component<OptionProps> {
|
|
|
122
152
|
}
|
|
123
153
|
static defaultProps: DefaultProps = {
|
|
124
154
|
disabled: false,
|
|
155
|
+
focused: false,
|
|
125
156
|
horizontalRule: "none",
|
|
126
157
|
onToggle: () => void 0,
|
|
127
158
|
role: "option",
|
|
@@ -145,17 +176,17 @@ export default class OptionItem extends React.Component<OptionProps> {
|
|
|
145
176
|
}
|
|
146
177
|
};
|
|
147
178
|
|
|
148
|
-
|
|
179
|
+
renderCell(): React.ReactNode {
|
|
149
180
|
const {
|
|
150
181
|
disabled,
|
|
151
182
|
label,
|
|
152
|
-
role,
|
|
153
183
|
selected,
|
|
154
184
|
testId,
|
|
155
|
-
style,
|
|
156
185
|
leftAccessory,
|
|
157
186
|
horizontalRule,
|
|
187
|
+
parentComponent,
|
|
158
188
|
rightAccessory,
|
|
189
|
+
style,
|
|
159
190
|
subtitle1,
|
|
160
191
|
subtitle2,
|
|
161
192
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
@@ -164,6 +195,7 @@ export default class OptionItem extends React.Component<OptionProps> {
|
|
|
164
195
|
onClick,
|
|
165
196
|
onToggle,
|
|
166
197
|
variant,
|
|
198
|
+
role,
|
|
167
199
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
|
168
200
|
...sharedProps
|
|
169
201
|
} = this.props;
|
|
@@ -180,10 +212,16 @@ export default class OptionItem extends React.Component<OptionProps> {
|
|
|
180
212
|
<DetailCell
|
|
181
213
|
disabled={disabled}
|
|
182
214
|
horizontalRule={horizontalRule}
|
|
183
|
-
rootStyle={
|
|
215
|
+
rootStyle={
|
|
216
|
+
parentComponent === "listbox"
|
|
217
|
+
? styles.listboxItem
|
|
218
|
+
: defaultStyle
|
|
219
|
+
}
|
|
184
220
|
style={styles.itemContainer}
|
|
185
|
-
aria-selected={
|
|
186
|
-
|
|
221
|
+
aria-selected={
|
|
222
|
+
parentComponent !== "listbox" && selected ? "true" : "false"
|
|
223
|
+
}
|
|
224
|
+
role={parentComponent !== "listbox" ? role : undefined}
|
|
187
225
|
testId={testId}
|
|
188
226
|
leftAccessory={
|
|
189
227
|
<>
|
|
@@ -220,17 +258,77 @@ export default class OptionItem extends React.Component<OptionProps> {
|
|
|
220
258
|
</LabelSmall>
|
|
221
259
|
) : undefined
|
|
222
260
|
}
|
|
223
|
-
onClick={
|
|
261
|
+
onClick={
|
|
262
|
+
parentComponent !== "listbox" ? this.handleClick : undefined
|
|
263
|
+
}
|
|
224
264
|
{...sharedProps}
|
|
225
265
|
/>
|
|
226
266
|
);
|
|
227
267
|
}
|
|
268
|
+
|
|
269
|
+
render(): React.ReactNode {
|
|
270
|
+
const {disabled, focused, parentComponent, role, selected} = this.props;
|
|
271
|
+
|
|
272
|
+
if (parentComponent === "listbox") {
|
|
273
|
+
return (
|
|
274
|
+
<StyledListItem
|
|
275
|
+
onMouseDown={(e) => {
|
|
276
|
+
// Prevents the combobox from losing focus when clicking
|
|
277
|
+
// on the option item.
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
}}
|
|
280
|
+
onClick={this.handleClick}
|
|
281
|
+
style={[
|
|
282
|
+
styles.reset,
|
|
283
|
+
styles.item,
|
|
284
|
+
focused && styles.itemFocused,
|
|
285
|
+
disabled && styles.itemDisabled,
|
|
286
|
+
]}
|
|
287
|
+
role={role}
|
|
288
|
+
aria-selected={selected ? "true" : "false"}
|
|
289
|
+
aria-disabled={disabled ? "true" : "false"}
|
|
290
|
+
id={this.props.id}
|
|
291
|
+
tabIndex={-1}
|
|
292
|
+
>
|
|
293
|
+
{this.renderCell()}
|
|
294
|
+
</StyledListItem>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return this.renderCell();
|
|
299
|
+
}
|
|
228
300
|
}
|
|
229
301
|
|
|
230
302
|
const {blue, white, offBlack} = color;
|
|
231
303
|
|
|
304
|
+
const focusedStyle = {
|
|
305
|
+
// Override the default focus state for the cell element, so that it
|
|
306
|
+
// can be added programmatically to the button element.
|
|
307
|
+
borderRadius: spacing.xxxSmall_4,
|
|
308
|
+
outline: `${spacing.xxxxSmall_2}px solid ${color.blue}`,
|
|
309
|
+
outlineOffset: -spacing.xxxxSmall_2,
|
|
310
|
+
};
|
|
311
|
+
|
|
232
312
|
const styles = StyleSheet.create({
|
|
313
|
+
reset: {
|
|
314
|
+
margin: 0,
|
|
315
|
+
padding: 0,
|
|
316
|
+
border: 0,
|
|
317
|
+
background: "none",
|
|
318
|
+
outline: "none",
|
|
319
|
+
fontSize: "100%",
|
|
320
|
+
verticalAlign: "baseline",
|
|
321
|
+
textAlign: "left",
|
|
322
|
+
textDecoration: "none",
|
|
323
|
+
listStyle: "none",
|
|
324
|
+
cursor: "pointer",
|
|
325
|
+
},
|
|
326
|
+
listboxItem: {
|
|
327
|
+
backgroundColor: "transparent",
|
|
328
|
+
color: "inherit",
|
|
329
|
+
},
|
|
233
330
|
item: {
|
|
331
|
+
backgroundColor: color.white,
|
|
234
332
|
// Reset the default styles for the cell element so it can grow
|
|
235
333
|
// vertically.
|
|
236
334
|
minHeight: "unset",
|
|
@@ -238,13 +336,7 @@ const styles = StyleSheet.create({
|
|
|
238
336
|
/**
|
|
239
337
|
* States
|
|
240
338
|
*/
|
|
241
|
-
":focus":
|
|
242
|
-
// Override the default focus state for the cell element, so that it
|
|
243
|
-
// can be added programmatically to the button element.
|
|
244
|
-
borderRadius: spacing.xxxSmall_4,
|
|
245
|
-
outline: `${spacing.xxxxSmall_2}px solid ${color.blue}`,
|
|
246
|
-
outlineOffset: -spacing.xxxxSmall_2,
|
|
247
|
-
},
|
|
339
|
+
":focus": focusedStyle,
|
|
248
340
|
|
|
249
341
|
":focus-visible": {
|
|
250
342
|
// Override the default focus-visible state for the cell element, so
|
|
@@ -259,6 +351,22 @@ const styles = StyleSheet.create({
|
|
|
259
351
|
background: blue,
|
|
260
352
|
},
|
|
261
353
|
|
|
354
|
+
[":active[aria-selected=false]" as any]: {},
|
|
355
|
+
|
|
356
|
+
// disabled
|
|
357
|
+
[":hover[aria-disabled=true]" as any]: {
|
|
358
|
+
cursor: "not-allowed",
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
[":is([aria-disabled=true])" as any]: {
|
|
362
|
+
color: color.offBlack32,
|
|
363
|
+
":focus-visible": {
|
|
364
|
+
// Prevent the focus ring from being displayed when the cell is
|
|
365
|
+
// disabled.
|
|
366
|
+
outline: "none",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
|
|
262
370
|
// Allow hover styles on non-touch devices only. This prevents an
|
|
263
371
|
// issue with hover being sticky on touch devices (e.g. mobile).
|
|
264
372
|
["@media not (hover: hover)" as any]: {
|
|
@@ -309,6 +417,10 @@ const styles = StyleSheet.create({
|
|
|
309
417
|
color: mix(color.fadedBlue16, white),
|
|
310
418
|
},
|
|
311
419
|
},
|
|
420
|
+
itemFocused: focusedStyle,
|
|
421
|
+
itemDisabled: {
|
|
422
|
+
outlineColor: color.offBlack32,
|
|
423
|
+
},
|
|
312
424
|
itemContainer: {
|
|
313
425
|
minHeight: "unset",
|
|
314
426
|
// Make sure that the item is always at least as tall as 40px.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {updateMultipleSelection} from "../util/selection";
|
|
3
|
+
import {MaybeValueOrValues, OptionItemComponent} from "../util/types";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/**
|
|
7
|
+
* The list of items to display in the listbox.
|
|
8
|
+
*/
|
|
9
|
+
children: Array<OptionItemComponent>;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the listbox is disabled.
|
|
12
|
+
*/
|
|
13
|
+
disabled: boolean | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The unique identifier of the listbox element.
|
|
16
|
+
*/
|
|
17
|
+
id: string;
|
|
18
|
+
/**
|
|
19
|
+
* The value of the currently selected items.
|
|
20
|
+
*/
|
|
21
|
+
value?: MaybeValueOrValues;
|
|
22
|
+
/**
|
|
23
|
+
* The type of selection that the listbox supports.
|
|
24
|
+
*/
|
|
25
|
+
selectionType: "single" | "multiple";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hook for managing the state of a listbox.
|
|
30
|
+
*
|
|
31
|
+
* It manages how the options are rendered and how the listbox behaves.
|
|
32
|
+
*
|
|
33
|
+
* This includes:
|
|
34
|
+
* - Keyboard navigation.
|
|
35
|
+
* - Selection management.
|
|
36
|
+
*/
|
|
37
|
+
export function useListbox({
|
|
38
|
+
children: options,
|
|
39
|
+
disabled,
|
|
40
|
+
id,
|
|
41
|
+
selectionType = "single",
|
|
42
|
+
value,
|
|
43
|
+
}: Props) {
|
|
44
|
+
// find the index of the first selected Item
|
|
45
|
+
const selectedValueIndex = React.useMemo(() => {
|
|
46
|
+
const firstValue = Array.isArray(value) ? value[0] : value;
|
|
47
|
+
if (!firstValue || firstValue === "") {
|
|
48
|
+
// Focus on the first item if no value is selected
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
return options.findIndex((item) => item.props.value === firstValue);
|
|
52
|
+
}, [options, value]);
|
|
53
|
+
// The index of the currently focused item in the listbox.
|
|
54
|
+
const [focusedIndex, setFocusedIndex] = React.useState(selectedValueIndex);
|
|
55
|
+
// Whether the listbox is currently focused.
|
|
56
|
+
const [isListboxFocused, setIsListboxFocused] = React.useState(false);
|
|
57
|
+
|
|
58
|
+
const [selected, setSelected] = React.useState(value);
|
|
59
|
+
|
|
60
|
+
const focusItem = (index: number) => {
|
|
61
|
+
setFocusedIndex(index);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const focusPreviousItem = React.useCallback(() => {
|
|
65
|
+
if (focusedIndex === 0) {
|
|
66
|
+
focusItem(options.length - 1);
|
|
67
|
+
} else {
|
|
68
|
+
focusItem(focusedIndex - 1);
|
|
69
|
+
}
|
|
70
|
+
}, [options, focusedIndex]);
|
|
71
|
+
|
|
72
|
+
const focusNextItem = React.useCallback(() => {
|
|
73
|
+
if (focusedIndex === options.length - 1) {
|
|
74
|
+
focusItem(0);
|
|
75
|
+
} else {
|
|
76
|
+
focusItem(focusedIndex + 1);
|
|
77
|
+
}
|
|
78
|
+
}, [options, focusedIndex]);
|
|
79
|
+
|
|
80
|
+
const selectOption = React.useCallback(
|
|
81
|
+
(index: number) => {
|
|
82
|
+
const optionItem = options[index] as OptionItemComponent;
|
|
83
|
+
|
|
84
|
+
if (optionItem.props.disabled) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (selectionType === "single") {
|
|
89
|
+
setSelected(optionItem.props.value);
|
|
90
|
+
} else {
|
|
91
|
+
setSelected((prevSelected) => {
|
|
92
|
+
const newSelectedValue = updateMultipleSelection(
|
|
93
|
+
prevSelected as Array<string>,
|
|
94
|
+
optionItem.props.value,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return newSelectedValue;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[options, selectionType],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const handleKeyDown = React.useCallback(
|
|
105
|
+
(event: React.KeyboardEvent) => {
|
|
106
|
+
const {key} = event;
|
|
107
|
+
|
|
108
|
+
switch (key) {
|
|
109
|
+
case "ArrowUp":
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
focusPreviousItem();
|
|
112
|
+
return;
|
|
113
|
+
case "ArrowDown":
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
focusNextItem();
|
|
116
|
+
return;
|
|
117
|
+
case "Home":
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
focusItem(0);
|
|
120
|
+
return;
|
|
121
|
+
case "End":
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
focusItem(options.length - 1);
|
|
124
|
+
return;
|
|
125
|
+
case "Enter":
|
|
126
|
+
case " ":
|
|
127
|
+
// Prevent form submission
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
|
|
130
|
+
selectOption(focusedIndex);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[focusNextItem, focusPreviousItem, focusedIndex, options, selectOption],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Some keys should be handled during the keyup event instead.
|
|
138
|
+
const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => {
|
|
139
|
+
switch (event.key) {
|
|
140
|
+
case " ":
|
|
141
|
+
// Prevent space from scrolling down the page
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const handleFocus = React.useCallback(() => {
|
|
148
|
+
if (!disabled) {
|
|
149
|
+
setIsListboxFocused(true);
|
|
150
|
+
}
|
|
151
|
+
}, [disabled]);
|
|
152
|
+
|
|
153
|
+
const handleBlur = React.useCallback(() => {
|
|
154
|
+
if (!disabled) {
|
|
155
|
+
setIsListboxFocused(false);
|
|
156
|
+
}
|
|
157
|
+
}, [disabled]);
|
|
158
|
+
|
|
159
|
+
const handleClick = React.useCallback(
|
|
160
|
+
(value: string) => {
|
|
161
|
+
const index = options.findIndex(
|
|
162
|
+
(item) => item.props.value === value,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const isOptionDisabled = options[index].props.disabled;
|
|
166
|
+
|
|
167
|
+
if (disabled || isOptionDisabled) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
focusItem(index);
|
|
172
|
+
selectOption(index);
|
|
173
|
+
},
|
|
174
|
+
[disabled, options, selectOption],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const renderList = React.useMemo(() => {
|
|
178
|
+
return options.map((component, index) => {
|
|
179
|
+
const isSelected =
|
|
180
|
+
selected?.includes(component.props.value) || false;
|
|
181
|
+
const optionId = id ? `${id}-option-${index}` : `option-${index}`;
|
|
182
|
+
|
|
183
|
+
// Renders option items and pass the extra props needed to manage
|
|
184
|
+
// the listbox state.
|
|
185
|
+
return React.cloneElement(component, {
|
|
186
|
+
key: index,
|
|
187
|
+
focused: isListboxFocused && index === focusedIndex,
|
|
188
|
+
disabled: component.props.disabled || disabled || false,
|
|
189
|
+
selected: isSelected,
|
|
190
|
+
variant: selectionType === "single" ? "check" : "checkbox",
|
|
191
|
+
parentComponent: "listbox",
|
|
192
|
+
id: optionId,
|
|
193
|
+
onToggle: () => {
|
|
194
|
+
handleClick(component.props.value);
|
|
195
|
+
},
|
|
196
|
+
role: "option",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}, [
|
|
200
|
+
options,
|
|
201
|
+
selected,
|
|
202
|
+
id,
|
|
203
|
+
isListboxFocused,
|
|
204
|
+
focusedIndex,
|
|
205
|
+
disabled,
|
|
206
|
+
selectionType,
|
|
207
|
+
handleClick,
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
isListboxFocused,
|
|
212
|
+
// current option focused
|
|
213
|
+
focusedIndex,
|
|
214
|
+
// list of options
|
|
215
|
+
renderList,
|
|
216
|
+
// selected value(s)
|
|
217
|
+
selected,
|
|
218
|
+
// handlers
|
|
219
|
+
handleKeyDown,
|
|
220
|
+
handleKeyUp,
|
|
221
|
+
handleFocus,
|
|
222
|
+
handleBlur,
|
|
223
|
+
};
|
|
224
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import SeparatorItem from "./components/separator-item";
|
|
|
4
4
|
import ActionMenu from "./components/action-menu";
|
|
5
5
|
import SingleSelect from "./components/single-select";
|
|
6
6
|
import MultiSelect from "./components/multi-select";
|
|
7
|
+
import Listbox from "./components/listbox";
|
|
7
8
|
|
|
8
9
|
import type {Labels} from "./components/multi-select";
|
|
9
10
|
import type {SingleSelectLabels} from "./components/single-select";
|
|
@@ -15,6 +16,7 @@ export {
|
|
|
15
16
|
ActionMenu,
|
|
16
17
|
SingleSelect,
|
|
17
18
|
MultiSelect,
|
|
19
|
+
Listbox,
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type {Labels, SingleSelectLabels};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {updateMultipleSelection} from "../selection";
|
|
2
|
+
|
|
3
|
+
describe("updateMultipleSelection", () => {
|
|
4
|
+
it("should create a new selection if there is no previous selection", () => {
|
|
5
|
+
// Arrange
|
|
6
|
+
const previousSelection = null;
|
|
7
|
+
const value = "apple";
|
|
8
|
+
|
|
9
|
+
// Act
|
|
10
|
+
const result = updateMultipleSelection(previousSelection, value);
|
|
11
|
+
|
|
12
|
+
// Assert
|
|
13
|
+
expect(result).toEqual(["apple"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should create a new selection if the previous selection is empty", () => {
|
|
17
|
+
// Arrange
|
|
18
|
+
const value = "apple";
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const result = updateMultipleSelection([], value);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(result).toEqual(["apple"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should add the value to the selection", () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const previousSelection = ["pear", "grape"];
|
|
30
|
+
const value = "apple";
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const result = updateMultipleSelection(previousSelection, value);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(result).toEqual(["pear", "grape", "apple"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should remove the value from the selection", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const previousSelection = ["pear", "grape", "apple"];
|
|
42
|
+
const value = "grape";
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const result = updateMultipleSelection(previousSelection, value);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(result).toEqual(["pear", "apple"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {MaybeString} from "./types";
|
|
2
|
+
|
|
3
|
+
export function updateMultipleSelection(
|
|
4
|
+
previousSelection: Array<MaybeString> | null | undefined,
|
|
5
|
+
value = "",
|
|
6
|
+
) {
|
|
7
|
+
if (!previousSelection) {
|
|
8
|
+
return [value];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return previousSelection.includes(value)
|
|
12
|
+
? // Item is already selected, remove it from the list
|
|
13
|
+
previousSelection.filter((item) => item !== value)
|
|
14
|
+
: // Item is not selected yet, add it to the list
|
|
15
|
+
[...previousSelection, value];
|
|
16
|
+
}
|
package/src/util/types.ts
CHANGED
|
@@ -46,6 +46,15 @@ export type OpenerProps = ClickableState & {
|
|
|
46
46
|
opened: boolean;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
export type
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
export type OptionItemComponent = React.ReactElement<
|
|
50
|
+
PropsFor<typeof OptionItem>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
export type OptionItemComponentArray = OptionItemComponent[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Allows optional values to be passed to the listbox.
|
|
57
|
+
*/
|
|
58
|
+
export type MaybeString = string | null | undefined;
|
|
59
|
+
|
|
60
|
+
export type MaybeValueOrValues = MaybeString | Array<MaybeString>;
|