/
SearchBar.tsx
188 lines (167 loc) Β· 5.34 KB
/
SearchBar.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import React, {
MouseEventHandler,
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import clsx from 'clsx';
import {
MenuIcon as DefaultMenuIcon,
SearchIcon as DefaultSearchInputIcon,
ReturnIcon,
XIcon,
} from './icons';
import { SearchInput as DefaultSearchInput, SearchInputProps } from './SearchInput';
export type AppMenuProps = {
close?: () => void;
};
type SearchBarButtonProps = {
className?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
};
const SearchBarButton = ({
children,
className,
onClick,
}: PropsWithChildren<SearchBarButtonProps>) => (
<button
className={clsx('str-chat__channel-search-bar-button', className)}
data-testid='search-bar-button'
onClick={onClick}
>
{children}
</button>
);
export type SearchBarController = {
/** Called on search input focus */
activateSearch: () => void;
/** Clears the search state, removes focus from the search input */
exitSearch: () => void;
/** Flag determining whether the search input is focused */
inputIsFocused: boolean;
/** Ref object for the input wrapper in the SearchBar */
searchBarRef: React.RefObject<HTMLDivElement>;
};
export type AdditionalSearchBarProps = {
/** Application menu to be displayed when clicked on MenuIcon */
AppMenu?: React.ComponentType<AppMenuProps>;
/** Custom icon component used to clear the input value on click. Displayed within the search input wrapper. */
ClearInputIcon?: React.ComponentType;
/** Custom icon component used to terminate the search UI session on click. */
ExitSearchIcon?: React.ComponentType;
/** Custom icon component used to invoke context menu. */
MenuIcon?: React.ComponentType;
/** Custom UI component to display the search text input */
SearchInput?: React.ComponentType<SearchInputProps>;
/** Custom icon used to indicate search input. */
SearchInputIcon?: React.ComponentType;
};
export type SearchBarProps = AdditionalSearchBarProps & SearchBarController & SearchInputProps;
// todo: add context menu control logic
export const SearchBar = (props: SearchBarProps) => {
const {
activateSearch,
AppMenu,
ClearInputIcon = XIcon,
exitSearch,
ExitSearchIcon = ReturnIcon,
inputIsFocused,
MenuIcon = DefaultMenuIcon,
searchBarRef,
SearchInput = DefaultSearchInput,
SearchInputIcon = DefaultSearchInputIcon,
...inputProps
} = props;
const [menuIsOpen, setMenuIsOpen] = useState(false);
const appMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!appMenuRef.current) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (menuIsOpen && event.key === 'Escape') {
setMenuIsOpen(false);
}
};
const clickListener = (e: MouseEvent) => {
if (
!(e.target instanceof HTMLElement) ||
!menuIsOpen ||
appMenuRef.current?.contains(e.target)
)
return;
setMenuIsOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('click', clickListener);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', clickListener);
};
}, [menuIsOpen]);
useEffect(() => {
if (!props.inputRef.current) return;
const handleFocus = () => {
activateSearch();
};
const handleBlur = (e: Event) => {
e.stopPropagation(); // handle blur/focus state with React state
};
props.inputRef.current.addEventListener('focus', handleFocus);
props.inputRef.current.addEventListener('blur', handleBlur);
return () => {
props.inputRef.current?.removeEventListener('focus', handleFocus);
props.inputRef.current?.addEventListener('blur', handleBlur);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleClearClick = useCallback(() => {
exitSearch();
inputProps.inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const closeAppMenu = useCallback(() => setMenuIsOpen(false), []);
return (
<div className='str-chat__channel-search-bar' data-testid='search-bar' ref={searchBarRef}>
{inputIsFocused ? (
<SearchBarButton
className='str-chat__channel-search-bar-button--exit-search'
onClick={exitSearch}
>
<ExitSearchIcon />
</SearchBarButton>
) : AppMenu ? (
<SearchBarButton
className='str-chat__channel-search-bar-button--menu'
onClick={() => setMenuIsOpen((prev) => !prev)}
>
<MenuIcon />
</SearchBarButton>
) : null}
<div
className={clsx(
'str-chat__channel-search-input--wrapper',
inputProps.query && 'str-chat__channel-search-input--wrapper-active',
)}
>
<div className='str-chat__channel-search-input--icon'>
<SearchInputIcon />
</div>
<SearchInput {...inputProps} />
<button
className='str-chat__channel-search-input--clear-button'
data-testid='clear-input-button'
disabled={!inputProps.query}
onClick={handleClearClick}
>
<ClearInputIcon />
</button>
</div>
{menuIsOpen && AppMenu && (
<div ref={appMenuRef}>
<AppMenu close={closeAppMenu} />
</div>
)}
</div>
);
};