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