From 7b9270ef6745419fd6dbd93d0100322a96b6faa5 Mon Sep 17 00:00:00 2001 From: Maofeng Date: Wed, 29 Jul 2026 21:22:31 +0800 Subject: [PATCH] feat(ui): expand reusable component foundation - add Base UI and shadcn primitives for overlays, forms, navigation, feedback, and data display - add optimized DOM ripple hooks with ref composition and integrate the behavior into interactive controls - add AnchoredSurface SVG geometry and arrow-aware popover, dropdown, and tooltip rendering - extend responsive breakpoint and style utilities, declare component dependencies, and remove the redundant package ESLint config --- packages/ui/eslint.config.js | 22 - packages/ui/package.json | 18 +- packages/ui/src/components/accordion.tsx | 72 ++ packages/ui/src/components/alert-dialog.tsx | 185 ++++ packages/ui/src/components/alert.tsx | 76 ++ .../ui/src/components/anchored-surface.tsx | 923 ++++++++++++++++++ packages/ui/src/components/aspect-ratio.tsx | 22 + packages/ui/src/components/attachment.tsx | 207 ++++ packages/ui/src/components/avatar.tsx | 107 ++ packages/ui/src/components/badge.tsx | 52 + packages/ui/src/components/breadcrumb.tsx | 125 +++ packages/ui/src/components/bubble.tsx | 128 +++ packages/ui/src/components/button-group.tsx | 87 ++ packages/ui/src/components/button.tsx | 13 +- packages/ui/src/components/calendar.tsx | 221 +++++ packages/ui/src/components/card.tsx | 103 ++ packages/ui/src/components/carousel.tsx | 240 +++++ packages/ui/src/components/chart.tsx | 373 +++++++ packages/ui/src/components/checkbox.tsx | 29 + packages/ui/src/components/collapsible.tsx | 19 + packages/ui/src/components/combobox.tsx | 297 ++++++ packages/ui/src/components/command.tsx | 194 ++++ packages/ui/src/components/context-menu.tsx | 271 +++++ packages/ui/src/components/dialog.tsx | 165 ++++ packages/ui/src/components/direction.tsx | 4 + packages/ui/src/components/drawer.tsx | 232 +++++ packages/ui/src/components/dropdown-menu.tsx | 347 +++++++ packages/ui/src/components/empty.tsx | 104 ++ packages/ui/src/components/field.tsx | 238 +++++ packages/ui/src/components/hover-card.tsx | 51 + packages/ui/src/components/input-group.tsx | 156 +++ packages/ui/src/components/input-otp.tsx | 85 ++ packages/ui/src/components/input.tsx | 20 + packages/ui/src/components/item.tsx | 201 ++++ packages/ui/src/components/kbd.tsx | 26 + packages/ui/src/components/label.tsx | 20 + packages/ui/src/components/marker.tsx | 71 ++ packages/ui/src/components/menubar.tsx | 278 ++++++ .../ui/src/components/message-scroller.tsx | 129 +++ packages/ui/src/components/message.tsx | 92 ++ packages/ui/src/components/native-select.tsx | 61 ++ .../ui/src/components/navigation-menu.tsx | 168 ++++ packages/ui/src/components/pagination.tsx | 130 +++ packages/ui/src/components/popover.tsx | 145 +++ packages/ui/src/components/progress.tsx | 83 ++ packages/ui/src/components/radio-group.tsx | 36 + packages/ui/src/components/resizable.tsx | 50 + packages/ui/src/components/scroll-area.tsx | 53 + packages/ui/src/components/select.tsx | 201 ++++ packages/ui/src/components/separator.tsx | 23 + packages/ui/src/components/sheet.tsx | 139 +++ packages/ui/src/components/sidebar.tsx | 721 ++++++++++++++ packages/ui/src/components/skeleton.tsx | 13 + packages/ui/src/components/slider.tsx | 52 + packages/ui/src/components/spinner.tsx | 10 + packages/ui/src/components/switch.tsx | 32 + packages/ui/src/components/table.tsx | 114 +++ packages/ui/src/components/tabs.tsx | 82 ++ packages/ui/src/components/textarea.tsx | 18 + packages/ui/src/components/toast.tsx | 232 +++++ packages/ui/src/components/toggle-group.tsx | 89 ++ packages/ui/src/components/toggle.tsx | 43 + packages/ui/src/components/tooltip.tsx | 119 +++ packages/ui/src/hooks/use-breakpoint.ts | 134 +++ packages/ui/src/hooks/use-mobile.ts | 1 + packages/ui/src/hooks/use-ripple.ts | 356 +++++++ packages/ui/src/lib/utils.ts | 14 + packages/ui/src/styles/globals.css | 231 ++--- 68 files changed, 9209 insertions(+), 144 deletions(-) delete mode 100644 packages/ui/eslint.config.js create mode 100644 packages/ui/src/components/accordion.tsx create mode 100644 packages/ui/src/components/alert-dialog.tsx create mode 100644 packages/ui/src/components/alert.tsx create mode 100644 packages/ui/src/components/anchored-surface.tsx create mode 100644 packages/ui/src/components/aspect-ratio.tsx create mode 100644 packages/ui/src/components/attachment.tsx create mode 100644 packages/ui/src/components/avatar.tsx create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/breadcrumb.tsx create mode 100644 packages/ui/src/components/bubble.tsx create mode 100644 packages/ui/src/components/button-group.tsx create mode 100644 packages/ui/src/components/calendar.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/carousel.tsx create mode 100644 packages/ui/src/components/chart.tsx create mode 100644 packages/ui/src/components/checkbox.tsx create mode 100644 packages/ui/src/components/collapsible.tsx create mode 100644 packages/ui/src/components/combobox.tsx create mode 100644 packages/ui/src/components/command.tsx create mode 100644 packages/ui/src/components/context-menu.tsx create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/direction.tsx create mode 100644 packages/ui/src/components/drawer.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/empty.tsx create mode 100644 packages/ui/src/components/field.tsx create mode 100644 packages/ui/src/components/hover-card.tsx create mode 100644 packages/ui/src/components/input-group.tsx create mode 100644 packages/ui/src/components/input-otp.tsx create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/item.tsx create mode 100644 packages/ui/src/components/kbd.tsx create mode 100644 packages/ui/src/components/label.tsx create mode 100644 packages/ui/src/components/marker.tsx create mode 100644 packages/ui/src/components/menubar.tsx create mode 100644 packages/ui/src/components/message-scroller.tsx create mode 100644 packages/ui/src/components/message.tsx create mode 100644 packages/ui/src/components/native-select.tsx create mode 100644 packages/ui/src/components/navigation-menu.tsx create mode 100644 packages/ui/src/components/pagination.tsx create mode 100644 packages/ui/src/components/popover.tsx create mode 100644 packages/ui/src/components/progress.tsx create mode 100644 packages/ui/src/components/radio-group.tsx create mode 100644 packages/ui/src/components/resizable.tsx create mode 100644 packages/ui/src/components/scroll-area.tsx create mode 100644 packages/ui/src/components/select.tsx create mode 100644 packages/ui/src/components/separator.tsx create mode 100644 packages/ui/src/components/sheet.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/components/skeleton.tsx create mode 100644 packages/ui/src/components/slider.tsx create mode 100644 packages/ui/src/components/spinner.tsx create mode 100644 packages/ui/src/components/switch.tsx create mode 100644 packages/ui/src/components/table.tsx create mode 100644 packages/ui/src/components/tabs.tsx create mode 100644 packages/ui/src/components/textarea.tsx create mode 100644 packages/ui/src/components/toast.tsx create mode 100644 packages/ui/src/components/toggle-group.tsx create mode 100644 packages/ui/src/components/toggle.tsx create mode 100644 packages/ui/src/components/tooltip.tsx create mode 100644 packages/ui/src/hooks/use-breakpoint.ts create mode 100644 packages/ui/src/hooks/use-mobile.ts create mode 100644 packages/ui/src/hooks/use-ripple.ts diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js deleted file mode 100644 index ef614d2..0000000 --- a/packages/ui/eslint.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - globals: globals.browser, - }, - }, -]) diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b6df8e..6ed1418 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,37 +4,39 @@ "type": "module", "private": true, "scripts": { - "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit" }, "dependencies": { "@base-ui/react": "^1.6.0", + "@base-ui/utils": "^0.3.1", "@fontsource-variable/inter": "^5.3.0", + "@shadcn/react": "^0.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.4.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", "lucide-react": "^1.27.0", "react": "^19.2.6", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", + "react-resizable-panels": "^4.12.2", + "recharts": "3.8.0", "shadcn": "^4.15.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.4.3" }, "devDependencies": { - "@eslint/js": "^10", "@tailwindcss/vite": "^4", "@turbo/gen": "^2.9.15", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17", "tailwindcss": "^4", - "typescript": "~6", - "typescript-eslint": "^8" + "typescript": "~6" }, "exports": { "./globals.css": "./src/styles/globals.css", diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 0000000..2d24444 --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,72 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" + +import { cn } from "@workspace/ui/lib/utils" +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 0000000..e2b37ff --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + *
+ * + * The tail is on top because the surface is below its anchor. + * + *
+ *
+ * ``` + */ +export const AnchoredSurface = React.forwardRef< + HTMLDivElement, + AnchoredSurfaceProps +>(function AnchoredSurface( + { + borderWidth = 1, + children, + className, + cornerRadius = "lg", + pathClassName, + render, + shadowClassName, + side = "bottom", + style, + tailPosition = 0.5, + tailSize = "md", + ...props + }, + forwardedRef +) { + const [contentRef, contentDimensions] = useElementDimensions() + const { baseWidth: tailBaseWidth, depth: tailDepth } = + TAIL_SIZE_PRESETS[tailSize] + const [arrowElement, setArrowElement] = React.useState( + null + ) + const baseUIArrowPlacement = useBaseUIArrowPlacement( + arrowElement, + tailBaseWidth + ) + const resolvedBorderWidth = Math.max(0, borderWidth) + const resolvedCornerRadii = React.useMemo( + () => resolveCornerRadii(cornerRadius), + [cornerRadius] + ) + const tailSide = baseUIArrowPlacement?.side ?? side + const resolvedTailPosition = Math.min(1, Math.max(0, tailPosition)) + const hasMeasuredDimensions = + contentDimensions.width > 0 && contentDimensions.height > 0 + const surfaceCornerRadii = React.useMemo( + () => + hasMeasuredDimensions + ? resolveSurfaceCornerRadii( + contentDimensions.width, + contentDimensions.height, + { + borderWidth: resolvedBorderWidth, + cornerRadii: resolvedCornerRadii, + tailBaseWidth, + tailDepth, + tailSide, + } + ) + : resolvedCornerRadii, + [ + contentDimensions.height, + contentDimensions.width, + hasMeasuredDimensions, + resolvedBorderWidth, + resolvedCornerRadii, + tailBaseWidth, + tailDepth, + tailSide, + ] + ) + const surfaceBorderRadius = `${surfaceCornerRadii.topLeft}px ${surfaceCornerRadii.topRight}px ${surfaceCornerRadii.bottomRight}px ${surfaceCornerRadii.bottomLeft}px` + const path = React.useMemo( + () => + hasMeasuredDimensions + ? createPopoverPath(contentDimensions.width, contentDimensions.height, { + borderWidth: resolvedBorderWidth, + cornerRadii: resolvedCornerRadii, + tailBaseWidth, + tailCenterOffset: baseUIArrowPlacement?.centerOffset, + tailDepth, + tailPosition: resolvedTailPosition, + tailSide, + }) + : "", + [ + baseUIArrowPlacement?.centerOffset, + contentDimensions.height, + contentDimensions.width, + hasMeasuredDimensions, + resolvedBorderWidth, + resolvedCornerRadii, + resolvedTailPosition, + tailBaseWidth, + tailDepth, + tailSide, + ] + ) + const hasHorizontalTail = tailSide === "top" || tailSide === "bottom" + const svgWidth = contentDimensions.width + (hasHorizontalTail ? 0 : tailDepth) + const svgHeight = + contentDimensions.height + (hasHorizontalTail ? tailDepth : 0) + const svgLeft = tailSide === "left" ? -tailDepth : 0 + const svgTop = tailSide === "top" ? -tailDepth : 0 + const hasResolvedArrowPlacement = + arrowElement === null || baseUIArrowPlacement !== null + const renderedSvgWidth = hasMeasuredDimensions ? svgWidth : 1 + const renderedSvgHeight = hasMeasuredDimensions ? svgHeight : 1 + const mergedContentRef = useMergedRefs(contentRef, forwardedRef) + const arrowContext = React.useMemo( + () => ({ + arrowBoxSize: tailBaseWidth, + setArrowElement, + }), + [tailBaseWidth] + ) + + const surface = useRender({ + defaultTagName: "div", + ref: mergedContentRef, + render, + props: { + ...props, + className: cn("relative isolate inline-block", className), + style: { + ...style, + borderColor: "transparent", + borderRadius: surfaceBorderRadius, + borderStyle: "solid", + borderWidth: resolvedBorderWidth, + boxSizing: "border-box", + visibility: hasResolvedArrowPlacement ? undefined : "hidden", + }, + children: ( + <> + + {children} + + + ), + }, + }) + + return ( + + {surface} + + ) +}) diff --git a/packages/ui/src/components/aspect-ratio.tsx b/packages/ui/src/components/aspect-ratio.tsx new file mode 100644 index 0000000..d9a6f84 --- /dev/null +++ b/packages/ui/src/components/aspect-ratio.tsx @@ -0,0 +1,22 @@ +import { cn } from "@workspace/ui/lib/utils" + +function AspectRatio({ + ratio, + className, + ...props +}: React.ComponentProps<"div"> & { ratio: number }) { + return ( +
+ ) +} + +export { AspectRatio } diff --git a/packages/ui/src/components/attachment.tsx b/packages/ui/src/components/attachment.tsx new file mode 100644 index 0000000..9866445 --- /dev/null +++ b/packages/ui/src/components/attachment.tsx @@ -0,0 +1,207 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/button" + +const attachmentVariants = cva( + "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + { + variants: { + size: { + default: + "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", + sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", + xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", + }, + orientation: { + horizontal: "min-w-40 items-center", + vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", + }, + }, + } +) + +function Attachment({ + className, + state = "done", + size = "default", + orientation = "horizontal", + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + return ( +
+ ) +} + +const attachmentMediaVariants = cva( + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + { + variants: { + variant: { + icon: "", + image: + "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", + }, + }, + defaultVariants: { + variant: "icon", + }, + } +) + +function AttachmentMedia({ + className, + variant = "icon", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/packages/ui/src/components/chart.tsx b/packages/ui/src/components/chart.tsx new file mode 100644 index 0000000..047bcf0 --- /dev/null +++ b/packages/ui/src/components/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@workspace/ui/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +