@thecb/components 6.0.0-beta.9 → 6.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thecb/components",
3
- "version": "6.0.0-beta.9",
3
+ "version": "6.0.2",
4
4
  "description": "Common lib for CityBase react components",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.esm.js",
@@ -78,6 +78,7 @@
78
78
  },
79
79
  "dependencies": {
80
80
  "@babel/runtime": "^7.15.4",
81
+ "core-js": "^3.22.5",
81
82
  "formatted-input": "^1.0.0",
82
83
  "framer-motion": "^1.11.0",
83
84
  "numeral": "^2.0.6",
@@ -1,26 +1,36 @@
1
1
  import React, { useEffect, Fragment, useState, useRef, createRef } from "react";
2
2
  import { Box, Stack } from "../layouts";
3
3
  import Text from "../text";
4
- import { noop } from "../../../util/general";
4
+ import {
5
+ noop,
6
+ inputDisabledStyle,
7
+ inputPlaceholderTextStyle
8
+ } from "../../../util/general";
5
9
  import DropdownIcon from "./DropdownIcon";
6
10
  import styled from "styled-components";
11
+ // support for Array.prototype.at() in older browsers
12
+ import "core-js/proposals/relative-indexing-method";
7
13
 
8
14
  import {
9
15
  WHITE,
10
16
  GREY_CHATEAU,
11
17
  STORM_GREY,
12
18
  MINESHAFT_GREY,
13
- ERROR_COLOR
19
+ ERROR_COLOR,
20
+ CHARADE_GREY
14
21
  } from "../../../constants/colors";
15
22
  import { fallbackValues } from "./Dropdown.theme";
16
23
  import { themeComponent } from "../../../util/themeUtils";
17
24
 
18
25
  const IconWrapper = styled.div`
26
+ position: absolute;
19
27
  display: flex;
20
28
  flex-direction: column;
21
29
  justify-content: center;
22
30
  transition: transform 0.3s ease;
23
- ${({ open }) => (open ? "transform: rotate(-180deg)" : "")}
31
+ ${({ open }) => (open ? "transform: rotate(-180deg)" : "")};
32
+ top: 20px;
33
+ right: 12px;
24
34
  `;
25
35
 
26
36
  const DropdownContentWrapper = styled.div`
@@ -39,9 +49,13 @@ const DropdownContentWrapper = styled.div`
39
49
  &:focus {
40
50
  outline: none;
41
51
  }
52
+
53
+ ul {
54
+ padding-left: 0;
55
+ }
42
56
  `;
43
57
 
44
- const DropdownItemWrapper = styled.div`
58
+ const DropdownItemWrapper = styled.li`
45
59
  background-color: ${({ selected, themeValues }) =>
46
60
  selected ? themeValues.selectedColor : WHITE};
47
61
  text-align: start;
@@ -51,6 +65,7 @@ const DropdownItemWrapper = styled.div`
51
65
  padding: 1rem;
52
66
  box-sizing: border-box;
53
67
  width: 100%;
68
+ list-style: none;
54
69
  cursor: ${({ disabled }) => (disabled ? "default" : "pointer")};
55
70
 
56
71
  &:hover {
@@ -72,14 +87,6 @@ const DropdownItemWrapper = styled.div`
72
87
  }
73
88
  `;
74
89
 
75
- const SearchInput = styled.input`
76
- border: none;
77
- background-color: transparent;
78
- font-size: 16px;
79
- height: 24px;
80
- min-width: 80%;
81
- `;
82
-
83
90
  const Dropdown = ({
84
91
  placeholder,
85
92
  options,
@@ -94,15 +101,19 @@ const Dropdown = ({
94
101
  widthFitOptions = false,
95
102
  disabled,
96
103
  hasTitles = false,
97
- autoEraseTypeAhead = true, // legacy behavior as of 05/22
104
+ autoEraseTypeAhead = true,
98
105
  ariaLabelledby,
99
- autocompleteValue = "" // autofill item for browsers, like country-name or address-level1 for state
106
+ autocompleteValue = "", // browser autofill value, like country-name or address-level1 for state
107
+ smoothScroll = true
100
108
  }) => {
101
109
  const [inputValue, setInputValue] = useState("");
102
110
  const [optionsState, setOptionsState] = useState([]);
103
111
  const [filteredOptions, setFilteredOptions] = useState([]);
104
112
  const [optionsChanged, setOptionsChanged] = useState(true);
105
113
  const [selectedRef, setSelectedRef] = useState(undefined);
114
+ const [focusedRef, setFocusedRef] = useState(undefined);
115
+ const [inputChangedByAutofill, setInputChangedByAutofill] = useState(false);
116
+ const [focusedByClick, setFocusedByClick] = useState(false);
106
117
 
107
118
  if (optionsState !== options) {
108
119
  setOptionsState(options);
@@ -122,18 +133,15 @@ const Dropdown = ({
122
133
  value ? options.find(option => option.value === value)?.text : placeholder;
123
134
 
124
135
  const onKeyDown = e => {
125
- console.log("key down event is", e.target);
126
- console.log("key down event value is", e.target.value);
127
136
  const { key, keyCode } = e;
128
137
  const focus = document.activeElement;
129
- console.log("dropdown value is", value);
130
- console.log("focus is", focus);
131
- console.log("option refs are", optionRefs.current);
132
138
  const optionEl = optionRefs.current.find(ref => ref.current === focus);
133
- console.log("option el is", optionEl);
134
139
  switch (key) {
135
140
  case "ArrowDown":
136
141
  e.preventDefault();
142
+ if (!isOpen) {
143
+ onClick();
144
+ }
137
145
  if (optionEl) {
138
146
  if (optionEl.current.nextElementSibling) {
139
147
  optionEl.current.nextElementSibling.focus();
@@ -173,9 +181,12 @@ const Dropdown = ({
173
181
  break;
174
182
  case "End":
175
183
  e.preventDefault();
176
- optionRefs.current[
177
- optionRefs?.current?.length ?? 0 - 1
178
- ].current.focus();
184
+ optionRefs.current.at(-1).current.focus();
185
+ break;
186
+ case "Escape":
187
+ if (isOpen) {
188
+ onClick();
189
+ }
179
190
  break;
180
191
  }
181
192
  if ((keyCode > 64 && keyCode < 91) || keyCode == 32 || keyCode == 189) {
@@ -184,21 +195,37 @@ const Dropdown = ({
184
195
  }
185
196
  };
186
197
 
198
+ const handleItemSelection = (evt, choice, i) => {
199
+ if (disabledValues.includes(choice.value)) {
200
+ evt.preventDefault();
201
+ } else {
202
+ setSelectedRef(optionRefs.current[i]);
203
+ onSelect(choice.value);
204
+ if (isOpen) {
205
+ onClick();
206
+ }
207
+ }
208
+ };
209
+
187
210
  useEffect(() => {
188
- console.log("option refs in isopen useffect", optionRefs);
189
- console.log(
190
- "option ref current in isopen useffect",
191
- optionRefs.current[0].current
192
- );
193
- console.log("selected refs in isopen useffect", selectedRef);
194
- console.log("value in isopen useffect", value);
195
- if (isOpen && selectedRef !== undefined && selectedRef.current !== null) {
196
- // WAI-ARIA requires the selected option to receive focus
211
+ const selectedRefExists =
212
+ selectedRef !== undefined && selectedRef.current !== null;
213
+ if (isOpen && selectedRefExists && !focusedByClick) {
214
+ // For keyboard users, WAI-ARIA requires the selected option to receive focus
197
215
  selectedRef.current.focus();
198
- } else if (isOpen && optionRefs.current[0].current) {
216
+ } else if (isOpen && optionRefs.current[0].current && !focusedByClick) {
199
217
  // If no selected option, first option receives focus
200
218
  optionRefs.current[0].current.focus();
201
219
  }
220
+ if (isOpen && focusedByClick && selectedRefExists) {
221
+ // To support autofill for mouse users, we maintain focus on input just scroll item into view
222
+ selectedRef.current.scrollIntoView({
223
+ behavior: smoothScroll ? "smooth" : "auto",
224
+ block: "nearest",
225
+ inline: "start"
226
+ });
227
+ setFocusedByClick(false);
228
+ }
202
229
  clearTimeout(timer);
203
230
  setInputValue("");
204
231
  }, [isOpen]);
@@ -219,14 +246,20 @@ const Dropdown = ({
219
246
 
220
247
  useEffect(() => {
221
248
  if (
222
- !isOpen &&
249
+ /*
250
+ Either user has typed a value into input that matches a non-disabled option or
251
+ user has autofilled or pasted into input a string matching a valid option
252
+ */
253
+ (!isOpen || inputChangedByAutofill) &&
223
254
  filteredOptions[0] &&
224
255
  !disabledValues.includes(filteredOptions[0].value) &&
225
256
  filteredOptions[0].text != placeholder
226
257
  ) {
227
- console.log("filtered options are", filteredOptions);
228
- console.log("option refs are", optionRefs);
258
+ setInputChangedByAutofill(false);
229
259
  onSelect(filteredOptions[0].value);
260
+ if (isOpen) {
261
+ setTimeout(() => onClick(), 1000);
262
+ }
230
263
  }
231
264
  if (optionRefs.current[0].current) {
232
265
  optionRefs.current[0].current.focus();
@@ -237,112 +270,125 @@ const Dropdown = ({
237
270
 
238
271
  return (
239
272
  <Box
273
+ padding="0"
274
+ background={isOpen ? themeValues.hoverColor : WHITE}
275
+ extraStyles={`position: relative;`}
276
+ minWidth="100%"
277
+ onClick={() => {
278
+ if (!isOpen) {
279
+ setFocusedByClick(true);
280
+ onClick();
281
+ }
282
+ }}
240
283
  onKeyDown={onKeyDown}
241
- onClick={onClick}
242
- padding="12px"
243
284
  width="100%"
244
- hoverStyles={`background-color: ${themeValues.hoverColor};`}
245
- background={isOpen ? themeValues.hoverColor : WHITE}
246
- aria-expanded={isOpen}
247
- role="combobox"
248
- aria-owns={`${ariaLabelledby}_listbox`}
249
- aria-haspopup="listbox"
250
- aria-labelledby={ariaLabelledby}
251
- extraStyles={
252
- disabled &&
253
- `color: #6e727e;
254
- background-color: #f7f7f7;
255
- pointer-events: none;`
256
- }
257
- title={hasTitles ? getSelection() : null}
258
- dataQa={placeholder}
259
- tabIndex={0}
260
- borderRadius="2px"
261
- borderSize="1px"
262
- borderColor={
263
- isError
264
- ? ERROR_COLOR
265
- : isOpen
266
- ? themeValues.selectedColor
267
- : GREY_CHATEAU
268
- }
269
285
  >
270
- <Stack direction="row" bottomItem={2} extraStyles={`position: relative;`}>
271
- <SearchInput
272
- aria-label={getSelection()}
273
- placeholder={getSelection()}
274
- value={inputValue}
275
- onChange={e => {
276
- console.log("input change event", e.target);
277
- console.log("input change event value", e.target.value);
278
- }}
279
- themeValues={themeValues}
280
- role="searchbox"
281
- type="text"
282
- aria-multiline="false"
283
- aria-autocomplete="list"
284
- aria-controls={`${ariaLabelledby}_listbox`}
285
- aria-activedescendant="selected_option"
286
- isOpen={isOpen}
287
- tabIndex={-1}
288
- name={autocompleteValue}
289
- autocomplete={autocompleteValue}
290
- />
291
- <IconWrapper open={isOpen}>
292
- <DropdownIcon />
293
- </IconWrapper>
294
- </Stack>
295
- {isOpen ? (
296
- <DropdownContentWrapper
297
- maxHeight={maxHeight}
298
- open={isOpen}
299
- ref={dropdownRef}
300
- widthFitOptions={widthFitOptions}
301
- tabIndex={0}
302
- role="listbox"
303
- id={`${ariaLabelledby}_listbox`}
304
- >
305
- <Stack childGap="0">
306
- {filteredOptions.map((choice, i) => {
307
- if (
308
- choice.value === value &&
309
- selectedRef !== optionRefs.current[i]
310
- ) {
311
- setSelectedRef(optionRefs.current[i]);
312
- }
313
- return (
314
- <DropdownItemWrapper
315
- id={choice.value === value ? "selected_option" : choice.value}
316
- key={choice.value}
317
- ref={optionRefs.current[i]}
318
- as="button"
319
- tabIndex={-1}
320
- onClick={
321
- disabledValues.includes(choice.value)
322
- ? evt => evt.preventDefault()
323
- : () => {
324
- setSelectedRef(optionRefs.current[i]);
325
- onSelect(choice.value);
326
- }
327
- }
328
- selected={choice.value === value}
329
- aria-selected={choice.value === value}
330
- disabled={disabledValues.includes(choice.value)}
331
- data-qa={choice.text}
332
- themeValues={themeValues}
333
- title={hasTitles ? choice.text : null}
334
- role="option"
335
- >
336
- <Text
337
- variant="p"
338
- color={
339
- choice.value === value
340
- ? WHITE
341
- : disabledValues.includes(choice.value)
342
- ? STORM_GREY
343
- : MINESHAFT_GREY
286
+ <Box
287
+ as="input"
288
+ aria-multiline="false"
289
+ aria-autocomplete="list"
290
+ aria-controls={`${ariaLabelledby}_listbox`}
291
+ aria-activedescendant="focused_option"
292
+ aria-owns={`${ariaLabelledby}_listbox`}
293
+ aria-haspopup="listbox"
294
+ aria-labelledby={ariaLabelledby}
295
+ aria-expanded={isOpen}
296
+ autocomplete={autocompleteValue}
297
+ background={isOpen ? themeValues.hoverColor : WHITE}
298
+ borderRadius="2px"
299
+ borderSize="1px"
300
+ borderColor={
301
+ isError
302
+ ? ERROR_COLOR
303
+ : isOpen
304
+ ? themeValues.selectedColor
305
+ : GREY_CHATEAU
306
+ }
307
+ extraStyles={
308
+ disabled
309
+ ? `${inputPlaceholderTextStyle}${inputDisabledStyle}`
310
+ : inputPlaceholderTextStyle
311
+ }
312
+ hoverStyles={`background-color: ${themeValues.hoverColor};`}
313
+ isOpen={isOpen}
314
+ minHeight="48px"
315
+ minWidth="100%"
316
+ name={autocompleteValue}
317
+ onChange={e => {
318
+ // support autofill and copy/paste
319
+ if (e.target.value !== inputValue) {
320
+ setInputValue(e.target.value);
321
+ setInputChangedByAutofill(true);
322
+ }
323
+ }}
324
+ padding="12px"
325
+ placeholder={getSelection()}
326
+ role="combobox"
327
+ themeValues={themeValues}
328
+ title={hasTitles ? getSelection() : null}
329
+ type="text"
330
+ tabIndex={0}
331
+ value={inputValue}
332
+ width="100%"
333
+ dataQa={placeholder}
334
+ />
335
+ <IconWrapper open={isOpen} onClick={onClick}>
336
+ <DropdownIcon />
337
+ </IconWrapper>
338
+ <Fragment>
339
+ {isOpen ? (
340
+ <DropdownContentWrapper
341
+ maxHeight={maxHeight}
342
+ open={isOpen}
343
+ ref={dropdownRef}
344
+ widthFitOptions={widthFitOptions}
345
+ tabIndex={0}
346
+ role="listbox"
347
+ id={`${ariaLabelledby}_listbox`}
348
+ >
349
+ <Stack childGap="0" as="ul">
350
+ {filteredOptions.map((choice, i) => {
351
+ if (
352
+ choice.value === value &&
353
+ selectedRef !== optionRefs.current[i]
354
+ ) {
355
+ setSelectedRef(optionRefs.current[i]);
356
+ }
357
+ return (
358
+ <DropdownItemWrapper
359
+ id={
360
+ focusedRef === optionRefs.current[i]
361
+ ? "focused_option"
362
+ : choice.value
344
363
  }
345
- extraStyles={`padding-left: 16px;
364
+ key={choice.value}
365
+ ref={optionRefs.current[i]}
366
+ tabIndex={-1}
367
+ onClick={e => handleItemSelection(e, choice, i)}
368
+ onKeyDown={e => {
369
+ if (e.keyCode === 13) {
370
+ handleItemSelection(e, choice, i);
371
+ }
372
+ }}
373
+ selected={choice.value === value}
374
+ aria-selected={choice.value === value}
375
+ disabled={disabledValues.includes(choice.value)}
376
+ data-qa={choice.text}
377
+ themeValues={themeValues}
378
+ title={hasTitles ? choice.text : null}
379
+ role="option"
380
+ onFocus={() => setFocusedRef(optionRefs.current[i])}
381
+ >
382
+ <Text
383
+ variant="p"
384
+ color={
385
+ choice.value === value
386
+ ? WHITE
387
+ : disabledValues.includes(choice.value)
388
+ ? STORM_GREY
389
+ : MINESHAFT_GREY
390
+ }
391
+ extraStyles={`padding-left: 16px;
346
392
  cursor: ${
347
393
  disabledValues.includes(choice.value)
348
394
  ? "default"
@@ -351,17 +397,18 @@ const Dropdown = ({
351
397
  white-space: nowrap;
352
398
  overflow: hidden;
353
399
  text-overflow: ellipsis;`}
354
- >
355
- {choice.text}
356
- </Text>
357
- </DropdownItemWrapper>
358
- );
359
- })}
360
- </Stack>
361
- </DropdownContentWrapper>
362
- ) : (
363
- <Fragment />
364
- )}
400
+ >
401
+ {choice.text}
402
+ </Text>
403
+ </DropdownItemWrapper>
404
+ );
405
+ })}
406
+ </Stack>
407
+ </DropdownContentWrapper>
408
+ ) : (
409
+ <Fragment />
410
+ )}
411
+ </Fragment>
365
412
  </Box>
366
413
  );
367
414
  };
@@ -20,7 +20,8 @@ const FormSelect = ({
20
20
  disabled,
21
21
  themeValues,
22
22
  hasTitles = false,
23
- autocompleteValue // autofill item for browsers, like country-name or address-level1 for state
23
+ autocompleteValue, // browser autofill value, like country-name or address-level1 for state
24
+ smoothScroll = true // whether the browser should animate scroll to selected item on first open
24
25
  }) => {
25
26
  const [open, setOpen] = useState(false);
26
27
  const dropdownRef = useRef(null);
@@ -76,6 +77,7 @@ const FormSelect = ({
76
77
  onClick={() => setOpen(!open)}
77
78
  disabled={disabled}
78
79
  autocompleteValue={autocompleteValue}
80
+ smoothScroll={smoothScroll}
79
81
  />
80
82
  <Stack direction="row" justify="space-between">
81
83
  {(field.hasErrors && field.dirty) || (field.hasErrors && showErrors) ? (
@@ -1,4 +1,4 @@
1
- import React, { Fragment } from "react";
1
+ import React, { Fragment, forwardRef } from "react";
2
2
  import { BoxWrapper } from "./Box.styled";
3
3
  import { safeChildren, screenReaderOnlyStyle } from "../../../util/general";
4
4
 
@@ -10,78 +10,84 @@ import { safeChildren, screenReaderOnlyStyle } from "../../../util/general";
10
10
  completely off screen (only for users of screen readers)
11
11
  */
12
12
 
13
- const Box = ({
14
- padding = "16px",
15
- borderSize = "0px",
16
- borderColor = "transparent",
17
- borderRadius,
18
- boxShadow = "none",
19
- background,
20
- color,
21
- minHeight,
22
- width,
23
- minWidth,
24
- maxWidth,
25
- borderWidthOverride,
26
- border,
27
- textAlign,
28
- hoverStyles,
29
- activeStyles,
30
- disabledStyles,
31
- variant,
32
- as,
33
- onClick,
34
- onKeyDown,
35
- onMouseEnter,
36
- onMouseLeave,
37
- onFocus,
38
- onBlur,
39
- onTouchEnd,
40
- theme,
41
- hiddenStyles,
42
- extraStyles,
43
- srOnly = false,
44
- dataQa,
45
- children,
46
- ...rest
47
- }) => (
48
- <BoxWrapper
49
- padding={padding}
50
- borderSize={borderSize}
51
- borderColor={borderColor}
52
- boxShadow={boxShadow}
53
- color={color}
54
- minHeight={minHeight}
55
- width={width}
56
- minWidth={minWidth}
57
- maxWidth={maxWidth}
58
- background={background}
59
- borderRadius={borderRadius}
60
- borderWidthOverride={borderWidthOverride}
61
- border={border}
62
- hoverStyles={hoverStyles}
63
- activeStyles={activeStyles}
64
- disabledStyles={disabledStyles}
65
- variant={variant}
66
- as={as}
67
- onClick={onClick}
68
- hiddenStyles={hiddenStyles}
69
- onKeyDown={onKeyDown}
70
- extraStyles={
71
- srOnly ? `${screenReaderOnlyStyle}${extraStyles}` : extraStyles
72
- }
73
- theme={theme}
74
- textAlign={textAlign}
75
- data-qa={dataQa}
76
- onMouseEnter={onMouseEnter}
77
- onMouseLeave={onMouseLeave}
78
- onFocus={onFocus}
79
- onBlur={onBlur}
80
- onTouchEnd={onTouchEnd}
81
- {...rest}
82
- >
83
- {safeChildren(children, <Fragment />)}
84
- </BoxWrapper>
13
+ const Box = forwardRef(
14
+ (
15
+ {
16
+ padding = "16px",
17
+ borderSize = "0px",
18
+ borderColor = "transparent",
19
+ borderRadius,
20
+ boxShadow = "none",
21
+ background,
22
+ color,
23
+ minHeight,
24
+ width,
25
+ minWidth,
26
+ maxWidth,
27
+ borderWidthOverride,
28
+ border,
29
+ textAlign,
30
+ hoverStyles,
31
+ activeStyles,
32
+ disabledStyles,
33
+ variant,
34
+ as,
35
+ onClick,
36
+ onKeyDown,
37
+ onMouseEnter,
38
+ onMouseLeave,
39
+ onFocus,
40
+ onBlur,
41
+ onTouchEnd,
42
+ theme,
43
+ hiddenStyles,
44
+ extraStyles,
45
+ srOnly = false,
46
+ dataQa,
47
+ children,
48
+ ...rest
49
+ },
50
+ ref
51
+ ) => (
52
+ <BoxWrapper
53
+ padding={padding}
54
+ borderSize={borderSize}
55
+ borderColor={borderColor}
56
+ boxShadow={boxShadow}
57
+ color={color}
58
+ minHeight={minHeight}
59
+ width={width}
60
+ minWidth={minWidth}
61
+ maxWidth={maxWidth}
62
+ background={background}
63
+ borderRadius={borderRadius}
64
+ borderWidthOverride={borderWidthOverride}
65
+ border={border}
66
+ hoverStyles={hoverStyles}
67
+ activeStyles={activeStyles}
68
+ disabledStyles={disabledStyles}
69
+ variant={variant}
70
+ as={as}
71
+ onClick={onClick}
72
+ hiddenStyles={hiddenStyles}
73
+ onKeyDown={onKeyDown}
74
+ extraStyles={
75
+ srOnly ? `${screenReaderOnlyStyle}${extraStyles}` : extraStyles
76
+ }
77
+ theme={theme}
78
+ textAlign={textAlign}
79
+ data-qa={dataQa}
80
+ onMouseEnter={onMouseEnter}
81
+ onMouseLeave={onMouseLeave}
82
+ onFocus={onFocus}
83
+ onBlur={onBlur}
84
+ onTouchEnd={onTouchEnd}
85
+ ref={ref}
86
+ {...rest}
87
+ >
88
+ {children && safeChildren(children, <Fragment />)}
89
+ </BoxWrapper>
90
+ )
85
91
  );
86
92
 
87
93
  export default Box;
@@ -35,6 +35,9 @@ const HighlightTabRow = ({
35
35
  role="region"
36
36
  aria-label="Payment step"
37
37
  >
38
+ <Box srOnly padding="0">
39
+ <Text>{`Current step: ${tabs[highlightIndex]}`}</Text>
40
+ </Box>
38
41
  <Center maxWidth="76.5rem">
39
42
  <Reel
40
43
  padding="0"