docs(ui): explain shared controls and canvas display adapters

This commit is contained in:
2026-11-12 01:51:59 +08:00
parent 1306df2b35
commit b51d78b47d
11 changed files with 115 additions and 0 deletions
+8
View File
@@ -1,11 +1,19 @@
/**
* 应用级反馈设施:提供共享Tooltip时间参数与通知挂载点。
* 这个模块只负责提示的呈现环境,任务进度、错误事实和通知触发仍由业务调用方管理。
*/
"use client";
import { createContext, type ReactNode } from "react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Toaster } from "sonner";
/** 默认false使独立挂载的IconButton退回原生title;只有AppFeedback子树启用统一浮层提示。 */
export const FeedbackContext = createContext(false);
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
export function AppFeedback({ children }: { children: ReactNode }) {
return <FeedbackContext.Provider value={true}>
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
+11
View File
@@ -1,9 +1,15 @@
/**
* 通用按钮的样式与属性适配层。variant表达视觉语义,size控制尺寸;点击、禁用和表单行为由调用方传入。
* 可通过asChild复用子元素,避免链接外再套按钮;这里不保存业务状态,也不默认指定button的type。
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/** 将基础交互样式、语义变体和尺寸变体组合成className;未指定时使用default/default。
* focus-visible提供键盘焦点反馈,disabled类限制指针交互并淡化外观,内嵌SVG统一尺寸。 */
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
@@ -34,6 +40,7 @@ const buttonVariants = cva(
},
);
/** 在原生button属性上增加样式变体和asChild;ref仍按按钮声明,实际宿主由asChild决定。 */
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -41,8 +48,11 @@ export interface ButtonProps
asChild?: boolean;
}
/** 转发ref和剩余原生属性,供调用方设置事件、可访问名称、disabled和表单属性。
* Slot把属性合并到唯一子元素;使用链接等宿主时,其语义及行为由该子元素承担。 */
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
},
);
// forwardRef生成包装组件后补显示名,便于React开发工具定位。
Button.displayName = "Button";
export { Button, buttonVariants };
+16
View File
@@ -1,3 +1,7 @@
/**
* 基于Radix的对话框展示封装:保留其开关、焦点管理和可访问语义,补项目统一的遮罩、布局与动画。
* 调用方通过Root的open/onOpenChange控制业务流程;保存中是否允许关闭等规则不由本模块决定。
*/
"use client";
import * as React from "react";
@@ -6,14 +10,19 @@ import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/** Root提供受控或非受控开关上下文,本层不再复制一份open状态。 */
const Dialog = DialogPrimitive.Root;
/** Trigger接入同一Root,负责触发打开;可沿用Radix的asChild组合调用方控件。 */
const DialogTrigger = DialogPrimitive.Trigger;
/** Portal把浮层移出页面普通布局,避免受父容器裁切;挂载方式沿用Radix。 */
const DialogPortal = DialogPrimitive.Portal;
/** 暴露关闭原语,供业务在底部动作区等位置放额外关闭入口。 */
const DialogClose = DialogPrimitive.Close;
/** 转发遮罩ref与属性,铺满视口;开关动画由Radix的data-state驱动,className允许调用方补样式。 */
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -29,6 +38,9 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
@@ -53,6 +65,7 @@ const DialogContent = React.forwardRef<
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/** 标题区只组织纵向间距与响应式文字对齐;原生div属性和children原样透传。 */
const DialogHeader = ({
className,
...props
@@ -67,6 +80,7 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
/** 动作区在窄屏使用反向纵排,在sm及以上改为靠右横排;不绑定提交或取消动作。 */
const DialogFooter = ({
className,
...props
@@ -81,6 +95,7 @@ const DialogFooter = ({
);
DialogFooter.displayName = "DialogFooter";
/** 转发Radix标题的ref/属性并统一字号;标题仍参与对话框的可访问命名关联。 */
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -96,6 +111,7 @@ const DialogTitle = React.forwardRef<
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
/** 转发Radix说明文字的ref/属性并使用弱化文字样式;说明关联由Radix上下文维护。 */
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+9
View File
@@ -1,3 +1,7 @@
/**
* 画布等工具栏使用的图标按钮。统一按钮尺寸、图标与可访问名称,并按反馈Provider决定使用浮层提示还是原生title。
* 按钮的业务动作由onClick等原生属性传入;这里不维护工具选择状态。
*/
"use client";
import { FeedbackContext } from "./app-feedback";
@@ -6,13 +10,17 @@ import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import styles from "./icon-button.module.css";
/** icon提供Lucide图标实现,label提供默认可访问名称与提示;不接受任意children替换内部图标。 */
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
icon: LucideIcon;
label: string;
};
/** 将ref交给真实button;type默认为button,避免放进表单时意外提交。
* 调用方仍可传aria-pressed、disabled等状态,组件不会自行判断命令是否可用。 */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
const feedback = useContext(FeedbackContext);
const button = (
<button
@@ -26,6 +34,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
);
// 没有全局提示环境就返回原生按钮;空title也不创建Tooltip。提示通过Portal避免被工具栏裁切。
if (!feedback || !title) return button;
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>