Skip to content

Commit d42eeba

Browse files
languyLaurent Nguyen
andauthored
Improve DocumentsTab filter input (#1998)
* Rework Input and dropdown in DocumentsTab * Improve input: implement Escape and add clear button * Undo body :focus outline, since fluent UI has a nicer focus style * Close dropdown if last element is tabbed * Fix unit tests * Fix theme and remove autocomplete * Load theme inside rendering function to fix using correct colors * Remove commented code * Add aria-label to clear filter button * Fix format * Fix keyboard navigation with tab and arrow up/down. Clear button becomes down button. --------- Co-authored-by: Laurent Nguyen <[email protected]>
1 parent 056be2a commit d42eeba

File tree

4 files changed

+528
-307
lines changed

4 files changed

+528
-307
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// This component is used to create a dropdown list of options for the user to select from.
2+
// The options are displayed in a dropdown list when the user clicks on the input field.
3+
// The user can then select an option from the list. The selected option is then displayed in the input field.
4+
5+
import { getTheme } from "@fluentui/react";
6+
import {
7+
Button,
8+
Divider,
9+
Input,
10+
Link,
11+
makeStyles,
12+
Popover,
13+
PopoverProps,
14+
PopoverSurface,
15+
PositioningImperativeRef,
16+
} from "@fluentui/react-components";
17+
import { ArrowDownRegular, DismissRegular } from "@fluentui/react-icons";
18+
import { NormalizedEventKey } from "Common/Constants";
19+
import { tokens } from "Explorer/Theme/ThemeUtil";
20+
import React, { FC, useEffect, useRef } from "react";
21+
22+
const useStyles = makeStyles({
23+
container: {
24+
padding: 0,
25+
},
26+
input: {
27+
flexGrow: 1,
28+
paddingRight: 0,
29+
outline: "none",
30+
"& input:focus": {
31+
outline: "none", // Undo body :focus dashed outline
32+
},
33+
},
34+
inputButton: {
35+
border: 0,
36+
},
37+
dropdownHeader: {
38+
width: "100%",
39+
fontSize: tokens.fontSizeBase300,
40+
fontWeight: 600,
41+
padding: `${tokens.spacingVerticalM} 0 0 ${tokens.spacingVerticalM}`,
42+
},
43+
dropdownStack: {
44+
display: "flex",
45+
flexDirection: "column",
46+
gap: tokens.spacingVerticalS,
47+
marginTop: tokens.spacingVerticalS,
48+
marginBottom: "1px",
49+
},
50+
dropdownOption: {
51+
fontSize: tokens.fontSizeBase300,
52+
fontWeight: 400,
53+
justifyContent: "left",
54+
padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`,
55+
overflow: "hidden",
56+
whiteSpace: "nowrap",
57+
textOverflow: "ellipsis",
58+
border: 0,
59+
":hover": {
60+
outline: `1px dashed ${tokens.colorNeutralForeground1Hover}`,
61+
backgroundColor: tokens.colorNeutralBackground2Hover,
62+
color: tokens.colorNeutralForeground1,
63+
},
64+
},
65+
bottomSection: {
66+
fontSize: tokens.fontSizeBase300,
67+
fontWeight: 400,
68+
padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`,
69+
overflow: "hidden",
70+
whiteSpace: "nowrap",
71+
textOverflow: "ellipsis",
72+
},
73+
});
74+
75+
export interface InputDatalistDropdownOptionSection {
76+
label: string;
77+
options: string[];
78+
}
79+
80+
export interface InputDataListProps {
81+
dropdownOptions: InputDatalistDropdownOptionSection[];
82+
placeholder?: string;
83+
title?: string;
84+
value: string;
85+
onChange: (value: string) => void;
86+
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
87+
autofocus?: boolean; // true: acquire focus on first render
88+
bottomLink?: {
89+
text: string;
90+
url: string;
91+
};
92+
}
93+
94+
export const InputDataList: FC<InputDataListProps> = ({
95+
dropdownOptions,
96+
placeholder,
97+
title,
98+
value,
99+
onChange,
100+
onKeyDown,
101+
autofocus,
102+
bottomLink,
103+
}) => {
104+
const styles = useStyles();
105+
const [showDropdown, setShowDropdown] = React.useState(false);
106+
const inputRef = useRef<HTMLInputElement>(null);
107+
const positioningRef = React.useRef<PositioningImperativeRef>(null);
108+
const [isInputFocused, setIsInputFocused] = React.useState(autofocus);
109+
const [autofocusFirstDropdownItem, setAutofocusFirstDropdownItem] = React.useState(false);
110+
111+
const theme = getTheme();
112+
const itemRefs = useRef([]);
113+
114+
useEffect(() => {
115+
if (inputRef.current) {
116+
positioningRef.current?.setTarget(inputRef.current);
117+
}
118+
}, [inputRef, positioningRef]);
119+
120+
useEffect(() => {
121+
if (isInputFocused) {
122+
inputRef.current?.focus();
123+
}
124+
}, [isInputFocused]);
125+
126+
useEffect(() => {
127+
if (autofocusFirstDropdownItem && showDropdown) {
128+
// Autofocus on first item if input isn't focused
129+
itemRefs.current[0]?.focus();
130+
setAutofocusFirstDropdownItem(false);
131+
}
132+
}, [autofocusFirstDropdownItem, showDropdown]);
133+
134+
const handleOpenChange: PopoverProps["onOpenChange"] = (e, data) => {
135+
if (isInputFocused && !data.open) {
136+
// Don't close if input is focused and we're opening the dropdown (which will steal the focus)
137+
return;
138+
}
139+
140+
setShowDropdown(data.open || false);
141+
if (data.open) {
142+
setIsInputFocused(true);
143+
}
144+
};
145+
146+
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
147+
if (e.key === NormalizedEventKey.Escape) {
148+
setShowDropdown(false);
149+
} else if (e.key === NormalizedEventKey.DownArrow) {
150+
setShowDropdown(true);
151+
setAutofocusFirstDropdownItem(true);
152+
}
153+
onKeyDown(e);
154+
};
155+
156+
const handleDownDropdownItemKeyDown = (
157+
e: React.KeyboardEvent<HTMLButtonElement | HTMLAnchorElement>,
158+
index: number,
159+
) => {
160+
if (e.key === NormalizedEventKey.Enter) {
161+
e.currentTarget.click();
162+
} else if (e.key === NormalizedEventKey.Escape) {
163+
setShowDropdown(false);
164+
inputRef.current?.focus();
165+
} else if (e.key === NormalizedEventKey.DownArrow) {
166+
if (index + 1 < itemRefs.current.length) {
167+
itemRefs.current[index + 1].focus();
168+
} else {
169+
setIsInputFocused(true);
170+
}
171+
} else if (e.key === NormalizedEventKey.UpArrow) {
172+
if (index - 1 >= 0) {
173+
itemRefs.current[index - 1].focus();
174+
} else {
175+
// Last item, focus back to input
176+
setIsInputFocused(true);
177+
}
178+
}
179+
};
180+
181+
// Flatten dropdownOptions to better manage refs and focus
182+
let flatIndex = 0;
183+
const indexMap = new Map<string, number>();
184+
for (let sectionIndex = 0; sectionIndex < dropdownOptions.length; sectionIndex++) {
185+
const section = dropdownOptions[sectionIndex];
186+
for (let optionIndex = 0; optionIndex < section.options.length; optionIndex++) {
187+
indexMap.set(`${sectionIndex}-${optionIndex}`, flatIndex);
188+
flatIndex++;
189+
}
190+
}
191+
192+
return (
193+
<>
194+
<Input
195+
id="filterInput"
196+
ref={inputRef}
197+
type="text"
198+
size="small"
199+
autoComplete="off"
200+
className={`filterInput ${styles.input}`}
201+
title={title}
202+
placeholder={placeholder}
203+
value={value}
204+
autoFocus
205+
onKeyDown={handleInputKeyDown}
206+
onChange={(e) => {
207+
const newValue = e.target.value;
208+
// Don't show dropdown if there is already a value in the input field (when user is typing)
209+
setShowDropdown(!(newValue.length > 0));
210+
onChange(newValue);
211+
}}
212+
onClick={(e) => {
213+
e.stopPropagation();
214+
}}
215+
onFocus={() => {
216+
// Don't show dropdown if there is already a value in the input field
217+
// or isInputFocused is undefined which means component is mounting
218+
setShowDropdown(!(value.length > 0) && isInputFocused !== undefined);
219+
220+
setIsInputFocused(true);
221+
}}
222+
onBlur={() => {
223+
setIsInputFocused(false);
224+
}}
225+
contentAfter={
226+
value.length > 0 ? (
227+
<Button
228+
aria-label="Clear filter"
229+
className={styles.inputButton}
230+
size="small"
231+
icon={<DismissRegular />}
232+
onClick={() => {
233+
onChange("");
234+
setIsInputFocused(true);
235+
}}
236+
/>
237+
) : (
238+
<Button
239+
aria-label="Open dropdown"
240+
className={styles.inputButton}
241+
size="small"
242+
icon={<ArrowDownRegular />}
243+
onClick={() => {
244+
setShowDropdown(true);
245+
setAutofocusFirstDropdownItem(true);
246+
}}
247+
/>
248+
)
249+
}
250+
/>
251+
<Popover
252+
inline
253+
unstable_disableAutoFocus
254+
// trapFocus
255+
open={showDropdown}
256+
onOpenChange={handleOpenChange}
257+
positioning={{ positioningRef, position: "below", align: "start", offset: 4 }}
258+
>
259+
<PopoverSurface className={styles.container}>
260+
{dropdownOptions.map((section, sectionIndex) => (
261+
<div key={section.label}>
262+
<div className={styles.dropdownHeader} style={{ color: theme.palette.themePrimary }}>
263+
{section.label}
264+
</div>
265+
<div className={styles.dropdownStack}>
266+
{section.options.map((option, index) => (
267+
<Button
268+
key={option}
269+
ref={(el) => (itemRefs.current[indexMap.get(`${sectionIndex}-${index}`)] = el)}
270+
appearance="transparent"
271+
shape="square"
272+
className={styles.dropdownOption}
273+
onClick={() => {
274+
onChange(option);
275+
setShowDropdown(false);
276+
setIsInputFocused(true);
277+
}}
278+
onBlur={() =>
279+
!bottomLink &&
280+
sectionIndex === dropdownOptions.length - 1 &&
281+
index === section.options.length - 1 &&
282+
setShowDropdown(false)
283+
}
284+
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
285+
handleDownDropdownItemKeyDown(e, indexMap.get(`${sectionIndex}-${index}`))
286+
}
287+
>
288+
{option}
289+
</Button>
290+
))}
291+
</div>
292+
</div>
293+
))}
294+
{bottomLink && (
295+
<>
296+
<Divider />
297+
<div className={styles.bottomSection}>
298+
<Link
299+
ref={(el) => (itemRefs.current[flatIndex] = el)}
300+
href={bottomLink.url}
301+
target="_blank"
302+
onBlur={() => setShowDropdown(false)}
303+
onKeyDown={(e: React.KeyboardEvent<HTMLAnchorElement>) => handleDownDropdownItemKeyDown(e, flatIndex)}
304+
>
305+
{bottomLink.text}
306+
</Link>
307+
</div>
308+
</>
309+
)}
310+
</PopoverSurface>
311+
</Popover>
312+
</>
313+
);
314+
};

src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -385,22 +385,6 @@ describe("Documents tab (noSql API)", () => {
385385
it("should render the page", () => {
386386
expect(wrapper).toMatchSnapshot();
387387
});
388-
389-
it("clicking on Edit filter should render the Apply Filter button", () => {
390-
wrapper
391-
.findWhere((node) => node.text() === "Edit Filter")
392-
.at(0)
393-
.simulate("click");
394-
expect(wrapper.findWhere((node) => node.text() === "Apply Filter").exists()).toBeTruthy();
395-
});
396-
397-
it("clicking on Edit filter should render input for filter", () => {
398-
wrapper
399-
.findWhere((node) => node.text() === "Edit Filter")
400-
.at(0)
401-
.simulate("click");
402-
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
403-
});
404388
});
405389

406390
describe("Command bar buttons", () => {

0 commit comments

Comments
 (0)