171 lines
3.7 KiB
TypeScript
171 lines
3.7 KiB
TypeScript
export interface ProjectPathMatch {
|
||
name: string;
|
||
path: string;
|
||
}
|
||
|
||
export interface ProjectPathInputProps {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||
suggestionTail: string;
|
||
matchList: ProjectPathMatch[];
|
||
selectedMatch: number;
|
||
onSelectMatch: (index: number) => void;
|
||
onAcceptMatch: (path: string) => void;
|
||
onCloseSuggestions: () => void;
|
||
currentPartial: string;
|
||
}
|
||
|
||
function renderHighlightedMatch(text: string, query: string) {
|
||
if (!query) return text;
|
||
let qIndex = 0;
|
||
const lowerQuery = query.toLowerCase();
|
||
const counts = new Map<string, number>();
|
||
return text.split("").map((char) => {
|
||
const isMatch =
|
||
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
|
||
if (isMatch) {
|
||
qIndex += 1;
|
||
}
|
||
const count = counts.get(char) ?? 0;
|
||
counts.set(char, count + 1);
|
||
return (
|
||
<span
|
||
key={`${char}-${count}`}
|
||
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
|
||
>
|
||
{char}
|
||
</span>
|
||
);
|
||
});
|
||
}
|
||
|
||
export function ProjectPathInput({
|
||
value,
|
||
onChange,
|
||
onKeyDown,
|
||
suggestionTail,
|
||
matchList,
|
||
selectedMatch,
|
||
onSelectMatch,
|
||
onAcceptMatch,
|
||
onCloseSuggestions,
|
||
currentPartial,
|
||
}: ProjectPathInputProps) {
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
marginTop: "12px",
|
||
marginBottom: "170px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
padding: "10px",
|
||
color: "#aaa",
|
||
fontFamily: "monospace",
|
||
whiteSpace: "pre",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{value}
|
||
{suggestionTail}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
placeholder="/path/to/project"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
style={{
|
||
width: "100%",
|
||
padding: "10px",
|
||
fontFamily: "monospace",
|
||
background: "transparent",
|
||
position: "relative",
|
||
zIndex: 1,
|
||
}}
|
||
/>
|
||
{matchList.length > 0 && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: "100%",
|
||
left: 0,
|
||
right: 0,
|
||
marginTop: "6px",
|
||
border: "1px solid #ddd",
|
||
borderRadius: "6px",
|
||
overflow: "hidden",
|
||
background: "#fff",
|
||
fontFamily: "monospace",
|
||
height: "160px",
|
||
overflowY: "auto",
|
||
boxSizing: "border-box",
|
||
zIndex: 2,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "flex-end",
|
||
alignItems: "center",
|
||
padding: "4px 6px",
|
||
borderBottom: "1px solid #eee",
|
||
background: "#fafafa",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label="Close suggestions"
|
||
onClick={onCloseSuggestions}
|
||
style={{
|
||
width: "24px",
|
||
height: "24px",
|
||
borderRadius: "4px",
|
||
border: "1px solid #ddd",
|
||
background: "#fff",
|
||
cursor: "pointer",
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{matchList.map((match, index) => {
|
||
const isSelected = index === selectedMatch;
|
||
return (
|
||
<button
|
||
key={match.path}
|
||
type="button"
|
||
onMouseEnter={() => onSelectMatch(index)}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
onSelectMatch(index);
|
||
onAcceptMatch(match.path);
|
||
}}
|
||
style={{
|
||
width: "100%",
|
||
textAlign: "left",
|
||
padding: "6px 8px",
|
||
border: "none",
|
||
background: isSelected ? "#f0f0f0" : "transparent",
|
||
cursor: "pointer",
|
||
fontFamily: "inherit",
|
||
}}
|
||
>
|
||
{renderHighlightedMatch(match.name, currentPartial)}/
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|