Skip to content

Commit afbba30

Browse files
feat(search): add responsive search with manual expansion (#2336)
* feat(search): add responsive search with manual expansion * refactor --------- Co-authored-by: Yujong Lee <[email protected]>
1 parent 0aca8ae commit afbba30

File tree

5 files changed

+89
-47
lines changed

5 files changed

+89
-47
lines changed

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"tinybase": "^6.7.5",
114114
"tinytick": "^1.2.8",
115115
"unified": "^11.0.5",
116+
"usehooks-ts": "^3.1.1",
116117
"vfile": "^6.0.3",
117118
"wavesurfer.js": "^7.12.1",
118119
"xstate": "^5.25.0",

apps/desktop/src/components/main/body/index.tsx

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Reorder } from "motion/react";
99
import { useCallback, useEffect, useRef, useState } from "react";
1010
import { useHotkeys } from "react-hotkeys-hook";
11+
import { useResizeObserver } from "usehooks-ts";
1112
import { useShallow } from "zustand/shallow";
1213

1314
import { Button } from "@hypr/ui/components/ui/button";
@@ -100,35 +101,11 @@ function Header({ tabs }: { tabs: Tab[] }) {
100101
);
101102
const tabsScrollContainerRef = useRef<HTMLDivElement>(null);
102103
const handleNewEmptyTab = useNewEmptyTab();
103-
const [scrollState, setScrollState] = useState({
104-
atStart: true,
105-
atEnd: true,
106-
});
107-
108-
const updateScrollState = useCallback(() => {
109-
const container = tabsScrollContainerRef.current;
110-
if (!container) return;
111-
112-
const { scrollLeft, scrollWidth, clientWidth } = container;
113-
const atStart = scrollLeft <= 1;
114-
const atEnd = scrollLeft + clientWidth >= scrollWidth - 1;
115-
setScrollState({ atStart, atEnd });
116-
}, []);
117-
118-
useEffect(() => {
119-
const container = tabsScrollContainerRef.current;
120-
if (!container) return;
121-
122-
updateScrollState();
123-
container.addEventListener("scroll", updateScrollState);
124-
const resizeObserver = new ResizeObserver(updateScrollState);
125-
resizeObserver.observe(container);
126-
127-
return () => {
128-
container.removeEventListener("scroll", updateScrollState);
129-
resizeObserver.disconnect();
130-
};
131-
}, [updateScrollState, tabs]);
104+
const [isSearchManuallyExpanded, setIsSearchManuallyExpanded] =
105+
useState(false);
106+
const { ref: rightContainerRef, hasSpace: hasSpaceForSearch } =
107+
useHasSpaceForSearch();
108+
const scrollState = useScrollState(tabsScrollContainerRef, [tabs]);
132109

133110
const setTabRef = useScrollActiveTabIntoView(tabs);
134111
useTabsShortcuts();
@@ -146,6 +123,7 @@ function Header({ tabs }: { tabs: Tab[] }) {
146123
<Button
147124
size="icon"
148125
variant="ghost"
126+
className="shrink-0"
149127
onClick={() => leftsidebar.setExpanded(true)}
150128
>
151129
<PanelLeftOpenIcon size={16} className="text-neutral-600" />
@@ -225,21 +203,27 @@ function Header({ tabs }: { tabs: Tab[] }) {
225203
</div>
226204

227205
<div
206+
ref={rightContainerRef}
228207
data-tauri-drag-region
229208
className="flex-1 flex h-full items-center justify-between"
230209
>
231-
<Button
232-
onClick={handleNewEmptyTab}
233-
variant="ghost"
234-
size="icon"
235-
className="text-neutral-600"
236-
>
237-
<PlusIcon size={16} />
238-
</Button>
210+
{!(isSearchManuallyExpanded && !hasSpaceForSearch) && (
211+
<Button
212+
onClick={handleNewEmptyTab}
213+
variant="ghost"
214+
size="icon"
215+
className="text-neutral-600"
216+
>
217+
<PlusIcon size={16} />
218+
</Button>
219+
)}
239220

240-
<div className="flex items-center gap-1 h-full">
221+
<div className="flex items-center gap-1 h-full ml-auto">
241222
<Update />
242-
<Search />
223+
<Search
224+
hasSpace={hasSpaceForSearch}
225+
onManualExpandChange={setIsSearchManuallyExpanded}
226+
/>
243227
</div>
244228
</div>
245229
</div>
@@ -552,6 +536,52 @@ export function StandardTabWrapper({
552536
);
553537
}
554538

539+
function useHasSpaceForSearch() {
540+
const ref = useRef<HTMLDivElement>(null);
541+
const { width = 0 } = useResizeObserver({
542+
ref: ref as React.RefObject<HTMLDivElement>,
543+
});
544+
return { ref, hasSpace: width >= 220 };
545+
}
546+
547+
function useScrollState(
548+
ref: React.RefObject<HTMLDivElement | null>,
549+
deps: unknown[] = [],
550+
) {
551+
const [scrollState, setScrollState] = useState({
552+
atStart: true,
553+
atEnd: true,
554+
});
555+
556+
const updateScrollState = useCallback(() => {
557+
const container = ref.current;
558+
if (!container) return;
559+
560+
const { scrollLeft, scrollWidth, clientWidth } = container;
561+
setScrollState({
562+
atStart: scrollLeft <= 1,
563+
atEnd: scrollLeft + clientWidth >= scrollWidth - 1,
564+
});
565+
}, [ref]);
566+
567+
useEffect(() => {
568+
const container = ref.current;
569+
if (!container) return;
570+
571+
updateScrollState();
572+
container.addEventListener("scroll", updateScrollState);
573+
const resizeObserver = new ResizeObserver(updateScrollState);
574+
resizeObserver.observe(container);
575+
576+
return () => {
577+
container.removeEventListener("scroll", updateScrollState);
578+
resizeObserver.disconnect();
579+
};
580+
}, [updateScrollState, ...deps]);
581+
582+
return scrollState;
583+
}
584+
555585
function useScrollActiveTabIntoView(tabs: Tab[]) {
556586
const tabRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
557587

apps/desktop/src/components/main/body/search.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useMediaQuery } from "@uidotdev/usehooks";
21
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react";
32
import { useEffect, useState } from "react";
43

@@ -9,14 +8,22 @@ import { cn } from "@hypr/utils";
98
import { useSearch } from "../../../contexts/search/ui";
109
import { useCmdKeyPressed } from "../../../hooks/useCmdKeyPressed";
1110

12-
export function Search() {
13-
const hasSpace = useMediaQuery("(min-width: 900px)");
14-
11+
export function Search({
12+
hasSpace,
13+
onManualExpandChange,
14+
}: {
15+
hasSpace: boolean;
16+
onManualExpandChange?: (isManuallyExpanded: boolean) => void;
17+
}) {
1518
const { focus, setFocusImpl, inputRef } = useSearch();
1619
const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);
1720

1821
const shouldShowExpanded = hasSpace || isManuallyExpanded;
1922

23+
useEffect(() => {
24+
onManualExpandChange?.(isManuallyExpanded);
25+
}, [isManuallyExpanded, onManualExpandChange]);
26+
2027
useEffect(() => {
2128
if (!shouldShowExpanded) {
2229
setFocusImpl(() => {
@@ -49,6 +56,7 @@ export function Search() {
4956
if (shouldShowExpanded) {
5057
return (
5158
<ExpandedSearch
59+
hasSpace={hasSpace}
5260
onFocus={handleExpandedFocus}
5361
onBlur={handleExpandedBlur}
5462
/>
@@ -79,26 +87,26 @@ function CollapsedSearch({ onClick }: { onClick: () => void }) {
7987
}
8088

8189
function ExpandedSearch({
90+
hasSpace,
8291
onFocus,
8392
onBlur,
8493
}: {
94+
hasSpace: boolean;
8595
onFocus?: () => void;
8696
onBlur?: () => void;
8797
}) {
8898
const { query, setQuery, isSearching, isIndexing, inputRef } = useSearch();
8999
const [isFocused, setIsFocused] = useState(false);
90100
const isCmdPressed = useCmdKeyPressed();
91-
const hasSpace = useMediaQuery("(min-width: 900px)");
92101

93102
const showLoading = isSearching || isIndexing;
94103
const showShortcut = isCmdPressed && !query;
95104

96-
// On narrow screens, always show the focused width when expanded
97105
const width = hasSpace
98106
? isFocused
99107
? "w-[250px]"
100108
: "w-[180px]"
101-
: "w-[250px]";
109+
: "w-[180px]";
102110

103111
return (
104112
<div

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import tseslint from "typescript-eslint";
44

55
export default defineConfig(
66
{
7-
ignores: ["**/target/**", "**/dist/**", "**/node_modules/**"],
7+
ignores: ["**/target/**", "**/dist/**", "**/node_modules/**", "**/*.gen.*"],
88
},
99
{
1010
files: ["apps/web/**/*.{ts,tsx}", "apps/desktop/**/*.{ts,tsx}"],

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)