Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
// Radix Accordion keeps type single | multiple, collapsible, value / defaultValue / onValueChange, disabled, and the keyboard
|
||||
// model (Arrow Up / Down, Home / End, Enter / Space) with ARIA. States are the Radix attributes (data-state, data-disabled).
|
||||
// Look comes from src/theme/accordion.css.
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The shadcn chevron is kept and rotates with data-state. Disabled keeps the Radix `disabled` attribute and `data-disabled`.
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger flex flex-1 items-start justify-between text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 transition-transform duration-200"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
// shadcn open / close animation (height from --radix-accordion-content-height) is kept; the inner div carries the padding so the
|
||||
// height animation is not distorted.
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden data-[state=closed]:animate-[accordion-up_0.2s_ease-out] data-[state=open]:animate-[accordion-down_0.2s_ease-out]"
|
||||
{...props}
|
||||
>
|
||||
<div data-slot="accordion-content-inner" className={cn(className)}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "./button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Radix AlertDialog: no Close (X) button, no outside-click dismiss, Escape still closes, initial focus goes to Cancel.
|
||||
// Surface, size and regions come from src/theme/alert-dialog.css.
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 outline-none duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("font-heading", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = Pick<React.ComponentProps<typeof Button>, "variant" | "appearance" | "size">
|
||||
|
||||
// Action and Cancel are the HM Button composed over the Radix part (Button asChild). The Radix part gets no data-slot of its
|
||||
// own, so the Button's data-slot / data-variant / data-appearance stay and the HM Button style applies. Alert Dialog defines
|
||||
// no Button look: Cancel defaults to the cancel role (Gray solid) and Action to primary solid; pass variant="destructive"
|
||||
// appearance="outline" for a dangerous action (the same pairing as the Dialog delete confirmation).
|
||||
function AlertDialogAction({
|
||||
variant = "primary",
|
||||
appearance = "solid",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & ActionButtonProps) {
|
||||
return (
|
||||
<Button variant={variant} appearance={appearance} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action {...props} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
variant = "cancel",
|
||||
appearance = "solid",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & ActionButtonProps) {
|
||||
return (
|
||||
<Button variant={variant} appearance={appearance} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel {...props} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
// Alert is an inline message, not a Toast. shadcn structure is kept (role="alert", optional leading svg icon, Title, Description);
|
||||
// the look comes from src/theme/alert.css via data-variant. Only default and destructive exist: the HM Theme has no success / warning /
|
||||
// information status colors yet, so none are invented. There is no close action.
|
||||
const alertVariants = cva(
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
destructive: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({ className, variant = "default", ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return <div data-slot="alert" data-variant={variant} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-title" className={cn("col-start-2 line-clamp-1 min-h-4", className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-description" className={cn("col-start-2 grid justify-items-start gap-1", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
|
||||
|
||||
// A layout primitive only: Radix keeps the `ratio` prop (width / height) and the sizing box. It draws no surface, radius or
|
||||
// border, and no image styling (object-fit, rounding) is applied; the consumer composes those on the child.
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Avatar keeps the image load handling: the Image renders only after it loads, otherwise the Fallback shows
|
||||
// (with an optional `delayMs`). `size` is the shadcn API (sm | default | lg); the dimensions and the fallback look come from
|
||||
// src/theme/avatar.css.
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn("relative flex shrink-0 overflow-hidden select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn("flex size-full items-center justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import { cn } from "cn"
|
||||
|
||||
// Badge is its own component, not a Button variant. Variants are the shadcn set (default / secondary / destructive / outline), each with a
|
||||
// meaning that exists in the current HM Theme (primary, neutral, destructive, bordered). Looks come from src/theme/badge.css via data-variant.
|
||||
// There is no success / warning / information variant: the HM Theme has no status palette for them yet.
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap outline-none [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
secondary: "",
|
||||
destructive: "",
|
||||
outline: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
return <Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Page-level navigation primitive: nav > ol > li, no router dependency (use `asChild` on BreadcrumbLink with any link component).
|
||||
// Look comes from src/theme/breadcrumb.css.
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn("flex flex-wrap items-center break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The current page is a label, not a link: `aria-current="page"` with `aria-disabled` and role=link (shadcn), no pointer behavior.
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("inline-flex items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Presentational only (aria-hidden + sr-only "More"). Wrapping it in a menu trigger is the consumer's composition.
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
// variant = ui_viz role, appearance = ui_viz treatment (docs/components/button.md §28).
|
||||
// `default` and `outline` are kept as aliases (ui_viz also keeps `outline` as an alias) so existing shadcn usage keeps working.
|
||||
// `ghost` and `link` are shadcn compatibility variants: ui_viz has no spec for them, so they keep the shadcn defaults.
|
||||
type ButtonAppearance = "brand" | "solid" | "outline"
|
||||
|
||||
// shadcn motion and disabled defaults. HM roles replace them: no transition, no press shift, not-allowed cursor.
|
||||
const shadcnMotion =
|
||||
"transition-all active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50"
|
||||
const hmRole = "disabled:cursor-not-allowed"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: hmRole,
|
||||
secondary: hmRole,
|
||||
modify: hmRole,
|
||||
destructive: hmRole,
|
||||
cancel: hmRole,
|
||||
default: hmRole,
|
||||
outline: hmRole,
|
||||
ghost: `${shadcnMotion} hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50`,
|
||||
link: `${shadcnMotion} text-primary underline-offset-4 hover:underline`,
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "primary",
|
||||
appearance = "brand",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
appearance?: ButtonAppearance
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const compat = variant === "ghost" || variant === "link"
|
||||
// `default` is the primary role; `outline` is primary + outline appearance.
|
||||
const role = variant === "default" || variant === "outline" ? "primary" : variant
|
||||
const treatment = variant === "outline" ? "outline" : appearance
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-variant={role}
|
||||
data-appearance={compat ? undefined : treatment}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant: role, size, className }))}
|
||||
{...props}
|
||||
// After the spread: `<XTrigger asChild><Button/></XTrigger>` passes the trigger's own data-slot down, which would drop the HM Button look.
|
||||
data-slot="button"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export type { ButtonAppearance }
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { ko } from "react-day-picker/locale"
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
// A date selection primitive on react-day-picker. The date math, month navigation, keyboard model (arrow keys, Home / End,
|
||||
// PageUp / PageDown), selection modes (single | multiple | range), disabled / outside days and the ARIA grid are the library's.
|
||||
// The default class names (`rdp-*`) and the day state attributes (`data-selected`, `data-today`, `data-outside`, `data-disabled`,
|
||||
// `data-focused`) stay as the library renders them; the HM look is in src/theme/calendar.css. Locale: Korean by default
|
||||
// (`react-day-picker/locale`, no extra dependency); pass `locale` for another one. Not a Date Picker (see date-picker.tsx).
|
||||
function Calendar({
|
||||
className,
|
||||
showOutsideDays = true,
|
||||
locale = ko,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
data-slot="calendar"
|
||||
showOutsideDays={showOutsideDays}
|
||||
locale={locale}
|
||||
className={cn(className)}
|
||||
components={{
|
||||
Chevron: ({ orientation, className: chevronClassName, ...chevronProps }) => {
|
||||
const Icon =
|
||||
orientation === "left"
|
||||
? ChevronLeftIcon
|
||||
: orientation === "right"
|
||||
? ChevronRightIcon
|
||||
: orientation === "up"
|
||||
? ChevronUpIcon
|
||||
: ChevronDownIcon
|
||||
return <Icon className={cn(chevronClassName)} {...(chevronProps as React.SVGProps<SVGSVGElement>)} />
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar }
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// Card is the ui_viz Content Card (external ui_viz docs/patterns/content-container.md Type B; HM spec: docs/components/card.md).
|
||||
// `surface` is its ui_viz surface axis: White (default) or Beige.
|
||||
// Border, radius, surface, padding and the Title typography come from packages/ui/src/theme/card.css. `size="sm"` is a shadcn size ui_viz does not define.
|
||||
type CardSurface = "surface" | "content"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
surface = "surface",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; surface?: CardSurface }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
data-surface={surface}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) py-(--card-spacing) text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { CardSurface }
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"
|
||||
import { cn } from "cn"
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
// Slide navigation on Embla (embla-carousel-react): drag / swipe, snapping, loop, alignment, plugins and the scroll math are
|
||||
// Embla's. The Carousel adds the previous / next controls, the ArrowLeft / ArrowRight keys and the carousel semantics
|
||||
// (`role="region"`, `aria-roledescription="carousel"`, slides as `role="group"` `aria-roledescription="slide"`), and exposes the
|
||||
// Embla API through `setApi`. It owns no data fetching or paging. The slide width and the content (cards, media) are the
|
||||
// consumer's; the gap and the controls are styled in src/theme/carousel.css.
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative min-w-0", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn("flex", orientation === "vertical" && "flex-col", className)}
|
||||
data-slot="carousel-track"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn("min-w-0 shrink-0 grow-0 basis-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The controls are Carousel's own buttons (not the Button component), so Button roles and tokens do not apply.
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button">) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="carousel-previous"
|
||||
className={cn(
|
||||
"absolute z-10 inline-flex items-center justify-center outline-none",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -translate-y-1/2"
|
||||
: "left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button">) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="carousel-next"
|
||||
className={cn(
|
||||
"absolute z-10 inline-flex items-center justify-center outline-none",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -translate-y-1/2"
|
||||
: "left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||
|
||||
// Radix Checkbox structure, data-state and ARIA are kept (ui-viz has no Checkbox spec; reference only); the look comes from
|
||||
// src/theme/checkbox.css. Indeterminate is `checked="indeterminate"` (Radix); the shadcn check icon is kept and a minus icon is shown for it.
|
||||
function Checkbox({ className, form, asChild, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
// Radix's reset listener uses the trigger's native form, while Root only passes `form` to its input.
|
||||
const useFormButton = !!form && !asChild
|
||||
const indicator = (
|
||||
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="flex items-center justify-center text-current">
|
||||
<CheckIcon aria-hidden="true" className="hidden group-data-[state=checked]/checkbox:block" />
|
||||
<MinusIcon aria-hidden="true" className="hidden group-data-[state=indeterminate]/checkbox:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
)
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
form={form}
|
||||
asChild={useFormButton || asChild}
|
||||
className={cn(
|
||||
"group/checkbox peer relative inline-flex shrink-0 items-center justify-center outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{useFormButton ? <button type="button" form={form}>{indicator}</button> : indicator}
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
// A single-region disclosure primitive. Radix keeps open / defaultOpen / onOpenChange / disabled, `aria-expanded`,
|
||||
// `aria-controls` and Enter / Space. States are the Radix attributes (data-state, data-disabled).
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
// With asChild the child keeps its own data-slot (same rule as DialogClose), so a Button used as the trigger keeps the HM Button
|
||||
// style. A plain trigger gets the HM text-trigger look from src/theme/collapsible.css.
|
||||
function CollapsibleTrigger({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
const slot = asChild ? {} : { "data-slot": "collapsible-trigger" }
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
asChild={asChild}
|
||||
className={cn(className)}
|
||||
{...slot}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// shadcn has no open / close animation for Collapsible and HM defines no Motion rule, so the content appears and hides directly.
|
||||
function CollapsibleContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "./command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
||||
import { useConnectedPopupRef } from "./use-connected-popup-ref"
|
||||
|
||||
// A searchable single select composed from the public Popover and Command (not Select, not a copy of its list):
|
||||
// <Popover><PopoverTrigger = field-like button role="combobox"/><PopoverContent><Command>Input + List</Command></PopoverContent></Popover>
|
||||
// Select stays for short lists that need no search (Radix Select). Combobox is for search / filter. Filtering, keyboard and the
|
||||
// listbox ARIA are cmdk's; Popover keeps Portal, collision, Escape and focus return to the trigger.
|
||||
// The typed `options` wrapper is a minimal API (value, label, disabled); anything richer is composed with Command directly.
|
||||
type ComboboxOption = {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ComboboxSize = "medium" | "large"
|
||||
|
||||
type ComboboxProps = Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"value" | "defaultValue" | "onChange" | "children"
|
||||
> & {
|
||||
options: ComboboxOption[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
/** Explicit value ownership. Omitted: infer from value !== undefined. Keep this mode stable while mounted. */
|
||||
controlled?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
size?: ComboboxSize
|
||||
}
|
||||
|
||||
function Combobox({
|
||||
className,
|
||||
options,
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
controlled: controlledProp,
|
||||
onValueChange,
|
||||
placeholder = "선택",
|
||||
searchPlaceholder = "검색...",
|
||||
emptyText = "결과가 없습니다.",
|
||||
size = "large",
|
||||
disabled,
|
||||
...props
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inner, setInner] = React.useState<string | undefined>(defaultValue)
|
||||
const controlled = controlledProp ?? (valueProp !== undefined)
|
||||
const value = controlled ? valueProp : inner
|
||||
const selected = options.find((option) => option.value === value)
|
||||
const popupRef = useConnectedPopupRef()
|
||||
|
||||
const select = (next: string) => {
|
||||
if (!controlled) setInner(next)
|
||||
onValueChange?.(next)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
data-slot="combobox-trigger"
|
||||
data-size={size}
|
||||
data-empty={selected ? undefined : ""}
|
||||
disabled={disabled}
|
||||
className={cn("flex w-full items-center justify-between text-left outline-none disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
>
|
||||
<span data-slot="combobox-value">{selected ? selected.label : placeholder}</span>
|
||||
<ChevronDownIcon data-slot="combobox-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={popupRef}
|
||||
data-combobox-popup=""
|
||||
data-size={size}
|
||||
align="start"
|
||||
sideOffset={-1}
|
||||
style={
|
||||
{
|
||||
"--hm-popover-padding": "0px",
|
||||
"--hm-popover-width": "var(--radix-popover-trigger-width)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label]}
|
||||
disabled={option.disabled}
|
||||
data-checked={option.value === value ? "" : undefined}
|
||||
onSelect={() => select(option.value)}
|
||||
>
|
||||
<span data-slot="combobox-item-label">{option.label}</span>
|
||||
<CheckIcon
|
||||
data-slot="combobox-item-check"
|
||||
aria-hidden="true"
|
||||
className={option.value === value ? "opacity-100" : "opacity-0"}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { Combobox }
|
||||
export type { ComboboxOption, ComboboxSize }
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
// A searchable list primitive on cmdk. Filtering (fuzzy match on item `value` and `keywords`, custom `filter`, `shouldFilter`),
|
||||
// arrow-key / Home / End navigation, Enter selection, disabled skipping, groups, the empty state and the listbox / option ARIA
|
||||
// are cmdk's. cmdk marks the highlighted item with `data-selected="true"` and disabled ones with `data-disabled="true"`.
|
||||
// Look comes from src/theme/command.css. There is no CommandDialog / global command palette here (a separate pattern).
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn("flex h-full w-full flex-col overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex items-center"
|
||||
>
|
||||
<SearchIcon className="shrink-0" aria-hidden="true" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("flex w-full outline-hidden disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("overflow-x-hidden overflow-y-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return <CommandPrimitive.Empty data-slot="command-empty" {...props} />
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn("overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("ml-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
// Surface, border, radius and shadow come from src/theme/context-menu.css (Floating Semantic). Radix keeps Portal, positioning,
|
||||
// collision handling, focus movement and keyboard navigation; the shadcn open / close animation classes are kept.
|
||||
// Bound preferred and minimum widths by Radix's available space on the resolved side, including submenus.
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-context-menu-content-available-height) max-w-(--radix-context-menu-content-available-width) min-w-[min(8rem,var(--radix-context-menu-content-available-width))] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
}
|
||||
|
||||
// `variant="destructive"` is the shadcn API (`data-variant`); the HM look is in the theme CSS.
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="context-menu-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
// The radio dot is the shadcn filled circle; its size and color are theme tokens.
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="context-menu-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<span data-slot="context-menu-radio-dot" className="block rounded-full bg-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn("ml-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-context-menu-content-available-height) max-w-(--radix-context-menu-content-available-width) min-w-[min(6rem,var(--radix-context-menu-content-available-width))] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuPortal,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuLabel,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import type { PropsBase } from "react-day-picker"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
|
||||
import { Calendar } from "./calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
||||
|
||||
// A single-date picker composed from the public Popover and Calendar (no new date engine, no Popover copy):
|
||||
// <Popover><PopoverTrigger = field-like button/><PopoverContent><Calendar mode="single"/></PopoverContent></Popover>
|
||||
// The package owns the value handling because every service picker needs the same trigger text, close-on-select and focus
|
||||
// return. Popover keeps Portal, collision, Escape and focus return to the trigger; Calendar keeps the date model and keyboard.
|
||||
// The trigger is a field (src/theme/date-picker.css, `--hm-date-picker-*`), not a Button, in Select's two sizes.
|
||||
type DatePickerSize = "medium" | "large"
|
||||
|
||||
type DatePickerProps = Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"value" | "defaultValue" | "onChange" | "children"
|
||||
> & {
|
||||
value?: Date
|
||||
defaultValue?: Date
|
||||
/** Explicit value ownership. Omitted: infer from value !== undefined. Keep this mode stable while mounted. */
|
||||
controlled?: boolean
|
||||
onValueChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
size?: DatePickerSize
|
||||
/** Text for the selected date. Default: local calendar date as YYYY.MM.DD, e.g. "2026.10.06". */
|
||||
format?: (date: Date) => string
|
||||
/** A few Calendar options (disabled dates, range of months, caption, locale, week start). */
|
||||
calendarProps?: Partial<
|
||||
Pick<PropsBase, "disabled" | "startMonth" | "endMonth" | "locale" | "captionLayout" | "weekStartsOn" | "today">
|
||||
>
|
||||
}
|
||||
|
||||
const defaultFormat = (date: Date) =>
|
||||
`${String(date.getFullYear()).padStart(4, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`
|
||||
|
||||
function DatePicker({
|
||||
className,
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
controlled: controlledProp,
|
||||
onValueChange,
|
||||
placeholder = "날짜 선택",
|
||||
size = "large",
|
||||
format = defaultFormat,
|
||||
calendarProps,
|
||||
disabled,
|
||||
...props
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inner, setInner] = React.useState<Date | undefined>(defaultValue)
|
||||
const controlled = controlledProp ?? (valueProp !== undefined)
|
||||
const value = controlled ? valueProp : inner
|
||||
|
||||
const select = (date: Date | undefined) => {
|
||||
if (!controlled) setInner(date)
|
||||
onValueChange?.(date)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="date-picker-trigger"
|
||||
data-size={size}
|
||||
data-empty={value ? undefined : ""}
|
||||
disabled={disabled}
|
||||
className={cn("flex w-full items-center justify-between text-left outline-none disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
>
|
||||
<span data-slot="date-picker-value">{value ? format(value) : placeholder}</span>
|
||||
<CalendarIcon data-slot="date-picker-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
style={{ "--hm-popover-padding": "0px", "--hm-popover-width": "auto" } as React.CSSProperties}
|
||||
// Radix focuses the first tabbable (the "previous month" icon button). Land on the day the Calendar marks as its tab stop
|
||||
// (selected, otherwise today / first day) so the keyboard can pick a date right away; focus still returns to the trigger on close.
|
||||
onOpenAutoFocus={(event) => {
|
||||
const day = (event.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||
".rdp-day:not([data-disabled]) button[tabindex='0']"
|
||||
)
|
||||
if (day) {
|
||||
event.preventDefault()
|
||||
day.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
required
|
||||
selected={value}
|
||||
onSelect={select}
|
||||
defaultMonth={value}
|
||||
{...calendarProps}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DatePicker }
|
||||
export type { DatePickerSize }
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "./button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
// With asChild the child keeps its own data-slot: Slot would otherwise replace the Button's `data-slot="button"` with
|
||||
// "dialog-close", and the HM Button styles (selected by data-slot="button") would not apply to Dialog actions.
|
||||
function DialogClose({
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
// An explicit `data-slot={undefined}` would still replace the child's value, so the prop is left out entirely.
|
||||
const slot = asChild ? {} : { "data-slot": "dialog-close" }
|
||||
return <DialogPrimitive.Close asChild={asChild} {...slot} {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 text-sm duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ui_viz Body region (dialog.md §1, §2.1): padding, scrolling and the 16/24 text come from src/theme/dialog.css. Header and Footer stay fixed; only Body scrolls.
|
||||
function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dialog-body" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
// Drawer is built on vaul (the shadcn Drawer base): drag to dismiss, snap points, scroll / drag arbitration and body
|
||||
// scroll lock are vaul's. vaul sits on Radix Dialog, so Portal, focus trap, Escape and ARIA are Radix's.
|
||||
//
|
||||
// vaul defaults `autoFocus` to false, which cancels Radix's initial focus: focus stays on the trigger behind the aria-hidden page
|
||||
// and Tab leaves the Drawer. HM turns it on by default so a modal Drawer moves focus inside like Dialog and Sheet; pass
|
||||
// `autoFocus={false}` to restore the vaul behavior.
|
||||
function Drawer({
|
||||
autoFocus = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" autoFocus={autoFocus} {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
// Same asChild rule as DialogClose: with asChild the child keeps its own data-slot so an HM Button keeps its style.
|
||||
function DrawerClose({
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
const slot = asChild ? {} : { "data-slot": "drawer-close" }
|
||||
return <DrawerPrimitive.Close asChild={asChild} {...slot} {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The handle (shown for the bottom direction) is the vaul drag affordance; surface, radius, border, shadow and handle
|
||||
// look come from src/theme/drawer.css. Direction comes from the Drawer root (`direction`, default bottom) as in shadcn.
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col outline-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-slot="drawer-handle"
|
||||
className="mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
|
||||
/>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Body region: the scroll owner, so Header and Footer stay in view. vaul decides drag vs. scroll from the scrollable ancestor.
|
||||
function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="drawer-body" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
// Surface, border, radius and shadow come from src/theme/dropdown-menu.css. Radix keeps Portal, positioning,
|
||||
// collision handling, focus movement and keyboard navigation; the shadcn open / close animation classes are kept.
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
// `variant="destructive"` is the shadcn API (`data-variant`); the HM look is in the theme CSS.
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
// The radio dot is the shadcn filled circle; its size and color are theme tokens.
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span data-slot="dropdown-menu-radio-dot" className="block rounded-full bg-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("ml-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-24 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
}
|
||||
|
||||
// A richer preview than Tooltip. Surface, border, radius, shadow, width and padding come from src/theme/hover-card.css.
|
||||
// Radix keeps the open / close delay, the hover bridge between trigger and content, and Portal positioning.
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 origin-(--radix-hover-card-content-transform-origin) outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// `size` is the ui_viz Input size (docs/components/input.md §24), not the native character-width attribute. Default is large (42px).
|
||||
// Face, border, radius, height, typography and the Focus / Error looks come from src/theme/input.css.
|
||||
type InputSize = "small" | "medium" | "large"
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
size = "large",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<"input">, "size"> & { size?: InputSize }) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"w-full min-w-0 transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export type { InputSize }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Label keeps the native label semantics (htmlFor / wrapping a control, click focuses the control).
|
||||
// Typography and color come from src/theme/label.css. Required star and Helper/Error message are not part of Label (consumer composition).
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Menubar as MenubarPrimitive } from "radix-ui"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// Radix Menubar keeps the menu-bar model: Left / Right between top menus, Down / Enter / Space to open, Up / Down between
|
||||
// items, sub menus, typeahead, Escape and focus return, with ARIA (`role=menubar`, `menu`, `menuitem*`). States are Radix attributes
|
||||
// (data-highlighted, data-disabled, data-state). Look comes from src/theme/menubar.css.
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn("flex items-center outline-hidden select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="menubar-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="menubar-item-indicator"
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<span data-slot="menubar-radio-dot" className="block rounded-full bg-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn("ml-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-32 origin-(--radix-menubar-content-transform-origin) overflow-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
// Radix NavigationMenu keeps the disclosure / link model (not an ARIA menu), hover open delay, Arrow-key and Tab movement,
|
||||
// Escape, the viewport and the indicator. Look comes from src/theme/navigation-menu.css. It is for navigation destinations
|
||||
// (links), not commands; it does not depend on any router (use `asChild` on NavigationMenuLink).
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
// Indicator measures trigger offsets against Radix's List wrapper. Only inline content needs an Item anchor.
|
||||
className={cn("group-data-[viewport=false]/navigation-menu:relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(
|
||||
"group inline-flex w-max items-center justify-center outline-none disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
data-slot="navigation-menu-trigger-icon"
|
||||
className="relative top-px ml-1 transition duration-300 group-data-[state=open]/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"top-0 left-0 w-full data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="navigation-menu-viewport-wrapper"
|
||||
className="absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-(--radix-navigation-menu-viewport-width)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// `active` (Radix) marks the link of the current location: it sets `data-active` and `aria-current="page"`.
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn("flex outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-slot="navigation-menu-indicator-arrow"
|
||||
className="relative top-[60%] size-2 rotate-45 rounded-tl-sm"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Presentational navigation primitive: the consumer owns the page state and the hrefs. Links stay anchors (never buttons).
|
||||
// Look comes from src/theme/pagination.css; it does not compose Button.
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row flex-wrap items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
size?: "default" | "icon"
|
||||
} & React.ComponentProps<"a">
|
||||
|
||||
// `isActive` marks the current page (`aria-current="page"`). A link with `aria-disabled="true"` is also taken out of the tab order
|
||||
// and its activation is cancelled, because an anchor has no native disabled state.
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
onClick,
|
||||
tabIndex,
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
const disabled = props["aria-disabled"] === true || props["aria-disabled"] === "true"
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
data-size={size}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
tabIndex={disabled ? -1 : tabIndex}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
{children ?? <span className="hidden sm:block">Previous</span>}
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="hidden sm:block">Next</span>}
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
// A non-modal floating panel (not a Dialog or Sheet). Surface, border, radius, shadow, width and padding come from
|
||||
// src/theme/popover.css. Radix keeps Portal, positioning, collision handling, Escape / outside close and focus return.
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 origin-(--radix-popover-content-transform-origin) outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
// The Radix close button. With asChild the child keeps its own data-slot (same rule as DialogClose), so a Button
|
||||
// inside keeps its HM style.
|
||||
function PopoverClose({
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Close>) {
|
||||
const slot = asChild ? {} : { "data-slot": "popover-close" }
|
||||
return <PopoverPrimitive.Close asChild={asChild} {...slot} {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Progress keeps `role="progressbar"` with aria-valuemin / aria-valuemax / aria-valuenow (and `data-state`:
|
||||
// loading | complete | indeterminate). The indicator width follows `value` (0..max). Look comes from packages/ui/src/theme/progress.css.
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
max,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
// Use the same valid range/default as Radix so the visual fill agrees with its ARIA value.
|
||||
const effectiveMax = typeof max === "number" && max > 0 ? max : 100
|
||||
const percentage = typeof value === "number" && value >= 0 && value <= effectiveMax
|
||||
? (value / effectiveMax) * 100
|
||||
: 0
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn("relative w-full overflow-hidden", className)}
|
||||
value={value}
|
||||
max={max}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(${percentage - 100}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
// Radix RadioGroup structure, roving focus, arrow-key selection and ARIA are kept (ui-viz has no Radio spec; reference only);
|
||||
// the look comes from src/theme/radio-group.css.
|
||||
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid", className)} {...props} />
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio peer relative inline-flex aspect-square shrink-0 items-center justify-center outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator data-slot="radio-group-indicator" className="flex items-center justify-center">
|
||||
<CircleIcon aria-hidden="true" className="fill-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Group, Panel, Separator } from "react-resizable-panels"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
// Panel layout interaction on react-resizable-panels v4 (Group / Panel / Separator). The library owns dragging, min / max
|
||||
// constraints, collapsing, the layout math, keyboard resizing, pointer hit-target expansion and the separator ARIA
|
||||
// (`role="separator"`, `aria-valuenow|min|max`, `aria-controls`, `aria-orientation`); it also sets the panels' inline sizing,
|
||||
// which cannot be overridden. Sizes: a number is pixels, a string without a unit ("30") or with "%" is a percentage.
|
||||
// Persistence, sidebar state and layout presets are not here (a later layout pattern). Look: src/theme/resizable.css.
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
// `withHandle` adds a visible grip in the middle of the divider (shadcn API). The divider itself is thin; the pointer target is
|
||||
// larger because the library expands it.
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn("relative flex items-center justify-center outline-none", className)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div
|
||||
data-slot="resizable-handle-grip"
|
||||
className="z-10 flex items-center justify-center"
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
// Radix ScrollArea owns scrolling (wheel, touch, native keyboard scroll, viewport measurement, thumb drag); only the scrollbar
|
||||
// chrome is styled, in src/theme/scroll-area.css. The scrollbar `type` keeps the Radix default ("hover").
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { useConnectedPopupRef } from "./use-connected-popup-ref"
|
||||
|
||||
// ui_viz Select size (docs/components/select.md §1.1): medium (outer 34px) or large (outer 44px, default).
|
||||
type SelectSize = "medium" | "large"
|
||||
|
||||
// One size per Select: SelectTrigger owns the `size` prop and registers it here, so the Portal-ed SelectContent (and its
|
||||
// items) read the same value without a second prop. Large is the default before the Trigger registers.
|
||||
const SelectSizeContext = React.createContext<{ size: SelectSize; setSize: (size: SelectSize) => void }>({
|
||||
size: "large",
|
||||
setSize: () => {},
|
||||
})
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const [size, setSize] = React.useState<SelectSize>("large")
|
||||
const value = React.useMemo(() => ({ size, setSize }), [size])
|
||||
return (
|
||||
<SelectSizeContext.Provider value={value}>
|
||||
<SelectPrimitive.Root data-slot="select" {...props} />
|
||||
</SelectSizeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
// `size` replaces the shadcn "sm" | "default". Border, radius, height, padding, typography and the Open / Focus looks come from
|
||||
// src/theme/select.css, including the independent Invalid border and keyboard Focus ring. The indicator is the ui_viz 18x18 glyph (§7).
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "large",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: SelectSize
|
||||
}) {
|
||||
const { setSize } = React.useContext(SelectSizeContext)
|
||||
React.useLayoutEffect(() => setSize(size), [size, setSize])
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between transition-colors outline-none select-none whitespace-nowrap disabled:cursor-not-allowed *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<svg
|
||||
data-slot="select-icon"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.50542"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M5 7 L9 11 L13 7" />
|
||||
</svg>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
// popper position, directly under the trigger: sideOffset -1 shares the 1px border, and select.css gives the list the
|
||||
// trigger width and the connected outline (docs/components/select.md §8).
|
||||
function SelectContent({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "start",
|
||||
sideOffset = -1,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
const { size } = React.useContext(SelectSizeContext)
|
||||
const popupRef = useConnectedPopupRef(ref)
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={popupRef}
|
||||
data-slot="select-content"
|
||||
data-size={size}
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ui_viz shows Hover, Selected and Keyboard Active as a background face only (no border, no check icon), so the
|
||||
// shadcn check indicator is removed and a selected item uses the same face as a highlighted one.
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
export type { SelectSize }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Separator: `decorative` (default true, no semantics) and `orientation` are kept. Thickness and color come from src/theme/separator.css.
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn("shrink-0 data-[orientation=horizontal]:w-full data-[orientation=vertical]:self-stretch", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "./button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
// Same asChild rule as DialogClose: with asChild the child keeps its own data-slot so an HM Button keeps its style.
|
||||
function SheetClose({
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
const slot = asChild ? {} : { "data-slot": "sheet-close" }
|
||||
return <SheetPrimitive.Close asChild={asChild} {...slot} {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// `side` is the shadcn API (top | right | bottom | left, default right). Position and slide animation stay shadcn;
|
||||
// surface, border, shadow, size and regions come from src/theme/sheet.css. Radix keeps Portal, focus trap, Escape and focus return.
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col outline-none transition duration-200 ease-in-out data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Body region: the scroll owner, so Header and Footer stay in view. Padding and scrolling come from src/theme/sheet.css.
|
||||
function SheetBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="sheet-body" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col-reverse sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "cn"
|
||||
|
||||
// Visual placeholder only (no loading logic). Size and shape come from `className`; the color and radius from
|
||||
// src/theme/skeleton.css; the pulse is the shadcn `animate-pulse`.
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Slider keeps value / defaultValue / onValueChange / onValueCommit, min / max / step, orientation, inverted, disabled,
|
||||
// pointer drag, the keyboard model (Arrow keys, Home / End, PageUp / PageDown) and `role="slider"` ARIA on each thumb.
|
||||
// An array value renders one thumb per entry (a range uses two). Look comes from src/theme/slider.css.
|
||||
// The `role="slider"` element is the Thumb, not the Root, so the Slider hands its `aria-label` to every thumb (a range gets
|
||||
// "label 1/2", "label 2/2"). Without `aria-label` Radix keeps its own thumb names (Minimum / Maximum for two thumbs).
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
disabled,
|
||||
name,
|
||||
min = 0,
|
||||
max = 100,
|
||||
"aria-label": ariaLabel,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
// Radix initializes uncontrolled values once; keep the thumb count on the same initial array.
|
||||
const initialValues = React.useRef(Array.isArray(defaultValue) ? defaultValue : [min])
|
||||
const _values = Array.isArray(value) ? value : initialValues.current
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
// Installed Radix does not disable its form inputs; unnamed inputs are excluded from submission.
|
||||
name={disabled ? undefined : name}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-vertical:h-full data-vertical:min-h-44 data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="absolute"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
aria-label={
|
||||
ariaLabel
|
||||
? _values.length > 1
|
||||
? `${ariaLabel} ${index + 1}/${_values.length}`
|
||||
: ariaLabel
|
||||
: undefined
|
||||
}
|
||||
className="block shrink-0 outline-hidden"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, LoaderCircleIcon, OctagonXIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
// Toast is Sonner. Sonner owns the queue, stacking, timeout, hover pause, swipe, promise lifecycle and the `aria-live` region;
|
||||
// this file only supplies the shadcn icon set. Surface, text, action and status values come from src/theme/sonner.css.
|
||||
// There is no Light / Dark switch (no next-themes), so `theme` stays Sonner's default. Place <Toaster /> once at the app root.
|
||||
function Toaster(props: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <LoaderCircleIcon className="size-4 animate-spin" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
export { toast } from "sonner"
|
||||
export type { ToasterProps, ExternalToast } from "sonner"
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
// Radix Switch structure (role=switch, data-state, keyboard) is kept; the look comes from src/theme/switch.css.
|
||||
// Transitions are the shadcn defaults (transition-all on the track, transition-transform on the thumb); no new motion is defined.
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex shrink-0 items-center outline-none transition-all focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb data-slot="switch-thumb" className="pointer-events-none block transition-transform" />
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// Static data display primitive: semantic <table> elements only. No sorting, filtering, selection state, column logic or paging;
|
||||
// those belong to a later Data Table pattern that composes this Table. The container scrolls horizontally on a narrow screen
|
||||
// (the Table is never turned into cards). Look comes from src/theme/table.css.
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return <thead data-slot="table-header" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return <tbody data-slot="table-body" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return <tfoot data-slot="table-footer" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
// A row marks the selected state with `data-state="selected"` (shadcn); the consumer decides which rows are selected.
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr data-slot="table-row" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"align-middle [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// ui_viz docs/components/tabs.md: horizontal, text-only, no size/variant axis. Bar, Tab geometry, colors, the Selected connection,
|
||||
// the `fill` profile and the overflow move buttons come from src/theme/tabs.css. The content surface is the consumer's panel.
|
||||
// TabsList renders a frame (`data-slot="tabs-frame"`) around the Radix list; the move buttons appear in the frame only when the tabs overflow (D-090).
|
||||
type TabsPanelSurface = "surface" | "content"
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn("group/tabs flex flex-col", className)} {...props} />
|
||||
}
|
||||
|
||||
const reducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
fill = true,
|
||||
panelSurface = "content",
|
||||
id,
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||
/** Defaults to Primary Fill: equal-width tabs, Green selected border, curved joins. Use false for Legacy non-fill. */
|
||||
fill?: boolean
|
||||
/** Defaults to content (Primary); surface selects Secondary. Visually ignored when fill is false. */
|
||||
panelSurface?: TabsPanelSurface
|
||||
}) {
|
||||
const uid = React.useId()
|
||||
const listId = id ?? `tabs-list-${uid}`
|
||||
const frameRef = React.useRef<HTMLDivElement>(null)
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [overflow, setOverflow] = React.useState(false)
|
||||
const [edge, setEdge] = React.useState({ start: true, end: true })
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
listRef.current = node
|
||||
if (typeof ref === "function") ref(node)
|
||||
else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
|
||||
const update = React.useCallback(() => {
|
||||
const list = listRef.current
|
||||
const frame = frameRef.current
|
||||
if (!list || !frame) return
|
||||
// Overflow: needed tab width > the whole frame width (measured before the buttons take space, so showing them cannot flip the result).
|
||||
setOverflow(list.scrollWidth > frame.clientWidth + 0.5)
|
||||
setEdge({ start: list.scrollLeft <= 0.5, end: list.scrollLeft + list.clientWidth >= list.scrollWidth - 0.5 })
|
||||
}, [])
|
||||
|
||||
const keepSelectedVisible = React.useCallback(() => {
|
||||
const list = listRef.current
|
||||
const active = list?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')
|
||||
if (!list || !active) return
|
||||
const l = list.getBoundingClientRect()
|
||||
const a = active.getBoundingClientRect()
|
||||
if (a.left < l.left || a.width > l.width) list.scrollLeft += a.left - l.left
|
||||
else if (a.right > l.right) list.scrollLeft += a.right - l.right
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const list = listRef.current
|
||||
const frame = frameRef.current
|
||||
if (!list || !frame) return
|
||||
const sync = () => {
|
||||
update()
|
||||
keepSelectedVisible()
|
||||
}
|
||||
const resize = new ResizeObserver(sync)
|
||||
resize.observe(frame)
|
||||
resize.observe(list)
|
||||
const mutation = new MutationObserver(sync)
|
||||
mutation.observe(list, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["data-state"] })
|
||||
list.addEventListener("scroll", update, { passive: true })
|
||||
document.fonts?.ready.then(sync)
|
||||
sync()
|
||||
return () => {
|
||||
resize.disconnect()
|
||||
mutation.disconnect()
|
||||
list.removeEventListener("scroll", update)
|
||||
}
|
||||
}, [update, keepSelectedVisible])
|
||||
|
||||
// One item per press: align the first item cut off on that side with the visible edge.
|
||||
const step = (dir: 1 | -1) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const l = list.getBoundingClientRect()
|
||||
const tabs = Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
let delta = 0
|
||||
if (dir === 1) {
|
||||
const next = tabs.find((t) => t.getBoundingClientRect().right > l.right + 0.5)
|
||||
if (next) delta = next.getBoundingClientRect().right - l.right
|
||||
} else {
|
||||
const prev = [...tabs].reverse().find((t) => t.getBoundingClientRect().left < l.left - 0.5)
|
||||
if (prev) delta = prev.getBoundingClientRect().left - l.left
|
||||
}
|
||||
if (delta) list.scrollBy({ left: delta, behavior: reducedMotion() ? "auto" : "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
data-slot="tabs-frame"
|
||||
data-overflow={overflow}
|
||||
data-fill={fill}
|
||||
data-panel-surface={panelSurface}
|
||||
>
|
||||
{overflow ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="tabs-arrow"
|
||||
data-direction="previous"
|
||||
aria-label="이전 탭 보기"
|
||||
aria-controls={listId}
|
||||
disabled={edge.start}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<TabsPrimitive.List ref={setRefs} id={listId} data-slot="tabs-list" className={cn("inline-flex w-fit", className)} {...props} />
|
||||
{overflow ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="tabs-arrow"
|
||||
data-direction="next"
|
||||
aria-label="다음 탭 보기"
|
||||
aria-controls={listId}
|
||||
disabled={edge.end}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex flex-none cursor-pointer items-center justify-center whitespace-nowrap outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export type { TabsPanelSurface }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// `resize` is the ui_viz Textarea resize policy (docs/components/textarea.md §2): vertical by default, none or both on request.
|
||||
// It replaces nothing in native HTML; readonly always resolves to none (§3.1). Face, border, padding and the Focus / Error looks
|
||||
// come from src/theme/textarea.css. Textarea has no size scale in ui_viz, so there is no `size` prop.
|
||||
type TextareaResize = "vertical" | "none" | "both"
|
||||
|
||||
function Textarea({
|
||||
className,
|
||||
resize = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"textarea"> & { resize?: TextareaResize }) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
data-resize={resize}
|
||||
className={cn(
|
||||
"block w-full min-w-0 transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export type { TextareaResize }
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { hasSingleElementChild, toggleVariants } from "./toggle"
|
||||
|
||||
// A group of pressed-state items. Radix keeps type single | multiple, value / defaultValue / onValueChange, orientation, disabled,
|
||||
// rovingFocus, `role="group"` (single: `radio` items with `aria-checked`; multiple: `aria-pressed`) and arrow-key movement.
|
||||
// `variant`, `size` and `spacing` (shadcn API) are given to the group and read by the items. Items are not the Toggle component:
|
||||
// they have their own look (src/theme/toggle-group.css, `--hm-toggle-group-*`). With `spacing={0}` (default) the items are connected.
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--hm-toggle-group-gap": `${spacing * 4}px` } as React.CSSProperties}
|
||||
className={cn("group/toggle-group flex w-fit flex-row items-center data-vertical:flex-col data-vertical:items-stretch", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
data-icon-only={hasSingleElementChild(children) ? "" : undefined}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center whitespace-nowrap outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
// A persistent pressed-state control (not an action button): Radix keeps pressed / defaultPressed / onPressedChange, disabled,
|
||||
// `aria-pressed`, `data-state="on" | "off"`, Enter / Space. The shadcn `variant` (default | outline) and `size` (default | sm | lg)
|
||||
// API is kept, but the look is HM's own (src/theme/toggle.css, `--hm-toggle-*`); it does not use Button variants or tokens.
|
||||
const toggleVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center whitespace-nowrap outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
outline: "",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "",
|
||||
lg: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Count content nodes rather than element siblings: CSS :only-child ignores a label's text node.
|
||||
// Arrays and fragments containing one icon must keep the same square geometry as a direct icon child.
|
||||
function hasSingleElementChild(children: React.ReactNode): boolean {
|
||||
const content = React.Children.toArray(children)
|
||||
if (content.length !== 1 || !React.isValidElement(content[0])) return false
|
||||
const child = content[0]
|
||||
return child.type === React.Fragment
|
||||
? hasSingleElementChild((child.props as { children?: React.ReactNode }).children)
|
||||
: true
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-icon-only={hasSingleElementChild(children) ? "" : undefined}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// hasSingleElementChild is shared internally with ToggleGroup; it is not exported by the package entry point.
|
||||
export { Toggle, toggleVariants, hasSingleElementChild }
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
const TooltipProviderContext = React.createContext(false)
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipProviderContext.Provider value={true}>
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Provider>
|
||||
</TooltipProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Reuse an HM Provider so Radix shares both settings and skip-delay state across the group.
|
||||
// A standalone Tooltip keeps its own default Provider.
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
const hasProvider = React.useContext(TooltipProviderContext)
|
||||
const tooltip = <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
|
||||
return hasProvider ? tooltip : <TooltipProvider>{tooltip}</TooltipProvider>
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
// Short, non-interactive text only (longer or interactive content belongs in Popover / Hover Card). Colors, radius,
|
||||
// padding and typography come from src/theme/tooltip.css; the arrow is the shadcn rotated square.
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) text-balance data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow
|
||||
data-slot="tooltip-arrow"
|
||||
className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]"
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
|
||||
// Radix exposes the collision-resolved side on Content, but not its portaled Trigger.
|
||||
// Mirror that attribute through Radix's own ARIA relationship; positioning stays Radix's.
|
||||
// No separate side state, global :has selector, or consumer API is needed.
|
||||
function useConnectedPopupRef(ref?: React.Ref<HTMLDivElement>) {
|
||||
return React.useCallback((content: HTMLDivElement | null) => {
|
||||
const refCleanup = typeof ref === "function" ? ref(content) : undefined
|
||||
if (ref && typeof ref !== "function") ref.current = content
|
||||
if (!content) return
|
||||
|
||||
const trigger = Array.from(content.ownerDocument.querySelectorAll<HTMLElement>("[aria-controls]"))
|
||||
.find((element) => element.getAttribute("aria-controls") === content.id)
|
||||
const sync = () => {
|
||||
const side = content.getAttribute("data-side")
|
||||
if (side === "top" || side === "bottom") trigger?.setAttribute("data-side", side)
|
||||
else trigger?.removeAttribute("data-side")
|
||||
}
|
||||
sync()
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(content, { attributes: true, attributeFilter: ["data-side"] })
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
trigger?.removeAttribute("data-side")
|
||||
if (typeof refCleanup === "function") refCleanup()
|
||||
else if (typeof ref === "function") ref(null)
|
||||
else if (ref) ref.current = null
|
||||
}
|
||||
}, [ref])
|
||||
}
|
||||
|
||||
export { useConnectedPopupRef }
|
||||
Reference in new issue
Block a user