refactor(platform): 统一控件 - 收敛下拉选择与按钮输入交互样式

This commit is contained in:
ddcat666
2026-09-25 00:01:09 +08:00
parent 5529c18454
commit a755102b09
61 changed files with 661 additions and 237 deletions

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -1 +1 @@
export { Select, type SelectOption, type SelectProps, type SelectSize } from "./select";
export { Select, type SelectOption, type SelectProps, type SelectSize, type SelectAppearance } from "./select";

View File

@@ -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";

View File

@@ -1,7 +1,7 @@
/* Rare UI Fluid Orb inspired launcher: isolated from canvas wheel/pointer gestures. */
.fluid-orb {
position: relative;
display: inline-block;
display: block;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--fluid-orb-color, #7164f6) 18%, white);

View File

@@ -81,12 +81,13 @@ float fbm(vec3 p) {
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv * 2.0 - 1.0;
p.x *= u_resolution.x / u_resolution.y;
// Center on the pixel grid. Dividing by the canvas size and then
// stretching x by the aspect ratio shifts the disc whenever the
// backing store is not square (border box, DPR rounding).
vec2 p = (gl_FragCoord.xy - u_resolution.xy * 0.5) / (0.5 * min(u_resolution.x, u_resolution.y));
float r = length(p);
if (r > 1.0) discard;
float y = uv.y;
float y = p.y * 0.5 + 0.5;
float t = u_time * 0.18;
float n1 = fbm(vec3(p * 1.28, t));
float n2 = fbm(vec3(p * 2.35 + n1 * 0.55, t * 1.22));