@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.
@@ -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 {AriaProps, StyleType, View} from "@khanacademy/wonder-blocks-core";
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
- render(): React.ReactNode {
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={defaultStyle}
215
+ rootStyle={
216
+ parentComponent === "listbox"
217
+ ? styles.listboxItem
218
+ : defaultStyle
219
+ }
184
220
  style={styles.itemContainer}
185
- aria-selected={selected ? "true" : "false"}
186
- role={role}
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={this.handleClick}
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 OptionItemComponentArray = React.ReactElement<
50
- React.ComponentProps<typeof OptionItem>
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>;