Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
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,
}
+33
View File
@@ -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 }
+53
View File
@@ -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 }
+34
View File
@@ -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,
}
+84
View File
@@ -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 }
+110
View File
@@ -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,
}
+230
View File
@@ -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 }
+134
View File
@@ -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 }
+127
View File
@@ -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 }
+179
View File
@@ -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,
}
+148
View File
@@ -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 }
+29
View File
@@ -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 }
+20
View File
@@ -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 }
+278
View File
@@ -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,
}
+59
View File
@@ -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 }
+228
View File
@@ -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 }
+156
View File
@@ -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 }
+70
View File
@@ -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 }
+26
View File
@@ -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"
+22
View File
@@ -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 }
+87
View File
@@ -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,
}
+162
View File
@@ -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 }
+66
View File
@@ -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 }
+73
View File
@@ -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 }