refactor(webui): unify floating controls
This commit is contained in:
@@ -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 };
|
||||
@@ -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]";
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user