@khanacademy/wonder-blocks-dropdown 5.3.5 → 5.3.7

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,12 +1,10 @@
1
1
  import * as React from "react";
2
2
  import {StyleSheet} from "aphrodite";
3
- import {__RouterContext} from "react-router";
4
3
 
5
4
  import type {AriaProps} from "@khanacademy/wonder-blocks-core";
6
5
 
7
6
  import {mix} from "@khanacademy/wonder-blocks-tokens";
8
7
  import {addStyle} from "@khanacademy/wonder-blocks-core";
9
- import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
10
8
  import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
11
9
  import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
12
10
  import * as tokens from "@khanacademy/wonder-blocks-tokens";
@@ -66,10 +64,24 @@ type DefaultProps = {
66
64
  isPlaceholder: SelectOpenerProps["isPlaceholder"];
67
65
  };
68
66
 
67
+ type SelectOpenerState = {
68
+ /**
69
+ * We only keep track of the pressed state to apply styling for when the select
70
+ * opener is pressed using Enter/Space. Other states (active, hover, focus)
71
+ * are not tracked because we use css pseudo-classes to handle those styles
72
+ * instead. Note: `:active` styling is only applied on clicks across browsers,
73
+ * and not on keyboard interaction.
74
+ */
75
+ pressed: boolean;
76
+ };
77
+
69
78
  /**
70
79
  * An opener that opens select boxes.
71
80
  */
72
- export default class SelectOpener extends React.Component<SelectOpenerProps> {
81
+ export default class SelectOpener extends React.Component<
82
+ SelectOpenerProps,
83
+ SelectOpenerState
84
+ > {
73
85
  static defaultProps: DefaultProps = {
74
86
  disabled: false,
75
87
  error: false,
@@ -77,12 +89,41 @@ export default class SelectOpener extends React.Component<SelectOpenerProps> {
77
89
  isPlaceholder: false,
78
90
  };
79
91
 
92
+ constructor(props: SelectOpenerProps) {
93
+ super(props);
94
+
95
+ this.state = {
96
+ pressed: false,
97
+ };
98
+ }
99
+
80
100
  handleClick: (e: React.SyntheticEvent) => void = (e) => {
81
101
  const {open} = this.props;
82
102
  this.props.onOpenChanged(!open);
83
103
  };
84
104
 
85
- renderClickableBehavior(router: any): React.ReactNode {
105
+ handleKeyDown: (e: React.KeyboardEvent) => void = (e) => {
106
+ const keyCode = e.key;
107
+ // Prevent default behavior for Enter key. Without this, the select
108
+ // is only open while the Enter key is pressed.
109
+ // Prevent default behavior for Space key. Without this, Safari stays in
110
+ // active state visually
111
+ if (keyCode === "Enter" || keyCode === " ") {
112
+ this.setState({pressed: true});
113
+ e.preventDefault();
114
+ }
115
+ };
116
+
117
+ handleKeyUp: (e: React.KeyboardEvent) => void = (e) => {
118
+ const keyCode = e.key;
119
+ // On key up for Enter and Space, trigger the click handler
120
+ if (keyCode === "Enter" || keyCode === " ") {
121
+ this.setState({pressed: false});
122
+ this.handleClick(e);
123
+ }
124
+ };
125
+
126
+ render(): React.ReactNode {
86
127
  const {
87
128
  children,
88
129
  disabled,
@@ -97,74 +138,52 @@ export default class SelectOpener extends React.Component<SelectOpenerProps> {
97
138
  ...sharedProps
98
139
  } = this.props;
99
140
 
100
- const ClickableBehavior = getClickableBehavior(router);
141
+ const stateStyles = _generateStyles(light, isPlaceholder, error);
101
142
 
102
- return (
103
- <ClickableBehavior disabled={disabled} onClick={this.handleClick}>
104
- {(state, childrenProps) => {
105
- const stateStyles = _generateStyles(
106
- light,
107
- isPlaceholder,
108
- error,
109
- );
110
- const {hovered, focused, pressed} = state;
111
-
112
- // The icon colors are kind of fickle. This is just logic
113
- // based on the zeplin design.
114
- const iconColor = light
115
- ? disabled || pressed
116
- ? "currentColor"
117
- : tokens.color.white
118
- : disabled
119
- ? tokens.color.offBlack32
120
- : tokens.color.offBlack64;
143
+ // The icon colors are kind of fickle. This is just logic
144
+ // based on the zeplin design.
145
+ const iconColor = light
146
+ ? disabled || error
147
+ ? "currentColor"
148
+ : tokens.color.white
149
+ : disabled
150
+ ? tokens.color.offBlack32
151
+ : tokens.color.offBlack64;
121
152
 
122
- const style = [
123
- styles.shared,
124
- stateStyles.default,
125
- disabled && stateStyles.disabled,
126
- !disabled &&
127
- (pressed
128
- ? stateStyles.active
129
- : (hovered || focused) && stateStyles.focus),
130
- ];
153
+ const style = [
154
+ styles.shared,
155
+ stateStyles.default,
156
+ disabled && stateStyles.disabled,
157
+ !disabled && this.state.pressed && stateStyles.pressed,
158
+ ];
131
159
 
132
- return (
133
- <StyledButton
134
- {...sharedProps}
135
- aria-expanded={open ? "true" : "false"}
136
- aria-haspopup="listbox"
137
- data-testid={testId}
138
- disabled={disabled}
139
- id={id}
140
- style={style}
141
- type="button"
142
- {...childrenProps}
143
- >
144
- <LabelMedium style={styles.text}>
145
- {/* Note(tamarab): Prevents unwanted vertical
146
- shift for empty selection */}
147
- {children || "\u00A0"}
148
- </LabelMedium>
149
- <PhosphorIcon
150
- icon={caretDownIcon}
151
- color={iconColor}
152
- size="small"
153
- style={styles.caret}
154
- aria-hidden="true"
155
- />
156
- </StyledButton>
157
- );
158
- }}
159
- </ClickableBehavior>
160
- );
161
- }
162
-
163
- render(): React.ReactNode {
164
160
  return (
165
- <__RouterContext.Consumer>
166
- {(router) => this.renderClickableBehavior(router)}
167
- </__RouterContext.Consumer>
161
+ <StyledButton
162
+ {...sharedProps}
163
+ aria-disabled={disabled}
164
+ aria-expanded={open ? "true" : "false"}
165
+ aria-haspopup="listbox"
166
+ data-testid={testId}
167
+ id={id}
168
+ style={style}
169
+ type="button"
170
+ onClick={!disabled ? this.handleClick : undefined}
171
+ onKeyDown={!disabled ? this.handleKeyDown : undefined}
172
+ onKeyUp={!disabled ? this.handleKeyUp : undefined}
173
+ >
174
+ <LabelMedium style={styles.text}>
175
+ {/* Note(tamarab): Prevents unwanted vertical
176
+ shift for empty selection */}
177
+ {children || "\u00A0"}
178
+ </LabelMedium>
179
+ <PhosphorIcon
180
+ icon={caretDownIcon}
181
+ color={iconColor}
182
+ size="small"
183
+ style={styles.caret}
184
+ aria-hidden="true"
185
+ />
186
+ </StyledButton>
168
187
  );
169
188
  }
170
189
  }
@@ -229,41 +248,77 @@ const _generateStyles = (
229
248
 
230
249
  let newStyles: Record<string, any> = {};
231
250
  if (light) {
251
+ const focusHoverStyling = {
252
+ borderColor: error ? tokens.color.red : tokens.color.white,
253
+ borderWidth: tokens.spacing.xxxxSmall_2,
254
+ paddingLeft: adjustedPaddingLeft,
255
+ paddingRight: adjustedPaddingRight,
256
+ };
257
+ const activePressedStyling = {
258
+ paddingLeft: adjustedPaddingLeft,
259
+ paddingRight: adjustedPaddingRight,
260
+ borderColor: error ? tokens.color.red : tokens.color.fadedBlue,
261
+ borderWidth: tokens.border.width.thin,
262
+ color: error
263
+ ? tokens.color.offBlack64
264
+ : placeholder
265
+ ? mix(tokens.color.white32, tokens.color.blue)
266
+ : tokens.color.fadedBlue,
267
+ backgroundColor: error
268
+ ? tokens.color.fadedRed
269
+ : tokens.color.activeBlue,
270
+ };
232
271
  newStyles = {
233
272
  default: {
234
273
  background: error ? tokens.color.fadedRed8 : "transparent",
235
- color: placeholder ? tokens.color.white50 : tokens.color.white,
274
+ color: error
275
+ ? tokens.color.offBlack64
276
+ : placeholder
277
+ ? tokens.color.white50
278
+ : tokens.color.white,
236
279
  borderColor: error ? tokens.color.red : tokens.color.white50,
237
280
  borderWidth: tokens.border.width.hairline,
238
- },
239
- focus: {
240
- borderColor: error
241
- ? tokens.color.fadedRed8
242
- : tokens.color.white,
243
- borderWidth: tokens.spacing.xxxxSmall_2,
244
- paddingLeft: adjustedPaddingLeft,
245
- paddingRight: adjustedPaddingRight,
246
- },
247
- active: {
248
- paddingLeft: adjustedPaddingLeft,
249
- paddingRight: adjustedPaddingRight,
250
- borderColor: error ? tokens.color.red : tokens.color.fadedBlue,
251
- borderWidth: tokens.border.width.thin,
252
- color: placeholder
253
- ? mix(tokens.color.white32, tokens.color.blue)
254
- : tokens.color.fadedBlue,
255
- backgroundColor: error
256
- ? tokens.color.fadedRed
257
- : tokens.color.activeBlue,
281
+ ":hover:not([aria-disabled=true])": focusHoverStyling,
282
+ // Allow hover styles on non-touch devices only. This prevents an
283
+ // issue with hover being sticky on touch devices (e.g. mobile).
284
+ ["@media not (hover: hover)"]: {
285
+ ":hover:not([aria-disabled=true])": {
286
+ borderColor: error
287
+ ? tokens.color.red
288
+ : tokens.color.white50,
289
+ borderWidth: tokens.border.width.hairline,
290
+ paddingLeft: tokens.spacing.medium_16,
291
+ paddingRight: tokens.spacing.small_12,
292
+ },
293
+ },
294
+ ":focus-visible:not([aria-disabled=true])": focusHoverStyling,
295
+ ":active:not([aria-disabled=true])": activePressedStyling,
258
296
  },
259
297
  disabled: {
260
298
  background: "transparent",
261
299
  borderColor: mix(tokens.color.white32, tokens.color.blue),
262
300
  color: mix(tokens.color.white32, tokens.color.blue),
263
- cursor: "auto",
301
+ cursor: "not-allowed",
302
+ ":focus-visible": {
303
+ boxShadow: `0 0 0 1px ${tokens.color.offBlack32}, 0 0 0 3px ${tokens.color.fadedBlue}`,
304
+ },
264
305
  },
306
+ pressed: activePressedStyling,
265
307
  };
266
308
  } else {
309
+ const focusHoverStyling = {
310
+ borderColor: error ? tokens.color.red : tokens.color.blue,
311
+ borderWidth: tokens.border.width.thin,
312
+ paddingLeft: adjustedPaddingLeft,
313
+ paddingRight: adjustedPaddingRight,
314
+ };
315
+ const activePressedStyling = {
316
+ background: error ? tokens.color.fadedRed : tokens.color.fadedBlue,
317
+ borderColor: error ? tokens.color.red : tokens.color.activeBlue,
318
+ borderWidth: tokens.border.width.thin,
319
+ paddingLeft: adjustedPaddingLeft,
320
+ paddingRight: adjustedPaddingRight,
321
+ };
267
322
  newStyles = {
268
323
  default: {
269
324
  background: error ? tokens.color.fadedRed8 : tokens.color.white,
@@ -272,28 +327,32 @@ const _generateStyles = (
272
327
  color: placeholder
273
328
  ? tokens.color.offBlack64
274
329
  : tokens.color.offBlack,
275
- },
276
- focus: {
277
- borderColor: error ? tokens.color.red : tokens.color.blue,
278
- borderWidth: tokens.border.width.thin,
279
- paddingLeft: adjustedPaddingLeft,
280
- paddingRight: adjustedPaddingRight,
281
- },
282
- active: {
283
- background: error
284
- ? tokens.color.fadedRed
285
- : tokens.color.fadedBlue,
286
- borderColor: error ? tokens.color.red : tokens.color.activeBlue,
287
- borderWidth: tokens.border.width.thin,
288
- paddingLeft: adjustedPaddingLeft,
289
- paddingRight: adjustedPaddingRight,
330
+ ":hover:not([aria-disabled=true])": focusHoverStyling,
331
+ // Allow hover styles on non-touch devices only. This prevents an
332
+ // issue with hover being sticky on touch devices (e.g. mobile).
333
+ ["@media not (hover: hover)"]: {
334
+ ":hover:not([aria-disabled=true])": {
335
+ borderColor: error
336
+ ? tokens.color.red
337
+ : tokens.color.offBlack16,
338
+ borderWidth: tokens.border.width.hairline,
339
+ paddingLeft: tokens.spacing.medium_16,
340
+ paddingRight: tokens.spacing.small_12,
341
+ },
342
+ },
343
+ ":focus-visible:not([aria-disabled=true])": focusHoverStyling,
344
+ ":active:not([aria-disabled=true])": activePressedStyling,
290
345
  },
291
346
  disabled: {
292
347
  background: tokens.color.offWhite,
293
348
  borderColor: tokens.color.offBlack16,
294
349
  color: tokens.color.offBlack64,
295
- cursor: "auto",
350
+ cursor: "not-allowed",
351
+ ":focus-visible": {
352
+ boxShadow: `0 0 0 1px ${tokens.color.white}, 0 0 0 3px ${tokens.color.offBlack32}`,
353
+ },
296
354
  },
355
+ pressed: activePressedStyling,
297
356
  };
298
357
  }
299
358
 
@@ -252,7 +252,12 @@ export default class SingleSelect extends React.Component<Props, State> {
252
252
  state: State,
253
253
  ): Partial<State> | null {
254
254
  return {
255
- open: typeof props.opened === "boolean" ? props.opened : state.open,
255
+ // open should always be false if select is disabled
256
+ open: props.disabled
257
+ ? false
258
+ : typeof props.opened === "boolean"
259
+ ? props.opened
260
+ : state.open,
256
261
  };
257
262
  }
258
263
 
@@ -448,6 +453,7 @@ export default class SingleSelect extends React.Component<Props, State> {
448
453
  style,
449
454
  "aria-invalid": ariaInvalid,
450
455
  "aria-required": ariaRequired,
456
+ disabled,
451
457
  } = this.props;
452
458
  const {searchText} = this.state;
453
459
  const allChildren = React.Children.toArray(children).filter(Boolean);
@@ -484,6 +490,7 @@ export default class SingleSelect extends React.Component<Props, State> {
484
490
  labels={labels}
485
491
  aria-invalid={ariaInvalid}
486
492
  aria-required={ariaRequired}
493
+ disabled={disabled}
487
494
  />
488
495
  );
489
496
  }