diff --git a/ee/apps/den-web/app/(den)/_components/ui/combobox.tsx b/ee/apps/den-web/app/(den)/_components/ui/combobox.tsx new file mode 100644 index 000000000..1412a96ed --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/combobox.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent } from "react"; +import { + denDropdownChevronSlotClass, + denDropdownFieldBaseClass, + denDropdownFieldOpenClass, + denDropdownListClass, + denDropdownMenuBaseClass, + denDropdownRowActiveClass, + denDropdownRowBaseClass, + denDropdownRowIdleClass, + denDropdownRowSelectedClass, +} from "./dropdown-styles"; + +export type DenComboboxOption = { + value: string; + label: string; + description?: string; + meta?: string; + keywords?: string[]; +}; + +export type DenComboboxProps = { + value: string; + options: DenComboboxOption[]; + onChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyLabel?: string; + disabled?: boolean; + className?: string; + ariaLabel?: string; +}; + +function normalizeQuery(value: string) { + return value.trim().toLowerCase(); +} + +function getOptionSearchText(option: DenComboboxOption) { + return [option.label, option.value, option.description ?? "", ...(option.keywords ?? [])] + .join(" ") + .toLowerCase(); +} + +export function DenCombobox({ + value, + options, + onChange, + placeholder = "Select an option...", + searchPlaceholder = "Search...", + emptyLabel = "No options match", + disabled = false, + className, + ariaLabel = "Select option", +}: DenComboboxProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [hasTypedSinceOpen, setHasTypedSinceOpen] = useState(false); + const [activeValue, setActiveValue] = useState(null); + const rootRef = useRef(null); + const inputRef = useRef(null); + const optionRefs = useRef>({}); + const listboxId = useId(); + + const selectedOption = useMemo(() => options.find((option) => option.value === value) ?? null, [options, value]); + const selectedLabel = selectedOption?.label ?? ""; + const normalizedQuery = hasTypedSinceOpen ? normalizeQuery(query) : ""; + const filteredOptions = useMemo(() => { + if (!normalizedQuery) { + return options; + } + + return options.filter((option) => getOptionSearchText(option).includes(normalizedQuery)); + }, [normalizedQuery, options]); + + const activeIndex = activeValue ? filteredOptions.findIndex((option) => option.value === activeValue) : -1; + const activeDescendant = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined; + const inputValue = open ? (hasTypedSinceOpen ? query : selectedLabel) : selectedLabel; + + function focusInput(selectText = false) { + requestAnimationFrame(() => { + inputRef.current?.focus(); + if (selectText) { + inputRef.current?.select(); + } + }); + } + + function openCombobox({ selectText = false }: { selectText?: boolean } = {}) { + if (disabled) { + return; + } + + setOpen(true); + setHasTypedSinceOpen(false); + setQuery(selectedLabel); + if (selectText) { + focusInput(true); + } + } + + function closeCombobox({ focus = false }: { focus?: boolean } = {}) { + setOpen(false); + setActiveValue(null); + setHasTypedSinceOpen(false); + setQuery(selectedLabel); + if (focus) { + focusInput(); + } + } + + function selectOption(nextValue: string) { + const nextOption = options.find((option) => option.value === nextValue) ?? null; + onChange(nextValue); + setOpen(false); + setActiveValue(null); + setHasTypedSinceOpen(false); + setQuery(nextOption?.label ?? ""); + focusInput(); + } + + function moveActive(step: 1 | -1) { + if (!filteredOptions.length) { + return; + } + + if (!activeValue) { + setActiveValue(step === 1 ? filteredOptions[0]?.value ?? null : filteredOptions[filteredOptions.length - 1]?.value ?? null); + return; + } + + const currentIndex = filteredOptions.findIndex((option) => option.value === activeValue); + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + step + filteredOptions.length) % filteredOptions.length; + setActiveValue(filteredOptions[nextIndex]?.value ?? null); + } + + function handleInputFocus(event: FocusEvent) { + if (disabled) { + return; + } + + openCombobox(); + + if (!open && selectedLabel) { + const input = event.currentTarget; + requestAnimationFrame(() => input.select()); + } + } + + function handleInputChange(event: ChangeEvent) { + if (!open) { + setOpen(true); + } + setHasTypedSinceOpen(true); + setQuery(event.target.value); + } + + function handleInputKeyDown(event: KeyboardEvent) { + if (disabled) { + return; + } + + if (!open && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); + openCombobox(); + setHasTypedSinceOpen(true); + setQuery(event.key); + setActiveValue(null); + return; + } + + if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + openCombobox(); + setActiveValue( + event.key === "ArrowUp" + ? options[options.length - 1]?.value ?? null + : selectedOption?.value ?? options[0]?.value ?? null, + ); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + moveActive(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveActive(-1); + return; + } + + if (event.key === "Home") { + if (open) { + event.preventDefault(); + setActiveValue(filteredOptions[0]?.value ?? null); + } + return; + } + + if (event.key === "End") { + if (open) { + event.preventDefault(); + setActiveValue(filteredOptions[filteredOptions.length - 1]?.value ?? null); + } + return; + } + + if (event.key === "Enter") { + if (open && activeValue) { + event.preventDefault(); + selectOption(activeValue); + } + return; + } + + if (event.key === "Escape") { + if (open) { + event.preventDefault(); + closeCombobox({ focus: true }); + } + } + } + + useEffect(() => { + if (open) { + return; + } + + setQuery(selectedLabel); + setHasTypedSinceOpen(false); + }, [open, selectedLabel]); + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (rootRef.current?.contains(event.target as Node)) { + return; + } + closeCombobox(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + }; + }, [open, selectedLabel]); + + useEffect(() => { + if (!open) { + return; + } + + setActiveValue((current) => { + if (current && filteredOptions.some((option) => option.value === current)) { + return current; + } + if (selectedOption && filteredOptions.some((option) => option.value === selectedOption.value)) { + return selectedOption.value; + } + return filteredOptions[0]?.value ?? null; + }); + }, [filteredOptions, open, selectedOption]); + + useEffect(() => { + if (!open || !activeValue) { + return; + } + + const frame = requestAnimationFrame(() => { + optionRefs.current[activeValue]?.scrollIntoView({ block: "nearest" }); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [activeValue, open]); + + return ( +
{ + if (!open) { + return; + } + + requestAnimationFrame(() => { + const activeElement = document.activeElement; + if (rootRef.current && activeElement instanceof Node && !rootRef.current.contains(activeElement)) { + closeCombobox(); + } + }); + }} + > +
+ { + if (!open) { + openCombobox({ selectText: true }); + } + }} + onKeyDown={handleInputKeyDown} + className={[ + denDropdownFieldBaseClass, + "rounded-lg placeholder:text-gray-400", + open ? denDropdownFieldOpenClass : "", + disabled ? "cursor-not-allowed opacity-60" : "cursor-text", + className ?? "", + ] + .filter(Boolean) + .join(" ")} + /> + + +
+ + {open ? ( +
+
+ {filteredOptions.length ? ( + filteredOptions.map((option, index) => { + const selected = option.value === value; + const active = option.value === activeValue; + + return ( + + ); + }) + ) : ( +
+ {query ? `${emptyLabel} "${query}"` : emptyLabel} +
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/dropdown-styles.ts b/ee/apps/den-web/app/(den)/_components/ui/dropdown-styles.ts new file mode 100644 index 000000000..e3340d1b1 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/dropdown-styles.ts @@ -0,0 +1,24 @@ +export const denDropdownFieldBaseClass = [ + "w-full border border-gray-200 bg-white", + "h-[42px] px-4 pr-10 text-[14px] leading-5 text-gray-900", + "outline-none transition-all focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5", +].join(" "); + +export const denDropdownFieldOpenClass = "border-gray-300 ring-2 ring-gray-900/5"; + +export const denDropdownChevronSlotClass = "pointer-events-none absolute inset-y-0 right-3 flex items-center"; + +export const denDropdownMenuBaseClass = [ + "absolute left-0 top-[calc(100%+0.375rem)] z-30 w-full overflow-hidden rounded-lg", + "border border-gray-200 bg-white shadow-[0_20px_44px_-28px_rgba(15,23,42,0.22)]", +].join(" "); + +export const denDropdownListClass = "max-h-72 overflow-y-auto p-1.5"; + +export const denDropdownRowBaseClass = "flex w-full justify-between gap-3 rounded-lg px-3 py-2.5 text-left transition"; + +export const denDropdownRowIdleClass = "bg-white hover:bg-gray-50"; + +export const denDropdownRowActiveClass = "bg-gray-50"; + +export const denDropdownRowSelectedClass = "bg-gray-50/80"; diff --git a/ee/apps/den-web/app/(den)/_components/ui/select.tsx b/ee/apps/den-web/app/(den)/_components/ui/select.tsx index 3322c5fd4..e01722297 100644 --- a/ee/apps/den-web/app/(den)/_components/ui/select.tsx +++ b/ee/apps/den-web/app/(den)/_components/ui/select.tsx @@ -1,57 +1,383 @@ "use client"; -import { ChevronDown } from "lucide-react"; -import type { SelectHTMLAttributes } from "react"; +import { Check, ChevronDown } from "lucide-react"; +import { + Children, + isValidElement, + useEffect, + useId, + useMemo, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, + type ReactElement, + type ReactNode, + type SelectHTMLAttributes, +} from "react"; +import { + denDropdownChevronSlotClass, + denDropdownFieldBaseClass, + denDropdownFieldOpenClass, + denDropdownListClass, + denDropdownMenuBaseClass, + denDropdownRowActiveClass, + denDropdownRowBaseClass, + denDropdownRowIdleClass, + denDropdownRowSelectedClass, +} from "./dropdown-styles"; + +type DenSelectOption = { + value: string; + disabled: boolean; + content: ReactNode; +}; export type DenSelectProps = Omit, "disabled"> & { - /** - * Disables the select and dims it to 60 % opacity. - * Forwarded as the native `disabled` attribute. - */ disabled?: boolean; }; -/** - * DenSelect - * - * Consistent native select for all dashboard pages, matched to the - * Shared Workspaces compact field sizing used by DenInput. - * - * Defaults: rounded-lg · h-[42px] · px-4/pr-10 · text-[14px]/leading-5 - * Chevron: custom Lucide chevron replaces browser-native control chrome. - * No className needed at the call site - override only when necessary. - */ +function getOptionText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + + if (Array.isArray(node)) { + return node.map(getOptionText).join(""); + } + + if (isValidElement<{ children?: ReactNode }>(node)) { + return getOptionText(node.props.children); + } + + return ""; +} + +function createSelectEvent(value: string, name?: string) { + return { + target: { value, name: name ?? "" }, + currentTarget: { value, name: name ?? "" }, + } as ChangeEvent; +} + +function createBlurEvent(value: string, name?: string) { + return { + target: { value, name: name ?? "" }, + currentTarget: { value, name: name ?? "" }, + } as FocusEvent; +} + +function getFirstEnabledOption(options: DenSelectOption[]) { + return options.find((option) => !option.disabled) ?? null; +} + export function DenSelect({ + value, + defaultValue, + name, + id, disabled = false, className, children, - ...rest + onChange, + onBlur, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, }: DenSelectProps) { + const [open, setOpen] = useState(false); + const [activeValue, setActiveValue] = useState(null); + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue !== undefined ? String(defaultValue) : null); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef>({}); + const listboxId = useId(); + + const options = useMemo(() => { + return Children.toArray(children).flatMap((child) => { + if (!isValidElement(child) || child.type !== "option") { + return []; + } + + const option = child as ReactElement<{ + value?: string | number; + disabled?: boolean; + children?: ReactNode; + }>; + + return [ + { + value: option.props.value !== undefined ? String(option.props.value) : getOptionText(option.props.children), + disabled: option.props.disabled === true, + content: option.props.children, + } satisfies DenSelectOption, + ]; + }); + }, [children]); + + useEffect(() => { + if (value === undefined && uncontrolledValue === null && options.length) { + setUncontrolledValue(options[0]?.value ?? null); + } + }, [options, uncontrolledValue, value]); + + const selectedValue = value !== undefined ? String(value) : uncontrolledValue ?? ""; + const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0] ?? null; + + function focusTrigger() { + requestAnimationFrame(() => triggerRef.current?.focus()); + } + + function openSelect(preferredValue?: string | null) { + if (disabled) { + return; + } + + setOpen(true); + const nextActive = options.find((option) => option.value === preferredValue && !option.disabled) ?? getFirstEnabledOption(options); + setActiveValue(nextActive?.value ?? null); + } + + function closeSelect({ focus = false }: { focus?: boolean } = {}) { + setOpen(false); + setActiveValue(null); + if (focus) { + focusTrigger(); + } + } + + function commitValue(nextValue: string) { + const nextOption = options.find((option) => option.value === nextValue); + if (!nextOption || nextOption.disabled) { + return; + } + + if (value === undefined) { + setUncontrolledValue(nextValue); + } + + onChange?.(createSelectEvent(nextValue, name)); + closeSelect({ focus: true }); + } + + function moveActive(step: 1 | -1) { + const enabledOptions = options.filter((option) => !option.disabled); + if (!enabledOptions.length) { + return; + } + + if (!activeValue) { + setActiveValue(step === 1 ? enabledOptions[0]?.value ?? null : enabledOptions[enabledOptions.length - 1]?.value ?? null); + return; + } + + const currentIndex = enabledOptions.findIndex((option) => option.value === activeValue); + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + step + enabledOptions.length) % enabledOptions.length; + setActiveValue(enabledOptions[nextIndex]?.value ?? null); + } + + function handleTriggerKeyDown(event: KeyboardEvent) { + if (disabled) { + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (!open) { + openSelect(selectedOption?.value ?? null); + return; + } + moveActive(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + openSelect(selectedOption?.value ?? options[options.length - 1]?.value ?? null); + return; + } + moveActive(-1); + return; + } + + if (event.key === "Home" && open) { + event.preventDefault(); + setActiveValue(getFirstEnabledOption(options)?.value ?? null); + return; + } + + if (event.key === "End" && open) { + event.preventDefault(); + const enabledOptions = options.filter((option) => !option.disabled); + setActiveValue(enabledOptions[enabledOptions.length - 1]?.value ?? null); + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!open) { + openSelect(selectedOption?.value ?? null); + return; + } + + if (activeValue) { + commitValue(activeValue); + } + return; + } + + if (event.key === "Escape" && open) { + event.preventDefault(); + closeSelect({ focus: true }); + } + } + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (rootRef.current?.contains(event.target as Node)) { + return; + } + closeSelect(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + }; + }, [open]); + + useEffect(() => { + if (!open || !activeValue) { + return; + } + + const frame = requestAnimationFrame(() => { + optionRefs.current[activeValue]?.scrollIntoView({ block: "nearest" }); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [activeValue, open]); + + const activeIndex = activeValue ? options.findIndex((option) => option.value === activeValue) : -1; + const activeDescendant = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined; + return ( -
- : null} + + + + {open ? ( +
+
+ {options.map((option, index) => { + const selected = option.value === selectedValue; + const active = option.value === activeValue; + + return ( + + ); + })} +
+
+ ) : null}
); } diff --git a/ee/apps/den-web/app/(den)/_components/ui/selectable-row.tsx b/ee/apps/den-web/app/(den)/_components/ui/selectable-row.tsx new file mode 100644 index 000000000..1cd8168fe --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/selectable-row.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Check } from "lucide-react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +export type DenSelectableRowProps = Omit, "children" | "type"> & { + title: ReactNode; + description?: ReactNode; + selected?: boolean; + aside?: ReactNode; +}; + +export function DenSelectableRow({ + title, + description, + selected = false, + disabled = false, + aside, + className, + ...rest +}: DenSelectableRowProps) { + return ( + + ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx index be5e9641e..08fcbdd91 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx @@ -5,7 +5,9 @@ import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, CheckCircle2, Circle, Cpu, Search } from "lucide-react"; import { DenButton } from "../../../../_components/ui/button"; +import { DenCombobox } from "../../../../_components/ui/combobox"; import { DenInput } from "../../../../_components/ui/input"; +import { DenSelectableRow } from "../../../../_components/ui/selectable-row"; import { UnderlineTabs } from "../../../../_components/ui/tabs"; import { DenTextarea } from "../../../../_components/ui/textarea"; import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; @@ -164,6 +166,17 @@ export function LlmProviderEditorScreen({ llmProviderId }: { llmProviderId?: str return models.filter((model) => model.name.toLowerCase().includes(normalizedQuery) || model.id.toLowerCase().includes(normalizedQuery)); }, [catalogDetail?.models, modelQuery]); + const catalogProviderOptions = useMemo( + () => + catalogProviders.map((catalogProvider) => ({ + value: catalogProvider.id, + label: catalogProvider.name, + description: catalogProvider.id, + meta: `${catalogProvider.modelCount} ${catalogProvider.modelCount === 1 ? "model" : "models"}`, + })), + [catalogProviders], + ); + async function saveProvider() { if (!orgId) { setSaveError("Organization not found."); @@ -320,21 +333,18 @@ export function LlmProviderEditorScreen({ llmProviderId }: { llmProviderId?: str {source === "models_dev" ? (
-