@leafygreen-ui/combobox 0.9.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.
@@ -0,0 +1,287 @@
1
+ import React from 'react';
2
+ import {
3
+ render,
4
+ configure,
5
+ queryByText,
6
+ queryByAttribute,
7
+ queryAllByAttribute,
8
+ } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { Combobox, ComboboxGroup, ComboboxOption } from '.';
11
+ import { BaseComboboxProps, ComboboxMultiselectProps } from './Combobox.types';
12
+ import { OptionObject } from './util';
13
+ import { isArray, isNull } from 'lodash';
14
+ import chalk from '@testing-library/jest-dom/node_modules/chalk';
15
+
16
+ export interface NestedObject {
17
+ label: string;
18
+ children: Array<string | OptionObject>;
19
+ }
20
+
21
+ export type Select = 'single' | 'multiple';
22
+ type renderComboboxProps = {
23
+ options?: Array<string | OptionObject | NestedObject>;
24
+ } & BaseComboboxProps &
25
+ ComboboxMultiselectProps<boolean>;
26
+
27
+ export const defaultOptions: Array<OptionObject> = [
28
+ {
29
+ value: 'apple',
30
+ displayName: 'Apple',
31
+ },
32
+ {
33
+ value: 'banana',
34
+ displayName: 'Banana',
35
+ },
36
+ {
37
+ value: 'carrot',
38
+ displayName: 'Carrot',
39
+ },
40
+ ];
41
+
42
+ export const groupedOptions: Array<NestedObject> = [
43
+ {
44
+ label: 'Fruit',
45
+ children: [
46
+ {
47
+ value: 'apple',
48
+ displayName: 'Apple',
49
+ },
50
+ {
51
+ value: 'banana',
52
+ displayName: 'Banana',
53
+ },
54
+ ],
55
+ },
56
+ {
57
+ label: 'Vegetables',
58
+ children: [
59
+ {
60
+ value: 'carrot',
61
+ displayName: 'Carrot',
62
+ },
63
+ {
64
+ value: 'eggplant',
65
+ displayName: 'Eggplant',
66
+ },
67
+ ],
68
+ },
69
+ ];
70
+
71
+ /**
72
+ * @param props Combobox props
73
+ * @returns Combobox JSX
74
+ */
75
+ export const getComboboxJSX = (props?: renderComboboxProps) => {
76
+ const isNested = (object: any): object is NestedObject =>
77
+ object.label && object.children;
78
+
79
+ const renderOption = (option: NestedObject | OptionObject | string) => {
80
+ if (isNested(option)) {
81
+ return (
82
+ <ComboboxGroup key={option.label} label={option.label}>
83
+ {option.children.map(renderOption)}
84
+ </ComboboxGroup>
85
+ );
86
+ } else {
87
+ const value = typeof option === 'string' ? option : option.value;
88
+ const displayName =
89
+ typeof option === 'string' ? undefined : option.displayName;
90
+
91
+ return (
92
+ <ComboboxOption key={value} value={value} displayName={displayName} />
93
+ );
94
+ }
95
+ };
96
+
97
+ const label = props?.label ?? 'Some label';
98
+ const options = props?.options ?? defaultOptions;
99
+ return (
100
+ <Combobox
101
+ data-testid="combobox-container"
102
+ label={label}
103
+ multiselect={props?.multiselect ?? false}
104
+ {...props}
105
+ >
106
+ {options.map(renderOption)}
107
+ </Combobox>
108
+ );
109
+ };
110
+
111
+ /**
112
+ * Renders a combobox
113
+ * @param select `'single' | 'multiple'`
114
+ * @param props `renderComboboxProps`
115
+ * @returns Object of combobox elements & utility functions
116
+ */
117
+ export function renderCombobox<T extends Select>(
118
+ select: T = 'single' as T,
119
+ props?: renderComboboxProps,
120
+ ) {
121
+ const multiselect = select === 'multiple';
122
+ const options = props?.options || defaultOptions;
123
+ props = { options, multiselect, ...props };
124
+
125
+ const renderResult = render(getComboboxJSX(props));
126
+ const containerEl = renderResult.getByTestId('combobox-container');
127
+ const labelEl = containerEl.getElementsByTagName('label')[0];
128
+ const comboboxEl = renderResult.getByRole('combobox');
129
+ const inputEl = containerEl.getElementsByTagName('input')[0];
130
+ const clearButtonEl = renderResult.queryByLabelText('Clear selection');
131
+
132
+ /**
133
+ * Since menu elements won't exist until component is interacted with,
134
+ * call this after opening the menu.
135
+ * @returns Object of menu elements
136
+ */
137
+ function getMenuElements() {
138
+ const menuContainerEl = renderResult.queryByRole('listbox');
139
+ const popoverEl = menuContainerEl?.firstChild;
140
+ const menuEl = menuContainerEl?.getElementsByTagName('ul')[0];
141
+ const optionElements = menuContainerEl?.getElementsByTagName('li');
142
+ const selectedElements = menuEl
143
+ ? select === 'single'
144
+ ? queryByAttribute('aria-selected', menuEl, 'true')
145
+ : queryAllByAttribute('aria-selected', menuEl, 'true')
146
+ : undefined;
147
+
148
+ return {
149
+ menuContainerEl,
150
+ popoverEl,
151
+ menuEl,
152
+ optionElements,
153
+ selectedElements: selectedElements as
154
+ | (T extends 'single' ? HTMLElement : Array<HTMLElement>)
155
+ | null,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Opens the menu by simulating a click on the combobox.
161
+ * @returns Object of menu elements
162
+ */
163
+ const openMenu = () => {
164
+ userEvent.click(comboboxEl);
165
+ return getMenuElements();
166
+ };
167
+
168
+ /**
169
+ * Rerenders the combobox with new props
170
+ * @param newProps
171
+ * @returns
172
+ */
173
+ const rerenderCombobox = (newProps: renderComboboxProps) =>
174
+ renderResult.rerender(getComboboxJSX({ ...props, ...newProps }));
175
+
176
+ /**
177
+ * @returns all chip elements
178
+ */
179
+ function queryAllChips(): Array<HTMLElement> {
180
+ return queryAllByAttribute(
181
+ 'data-leafygreen-ui',
182
+ containerEl,
183
+ 'combobox-chip',
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Get the chip(s) with the provided display name(s)
189
+ * @param names: `string` | `Array<string>`
190
+ * @returns A single HTMLElement or array of HTMLElements
191
+ */
192
+ function queryChipsByName(names: string): HTMLElement | null;
193
+ function queryChipsByName(names: Array<string>): Array<HTMLElement> | null;
194
+ function queryChipsByName(
195
+ names: string | Array<string>,
196
+ ): HTMLElement | Array<HTMLElement> | null {
197
+ if (typeof names === 'string') {
198
+ const span = queryByText(comboboxEl, names);
199
+ return span ? span.parentElement : null;
200
+ } else {
201
+ const spans = names
202
+ .map((name: any) => queryByText(comboboxEl, name))
203
+ .filter(span => !isNull(span))
204
+ .map(span => span?.parentElement);
205
+ return spans.length > 0 ? (spans as Array<HTMLElement>) : null;
206
+ }
207
+ }
208
+
209
+ function queryChipsByIndex(index: number): HTMLElement | null;
210
+ function queryChipsByIndex(index: 'first' | 'last'): HTMLElement | null;
211
+ function queryChipsByIndex(index: Array<number>): Array<HTMLElement> | null;
212
+ function queryChipsByIndex(
213
+ index: 'first' | 'last' | number | Array<number>,
214
+ ): HTMLElement | Array<HTMLElement> | null {
215
+ const allChips = queryAllChips();
216
+
217
+ if (allChips.length > 0) {
218
+ if (typeof index === 'number' && index <= allChips.length) {
219
+ return allChips[index];
220
+ } else if (typeof index === 'string') {
221
+ return index === 'first' ? allChips[0] : allChips[allChips.length - 1];
222
+ } else if (isArray(index) && index.every(i => i <= allChips.length)) {
223
+ return index.map(i => allChips[i]);
224
+ }
225
+ }
226
+
227
+ return null;
228
+ }
229
+
230
+ return {
231
+ ...renderResult,
232
+ rerenderCombobox,
233
+ queryChipsByName,
234
+ queryChipsByIndex,
235
+ queryAllChips,
236
+ getMenuElements,
237
+ openMenu,
238
+ containerEl,
239
+ labelEl,
240
+ comboboxEl,
241
+ inputEl,
242
+ clearButtonEl,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Conditionally runs a test
248
+ * @param condition
249
+ * @returns `test`
250
+ */
251
+ export const testif = (condition: boolean) => (condition ? test : test.skip);
252
+
253
+ configure({
254
+ getElementError: message => new Error(message ?? ''),
255
+ });
256
+
257
+ expect.extend({
258
+ toContainFocus(recieved: HTMLElement) {
259
+ return recieved.contains(document.activeElement)
260
+ ? {
261
+ pass: true,
262
+ message: () =>
263
+ `\t Expected element not to contain focus: \n\t\t ${chalk.red(
264
+ recieved.outerHTML,
265
+ )} \n\t Element with focus: \n\t\t ${chalk.blue(
266
+ document.activeElement?.outerHTML,
267
+ )}`,
268
+ }
269
+ : {
270
+ pass: false,
271
+ message: () =>
272
+ `\t Expected element to contain focus: \n\t\t ${chalk.green(
273
+ recieved.outerHTML,
274
+ )} \n\t Element with focus: \n\t\t ${chalk.red(
275
+ document.activeElement?.outerHTML,
276
+ )}`,
277
+ };
278
+ },
279
+ });
280
+
281
+ declare global {
282
+ namespace jest {
283
+ interface Matchers<R> {
284
+ toContainFocus(): R;
285
+ }
286
+ }
287
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as Combobox } from './Combobox';
2
+ export { default as ComboboxOption } from './ComboboxOption';
3
+ export { default as ComboboxGroup } from './ComboboxGroup';
package/src/util.tsx ADDED
@@ -0,0 +1,117 @@
1
+ import { isComponentType, keyMap as _keyMap } from '@leafygreen-ui/lib';
2
+ import { kebabCase } from 'lodash';
3
+ import React from 'react';
4
+ import { ComboboxOptionProps } from './Combobox.types';
5
+
6
+ // TODO - remove this when lib/keyMap supports Backspace & Delete
7
+ export const keyMap = {
8
+ ..._keyMap,
9
+ Backspace: 8,
10
+ Delete: 46,
11
+ } as const;
12
+
13
+ /**
14
+ *
15
+ * Wraps the first instance of `wrap` found in `str` within the provided `element`.
16
+ *
17
+ * E.g. `wrapJSX('Apple', 'ap', 'em') => <><em>Ap</em>ple</>`
18
+ *
19
+ * @param str
20
+ * @param wrap
21
+ * @param element
22
+ * @returns `JSX.Element`
23
+ */
24
+ export const wrapJSX = (
25
+ str: string,
26
+ wrap?: string,
27
+ element?: string,
28
+ ): JSX.Element => {
29
+ if (wrap && element) {
30
+ const regex = new RegExp(wrap, 'gi');
31
+ const startIndex = str.search(regex);
32
+ const endIndex = startIndex + wrap.length;
33
+ const nameArr = str.split('');
34
+ const start = nameArr.slice(0, startIndex).join('');
35
+ const end = nameArr.slice(endIndex).join('');
36
+ const match = nameArr.slice(startIndex, endIndex).join('');
37
+ const matchEl = React.createElement(element, null, match);
38
+ return (
39
+ <>
40
+ {start}
41
+ {matchEl}
42
+ {end}
43
+ </>
44
+ );
45
+ }
46
+
47
+ return <>{str}</>;
48
+ };
49
+
50
+ /**
51
+ *
52
+ * Returns an object with properties `value` & `displayName`
53
+ * based on the props provided
54
+ *
55
+ * @property value: string
56
+ * @property displayName: string
57
+ */
58
+ export const getNameAndValue = ({
59
+ value: valProp,
60
+ displayName: nameProp,
61
+ }: ComboboxOptionProps): {
62
+ value: string;
63
+ displayName: string;
64
+ } => {
65
+ return {
66
+ value: valProp ?? kebabCase(nameProp),
67
+ displayName: nameProp ?? valProp ?? '', // TODO consider adding a prop to customize displayName => startCase(valProp),
68
+ };
69
+ };
70
+
71
+ export interface OptionObject {
72
+ value: string;
73
+ displayName: string;
74
+ hasGlyph?: boolean;
75
+ }
76
+
77
+ /**
78
+ *
79
+ * Flattens multiple nested ComboboxOptions into a 1D array
80
+ *
81
+ * @param _children
82
+ * @returns `Array<OptionObject>`
83
+ */
84
+ export const flattenChildren = (
85
+ _children: React.ReactNode,
86
+ ): Array<OptionObject> => {
87
+ // TS doesn't like .reduce
88
+ // @ts-expect-error
89
+ return React.Children.toArray(_children).reduce(
90
+ // @ts-expect-error
91
+ (
92
+ acc: Array<OptionObject>,
93
+ child: React.ReactNode,
94
+ ): Array<OptionObject> | undefined => {
95
+ if (isComponentType(child, 'ComboboxOption')) {
96
+ const { value, displayName } = getNameAndValue(child.props);
97
+ const { glyph } = child.props;
98
+
99
+ return [
100
+ ...acc,
101
+ {
102
+ value,
103
+ displayName,
104
+ hasGlyph: !!glyph,
105
+ },
106
+ ];
107
+ } else if (isComponentType(child, 'ComboboxGroup')) {
108
+ const { children } = child.props;
109
+
110
+ if (children) {
111
+ return [...acc, ...flattenChildren(children)];
112
+ }
113
+ }
114
+ },
115
+ [] as Array<OptionObject>,
116
+ );
117
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "extends": "../../package.tsconfig.json",
3
+ "compilerOptions": {
4
+ "declarationDir": "dist",
5
+ "outDir": "dist",
6
+ "rootDir": "src"
7
+ },
8
+ "include": ["src/**/*"],
9
+ "references": [
10
+ {
11
+ "path": "../checkbox"
12
+ },
13
+ {
14
+ "path": "../emotion"
15
+ },
16
+ {
17
+ "path": "../icon"
18
+ },
19
+ {
20
+ "path": "../icon-button"
21
+ },
22
+ {
23
+ "path": "../interaction-ring"
24
+ },
25
+ {
26
+ "path": "../inline-definition"
27
+ },
28
+ {
29
+ "path": "../hooks"
30
+ },
31
+ {
32
+ "path": "../lib"
33
+ },
34
+ {
35
+ "path": "../palette"
36
+ },
37
+ {
38
+ "path": "../popover"
39
+ },
40
+ {
41
+ "path": "../tooltip"
42
+ },
43
+ {
44
+ "path": "../typography"
45
+ }
46
+ ]
47
+ }