@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
package/dist/es/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { CompactCell, DetailCell } from '@khanacademy/wonder-blocks-cell';
|
|
|
4
4
|
import * as tokens from '@khanacademy/wonder-blocks-tokens';
|
|
5
5
|
import { spacing, color, mix, fade } from '@khanacademy/wonder-blocks-tokens';
|
|
6
6
|
import { LabelMedium, LabelSmall, LabelLarge } from '@khanacademy/wonder-blocks-typography';
|
|
7
|
-
import { View, addStyle } from '@khanacademy/wonder-blocks-core';
|
|
7
|
+
import { View, addStyle, useUniqueIdWithMock } from '@khanacademy/wonder-blocks-core';
|
|
8
8
|
import { Strut } from '@khanacademy/wonder-blocks-layout';
|
|
9
9
|
import { PhosphorIcon } from '@khanacademy/wonder-blocks-icon';
|
|
10
10
|
import checkIcon from '@phosphor-icons/core/bold/check-bold.svg';
|
|
@@ -88,13 +88,13 @@ class ActionItem extends React.Component {
|
|
|
88
88
|
style,
|
|
89
89
|
testId
|
|
90
90
|
} = this.props;
|
|
91
|
-
const defaultStyle = [styles$
|
|
91
|
+
const defaultStyle = [styles$9.wrapper, style];
|
|
92
92
|
const labelComponent = typeof label === "string" ? React.createElement(LabelMedium, {
|
|
93
93
|
lang: lang,
|
|
94
|
-
style: styles$
|
|
94
|
+
style: styles$9.label
|
|
95
95
|
}, label) : React.cloneElement(label, _extends({
|
|
96
96
|
lang,
|
|
97
|
-
style: styles$
|
|
97
|
+
style: styles$9.label
|
|
98
98
|
}, label.props));
|
|
99
99
|
return React.createElement(CompactCell, {
|
|
100
100
|
disabled: disabled,
|
|
@@ -102,7 +102,7 @@ class ActionItem extends React.Component {
|
|
|
102
102
|
rootStyle: defaultStyle,
|
|
103
103
|
leftAccessory: leftAccessory,
|
|
104
104
|
rightAccessory: rightAccessory,
|
|
105
|
-
style: [styles$
|
|
105
|
+
style: [styles$9.shared, indent && styles$9.indent],
|
|
106
106
|
role: role,
|
|
107
107
|
testId: testId,
|
|
108
108
|
title: labelComponent,
|
|
@@ -119,7 +119,7 @@ ActionItem.defaultProps = {
|
|
|
119
119
|
role: "menuitem"
|
|
120
120
|
};
|
|
121
121
|
ActionItem.__IS_ACTION_ITEM__ = true;
|
|
122
|
-
const styles$
|
|
122
|
+
const styles$9 = StyleSheet.create({
|
|
123
123
|
wrapper: {
|
|
124
124
|
minHeight: DROPDOWN_ITEM_HEIGHT,
|
|
125
125
|
touchAction: "manipulation",
|
|
@@ -176,10 +176,10 @@ const Check = function Check(props) {
|
|
|
176
176
|
return React.createElement(PhosphorIcon, {
|
|
177
177
|
icon: checkIcon,
|
|
178
178
|
size: "small",
|
|
179
|
-
style: [styles$
|
|
179
|
+
style: [styles$8.bounds, !selected && styles$8.hide]
|
|
180
180
|
});
|
|
181
181
|
};
|
|
182
|
-
const styles$
|
|
182
|
+
const styles$8 = StyleSheet.create({
|
|
183
183
|
bounds: {
|
|
184
184
|
alignSelf: "center",
|
|
185
185
|
height: spacing.medium_16,
|
|
@@ -203,7 +203,7 @@ const Checkbox = function Checkbox(props) {
|
|
|
203
203
|
} = props;
|
|
204
204
|
return React.createElement(View, {
|
|
205
205
|
className: "checkbox",
|
|
206
|
-
style: [styles$
|
|
206
|
+
style: [styles$7.checkbox, selected && !disabled && styles$7.noBorder, disabled && styles$7.disabledCheckbox]
|
|
207
207
|
}, selected && React.createElement(PhosphorIcon, {
|
|
208
208
|
icon: checkIcon,
|
|
209
209
|
size: "small",
|
|
@@ -212,10 +212,10 @@ const Checkbox = function Checkbox(props) {
|
|
|
212
212
|
width: spacing.small_12,
|
|
213
213
|
height: spacing.small_12,
|
|
214
214
|
margin: spacing.xxxxSmall_2
|
|
215
|
-
}, disabled && selected && styles$
|
|
215
|
+
}, disabled && selected && styles$7.disabledCheckFormatting]
|
|
216
216
|
}));
|
|
217
217
|
};
|
|
218
|
-
const styles$
|
|
218
|
+
const styles$7 = StyleSheet.create({
|
|
219
219
|
checkbox: {
|
|
220
220
|
alignSelf: "center",
|
|
221
221
|
minHeight: spacing.medium_16,
|
|
@@ -240,7 +240,8 @@ const styles$6 = StyleSheet.create({
|
|
|
240
240
|
}
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
-
const _excluded$5 = ["disabled", "label", "
|
|
243
|
+
const _excluded$5 = ["disabled", "label", "selected", "testId", "leftAccessory", "horizontalRule", "parentComponent", "rightAccessory", "style", "subtitle1", "subtitle2", "value", "onClick", "onToggle", "variant", "role"];
|
|
244
|
+
const StyledListItem = addStyle("li");
|
|
244
245
|
class OptionItem extends React.Component {
|
|
245
246
|
constructor(...args) {
|
|
246
247
|
super(...args);
|
|
@@ -266,31 +267,32 @@ class OptionItem extends React.Component {
|
|
|
266
267
|
return Checkbox;
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
|
-
|
|
270
|
+
renderCell() {
|
|
270
271
|
const _this$props = this.props,
|
|
271
272
|
{
|
|
272
273
|
disabled,
|
|
273
274
|
label,
|
|
274
|
-
role,
|
|
275
275
|
selected,
|
|
276
276
|
testId,
|
|
277
|
-
style,
|
|
278
277
|
leftAccessory,
|
|
279
278
|
horizontalRule,
|
|
279
|
+
parentComponent,
|
|
280
280
|
rightAccessory,
|
|
281
|
+
style,
|
|
281
282
|
subtitle1,
|
|
282
|
-
subtitle2
|
|
283
|
+
subtitle2,
|
|
284
|
+
role
|
|
283
285
|
} = _this$props,
|
|
284
286
|
sharedProps = _objectWithoutPropertiesLoose(_this$props, _excluded$5);
|
|
285
287
|
const CheckComponent = this.getCheckComponent();
|
|
286
|
-
const defaultStyle = [styles$
|
|
288
|
+
const defaultStyle = [styles$6.item, style];
|
|
287
289
|
return React.createElement(DetailCell, _extends({
|
|
288
290
|
disabled: disabled,
|
|
289
291
|
horizontalRule: horizontalRule,
|
|
290
|
-
rootStyle: defaultStyle,
|
|
291
|
-
style: styles$
|
|
292
|
-
"aria-selected": selected ? "true" : "false",
|
|
293
|
-
role: role,
|
|
292
|
+
rootStyle: parentComponent === "listbox" ? styles$6.listboxItem : defaultStyle,
|
|
293
|
+
style: styles$6.itemContainer,
|
|
294
|
+
"aria-selected": parentComponent !== "listbox" && selected ? "true" : "false",
|
|
295
|
+
role: parentComponent !== "listbox" ? role : undefined,
|
|
294
296
|
testId: testId,
|
|
295
297
|
leftAccessory: React.createElement(React.Fragment, null, leftAccessory ? React.createElement(View, {
|
|
296
298
|
style: {
|
|
@@ -310,17 +312,42 @@ class OptionItem extends React.Component {
|
|
|
310
312
|
className: "subtitle"
|
|
311
313
|
}, subtitle1) : undefined,
|
|
312
314
|
title: React.createElement(LabelMedium, {
|
|
313
|
-
style: styles$
|
|
315
|
+
style: styles$6.label
|
|
314
316
|
}, label),
|
|
315
317
|
subtitle2: subtitle2 ? React.createElement(LabelSmall, {
|
|
316
318
|
className: "subtitle"
|
|
317
319
|
}, subtitle2) : undefined,
|
|
318
|
-
onClick: this.handleClick
|
|
320
|
+
onClick: parentComponent !== "listbox" ? this.handleClick : undefined
|
|
319
321
|
}, sharedProps));
|
|
320
322
|
}
|
|
323
|
+
render() {
|
|
324
|
+
const {
|
|
325
|
+
disabled,
|
|
326
|
+
focused,
|
|
327
|
+
parentComponent,
|
|
328
|
+
role,
|
|
329
|
+
selected
|
|
330
|
+
} = this.props;
|
|
331
|
+
if (parentComponent === "listbox") {
|
|
332
|
+
return React.createElement(StyledListItem, {
|
|
333
|
+
onMouseDown: e => {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
},
|
|
336
|
+
onClick: this.handleClick,
|
|
337
|
+
style: [styles$6.reset, styles$6.item, focused && styles$6.itemFocused, disabled && styles$6.itemDisabled],
|
|
338
|
+
role: role,
|
|
339
|
+
"aria-selected": selected ? "true" : "false",
|
|
340
|
+
"aria-disabled": disabled ? "true" : "false",
|
|
341
|
+
id: this.props.id,
|
|
342
|
+
tabIndex: -1
|
|
343
|
+
}, this.renderCell());
|
|
344
|
+
}
|
|
345
|
+
return this.renderCell();
|
|
346
|
+
}
|
|
321
347
|
}
|
|
322
348
|
OptionItem.defaultProps = {
|
|
323
349
|
disabled: false,
|
|
350
|
+
focused: false,
|
|
324
351
|
horizontalRule: "none",
|
|
325
352
|
onToggle: () => void 0,
|
|
326
353
|
role: "option",
|
|
@@ -332,14 +359,33 @@ const {
|
|
|
332
359
|
white,
|
|
333
360
|
offBlack
|
|
334
361
|
} = color;
|
|
335
|
-
const
|
|
362
|
+
const focusedStyle = {
|
|
363
|
+
borderRadius: spacing.xxxSmall_4,
|
|
364
|
+
outline: `${spacing.xxxxSmall_2}px solid ${color.blue}`,
|
|
365
|
+
outlineOffset: -spacing.xxxxSmall_2
|
|
366
|
+
};
|
|
367
|
+
const styles$6 = StyleSheet.create({
|
|
368
|
+
reset: {
|
|
369
|
+
margin: 0,
|
|
370
|
+
padding: 0,
|
|
371
|
+
border: 0,
|
|
372
|
+
background: "none",
|
|
373
|
+
outline: "none",
|
|
374
|
+
fontSize: "100%",
|
|
375
|
+
verticalAlign: "baseline",
|
|
376
|
+
textAlign: "left",
|
|
377
|
+
textDecoration: "none",
|
|
378
|
+
listStyle: "none",
|
|
379
|
+
cursor: "pointer"
|
|
380
|
+
},
|
|
381
|
+
listboxItem: {
|
|
382
|
+
backgroundColor: "transparent",
|
|
383
|
+
color: "inherit"
|
|
384
|
+
},
|
|
336
385
|
item: {
|
|
386
|
+
backgroundColor: color.white,
|
|
337
387
|
minHeight: "unset",
|
|
338
|
-
":focus":
|
|
339
|
-
borderRadius: spacing.xxxSmall_4,
|
|
340
|
-
outline: `${spacing.xxxxSmall_2}px solid ${color.blue}`,
|
|
341
|
-
outlineOffset: -spacing.xxxxSmall_2
|
|
342
|
-
},
|
|
388
|
+
":focus": focusedStyle,
|
|
343
389
|
":focus-visible": {
|
|
344
390
|
overflow: "visible"
|
|
345
391
|
},
|
|
@@ -347,6 +393,16 @@ const styles$5 = StyleSheet.create({
|
|
|
347
393
|
color: white,
|
|
348
394
|
background: blue
|
|
349
395
|
},
|
|
396
|
+
[":active[aria-selected=false]"]: {},
|
|
397
|
+
[":hover[aria-disabled=true]"]: {
|
|
398
|
+
cursor: "not-allowed"
|
|
399
|
+
},
|
|
400
|
+
[":is([aria-disabled=true])"]: {
|
|
401
|
+
color: color.offBlack32,
|
|
402
|
+
":focus-visible": {
|
|
403
|
+
outline: "none"
|
|
404
|
+
}
|
|
405
|
+
},
|
|
350
406
|
["@media not (hover: hover)"]: {
|
|
351
407
|
[":hover[aria-disabled=false]"]: {
|
|
352
408
|
color: white,
|
|
@@ -382,6 +438,10 @@ const styles$5 = StyleSheet.create({
|
|
|
382
438
|
color: mix(color.fadedBlue16, white)
|
|
383
439
|
}
|
|
384
440
|
},
|
|
441
|
+
itemFocused: focusedStyle,
|
|
442
|
+
itemDisabled: {
|
|
443
|
+
outlineColor: color.offBlack32
|
|
444
|
+
},
|
|
385
445
|
itemContainer: {
|
|
386
446
|
minHeight: "unset",
|
|
387
447
|
padding: `${spacing.xSmall_8 + spacing.xxxxSmall_2}px ${spacing.xSmall_8}px`,
|
|
@@ -405,13 +465,13 @@ class SeparatorItem extends React.Component {
|
|
|
405
465
|
}
|
|
406
466
|
render() {
|
|
407
467
|
return React.createElement(View, {
|
|
408
|
-
style: [styles$
|
|
468
|
+
style: [styles$5.separator, this.props.style],
|
|
409
469
|
"aria-hidden": "true"
|
|
410
470
|
});
|
|
411
471
|
}
|
|
412
472
|
}
|
|
413
473
|
SeparatorItem.__IS_SEPARATOR_ITEM__ = true;
|
|
414
|
-
const styles$
|
|
474
|
+
const styles$5 = StyleSheet.create({
|
|
415
475
|
separator: {
|
|
416
476
|
boxShadow: `0 -1px ${color.offBlack16}`,
|
|
417
477
|
height: 1,
|
|
@@ -1077,7 +1137,7 @@ class DropdownCore extends React.Component {
|
|
|
1077
1137
|
const numResults = items.length;
|
|
1078
1138
|
if (numResults === 0) {
|
|
1079
1139
|
return React.createElement(LabelMedium, {
|
|
1080
|
-
style: styles$
|
|
1140
|
+
style: styles$4.noResult,
|
|
1081
1141
|
testId: "dropdown-core-no-results"
|
|
1082
1142
|
}, noResults);
|
|
1083
1143
|
}
|
|
@@ -1149,7 +1209,7 @@ class DropdownCore extends React.Component {
|
|
|
1149
1209
|
onChange: this.handleSearchTextChanged,
|
|
1150
1210
|
placeholder: labels.filter,
|
|
1151
1211
|
ref: this.searchFieldRef,
|
|
1152
|
-
style: styles$
|
|
1212
|
+
style: styles$4.searchInputStyle,
|
|
1153
1213
|
value: searchText || ""
|
|
1154
1214
|
});
|
|
1155
1215
|
}
|
|
@@ -1167,11 +1227,11 @@ class DropdownCore extends React.Component {
|
|
|
1167
1227
|
const minDropdownWidth = openerStyle ? openerStyle.getPropertyValue("width") : 0;
|
|
1168
1228
|
return React.createElement(View, {
|
|
1169
1229
|
onMouseUp: this.handleDropdownMouseUp,
|
|
1170
|
-
style: [styles$
|
|
1230
|
+
style: [styles$4.dropdown, light && styles$4.light, isReferenceHidden && styles$4.hidden, dropdownStyle],
|
|
1171
1231
|
testId: "dropdown-core-container"
|
|
1172
1232
|
}, isFilterable && this.renderSearchField(), React.createElement(View, {
|
|
1173
1233
|
role: role,
|
|
1174
|
-
style: [styles$
|
|
1234
|
+
style: [styles$4.listboxOrMenu, {
|
|
1175
1235
|
minWidth: minDropdownWidth
|
|
1176
1236
|
}],
|
|
1177
1237
|
"aria-invalid": role === "listbox" ? ariaInvalid : undefined,
|
|
@@ -1205,7 +1265,7 @@ class DropdownCore extends React.Component {
|
|
|
1205
1265
|
"aria-live": "polite",
|
|
1206
1266
|
"aria-atomic": "true",
|
|
1207
1267
|
"aria-relevant": "additions text",
|
|
1208
|
-
style: styles$
|
|
1268
|
+
style: styles$4.srOnly,
|
|
1209
1269
|
"data-testid": "dropdown-live-region"
|
|
1210
1270
|
}, open && labels.someResults(totalItems));
|
|
1211
1271
|
}
|
|
@@ -1219,7 +1279,7 @@ class DropdownCore extends React.Component {
|
|
|
1219
1279
|
return React.createElement(View, {
|
|
1220
1280
|
onKeyDown: this.handleKeyDown,
|
|
1221
1281
|
onKeyUp: this.handleKeyUp,
|
|
1222
|
-
style: [styles$
|
|
1282
|
+
style: [styles$4.menuWrapper, style],
|
|
1223
1283
|
className: className
|
|
1224
1284
|
}, this.renderLiveRegion(), opener, open && this.renderDropdown());
|
|
1225
1285
|
}
|
|
@@ -1237,7 +1297,7 @@ DropdownCore.defaultProps = {
|
|
|
1237
1297
|
light: false,
|
|
1238
1298
|
selectionType: "single"
|
|
1239
1299
|
};
|
|
1240
|
-
const styles$
|
|
1300
|
+
const styles$4 = StyleSheet.create({
|
|
1241
1301
|
menuWrapper: {
|
|
1242
1302
|
width: "fit-content"
|
|
1243
1303
|
},
|
|
@@ -1370,11 +1430,11 @@ const sharedStyles = StyleSheet.create({
|
|
|
1370
1430
|
position: "absolute"
|
|
1371
1431
|
}
|
|
1372
1432
|
});
|
|
1373
|
-
const styles$
|
|
1433
|
+
const styles$3 = {};
|
|
1374
1434
|
const _generateStyles$1 = localColor => {
|
|
1375
1435
|
const buttonType = localColor;
|
|
1376
|
-
if (styles$
|
|
1377
|
-
return styles$
|
|
1436
|
+
if (styles$3[buttonType]) {
|
|
1437
|
+
return styles$3[buttonType];
|
|
1378
1438
|
}
|
|
1379
1439
|
const {
|
|
1380
1440
|
offBlack32
|
|
@@ -1406,8 +1466,8 @@ const _generateStyles$1 = localColor => {
|
|
|
1406
1466
|
cursor: "default"
|
|
1407
1467
|
}
|
|
1408
1468
|
};
|
|
1409
|
-
styles$
|
|
1410
|
-
return styles$
|
|
1469
|
+
styles$3[buttonType] = StyleSheet.create(newStyles);
|
|
1470
|
+
return styles$3[buttonType];
|
|
1411
1471
|
};
|
|
1412
1472
|
|
|
1413
1473
|
const _excluded$3 = ["text", "opened"];
|
|
@@ -1543,7 +1603,7 @@ class ActionMenu extends React.Component {
|
|
|
1543
1603
|
items: items,
|
|
1544
1604
|
openerElement: this.openerElement,
|
|
1545
1605
|
onOpenChanged: this.handleOpenChanged,
|
|
1546
|
-
dropdownStyle: [styles$
|
|
1606
|
+
dropdownStyle: [styles$2.menuTopSpace, dropdownStyle]
|
|
1547
1607
|
});
|
|
1548
1608
|
}
|
|
1549
1609
|
}
|
|
@@ -1551,7 +1611,7 @@ ActionMenu.defaultProps = {
|
|
|
1551
1611
|
alignment: "left",
|
|
1552
1612
|
disabled: false
|
|
1553
1613
|
};
|
|
1554
|
-
const styles$
|
|
1614
|
+
const styles$2 = StyleSheet.create({
|
|
1555
1615
|
caret: {
|
|
1556
1616
|
marginLeft: 4
|
|
1557
1617
|
},
|
|
@@ -1603,7 +1663,7 @@ class SelectOpener extends React.Component {
|
|
|
1603
1663
|
pressed
|
|
1604
1664
|
} = state;
|
|
1605
1665
|
const iconColor = light ? disabled || pressed ? "currentColor" : tokens.color.white : disabled ? tokens.color.offBlack32 : tokens.color.offBlack64;
|
|
1606
|
-
const style = [styles.shared, stateStyles.default, disabled && stateStyles.disabled, !disabled && (pressed ? stateStyles.active : (hovered || focused) && stateStyles.focus)];
|
|
1666
|
+
const style = [styles$1.shared, stateStyles.default, disabled && stateStyles.disabled, !disabled && (pressed ? stateStyles.active : (hovered || focused) && stateStyles.focus)];
|
|
1607
1667
|
return React.createElement(StyledButton, _extends({}, sharedProps, {
|
|
1608
1668
|
"aria-expanded": open ? "true" : "false",
|
|
1609
1669
|
"aria-haspopup": "listbox",
|
|
@@ -1613,12 +1673,12 @@ class SelectOpener extends React.Component {
|
|
|
1613
1673
|
style: style,
|
|
1614
1674
|
type: "button"
|
|
1615
1675
|
}, childrenProps), React.createElement(LabelMedium, {
|
|
1616
|
-
style: styles.text
|
|
1676
|
+
style: styles$1.text
|
|
1617
1677
|
}, children || "\u00A0"), React.createElement(PhosphorIcon, {
|
|
1618
1678
|
icon: caretDownIcon,
|
|
1619
1679
|
color: iconColor,
|
|
1620
1680
|
size: "small",
|
|
1621
|
-
style: styles.caret,
|
|
1681
|
+
style: styles$1.caret,
|
|
1622
1682
|
"aria-hidden": "true"
|
|
1623
1683
|
}));
|
|
1624
1684
|
});
|
|
@@ -1633,7 +1693,7 @@ SelectOpener.defaultProps = {
|
|
|
1633
1693
|
light: false,
|
|
1634
1694
|
isPlaceholder: false
|
|
1635
1695
|
};
|
|
1636
|
-
const styles = StyleSheet.create({
|
|
1696
|
+
const styles$1 = StyleSheet.create({
|
|
1637
1697
|
shared: {
|
|
1638
1698
|
position: "relative",
|
|
1639
1699
|
display: "inline-flex",
|
|
@@ -2230,4 +2290,207 @@ MultiSelect.defaultProps = {
|
|
|
2230
2290
|
selectedValues: []
|
|
2231
2291
|
};
|
|
2232
2292
|
|
|
2233
|
-
|
|
2293
|
+
function updateMultipleSelection(previousSelection, value = "") {
|
|
2294
|
+
if (!previousSelection) {
|
|
2295
|
+
return [value];
|
|
2296
|
+
}
|
|
2297
|
+
return previousSelection.includes(value) ? previousSelection.filter(item => item !== value) : [...previousSelection, value];
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function useListbox({
|
|
2301
|
+
children: options,
|
|
2302
|
+
disabled,
|
|
2303
|
+
id,
|
|
2304
|
+
selectionType = "single",
|
|
2305
|
+
value
|
|
2306
|
+
}) {
|
|
2307
|
+
const selectedValueIndex = React.useMemo(() => {
|
|
2308
|
+
const firstValue = Array.isArray(value) ? value[0] : value;
|
|
2309
|
+
if (!firstValue || firstValue === "") {
|
|
2310
|
+
return 0;
|
|
2311
|
+
}
|
|
2312
|
+
return options.findIndex(item => item.props.value === firstValue);
|
|
2313
|
+
}, [options, value]);
|
|
2314
|
+
const [focusedIndex, setFocusedIndex] = React.useState(selectedValueIndex);
|
|
2315
|
+
const [isListboxFocused, setIsListboxFocused] = React.useState(false);
|
|
2316
|
+
const [selected, setSelected] = React.useState(value);
|
|
2317
|
+
const focusItem = index => {
|
|
2318
|
+
setFocusedIndex(index);
|
|
2319
|
+
};
|
|
2320
|
+
const focusPreviousItem = React.useCallback(() => {
|
|
2321
|
+
if (focusedIndex === 0) {
|
|
2322
|
+
focusItem(options.length - 1);
|
|
2323
|
+
} else {
|
|
2324
|
+
focusItem(focusedIndex - 1);
|
|
2325
|
+
}
|
|
2326
|
+
}, [options, focusedIndex]);
|
|
2327
|
+
const focusNextItem = React.useCallback(() => {
|
|
2328
|
+
if (focusedIndex === options.length - 1) {
|
|
2329
|
+
focusItem(0);
|
|
2330
|
+
} else {
|
|
2331
|
+
focusItem(focusedIndex + 1);
|
|
2332
|
+
}
|
|
2333
|
+
}, [options, focusedIndex]);
|
|
2334
|
+
const selectOption = React.useCallback(index => {
|
|
2335
|
+
const optionItem = options[index];
|
|
2336
|
+
if (optionItem.props.disabled) {
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
if (selectionType === "single") {
|
|
2340
|
+
setSelected(optionItem.props.value);
|
|
2341
|
+
} else {
|
|
2342
|
+
setSelected(prevSelected => {
|
|
2343
|
+
const newSelectedValue = updateMultipleSelection(prevSelected, optionItem.props.value);
|
|
2344
|
+
return newSelectedValue;
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
}, [options, selectionType]);
|
|
2348
|
+
const handleKeyDown = React.useCallback(event => {
|
|
2349
|
+
const {
|
|
2350
|
+
key
|
|
2351
|
+
} = event;
|
|
2352
|
+
switch (key) {
|
|
2353
|
+
case "ArrowUp":
|
|
2354
|
+
event.preventDefault();
|
|
2355
|
+
focusPreviousItem();
|
|
2356
|
+
return;
|
|
2357
|
+
case "ArrowDown":
|
|
2358
|
+
event.preventDefault();
|
|
2359
|
+
focusNextItem();
|
|
2360
|
+
return;
|
|
2361
|
+
case "Home":
|
|
2362
|
+
event.preventDefault();
|
|
2363
|
+
focusItem(0);
|
|
2364
|
+
return;
|
|
2365
|
+
case "End":
|
|
2366
|
+
event.preventDefault();
|
|
2367
|
+
focusItem(options.length - 1);
|
|
2368
|
+
return;
|
|
2369
|
+
case "Enter":
|
|
2370
|
+
case " ":
|
|
2371
|
+
event.preventDefault();
|
|
2372
|
+
selectOption(focusedIndex);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
}, [focusNextItem, focusPreviousItem, focusedIndex, options, selectOption]);
|
|
2376
|
+
const handleKeyUp = React.useCallback(event => {
|
|
2377
|
+
switch (event.key) {
|
|
2378
|
+
case " ":
|
|
2379
|
+
event.preventDefault();
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
}, []);
|
|
2383
|
+
const handleFocus = React.useCallback(() => {
|
|
2384
|
+
if (!disabled) {
|
|
2385
|
+
setIsListboxFocused(true);
|
|
2386
|
+
}
|
|
2387
|
+
}, [disabled]);
|
|
2388
|
+
const handleBlur = React.useCallback(() => {
|
|
2389
|
+
if (!disabled) {
|
|
2390
|
+
setIsListboxFocused(false);
|
|
2391
|
+
}
|
|
2392
|
+
}, [disabled]);
|
|
2393
|
+
const handleClick = React.useCallback(value => {
|
|
2394
|
+
const index = options.findIndex(item => item.props.value === value);
|
|
2395
|
+
const isOptionDisabled = options[index].props.disabled;
|
|
2396
|
+
if (disabled || isOptionDisabled) {
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
focusItem(index);
|
|
2400
|
+
selectOption(index);
|
|
2401
|
+
}, [disabled, options, selectOption]);
|
|
2402
|
+
const renderList = React.useMemo(() => {
|
|
2403
|
+
return options.map((component, index) => {
|
|
2404
|
+
const isSelected = (selected == null ? void 0 : selected.includes(component.props.value)) || false;
|
|
2405
|
+
const optionId = id ? `${id}-option-${index}` : `option-${index}`;
|
|
2406
|
+
return React.cloneElement(component, {
|
|
2407
|
+
key: index,
|
|
2408
|
+
focused: isListboxFocused && index === focusedIndex,
|
|
2409
|
+
disabled: component.props.disabled || disabled || false,
|
|
2410
|
+
selected: isSelected,
|
|
2411
|
+
variant: selectionType === "single" ? "check" : "checkbox",
|
|
2412
|
+
parentComponent: "listbox",
|
|
2413
|
+
id: optionId,
|
|
2414
|
+
onToggle: () => {
|
|
2415
|
+
handleClick(component.props.value);
|
|
2416
|
+
},
|
|
2417
|
+
role: "option"
|
|
2418
|
+
});
|
|
2419
|
+
});
|
|
2420
|
+
}, [options, selected, id, isListboxFocused, focusedIndex, disabled, selectionType, handleClick]);
|
|
2421
|
+
return {
|
|
2422
|
+
isListboxFocused,
|
|
2423
|
+
focusedIndex,
|
|
2424
|
+
renderList,
|
|
2425
|
+
selected,
|
|
2426
|
+
handleKeyDown,
|
|
2427
|
+
handleKeyUp,
|
|
2428
|
+
handleFocus,
|
|
2429
|
+
handleBlur
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function Listbox(props) {
|
|
2434
|
+
const {
|
|
2435
|
+
children,
|
|
2436
|
+
disabled,
|
|
2437
|
+
id,
|
|
2438
|
+
onChange,
|
|
2439
|
+
selectionType = "single",
|
|
2440
|
+
style,
|
|
2441
|
+
tabIndex = 0,
|
|
2442
|
+
testId,
|
|
2443
|
+
value,
|
|
2444
|
+
"aria-label": ariaLabel,
|
|
2445
|
+
"aria-labelledby": ariaLabelledby
|
|
2446
|
+
} = props;
|
|
2447
|
+
const ids = useUniqueIdWithMock("listbox");
|
|
2448
|
+
const uniqueId = id != null ? id : ids.get("id");
|
|
2449
|
+
const {
|
|
2450
|
+
focusedIndex,
|
|
2451
|
+
isListboxFocused,
|
|
2452
|
+
renderList,
|
|
2453
|
+
selected,
|
|
2454
|
+
handleKeyDown,
|
|
2455
|
+
handleKeyUp,
|
|
2456
|
+
handleFocus,
|
|
2457
|
+
handleBlur
|
|
2458
|
+
} = useListbox({
|
|
2459
|
+
children,
|
|
2460
|
+
disabled,
|
|
2461
|
+
id: uniqueId,
|
|
2462
|
+
selectionType,
|
|
2463
|
+
value
|
|
2464
|
+
});
|
|
2465
|
+
React.useEffect(() => {
|
|
2466
|
+
if (selected && selected !== value) {
|
|
2467
|
+
onChange == null ? void 0 : onChange(selected);
|
|
2468
|
+
}
|
|
2469
|
+
}, [onChange, selected, value]);
|
|
2470
|
+
return React.createElement(View, {
|
|
2471
|
+
role: "listbox",
|
|
2472
|
+
"aria-disabled": disabled,
|
|
2473
|
+
id: uniqueId,
|
|
2474
|
+
style: [styles.listbox, style, disabled && styles.disabled],
|
|
2475
|
+
tabIndex: tabIndex,
|
|
2476
|
+
onKeyDown: handleKeyDown,
|
|
2477
|
+
onKeyUp: handleKeyUp,
|
|
2478
|
+
onFocus: handleFocus,
|
|
2479
|
+
onBlur: handleBlur,
|
|
2480
|
+
testId: testId,
|
|
2481
|
+
"aria-activedescendant": isListboxFocused ? renderList[focusedIndex].props.id : undefined,
|
|
2482
|
+
"aria-label": ariaLabel,
|
|
2483
|
+
"aria-labelledby": ariaLabelledby,
|
|
2484
|
+
"aria-multiselectable": selectionType === "multiple"
|
|
2485
|
+
}, renderList);
|
|
2486
|
+
}
|
|
2487
|
+
const styles = StyleSheet.create({
|
|
2488
|
+
listbox: {
|
|
2489
|
+
outline: "none"
|
|
2490
|
+
},
|
|
2491
|
+
disabled: {
|
|
2492
|
+
color: color.offBlack64
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
export { ActionItem, ActionMenu, Listbox, MultiSelect, OptionItem, SeparatorItem, SingleSelect };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { MaybeValueOrValues, OptionItemComponent } from "../util/types";
|
|
3
|
+
type Props = {
|
|
4
|
+
/**
|
|
5
|
+
* The list of items to display in the listbox.
|
|
6
|
+
*/
|
|
7
|
+
children: Array<OptionItemComponent>;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the listbox is disabled.
|
|
10
|
+
*/
|
|
11
|
+
disabled: boolean | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* The unique identifier of the listbox element.
|
|
14
|
+
*/
|
|
15
|
+
id: string;
|
|
16
|
+
/**
|
|
17
|
+
* The value of the currently selected items.
|
|
18
|
+
*/
|
|
19
|
+
value?: MaybeValueOrValues;
|
|
20
|
+
/**
|
|
21
|
+
* The type of selection that the listbox supports.
|
|
22
|
+
*/
|
|
23
|
+
selectionType: "single" | "multiple";
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Hook for managing the state of a listbox.
|
|
27
|
+
*
|
|
28
|
+
* It manages how the options are rendered and how the listbox behaves.
|
|
29
|
+
*
|
|
30
|
+
* This includes:
|
|
31
|
+
* - Keyboard navigation.
|
|
32
|
+
* - Selection management.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useListbox({ children: options, disabled, id, selectionType, value, }: Props): {
|
|
35
|
+
isListboxFocused: boolean;
|
|
36
|
+
focusedIndex: number;
|
|
37
|
+
renderList: React.ReactElement<Pick<Readonly<import("../../../wonder-blocks-core/src/util/aria-types").AriaAttributes> & Readonly<{
|
|
38
|
+
role?: import("../../../wonder-blocks-core/src/util/aria-types").AriaRole | undefined;
|
|
39
|
+
}> & {
|
|
40
|
+
label: import("../util/types").OptionLabel;
|
|
41
|
+
labelAsText?: string | undefined;
|
|
42
|
+
value: string;
|
|
43
|
+
disabled: boolean;
|
|
44
|
+
onClick?: (() => unknown) | undefined;
|
|
45
|
+
onToggle: (value: string) => unknown;
|
|
46
|
+
selected: boolean;
|
|
47
|
+
focused: boolean;
|
|
48
|
+
role: "menuitem" | "option";
|
|
49
|
+
testId?: string | undefined;
|
|
50
|
+
variant?: "checkbox" | "check" | undefined;
|
|
51
|
+
style?: import("@khanacademy/wonder-blocks-core").StyleType;
|
|
52
|
+
parentComponent?: "listbox" | "dropdown" | undefined;
|
|
53
|
+
id?: string | undefined;
|
|
54
|
+
horizontalRule: import("../../../wonder-blocks-cell/src/util/types").HorizontalRuleVariant | undefined;
|
|
55
|
+
leftAccessory?: React.ReactNode;
|
|
56
|
+
rightAccessory?: React.ReactNode;
|
|
57
|
+
subtitle1?: import("../../../wonder-blocks-cell/src/util/types").TypographyText | undefined;
|
|
58
|
+
subtitle2?: import("../../../wonder-blocks-cell/src/util/types").TypographyText | undefined;
|
|
59
|
+
}, "style" | "label" | "id" | "value" | "onClick" | keyof import("../../../wonder-blocks-core/src/util/aria-types").AriaAttributes | "testId" | "leftAccessory" | "rightAccessory" | "labelAsText" | "variant" | "parentComponent" | "subtitle1" | "subtitle2"> & {
|
|
60
|
+
disabled?: boolean | undefined;
|
|
61
|
+
role?: "menuitem" | "option" | undefined;
|
|
62
|
+
selected?: boolean | undefined;
|
|
63
|
+
onToggle?: ((value: string) => unknown) | undefined;
|
|
64
|
+
focused?: boolean | undefined;
|
|
65
|
+
horizontalRule?: import("../../../wonder-blocks-cell/src/util/types").HorizontalRuleVariant | undefined;
|
|
66
|
+
} & {}, string | React.JSXElementConstructor<any>>[];
|
|
67
|
+
selected: string | import("../util/types").MaybeString[] | null | undefined;
|
|
68
|
+
handleKeyDown: (event: React.KeyboardEvent) => void;
|
|
69
|
+
handleKeyUp: (event: React.KeyboardEvent) => void;
|
|
70
|
+
handleFocus: () => void;
|
|
71
|
+
handleBlur: () => void;
|
|
72
|
+
};
|
|
73
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ 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
|
import type { Labels } from "./components/multi-select";
|
|
8
9
|
import type { SingleSelectLabels } from "./components/single-select";
|
|
9
|
-
export { ActionItem, OptionItem, SeparatorItem, ActionMenu, SingleSelect, MultiSelect, };
|
|
10
|
+
export { ActionItem, OptionItem, SeparatorItem, ActionMenu, SingleSelect, MultiSelect, Listbox, };
|
|
10
11
|
export type { Labels, SingleSelectLabels };
|