Skip to content

Commit 2eb33bd

Browse files
committed
feat: filter popup
1 parent 71b0f67 commit 2eb33bd

File tree

1 file changed

+177
-12
lines changed

1 file changed

+177
-12
lines changed

src/components/Search/index.tsx

Lines changed: 177 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState, useTransition } from "r
22
import { useLocation, useNavigate } from "react-router-dom";
33
import { styled } from "@linaria/react";
44
import { SearchContext } from "./SearchContext";
5+
import { KindIcon, IconKind } from "../KindIcon";
56

67
export function useCtrlFHook<T extends HTMLElement>() {
78
const ref = useRef<T | null>(null);
@@ -47,6 +48,133 @@ export const SearchInput = styled.input`
4748
}
4849
`;
4950

51+
const SEARCH_TAGS = [
52+
{ tag: "module:", icon: "field" as IconKind, label: "Module", description: "Filter by module name", example: "e.g. module:client" },
53+
{ tag: "offset:", icon: "meta-default" as IconKind, label: "Offset", description: "Filter by byte offset", example: "e.g. offset:0x1A0" },
54+
{ tag: "metadata:", icon: "meta-tag" as IconKind, label: "Metadata", description: "Filter by metadata key name", example: "e.g. metadata:MNetworkEnable" },
55+
{ tag: "metadatavalue:", icon: "meta-variable" as IconKind, label: "Metadata Value", description: "Filter by metadata value", example: "e.g. metadatavalue:true" },
56+
] as const;
57+
58+
function getLastWord(input: string): string {
59+
if (input === "" || input.endsWith(" ")) return "";
60+
return input.split(" ").at(-1) ?? "";
61+
}
62+
63+
function shouldShowPopup(input: string): boolean {
64+
return !getLastWord(input).includes(":");
65+
}
66+
67+
function filterTags(lastWord: string) {
68+
if (lastWord === "") return SEARCH_TAGS;
69+
return SEARCH_TAGS.filter(t => t.tag.startsWith(lastWord.toLowerCase()));
70+
}
71+
72+
function insertTag(inputValue: string, tag: string): string {
73+
if (inputValue === "" || inputValue.endsWith(" ")) return inputValue + tag;
74+
const parts = inputValue.split(" ");
75+
parts[parts.length - 1] = tag;
76+
return parts.join(" ");
77+
}
78+
79+
const SearchBoxWrapper = styled.div`
80+
position: relative;
81+
width: 100%;
82+
`;
83+
84+
const TagPopup = styled.div`
85+
position: absolute;
86+
top: calc(100% + 4px);
87+
left: 0;
88+
right: 0;
89+
background: var(--group);
90+
border: 1px solid var(--group-border);
91+
border-radius: 8px;
92+
box-shadow: var(--group-shadow);
93+
z-index: 200;
94+
overflow: hidden;
95+
`;
96+
97+
const TagPopupHeader = styled.div`
98+
padding: 6px 12px 4px;
99+
font-size: 11px;
100+
font-weight: 600;
101+
text-transform: uppercase;
102+
letter-spacing: 0.05em;
103+
color: var(--text-dim);
104+
`;
105+
106+
const TagItem = styled.button`
107+
display: flex;
108+
align-items: center;
109+
gap: 10px;
110+
width: 100%;
111+
padding: 7px 12px;
112+
border: none;
113+
background: transparent;
114+
color: var(--text);
115+
font-size: 14px;
116+
cursor: pointer;
117+
text-align: left;
118+
&[data-active], &:hover { background: var(--group-members); }
119+
`;
120+
121+
const TagItemText = styled.div`
122+
display: flex;
123+
flex-direction: column;
124+
gap: 1px;
125+
flex: 1;
126+
min-width: 0;
127+
`;
128+
129+
const TagItemName = styled.span`
130+
font-weight: 600;
131+
font-size: 13px;
132+
font-family: monospace;
133+
`;
134+
135+
const TagItemDesc = styled.span`
136+
font-size: 12px;
137+
color: var(--text-dim);
138+
`;
139+
140+
const TagItemExample = styled.span`
141+
font-size: 11px;
142+
color: var(--text-dim);
143+
opacity: 0.6;
144+
font-family: monospace;
145+
flex-shrink: 0;
146+
`;
147+
148+
function SearchTagPopup({
149+
tags, activeIndex, onSelect,
150+
}: {
151+
tags: typeof SEARCH_TAGS[number][];
152+
activeIndex: number;
153+
onSelect: (tag: string) => void;
154+
}) {
155+
return (
156+
<TagPopup role="listbox" aria-label="Search tag suggestions">
157+
<TagPopupHeader>Filters</TagPopupHeader>
158+
{tags.map((t, i) => (
159+
<TagItem
160+
key={t.tag}
161+
role="option"
162+
aria-selected={i === activeIndex}
163+
data-active={i === activeIndex || undefined}
164+
onMouseDown={(e) => { e.preventDefault(); onSelect(t.tag); }}
165+
>
166+
<KindIcon kind={t.icon} size="small" />
167+
<TagItemText>
168+
<TagItemName>{t.tag}</TagItemName>
169+
<TagItemDesc>{t.description}</TagItemDesc>
170+
</TagItemText>
171+
<TagItemExample>{t.example}</TagItemExample>
172+
</TagItem>
173+
))}
174+
</TagPopup>
175+
);
176+
}
177+
50178
export function SearchBox({
51179
baseUrl,
52180
className,
@@ -58,6 +186,8 @@ export function SearchBox({
58186
}) {
59187
const { search, setSearch } = useContext(SearchContext);
60188
const [inputValue, setInputValue] = useState(search);
189+
const [isFocused, setIsFocused] = useState(false);
190+
const [activeIndex, setActiveIndex] = useState(0);
61191
const navigate = useNavigate();
62192
const location = useLocation();
63193
const [, startTransition] = useTransition();
@@ -77,9 +207,27 @@ export function SearchBox({
77207
// infinite loop: setSearch triggers a navigate which changes location.search.
78208
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
79209

210+
const lastWord = getLastWord(inputValue);
211+
const filteredTags = filterTags(lastWord);
212+
const showPopup = isFocused && shouldShowPopup(inputValue) && filteredTags.length > 0;
213+
214+
const handleTagSelect = (tag: string) => {
215+
const newValue = insertTag(inputValue, tag);
216+
setInputValue(newValue);
217+
ownNavigateRef.current = true;
218+
const replace = inputValue !== "" || newValue === "";
219+
startTransition(() => {
220+
setSearch(newValue);
221+
navigate(newValue === "" ? baseUrl : `${baseUrl}?search=${encodeURIComponent(newValue)}`, { replace });
222+
});
223+
setActiveIndex(0);
224+
ref.current?.focus();
225+
};
226+
80227
const onChange: React.ChangeEventHandler<HTMLInputElement> = ({ target: { value } }) => {
81228
const wasSearching = inputValue !== "";
82229
setInputValue(value);
230+
setActiveIndex(0);
83231
ownNavigateRef.current = true;
84232
// Push a history entry when starting a new search so back button works.
85233
// Replace while typing to avoid polluting history with every keystroke.
@@ -92,20 +240,37 @@ export function SearchBox({
92240
});
93241
};
94242

243+
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
244+
if (!showPopup) return;
245+
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, filteredTags.length - 1)); }
246+
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); }
247+
else if (e.key === "Enter" || e.key === "Tab") { if (filteredTags[activeIndex]) { e.preventDefault(); handleTagSelect(filteredTags[activeIndex].tag); } }
248+
else if (e.key === "Escape") { setIsFocused(false); }
249+
};
250+
95251
const ref = useCtrlFHook<HTMLInputElement>();
96252

97253
return (
98-
<SearchInput
99-
type="search"
100-
className={className}
101-
placeholder={placeholder}
102-
ref={ref}
103-
value={inputValue}
104-
onChange={onChange}
105-
aria-label="Search"
106-
spellCheck={false}
107-
autoCorrect="off"
108-
autoCapitalize="off"
109-
/>
254+
<SearchBoxWrapper className={className}>
255+
<SearchInput
256+
type="search"
257+
placeholder={placeholder}
258+
ref={ref}
259+
value={inputValue}
260+
onChange={onChange}
261+
onFocus={() => setIsFocused(true)}
262+
onBlur={() => setIsFocused(false)}
263+
onKeyDown={onKeyDown}
264+
aria-label="Search"
265+
aria-autocomplete="list"
266+
aria-expanded={showPopup}
267+
spellCheck={false}
268+
autoCorrect="off"
269+
autoCapitalize="off"
270+
/>
271+
{showPopup && (
272+
<SearchTagPopup tags={filteredTags} activeIndex={activeIndex} onSelect={handleTagSelect} />
273+
)}
274+
</SearchBoxWrapper>
110275
);
111276
}

0 commit comments

Comments
 (0)