@thecb/components 6.0.0-beta.3 → 6.0.0-beta.30

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.3",
3
+ "version": "6.0.0-beta.30",
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",
@@ -19,6 +19,7 @@ const CountryDropdown = ({
19
19
  errorMessages={errorMessages}
20
20
  showErrors={showErrors}
21
21
  onChange={onChange}
22
+ autocompleteValue="country-name"
22
23
  />
23
24
  );
24
25
  export default CountryDropdown;
@@ -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: ${({ themeValues }) =>
78
- themeValues.hoverColor && themeValues.hoverColor};
79
- font-size: 16px;
80
- height: 24px;
81
- `;
82
-
83
85
  const Dropdown = ({
84
86
  placeholder,
85
87
  options,
@@ -94,13 +96,18 @@ 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,
100
+ ariaLabelledby,
101
+ autocompleteValue = "" // autofill item for browsers, like country-name or address-level1 for state
98
102
  }) => {
99
103
  const [inputValue, setInputValue] = useState("");
100
104
  const [optionsState, setOptionsState] = useState([]);
101
105
  const [filteredOptions, setFilteredOptions] = useState([]);
102
106
  const [optionsChanged, setOptionsChanged] = useState(true);
103
107
  const [selectedRef, setSelectedRef] = useState(undefined);
108
+ const [focusedRef, setFocusedRef] = useState(undefined);
109
+ const [inputChangedByAutofill, setInputChangedByAutofill] = useState(false);
110
+ const [focusedByClick, setFocusedByClick] = useState(false);
104
111
 
105
112
  if (optionsState !== options) {
106
113
  setOptionsState(options);
@@ -115,21 +122,23 @@ const Dropdown = ({
115
122
  const [timer, setTimer] = useState(null);
116
123
  const optionRefs = useRef([...Array(options.length)].map(() => createRef()));
117
124
  const dropdownRef = useRef(null);
125
+ const inputRef = useRef(null);
118
126
 
119
127
  const getSelection = () =>
120
128
  value ? options.find(option => option.value === value)?.text : placeholder;
121
129
 
122
130
  const onKeyDown = e => {
131
+ console.log("current input value top of keyDown", inputValue);
123
132
  const { key, keyCode } = e;
124
133
  const focus = document.activeElement;
125
134
  console.log("dropdown value is", value);
126
- console.log("focus is", focus);
127
- console.log("option refs are", optionRefs.current);
128
135
  const optionEl = optionRefs.current.find(ref => ref.current === focus);
129
- console.log("option el is", optionEl);
130
136
  switch (key) {
131
137
  case "ArrowDown":
132
138
  e.preventDefault();
139
+ if (!isOpen) {
140
+ onClick();
141
+ }
133
142
  if (optionEl) {
134
143
  if (optionEl.current.nextElementSibling) {
135
144
  optionEl.current.nextElementSibling.focus();
@@ -161,27 +170,60 @@ const Dropdown = ({
161
170
  break;
162
171
  case "Backspace" || "Delete":
163
172
  e.preventDefault();
164
- console.log("input value is", inputValue);
165
- console.log("new input value will be", inputValue.slice(0, -1));
166
173
  setInputValue(inputValue.slice(0, -1));
167
174
  break;
175
+ case "Home":
176
+ e.preventDefault();
177
+ optionRefs.current[0].current.focus();
178
+ break;
179
+ case "End":
180
+ e.preventDefault();
181
+ console.log("option refs current", optionRefs.current);
182
+ optionRefs.current.at(-1).current.focus();
183
+ break;
184
+ case "Escape":
185
+ if (isOpen) {
186
+ onClick();
187
+ }
188
+ break;
168
189
  }
169
190
  if ((keyCode > 64 && keyCode < 91) || keyCode == 32 || keyCode == 189) {
170
191
  e.preventDefault();
192
+ console.log("current input value inside keydown if", inputValue);
171
193
  setInputValue(inputValue + key);
172
194
  }
173
195
  };
174
196
 
197
+ const handleItemSelection = (evt, choice, i) => {
198
+ if (disabledValues.includes(choice.value)) {
199
+ evt.preventDefault();
200
+ } else {
201
+ setSelectedRef(optionRefs.current[i]);
202
+ onSelect(choice.value);
203
+ if (isOpen) {
204
+ onClick();
205
+ }
206
+ }
207
+ };
208
+
175
209
  useEffect(() => {
176
- console.log(
177
- "option refs in isopen useffect",
178
- optionRefs.current[0].current
179
- );
180
- console.log("selected refs in isopen useffect", selectedRef);
181
- console.log("value in isopen useffect", value);
182
- if (isOpen && optionRefs.current[0].current) {
210
+ const selectedRefExists =
211
+ selectedRef !== undefined && selectedRef.current !== null;
212
+ if (isOpen && selectedRefExists && !focusedByClick) {
213
+ // WAI-ARIA requires the selected option to receive focus
214
+ selectedRef.current.focus();
215
+ } else if (isOpen && optionRefs.current[0].current && !focusedByClick) {
216
+ // If no selected option, first option receives focus
183
217
  optionRefs.current[0].current.focus();
184
218
  }
219
+ if (isOpen && focusedByClick && selectedRefExists) {
220
+ selectedRef.current.scrollIntoView({
221
+ behavior: "smooth",
222
+ block: "nearest",
223
+ inline: "start"
224
+ });
225
+ setFocusedByClick(false);
226
+ }
185
227
  clearTimeout(timer);
186
228
  setInputValue("");
187
229
  }, [isOpen]);
@@ -189,7 +231,7 @@ const Dropdown = ({
189
231
  useEffect(() => {
190
232
  if (autoEraseTypeAhead) {
191
233
  clearTimeout(timer);
192
- setTimer(setTimeout(() => setInputValue(""), 2000));
234
+ setTimer(setTimeout(() => setInputValue(""), 3000));
193
235
  }
194
236
  setFilteredOptions(
195
237
  options.filter(
@@ -202,12 +244,21 @@ const Dropdown = ({
202
244
 
203
245
  useEffect(() => {
204
246
  if (
205
- !isOpen &&
247
+ /*
248
+ Either user has typed a value into input that matches a non-disabled option
249
+ or
250
+ user has autofilled or pasted into input a string matching a valid option
251
+ */
252
+ (!isOpen || inputChangedByAutofill) &&
206
253
  filteredOptions[0] &&
207
254
  !disabledValues.includes(filteredOptions[0].value) &&
208
255
  filteredOptions[0].text != placeholder
209
256
  ) {
257
+ setInputChangedByAutofill(false);
210
258
  onSelect(filteredOptions[0].value);
259
+ if (isOpen) {
260
+ setTimeout(() => onClick(), 2000);
261
+ }
211
262
  }
212
263
  if (optionRefs.current[0].current) {
213
264
  optionRefs.current[0].current.focus();
@@ -218,26 +269,32 @@ const Dropdown = ({
218
269
 
219
270
  return (
220
271
  <Box
221
- onKeyDown={onKeyDown}
222
- onClick={onClick}
223
272
  padding="0"
273
+ background={isOpen ? themeValues.hoverColor : WHITE}
274
+ extraStyles={`position: relative;`}
275
+ minWidth="100%"
276
+ onClick={() => {
277
+ if (!isOpen) {
278
+ setFocusedByClick(true);
279
+ onClick();
280
+ }
281
+ }}
282
+ onKeyDown={onKeyDown}
224
283
  width="100%"
225
- hoverStyles={`background-color: ${themeValues.hoverColor};`}
226
- aria-expanded={isOpen}
227
- extraStyles={
228
- disabled &&
229
- `color: #6e727e;
230
- background-color: #f7f7f7;
231
- pointer-events: none;`
232
- }
233
- title={hasTitles ? getSelection() : null}
234
284
  >
235
285
  <Box
236
- as="button"
286
+ as="input"
287
+ aria-multiline="false"
288
+ aria-autocomplete="list"
289
+ aria-controls={`${ariaLabelledby}_listbox`}
290
+ aria-activedescendant="focused_option"
291
+ aria-owns={`${ariaLabelledby}_listbox`}
292
+ aria-haspopup="listbox"
293
+ aria-labelledby={ariaLabelledby}
294
+ aria-expanded={isOpen}
295
+ autocomplete={autocompleteValue}
237
296
  background={isOpen ? themeValues.hoverColor : WHITE}
238
- width="100%"
239
- padding="12px"
240
- hoverStyles={`background-color: ${themeValues.hoverColor};`}
297
+ borderRadius="2px"
241
298
  borderSize="1px"
242
299
  borderColor={
243
300
  isError
@@ -246,82 +303,96 @@ const Dropdown = ({
246
303
  ? themeValues.selectedColor
247
304
  : GREY_CHATEAU
248
305
  }
249
- borderRadius="2px"
250
- tabIndex={0}
251
- dataQa={placeholder}
252
- extraStyles={`height: 48px;
253
- ${disabled &&
254
- `color: #6e727e;
255
- background-color: #f7f7f7;
256
- pointer-events: none;`}
257
- `}
258
- >
259
- <Stack direction="row" bottomItem={2}>
260
- {isOpen ? (
261
- <SearchInput
262
- aria-label={inputValue || "Dropdown awaiting search value"}
263
- value={inputValue}
264
- onChange={noop}
265
- themeValues={themeValues}
266
- />
267
- ) : (
268
- <Text
269
- variant="p"
270
- extraStyles={
271
- disabled &&
272
- `color: #6e727e;
306
+ extraStyles={
307
+ disabled &&
308
+ `color: #6e727e;
273
309
  background-color: #f7f7f7;
274
310
  pointer-events: none;`
275
- }
276
- >
277
- {getSelection()}
278
- </Text>
279
- )}
280
- <IconWrapper open={isOpen}>
281
- <DropdownIcon />
282
- </IconWrapper>
283
- </Stack>
284
- </Box>
285
- {isOpen ? (
286
- <DropdownContentWrapper
287
- maxHeight={maxHeight}
288
- open={isOpen}
289
- ref={dropdownRef}
290
- widthFitOptions={widthFitOptions}
291
- tabIndex={0}
292
- >
293
- <Stack childGap="0">
294
- {filteredOptions.map((choice, i) => {
295
- if (selectedRef === undefined && choice.value === value) {
296
- setSelectedRef(optionRefs.current[i]);
297
- }
298
- return (
299
- <DropdownItemWrapper
300
- key={choice.value}
301
- ref={optionRefs.current[i]}
302
- as="button"
303
- tabIndex={-1}
304
- onClick={
305
- disabledValues.includes(choice.value)
306
- ? evt => evt.preventDefault()
307
- : () => onSelect(choice.value)
308
- }
309
- selected={choice.value === value}
310
- disabled={disabledValues.includes(choice.value)}
311
- data-qa={choice.text}
312
- themeValues={themeValues}
313
- title={hasTitles ? choice.text : null}
314
- >
315
- <Text
316
- variant="p"
317
- color={
318
- choice.value === value
319
- ? WHITE
320
- : disabledValues.includes(choice.value)
321
- ? STORM_GREY
322
- : MINESHAFT_GREY
311
+ }
312
+ hoverStyles={`background-color: ${themeValues.hoverColor};`}
313
+ isOpen={isOpen}
314
+ minHeight="48px"
315
+ minWidth="100%"
316
+ name={autocompleteValue}
317
+ onChange={e => {
318
+ console.log("current input value onChange", inputValue);
319
+ console.log("input change event", e.target);
320
+ console.log("input change event value", e.target.value);
321
+ // support autofill and copy/paste
322
+ if (e.target.value !== inputValue) {
323
+ setInputValue(e.target.value);
324
+ setInputChangedByAutofill(true);
325
+ }
326
+ }}
327
+ padding="12px"
328
+ placeholder={getSelection()}
329
+ ref={inputRef}
330
+ role="combobox"
331
+ themeValues={themeValues}
332
+ title={hasTitles ? getSelection() : null}
333
+ type="text"
334
+ tabIndex={0}
335
+ value={inputValue}
336
+ width="100%"
337
+ dataQa={placeholder}
338
+ />
339
+ <IconWrapper open={isOpen} onClick={onClick}>
340
+ <DropdownIcon />
341
+ </IconWrapper>
342
+ <Fragment>
343
+ {isOpen ? (
344
+ <DropdownContentWrapper
345
+ maxHeight={maxHeight}
346
+ open={isOpen}
347
+ ref={dropdownRef}
348
+ widthFitOptions={widthFitOptions}
349
+ tabIndex={0}
350
+ role="listbox"
351
+ id={`${ariaLabelledby}_listbox`}
352
+ >
353
+ <Stack childGap="0" as="ul">
354
+ {filteredOptions.map((choice, i) => {
355
+ if (
356
+ choice.value === value &&
357
+ selectedRef !== optionRefs.current[i]
358
+ ) {
359
+ setSelectedRef(optionRefs.current[i]);
360
+ }
361
+ return (
362
+ <DropdownItemWrapper
363
+ id={
364
+ focusedRef === optionRefs.current[i]
365
+ ? "focused_option"
366
+ : choice.value
323
367
  }
324
- extraStyles={`padding-left: 16px;
368
+ key={choice.value}
369
+ ref={optionRefs.current[i]}
370
+ tabIndex={-1}
371
+ onClick={e => handleItemSelection(e, choice, i)}
372
+ onKeyDown={e => {
373
+ if (e.keyCode === 13) {
374
+ handleItemSelection(e, choice, i);
375
+ }
376
+ }}
377
+ selected={choice.value === value}
378
+ aria-selected={choice.value === value}
379
+ disabled={disabledValues.includes(choice.value)}
380
+ data-qa={choice.text}
381
+ themeValues={themeValues}
382
+ title={hasTitles ? choice.text : null}
383
+ role="option"
384
+ onFocus={() => setFocusedRef(optionRefs.current[i])}
385
+ >
386
+ <Text
387
+ variant="p"
388
+ color={
389
+ choice.value === value
390
+ ? WHITE
391
+ : disabledValues.includes(choice.value)
392
+ ? STORM_GREY
393
+ : MINESHAFT_GREY
394
+ }
395
+ extraStyles={`padding-left: 16px;
325
396
  cursor: ${
326
397
  disabledValues.includes(choice.value)
327
398
  ? "default"
@@ -330,17 +401,18 @@ const Dropdown = ({
330
401
  white-space: nowrap;
331
402
  overflow: hidden;
332
403
  text-overflow: ellipsis;`}
333
- >
334
- {choice.text}
335
- </Text>
336
- </DropdownItemWrapper>
337
- );
338
- })}
339
- </Stack>
340
- </DropdownContentWrapper>
341
- ) : (
342
- <Fragment />
343
- )}
404
+ >
405
+ {choice.text}
406
+ </Text>
407
+ </DropdownItemWrapper>
408
+ );
409
+ })}
410
+ </Stack>
411
+ </DropdownContentWrapper>
412
+ ) : (
413
+ <Fragment />
414
+ )}
415
+ </Fragment>
344
416
  </Box>
345
417
  );
346
418
  };
@@ -19,7 +19,8 @@ const FormSelect = ({
19
19
  disabledValues,
20
20
  disabled,
21
21
  themeValues,
22
- hasTitles = false
22
+ hasTitles = false,
23
+ autocompleteValue // autofill item for browsers, like country-name or address-level1 for state
23
24
  }) => {
24
25
  const [open, setOpen] = useState(false);
25
26
  const dropdownRef = useRef(null);
@@ -58,7 +59,7 @@ const FormSelect = ({
58
59
  </Cluster>
59
60
  </Box>
60
61
  <Dropdown
61
- aria-labelledby={labelTextWhenNoError.replace(/\s+/g, "-")}
62
+ ariaLabelledby={labelTextWhenNoError.replace(/\s+/g, "-")}
62
63
  maxHeight={dropdownMaxHeight}
63
64
  hasTitles={hasTitles}
64
65
  placeholder={options[0] ? options[0].text : ""}
@@ -74,6 +75,7 @@ const FormSelect = ({
74
75
  }
75
76
  onClick={() => setOpen(!open)}
76
77
  disabled={disabled}
78
+ autocompleteValue={autocompleteValue}
77
79
  />
78
80
  <Stack direction="row" justify="space-between">
79
81
  {(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;
@@ -22,6 +22,7 @@ const FormStateDropdown = ({
22
22
  labelTextWhenNoError={labelTextWhenNoError}
23
23
  errorMessages={errorMessages}
24
24
  showErrors={showErrors}
25
+ autocompleteValue="address-level1"
25
26
  />
26
27
  );
27
28
  };