@simplybusiness/mobius 5.5.0 → 5.6.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cjs/components/Combobox/Combobox.js +56 -33
  3. package/dist/cjs/components/Combobox/Combobox.js.map +1 -1
  4. package/dist/cjs/components/Combobox/Listbox.js +58 -0
  5. package/dist/cjs/components/Combobox/Listbox.js.map +1 -0
  6. package/dist/cjs/components/Combobox/Option.js +44 -0
  7. package/dist/cjs/components/Combobox/Option.js.map +1 -0
  8. package/dist/cjs/components/Combobox/fixtures.js +115 -0
  9. package/dist/cjs/components/Combobox/fixtures.js.map +1 -1
  10. package/dist/cjs/components/Combobox/useComboboxHighlight.js +86 -0
  11. package/dist/cjs/components/Combobox/useComboboxHighlight.js.map +1 -0
  12. package/dist/cjs/components/Combobox/utils.js +46 -0
  13. package/dist/cjs/components/Combobox/utils.js.map +1 -0
  14. package/dist/cjs/components/TextArea/TextArea.js +4 -4
  15. package/dist/cjs/components/TextArea/TextArea.js.map +1 -1
  16. package/dist/cjs/components/TextAreaInput/TextAreaInput.js +6 -3
  17. package/dist/cjs/components/TextAreaInput/TextAreaInput.js.map +1 -1
  18. package/dist/cjs/components/TextField/TextField.js +4 -3
  19. package/dist/cjs/components/TextField/TextField.js.map +1 -1
  20. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  21. package/dist/esm/components/Combobox/Combobox.js +55 -32
  22. package/dist/esm/components/Combobox/Combobox.js.map +1 -1
  23. package/dist/esm/components/Combobox/Listbox.js +43 -0
  24. package/dist/esm/components/Combobox/Listbox.js.map +1 -0
  25. package/dist/esm/components/Combobox/Option.js +29 -0
  26. package/dist/esm/components/Combobox/Option.js.map +1 -0
  27. package/dist/esm/components/Combobox/fixtures.js +109 -0
  28. package/dist/esm/components/Combobox/fixtures.js.map +1 -1
  29. package/dist/esm/components/Combobox/types.js.map +1 -1
  30. package/dist/esm/components/Combobox/useComboboxHighlight.js +76 -0
  31. package/dist/esm/components/Combobox/useComboboxHighlight.js.map +1 -0
  32. package/dist/esm/components/Combobox/utils.js +20 -0
  33. package/dist/esm/components/Combobox/utils.js.map +1 -0
  34. package/dist/esm/components/TextArea/TextArea.js +4 -4
  35. package/dist/esm/components/TextArea/TextArea.js.map +1 -1
  36. package/dist/esm/components/TextAreaInput/TextAreaInput.js +6 -3
  37. package/dist/esm/components/TextAreaInput/TextAreaInput.js.map +1 -1
  38. package/dist/esm/components/TextField/TextField.js +4 -3
  39. package/dist/esm/components/TextField/TextField.js.map +1 -1
  40. package/dist/types/components/Combobox/Combobox.d.ts +1 -1
  41. package/dist/types/components/Combobox/Combobox.stories.d.ts +4 -1
  42. package/dist/types/components/Combobox/Listbox.d.ts +10 -0
  43. package/dist/types/components/Combobox/Option.d.ts +2 -0
  44. package/dist/types/components/Combobox/fixtures.d.ts +5 -0
  45. package/dist/types/components/Combobox/types.d.ts +17 -2
  46. package/dist/types/components/Combobox/useComboboxHighlight.d.ts +10 -0
  47. package/dist/types/components/Combobox/useComboboxHighlight.test.d.ts +1 -0
  48. package/dist/types/components/Combobox/utils.d.ts +6 -0
  49. package/dist/types/components/Combobox/utils.test.d.ts +1 -0
  50. package/dist/types/components/TextArea/TextArea.d.ts +2 -2
  51. package/dist/types/components/TextAreaInput/TextAreaInput.d.ts +3 -1
  52. package/package.json +1 -1
  53. package/src/components/Combobox/Combobox.css +51 -4
  54. package/src/components/Combobox/Combobox.mdx +147 -0
  55. package/src/components/Combobox/Combobox.stories.tsx +47 -2
  56. package/src/components/Combobox/Combobox.test.tsx +535 -316
  57. package/src/components/Combobox/Combobox.tsx +78 -58
  58. package/src/components/Combobox/Listbox.tsx +74 -0
  59. package/src/components/Combobox/Option.tsx +41 -0
  60. package/src/components/Combobox/fixtures.tsx +111 -0
  61. package/src/components/Combobox/types.tsx +22 -4
  62. package/src/components/Combobox/useComboboxHighlight.test.tsx +242 -0
  63. package/src/components/Combobox/useComboboxHighlight.tsx +88 -0
  64. package/src/components/Combobox/utils.test.tsx +120 -0
  65. package/src/components/Combobox/utils.tsx +50 -0
  66. package/src/components/TextArea/TextArea.tsx +6 -6
  67. package/src/components/TextAreaInput/TextAreaInput.tsx +16 -4
  68. package/src/components/TextField/TextField.test.tsx +8 -0
  69. package/src/components/TextField/TextField.tsx +3 -1
@@ -1,33 +1,42 @@
1
- import { forwardRef, useId, useRef, useState, useEffect } from "react";
2
1
  import classNames from "classnames/dedupe";
2
+ import { forwardRef, useEffect, useId, useRef, useState } from "react";
3
+ import type { ForwardedRefComponent } from "../../types";
3
4
  import { TextField } from "../TextField";
5
+ import { Listbox } from "./Listbox"; // Import Listbox component
4
6
  import type {
5
- ComboboxProps,
6
7
  ComboboxElementType,
7
- ComboboxRef,
8
8
  ComboboxOption,
9
+ ComboboxOptions,
10
+ ComboboxProps,
11
+ ComboboxRef,
9
12
  } from "./types";
10
- import type { ForwardedRefComponent } from "../../types";
11
-
12
- const getOptionValue = (option: ComboboxOption) =>
13
- typeof option === "string" ? option : option.value;
14
-
15
- const getOptionLabel = (option: ComboboxOption) =>
16
- typeof option === "string" ? option : option.label;
13
+ import { useComboboxHighlight } from "./useComboboxHighlight";
14
+ import { filterOptions, getOptionValue, isOptionGroup } from "./utils";
17
15
 
18
16
  export const Combobox: ForwardedRefComponent<
19
17
  ComboboxProps,
20
18
  ComboboxElementType
21
19
  > = forwardRef((props: ComboboxProps, ref: ComboboxRef) => {
22
- const { defaultValue, options, onSelected } = props;
20
+ const { defaultValue, options, onSelected, className, placeholder, icon } =
21
+ props;
23
22
 
24
- const fallbackRef = useRef();
23
+ const fallbackRef = useRef<HTMLInputElement>(null);
25
24
  const [inputValue, setInputValue] = useState(defaultValue || "");
26
25
  const [isOpen, setIsOpen] = useState(false);
27
- const [highlightedIndex, setHighlightedIndex] = useState(-1);
28
- const [filteredOptions, setFilteredOptions] = useState<ComboboxOption[]>([]);
26
+ const [filteredOptions, setFilteredOptions] = useState<ComboboxOptions>([]);
27
+ const {
28
+ highlightedIndex,
29
+ highlightedGroupIndex,
30
+ highlightNextOption,
31
+ highlightPreviousOption,
32
+ highlightFirstOption,
33
+ highlightLastOption,
34
+ clearHighlight,
35
+ } = useComboboxHighlight(filteredOptions);
36
+
29
37
  const inputRef = ref || fallbackRef;
30
38
  const listboxId = useId();
39
+ const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
31
40
 
32
41
  useEffect(() => {
33
42
  const fetchOptions = async () => {
@@ -35,13 +44,7 @@ export const Combobox: ForwardedRefComponent<
35
44
  const result = await options(inputValue);
36
45
  setFilteredOptions(result);
37
46
  } else {
38
- setFilteredOptions(
39
- options.filter(option =>
40
- getOptionLabel(option)
41
- .toLowerCase()
42
- .includes(inputValue.toLowerCase()),
43
- ),
44
- );
47
+ setFilteredOptions(filterOptions(options, inputValue));
45
48
  }
46
49
  };
47
50
 
@@ -49,75 +52,101 @@ export const Combobox: ForwardedRefComponent<
49
52
  }, [inputValue, options]);
50
53
 
51
54
  const handleFocus = () => {
55
+ if (blurTimeoutRef.current) {
56
+ clearTimeout(blurTimeoutRef.current);
57
+ blurTimeoutRef.current = null;
58
+ }
52
59
  setIsOpen(true);
53
60
  };
54
61
 
55
62
  const handleBlur = () => {
56
- setIsOpen(false);
63
+ blurTimeoutRef.current = setTimeout(() => {
64
+ setIsOpen(false);
65
+ }, 150);
57
66
  };
58
67
 
59
68
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60
69
  const newValue = e.target.value;
61
70
  setInputValue(newValue);
62
71
  setIsOpen(true);
63
- setHighlightedIndex(-1);
72
+ clearHighlight();
64
73
  };
65
74
 
66
75
  const handleOptionSelect = (option: ComboboxOption) => {
67
76
  const value = getOptionValue(option);
77
+ if (!value) return;
68
78
  setIsOpen(false);
69
79
  setInputValue(value);
70
80
  onSelected?.(value);
71
81
  };
72
82
 
83
+ function getHighlightedOption() {
84
+ if (highlightedIndex === -1) return undefined;
85
+
86
+ if (isOptionGroup(filteredOptions)) {
87
+ const group = filteredOptions[highlightedGroupIndex];
88
+ return group?.options[highlightedIndex];
89
+ }
90
+
91
+ return filteredOptions[highlightedIndex];
92
+ }
93
+
73
94
  const handleKeyDown = (e: React.KeyboardEvent) => {
74
95
  switch (e.key) {
75
96
  case "ArrowDown":
76
97
  e.preventDefault();
77
98
  setIsOpen(true);
78
- setHighlightedIndex(prev =>
79
- prev < filteredOptions.length - 1 ? prev + 1 : 0,
80
- );
99
+ highlightNextOption();
81
100
  break;
82
101
  case "ArrowUp":
83
102
  e.preventDefault();
84
103
  setIsOpen(true);
85
- setHighlightedIndex(prev =>
86
- prev > 0 ? prev - 1 : filteredOptions.length - 1,
87
- );
104
+ highlightPreviousOption();
105
+ break;
106
+ case "Home":
107
+ e.preventDefault();
108
+ setIsOpen(true);
109
+ highlightFirstOption();
110
+ break;
111
+ case "End":
112
+ e.preventDefault();
113
+ setIsOpen(true);
114
+ highlightLastOption();
88
115
  break;
89
116
  case "Enter":
90
117
  e.preventDefault();
91
- if (isOpen && highlightedIndex >= 0) {
92
- handleOptionSelect(filteredOptions[highlightedIndex]);
93
- } else if (
94
- isOpen &&
95
- highlightedIndex === -1 &&
96
- filteredOptions.length > 0
97
- ) {
98
- handleOptionSelect(filteredOptions[0]);
118
+ if (isOpen) {
119
+ handleOptionSelect(getHighlightedOption()!);
99
120
  }
100
121
  break;
101
122
  case "Escape":
102
123
  e.preventDefault();
103
124
  setIsOpen(false);
104
- setHighlightedIndex(-1);
125
+ clearHighlight();
105
126
  break;
106
127
  default:
107
128
  // Do nothing
108
129
  }
109
130
  };
110
131
 
132
+ const classes = classNames(
133
+ "mobius mobius-combobox",
134
+ { expanded: isOpen },
135
+ className,
136
+ );
137
+
111
138
  return (
112
- <div className="mobius mobius-combobox">
139
+ <div className={classes}>
113
140
  <TextField
114
141
  className="mobius-combobox__input"
115
142
  role="combobox"
116
143
  value={inputValue}
144
+ placeholder={placeholder}
117
145
  onFocus={handleFocus}
118
146
  onBlur={handleBlur}
119
147
  onKeyDown={handleKeyDown}
120
148
  onChange={handleInputChange}
149
+ autoComplete="off"
121
150
  aria-autocomplete="list"
122
151
  aria-haspopup="listbox"
123
152
  aria-controls={listboxId}
@@ -125,29 +154,20 @@ export const Combobox: ForwardedRefComponent<
125
154
  aria-activedescendant={
126
155
  highlightedIndex === -1
127
156
  ? undefined
128
- : `${listboxId}-option-${highlightedIndex}`
157
+ : `${listboxId}-option-${highlightedGroupIndex}-${highlightedIndex}`
129
158
  }
130
- // @ts-expect-error I have no idea how ref typing works
159
+ prefixInside={icon}
131
160
  ref={inputRef}
132
161
  />
133
162
  {isOpen && (
134
- <ul role="listbox" id={listboxId} className="mobius-combobox__list">
135
- {filteredOptions.map((option, index) => (
136
- <li
137
- role="option"
138
- key={getOptionValue(option)}
139
- id={`${listboxId}-option-${index}`}
140
- aria-selected={highlightedIndex === index}
141
- onMouseDown={() => handleOptionSelect(option)}
142
- className={classNames("mobius-combobox__option", {
143
- "mobius-combobox__option--is-highlighted":
144
- highlightedIndex === index,
145
- })}
146
- >
147
- {getOptionLabel(option)}
148
- </li>
149
- ))}
150
- </ul>
163
+ <Listbox
164
+ id={listboxId}
165
+ options={filteredOptions}
166
+ highlightedIndex={highlightedIndex}
167
+ highlightedGroupIndex={highlightedGroupIndex}
168
+ onOptionSelect={handleOptionSelect}
169
+ expanded={isOpen}
170
+ />
151
171
  )}
152
172
  </div>
153
173
  );
@@ -0,0 +1,74 @@
1
+ import classNames from "classnames";
2
+ import { Option } from "./Option";
3
+ import type { ComboboxOption, ComboboxOptions } from "./types";
4
+ import { isOptionGroup } from "./utils";
5
+
6
+ export type ListboxProps = {
7
+ id: string;
8
+ options: ComboboxOptions;
9
+ highlightedIndex: number;
10
+ highlightedGroupIndex: number;
11
+ onOptionSelect: (option: ComboboxOption) => void;
12
+ expanded?: boolean;
13
+ };
14
+
15
+ export const Listbox = ({
16
+ id,
17
+ options,
18
+ highlightedIndex,
19
+ highlightedGroupIndex,
20
+ onOptionSelect,
21
+ expanded = false,
22
+ }: ListboxProps) => {
23
+ const classes = classNames("mobius-combobox__list", {
24
+ expanded,
25
+ });
26
+
27
+ return (
28
+ <div role="listbox" id={id} className={classes}>
29
+ {isOptionGroup(options)
30
+ ? options.map((option, groupIndex) => (
31
+ <ul
32
+ role="group"
33
+ key={option.heading}
34
+ aria-labelledby={`${id}-group-${groupIndex}`}
35
+ className="mobius-combobox__group"
36
+ >
37
+ <li
38
+ role="presentation"
39
+ id={`${id}-group-${groupIndex}`}
40
+ className="mobius-combobox__group-label"
41
+ >
42
+ {option.heading}
43
+ </li>
44
+ {option.options.map((groupOption, index) => (
45
+ <Option
46
+ // eslint-disable-next-line react/no-array-index-key
47
+ key={`${id}-option-${groupIndex}-${index}`}
48
+ option={groupOption}
49
+ index={index}
50
+ groupIndex={groupIndex}
51
+ isHighlighted={
52
+ highlightedIndex === index &&
53
+ highlightedGroupIndex === groupIndex
54
+ }
55
+ onOptionSelect={onOptionSelect}
56
+ id={id}
57
+ />
58
+ ))}
59
+ </ul>
60
+ ))
61
+ : options.map((option, index) => (
62
+ <Option
63
+ // eslint-disable-next-line react/no-array-index-key
64
+ key={index}
65
+ option={option}
66
+ index={index}
67
+ isHighlighted={highlightedIndex === index}
68
+ onOptionSelect={onOptionSelect}
69
+ id={id}
70
+ />
71
+ ))}
72
+ </div>
73
+ );
74
+ };
@@ -0,0 +1,41 @@
1
+ import { useEffect, useRef } from "react";
2
+ import classNames from "classnames/dedupe";
3
+ import { getOptionValue, getOptionLabel } from "./utils";
4
+ import type { ComboboxOptionProps } from "./types";
5
+
6
+ export const Option = ({
7
+ option,
8
+ index,
9
+ groupIndex = 0,
10
+ isHighlighted,
11
+ onOptionSelect,
12
+ id,
13
+ }: ComboboxOptionProps) => {
14
+ const optionRef = useRef<HTMLLIElement>(null);
15
+
16
+ useEffect(() => {
17
+ if (
18
+ isHighlighted &&
19
+ optionRef.current &&
20
+ optionRef.current.scrollIntoView
21
+ ) {
22
+ optionRef.current.scrollIntoView({ block: "nearest" });
23
+ }
24
+ }, [isHighlighted]);
25
+
26
+ return (
27
+ <li
28
+ ref={optionRef}
29
+ role="option"
30
+ key={getOptionValue(option)}
31
+ id={`${id}-option-${groupIndex}-${index}`}
32
+ aria-selected={isHighlighted}
33
+ onMouseDown={() => onOptionSelect(option)}
34
+ className={classNames("mobius-combobox__option", {
35
+ "mobius-combobox__option--is-highlighted": isHighlighted,
36
+ })}
37
+ >
38
+ {getOptionLabel(option)}
39
+ </li>
40
+ );
41
+ };
@@ -91,3 +91,114 @@ export const FRUITS_OBJECTS = [
91
91
  { label: "Tangerine", value: "tangerine" },
92
92
  { label: "Watermelon", value: "watermelon" },
93
93
  ];
94
+
95
+ export const FRUITS_GROUPS = [
96
+ {
97
+ heading: "A-L",
98
+ options: [
99
+ "Apple",
100
+ "Apricot",
101
+ "Avocado",
102
+ "Banana",
103
+ "Blackberry",
104
+ "Blueberry",
105
+ "Cantaloupe",
106
+ "Cherry",
107
+ "Clementine",
108
+ "Coconut",
109
+ "Cranberry",
110
+ "Date",
111
+ "Dragonfruit",
112
+ "Durian",
113
+ "Elderberry",
114
+ "Fig",
115
+ "Grape",
116
+ "Grapefruit",
117
+ "Guava",
118
+ "Honeydew",
119
+ "Jackfruit",
120
+ "Kiwi",
121
+ "Kumquat",
122
+ "Lemon",
123
+ "Lime",
124
+ "Lychee",
125
+ ],
126
+ },
127
+ {
128
+ heading: "M-Z",
129
+ options: [
130
+ "Mango",
131
+ "Mulberry",
132
+ "Nectarine",
133
+ "Orange",
134
+ "Papaya",
135
+ "Passionfruit",
136
+ "Peach",
137
+ "Pear",
138
+ "Persimmon",
139
+ "Pineapple",
140
+ "Plum",
141
+ "Pomegranate",
142
+ "Raspberry",
143
+ "Redcurrant",
144
+ "Starfruit",
145
+ "Strawberry",
146
+ "Tangerine",
147
+ "Watermelon",
148
+ ],
149
+ },
150
+ ];
151
+
152
+ export const US_STATES = [
153
+ "Alabama",
154
+ "Alaska",
155
+ "Arizona",
156
+ "Arkansas",
157
+ "California",
158
+ "Colorado",
159
+ "Connecticut",
160
+ "Delaware",
161
+ "District Of Columbia",
162
+ "Florida",
163
+ "Georgia",
164
+ "Hawaii",
165
+ "Idaho",
166
+ "Illinois",
167
+ "Indiana",
168
+ "Iowa",
169
+ "Kansas",
170
+ "Kentucky",
171
+ "Louisiana",
172
+ "Maine",
173
+ "Maryland",
174
+ "Massachusetts",
175
+ "Michigan",
176
+ "Minnesota",
177
+ "Mississippi",
178
+ "Missouri",
179
+ "Montana",
180
+ "Nebraska",
181
+ "Nevada",
182
+ "New Hampshire",
183
+ "New Jersey",
184
+ "New Mexico",
185
+ "New York",
186
+ "North Carolina",
187
+ "North Dakota",
188
+ "Ohio",
189
+ "Oklahoma",
190
+ "Oregon",
191
+ "Pennsylvania",
192
+ "Rhode Island",
193
+ "South Carolina",
194
+ "South Dakota",
195
+ "Tennessee",
196
+ "Texas",
197
+ "Utah",
198
+ "Vermont",
199
+ "Virginia",
200
+ "Washington",
201
+ "West Virginia",
202
+ "Wisconsin",
203
+ "Wyoming",
204
+ ];
@@ -1,12 +1,14 @@
1
- import type { Ref } from "react";
1
+ import type { ReactElement, Ref } from "react";
2
2
  import type { TextFieldElementType, TextFieldProps } from "../TextField";
3
3
 
4
4
  export type ComboboxProps = TextFieldProps & {
5
5
  /** The list of options to display in the dropdown */
6
6
  options:
7
- | ComboboxOption[]
8
- | ((filter: string) => ComboboxOption[])
9
- | ((filter: string) => Promise<ComboboxOption[]>);
7
+ | ComboboxOptions
8
+ | ((filter: string) => ComboboxOptions)
9
+ | ((filter: string) => Promise<ComboboxOptions>);
10
+ /** An icon to display in TextField (left side) */
11
+ icon?: ReactElement;
10
12
  /** The default value of the selected option */
11
13
  defaultValue?: string | undefined;
12
14
  /** Callback when the selected option changes */
@@ -15,6 +17,22 @@ export type ComboboxProps = TextFieldProps & {
15
17
 
16
18
  export type ComboboxOption = string | { label: string; value: string };
17
19
 
20
+ export type ComboboxOptionGroup = {
21
+ heading: string;
22
+ options: ComboboxOption[];
23
+ };
24
+
25
+ export type ComboboxOptions = ComboboxOption[] | ComboboxOptionGroup[];
26
+
18
27
  export type ComboboxElementType = TextFieldElementType;
19
28
 
20
29
  export type ComboboxRef = Ref<ComboboxElementType>;
30
+
31
+ export type ComboboxOptionProps = {
32
+ option: ComboboxOption;
33
+ index: number;
34
+ groupIndex?: number;
35
+ isHighlighted: boolean;
36
+ onOptionSelect: (option: ComboboxOption) => void;
37
+ id: string;
38
+ };