@thecb/components 6.0.0-beta.7 → 6.0.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.7",
3
+ "version": "6.0.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);
@@ -124,14 +130,13 @@ const Dropdown = ({
124
130
  const onKeyDown = e => {
125
131
  const { key, keyCode } = e;
126
132
  const focus = document.activeElement;
127
- console.log("dropdown value is", value);
128
- console.log("focus is", focus);
129
- console.log("option refs are", optionRefs.current);
130
133
  const optionEl = optionRefs.current.find(ref => ref.current === focus);
131
- console.log("option el is", optionEl);
132
134
  switch (key) {
133
135
  case "ArrowDown":
134
136
  e.preventDefault();
137
+ if (!isOpen) {
138
+ onClick();
139
+ }
135
140
  if (optionEl) {
136
141
  if (optionEl.current.nextElementSibling) {
137
142
  optionEl.current.nextElementSibling.focus();
@@ -171,9 +176,12 @@ const Dropdown = ({
171
176
  break;
172
177
  case "End":
173
178
  e.preventDefault();
174
- optionRefs.current[
175
- optionRefs?.current?.length ?? 0 - 1
176
- ].current.focus();
179
+ optionRefs.current.at(-1).current.focus();
180
+ break;
181
+ case "Escape":
182
+ if (isOpen) {
183
+ onClick();
184
+ }
177
185
  break;
178
186
  }
179
187
  if ((keyCode > 64 && keyCode < 91) || keyCode == 32 || keyCode == 189) {
@@ -182,20 +190,37 @@ const Dropdown = ({
182
190
  }
183
191
  };
184
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
+
185
205
  useEffect(() => {
186
- console.log(
187
- "option refs in isopen useffect",
188
- optionRefs.current[0].current
189
- );
190
- console.log("selected refs in isopen useffect", selectedRef);
191
- console.log("value in isopen useffect", value);
192
- if (isOpen && selectedRef !== undefined) {
193
- // 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
194
210
  selectedRef.current.focus();
195
- } else if (isOpen && optionRefs.current[0].current) {
211
+ } else if (isOpen && optionRefs.current[0].current && !focusedByClick) {
196
212
  // If no selected option, first option receives focus
197
213
  optionRefs.current[0].current.focus();
198
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
+ }
199
224
  clearTimeout(timer);
200
225
  setInputValue("");
201
226
  }, [isOpen]);
@@ -203,7 +228,7 @@ const Dropdown = ({
203
228
  useEffect(() => {
204
229
  if (autoEraseTypeAhead) {
205
230
  clearTimeout(timer);
206
- setTimer(setTimeout(() => setInputValue(""), 5000));
231
+ setTimer(setTimeout(() => setInputValue(""), 3000));
207
232
  }
208
233
  setFilteredOptions(
209
234
  options.filter(
@@ -216,14 +241,20 @@ const Dropdown = ({
216
241
 
217
242
  useEffect(() => {
218
243
  if (
219
- !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) &&
220
249
  filteredOptions[0] &&
221
250
  !disabledValues.includes(filteredOptions[0].value) &&
222
251
  filteredOptions[0].text != placeholder
223
252
  ) {
224
- console.log("filtered options are", filteredOptions);
225
- console.log("option refs are", optionRefs);
253
+ setInputChangedByAutofill(false);
226
254
  onSelect(filteredOptions[0].value);
255
+ if (isOpen) {
256
+ setTimeout(() => onClick(), 1000);
257
+ }
227
258
  }
228
259
  if (optionRefs.current[0].current) {
229
260
  optionRefs.current[0].current.focus();
@@ -234,30 +265,32 @@ const Dropdown = ({
234
265
 
235
266
  return (
236
267
  <Box
237
- onKeyDown={onKeyDown}
238
- onClick={onClick}
239
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}
240
279
  width="100%"
241
- hoverStyles={`background-color: ${themeValues.hoverColor};`}
242
- aria-expanded={isOpen}
243
- role="combobox"
244
- aria-owns={`${ariaLabelledby}_listbox`}
245
- aria-haspopup="listbox"
246
- aria-labelledby={ariaLabelledby}
247
- extraStyles={
248
- disabled &&
249
- `color: #6e727e;
250
- background-color: #f7f7f7;
251
- pointer-events: none;`
252
- }
253
- title={hasTitles ? getSelection() : null}
254
280
  >
255
281
  <Box
256
- 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}
257
292
  background={isOpen ? themeValues.hoverColor : WHITE}
258
- width="100%"
259
- padding="12px"
260
- hoverStyles={`background-color: ${themeValues.hoverColor};`}
293
+ borderRadius="2px"
261
294
  borderSize="1px"
262
295
  borderColor={
263
296
  isError
@@ -266,94 +299,92 @@ const Dropdown = ({
266
299
  ? themeValues.selectedColor
267
300
  : GREY_CHATEAU
268
301
  }
269
- 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"
270
326
  tabIndex={0}
327
+ value={inputValue}
328
+ width="100%"
271
329
  dataQa={placeholder}
272
- extraStyles={`height: 48px;
273
- ${disabled &&
274
- `color: #6e727e;
275
- background-color: #f7f7f7;
276
- pointer-events: none;`}
277
- `}
278
- >
279
- <Stack
280
- direction="row"
281
- bottomItem={2}
282
- extraStyles={`position: relative;`}
283
- >
284
- <SearchInput
285
- aria-label={getSelection()}
286
- placeholder={getSelection()}
287
- value={inputValue}
288
- onChange={noop}
289
- themeValues={themeValues}
290
- role="searchbox"
291
- type="text"
292
- aria-multiline="false"
293
- aria-autocomplete="list"
294
- aria-controls={`${ariaLabelledby}_listbox`}
295
- aria-activedescendant="selected_option"
296
- isOpen={isOpen}
297
- tabIndex={-1}
298
- name={autocompleteValue}
299
- autocomplete={autocompleteValue}
300
- />
301
- <IconWrapper open={isOpen}>
302
- <DropdownIcon />
303
- </IconWrapper>
304
- </Stack>
305
- </Box>
306
- {isOpen ? (
307
- <DropdownContentWrapper
308
- maxHeight={maxHeight}
309
- open={isOpen}
310
- ref={dropdownRef}
311
- widthFitOptions={widthFitOptions}
312
- tabIndex={0}
313
- role="listbox"
314
- id={`${ariaLabelledby}_listbox`}
315
- >
316
- <Stack childGap="0">
317
- {filteredOptions.map((choice, i) => {
318
- if (
319
- choice.value === value &&
320
- selectedRef !== optionRefs.current[i]
321
- ) {
322
- setSelectedRef(optionRefs.current[i]);
323
- }
324
- return (
325
- <DropdownItemWrapper
326
- id={choice.value === value ? "selected_option" : choice.value}
327
- key={choice.value}
328
- ref={optionRefs.current[i]}
329
- as="button"
330
- tabIndex={-1}
331
- onClick={
332
- disabledValues.includes(choice.value)
333
- ? evt => evt.preventDefault()
334
- : () => {
335
- setSelectedRef(optionRefs.current[i]);
336
- onSelect(choice.value);
337
- }
338
- }
339
- selected={choice.value === value}
340
- aria-selected={choice.value === value}
341
- disabled={disabledValues.includes(choice.value)}
342
- data-qa={choice.text}
343
- themeValues={themeValues}
344
- title={hasTitles ? choice.text : null}
345
- role="option"
346
- >
347
- <Text
348
- variant="p"
349
- color={
350
- choice.value === value
351
- ? WHITE
352
- : disabledValues.includes(choice.value)
353
- ? STORM_GREY
354
- : 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
355
359
  }
356
- 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;
357
388
  cursor: ${
358
389
  disabledValues.includes(choice.value)
359
390
  ? "default"
@@ -362,17 +393,18 @@ const Dropdown = ({
362
393
  white-space: nowrap;
363
394
  overflow: hidden;
364
395
  text-overflow: ellipsis;`}
365
- >
366
- {choice.text}
367
- </Text>
368
- </DropdownItemWrapper>
369
- );
370
- })}
371
- </Stack>
372
- </DropdownContentWrapper>
373
- ) : (
374
- <Fragment />
375
- )}
396
+ >
397
+ {choice.text}
398
+ </Text>
399
+ </DropdownItemWrapper>
400
+ );
401
+ })}
402
+ </Stack>
403
+ </DropdownContentWrapper>
404
+ ) : (
405
+ <Fragment />
406
+ )}
407
+ </Fragment>
376
408
  </Box>
377
409
  );
378
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;