@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.
- package/README.md +126 -0
- package/dist/Chip.d.ts +4 -0
- package/dist/Chip.d.ts.map +1 -0
- package/dist/Combobox.d.ts +7 -0
- package/dist/Combobox.d.ts.map +1 -0
- package/dist/Combobox.styles.d.ts +44 -0
- package/dist/Combobox.styles.d.ts.map +1 -0
- package/dist/Combobox.types.d.ts +230 -0
- package/dist/Combobox.types.d.ts.map +1 -0
- package/dist/ComboboxContext.d.ts +15 -0
- package/dist/ComboboxContext.d.ts.map +1 -0
- package/dist/ComboboxGroup.d.ts +9 -0
- package/dist/ComboboxGroup.d.ts.map +1 -0
- package/dist/ComboboxOption.d.ts +13 -0
- package/dist/ComboboxOption.d.ts.map +1 -0
- package/dist/ComboboxTestUtils.d.ts +126 -0
- package/dist/ComboboxTestUtils.d.ts.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/util.d.ts +53 -0
- package/dist/util.d.ts.map +1 -0
- package/package.json +32 -0
- package/src/Chip.tsx +223 -0
- package/src/Combobox.spec.tsx +1136 -0
- package/src/Combobox.story.tsx +248 -0
- package/src/Combobox.styles.ts +354 -0
- package/src/Combobox.tsx +1180 -0
- package/src/Combobox.types.ts +293 -0
- package/src/ComboboxContext.tsx +21 -0
- package/src/ComboboxGroup.tsx +61 -0
- package/src/ComboboxOption.tsx +200 -0
- package/src/ComboboxTestUtils.tsx +287 -0
- package/src/index.ts +3 -0
- package/src/util.tsx +117 -0
- package/tsconfig.json +47 -0
- package/tsconfig.tsbuildinfo +3889 -0
|
@@ -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
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
|
+
}
|