@bitrise/bitkit 10.9.1 → 10.9.2
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 +1 -1
- package/src/Components/Dropdown/Dropdown.context.tsx +14 -9
- package/src/Components/Dropdown/Dropdown.stories.tsx +21 -0
- package/src/Components/Dropdown/Dropdown.test.tsx +121 -29
- package/src/Components/Dropdown/Dropdown.theme.ts +2 -1
- package/src/Components/Dropdown/Dropdown.tsx +92 -53
- package/src/Components/Dropdown/DropdownButton.tsx +3 -1
- package/src/Components/Dropdown/DropdownOption.tsx +18 -12
- package/src/Components/Dropdown/hooks/useFloatingDropdown.ts +11 -4
- package/src/Components/Dropdown/hooks/useSimpleSearch.tsx +2 -11
- package/src/Components/Input/Input.theme.ts +1 -0
- package/src/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -4,11 +4,12 @@ import { createContext } from '@chakra-ui/react-utils';
|
|
|
4
4
|
|
|
5
5
|
const [DropdownStylesProvider, useDropdownStyles] = createStylesContext('Dropdown');
|
|
6
6
|
|
|
7
|
-
export type DropdownEventArgs = { value:
|
|
7
|
+
export type DropdownEventArgs<T> = { value: T; index: number | undefined; label: ReactNode };
|
|
8
8
|
|
|
9
|
-
type DropdownContext = {
|
|
10
|
-
formValue:
|
|
11
|
-
onOptionSelected: (arg: DropdownEventArgs) => void;
|
|
9
|
+
type DropdownContext<T> = {
|
|
10
|
+
formValue: T;
|
|
11
|
+
onOptionSelected: (arg: DropdownEventArgs<T>) => void;
|
|
12
|
+
listRef: React.RefObject<(HTMLElement | null)[]>;
|
|
12
13
|
searchValue: string;
|
|
13
14
|
searchRef: React.Ref<HTMLInputElement>;
|
|
14
15
|
searchOnChange: (sv: string) => void;
|
|
@@ -18,20 +19,24 @@ type DropdownContext = {
|
|
|
18
19
|
getItemProps: (x: object) => object;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
const [DropdownContextProvider, useDropdownContext] = createContext<DropdownContext
|
|
22
|
+
const [DropdownContextProvider, useDropdownContext] = createContext<DropdownContext<unknown>>();
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
function useDropdownContextTyped<T>(): DropdownContext<T> {
|
|
25
|
+
return useDropdownContext() as unknown as DropdownContext<T>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DropdownProvider = <T,>({
|
|
24
29
|
styles,
|
|
25
30
|
context,
|
|
26
31
|
children,
|
|
27
32
|
}: {
|
|
28
33
|
styles: ComponentProps<typeof DropdownStylesProvider>['value'];
|
|
29
34
|
children: ReactNode;
|
|
30
|
-
context: DropdownContext
|
|
35
|
+
context: DropdownContext<T>;
|
|
31
36
|
}) => (
|
|
32
|
-
<DropdownContextProvider value={context}>
|
|
37
|
+
<DropdownContextProvider value={context as DropdownContext<unknown>}>
|
|
33
38
|
<DropdownStylesProvider value={styles}>{children}</DropdownStylesProvider>
|
|
34
39
|
</DropdownContextProvider>
|
|
35
40
|
);
|
|
36
41
|
|
|
37
|
-
export { DropdownProvider, useDropdownContext, useDropdownStyles };
|
|
42
|
+
export { DropdownProvider, useDropdownContextTyped as useDropdownContext, useDropdownStyles };
|
|
@@ -56,6 +56,27 @@ export const WithDetail = () => {
|
|
|
56
56
|
);
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
export const WithLongOptions = () => {
|
|
60
|
+
return (
|
|
61
|
+
<Dropdown defaultValue="y" w="150px">
|
|
62
|
+
<DropdownOption value="y">
|
|
63
|
+
some very long option text longer than the component with verylongwordwithoutbreakopportunities
|
|
64
|
+
</DropdownOption>
|
|
65
|
+
<DropdownOption>some very long option text longer than the component</DropdownOption>
|
|
66
|
+
</Dropdown>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const WithoutSearch = () => {
|
|
71
|
+
return (
|
|
72
|
+
<Dropdown search={false}>
|
|
73
|
+
<DropdownOption>opt1</DropdownOption>
|
|
74
|
+
<DropdownOption>opt2</DropdownOption>
|
|
75
|
+
<DropdownOption>opt3</DropdownOption>
|
|
76
|
+
</Dropdown>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
59
80
|
export const Default: ComponentStoryFn<typeof Dropdown> = (args) => {
|
|
60
81
|
return (
|
|
61
82
|
<form
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, MutableRefObject, StrictMode, useState } from 'react';
|
|
1
|
+
import { ComponentProps, forwardRef, MutableRefObject, StrictMode, useState } from 'react';
|
|
2
2
|
import { setTimeout } from 'timers/promises';
|
|
3
3
|
import { render as renderRTL, screen, waitFor } from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
@@ -12,12 +12,15 @@ const render = (ui: React.ReactElement) => {
|
|
|
12
12
|
return renderRTL(<StrictMode>{ui}</StrictMode>);
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
const TestComponent = forwardRef<
|
|
15
|
+
const TestComponent = forwardRef<
|
|
16
|
+
HTMLFormElement,
|
|
17
|
+
{ submit: (data: unknown) => void } & ComponentProps<typeof Dropdown>
|
|
18
|
+
>(({ submit, ...props }, ref) => {
|
|
16
19
|
const { register, handleSubmit } = useForm({ defaultValues: { dropdown: 'unset' } });
|
|
17
20
|
|
|
18
21
|
return (
|
|
19
|
-
<form
|
|
20
|
-
<Dropdown aria-label="Test" {...register('dropdown')}>
|
|
22
|
+
<form onSubmit={handleSubmit(submit)} ref={ref}>
|
|
23
|
+
<Dropdown {...props} aria-label="Test" {...register('dropdown')}>
|
|
21
24
|
<DropdownOption value="x">Test Opt1</DropdownOption>
|
|
22
25
|
<DropdownOption value="y">Test Opt2</DropdownOption>
|
|
23
26
|
<DropdownOption value="z">Test Opt3</DropdownOption>
|
|
@@ -37,6 +40,39 @@ describe('Dropdown', () => {
|
|
|
37
40
|
expect(button).toHaveTextContent('Test Opt1');
|
|
38
41
|
});
|
|
39
42
|
|
|
43
|
+
describe('supports null value', () => {
|
|
44
|
+
it('can be selected', async () => {
|
|
45
|
+
const handler = jest.fn();
|
|
46
|
+
const Test = () => {
|
|
47
|
+
return (
|
|
48
|
+
<Dropdown onChange={(ev) => handler(ev.target.value)}>
|
|
49
|
+
<DropdownOption value={null}>null</DropdownOption>
|
|
50
|
+
</Dropdown>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
render(<Test />);
|
|
54
|
+
const button = await screen.findByRole('combobox');
|
|
55
|
+
await userEvent.click(button);
|
|
56
|
+
const opt = await screen.findByRole('listbox');
|
|
57
|
+
await userEvent.selectOptions(opt, 'null');
|
|
58
|
+
expect(handler).toHaveBeenCalledWith(null);
|
|
59
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
it('works as defaultValue', async () => {
|
|
62
|
+
const handler = jest.fn();
|
|
63
|
+
const Test = () => {
|
|
64
|
+
return (
|
|
65
|
+
<Dropdown defaultValue={null} onChange={(ev) => handler(ev.target.value)}>
|
|
66
|
+
<DropdownOption value={null}>null</DropdownOption>
|
|
67
|
+
</Dropdown>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
render(<Test />);
|
|
71
|
+
const button = await screen.findByRole('combobox');
|
|
72
|
+
expect(button).toHaveTextContent('null');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
40
76
|
it('search field can be disabled', async () => {
|
|
41
77
|
render(
|
|
42
78
|
<Dropdown search={false}>
|
|
@@ -77,8 +113,8 @@ describe('Dropdown', () => {
|
|
|
77
113
|
});
|
|
78
114
|
|
|
79
115
|
it('works in controlled mode (from the inside)', async () => {
|
|
80
|
-
const Test = ({ valueRef }: { valueRef: MutableRefObject<string> }) => {
|
|
81
|
-
const [value, setValue] = useState('x');
|
|
116
|
+
const Test = ({ valueRef }: { valueRef: MutableRefObject<string | null> }) => {
|
|
117
|
+
const [value, setValue] = useState<string | null>('x');
|
|
82
118
|
valueRef.current = value;
|
|
83
119
|
return (
|
|
84
120
|
<Dropdown value={value} onChange={(ev) => setValue(ev.target.value)}>
|
|
@@ -215,33 +251,73 @@ describe('Dropdown', () => {
|
|
|
215
251
|
expect(option1).toBeInTheDocument();
|
|
216
252
|
expect(option2).not.toBeInTheDocument();
|
|
217
253
|
});
|
|
218
|
-
});
|
|
219
254
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
255
|
+
it('works with keyboard navigation', async () => {
|
|
256
|
+
const handler = jest.fn();
|
|
257
|
+
const Test = () => {
|
|
258
|
+
return (
|
|
259
|
+
<Dropdown onChange={(ev) => handler(ev.target.value)}>
|
|
260
|
+
<DropdownDetailedOption value="y" icon={<Avatar name="hello" />} subtitle="ok" title="testx" />
|
|
261
|
+
<DropdownDetailedOption value="x" icon={<Avatar name="hello" />} subtitle="ok" title="test" />
|
|
262
|
+
<DropdownDetailedOption value="z" icon={<Avatar name="hello" />} subtitle="ok" title="test" />
|
|
263
|
+
</Dropdown>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
render(<Test />);
|
|
223
267
|
const button = await screen.findByRole('combobox');
|
|
224
|
-
|
|
225
268
|
await userEvent.click(button);
|
|
226
269
|
const search = await screen.findByRole('search');
|
|
227
|
-
await
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
expect(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
270
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
271
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
272
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
273
|
+
await userEvent.keyboard('{Enter}');
|
|
274
|
+
expect(handler).toHaveBeenCalledWith('x');
|
|
275
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('search', () => {
|
|
280
|
+
describe('custom search', () => {
|
|
281
|
+
it('works', async () => {
|
|
282
|
+
render(<CustomSearch />);
|
|
283
|
+
const button = await screen.findByRole('combobox');
|
|
284
|
+
|
|
285
|
+
await userEvent.click(button);
|
|
286
|
+
const search = await screen.findByRole('search');
|
|
287
|
+
await userEvent.type(search, 'text1');
|
|
288
|
+
let option1 = screen.queryByRole('option', { name: 'text1' });
|
|
289
|
+
let option2 = screen.queryByRole('option', { name: 'text2' });
|
|
290
|
+
let option3 = screen.queryByRole('option', { name: 'text3' });
|
|
291
|
+
let option4 = screen.queryByRole('option', { name: 'text4' });
|
|
292
|
+
expect(option1).toBeInTheDocument();
|
|
293
|
+
expect(option2).not.toBeInTheDocument();
|
|
294
|
+
expect(option3).not.toBeInTheDocument();
|
|
295
|
+
expect(option4).not.toBeInTheDocument();
|
|
296
|
+
await userEvent.clear(search);
|
|
297
|
+
option1 = screen.queryByRole('option', { name: 'text1' });
|
|
298
|
+
option2 = screen.queryByRole('option', { name: 'text2' });
|
|
299
|
+
option3 = screen.queryByRole('option', { name: 'text3' });
|
|
300
|
+
option4 = screen.queryByRole('option', { name: 'text4' });
|
|
301
|
+
expect(option1).toBeInTheDocument();
|
|
302
|
+
expect(option2).toBeInTheDocument();
|
|
303
|
+
expect(option3).toBeInTheDocument();
|
|
304
|
+
expect(option4).toBeInTheDocument();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('resets after closing', async () => {
|
|
308
|
+
render(<CustomSearch />);
|
|
309
|
+
const button = await screen.findByRole('combobox');
|
|
310
|
+
|
|
311
|
+
await userEvent.click(button);
|
|
312
|
+
const search = await screen.findByRole('search');
|
|
313
|
+
await userEvent.type(search, 'text1');
|
|
314
|
+
await userEvent.click(button);
|
|
315
|
+
await userEvent.click(button);
|
|
316
|
+
const option1 = screen.queryByRole('option', { name: 'text1' });
|
|
317
|
+
const option2 = screen.queryByRole('option', { name: 'text2' });
|
|
318
|
+
expect(option1).toBeInTheDocument();
|
|
319
|
+
expect(option2).toBeInTheDocument();
|
|
320
|
+
});
|
|
245
321
|
});
|
|
246
322
|
|
|
247
323
|
it('removes group when items are filtered', async () => {
|
|
@@ -396,6 +472,22 @@ describe('Dropdown', () => {
|
|
|
396
472
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
397
473
|
});
|
|
398
474
|
|
|
475
|
+
it('works without search', async () => {
|
|
476
|
+
const handler = jest.fn();
|
|
477
|
+
render(<TestComponent search={false} submit={(data: unknown) => handler(data)} />);
|
|
478
|
+
const button = await screen.findByRole('combobox', { name: 'Test' });
|
|
479
|
+
const submit = await screen.findByRole('button', { name: 'Send' });
|
|
480
|
+
|
|
481
|
+
await userEvent.click(button);
|
|
482
|
+
await screen.findByRole('listbox');
|
|
483
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
484
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
485
|
+
await userEvent.keyboard('{Enter}');
|
|
486
|
+
await userEvent.click(submit);
|
|
487
|
+
expect(handler).toHaveBeenCalledWith({ dropdown: 'y' });
|
|
488
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
489
|
+
});
|
|
490
|
+
|
|
399
491
|
it('works with groups', async () => {
|
|
400
492
|
const handler = jest.fn();
|
|
401
493
|
const Test = () => {
|
|
@@ -43,6 +43,7 @@ const DropdownTheme = {
|
|
|
43
43
|
item: {
|
|
44
44
|
cursor: 'pointer',
|
|
45
45
|
userSelect: 'none',
|
|
46
|
+
wordBreak: 'break-word',
|
|
46
47
|
color: 'purple.10',
|
|
47
48
|
paddingY: '8',
|
|
48
49
|
paddingX: '16',
|
|
@@ -73,7 +74,7 @@ const DropdownTheme = {
|
|
|
73
74
|
zIndex: '1',
|
|
74
75
|
display: 'flex',
|
|
75
76
|
minH: 0,
|
|
76
|
-
maxH: 'min(
|
|
77
|
+
maxH: 'max(min(var(--dropdown-floating-max, 16rem), var(--floating-available-height)), var(--dropdown-floating-min, 10rem))',
|
|
77
78
|
flexShrink: 1,
|
|
78
79
|
flexDir: 'column',
|
|
79
80
|
},
|
|
@@ -21,22 +21,25 @@ import {
|
|
|
21
21
|
import { FloatingFocusManager } from '@floating-ui/react-dom-interactions';
|
|
22
22
|
import Icon from '../Icon/Icon';
|
|
23
23
|
import { DropdownEventArgs, DropdownProvider, useDropdownContext, useDropdownStyles } from './Dropdown.context';
|
|
24
|
-
import { DropdownOption, DropdownGroup, DropdownDetailedOption } from './DropdownOption';
|
|
24
|
+
import { DropdownOption, DropdownGroup, DropdownDetailedOption, DropdownOptionProps } from './DropdownOption';
|
|
25
25
|
import DropdownButton from './DropdownButton';
|
|
26
26
|
import useFloatingDropdown from './hooks/useFloatingDropdown';
|
|
27
27
|
import { useSimpleSearch, NoResultsFound } from './hooks/useSimpleSearch';
|
|
28
28
|
import { isSearchable } from './isNodeMatch';
|
|
29
29
|
|
|
30
|
-
type
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
type DropdownSearchCustomProps = {
|
|
31
|
+
value: string;
|
|
32
|
+
onChange: (newValue: string) => void;
|
|
33
|
+
};
|
|
34
|
+
type DropdownSearchProps = { placeholder?: string; reset?: boolean };
|
|
35
|
+
const DropdownSearch = ({
|
|
36
|
+
placeholder = 'Start typing to filter options',
|
|
37
|
+
reset = true,
|
|
38
|
+
...rest
|
|
39
|
+
}: DropdownSearchProps | (DropdownSearchProps & DropdownSearchCustomProps)) => {
|
|
38
40
|
const { search } = useDropdownStyles();
|
|
39
41
|
const { searchValue, searchOnSubmit, searchOnChange, searchRef } = useDropdownContext();
|
|
42
|
+
|
|
40
43
|
const { value, onChange } = 'onChange' in rest ? rest : { value: searchValue, onChange: searchOnChange };
|
|
41
44
|
|
|
42
45
|
const onChangeCB = useCallback(
|
|
@@ -45,6 +48,13 @@ const DropdownSearch = ({ placeholder = 'Start typing to filter options', ...res
|
|
|
45
48
|
},
|
|
46
49
|
[onChange],
|
|
47
50
|
);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
return () => {
|
|
53
|
+
if (reset) {
|
|
54
|
+
onChange('');
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}, [reset]);
|
|
48
58
|
const onKeyDown = useCallback(
|
|
49
59
|
(ev: React.KeyboardEvent) => {
|
|
50
60
|
if (ev.key === 'Enter') {
|
|
@@ -74,15 +84,17 @@ const DropdownSearch = ({ placeholder = 'Start typing to filter options', ...res
|
|
|
74
84
|
};
|
|
75
85
|
export { DropdownOption, DropdownGroup, DropdownSearch, NoResultsFound, DropdownDetailedOption };
|
|
76
86
|
|
|
77
|
-
type DropdownInstance = { value:
|
|
78
|
-
type DropdownChangeEventHandler = (ev: { target: DropdownInstance }) => void;
|
|
79
|
-
export interface DropdownProps extends ChakraProps {
|
|
87
|
+
type DropdownInstance<T> = { value: T; name?: string };
|
|
88
|
+
type DropdownChangeEventHandler<T> = (ev: { target: DropdownInstance<T> }) => void;
|
|
89
|
+
export interface DropdownProps<T> extends ChakraProps {
|
|
80
90
|
name?: string;
|
|
81
|
-
onChange?: DropdownChangeEventHandler
|
|
82
|
-
onBlur?: DropdownChangeEventHandler
|
|
83
|
-
value?:
|
|
84
|
-
defaultValue?:
|
|
91
|
+
onChange?: DropdownChangeEventHandler<T>;
|
|
92
|
+
onBlur?: DropdownChangeEventHandler<T>;
|
|
93
|
+
value?: T;
|
|
94
|
+
defaultValue?: T;
|
|
85
95
|
size?: 'small' | 'medium';
|
|
96
|
+
dropdownMaxHeight?: ChakraProps['maxH'];
|
|
97
|
+
dropdownMinHeight?: ChakraProps['minH'];
|
|
86
98
|
readOnly?: boolean;
|
|
87
99
|
disabled?: boolean;
|
|
88
100
|
placeholder?: string;
|
|
@@ -91,31 +103,18 @@ export interface DropdownProps extends ChakraProps {
|
|
|
91
103
|
children?: ReactNode;
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
function
|
|
95
|
-
children,
|
|
96
|
-
listRef,
|
|
97
|
-
getItemProps,
|
|
98
|
-
}: {
|
|
99
|
-
children: ReactNode;
|
|
100
|
-
listRef: React.MutableRefObject<(HTMLElement | null)[]>;
|
|
101
|
-
getItemProps: (userProps?: React.HTMLProps<HTMLElement> | undefined) => Record<string, unknown>;
|
|
102
|
-
}) {
|
|
106
|
+
function useOptionListWithIndexes({ children }: { children: ReactNode }) {
|
|
103
107
|
return useMemo(() => {
|
|
104
108
|
const childList = React.Children.toArray(children);
|
|
105
109
|
let idx = 0;
|
|
106
110
|
const transform = (ch: ReactNode): ReactNode => {
|
|
107
111
|
if (React.isValidElement(ch)) {
|
|
108
|
-
if (ch.type === DropdownOption) {
|
|
112
|
+
if (ch.type === DropdownOption || ch.type === DropdownDetailedOption) {
|
|
109
113
|
const index = idx;
|
|
110
114
|
idx += 1;
|
|
111
115
|
return cloneElement(ch, {
|
|
112
|
-
...
|
|
113
|
-
|
|
114
|
-
index,
|
|
115
|
-
ref(node) {
|
|
116
|
-
listRef.current[index] = node;
|
|
117
|
-
},
|
|
118
|
-
}),
|
|
116
|
+
...ch.props,
|
|
117
|
+
index,
|
|
119
118
|
});
|
|
120
119
|
}
|
|
121
120
|
if ('children' in ch.props) {
|
|
@@ -131,17 +130,18 @@ function useOptionListWithRefs({
|
|
|
131
130
|
}, [children]);
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
type UseDropdownProps = {
|
|
135
|
-
ref: React.Ref<DropdownInstance
|
|
133
|
+
type UseDropdownProps<T> = {
|
|
134
|
+
ref: React.Ref<DropdownInstance<T>>;
|
|
136
135
|
optionsRef: React.RefObject<HTMLDivElement>;
|
|
137
136
|
};
|
|
138
137
|
|
|
139
|
-
function findOption(children: ReactNode, value:
|
|
138
|
+
function findOption<T>(children: ReactNode, value: T): { label: ReactNode; index: number } | null {
|
|
140
139
|
const list = React.Children.toArray(children);
|
|
141
140
|
for (let i = 0; i < list.length; i++) {
|
|
142
141
|
const elem = list[i];
|
|
143
142
|
if (React.isValidElement(elem)) {
|
|
144
|
-
|
|
143
|
+
const optValue = typeof elem.props.value === 'undefined' ? null : elem.props.value;
|
|
144
|
+
if (elem.type === DropdownOption && optValue === value) {
|
|
145
145
|
return { label: elem.props.children, index: elem.props.index };
|
|
146
146
|
}
|
|
147
147
|
const ch =
|
|
@@ -154,7 +154,7 @@ function findOption(children: ReactNode, value: string): { label: ReactNode; ind
|
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
function useDropdown({
|
|
157
|
+
function useDropdown<T>({
|
|
158
158
|
name,
|
|
159
159
|
onChange,
|
|
160
160
|
value,
|
|
@@ -164,12 +164,12 @@ function useDropdown({
|
|
|
164
164
|
children,
|
|
165
165
|
readOnly,
|
|
166
166
|
...rest
|
|
167
|
-
}: DropdownProps & UseDropdownProps) {
|
|
167
|
+
}: DropdownProps<T> & UseDropdownProps<T>) {
|
|
168
168
|
const searchRef = useRef(null);
|
|
169
169
|
const {
|
|
170
|
-
|
|
170
|
+
close,
|
|
171
171
|
isOpen,
|
|
172
|
-
|
|
172
|
+
getReferenceProps,
|
|
173
173
|
floatingProps,
|
|
174
174
|
context: floatingContext,
|
|
175
175
|
activeIndex,
|
|
@@ -178,7 +178,7 @@ function useDropdown({
|
|
|
178
178
|
getItemProps,
|
|
179
179
|
listRef,
|
|
180
180
|
} = useFloatingDropdown({ enabled: !readOnly, optionsRef });
|
|
181
|
-
const [formValue, setFormValue] = useControllableState({
|
|
181
|
+
const [formValue, setFormValue] = useControllableState<T>({
|
|
182
182
|
onChange: (newValue) => onChange?.({ target: { value: newValue, name } }),
|
|
183
183
|
defaultValue,
|
|
184
184
|
value,
|
|
@@ -186,25 +186,33 @@ function useDropdown({
|
|
|
186
186
|
|
|
187
187
|
const [formLabel, setFormLabel] = useState<ReactNode>();
|
|
188
188
|
useImperativeHandle(ref, () => ({ value: formValue, name }), [formValue, name]);
|
|
189
|
-
const refdChildren =
|
|
189
|
+
const refdChildren = useOptionListWithIndexes({ children });
|
|
190
190
|
|
|
191
191
|
const searchOnSubmit = useCallback(() => {
|
|
192
192
|
if (activeIndex !== null) {
|
|
193
193
|
listRef.current[activeIndex]?.click();
|
|
194
194
|
}
|
|
195
195
|
}, [activeIndex]);
|
|
196
|
+
const referenceKeyDown = useCallback(
|
|
197
|
+
(ev: React.KeyboardEvent) => {
|
|
198
|
+
if (ev.key === 'Enter') {
|
|
199
|
+
ev.preventDefault();
|
|
200
|
+
searchOnSubmit();
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[searchOnSubmit],
|
|
204
|
+
);
|
|
196
205
|
const { searchValue, searchOnChange, ...searchResults } = useSimpleSearch({
|
|
197
206
|
children: refdChildren,
|
|
198
207
|
onSearch: () => setActiveIndex(null),
|
|
199
|
-
isOpen,
|
|
200
208
|
});
|
|
201
209
|
|
|
202
210
|
const onOptionSelected = useCallback(
|
|
203
|
-
(args: DropdownEventArgs) => {
|
|
211
|
+
(args: DropdownEventArgs<T>) => {
|
|
204
212
|
setFormValue(args.value);
|
|
205
|
-
|
|
213
|
+
close();
|
|
206
214
|
},
|
|
207
|
-
[
|
|
215
|
+
[close, setFormValue],
|
|
208
216
|
);
|
|
209
217
|
const context = useMemo(
|
|
210
218
|
() => ({
|
|
@@ -216,8 +224,19 @@ function useDropdown({
|
|
|
216
224
|
searchValue,
|
|
217
225
|
searchRef,
|
|
218
226
|
searchOnSubmit,
|
|
227
|
+
listRef,
|
|
219
228
|
}),
|
|
220
|
-
[
|
|
229
|
+
[
|
|
230
|
+
listRef,
|
|
231
|
+
formValue,
|
|
232
|
+
onOptionSelected,
|
|
233
|
+
activeIndex,
|
|
234
|
+
getItemProps,
|
|
235
|
+
searchOnChange,
|
|
236
|
+
searchValue,
|
|
237
|
+
searchRef,
|
|
238
|
+
searchOnSubmit,
|
|
239
|
+
],
|
|
221
240
|
);
|
|
222
241
|
|
|
223
242
|
useEffect(() => {
|
|
@@ -229,7 +248,9 @@ function useDropdown({
|
|
|
229
248
|
}, [refdChildren, formValue]);
|
|
230
249
|
return {
|
|
231
250
|
isOpen,
|
|
232
|
-
referenceProps
|
|
251
|
+
referenceProps: getReferenceProps({
|
|
252
|
+
onKeyDown: referenceKeyDown,
|
|
253
|
+
}),
|
|
233
254
|
floatingProps,
|
|
234
255
|
floatingContext,
|
|
235
256
|
searchOnSubmit,
|
|
@@ -242,8 +263,11 @@ function useDropdown({
|
|
|
242
263
|
};
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
const Dropdown = forwardRef<DropdownInstance
|
|
246
|
-
(
|
|
266
|
+
const Dropdown = forwardRef<DropdownInstance<string | null>, DropdownProps<string | null>>(
|
|
267
|
+
(
|
|
268
|
+
{ dropdownMaxHeight, dropdownMinHeight, readOnly, onBlur, placeholder, name, search, size = 'medium', ...props },
|
|
269
|
+
ref,
|
|
270
|
+
) => {
|
|
247
271
|
const optionsRef = useRef(null);
|
|
248
272
|
const {
|
|
249
273
|
context: dropdownCtx,
|
|
@@ -273,6 +297,14 @@ const Dropdown = forwardRef<DropdownInstance, DropdownProps>(
|
|
|
273
297
|
onBlur({ target: { value: formValue, name } });
|
|
274
298
|
}
|
|
275
299
|
};
|
|
300
|
+
const listStyles = useMemo(
|
|
301
|
+
() => ({
|
|
302
|
+
...dropdownStyles.list,
|
|
303
|
+
'--dropdown-floating-max': dropdownMaxHeight,
|
|
304
|
+
'--dropdown-floating-min': dropdownMinHeight,
|
|
305
|
+
}),
|
|
306
|
+
[dropdownMinHeight, dropdownMaxHeight, dropdownStyles.list],
|
|
307
|
+
);
|
|
276
308
|
const searchElement = search === false ? null : search || <DropdownSearch />;
|
|
277
309
|
return (
|
|
278
310
|
<>
|
|
@@ -280,7 +312,7 @@ const Dropdown = forwardRef<DropdownInstance, DropdownProps>(
|
|
|
280
312
|
<DropdownProvider context={dropdownCtx} styles={dropdownStyles}>
|
|
281
313
|
<DropdownButton
|
|
282
314
|
{...referenceProps}
|
|
283
|
-
|
|
315
|
+
{...props}
|
|
284
316
|
placeholder={placeholder}
|
|
285
317
|
size={size}
|
|
286
318
|
formLabel={formLabel}
|
|
@@ -289,7 +321,7 @@ const Dropdown = forwardRef<DropdownInstance, DropdownProps>(
|
|
|
289
321
|
/>
|
|
290
322
|
{isOpen && (
|
|
291
323
|
<FloatingFocusManager initialFocus={searchRef} context={floatingContext}>
|
|
292
|
-
<chakra.div {...floatingProps} sx={
|
|
324
|
+
<chakra.div {...floatingProps} sx={listStyles}>
|
|
293
325
|
{searchElement}
|
|
294
326
|
<chakra.div tabIndex={-1} ref={optionsRef} __css={dropdownStyles.options}>
|
|
295
327
|
{children}
|
|
@@ -303,4 +335,11 @@ const Dropdown = forwardRef<DropdownInstance, DropdownProps>(
|
|
|
303
335
|
},
|
|
304
336
|
);
|
|
305
337
|
|
|
338
|
+
export function typedDropdown<T>() {
|
|
339
|
+
return {
|
|
340
|
+
Dropdown: Dropdown as React.ForwardRefExoticComponent<DropdownProps<T> & React.RefAttributes<DropdownInstance<T>>>,
|
|
341
|
+
DropdownOption: DropdownOption as (p: DropdownOptionProps<T>) => JSX.Element,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
306
345
|
export default Dropdown;
|
|
@@ -17,7 +17,9 @@ const DropdownButton = forwardRef<HTMLButtonElement, DropdownButtonProps>(
|
|
|
17
17
|
const iconSize = size === 'medium' ? '24' : '16';
|
|
18
18
|
return (
|
|
19
19
|
<chakra.button aria-readonly={readOnly} type="button" ref={ref} onBlur={blurHandler} __css={field} {...rest}>
|
|
20
|
-
<chakra.span flexGrow={1}>
|
|
20
|
+
<chakra.span overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" flexGrow={1}>
|
|
21
|
+
{formLabel || placeholder}
|
|
22
|
+
</chakra.span>
|
|
21
23
|
<Icon aria-hidden="true" __css={icon} name="DropdownArrows" fontSize={iconSize} size={iconSize} />
|
|
22
24
|
</chakra.button>
|
|
23
25
|
);
|
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ReactNode, useId } from 'react';
|
|
2
2
|
import { chakra } from '@chakra-ui/react';
|
|
3
3
|
import { cx } from '@chakra-ui/utils';
|
|
4
4
|
import Text from '../Text/Text';
|
|
5
5
|
import Divider from '../Divider/Divider';
|
|
6
6
|
import { useDropdownContext, useDropdownStyles } from './Dropdown.context';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
export type DropdownOptionProps<T> = {
|
|
9
|
+
value?: T | null;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
'aria-label'?: string;
|
|
12
|
+
};
|
|
13
|
+
const DropdownOption = <T = string,>({ value = null, children, ...rest }: DropdownOptionProps<T>) => {
|
|
12
14
|
const { item } = useDropdownStyles();
|
|
13
|
-
const ctx = useDropdownContext();
|
|
15
|
+
const ctx = useDropdownContext<T | null>();
|
|
16
|
+
const { index } = rest as { index?: number };
|
|
14
17
|
return (
|
|
15
18
|
<chakra.div
|
|
16
19
|
role="option"
|
|
17
20
|
id={useId()}
|
|
18
|
-
ref={
|
|
21
|
+
ref={(node) => {
|
|
22
|
+
if (ctx.listRef.current) {
|
|
23
|
+
ctx.listRef.current[index!] = node;
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
19
26
|
{...rest}
|
|
20
27
|
className={cx(
|
|
21
28
|
value === ctx.formValue && 'bitkit-select__option-active',
|
|
@@ -24,14 +31,14 @@ const DropdownOption = forwardRef<
|
|
|
24
31
|
__css={item}
|
|
25
32
|
{...ctx.getItemProps({
|
|
26
33
|
onClick() {
|
|
27
|
-
ctx.onOptionSelected({ value
|
|
34
|
+
ctx.onOptionSelected({ value, index, label: children });
|
|
28
35
|
},
|
|
29
36
|
})}
|
|
30
37
|
>
|
|
31
38
|
{children}
|
|
32
39
|
</chakra.div>
|
|
33
40
|
);
|
|
34
|
-
}
|
|
41
|
+
};
|
|
35
42
|
|
|
36
43
|
const DropdownGroup = ({ children, label }: { children: ReactNode; label: string }) => {
|
|
37
44
|
const { group } = useDropdownStyles();
|
|
@@ -54,16 +61,15 @@ const DropdownDetailedOption = ({
|
|
|
54
61
|
title,
|
|
55
62
|
subtitle,
|
|
56
63
|
value,
|
|
57
|
-
|
|
64
|
+
...rest
|
|
58
65
|
}: {
|
|
59
66
|
icon: ReactNode;
|
|
60
67
|
title: string;
|
|
61
68
|
subtitle: string;
|
|
62
69
|
value?: string;
|
|
63
|
-
index?: number;
|
|
64
70
|
}) => {
|
|
65
71
|
return (
|
|
66
|
-
<DropdownOption value={value}
|
|
72
|
+
<DropdownOption value={value} {...rest} aria-label={title}>
|
|
67
73
|
<chakra.div alignItems="center" gap="12" display="flex" flexDir="row">
|
|
68
74
|
{icon}
|
|
69
75
|
<chakra.div>
|