diff --git a/src/components/ui/dropdown.tsx b/src/components/ui/dropdown.tsx new file mode 100644 index 0000000..44fa482 --- /dev/null +++ b/src/components/ui/dropdown.tsx @@ -0,0 +1,152 @@ +import { TextAttributes, type MouseEvent } from "@opentui/core"; +import { useBindings } from "@opentui/keymap/solid"; +import { createEffect, createSignal, For, Show } from "solid-js"; +import { EmptyBorderChars, HIGHLIGHT_ACCENT_COLOR } from "../../constants/constants"; + +export interface DropdownOption { + label: string; + value: T; +} + +interface DropdownProps { + options: DropdownOption[]; + value: T; + onChange: (value: T) => void; + open: boolean; + onOpenChange: (open: boolean) => void; + focused?: boolean; + placeholder?: string; + width?: number | "auto" | `${number}%`; + marginTop?: number; + marginBottom?: number; +} + +const LIST_Z = 1001; +// Constant so the reserved flow space doesn't shift when the dropdown toggles. +const TRIGGER_HEIGHT = 3; + +export function Dropdown(props: DropdownProps) { + const [hovered, setHovered] = createSignal(false); + const [highlight, setHighlight] = createSignal(0); + + const isHighlighted = () => props.focused || hovered() || props.open; + + const selectedLabel = () => + props.options.find((option) => option.value === props.value)?.label ?? + props.placeholder ?? + "Select..."; + + const close = () => props.onOpenChange(false); + + const clampHighlight = (index: number) => Math.max(0, Math.min(index, props.options.length - 1)); + + const moveHighlight = (delta: number) => { + setHighlight((current) => clampHighlight(current + delta)); + }; + + const selectAt = (index: number) => { + const option = props.options[index]; + if (option) { + props.onChange(option.value); + } + close(); + }; + + const toggleOpen = () => props.onOpenChange(!props.open); + + createEffect(() => { + if (!props.open) return; + const currentIndex = props.options.findIndex((option) => option.value === props.value); + setHighlight(currentIndex < 0 ? 0 : currentIndex); + }); + + useBindings(() => ({ + priority: 200, + bindings: props.open + ? [ + { key: "down", cmd: () => moveHighlight(1) }, + { key: "j", cmd: () => moveHighlight(1) }, + { key: "up", cmd: () => moveHighlight(-1) }, + { key: "k", cmd: () => moveHighlight(-1) }, + { key: "return", cmd: () => selectAt(highlight()) }, + { key: "escape", cmd: close }, + { key: "tab", cmd: close }, + { key: "shift+tab", cmd: close }, + ] + : [], + })); + + return ( + + setHovered(true)} + onMouseOut={() => setHovered(false)} + > + + + + + + + + {(option, index) => { + const active = () => highlight() === index(); + return ( + setHighlight(index())} + onMouseDown={(e?: MouseEvent) => { + e?.stopPropagation(); + selectAt(index()); + }} + > + + + ); + }} + + + + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index a90cf3e..daae97e 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,5 +1,7 @@ export { Button } from "./button"; export { ButtonRow } from "./button-row"; +export { Dropdown } from "./dropdown"; +export type { DropdownOption } from "./dropdown"; export { FileList } from "./file-list"; export { Label } from "./label"; export { PDFPreviewFrame } from "./pdf-preview-frame"; diff --git a/src/components/ui/text-input.tsx b/src/components/ui/text-input.tsx index 654b4bd..91cf6ed 100644 --- a/src/components/ui/text-input.tsx +++ b/src/components/ui/text-input.tsx @@ -2,10 +2,10 @@ import { TextAttributes } from "@opentui/core"; import type { Accessor, Setter } from "solid-js"; import { EmptyBorderChars, HIGHLIGHT_ACCENT_COLOR } from "../../constants/constants"; -interface TextInputProps { +interface TextInputProps { label: string; - value: Accessor; - onInput: Setter; + value: Accessor; + onInput: Setter; placeholder?: string; focused: boolean; onFocus: () => void; @@ -13,10 +13,16 @@ interface TextInputProps { flexGrow?: number; marginTop?: number; marginBottom?: number; + marginLeft?: number; + marginRight?: number; + paddingTop?: number; + paddingBottom?: number; + paddingLeft?: number; + paddingRight?: number; width?: number | "auto" | `${number}%`; } -export function TextInput(props: TextInputProps) { +export function TextInput(props: TextInputProps) { const borderColor = () => (props.focused ? HIGHLIGHT_ACCENT_COLOR : "#34495e"); return ( @@ -29,24 +35,24 @@ export function TextInput(props: TextInputProps) { flexGrow={props.flexGrow} width={props.width ?? "100%"} > - +