Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions web/src/components/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ interface ComboboxProps {
/** Called when an option is explicitly picked from the list. */
onSelect: (id: string) => void;
placeholder?: string;
/** Overrides the input's default styling (compact/inline variants). */
inputClassName?: string;
/** Overrides the popover's max-height (e.g. for inputs low on the sheet). */
menuMaxHeightClassName?: string;
}

/**
* A text input with a suggestion popover. Typing filters the options;
* text that matches nothing behaves exactly like a plain Input.
*/
export default function Combobox({ value, onChange, options, onSelect, placeholder }: ComboboxProps) {
export default function Combobox({ value, onChange, options, onSelect, placeholder, inputClassName, menuMaxHeightClassName }: ComboboxProps) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const [typing, setTyping] = useState(false);
Expand Down Expand Up @@ -54,11 +58,26 @@ export default function Combobox({ value, onChange, options, onSelect, placehold
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);

function reposition() {
if (!inputRef.current) return;
const rect = inputRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}

// The popover is position:fixed, so scrolling any ancestor (page or the
// sheet body) would leave it stranded — track the input while open.
useEffect(() => {
if (!open) return;
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open]);

function show() {
if (inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}
reposition();
setHighlighted(0);
setOpen(true);
}
Expand Down Expand Up @@ -103,7 +122,7 @@ export default function Combobox({ value, onChange, options, onSelect, placehold
role="combobox"
aria-expanded={open && filtered.length > 0}
autoComplete="off"
className="w-full px-4 py-3 pr-10 bg-surface-raised border border-border rounded-lg text-text text-sm outline-none transition-colors focus:border-border-focus focus:shadow-[0_0_0_3px_var(--color-primary-ring)]"
className={inputClassName ?? "w-full px-4 py-3 pr-10 bg-surface-raised border border-border rounded-lg text-text text-sm outline-none transition-colors focus:border-border-focus focus:shadow-[0_0_0_3px_var(--color-primary-ring)]"}
/>
<button
type="button"
Expand All @@ -130,8 +149,8 @@ export default function Combobox({ value, onChange, options, onSelect, placehold
createPortal(
<div
ref={listRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 max-h-64 overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width, scrollbarWidth: "thin", scrollbarColor: "var(--color-border) var(--color-surface)" }}
className={`fixed z-50 bg-surface border border-border rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 overflow-y-auto thin-scrollbar ${menuMaxHeightClassName ?? "max-h-64"}`}
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{filtered.map((option, i) => (
<button
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/CreatableSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ export default function CreatableSelect({ values, onChange, options = [], placeh
createPortal(
<div
ref={listRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 max-h-64 overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width, scrollbarWidth: "thin", scrollbarColor: "var(--color-border) var(--color-surface)" }}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 max-h-64 overflow-y-auto thin-scrollbar"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{items.map((item, i) => {
if (item.type === "create") {
Expand Down
177 changes: 177 additions & 0 deletions web/src/components/TemplateInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";

interface TemplateInputProps {
value: string;
onChange: (text: string) => void;
/** Credential keys suggested while the caret sits inside a {{ … placeholder. */
suggestions: string[];
placeholder?: string;
/** Called on Enter when the suggestion popover is closed. */
onEnter?: () => void;
}

/**
* Finds the unclosed {{ token the caret is inside, if any: the last "{{"
* before the caret with no closing brace between it and the caret.
*/
function openToken(value: string, caret: number): { start: number; partial: string } | null {
const before = value.slice(0, caret);
const start = before.lastIndexOf("{{");
if (start === -1) return null;
const inner = before.slice(start + 2);
if (inner.includes("}")) return null;
return { start, partial: inner.trim() };
}

/**
* A text input for header-value templates. Typing "{{" opens a popover
* listing stored credential keys; picking one inserts "{{ KEY }}" at the
* caret. Outside a placeholder it behaves exactly like a plain Input.
*/
export default function TemplateInput({
value,
onChange,
suggestions,
placeholder,
onEnter,
}: TemplateInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [caret, setCaret] = useState(0);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
// Caret restore target after a programmatic insert, applied post-render.
const pendingCaret = useRef<number | null>(null);

const token = focused && !dismissed ? openToken(value, caret) : null;
const query = token?.partial.toLowerCase() ?? "";
const matches = token
? suggestions.filter((k) => !query || k.toLowerCase().includes(query))
: [];
const open = matches.length > 0;

useEffect(() => {
setDismissed(false);
}, [value]);

useEffect(() => {
if (highlighted >= matches.length) setHighlighted(0);
}, [matches.length, highlighted]);

// The popover is position:fixed, so scrolling any ancestor (page or the
// sheet body) would leave it stranded — track the input while open.
useEffect(() => {
if (!open) return;
function reposition() {
if (!inputRef.current) return;
const rect = inputRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}
reposition();
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open]);

useEffect(() => {
if (pendingCaret.current === null || !inputRef.current) return;
inputRef.current.setSelectionRange(pendingCaret.current, pendingCaret.current);
setCaret(pendingCaret.current);
pendingCaret.current = null;
}, [value]);

function syncCaret(el: HTMLInputElement) {
setCaret(el.selectionStart ?? 0);
}

function insert(key: string) {
const t = openToken(value, caret);
if (!t) return;
// Consume a closing "}}" the user may have already typed after the caret.
const after = value.slice(caret).replace(/^\s*\}\}/, "");
const inserted = `{{ ${key} }}`;
pendingCaret.current = t.start + inserted.length;
onChange(value.slice(0, t.start) + inserted + after);
inputRef.current?.focus();
}

function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!open) {
if (e.key === "Enter") onEnter?.();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((h) => (h + 1) % matches.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((h) => (h - 1 + matches.length) % matches.length);
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insert(matches[Math.min(highlighted, matches.length - 1)]);
} else if (e.key === "Escape") {
e.stopPropagation();
setDismissed(true);
}
}

return (
<div className="relative w-full">
<input
ref={inputRef}
value={value}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => {
onChange(e.target.value);
syncCaret(e.target);
setHighlighted(0);
}}
onSelect={(e) => syncCaret(e.currentTarget)}
onFocus={(e) => {
setFocused(true);
syncCaret(e.currentTarget);
}}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={open}
className="w-full px-4 py-3 bg-surface-raised border border-border rounded-lg text-text text-sm outline-none transition-colors focus:border-border-focus focus:shadow-[0_0_0_3px_var(--color-primary-ring)]"
/>
{open &&
createPortal(
<div
ref={listRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.12)] py-1 max-h-64 overflow-y-auto thin-scrollbar"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="px-4 py-1.5 text-[11px] font-mono uppercase tracking-[0.18em] text-text-dim">
Credentials
</div>
{matches.map((key, i) => (
<button
key={key}
type="button"
// mousedown so selection wins over the input's blur handling
onMouseDown={(e) => {
e.preventDefault();
insert(key);
}}
onMouseEnter={() => setHighlighted(i)}
className={`w-full text-left px-4 py-2 font-mono text-sm text-text transition-colors ${i === highlighted ? "bg-bg" : ""}`}
>
{key}
</button>
))}
</div>,
document.body
)}
</div>
);
}
Loading