refactor(webui): unify floating controls

This commit is contained in:
Xubin Ren
2026-08-04 18:05:34 +08:00
parent faff0ac2fa
commit 7819cef7bd
16 changed files with 777 additions and 257 deletions
+137
View File
@@ -0,0 +1,137 @@
import * as React from "react";
import {
floatingItemClassName,
floatingItemFocusClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
interface ComboboxNavigationOptions {
open: boolean;
values: readonly string[];
selectedValue?: string;
onSelect: (value: string) => void;
onClose: () => void;
}
export function useComboboxNavigation({
open,
values,
selectedValue,
onSelect,
onClose,
}: ComboboxNavigationOptions) {
const listboxId = React.useId();
const [activeValue, setActiveValue] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
setActiveValue((current) => {
if (current && values.includes(current)) return current;
if (selectedValue && values.includes(selectedValue)) return selectedValue;
return values[0] ?? null;
});
}, [open, selectedValue, values]);
const activeIndex = activeValue ? values.indexOf(activeValue) : -1;
const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
React.useEffect(() => {
if (!activeOptionId) return;
const option = document.getElementById(activeOptionId);
option?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const move = (offset: number) => {
if (!values.length) return;
const nextIndex = activeIndex < 0
? offset > 0 ? 0 : values.length - 1
: (activeIndex + offset + values.length) % values.length;
setActiveValue(values[nextIndex]);
};
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Home":
if (values.length) {
event.preventDefault();
setActiveValue(values[0]);
}
break;
case "End":
if (values.length) {
event.preventDefault();
setActiveValue(values[values.length - 1]);
}
break;
case "Enter":
if (activeValue) {
event.preventDefault();
onSelect(activeValue);
}
break;
case "Escape":
event.preventDefault();
onClose();
break;
}
};
const inputProps = {
role: "combobox" as const,
"aria-autocomplete": "list" as const,
"aria-controls": listboxId,
"aria-expanded": open,
"aria-activedescendant": activeOptionId,
onKeyDown: onInputKeyDown,
};
const listProps = {
id: listboxId,
role: "listbox" as const,
};
const getOptionProps = (value: string) => {
const index = values.indexOf(value);
return {
id: `${listboxId}-option-${index}`,
role: "option" as const,
"aria-selected": value === selectedValue,
"data-highlighted": value === activeValue ? "" : undefined,
tabIndex: -1,
onPointerMove: () => setActiveValue(value),
onClick: () => onSelect(value),
};
};
return { inputProps, listProps, getOptionProps };
}
const ComboboxOption = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, type = "button", ...props }, ref) => (
<button
ref={ref}
type={type}
className={cn(
floatingItemClassName,
floatingItemFocusClassName,
"w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground aria-selected:bg-muted/80",
className,
)}
{...props}
/>
));
ComboboxOption.displayName = "ComboboxOption";
export { ComboboxOption };
+32 -13
View File
@@ -2,6 +2,12 @@ import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import {
floatingItemClassName,
floatingItemFocusClassName,
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -11,11 +17,8 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
@@ -46,7 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
menuContentClassName,
floatingSurfaceClassName,
"min-w-[10rem]",
className,
)}
{...props}
@@ -68,8 +72,9 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
menuContentClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
"min-w-[10rem]",
className,
)}
{...props}
@@ -82,13 +87,15 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
tone?: "default" | "destructive";
}
>(({ className, inset, ...props }, ref) => (
>(({ className, inset, tone = "default", ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
menuItemClassName,
inset && "pl-8",
tone === "destructive" && "text-destructive focus:text-destructive",
className,
)}
{...props}
@@ -123,20 +130,32 @@ DropdownMenuCheckboxItem.displayName =
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
indicator?: "dot" | "check";
indicatorPosition?: "start" | "end";
}
>(({ className, children, indicator = "dot", indicatorPosition = "start", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
menuItemClassName,
"pl-8 pr-2.5",
indicatorPosition === "start" ? "pl-8 pr-2.5" : "pl-2.5 pr-8",
className,
)}
{...props}
>
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<span
className={cn(
"absolute flex h-3.5 w-3.5 items-center justify-center",
indicatorPosition === "start" ? "left-2.5" : "right-2.5",
)}
>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
{indicator === "check" ? (
<Check className="h-3.5 w-3.5" />
) : (
<Circle className="h-2 w-2 fill-current" />
)}
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -0,0 +1,14 @@
export const floatingSurfaceVisualClassName =
"rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
export const floatingSurfaceClassName =
`${floatingSurfaceVisualClassName} z-50 max-h-[min(var(--radix-popper-available-height),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
export const floatingSurfaceMotionClassName =
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
export const floatingItemClassName =
"relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
export const floatingItemFocusClassName =
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
interface PopoverContentProps
extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
portalContainer?: HTMLElement | null;
}
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
<PopoverPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export {
Popover,
PopoverContent,
PopoverTrigger,
};