Skip to content

Commit 8ea6099

Browse files
committed
feat(UI): add autocomplete component to components library
1 parent 30719ac commit 8ea6099

File tree

13 files changed

+418
-0
lines changed

13 files changed

+418
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { BADGE } from '@geometricpanda/storybook-addon-badges';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import React from 'react';
4+
import AutoComplete from './AutoComplete';
5+
6+
// Auto Docs
7+
const meta = {
8+
title: 'Components / AutoComplete',
9+
component: AutoComplete,
10+
11+
// Display Properties
12+
parameters: {
13+
layout: 'centered',
14+
badges: [BADGE.STABLE, 'readyForDesignReview'],
15+
docs: {
16+
subtitle: 'This component allows to add autocompletion',
17+
},
18+
},
19+
20+
// Component-level argTypes
21+
argTypes: {
22+
dataTestId: {
23+
description: 'Optional property to set data-testid',
24+
control: 'text',
25+
},
26+
className: {
27+
description: 'Optional class names to pass into AutoComplete',
28+
control: 'text',
29+
},
30+
value: {
31+
description: 'Value of input',
32+
},
33+
defaultValue: {
34+
description: 'Selected option by default',
35+
},
36+
options: {
37+
description: 'Options available in dropdown',
38+
table: {
39+
type: {
40+
summary: 'OptionType',
41+
detail: `{
42+
label: React.ReactNode;
43+
value?: string | number | null;
44+
disabled?: boolean;
45+
[name: string]: any;
46+
children?: Omit<OptionType, 'children'>[];
47+
}
48+
`,
49+
},
50+
},
51+
},
52+
open: {
53+
description: 'Controlled open state of dropdown',
54+
},
55+
defaultActiveFirstOption: {
56+
description: 'Whether active first option by default',
57+
},
58+
filterOption: {
59+
description: 'If true, filter options by input, if function, filter options against it',
60+
},
61+
dropdownContentHeight: {
62+
description: "Height of dropdown's content",
63+
},
64+
onSelect: {
65+
description: 'Called when a option is selected',
66+
},
67+
onSearch: {
68+
description: 'Called when searching items',
69+
},
70+
onChange: {
71+
description: 'Called when selecting an option or changing an input value',
72+
},
73+
onDropdownVisibleChange: {
74+
description: 'Called when dropdown opened/closed',
75+
},
76+
onClear: {
77+
description: 'Called when clear',
78+
},
79+
dropdownRender: {
80+
description: 'Customize dropdown content',
81+
},
82+
dropdownAlign: {
83+
description: "Adjust how the autocomplete's dropdown should be aligned",
84+
},
85+
style: {
86+
description: 'Additional styles for the wrapper of the children',
87+
},
88+
dropdownStyle: {
89+
description: 'Additional styles for the dropdown',
90+
},
91+
dropdownMatchSelectWidth: {
92+
description:
93+
'Determine whether the dropdown menu and the select input are the same width.' +
94+
'Default set min-width same as input. Will ignore when value less than select width.',
95+
},
96+
},
97+
98+
// Define defaults
99+
args: {
100+
options: [
101+
{ label: 'test', value: 'test' },
102+
{ label: 'test2', value: 'test2' },
103+
],
104+
},
105+
} satisfies Meta<typeof AutoComplete>;
106+
107+
export default meta;
108+
109+
// Stories
110+
111+
type Story = StoryObj<typeof meta>;
112+
113+
// Basic story is what is displayed 1st in storybook & is used as the code sandbox
114+
// Pass props to this so that it can be customized via the UI props panel
115+
export const sandbox: Story = {
116+
tags: ['dev'],
117+
render: (props) => (
118+
<AutoComplete {...props}>
119+
<input />
120+
</AutoComplete>
121+
),
122+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { AutoComplete as AntdAutoComplete } from 'antd';
2+
import React, { useCallback, useEffect, useState } from 'react';
3+
import ClickOutside from '../Utils/ClickOutside/ClickOutside';
4+
import { DropdownWrapper } from './components';
5+
import { AUTOCOMPLETE_WRAPPER_CLASS_CSS_SELECTOR, AUTOCOMPLETE_WRAPPER_CLASS_NAME, ESCAPE_KEY } from './constants';
6+
import { AutoCompleteProps, OptionType } from './types';
7+
import { OverlayClassProvider } from '../Utils';
8+
9+
export default function AutoComplete({
10+
children,
11+
dropdownContentHeight,
12+
dataTestId,
13+
onDropdownVisibleChange,
14+
onChange,
15+
onClear,
16+
value,
17+
...props
18+
}: React.PropsWithChildren<AutoCompleteProps>) {
19+
const [internalValue, setInternalValue] = useState<string>(value || '');
20+
const [internalOpen, setInternalOpen] = useState<boolean>(true);
21+
22+
useEffect(() => {
23+
onDropdownVisibleChange?.(internalOpen);
24+
}, [internalOpen, onDropdownVisibleChange]);
25+
26+
const onChangeHandler = (newValue: string, option: OptionType | OptionType[]) => {
27+
setInternalValue(newValue);
28+
if (!internalOpen && newValue !== '') setInternalOpen(true);
29+
onChange?.(newValue, option);
30+
};
31+
32+
const onKeyDownHandler = useCallback(
33+
(event: React.KeyboardEvent) => {
34+
if (event.key === ESCAPE_KEY) {
35+
if (internalOpen) {
36+
setInternalOpen(false);
37+
} else {
38+
setInternalValue('');
39+
onClear?.();
40+
}
41+
}
42+
},
43+
[internalOpen, setInternalValue, onClear],
44+
);
45+
46+
const onBlur = (event: React.FocusEvent) => {
47+
if (
48+
event.relatedTarget &&
49+
!(event.relatedTarget as HTMLElement).closest(AUTOCOMPLETE_WRAPPER_CLASS_CSS_SELECTOR)
50+
) {
51+
setInternalOpen(false);
52+
}
53+
};
54+
55+
return (
56+
<ClickOutside
57+
ignoreSelector={AUTOCOMPLETE_WRAPPER_CLASS_CSS_SELECTOR}
58+
onClickOutside={() => setInternalOpen(false)}
59+
>
60+
<AntdAutoComplete
61+
open={internalOpen}
62+
value={internalValue}
63+
{...props}
64+
listHeight={dropdownContentHeight}
65+
data-testid={dataTestId}
66+
onClick={() => setInternalOpen(true)}
67+
onBlur={onBlur}
68+
onClear={onClear}
69+
onKeyDown={onKeyDownHandler}
70+
onChange={onChangeHandler}
71+
dropdownRender={(menu) => {
72+
return (
73+
// Pass overlay class name to children to add possibility not to close autocomplete by clicking on child's portals
74+
// as ClickOutside will ignore them
75+
<OverlayClassProvider overlayClassName={AUTOCOMPLETE_WRAPPER_CLASS_NAME}>
76+
<DropdownWrapper className={AUTOCOMPLETE_WRAPPER_CLASS_NAME}>
77+
{props?.dropdownRender?.(menu) ?? menu}
78+
</DropdownWrapper>
79+
</OverlayClassProvider>
80+
);
81+
}}
82+
>
83+
{children}
84+
</AntdAutoComplete>
85+
</ClickOutside>
86+
);
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import styled from 'styled-components';
2+
3+
export const DropdownWrapper = styled.div`
4+
& .rc-virtual-list-scrollbar-thumb {
5+
background: rgba(193, 196, 208, 0.8) !important;
6+
}
7+
& .rc-virtual-list-scrollbar-show {
8+
background: rgba(193, 196, 208, 0.3) !important;
9+
}
10+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const AUTOCOMPLETE_WRAPPER_CLASS_NAME = 'autocomplete-wrapper';
2+
export const AUTOCOMPLETE_WRAPPER_CLASS_CSS_SELECTOR = `.${AUTOCOMPLETE_WRAPPER_CLASS_NAME}`;
3+
export const ESCAPE_KEY = 'Escape';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import AutoComplete from './AutoComplete';
2+
3+
export { AutoComplete };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { DefaultOptionType } from 'antd/lib/select';
2+
import { AlignType } from 'rc-trigger/lib/interface';
3+
import React from 'react';
4+
5+
export type ValueType = string;
6+
7+
export type OptionType = DefaultOptionType;
8+
9+
export interface AutoCompleteProps {
10+
dataTestId?: string;
11+
className?: string;
12+
13+
value?: ValueType;
14+
defaultValue?: ValueType;
15+
options: OptionType[];
16+
open?: boolean;
17+
18+
defaultActiveFirstOption?: boolean;
19+
filterOption?: boolean | ((inputValue: ValueType, option?: OptionType) => boolean);
20+
dropdownContentHeight?: number;
21+
22+
onSelect?: (value: ValueType, option: OptionType) => void;
23+
onSearch?: (value: ValueType) => void;
24+
onChange?: (value: ValueType, option: OptionType | OptionType[]) => void;
25+
onClear?: () => void;
26+
onDropdownVisibleChange?: (isOpen: boolean) => void;
27+
28+
dropdownRender?: (menu: React.ReactElement) => React.ReactElement | undefined;
29+
notFoundContent?: React.ReactNode;
30+
31+
dropdownAlign?: AlignType;
32+
style?: React.CSSProperties;
33+
dropdownStyle?: React.CSSProperties;
34+
dropdownMatchSelectWidth?: boolean | number;
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { BADGE } from '@geometricpanda/storybook-addon-badges';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import React from 'react';
4+
import ClickOutside from './ClickOutside';
5+
6+
// Auto Docs
7+
const meta = {
8+
title: 'Utils / ClickOutside',
9+
component: ClickOutside,
10+
11+
// Display Properties
12+
parameters: {
13+
layout: 'centered',
14+
badges: [BADGE.STABLE, 'readyForDesignReview'],
15+
docs: {
16+
subtitle: 'This component allows to add autocompletion',
17+
},
18+
},
19+
20+
// Component-level argTypes
21+
argTypes: {
22+
onClickOutside: {
23+
description: 'Called on clicking outside',
24+
},
25+
ignoreSelector: {
26+
description: 'Optional CSS-selector to ignore handling of clicks as outside clicks',
27+
},
28+
outsideSelector: {
29+
description: 'Optional CSS-selector to cosider clicked element as outside click',
30+
},
31+
ignoreWrapper: {
32+
description: 'Enable to ignore clicking outside of wrapper',
33+
},
34+
},
35+
36+
// Define defaults
37+
args: {
38+
onClickOutside: () => console.log('Clicked outside'),
39+
},
40+
} satisfies Meta<typeof ClickOutside>;
41+
42+
export default meta;
43+
44+
// Stories
45+
46+
type Story = StoryObj<typeof meta>;
47+
48+
// Basic story is what is displayed 1st in storybook & is used as the code sandbox
49+
// Pass props to this so that it can be customized via the UI props panel
50+
export const sandbox: Story = {
51+
tags: ['dev'],
52+
render: (props) => (
53+
<ClickOutside {...props}>
54+
<button type="button">Button</button>
55+
</ClickOutside>
56+
),
57+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { ClickOutsideProps } from './types';
3+
4+
export default function ClickOutside({
5+
children,
6+
onClickOutside,
7+
outsideSelector,
8+
ignoreSelector,
9+
ignoreWrapper,
10+
}: React.PropsWithChildren<ClickOutsideProps>) {
11+
const wrapperRef = useRef<HTMLDivElement>(null);
12+
13+
useEffect(() => {
14+
/**
15+
* Handles click events outside the wrapper or based on selectors.
16+
*/
17+
const handleClickOutside = (event: MouseEvent): void => {
18+
const target = event.target as HTMLElement;
19+
20+
const isInsideOfWrapper = (wrapperRef.current as HTMLDivElement).contains((event.target as Node) || null);
21+
console.log(event.target, wrapperRef.current);
22+
23+
// Ignore clicks on elements matching `ignoreSelector`
24+
if (ignoreSelector && target.closest(ignoreSelector)) {
25+
return;
26+
}
27+
28+
// Trigger `onClickOutside` if the click is on an element matching `outsideSelector`
29+
if (outsideSelector && target.closest(outsideSelector)) {
30+
return;
31+
}
32+
33+
// Trigger `onClickOutside` if the click is outside the wrapper
34+
if (!ignoreWrapper && !isInsideOfWrapper) {
35+
onClickOutside(event);
36+
}
37+
};
38+
39+
if (wrapperRef && wrapperRef.current) {
40+
document.addEventListener('mousedown', handleClickOutside);
41+
}
42+
return () => document.removeEventListener('mousedown', handleClickOutside);
43+
}, [onClickOutside, ignoreSelector, outsideSelector, ignoreWrapper]);
44+
45+
return <div ref={wrapperRef}>{children}</div>;
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface ClickOutsideProps {
2+
onClickOutside: (event: MouseEvent) => void;
3+
outsideSelector?: string; // Selector for elements that should trigger `onClickOutside`
4+
ignoreSelector?: string; // Selector for elements that should be ignored
5+
ignoreWrapper?: boolean; // Enable to ignore click outside the wrapper
6+
}

0 commit comments

Comments
 (0)