1/**
2 * The sidebar header: one row holding search, project scope and new thread.
3 *
4 * Search owns the row's text and spans it. Project scope collapses to an icon
5 * that sits with new-project and new-thread as a segmented group at the end.
6 * The scope icon swaps to the project favicon while a project is selected,
7 * so the header still names the scope after the row that showed it is gone.
8 *
9 * The scope picker itself is passed in: its combobox state lives with the rest
10 * of the sidebar's scope logic. `searchFieldRef` lands on the search field so
11 * the picker's popup can anchor to that width rather than to its 28px trigger.
12 */
13import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react";
14import {
15 type ComponentProps,
16 type KeyboardEvent as ReactKeyboardEvent,
17 type MouseEvent as ReactMouseEvent,
18 type ReactNode,
19 type RefObject,
20} from "react";
21
22import { cn } from "~/lib/utils";
23import { Button } from "../ui/button";
24import { SidebarInput, SidebarMenuButton } from "../ui/sidebar";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
26
27export interface SidebarThreadHeaderProps {
28 /** Lands on the search field so a popup can anchor to its width. */
29 searchFieldRef?: RefObject<HTMLDivElement | null>;
30 /** Without projects there is nothing to scope, so those controls stay out. */
31 hasProjects: boolean;
32 /** The project scope combobox, rendered as the first icon of the group. */
33 projectScope: ReactNode;
34 onNewProject: () => void;
35 /** Receives the click so Shift+click can skip the project picker. */
36 onNewThread: (event: ReactMouseEvent) => void;
37 newThreadDisabled: boolean;
38 newThreadShortcutLabel: string | null | undefined;
39 newThreadInProjectShortcutLabel: string | null | undefined;
40 /** Shift+click only matters once there is more than one project to pick. */
41 showNewThreadInProjectHint: boolean;
42 searchInputRef: RefObject<HTMLInputElement | null>;
43 searchQuery: string;
44 onSearchQueryChange: (value: string) => void;
45 onSearchKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
46 isSearching: boolean;
47 searchResultCount: number;
48 activeSearchResultIndex: number;
49 onClearSearch: () => void;
50}
51
52export function SidebarThreadHeader({
53 searchFieldRef,
54 hasProjects,
55 projectScope,
56 onNewProject,
57 onNewThread,
58 newThreadDisabled,
59 newThreadShortcutLabel,
60 newThreadInProjectShortcutLabel,
61 showNewThreadInProjectHint,
62 searchInputRef,
63 searchQuery,
64 onSearchQueryChange,
65 onSearchKeyDown,
66 isSearching,
67 searchResultCount,
68 activeSearchResultIndex,
69 onClearSearch,
70}: SidebarThreadHeaderProps) {
71 const resultsVisible = isSearching && searchResultCount > 0;
72 // Results shrink as the query narrows, so the active index can outrun the
73 // list; pointing aria-activedescendant at a removed option strands the
74 // screen reader on nothing.
75 const activeResultExists = resultsVisible && activeSearchResultIndex < searchResultCount;
76 const newThreadLabel = newThreadShortcutLabel
77 ? `New thread (${newThreadShortcutLabel})`
78 : "New thread";
79
80 return (
81 <div className="flex items-center gap-1">
82 <div
83 ref={searchFieldRef}
84 className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
85 >
86 <SearchIcon className="size-4 shrink-0 text-(--sidebar-icon-color)" />
87 <SidebarInput
88 ref={searchInputRef}
89 nativeInput
90 type="search"
91 value={searchQuery}
92 onChange={(event) => onSearchQueryChange(event.currentTarget.value)}
93 onKeyDown={onSearchKeyDown}
94 placeholder="Search"
95 aria-label="Search threads"
96 role="combobox"
97 aria-autocomplete="list"
98 aria-expanded={resultsVisible}
99 aria-controls={resultsVisible ? "sidebar-thread-search-results" : undefined}
100 aria-activedescendant={
101 activeResultExists
102 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
103 : undefined
104 }
105 className="min-w-0 flex-1"
106 />
107 {isSearching ? (
108 <Button
109 type="button"
110 size="icon-micro"
111 variant="ghost-muted"
112 className="shrink-0"
113 aria-label="Clear thread search"
114 onClick={() => {
115 onClearSearch();
116 searchInputRef.current?.focus();
117 }}
118 >
119 <XIcon className="size-3" />
120 </Button>
121 ) : null}
122 </div>
123 {/* Unfilled like the search field beside it: the buttons carry their own
124 hover states, and a background well reads far louder on themed
125 palettes than on the base light and dark ones. */}
126 <div className="flex shrink-0 items-center">
127 {hasProjects ? (
128 <>
129 {projectScope}
130 <SidebarHeaderIconButton label="Add project" onClick={onNewProject}>
131 <FolderPlusIcon />
132 </SidebarHeaderIconButton>
133 </>
134 ) : null}
135 <SidebarHeaderIconButton
136 label="New thread"
137 tooltip={
138 showNewThreadInProjectHint ? (
139 <span className="flex flex-col gap-0.5">
140 <span>{newThreadLabel}</span>
141 <span className="text-muted-foreground">
142 New thread in current project: Shift+click
143 {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
144 </span>
145 </span>
146 ) : (
147 newThreadLabel
148 )
149 }
150 disabled={newThreadDisabled}
151 onClick={onNewThread}
152 >
153 <SquarePenIcon />
154 </SidebarHeaderIconButton>
155 </div>
156 </div>
157 );
158}
159
160/**
161 * Icon button with a tooltip, sized for the header's segmented pair. Spreads
162 * unknown props through so it can serve as a popup trigger's render target,
163 * which injects its own handlers, ref and aria state.
164 */
165export function SidebarHeaderIconButton({
166 label,
167 tooltip = label,
168 className,
169 children,
170 ...rest
171}: {
172 /** Accessible name; also the tooltip unless `tooltip` says more. */
173 label: string;
174 tooltip?: ReactNode;
175 className?: string | undefined;
176 children?: ReactNode;
177} & Omit<
178 ComponentProps<typeof SidebarMenuButton>,
179 "children" | "className" | "tooltip" | "isActive" | "aria-label"
180>) {
181 return (
182 <Tooltip>
183 <TooltipTrigger
184 render={
185 <SidebarMenuButton
186 size="icon"
187 type="button"
188 aria-label={label}
189 {...rest}
190 className={cn("relative size-7 shrink-0", className)}
191 />
192 }
193 >
194 {children}
195 {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */}
196 <span
197 aria-hidden
198 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
199 />
200 </TooltipTrigger>
201 <TooltipPopup side="top">{tooltip}</TooltipPopup>
202 </Tooltip>
203 );
204}