refactor(platform): 统一控件 - 收敛下拉选择与按钮输入交互样式
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { useState, type ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2, type LucideIcon } from "lucide-react";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { cn } from "@/lib/utils";
|
||||
* 明暗:全部颜色走 token;active 按压底用 bg-surface-active,明暗自动适配。
|
||||
*/
|
||||
export const iconButtonVariants = cva(
|
||||
["inline-flex shrink-0 items-center justify-center rounded-md", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45", "motion-safe:active:scale-[0.96]"],
|
||||
["inline-flex shrink-0 items-center justify-center rounded-md", "outline-none focus-visible:outline-none data-[input-modality=keyboard]:focus-visible:ring-2 data-[input-modality=keyboard]:focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45", "motion-safe:active:scale-[0.96]"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -58,9 +58,31 @@ export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({ variant, size, icon: Icon, loading = false, active = false, className, type = "button", disabled, ...props }: IconButtonProps) {
|
||||
export function IconButton({ variant, size, icon: Icon, loading = false, active = false, className, type = "button", disabled, onPointerDown, onKeyDown, onBlur, ...props }: IconButtonProps) {
|
||||
const [inputModality, setInputModality] = useState<"pointer" | "keyboard">("pointer");
|
||||
return (
|
||||
<button data-slot="icon-button" type={type} disabled={disabled || loading} aria-busy={loading || undefined} data-active={active || undefined} className={cn(iconButtonVariants({ variant, size }), className)} {...props}>
|
||||
<button
|
||||
data-slot="icon-button"
|
||||
data-input-modality={inputModality}
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(iconButtonVariants({ variant, size }), className)}
|
||||
onPointerDown={(event) => {
|
||||
setInputModality("pointer");
|
||||
onPointerDown?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab" || event.key === "Enter" || event.key === " ") setInputModality("keyboard");
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setInputModality("pointer");
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 aria-hidden className="animate-spin" /> : <Icon aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -21,7 +21,7 @@ import { cn } from "@/lib/utils";
|
||||
* 明暗:全部颜色走 token;active 用按压底语义 token,明暗自动适配。
|
||||
*/
|
||||
export const toolButtonVariants = cva(
|
||||
["inline-flex select-none items-center justify-center gap-1.5 rounded-md", "font-medium transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45"],
|
||||
["inline-flex select-none items-center justify-center gap-1.5 rounded-md px-3", "font-medium transition-colors", "outline-none focus-visible:outline-none data-[input-modality=keyboard]:focus-visible:ring-2 data-[input-modality=keyboard]:focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-45"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -74,12 +74,14 @@ export type ToolButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
export function ToolButton({ variant, size, tone, icon, active = false, expands = false, loading = false, label, className, type = "button", disabled, ...props }: ToolButtonProps) {
|
||||
export function ToolButton({ variant, size, tone, icon, active = false, expands = false, loading = false, label, className, type = "button", disabled, onPointerDown, onKeyDown, onBlur, ...props }: ToolButtonProps) {
|
||||
const [inputModality, setInputModality] = useState<"pointer" | "keyboard">("pointer");
|
||||
const pressed = !expands && active;
|
||||
const expanded = expands && active;
|
||||
return (
|
||||
<button
|
||||
data-slot="tool-button"
|
||||
data-input-modality={inputModality}
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
aria-pressed={pressed || undefined}
|
||||
@@ -87,6 +89,18 @@ export function ToolButton({ variant, size, tone, icon, active = false, expands
|
||||
aria-busy={loading || undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(toolButtonVariants({ variant, size, tone, active }), className)}
|
||||
onPointerDown={(event) => {
|
||||
setInputModality("pointer");
|
||||
onPointerDown?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab" || event.key === "Enter" || event.key === " ") setInputModality("keyboard");
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setInputModality("pointer");
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 aria-hidden className="animate-spin" /> : icon}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { Select, type SelectOption, type SelectProps, type SelectSize } from "./select";
|
||||
export { Select, type SelectOption, type SelectProps, type SelectSize, type SelectAppearance } from "./select";
|
||||
|
||||
@@ -1,159 +1,70 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button as AriaButton, ListBox as AriaListBox, ListBoxItem as AriaListBoxItem, Popover as AriaPopover, Select as AriaSelect } from "react-aria-components";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { forwardRef, useState, type ReactElement, type ReactNode, type RefAttributes } from "react";
|
||||
import AntSelect, { type BaseOptionType, type DefaultOptionType, type RefSelectProps, type SelectProps as AntSelectProps } from "antd/es/select";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Select — 下拉选择(自研,react-aria-components 单选壳,无远程/多选)。
|
||||
* 影策唯一下拉选择组件。
|
||||
*
|
||||
* - API 对齐 AntD Select 的单选子集:value/onChange(options 值)/options/placeholder/
|
||||
* disabled/allowClear/size/className,可直接替换纯单选场景;可直接置于 antd Form.Item。
|
||||
* - 浮层定位、进出场、焦点与类型选择由 RAC 承担(ADR-0008:复杂控件以 RAC 为无障碍地基)。
|
||||
* - Popover isNonModal:避免 RAC modal 滚动锁把页面滚动条锁死(同 boardui 注释);
|
||||
* isDismissable 与 isNonModal 互斥,故外部 dismiss 手动处理(全局 pointerdown capture)。
|
||||
* - 样式只吃语义 token:trigger border-border/bg-background/hover:border-foreground/40、
|
||||
* popover bg-surface-strong + hairline、item hover:bg-surface-hover、选中 Check 前景色,无裸色。
|
||||
* - 范围外(本批不做,后续补):mode="multiple"/"tags"、showSearch(需 ComboBox)、
|
||||
* loading、labelInValue、maxCount、suffixIcon。
|
||||
* 对标:AntD Select(单选);boardui base/select。
|
||||
* 所有单选、多选、可搜索选择都复用 Ant Design 的同一实现;本文件只负责
|
||||
* 统一项目内的入口、尺寸别名、ariaLabel 兼容和公共 class,避免页面各自维护
|
||||
* 一套浮层和焦点样式。视觉规则集中在 app-theme.ts 与 globals.css。
|
||||
*/
|
||||
|
||||
export type SelectSize = "sm" | "md";
|
||||
export type SelectAppearance = "soft" | "pill" | "field";
|
||||
|
||||
export interface SelectOption<V extends string = string> {
|
||||
export type SelectOption<V extends string | number = string> = DefaultOptionType & {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** 原生 title */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SelectProps<V extends string = string> {
|
||||
value?: V;
|
||||
onChange?: (value: V) => void;
|
||||
options?: Array<SelectOption<V>>;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
size?: SelectSize;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS: Record<SelectSize, string> = {
|
||||
sm: "h-7 text-xs",
|
||||
md: "h-8 text-sm",
|
||||
};
|
||||
|
||||
export function Select<V extends string = string>({ value, onChange, options = [], placeholder, disabled = false, allowClear = false, size = "md", ariaLabel, className }: SelectProps<V>) {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverWidth, setPopoverWidth] = useState<number | null>(null);
|
||||
export type SelectProps<ValueType = any, OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType> = Omit<AntSelectProps<ValueType, OptionType>, "size"> & {
|
||||
/** 兼容旧的基础 Select API;完整 AntD 尺寸仍可使用 small/middle/large。 */
|
||||
size?: SelectSize | "small" | "middle" | "large";
|
||||
/** 兼容基础 Select 的显式无障碍标签。 */
|
||||
ariaLabel?: string;
|
||||
/** 统一触发器外观;所有外观都无描边,差异只体现在圆角与内边距。 */
|
||||
appearance?: SelectAppearance;
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const measure = () => {
|
||||
const width = triggerRef.current?.offsetWidth;
|
||||
if (width) setPopoverWidth(width + 2);
|
||||
};
|
||||
measure();
|
||||
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
|
||||
if (observer && triggerRef.current) observer.observe(triggerRef.current);
|
||||
return () => observer?.disconnect();
|
||||
}, [open]);
|
||||
type UnifiedSelect = (<ValueType = any, OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType>(props: SelectProps<ValueType, OptionType> & RefAttributes<RefSelectProps>) => ReactElement) & { displayName?: string };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
const insideTrigger = triggerRef.current?.contains(target);
|
||||
const insidePopover = popoverRef.current?.contains(target);
|
||||
if (!insideTrigger && !insidePopover) setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((option) => option.value === value);
|
||||
const displayLabel = selected?.label ?? value;
|
||||
|
||||
const clear = () => {
|
||||
setOpen(false);
|
||||
onChange?.(undefined as unknown as V);
|
||||
};
|
||||
const sizeMap: Record<SelectSize, "small" | "middle"> = {
|
||||
sm: "small",
|
||||
md: "middle",
|
||||
};
|
||||
|
||||
export const Select = forwardRef<RefSelectProps, SelectProps>(function Select({ size, ariaLabel, appearance = "pill", className, variant, onMouseDown, onKeyDown, onFocus, onBlur, ...props }, ref) {
|
||||
const [inputModality, setInputModality] = useState<"unknown" | "pointer" | "keyboard">("unknown");
|
||||
const normalizedSize = size && size in sizeMap ? sizeMap[size as SelectSize] : size;
|
||||
return (
|
||||
<AriaSelect
|
||||
isDisabled={disabled}
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
selectedKey={value ?? undefined}
|
||||
onSelectionChange={(key) => {
|
||||
setOpen(false);
|
||||
if (key != null) onChange?.(key as V);
|
||||
}}
|
||||
<AntSelect
|
||||
ref={ref}
|
||||
{...props}
|
||||
variant={variant ?? "filled"}
|
||||
size={normalizedSize as AntSelectProps["size"]}
|
||||
aria-label={ariaLabel}
|
||||
className={cn("inline-flex w-full", className)}
|
||||
>
|
||||
<AriaButton
|
||||
ref={triggerRef}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-background px-2.5 font-normal text-foreground outline-none transition-colors motion-reduce:transition-none",
|
||||
"hover:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-55",
|
||||
open && "border-foreground/60",
|
||||
TRIGGER_CLASS[size],
|
||||
)}
|
||||
>
|
||||
<span className={cn("min-w-0 flex-1 truncate text-left", displayLabel === undefined && "text-muted-foreground")}>{displayLabel === undefined ? placeholder : displayLabel}</span>
|
||||
{allowClear && value !== undefined && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="清空选择"
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown aria-hidden="true" className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none", open && "rotate-180")} />
|
||||
</AriaButton>
|
||||
<AriaPopover ref={popoverRef} isNonModal placement="bottom" offset={4} style={popoverWidth ? { width: popoverWidth } : undefined} className={cn("z-50 rounded-lg border border-border bg-surface-strong p-1 shadow-lg", "ra-pop-in")}>
|
||||
<AriaListBox aria-label={ariaLabel ?? placeholder ?? "选项"} className="max-h-60 overflow-auto outline-none">
|
||||
{options.map((option) => (
|
||||
<AriaListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={typeof option.label === "string" ? option.label : option.value}
|
||||
isDisabled={option.disabled}
|
||||
className={({ isFocused, isSelected, isDisabled: itemDisabled }) =>
|
||||
cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors motion-reduce:transition-none",
|
||||
"focus-visible:bg-surface-hover",
|
||||
(isFocused || isSelected) && "bg-surface-hover",
|
||||
isSelected ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
itemDisabled && "cursor-not-allowed opacity-50",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<span className={cn("min-w-0 flex-1 truncate")} title={option.title}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && <Check className="size-3.5 shrink-0 text-foreground" />}
|
||||
</>
|
||||
)}
|
||||
</AriaListBoxItem>
|
||||
))}
|
||||
</AriaListBox>
|
||||
</AriaPopover>
|
||||
</AriaSelect>
|
||||
className={cn("app-unified-select", `app-unified-select--${appearance}`, className)}
|
||||
data-input-modality={inputModality}
|
||||
onMouseDown={(event) => {
|
||||
setInputModality("pointer");
|
||||
onMouseDown?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
setInputModality("keyboard");
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setInputModality((current) => current === "unknown" ? "keyboard" : current);
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setInputModality("unknown");
|
||||
onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}) as UnifiedSelect;
|
||||
|
||||
Select.displayName = "UnifiedSelect";
|
||||
|
||||
Reference in New Issue
Block a user