@khanacademy/wonder-blocks-dropdown 5.3.8 → 5.4.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.
@@ -1,7 +1,11 @@
1
1
  import * as React from "react";
2
2
  import * as ReactDOM from "react-dom";
3
3
  import {StyleSheet} from "aphrodite";
4
- import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
4
+ import {
5
+ IDProvider,
6
+ type AriaProps,
7
+ type StyleType,
8
+ } from "@khanacademy/wonder-blocks-core";
5
9
  import DropdownOpener from "./dropdown-opener";
6
10
  import ActionItem from "./action-item";
7
11
  import OptionItem from "./option-item";
@@ -72,6 +76,19 @@ type Props = AriaProps &
72
76
  * element to access pointer event state.
73
77
  */
74
78
  opener?: (openerProps: OpenerProps) => React.ReactElement<any>;
79
+ /**
80
+ * Unique identifier attached to the menu dropdown. If used, we need to
81
+ * guarantee that the ID is unique within everything rendered on a page.
82
+ * If one is not provided, one is auto-generated. It is used for the
83
+ * opener's `aria-controls` attribute for screenreaders.
84
+ */
85
+ dropdownId?: string;
86
+ /**
87
+ * Unique identifier attached to the field control. If used, we need to
88
+ * guarantee that the ID is unique within everything rendered on a page.
89
+ * If one is not provided, one is auto-generated.
90
+ */
91
+ id?: string;
75
92
  }>;
76
93
 
77
94
  type State = Readonly<{
@@ -203,14 +220,18 @@ export default class ActionMenu extends React.Component<Props, State> {
203
220
  };
204
221
  // @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'ReactChild | ReactFragment | ReactPortal' is not assignable to parameter of type 'ReactElement<any, string | JSXElementConstructor<any>>'.
205
222
  } else if (OptionItem.isClassOf(item)) {
223
+ const selected = selectedValues
224
+ ? selectedValues.includes(value)
225
+ : false;
206
226
  return {
207
227
  ...itemObject,
208
228
  populatedProps: {
209
229
  onToggle: this.handleOptionSelected,
210
- selected: selectedValues
211
- ? selectedValues.includes(value)
212
- : false,
230
+ selected,
213
231
  variant: "check",
232
+ role: "menuitemcheckbox",
233
+ "aria-checked": selected,
234
+ "aria-selected": undefined,
214
235
  },
215
236
  };
216
237
  } else {
@@ -229,61 +250,77 @@ export default class ActionMenu extends React.Component<Props, State> {
229
250
 
230
251
  renderOpener(
231
252
  numItems: number,
253
+ dropdownId: string,
232
254
  ): React.ReactElement<React.ComponentProps<typeof DropdownOpener>> {
233
- const {disabled, menuText, opener, testId} = this.props;
255
+ const {disabled, menuText, opener, testId, id} = this.props;
234
256
 
235
257
  return (
236
- <DropdownOpener
237
- onClick={this.handleClick}
238
- disabled={numItems === 0 || disabled}
239
- text={menuText}
240
- ref={this.handleOpenerRef}
241
- testId={opener ? undefined : testId}
242
- opened={this.state.opened}
243
- >
244
- {opener
245
- ? opener
246
- : (openerProps) => {
247
- const {
248
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
249
- text,
250
- opened,
251
- ...eventState
252
- } = openerProps;
253
- return (
254
- <ActionMenuOpenerCore
255
- {...eventState}
256
- disabled={disabled}
257
- opened={!!opened}
258
- testId={testId}
259
- >
260
- {menuText}
261
- </ActionMenuOpenerCore>
262
- );
263
- }}
264
- </DropdownOpener>
258
+ <IDProvider id={id} scope="action-menu-opener">
259
+ {(uniqueOpenerId) => (
260
+ <DropdownOpener
261
+ id={uniqueOpenerId}
262
+ aria-controls={dropdownId}
263
+ aria-haspopup="menu"
264
+ onClick={this.handleClick}
265
+ disabled={numItems === 0 || disabled}
266
+ text={menuText}
267
+ ref={this.handleOpenerRef}
268
+ testId={opener ? undefined : testId}
269
+ opened={this.state.opened}
270
+ >
271
+ {opener
272
+ ? opener
273
+ : (openerProps) => {
274
+ const {
275
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
276
+ text,
277
+ opened,
278
+ ...eventState
279
+ } = openerProps;
280
+ return (
281
+ <ActionMenuOpenerCore
282
+ {...eventState}
283
+ disabled={disabled}
284
+ opened={!!opened}
285
+ testId={testId}
286
+ >
287
+ {menuText}
288
+ </ActionMenuOpenerCore>
289
+ );
290
+ }}
291
+ </DropdownOpener>
292
+ )}
293
+ </IDProvider>
265
294
  );
266
295
  }
267
296
 
268
297
  render(): React.ReactNode {
269
- const {alignment, dropdownStyle, style, className} = this.props;
298
+ const {alignment, dropdownStyle, style, className, dropdownId} =
299
+ this.props;
270
300
 
271
301
  const items = this.getMenuItems();
272
- const dropdownOpener = this.renderOpener(items.length);
273
302
 
274
303
  return (
275
- <DropdownCore
276
- role="menu"
277
- style={style}
278
- className={className}
279
- opener={dropdownOpener}
280
- alignment={alignment}
281
- open={this.state.opened}
282
- items={items}
283
- openerElement={this.openerElement}
284
- onOpenChanged={this.handleOpenChanged}
285
- dropdownStyle={[styles.menuTopSpace, dropdownStyle]}
286
- />
304
+ <IDProvider id={dropdownId} scope="action-menu-dropdown">
305
+ {(uniqueDropdownId) => (
306
+ <DropdownCore
307
+ id={uniqueDropdownId}
308
+ role="menu"
309
+ style={style}
310
+ className={className}
311
+ opener={this.renderOpener(
312
+ items.length,
313
+ uniqueDropdownId,
314
+ )}
315
+ alignment={alignment}
316
+ open={this.state.opened}
317
+ items={items}
318
+ openerElement={this.openerElement}
319
+ onOpenChanged={this.handleOpenChanged}
320
+ dropdownStyle={[styles.menuTopSpace, dropdownStyle]}
321
+ />
322
+ )}
323
+ </IDProvider>
287
324
  );
288
325
  }
289
326
  }
@@ -173,6 +173,10 @@ type ExportProps = Readonly<{
173
173
  * Whether the dropdown and it's interactions should be disabled.
174
174
  */
175
175
  disabled?: boolean;
176
+ /**
177
+ * Unique identifier attached to the dropdown.
178
+ */
179
+ id?: string;
176
180
 
177
181
  // Optional props with defaults
178
182
  /**
@@ -861,7 +865,7 @@ class DropdownCore extends React.Component<Props, State> {
861
865
  },
862
866
  // Only pass the ref if the item is focusable.
863
867
  ref: focusable ? currentRef : null,
864
- role: itemRole,
868
+ role: populatedProps.role || itemRole,
865
869
  });
866
870
  });
867
871
  }
@@ -879,6 +883,7 @@ class DropdownCore extends React.Component<Props, State> {
879
883
  const itemRole = this.getItemRole();
880
884
 
881
885
  return this.props.items.map((item, index) => {
886
+ const {populatedProps} = item;
882
887
  if (!SeparatorItem.isClassOf(item.component) && item.focusable) {
883
888
  focusCounter += 1;
884
889
  }
@@ -887,7 +892,7 @@ class DropdownCore extends React.Component<Props, State> {
887
892
 
888
893
  return {
889
894
  ...item,
890
- role: itemRole,
895
+ role: populatedProps.role || itemRole,
891
896
  ref: item.focusable
892
897
  ? this.state.itemRefs[focusIndex]
893
898
  ? this.state.itemRefs[focusIndex].ref
@@ -952,6 +957,7 @@ class DropdownCore extends React.Component<Props, State> {
952
957
  light,
953
958
  openerElement,
954
959
  role,
960
+ id,
955
961
  } = this.props;
956
962
 
957
963
  // The dropdown width is at least the width of the opener.
@@ -977,6 +983,7 @@ class DropdownCore extends React.Component<Props, State> {
977
983
  >
978
984
  {isFilterable && this.renderSearchField()}
979
985
  <View
986
+ id={id}
980
987
  role={role}
981
988
  style={[
982
989
  styles.listboxOrMenu,
@@ -39,6 +39,10 @@ type Props = Partial<Omit<AriaProps, "aria-disabled">> & {
39
39
  * Whether the dropdown is opened.
40
40
  */
41
41
  opened: boolean;
42
+ /**
43
+ * The unique identifier for the opener.
44
+ */
45
+ id?: string;
42
46
  };
43
47
 
44
48
  type DefaultProps = {
@@ -58,7 +62,15 @@ class DropdownOpener extends React.Component<Props> {
58
62
  eventState: ClickableState,
59
63
  clickableChildrenProps: ChildrenProps,
60
64
  ): React.ReactElement {
61
- const {disabled, testId, text, opened} = this.props;
65
+ const {
66
+ disabled,
67
+ testId,
68
+ text,
69
+ opened,
70
+ "aria-controls": ariaControls,
71
+ "aria-haspopup": ariaHasPopUp,
72
+ id,
73
+ } = this.props;
62
74
  const renderedChildren = this.props.children({
63
75
  ...eventState,
64
76
  text,
@@ -70,6 +82,10 @@ class DropdownOpener extends React.Component<Props> {
70
82
  return React.cloneElement(renderedChildren, {
71
83
  ...clickableChildrenProps,
72
84
  disabled,
85
+ "aria-controls": ariaControls,
86
+ id,
87
+ "aria-expanded": opened ? "true" : "false",
88
+ "aria-haspopup": ariaHasPopUp,
73
89
  onClick: childrenProps.onClick
74
90
  ? (e: React.MouseEvent) => {
75
91
  // This is done to avoid overriding a
@@ -1,7 +1,11 @@
1
1
  import * as React from "react";
2
2
  import * as ReactDOM from "react-dom";
3
3
 
4
- import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
4
+ import {
5
+ IDProvider,
6
+ type AriaProps,
7
+ type StyleType,
8
+ } from "@khanacademy/wonder-blocks-core";
5
9
 
6
10
  import ActionItem from "./action-item";
7
11
  import DropdownCore from "./dropdown-core";
@@ -158,6 +162,13 @@ type Props = AriaProps &
158
162
  * Test ID used for e2e testing.
159
163
  */
160
164
  testId?: string;
165
+ /**
166
+ * Unique identifier attached to the listbox dropdown. If used, we need to
167
+ * guarantee that the ID is unique within everything rendered on a page.
168
+ * If one is not provided, one is auto-generated. It is used for the
169
+ * opener's `aria-controls` attribute for screenreaders.
170
+ */
171
+ dropdownId?: string;
161
172
  }>;
162
173
 
163
174
  type State = Readonly<{
@@ -479,6 +490,7 @@ export default class MultiSelect extends React.Component<Props, State> {
479
490
  React.ComponentProps<typeof OptionItem>
480
491
  >[],
481
492
  isDisabled: boolean,
493
+ dropdownId: string,
482
494
  ):
483
495
  | React.ReactElement<React.ComponentProps<typeof DropdownOpener>>
484
496
  | React.ReactElement<React.ComponentProps<typeof SelectOpener>> {
@@ -511,30 +523,40 @@ export default class MultiSelect extends React.Component<Props, State> {
511
523
 
512
524
  const menuText = this.getMenuText(allChildren);
513
525
 
514
- const dropdownOpener = opener ? (
515
- <DropdownOpener
516
- onClick={this.handleClick}
517
- disabled={isDisabled}
518
- ref={this.handleOpenerRef}
519
- text={menuText}
520
- opened={this.state.open}
521
- >
522
- {opener}
523
- </DropdownOpener>
524
- ) : (
525
- <SelectOpener
526
- {...sharedProps}
527
- disabled={isDisabled}
528
- id={id}
529
- isPlaceholder={menuText === noneSelected}
530
- light={light}
531
- onOpenChanged={this.handleOpenChanged}
532
- open={this.state.open}
533
- ref={this.handleOpenerRef}
534
- testId={testId}
535
- >
536
- {menuText}
537
- </SelectOpener>
526
+ const dropdownOpener = (
527
+ <IDProvider id={id} scope="multi-select-opener">
528
+ {(uniqueOpenerId) => {
529
+ return opener ? (
530
+ <DropdownOpener
531
+ id={uniqueOpenerId}
532
+ aria-controls={dropdownId}
533
+ aria-haspopup="listbox"
534
+ onClick={this.handleClick}
535
+ disabled={isDisabled}
536
+ ref={this.handleOpenerRef}
537
+ text={menuText}
538
+ opened={this.state.open}
539
+ >
540
+ {opener}
541
+ </DropdownOpener>
542
+ ) : (
543
+ <SelectOpener
544
+ {...sharedProps}
545
+ disabled={isDisabled}
546
+ id={uniqueOpenerId}
547
+ aria-controls={dropdownId}
548
+ isPlaceholder={menuText === noneSelected}
549
+ light={light}
550
+ onOpenChanged={this.handleOpenChanged}
551
+ open={this.state.open}
552
+ ref={this.handleOpenerRef}
553
+ testId={testId}
554
+ >
555
+ {menuText}
556
+ </SelectOpener>
557
+ );
558
+ }}
559
+ </IDProvider>
538
560
  );
539
561
 
540
562
  return dropdownOpener;
@@ -552,6 +574,7 @@ export default class MultiSelect extends React.Component<Props, State> {
552
574
  "aria-invalid": ariaInvalid,
553
575
  "aria-required": ariaRequired,
554
576
  disabled,
577
+ dropdownId,
555
578
  } = this.props;
556
579
  const {open, searchText} = this.state;
557
580
  const {clearSearch, filter, noResults, someSelected} =
@@ -567,44 +590,54 @@ export default class MultiSelect extends React.Component<Props, State> {
567
590
  ).length;
568
591
  const filteredItems = this.getMenuItems(allChildren);
569
592
  const isDisabled = numEnabledOptions === 0 || disabled;
570
- const opener = this.renderOpener(allChildren, isDisabled);
571
593
 
572
594
  return (
573
- <DropdownCore
574
- role="listbox"
575
- alignment={alignment}
576
- dropdownStyle={[
577
- isFilterable && filterableDropdownStyle,
578
- selectDropdownStyle,
579
- dropdownStyle,
580
- ]}
581
- isFilterable={isFilterable}
582
- items={[
583
- ...this.getShortcuts(numEnabledOptions),
584
- ...filteredItems,
585
- ]}
586
- light={light}
587
- onOpenChanged={this.handleOpenChanged}
588
- open={open}
589
- opener={opener}
590
- openerElement={this.state.openerElement}
591
- selectionType="multi"
592
- style={style}
593
- className={className}
594
- onSearchTextChanged={
595
- isFilterable ? this.handleSearchTextChanged : undefined
596
- }
597
- searchText={isFilterable ? searchText : ""}
598
- labels={{
599
- clearSearch,
600
- filter,
601
- noResults,
602
- someResults: someSelected,
603
- }}
604
- aria-invalid={ariaInvalid}
605
- aria-required={ariaRequired}
606
- disabled={isDisabled}
607
- />
595
+ <IDProvider id={dropdownId} scope="multi-select-dropdown">
596
+ {(uniqueDropdownId) => (
597
+ <DropdownCore
598
+ id={uniqueDropdownId}
599
+ role="listbox"
600
+ alignment={alignment}
601
+ dropdownStyle={[
602
+ isFilterable && filterableDropdownStyle,
603
+ selectDropdownStyle,
604
+ dropdownStyle,
605
+ ]}
606
+ isFilterable={isFilterable}
607
+ items={[
608
+ ...this.getShortcuts(numEnabledOptions),
609
+ ...filteredItems,
610
+ ]}
611
+ light={light}
612
+ onOpenChanged={this.handleOpenChanged}
613
+ open={open}
614
+ opener={this.renderOpener(
615
+ allChildren,
616
+ isDisabled,
617
+ uniqueDropdownId,
618
+ )}
619
+ openerElement={this.state.openerElement}
620
+ selectionType="multi"
621
+ style={style}
622
+ className={className}
623
+ onSearchTextChanged={
624
+ isFilterable
625
+ ? this.handleSearchTextChanged
626
+ : undefined
627
+ }
628
+ searchText={isFilterable ? searchText : ""}
629
+ labels={{
630
+ clearSearch,
631
+ filter,
632
+ noResults,
633
+ someResults: someSelected,
634
+ }}
635
+ aria-invalid={ariaInvalid}
636
+ aria-required={ariaRequired}
637
+ disabled={isDisabled}
638
+ />
639
+ )}
640
+ </IDProvider>
608
641
  );
609
642
  }
610
643
  }
@@ -65,7 +65,7 @@ type OptionProps = AriaProps & {
65
65
  /**
66
66
  * Aria role to use, defaults to "option".
67
67
  */
68
- role: "menuitem" | "option";
68
+ role: "menuitem" | "option" | "menuitemcheckbox";
69
69
  /**
70
70
  * Test ID used for e2e testing.
71
71
  */
@@ -322,7 +322,7 @@ const _generateStyles = (
322
322
  newStyles = {
323
323
  default: {
324
324
  background: error ? tokens.color.fadedRed8 : tokens.color.white,
325
- borderColor: error ? tokens.color.red : tokens.color.offBlack16,
325
+ borderColor: error ? tokens.color.red : tokens.color.offBlack50,
326
326
  borderWidth: tokens.border.width.hairline,
327
327
  color: placeholder
328
328
  ? tokens.color.offBlack64
@@ -334,7 +334,7 @@ const _generateStyles = (
334
334
  ":hover:not([aria-disabled=true])": {
335
335
  borderColor: error
336
336
  ? tokens.color.red
337
- : tokens.color.offBlack16,
337
+ : tokens.color.offBlack50,
338
338
  borderWidth: tokens.border.width.hairline,
339
339
  paddingLeft: tokens.spacing.medium_16,
340
340
  paddingRight: tokens.spacing.small_12,