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 }
+286
View File
@@ -0,0 +1,286 @@
// Public API of @hm-design/ui. Styles are separate entries: "@hm-design/ui/tailwind.css" and "@hm-design/ui/theme.css".
export { Button, buttonVariants } from "./components/ui/button"
export type { ButtonAppearance } from "./components/ui/button"
export { Input } from "./components/ui/input"
export type { InputSize } from "./components/ui/input"
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "./components/ui/select"
export type { SelectSize } from "./components/ui/select"
export { Textarea } from "./components/ui/textarea"
export type { TextareaResize } from "./components/ui/textarea"
export { Card,CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./components/ui/card"
export type { CardSurface } from "./components/ui/card"
export {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "./components/ui/dialog"
export { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
export type { TabsPanelSurface } from "./components/ui/tabs"
export { Label } from "./components/ui/label"
export { Checkbox } from "./components/ui/checkbox"
export { RadioGroup, RadioGroupItem } from "./components/ui/radio-group"
export { Switch } from "./components/ui/switch"
export { Badge, badgeVariants } from "./components/ui/badge"
export { Alert, AlertDescription, AlertTitle } from "./components/ui/alert"
export { Separator } from "./components/ui/separator"
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./components/ui/dropdown-menu"
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger } from "./components/ui/popover"
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip"
export { HoverCard, HoverCardContent, HoverCardTrigger } from "./components/ui/hover-card"
export {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
} from "./components/ui/sheet"
export {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
} from "./components/ui/drawer"
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
} from "./components/ui/alert-dialog"
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion"
export { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./components/ui/collapsible"
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "./components/ui/breadcrumb"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "./components/ui/pagination"
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
} from "./components/ui/navigation-menu"
export {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarLabel,
MenubarMenu,
MenubarPortal,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "./components/ui/menubar"
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "./components/ui/table"
export { Progress } from "./components/ui/progress"
export { Skeleton } from "./components/ui/skeleton"
export { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar"
export { ScrollArea, ScrollBar } from "./components/ui/scroll-area"
export { Slider } from "./components/ui/slider"
export { Toggle, toggleVariants } from "./components/ui/toggle"
export { ToggleGroup, ToggleGroupItem } from "./components/ui/toggle-group"
export { Calendar } from "./components/ui/calendar"
export {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "./components/ui/command"
export { DatePicker } from "./components/ui/date-picker"
export type { DatePickerSize } from "./components/ui/date-picker"
export { Combobox } from "./components/ui/combobox"
export type { ComboboxOption, ComboboxSize } from "./components/ui/combobox"
export {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
} from "./components/ui/carousel"
export type { CarouselApi } from "./components/ui/carousel"
export { AspectRatio } from "./components/ui/aspect-ratio"
export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable"
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "./components/ui/context-menu"
export { Toaster, toast } from "./components/ui/sonner"
export type { ExternalToast, ToasterProps } from "./components/ui/sonner"
// Patterns: reusable work-screen UI composed from the primitives above (src/patterns). Pattern code adds no primitive styling of its own.
export {
DataTable,
DataTableColumnHeader,
DataTablePagination,
DataTableViewOptions,
dataTableSelectColumn,
useDataTable,
} from "./patterns/data-table"
export type { DataTableColumnDef, DataTableInstance, DataTableOptions, DataTableProps } from "./patterns/data-table"
export { FilterBar, FilterBarActions, FilterBarSearch } from "./patterns/filter-bar"
export type { FilterBarSearchProps } from "./patterns/filter-bar/filter-bar"
export {
countDashboardFilter,
dashboardFilterDefinitions,
dashboardFilterIds,
dashboardStatusAttributes,
dashboardStatusDefinitions,
matchesDashboardFilter,
} from "./patterns/dashboard-status"
export type { DashboardFilterDefinition, DashboardFilterId, DashboardStatusDefinition, DashboardStatusId, DashboardStatusRecord, DashboardStatusTone } from "./patterns/dashboard-status"
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities } from "./patterns/gnb"
export type { GNBMobileMenuProps, GNBUserProps } from "./patterns/gnb"
export { LNB, LNBBrand, LNBGroup, LNBItem, LNBNav, LNBProfile } from "./patterns/lnb"
export { LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink, LNBMobileGroup } from "./patterns/lnb"
export type { LNBMobileMenuProps } from "./patterns/lnb"
export type { LNBGroupProps, LNBProfileProps } from "./patterns/lnb"
export { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle } from "./patterns/page-header"
export { DashboardContent, DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar } from "./patterns/dashboard-layout"
export type { DashboardLayoutProps } from "./patterns/dashboard-layout"
export { LoadingScreen } from "./patterns/loading-screen"
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn"
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { cn } from "cn"
// DASHBOARD is one navigation mode. Slots contain Dashboard-owned Header/Menu/Sidebar, never external GNB or general LNB patterns.
// DashboardMain owns vertical scrolling; the outer frame scrolls the entire Dashboard horizontally below 960px (1232px with sidebar).
// Dashboard navigation keeps its geometry at every viewport width, independent of the GNB/LNB responsive policies.
type DashboardLayoutProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Dashboard navigation header; do not pass a GNB. */
header?: React.ReactNode
/** Dashboard navigation sidebar; do not pass a general LNB. */
sidebar?: React.ReactNode
children: React.ReactNode
}
function DashboardLayout({ header, sidebar, className, children, ...props }: DashboardLayoutProps) {
return (
<div role="region" aria-label="Dashboard" tabIndex={0} className={cn(className)} {...props} data-slot="dashboard-scroll" data-navigation-mode="DASHBOARD">
<div
data-slot="dashboard-layout"
data-header={header ? "" : undefined}
data-sidebar={sidebar ? "" : undefined}
className="flex flex-col"
>
{header}
<div data-slot="dashboard-body" className="flex">
{sidebar}
{children}
</div>
</div>
</div>
)
}
// Internal navigation elements: share Semantic tokens, not GNB/LNB layout or responsive variables.
function DashboardHeader({ className, ...props }: React.ComponentProps<"header">) {
return <header className={cn(className)} {...props} data-slot="dashboard-header" />
}
function DashboardSidebar({ className, ...props }: React.ComponentProps<"aside">) {
return <aside className={cn("flex flex-col", className)} {...props} data-slot="dashboard-sidebar" />
}
// The content surface and vertical scroll container of the screen (`<main>`). `min-width: 0` keeps wide children (tables, carousels,
// comboboxes) from widening the page; they scroll or shrink inside it.
function DashboardMain({ className, ...props }: React.ComponentProps<"main">) {
return <main data-slot="dashboard-main" className={cn(className)} {...props} />
}
// Vertical stack of sections (cards, Filter Bar + Data Table...) with the layout gap. It styles no section itself.
function DashboardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dashboard-content" className={cn("grid", className)} {...props} />
}
export { DashboardContent, DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar }
export type { DashboardLayoutProps }
@@ -0,0 +1,2 @@
export { DashboardContent, DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar } from "./dashboard-layout"
export type { DashboardLayoutProps } from "./dashboard-layout"
@@ -0,0 +1,55 @@
/**
* Runtime-neutral Dashboard work status contract.
*
* This file intentionally has no React or DOM dependency. React, Static HTML,
* and other runtimes consume the same ids, labels, matching rules, and
* `data-*` state vocabulary.
*/
export type DashboardStatusId = "active" | "hold" | "done"
export type DashboardFilterId = "all" | DashboardStatusId | "issue"
export type DashboardStatusTone = "progress" | "hold" | "complete" | "issue"
export type DashboardStatusDefinition = {
id: DashboardStatusId
label: string
tone: DashboardStatusTone
ariaLabel: string
}
export type DashboardFilterDefinition =
| { id: "all"; label: string; ariaLabel: string }
| (DashboardStatusDefinition & { id: DashboardStatusId })
| { id: "issue"; label: string; tone: "issue"; ariaLabel: string; independentFlag: true }
export const dashboardStatusDefinitions: Record<DashboardStatusId, DashboardStatusDefinition> = {
active: { id: "active", label: "진행", tone: "progress", ariaLabel: "진행 상태" },
hold: { id: "hold", label: "보류", tone: "hold", ariaLabel: "보류 상태" },
done: { id: "done", label: "완료", tone: "complete", ariaLabel: "완료 상태" },
}
export const dashboardFilterDefinitions: Record<DashboardFilterId, DashboardFilterDefinition> = {
all: { id: "all", label: "전체", ariaLabel: "전체 업무" },
...dashboardStatusDefinitions,
issue: { id: "issue", label: "이슈", tone: "issue", ariaLabel: "이슈 업무", independentFlag: true },
}
export const dashboardFilterIds: DashboardFilterId[] = ["all", "active", "hold", "done", "issue"]
export type DashboardStatusRecord = { status: DashboardStatusId; issue?: boolean }
export function matchesDashboardFilter(record: DashboardStatusRecord, filter: DashboardFilterId): boolean {
return filter === "all" || (filter === "issue" ? record.issue === true : record.status === filter)
}
export function countDashboardFilter<T extends DashboardStatusRecord>(records: readonly T[], filter: DashboardFilterId): number {
return records.filter((record) => matchesDashboardFilter(record, filter)).length
}
/** Stable DOM vocabulary for React, Static HTML, and other adapters. */
export const dashboardStatusAttributes = {
status: "data-status",
issue: "data-issue",
filter: "data-filter",
selected: "aria-pressed",
} as const
@@ -0,0 +1,13 @@
/* Runtime-neutral compatibility contract for the existing Dashboard Story status colors.
These are not public Theme tokens. They preserve the approved Story values until
a Production-wide semantic status palette is explicitly introduced. */
:root {
--hm-dashboard-status-active: #00c896;
--hm-dashboard-status-hold: #ff9500;
--hm-dashboard-status-issue: #ff4d4f;
}
/* Resolve the Theme-dependent alias in each independent HM Theme scope. */
:root, [data-hm-theme] {
--hm-dashboard-status-done: var(--hm-sem-palette-white);
}
@@ -0,0 +1,9 @@
export {
countDashboardFilter,
dashboardFilterDefinitions,
dashboardFilterIds,
dashboardStatusAttributes,
dashboardStatusDefinitions,
matchesDashboardFilter,
} from "./contract"
export type { DashboardFilterDefinition, DashboardFilterId, DashboardStatusDefinition, DashboardStatusId, DashboardStatusRecord, DashboardStatusTone } from "./contract"
@@ -0,0 +1,335 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import {
columnFilteringFeature,
columnVisibilityFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
filterFn_equalsString,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
tableFeatures,
useTable,
type Column,
type ColumnDef,
type ReactTable,
type RowData,
type TableOptions,
} from "@tanstack/react-table"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ChevronDownIcon } from "lucide-react"
import { Button } from "../../components/ui/button"
import { Checkbox } from "../../components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../../components/ui/pagination"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"
import { Skeleton } from "../../components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
// Data Table pattern: the HM Table primitive (presentation only) driven by TanStack Table v9. The table state model (sorting, filters,
// selection, pagination, column visibility) is TanStack's own; this file registers the features and composes the HM primitives.
// Cell / header looks stay in src/theme/table.css; only the toolbar, footer, empty and loading wrappers are styled here (src/theme/data-table.css).
const dataTableFeatures = tableFeatures({
columnFilteringFeature,
globalFilteringFeature,
rowSortingFeature,
rowSelectionFeature,
rowPaginationFeature,
columnVisibilityFeature,
filteredRowModel: createFilteredRowModel(),
sortedRowModel: createSortedRowModel(),
paginatedRowModel: createPaginatedRowModel(),
filterFns: { includesString: filterFn_includesString, equalsString: filterFn_equalsString },
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic, datetime: sortFn_datetime },
})
type DataTableFeatures = typeof dataTableFeatures
type DataTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<DataTableFeatures, TData, TValue>
type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
type DataTableOptions<TData extends RowData> = Omit<TableOptions<DataTableFeatures, TData>, "features">
// Keep `columns` and `data` referentially stable (module scope, useMemo or state): a new array each render rebuilds every row model.
function useDataTable<TData extends RowData>(options: DataTableOptions<TData>): DataTableInstance<TData> {
return useTable({ features: dataTableFeatures, ...options } as TableOptions<DataTableFeatures, TData>)
}
// Selection column: header checkbox (all rows of the page, mixed when some) and one checkbox per row, both the HM Checkbox.
function dataTableSelectColumn<TData extends RowData>(): DataTableColumnDef<TData, unknown> {
return {
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="현재 페이지 전체 선택"
checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(value === true)}
/>
),
cell: ({ row }) => (
<Checkbox aria-label="행 선택" checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(value === true)} />
),
enableSorting: false,
enableHiding: false,
}
}
// Sortable header: a button that cycles none → asc → desc → none (TanStack's own order). The sort state is announced by `aria-sort`
// on the <th>, which DataTable sets from the column.
function DataTableColumnHeader<TData extends RowData, TValue>({
column,
title,
className,
}: {
column: Column<DataTableFeatures, TData, TValue>
title: string
className?: string
}) {
if (!column.getCanSort()) return <span className={className}>{title}</span>
const sorted = column.getIsSorted()
const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ArrowUpDownIcon
return (
<button
type="button"
data-slot="data-table-sort"
data-sorted={sorted || undefined}
className={cn("inline-flex items-center", className)}
onClick={() => (sorted === "desc" ? column.clearSorting() : column.toggleSorting(sorted === "asc"))}
aria-label={`${title} 정렬${sorted === "asc" ? ", 오름차순" : sorted === "desc" ? ", 내림차순" : ""}`}
>
{title}
<Icon aria-hidden="true" />
</button>
)
}
// Column visibility menu: the HM Dropdown Menu with one checkbox item per hideable column (labelled from `labels[column.id]`, else the column id).
function DataTableViewOptions<TData extends RowData>({
table,
label = "열",
labels,
className,
}: {
table: DataTableInstance<TData>
label?: string
/** Menu text per column id (a column header can be a component, so it cannot be read back). */
labels?: Record<string, string>
className?: string
}) {
const columns = table.getAllLeafColumns().filter((column) => column.getCanHide())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="primary" appearance="outline" className={className}>
{label}
<ChevronDownIcon aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>표시할 열</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(value)}
onSelect={(event) => event.preventDefault()}
>
{labels?.[column.id] ?? column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// Page list with ellipses: 1 … (p-1 p p+1) … total.
function pageRange(page: number, total: number): (number | "start" | "end")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
const out: (number | "start" | "end")[] = [1]
const from = Math.max(2, Math.min(page - 1, total - 4))
const to = Math.min(total - 1, Math.max(page + 1, 5))
if (from > 2) out.push("start")
for (let p = from; p <= to; p++) out.push(p)
if (to < total - 1) out.push("end")
out.push(total)
return out
}
// Footer: selected count, page size (HM Select) and the HM Pagination bound to the TanStack page state.
function DataTablePagination<TData extends RowData>({
table,
pageSizeOptions = [10, 20, 50],
className,
}: {
table: DataTableInstance<TData>
pageSizeOptions?: number[]
className?: string
}) {
const { pageIndex, pageSize } = table.state.pagination
const pageCount = Math.max(table.getPageCount(), 1)
const page = pageIndex + 1
const selected = table.getFilteredSelectedRowModel().rows.length
const total = table.getFilteredRowModel().rows.length
const go = (next: number) => (event: React.MouseEvent) => {
event.preventDefault()
table.setPageIndex(next - 1)
}
return (
<div data-slot="data-table-footer" className={className}>
<p data-slot="data-table-summary" role="status">
{selected > 0 ? `${selected}개 선택됨 · 전체 ${total}개` : `전체 ${total}개`}
</p>
<div data-slot="data-table-page-size">
<span id="data-table-page-size-label">페이지당</span>
<Select value={String(pageSize)} onValueChange={(value) => table.setPageSize(Number(value))}>
<SelectTrigger size="medium" aria-labelledby="data-table-page-size-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}개
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination data-slot="data-table-pagination">
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" aria-disabled={!table.getCanPreviousPage()} onClick={go(page - 1)} />
</PaginationItem>
{pageRange(page, pageCount).map((entry) =>
typeof entry === "number" ? (
<PaginationItem key={entry}>
<PaginationLink href="#" isActive={entry === page} onClick={go(entry)}>
{entry}
</PaginationLink>
</PaginationItem>
) : (
<PaginationItem key={entry}>
<PaginationEllipsis />
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext href="#" aria-disabled={!table.getCanNextPage()} onClick={go(page + 1)} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}
type DataTableProps<TData extends RowData> = {
/** The table instance from `useDataTable`. */
table: DataTableInstance<TData>
/** Shows skeleton rows instead of data (the header stays). */
loading?: boolean
/** Shown in one full-width row when there are no rows (no data, or every row filtered out). */
emptyMessage?: React.ReactNode
/** Footer with selected count, page size and Pagination. Default true. */
pagination?: boolean
pageSizeOptions?: number[]
className?: string
}
function DataTable<TData extends RowData>({
table,
loading = false,
emptyMessage = "표시할 데이터가 없습니다.",
pagination = true,
pageSizeOptions,
className,
}: DataTableProps<TData>) {
const rows = table.getRowModel().rows
const columnCount = table.getVisibleLeafColumns().length
const skeletonRows = Math.min(table.state.pagination.pageSize, 5)
return (
<div data-slot="data-table" className={className}>
<Table aria-busy={loading || undefined}>
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => {
const sorted = header.column.getIsSorted()
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: skeletonRows }, (_, index) => (
<TableRow key={index}>
{table.getVisibleLeafColumns().map((column) => (
<TableCell key={column.id}>
<Skeleton data-slot="data-table-skeleton" />
</TableCell>
))}
</TableRow>
))
) : rows.length ? (
rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={Math.max(columnCount, 1)} data-slot="data-table-empty">
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{pagination ? <DataTablePagination table={table} pageSizeOptions={pageSizeOptions} /> : null}
</div>
)
}
export {
DataTable,
DataTableColumnHeader,
DataTablePagination,
DataTableViewOptions,
dataTableSelectColumn,
useDataTable,
}
export type { DataTableColumnDef, DataTableInstance, DataTableOptions, DataTableProps }
@@ -0,0 +1,9 @@
export {
DataTable,
DataTableColumnHeader,
DataTablePagination,
DataTableViewOptions,
dataTableSelectColumn,
useDataTable,
} from "./data-table"
export type { DataTableColumnDef, DataTableInstance, DataTableOptions, DataTableProps } from "./data-table"
@@ -0,0 +1,64 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { SearchIcon, XIcon } from "lucide-react"
import { Input, type InputSize } from "../../components/ui/input"
// Filter Bar pattern: the search / filter condition row of a list screen. It is a layout (flex, wrap, control widths) plus one helper;
// the controls inside are the HM Input / Select / Combobox / Date Picker / Button themselves, composed as children, and the consumer
// owns the filter values. It has no dependency on Data Table. The wrapper adds no role (each control keeps its own semantics).
// <FilterBar>
// <FilterBarSearch value onValueChange />
// <Select>…</Select> <Combobox … /> <DatePicker … />
// <FilterBarActions><Button onClick={reset}>초기화</Button></FilterBarActions>
// </FilterBar>
function FilterBar({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="filter-bar" className={cn("flex flex-wrap items-center", className)} {...props} />
}
type FilterBarSearchProps = Omit<React.ComponentProps<"input">, "size" | "value" | "defaultValue" | "onChange" | "type"> & {
value: string
onValueChange: (value: string) => void
size?: InputSize
}
// Search icon + HM Input + a clear button once there is text. `aria-label` defaults to the placeholder so the field always has a name.
function FilterBarSearch({
className,
value,
onValueChange,
size = "medium",
placeholder = "검색",
"aria-label": ariaLabel,
...props
}: FilterBarSearchProps) {
return (
<div data-slot="filter-bar-search" className={cn("relative", className)}>
<SearchIcon aria-hidden="true" data-slot="filter-bar-search-icon" className="pointer-events-none absolute" />
<Input
type="search"
size={size}
value={value}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
onChange={(event) => onValueChange(event.target.value)}
{...props}
/>
{value ? (
<button type="button" data-slot="filter-bar-search-clear" aria-label="검색어 지우기" className="absolute" onClick={() => onValueChange("")}>
<XIcon aria-hidden="true" />
</button>
) : null}
</div>
)
}
// Pushes its content (Reset, Apply, ...) to the end of the row; on a narrow screen it wraps to its own line.
function FilterBarActions({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="filter-bar-actions" className={cn("flex items-center", className)} {...props} />
}
export { FilterBar, FilterBarActions, FilterBarSearch }
export type { FilterBarSearchProps }
@@ -0,0 +1 @@
export { FilterBar, FilterBarActions, FilterBarSearch } from "./filter-bar"
+216
View File
@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { MenuIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { Button } from "../../components/ui/button"
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../../components/ui/sheet"
// GNB pattern: the global bar of a service (brand, primary navigation, utilities). It owns the bar (height, surface, border, padding,
// gaps, the desktop / mobile switch) and nothing inside it: the primary navigation is the HM Navigation Menu, the user menu is
// Dropdown Menu + Avatar, the mobile menu is a Sheet, icon actions are Buttons with Tooltip / Badge / Popover. All of them are composed
// as children and keep their own look and behavior. No menu data lives here; the consumer renders its own items.
// <GNB>
// <GNBMobileMenu>…<GNBMobileLink href active>…</GNBMobileMenu> (mobile only: hamburger → Sheet)
// <GNBBrand href="/">…</GNBBrand>
// <GNBNav><NavigationMenu viewport={false} aria-label="주 메뉴">…</NavigationMenu></GNBNav> (desktop only)
// <GNBUtilities>… <DropdownMenu><DropdownMenuTrigger asChild><GNBUser name description><Avatar/></GNBUser></…></DropdownMenu></GNBUtilities>
// </GNB>
// Position is static by default; `sticky` pins it to the top of the scroll container. It is never fixed: the page layout decides.
// The bar height is exposed as `--hm-gnb-height` for the content layout of GNB Mode.
function GNB({ className, sticky = false, ...props }: React.ComponentProps<"header"> & { sticky?: boolean }) {
return <header data-slot="gnb" data-sticky={sticky ? "" : undefined} className={className} {...props} />
}
// Brand link, horizontal: an optional `img` / `svg` logo (only when you have a logo asset; no placeholder, no empty space) then one
// `<span>` with an optional `<small>` eyebrow and a `<strong>` name; both shorten with an ellipsis when the bar is tight.
// `asChild` renders the consumer's router link.
function GNBBrand({ className, asChild = false, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
return <Comp data-slot="gnb-brand" className={cn("flex items-center", className)} {...props} />
}
// Primary navigation region (desktop only; hidden below the mobile breakpoint), centered on the whole bar. It holds the HM
// `<NavigationMenu viewport={false} aria-label>`, which already renders the `<nav>` landmark, so this wrapper adds no role.
function GNBNav({ className, ref, ...props }: React.ComponentProps<"div">) {
const region = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => region.current!, [])
React.useLayoutEffect(() => {
const node = region.current
if (!node) return
const view = node.ownerDocument.defaultView!
const desktop = view.matchMedia("(min-width: 1024px)")
const resize = new ResizeObserver(() => alignPanels())
const panels = new Set<HTMLElement>()
const alignPanels = () => {
if (!desktop.matches) return
const gnb = node.closest<HTMLElement>('[data-slot="gnb"]')!
const edge = parseFloat(view.getComputedStyle(gnb).paddingLeft)
node.querySelectorAll<HTMLElement>('[data-slot="navigation-menu-content"]').forEach((panel) => {
if (!panels.has(panel)) { panels.add(panel); resize.observe(panel) }
// Use layout geometry, unaffected by Radix's opening scale animation or our correction.
const parent = panel.offsetParent as HTMLElement | null
if (!parent) return
const trigger = parent.querySelector<HTMLElement>('[data-slot="navigation-menu-trigger"]')
if (!trigger) return
const parentLeft = parent.getBoundingClientRect().left + parent.clientLeft
const triggerBounds = trigger.getBoundingClientRect()
const centeredLeft = triggerBounds.left + (triggerBounds.width - panel.offsetWidth) / 2
const left = Math.max(edge, Math.min(centeredLeft, node.ownerDocument.documentElement.clientWidth - edge - panel.offsetWidth))
const offset = left - parentLeft
const value = `${offset}px`
if (panel.style.getPropertyValue("--hm-gnb-panel-offset-x") !== value) {
panel.style.setProperty("--hm-gnb-panel-offset-x", value)
}
// Integer layout widths ignore equal-panel fractional measurement noise.
// Reuse the same clamped geometry; joins may extend on only one side.
const wider = panel.offsetWidth > trigger.offsetWidth
const extensions = {
"--hm-gnb-panel-left-extension": wider ? Math.max(0, triggerBounds.left - left) : 0,
"--hm-gnb-panel-right-extension": wider ? Math.max(0, left + panel.offsetWidth - triggerBounds.right) : 0,
}
Object.entries(extensions).forEach(([property, extension]) => {
const value = `${extension}px`
if (trigger.style.getPropertyValue(property) !== value) trigger.style.setProperty(property, value)
})
})
panels.forEach((panel) => { if (!node.contains(panel)) { resize.unobserve(panel); panels.delete(panel) } })
}
// NavigationMenu has no collision-positioning API. Observe only the GNB region;
// Radix still owns disclosure, focus and keyboard navigation.
const mutation = new MutationObserver(alignPanels)
mutation.observe(node, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state"] })
resize.observe(node)
view.addEventListener("resize", alignPanels)
desktop.addEventListener("change", alignPanels)
alignPanels()
return () => {
mutation.disconnect()
resize.disconnect()
view.removeEventListener("resize", alignPanels)
desktop.removeEventListener("change", alignPanels)
panels.forEach((panel) => panel.style.removeProperty("--hm-gnb-panel-offset-x"))
node.querySelectorAll<HTMLElement>('[data-slot="navigation-menu-trigger"]').forEach((trigger) => {
trigger.style.removeProperty("--hm-gnb-panel-left-extension")
trigger.style.removeProperty("--hm-gnb-panel-right-extension")
})
}
}, [])
return <div ref={region} data-slot="gnb-nav" className={cn("flex items-center", className)} {...props} />
}
// Right-aligned utility actions (search, notification, user menu...). Plain container; the order and the set are the consumer's.
function GNBUtilities({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="gnb-utilities" className={cn("flex items-center", className)} {...props} />
}
type GNBUserProps = Omit<React.ComponentProps<"button">, "children"> & {
name: string
description?: string
/** The Avatar (mobile bar only). */
children?: React.ReactNode
}
// Trigger button of the user menu: name / role text on the desktop bar; the Avatar (children) shows on the mobile bar only, where the
// text is hidden (ui-viz's profile is text-only, so the desktop bar has no image). Because of that the
// accessible name defaults to "<name> 사용자 메뉴". Use it as `<DropdownMenuTrigger asChild>`; the data-slot comes after the
// spread so the trigger's own slot does not replace it (same rule as Button).
function GNBUser({ className, name, description, children, "aria-label": ariaLabel, ...props }: GNBUserProps) {
return (
<button type="button" aria-label={ariaLabel ?? `${name} 사용자 메뉴`} className={cn("flex items-center", className)} {...props} data-slot="gnb-user">
{children}
<span data-slot="gnb-user-text">
<span data-slot="gnb-user-name">{name}</span>
{description ? <span data-slot="gnb-user-description">{description}</span> : null}
</span>
</button>
)
}
type GNBMobileMenuProps = React.ComponentProps<typeof Sheet> & {
/** Names the trigger button, the Sheet title and the navigation. */
title?: string
/** Sheet side. */
side?: "left" | "right"
className?: string
}
// Mobile navigation: a hamburger button (mobile only) that opens an HM Sheet with the destinations as plain links. It replaces the
// desktop Navigation Menu on a narrow screen instead of squeezing it. Radix keeps the focus trap, Escape and focus return.
// `GNBMobileLink` closes it on click. `open` / `defaultOpen` / `onOpenChange` work as on Sheet.
const GNBMobileMenuContext = React.createContext<{ close: () => void } | null>(null)
function GNBMobileMenu({ title = "메뉴", side = "left", className, children, open: openProp, defaultOpen = false, onOpenChange, ...props }: GNBMobileMenuProps) {
const [openState, setOpenState] = React.useState(defaultOpen)
const open = openProp ?? openState
const setOpen = (next: boolean) => {
if (openProp === undefined) setOpenState(next)
onOpenChange?.(next)
}
const context = React.useMemo(() => ({ close: () => setOpen(false) }), [openProp, onOpenChange])
return (
<Sheet open={open} onOpenChange={setOpen} {...props}>
<SheetTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={title} className={cn("lg:hidden", className)}>
<MenuIcon aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side={side} aria-describedby={undefined}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetBody>
<GNBMobileMenuContext.Provider value={context}>
<nav data-slot="gnb-mobile-nav" aria-label={title}>
{children}
</nav>
</GNBMobileMenuContext.Provider>
</SheetBody>
</SheetContent>
</Sheet>
)
}
// A destination link inside the mobile menu. `active` marks the current location (`aria-current="page"`). It closes the Sheet on click
// even when the consumer's router handler calls preventDefault (a Radix Close would skip a prevented click), so it must be used inside
// `GNBMobileMenu`. `asChild` renders the consumer's router link.
function GNBMobileLink({
className,
active = false,
asChild = false,
onClick,
...props
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
const menu = React.useContext(GNBMobileMenuContext)
return (
<Comp
data-slot="gnb-mobile-link"
aria-current={active ? "page" : undefined}
className={cn("flex items-center", className)}
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event)
menu?.close()
}}
{...props}
/>
)
}
// A titled group of links in the mobile menu (the second level of a desktop Navigation Menu content).
function GNBMobileGroup({ className, label, children, ...props }: Omit<React.ComponentProps<"div">, "children"> & { label: string; children: React.ReactNode }) {
const labelId = React.useId()
return (
<div data-slot="gnb-mobile-group" role="group" aria-labelledby={labelId} className={cn(className)} {...props}>
<p data-slot="gnb-mobile-group-label" id={labelId}>
{label}
</p>
{children}
</div>
)
}
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities }
export type { GNBMobileMenuProps, GNBUserProps }
+2
View File
@@ -0,0 +1,2 @@
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities } from "./gnb"
export type { GNBMobileMenuProps, GNBUserProps } from "./gnb"
+5
View File
@@ -0,0 +1,5 @@
export { LNB, LNBBrand, LNBGroup, LNBItem, LNBNav, LNBProfile } from "./lnb"
export type { LNBGroupProps, LNBProfileProps } from "./lnb"
export { LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink, LNBMobileGroup } from "./lnb-mobile"
export type { LNBMobileMenuProps } from "./lnb-mobile"
+113
View File
@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { MenuIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { Button } from "../../components/ui/button"
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../../components/ui/sheet"
// Responsive representation of LNB Mode. Hidden at >=1024px; shares destinations and state with the desktop LNB.
function LNBMobileHeader({ className, ...props }: React.ComponentProps<"header">) {
return <header className={cn(className)} {...props} data-slot="lnb-mobile-header" />
}
function LNBMobileBrand({ className, asChild = false, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
return <Comp className={cn("flex items-center", className)} {...props} data-slot="lnb-mobile-brand" />
}
type LNBMobileMenuProps = React.ComponentProps<typeof Sheet> & {
/** Names the trigger button, the Sheet title and the navigation. */
title?: string
/** Sheet side. */
side?: "left" | "right"
className?: string
}
// Mobile navigation: a hamburger button (mobile only) that opens an HM Sheet with the destinations as plain links. It replaces the
// desktop LNB on a narrow screen instead of squeezing it. Radix keeps the focus trap, Escape and focus return.
// `LNBMobileLink` closes it on click. `open` / `defaultOpen` / `onOpenChange` work as on Sheet.
const LNBMobileMenuContext = React.createContext<{ close: () => void } | null>(null)
function LNBMobileMenu({ title = "메뉴", side = "left", className, children, open: openProp, defaultOpen = false, onOpenChange, ...props }: LNBMobileMenuProps) {
const [openState, setOpenState] = React.useState(defaultOpen)
const [mobile, setMobile] = React.useState(false)
React.useEffect(() => {
const media = window.matchMedia("(max-width: 1023px)")
const sync = () => setMobile(media.matches)
sync()
media.addEventListener("change", sync)
return () => media.removeEventListener("change", sync)
}, [])
const open = openProp ?? openState
const setOpen = (next: boolean) => {
if (openProp === undefined) setOpenState(next)
onOpenChange?.(next)
}
const context = React.useMemo(() => ({ close: () => setOpen(false) }), [openProp, onOpenChange])
return (
<Sheet open={mobile && open} onOpenChange={setOpen} {...props}>
<SheetTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={title} className={cn("lg:hidden", className)}>
<MenuIcon aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side={side} aria-describedby={undefined}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetBody>
<LNBMobileMenuContext.Provider value={context}>
<nav data-slot="lnb-mobile-nav" aria-label={title}>
{children}
</nav>
</LNBMobileMenuContext.Provider>
</SheetBody>
</SheetContent>
</Sheet>
)
}
// A destination link inside the mobile menu. `active` marks the current location (`aria-current="page"`). It closes the Sheet on click
// even when the consumer's router handler calls preventDefault (a Radix Close would skip a prevented click), so it must be used inside
// `LNBMobileMenu`. `asChild` renders the consumer's router link.
function LNBMobileLink({
className,
active = false,
asChild = false,
onClick,
...props
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
const menu = React.useContext(LNBMobileMenuContext)
return (
<Comp
data-slot="lnb-mobile-link"
aria-current={active ? "page" : undefined}
className={cn("flex items-center", className)}
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event)
menu?.close()
}}
{...props}
/>
)
}
// A titled group of links in the mobile representation of the desktop LNB tree.
function LNBMobileGroup({ className, label, children, ...props }: Omit<React.ComponentProps<"div">, "children"> & { label: string; children: React.ReactNode }) {
const labelId = React.useId()
return (
<div data-slot="lnb-mobile-group" role="group" aria-labelledby={labelId} className={cn(className)} {...props}>
<p data-slot="lnb-mobile-group-label" id={labelId}>
{label}
</p>
{children}
</div>
)
}
export { LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink, LNBMobileGroup }
export type { LNBMobileMenuProps }
+113
View File
@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { ChevronRightIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../components/ui/collapsible"
// Internal composition depth; consumers keep the existing nested-group API.
const LNBGroupDepthContext = React.createContext(0)
// LNB pattern: the local navigation column of a service (brand, menu, profile). Layout visual reference is the ui-viz LNB; the parts
// are plain elements plus the HM Collapsible for 2-depth groups. The pattern owns the column (width, green surface, spacing) and the
// menu item look; menu data lives with the consumer. Below 1024px the desktop column is hidden; use the LNBMobileHeader and
// LNBMobileMenu responsive representation of the same LNB Mode, never a GNB.
// <LNB>
// <LNBBrand><span><small>시스템</small><strong>서비스명</strong></span></LNBBrand>
// <LNBNav aria-label="서비스 메뉴">
// <LNBItem href="#a" active><LayoutGridIcon/>메뉴 1</LNBItem>
// <LNBGroup label="메뉴 2" current><LNBItem href="#b" active>메뉴 2-1</LNBItem> …</LNBGroup>
// </LNBNav>
// <LNBProfile name="김한맥" description="프로젝트 관리자"><Avatar>…</Avatar></LNBProfile>
// </LNB>
// The column does not draw the content side: the content next to it must be a positioned surface after it in the DOM with the left
// corners rounded by `--hm-lnb-join` (LNB Mode content contract); the green column continues behind those corners.
function LNB({ className, ...props }: React.ComponentProps<"aside">) {
return <aside data-slot="lnb" className={cn("flex flex-col", className)} {...props} />
}
// Brand block, vertical: an optional `img` / `svg` logo (only when you have a logo asset; no placeholder, no empty space) above one
// `<span>` with an optional `<small>` eyebrow and a `<strong>` service name on the same start line.
function LNBBrand({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="lnb-brand" className={cn("flex flex-col", className)} {...props} />
}
// Menu region (give it an `aria-label`). It scrolls when the menu is taller than the column; brand and profile stay put.
function LNBNav({ className, ...props }: React.ComponentProps<"nav">) {
return <nav data-slot="lnb-nav" className={cn("flex flex-col", className)} {...props} />
}
// One destination. `active` marks the current location (`aria-current="page"`, Beige face joined to the content). An optional leading
// icon (lucide etc.) is any `svg` child. `asChild` renders the consumer's router link.
function LNBItem({
className,
active = false,
asChild = false,
...props
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
const groupDepth = React.useContext(LNBGroupDepthContext)
const link = <Comp data-slot="lnb-item" aria-current={active ? "page" : undefined} className={cn("flex items-center", className)} {...props} />
return groupDepth === 2 ? <div data-slot="lnb-third-row">{link}</div> : link
}
type LNBGroupProps = Omit<React.ComponentProps<typeof Collapsible>, "children"> & {
label: string
/** A child is the current location: the group name and arrow turn to the accent color (open or closed). */
current?: boolean
children: React.ReactNode
}
// 2-depth group: the HM Collapsible (Radix disclosure: `aria-expanded`, `aria-controls`, Enter / Space) with an arrow, the group
// name and its `LNBItem` children. Selecting a child does not open or close it; the consumer decides (`open`, `defaultOpen`).
// 3-depth: nest an `LNBGroup` inside a group (no depth prop); its items get the 3-depth look and connector from lnb.css. Mark the
// selected link `active` and every group on its path `current`. A closed group unmounts its content, so keep a nested group's `open`
// in the consumer when it must survive closing the outer group.
// <LNBGroup label="1뎁스" current open={a} onOpenChange={setA}>
// <LNBGroup label="2뎁스" current open={b} onOpenChange={setB}><LNBItem href="#c" active>3뎁스</LNBItem>…</LNBGroup>
// <LNBItem href="#d">2뎁스</LNBItem>
// </LNBGroup>
function LNBGroup({ label, current = false, className, children, ...props }: LNBGroupProps) {
const groupDepth = React.useContext(LNBGroupDepthContext)
return (
<Collapsible data-slot="lnb-group" data-current={current ? "" : undefined} className={className} {...props}>
<CollapsibleTrigger asChild>
<button type="button" data-slot="lnb-group-trigger" className="flex items-center">
<ChevronRightIcon aria-hidden="true" data-slot="lnb-group-arrow" />
<span>{label}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent asChild>
<div data-slot="lnb-group-content">
<LNBGroupDepthContext.Provider value={groupDepth + 1}>{children}</LNBGroupDepthContext.Provider>
</div>
</CollapsibleContent>
</Collapsible>
)
}
type LNBProfileProps = Omit<React.ComponentProps<"button">, "children"> & {
name: string
description?: string
/** The Avatar. */
children?: React.ReactNode
}
// User-menu trigger at the bottom of the column: Avatar (children, default size) + name and role. The whole row is a native button so
// it composes directly with `DropdownMenuTrigger asChild`; Radix owns Enter / Space, arrow navigation, Escape and focus return.
function LNBProfile({ className, name, description, children, "aria-label": ariaLabel, ...props }: LNBProfileProps) {
return (
<button type="button" aria-label={ariaLabel ?? `${name} 사용자 메뉴`} className={cn(className)} {...props} data-slot="lnb-profile">
{children}
<div data-slot="lnb-profile-text">
<strong>{name}</strong>
{description ? <p>{description}</p> : null}
</div>
</button>
)
}
export { LNB, LNBBrand, LNBGroup, LNBItem, LNBNav, LNBProfile }
export type { LNBGroupProps, LNBProfileProps }
@@ -0,0 +1 @@
export { LoadingScreen } from "./loading-screen"
@@ -0,0 +1,40 @@
import * as React from "react"
import { cn } from "cn"
const BAR_COUNT = 24
// Loading Screen pattern: a green surface with a 24-bar spinner and a "Loading..." text, for a region or a whole screen while its content
// is being prepared. The progress is unknown (use Progress when the amount is known, Skeleton when the content shape is known).
// Visual reference is the ui-viz Dashboard LoadingScreen (inline / fullscreen). Layout and motion only: it has no async lifecycle, cancel
// or close control, so the consumer mounts it while loading and removes it when done.
// <LoadingScreen /> inline, fills the parent width
// <LoadingScreen fullscreen /> covers the viewport (fixed; keep it out of a transformed ancestor)
// <LoadingScreen>데이터를 불러오는 중</LoadingScreen> children replace the "Loading" label ("..." is added)
// `role="status"` makes the label a polite live region; the bars are decorative (aria-hidden).
function LoadingScreen({
className,
fullscreen = false,
children = "Loading",
...props
}: React.ComponentProps<"div"> & { fullscreen?: boolean }) {
return (
<div
data-slot="loading-screen"
data-variant={fullscreen ? "fullscreen" : "inline"}
role="status"
className={cn(className)}
{...props}
>
<div data-slot="loading-screen-content">
<div data-slot="loading-spinner" aria-hidden="true">
{Array.from({ length: BAR_COUNT }, (_, index) => (
<span key={index} style={{ "--hm-loading-index": index } as React.CSSProperties} />
))}
</div>
<p data-slot="loading-text">{children}</p>
</div>
</div>
)
}
export { LoadingScreen }
@@ -0,0 +1 @@
export { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle } from "./page-header"
@@ -0,0 +1,37 @@
import * as React from "react"
import { cn } from "cn"
// Page Header pattern: the top of every screen's content. Breadcrumb (the HM Breadcrumb, composed as a child), the page title (the
// screen's only `h1`), an optional description or meta line, and the page actions (Buttons, Dropdown Menu...) at the end.
// Layout only: the Breadcrumb and Buttons keep their own look. Layout visual reference is the ui-viz page heading.
// <PageHeader>
// <PageHeaderContent>
// <Breadcrumb>…</Breadcrumb>
// <PageHeaderTitle>업무 현황</PageHeaderTitle>
// <PageHeaderDescription>…</PageHeaderDescription>
// </PageHeaderContent>
// <PageHeaderActions><Button>신규 등록</Button></PageHeaderActions>
// </PageHeader>
function PageHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="page-header" className={cn("flex", className)} {...props} />
}
// Start side: Breadcrumb, title and description stacked.
function PageHeaderContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="page-header-content" className={cn("grid", className)} {...props} />
}
function PageHeaderTitle({ className, ...props }: React.ComponentProps<"h1">) {
return <h1 data-slot="page-header-title" className={cn(className)} {...props} />
}
function PageHeaderDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="page-header-description" className={cn(className)} {...props} />
}
// End side: actions on one row, wrapping on a narrow screen. Pass the Buttons as children.
function PageHeaderActions({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="page-header-actions" className={cn("flex flex-wrap items-center", className)} {...props} />
}
export { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle }
+82
View File
@@ -0,0 +1,82 @@
/* Tailwind-side setup for @hm-design/ui: shadcn utilities, the @theme mapping of the shadcn variables, and the base layer.
The consuming app imports "tailwindcss" itself first; HM component values live in theme.css. */
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* Lets Tailwind scan the component classes in this package (the package may sit in node_modules). */
@source "./components";
@source "./patterns";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
:root {
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+55
View File
@@ -0,0 +1,55 @@
/* Shared HM theme and component styles. App/demo layout stays in hm-theme.css. */
@import "./theme/default.css";
@import "./theme/overlay.css";
@import "./theme/accordion.css";
@import "./theme/alert.css";
@import "./theme/alert-dialog.css";
@import "./theme/drawer.css";
@import "./theme/sheet.css";
@import "./theme/avatar.css";
@import "./theme/badge.css";
@import "./theme/breadcrumb.css";
@import "./theme/button.css";
@import "./theme/calendar.css";
@import "./theme/carousel.css";
@import "./theme/card.css";
@import "./theme/checkbox.css";
@import "./theme/collapsible.css";
@import "./theme/combobox.css";
@import "./theme/context-menu.css";
@import "./theme/command.css";
@import "./theme/data-table.css";
@import "./theme/date-picker.css";
@import "./theme/filter-bar.css";
@import "./theme/gnb.css";
@import "./theme/lnb.css";
@import "./theme/page-header.css";
@import "./theme/dashboard-layout.css";
@import "./theme/loading-screen.css";
@import "./theme/dialog.css";
@import "./theme/dropdown-menu.css";
@import "./theme/hover-card.css";
@import "./theme/popover.css";
@import "./theme/toggle.css";
@import "./theme/toggle-group.css";
@import "./theme/tooltip.css";
@import "./theme/input.css";
@import "./theme/label.css";
@import "./theme/menubar.css";
@import "./theme/navigation-menu.css";
@import "./theme/pagination.css";
@import "./theme/progress.css";
@import "./theme/radio-group.css";
@import "./theme/resizable.css";
@import "./theme/scroll-area.css";
@import "./theme/select.css";
@import "./theme/sonner.css";
@import "./theme/separator.css";
@import "./theme/skeleton.css";
@import "./theme/slider.css";
@import "./theme/switch.css";
@import "./theme/table.css";
@import "./theme/tabs.css";
@import "./theme/textarea.css";
@font-face { font-family: "Noto Sans KR"; src: url("/fonts/NotoSansKR-variable.ttf") format("truetype"); font-style: normal; font-weight: 100 900; font-display: swap; }
+67
View File
@@ -0,0 +1,67 @@
/* Accordion mapping. ui-viz lists Accordion only as a component name (no spec), so these are current HM values built from the HM
Semantic tokens. Items are separated by a 1px divider; no outer box. Radix attributes drive the states (data-state, data-disabled);
the open / close animation and the chevron rotation stay the shadcn ones. */
:root, [data-hm-theme] {
--hm-accordion-text: var(--hm-sem-fg-primary);
--hm-accordion-content-text: var(--hm-sem-fg-primary);
--hm-accordion-divider-width: 1px;
--hm-accordion-divider-color: var(--hm-sem-stroke-input-default);
--hm-accordion-trigger-min-height: 48px;
--hm-accordion-trigger-padding-y: 12px;
--hm-accordion-trigger-gap: 16px;
--hm-accordion-trigger-font-size: 16px;
--hm-accordion-trigger-line-height: 24px;
--hm-accordion-trigger-font-weight: 500;
--hm-accordion-icon-size: 20px;
--hm-accordion-icon-color: var(--hm-sem-fg-secondary);
--hm-accordion-content-font-size: 14px;
--hm-accordion-content-line-height: 20px;
--hm-accordion-content-padding-bottom: 16px;
/* Current HM value for this component only; not a global Disabled rule. */
--hm-accordion-disabled-opacity: 0.6;
}
[data-slot="accordion-item"] {
border-bottom: var(--hm-accordion-divider-width) solid var(--hm-accordion-divider-color);
}
[data-slot="accordion-item"]:last-child {
border-bottom: 0;
}
[data-slot="accordion-trigger"] {
box-sizing: border-box;
min-height: var(--hm-accordion-trigger-min-height);
padding: var(--hm-accordion-trigger-padding-y) 0;
gap: var(--hm-accordion-trigger-gap);
align-items: center;
color: var(--hm-accordion-text);
font-size: var(--hm-accordion-trigger-font-size);
line-height: var(--hm-accordion-trigger-line-height);
font-weight: var(--hm-accordion-trigger-font-weight);
overflow-wrap: anywhere;
cursor: pointer;
}
[data-slot="accordion-trigger"]:hover:not(:disabled) {
background: transparent;
}
[data-slot="accordion-trigger"]:disabled {
opacity: var(--hm-accordion-disabled-opacity);
cursor: not-allowed;
}
[data-slot="accordion-trigger-icon"] {
width: var(--hm-accordion-icon-size);
height: var(--hm-accordion-icon-size);
color: var(--hm-accordion-icon-color);
}
[data-slot="accordion-content-inner"] {
padding-bottom: var(--hm-accordion-content-padding-bottom);
color: var(--hm-accordion-content-text);
font-size: var(--hm-accordion-content-font-size);
line-height: var(--hm-accordion-content-line-height);
font-weight: 400;
overflow-wrap: anywhere;
}
+60
View File
@@ -0,0 +1,60 @@
/* Alert Dialog mapping. ui-viz has no Alert Dialog spec (its Dialog doc lists it as a follow-up), so these are current HM values built
from the HM Semantic tokens. A compact confirmation: Header (Title + Description) and Actions, no Close (X), no Body region.
Radix AlertDialog keeps Portal, focus trap, initial focus on Cancel, Escape, no outside dismiss and focus return. Action and
Cancel are the HM Button (composed in the TSX), so no button style is set here. The Overlay keeps the shadcn default.
Values repeat Dialog's look in meaning only: no --hm-dialog-* token is referenced. */
:root, [data-hm-theme] {
--hm-alert-dialog-background: var(--hm-sem-bg-surface);
--hm-alert-dialog-text: var(--hm-sem-fg-primary);
--hm-alert-dialog-border-width: 1px;
--hm-alert-dialog-border-color: var(--hm-sem-stroke-input-default);
--hm-alert-dialog-radius: 8px;
--hm-alert-dialog-shadow: 0 0 12px color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-alert-dialog-width: 400px;
--hm-alert-dialog-edge: 16px;
--hm-alert-dialog-padding: 24px;
--hm-alert-dialog-header-gap: 8px;
--hm-alert-dialog-actions-gap: 8px;
--hm-alert-dialog-actions-margin-top: 24px;
--hm-alert-dialog-title-font-size: 18px;
--hm-alert-dialog-title-line-height: 28px;
--hm-alert-dialog-title-font-weight: 700;
--hm-alert-dialog-description-font-size: 14px;
--hm-alert-dialog-description-line-height: 20px;
--hm-alert-dialog-description-color: var(--hm-sem-fg-secondary);
}
[data-slot="alert-dialog-content"] {
box-sizing: border-box;
width: min(var(--hm-alert-dialog-width), calc(100vw - 2 * var(--hm-alert-dialog-edge)));
max-height: calc(100dvh - 2 * var(--hm-alert-dialog-edge));
overflow: auto;
padding: var(--hm-alert-dialog-padding);
border: var(--hm-alert-dialog-border-width) solid var(--hm-alert-dialog-border-color);
border-radius: var(--hm-alert-dialog-radius);
background: var(--hm-alert-dialog-background);
color: var(--hm-alert-dialog-text);
box-shadow: var(--hm-alert-dialog-shadow);
overflow-wrap: anywhere;
}
[data-slot="alert-dialog-header"] {
gap: var(--hm-alert-dialog-header-gap);
}
[data-slot="alert-dialog-title"] {
font-size: var(--hm-alert-dialog-title-font-size);
line-height: var(--hm-alert-dialog-title-line-height);
font-weight: var(--hm-alert-dialog-title-font-weight);
color: var(--hm-alert-dialog-text);
}
[data-slot="alert-dialog-description"] {
font-size: var(--hm-alert-dialog-description-font-size);
line-height: var(--hm-alert-dialog-description-line-height);
font-weight: 400;
color: var(--hm-alert-dialog-description-color);
}
[data-slot="alert-dialog-footer"] {
gap: var(--hm-alert-dialog-actions-gap);
margin-top: var(--hm-alert-dialog-actions-margin-top);
}
+63
View File
@@ -0,0 +1,63 @@
/* Alert mapping — current HM values from the shared Semantic tokens. ui-viz has no Alert spec (and its success / warning / information
status colors are undecided), so only a neutral default and a destructive variant exist. Variants use data-variant. */
:root, [data-hm-theme] {
--hm-alert-radius: var(--hm-radius-control-md);
--hm-alert-padding-x: 16px;
--hm-alert-padding-y: 12px;
--hm-alert-border-width: 1px;
--hm-alert-icon-size: 16px;
--hm-alert-title-font-size: 14px;
--hm-alert-title-line-height: 20px;
--hm-alert-title-font-weight: 500;
--hm-alert-description-font-size: 14px;
--hm-alert-description-line-height: 20px;
--hm-alert-description-font-weight: 400;
--hm-alert-default-background: var(--hm-sem-bg-surface);
--hm-alert-default-border-color: var(--hm-sem-stroke-default);
--hm-alert-default-title-color: var(--hm-sem-fg-primary);
--hm-alert-default-description-color: var(--hm-sem-fg-secondary);
--hm-alert-destructive-background: var(--hm-sem-bg-surface);
--hm-alert-destructive-border-color: var(--hm-sem-action-destructive);
--hm-alert-destructive-title-color: var(--hm-sem-action-destructive);
--hm-alert-destructive-description-color: var(--hm-sem-fg-secondary);
}
[data-slot="alert"] {
box-sizing: border-box;
padding: var(--hm-alert-padding-y) var(--hm-alert-padding-x);
border: var(--hm-alert-border-width) solid var(--hm-alert-default-border-color);
border-radius: var(--hm-alert-radius);
background: var(--hm-alert-default-background);
font-size: var(--hm-alert-description-font-size);
line-height: var(--hm-alert-description-line-height);
}
[data-slot="alert"][data-variant="default"] {
color: var(--hm-alert-default-title-color);
}
[data-slot="alert"][data-variant="destructive"] {
border-color: var(--hm-alert-destructive-border-color);
background: var(--hm-alert-destructive-background);
color: var(--hm-alert-destructive-title-color);
}
[data-slot="alert-title"] {
font-size: var(--hm-alert-title-font-size);
line-height: var(--hm-alert-title-line-height);
font-weight: var(--hm-alert-title-font-weight);
}
[data-slot="alert-description"] {
font-size: var(--hm-alert-description-font-size);
line-height: var(--hm-alert-description-line-height);
font-weight: var(--hm-alert-description-font-weight);
}
[data-slot="alert"][data-variant="default"] [data-slot="alert-description"] {
color: var(--hm-alert-default-description-color);
}
[data-slot="alert"][data-variant="destructive"] [data-slot="alert-description"] {
color: var(--hm-alert-destructive-description-color);
}
[data-slot="alert"] > svg {
width: var(--hm-alert-icon-size);
height: var(--hm-alert-icon-size);
}
+41
View File
@@ -0,0 +1,41 @@
/* Avatar mapping. ui-viz lists Avatar only as a component name (no spec), so these are current HM values. Sizes follow the shadcn
API (sm | default | lg). The fallback uses the green tint face (the shared accent). */
:root, [data-hm-theme] {
--hm-avatar-size-sm: 24px;
--hm-avatar-size-md: 32px;
--hm-avatar-size-lg: 40px;
--hm-avatar-radius: 9999px;
--hm-avatar-fallback-background: var(--accent);
--hm-avatar-fallback-text: var(--accent-foreground);
--hm-avatar-fallback-font-weight: 500;
--hm-avatar-fallback-font-size-sm: 10px;
--hm-avatar-fallback-font-size-md: 12px;
--hm-avatar-fallback-font-size-lg: 14px;
}
[data-slot="avatar"] {
--hm-avatar-size: var(--hm-avatar-size-md);
--hm-avatar-fallback-font-size: var(--hm-avatar-fallback-font-size-md);
width: var(--hm-avatar-size);
height: var(--hm-avatar-size);
border-radius: var(--hm-avatar-radius);
}
[data-slot="avatar"][data-size="sm"] {
--hm-avatar-size: var(--hm-avatar-size-sm);
--hm-avatar-fallback-font-size: var(--hm-avatar-fallback-font-size-sm);
}
[data-slot="avatar"][data-size="lg"] {
--hm-avatar-size: var(--hm-avatar-size-lg);
--hm-avatar-fallback-font-size: var(--hm-avatar-fallback-font-size-lg);
}
[data-slot="avatar-fallback"] {
border-radius: var(--hm-avatar-radius);
background: var(--hm-avatar-fallback-background);
color: var(--hm-avatar-fallback-text);
font-size: var(--hm-avatar-fallback-font-size);
font-weight: var(--hm-avatar-fallback-font-weight);
line-height: 1;
overflow: hidden;
white-space: nowrap;
}
+58
View File
@@ -0,0 +1,58 @@
/* Badge mapping — current HM values from the shared Semantic tokens (ui-viz has no Badge spec; it was on hold there, so it is reference only).
Variants use data-variant. Only roles that exist in the HM Theme: primary, neutral, destructive, outline. */
:root, [data-hm-theme] {
--hm-badge-radius: var(--hm-radius-control-md);
--hm-badge-padding-x: 8px;
--hm-badge-padding-y: 2px;
--hm-badge-border-width: 1px;
--hm-badge-font-size: 12px;
--hm-badge-line-height: 18px;
--hm-badge-font-weight: 500;
/* Default is the Strong Brand / Primary color (Primary Green), not the Active State Semantic. */
--hm-badge-default-background: var(--hm-sem-action-primary);
--hm-badge-default-text: var(--hm-sem-on-action-primary);
--hm-badge-default-border-color: var(--hm-sem-action-primary);
--hm-badge-secondary-background: var(--hm-sem-bg-subtle);
--hm-badge-secondary-text: var(--hm-sem-fg-secondary);
--hm-badge-secondary-border-color: var(--hm-sem-bg-subtle);
--hm-badge-destructive-background: var(--hm-sem-action-destructive);
--hm-badge-destructive-text: var(--hm-sem-on-action-primary);
--hm-badge-destructive-border-color: var(--hm-sem-action-destructive);
--hm-badge-outline-background: var(--hm-sem-bg-surface);
--hm-badge-outline-text: var(--hm-sem-fg-primary);
--hm-badge-outline-border-color: var(--hm-sem-stroke-default);
}
[data-slot="badge"] {
box-sizing: border-box;
padding: var(--hm-badge-padding-y) var(--hm-badge-padding-x);
border: var(--hm-badge-border-width) solid transparent;
border-radius: var(--hm-badge-radius);
font-size: var(--hm-badge-font-size);
line-height: var(--hm-badge-line-height);
font-weight: var(--hm-badge-font-weight);
}
[data-slot="badge"][data-variant="default"] {
background: var(--hm-badge-default-background);
color: var(--hm-badge-default-text);
border-color: var(--hm-badge-default-border-color);
}
[data-slot="badge"][data-variant="secondary"] {
background: var(--hm-badge-secondary-background);
color: var(--hm-badge-secondary-text);
border-color: var(--hm-badge-secondary-border-color);
}
[data-slot="badge"][data-variant="destructive"] {
background: var(--hm-badge-destructive-background);
color: var(--hm-badge-destructive-text);
border-color: var(--hm-badge-destructive-border-color);
}
[data-slot="badge"][data-variant="outline"] {
background: var(--hm-badge-outline-background);
color: var(--hm-badge-outline-text);
border-color: var(--hm-badge-outline-border-color);
}
+63
View File
@@ -0,0 +1,63 @@
/* Breadcrumb mapping. ui-viz lists Breadcrumb only as a component name (no spec), so these are current HM values built from the HM
Semantic tokens. Parent links are secondary text that turns primary on hover; the current page is a primary-colored label. */
:root, [data-hm-theme] {
--hm-breadcrumb-font-size: 14px;
--hm-breadcrumb-line-height: 20px;
--hm-breadcrumb-font-weight: 400;
--hm-breadcrumb-gap: 8px;
--hm-breadcrumb-link-color: var(--hm-sem-fg-secondary);
--hm-breadcrumb-link-hover-color: var(--hm-sem-fg-primary);
--hm-breadcrumb-page-color: var(--hm-sem-fg-primary);
--hm-breadcrumb-page-font-weight: 700;
--hm-breadcrumb-separator-color: var(--hm-sem-fg-secondary);
--hm-breadcrumb-separator-size: 16px;
--hm-breadcrumb-ellipsis-size: 20px;
--hm-breadcrumb-focus-radius: 2px;
}
[data-slot="breadcrumb-list"] {
gap: var(--hm-breadcrumb-gap);
color: var(--hm-breadcrumb-link-color);
font-size: var(--hm-breadcrumb-font-size);
line-height: var(--hm-breadcrumb-line-height);
font-weight: var(--hm-breadcrumb-font-weight);
}
[data-slot="breadcrumb-item"] {
gap: var(--hm-breadcrumb-gap);
}
[data-slot="breadcrumb-link"] {
color: var(--hm-breadcrumb-link-color);
border-radius: var(--hm-breadcrumb-focus-radius);
text-decoration: none;
outline: none;
}
[data-slot="breadcrumb-link"]:hover {
color: var(--hm-breadcrumb-link-hover-color);
text-decoration: underline;
text-underline-offset: 4px;
}
[data-slot="breadcrumb-link"]:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="breadcrumb-page"] {
color: var(--hm-breadcrumb-page-color);
font-weight: var(--hm-breadcrumb-page-font-weight);
cursor: default;
}
[data-slot="breadcrumb-separator"] {
color: var(--hm-breadcrumb-separator-color);
}
[data-slot="breadcrumb-separator"] > svg {
width: var(--hm-breadcrumb-separator-size);
height: var(--hm-breadcrumb-separator-size);
}
[data-slot="breadcrumb-ellipsis"] {
width: var(--hm-breadcrumb-ellipsis-size);
height: var(--hm-breadcrumb-ellipsis-size);
color: var(--hm-breadcrumb-separator-color);
}
[data-slot="breadcrumb-ellipsis"] > svg {
width: var(--hm-breadcrumb-separator-size);
height: var(--hm-breadcrumb-separator-size);
}
+192
View File
@@ -0,0 +1,192 @@
/* Button mapping: shared HM theme values are the defaults; each mapping can be overridden alone.
variant = ui_viz role, appearance = ui_viz treatment. Only [data-appearance] buttons (the HM roles) are styled here;
ghost and link carry no data-appearance and keep the shadcn defaults. */
:root, [data-hm-theme] {
/* ui_viz: docs/components/button.md §1.1 D-087 — Confirmed */
--hm-button-radius: var(--hm-radius-button);
--hm-button-font-family: var(--hm-font-family-base);
--hm-button-weight-brand: var(--hm-font-weight-bold);
--hm-button-weight-standard: var(--hm-font-weight-medium);
--hm-button-text: var(--hm-sem-on-action-primary);
--hm-button-disabled-opacity: 0.6;
/* ui_viz: docs/components/button.md §26 / §1.1 D-087 — Confirmed (size, padding-x, label size / line-height) */
--hm-button-height-sm: 28px;
--hm-button-height-md: var(--hm-control-height-md);
--hm-button-height-lg: 42px;
--hm-button-padding-x-sm: 10px;
--hm-button-padding-x-md: 14px;
--hm-button-padding-x-lg: 18px;
--hm-button-font-size-sm: 12px;
--hm-button-font-size-md: var(--hm-font-size-button-md);
--hm-button-font-size-lg: 15px;
--hm-button-line-height-sm: 18px;
--hm-button-line-height-md: 20px;
--hm-button-line-height-lg: 22px;
/* ui_viz: docs/components/button.md §1.1 D-087 Brand gradient (180deg, bg stops 0/30/65/100, border stops 0/50/100) — Confirmed */
--hm-button-brand-green-background: var(--hm-sem-surface-action-primary);
--hm-button-brand-green-border: var(--hm-sem-border-action-primary);
--hm-button-brand-brown-background: linear-gradient(180deg, var(--hm-sem-palette-brown-light-2) 0%, var(--hm-sem-palette-brown-light-1) 30%, var(--hm-sem-palette-brown-dark-1) 65%, var(--hm-sem-palette-brown-deep) 100%);
--hm-button-brand-brown-border: linear-gradient(180deg, var(--hm-sem-palette-brown-dark-1) 0%, var(--hm-sem-palette-brown-highlight) 50%, var(--hm-sem-palette-brown-deep) 100%);
--hm-button-brand-orange-background: linear-gradient(180deg, var(--hm-sem-palette-orange-light) 0%, var(--hm-sem-palette-orange-mid) 30%, var(--hm-sem-palette-orange-dark) 65%, var(--hm-sem-palette-orange-deep) 100%);
--hm-button-brand-orange-border: linear-gradient(180deg, var(--hm-sem-palette-orange-border) 0%, var(--hm-sem-palette-orange-light-4) 50%, var(--hm-sem-palette-orange-border-deep) 100%);
/* ui_viz: docs/components/button.md §1.1 D-087 Brand bevel (Green, Brown only) — Confirmed */
--hm-button-brand-bevel: inset 0 1px 1px var(--hm-color-action-shadow-light), inset 0 -1px 2px var(--hm-color-action-shadow-dark);
/* ui_viz: docs/components/button.md §28 / §1.1 D-086, D-087 Standard Solid (face / border) — Confirmed */
--hm-button-solid-green-background: var(--hm-sem-palette-green-light-1);
--hm-button-solid-green-border: var(--hm-sem-palette-green-dark-1);
--hm-button-solid-brown-background: var(--hm-sem-palette-brown-light-1);
--hm-button-solid-brown-border: var(--hm-sem-palette-brown-dark-1);
--hm-button-solid-orange-background: var(--hm-sem-palette-orange-default);
--hm-button-solid-orange-border: var(--hm-sem-palette-orange-default);
--hm-button-solid-gray-background: var(--hm-sem-palette-gray);
--hm-button-solid-gray-border: var(--hm-sem-palette-gray-dark);
/* ui_viz: docs/components/button.md §28 Standard Outline (ink) and D-087 Red Outline Hover/Pressed — Confirmed */
--hm-button-outline-green-ink: var(--hm-sem-action-primary);
--hm-button-outline-brown-ink: var(--hm-sem-palette-brown-default);
--hm-button-outline-red-ink: var(--hm-sem-action-destructive);
--hm-button-outline-red-surface: var(--hm-color-action-destructive-hover);
--hm-button-outline-surface: var(--hm-sem-bg-surface);
/* ui_viz: docs/components/button.md §12 Hover and Pressed are the same Black 20% state surface — Confirmed */
--hm-button-state-overlay: var(--hm-color-action-overlay-hover);
}
/* Roles: pick the colors of each treatment. */
[data-slot="button"][data-variant="primary"] {
--hm-button-brand-background: var(--hm-button-brand-green-background);
--hm-button-brand-border: var(--hm-button-brand-green-border);
--hm-button-solid-background: var(--hm-button-solid-green-background);
--hm-button-solid-border: var(--hm-button-solid-green-border);
--hm-button-outline-ink: var(--hm-button-outline-green-ink);
}
[data-slot="button"][data-variant="secondary"] {
--hm-button-brand-background: var(--hm-button-brand-brown-background);
--hm-button-brand-border: var(--hm-button-brand-brown-border);
--hm-button-solid-background: var(--hm-button-solid-brown-background);
--hm-button-solid-border: var(--hm-button-solid-brown-border);
--hm-button-outline-ink: var(--hm-button-outline-brown-ink);
}
[data-slot="button"][data-variant="modify"] {
--hm-button-brand-background: var(--hm-button-brand-orange-background);
--hm-button-brand-border: var(--hm-button-brand-orange-border);
--hm-button-solid-background: var(--hm-button-solid-orange-background);
--hm-button-solid-border: var(--hm-button-solid-orange-border);
}
[data-slot="button"][data-variant="cancel"] {
--hm-button-solid-background: var(--hm-button-solid-gray-background);
--hm-button-solid-border: var(--hm-button-solid-gray-border);
--hm-button-outline-ink: var(--hm-sem-action-cancel);
}
[data-slot="button"][data-variant="destructive"] {
--hm-button-outline-ink: var(--hm-button-outline-red-ink);
}
/* Shared. The last two shadows keep the shadcn focus and invalid ring visible on top of the bevel and state surface. */
[data-slot="button"][data-appearance] {
--hm-button-border-width: 1px;
--hm-button-bevel: 0 0 #0000;
--hm-button-state-surface: 0 0 #0000;
border: var(--hm-button-border-width) solid transparent;
border-radius: var(--hm-button-radius);
font-family: var(--hm-button-font-family);
font-weight: var(--hm-button-weight-standard);
box-shadow: var(--hm-button-state-surface), var(--hm-button-bevel), var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000);
}
/* Size: min-height with vertical padding; explicit wrapping labels can still grow. */
[data-slot="button"][data-appearance]:is([data-size="default"], [data-size="sm"], [data-size="lg"]) {
--hm-button-height: var(--hm-button-height-md);
--hm-button-padding-x: var(--hm-button-padding-x-md);
--hm-button-font-size: var(--hm-button-font-size-md);
--hm-button-line-height: var(--hm-button-line-height-md);
height: auto;
min-height: var(--hm-button-height);
max-width: 100%;
padding: calc((var(--hm-button-height) - var(--hm-button-line-height)) / 2 - var(--hm-button-border-width)) var(--hm-button-padding-x);
font-size: var(--hm-button-font-size);
line-height: var(--hm-button-line-height);
text-align: center;
word-break: keep-all;
overflow-wrap: anywhere;
}
/* Keep the existing shadcn nowrap default. Normal labels retain their intrinsic
width when a parent is narrower; consumers can opt into the existing long-label
behavior with className="whitespace-normal", without a new prop.
Utilities remain able to override these layout defaults. */
@layer components {
[data-slot="button"][data-appearance]:is([data-size="default"], [data-size="sm"], [data-size="lg"]) {
min-width: max-content;
}
[data-slot="button"][data-appearance].whitespace-normal:is([data-size="default"], [data-size="sm"], [data-size="lg"]) {
min-width: 0;
}
}
[data-slot="button"][data-appearance][data-size="sm"] {
--hm-button-height: var(--hm-button-height-sm);
--hm-button-padding-x: var(--hm-button-padding-x-sm);
--hm-button-font-size: var(--hm-button-font-size-sm);
--hm-button-line-height: var(--hm-button-line-height-sm);
}
[data-slot="button"][data-appearance][data-size="lg"] {
--hm-button-height: var(--hm-button-height-lg);
--hm-button-padding-x: var(--hm-button-padding-x-lg);
--hm-button-font-size: var(--hm-button-font-size-lg);
--hm-button-line-height: var(--hm-button-line-height-lg);
}
/* Brand: two-layer gradient (face on padding-box, border on border-box), white label, weight 700. */
[data-slot="button"][data-appearance="brand"] {
--hm-button-border-width: 1.5px;
border-color: transparent;
background: var(--hm-button-brand-background) padding-box, var(--hm-button-brand-border) border-box;
color: var(--hm-button-text);
font-weight: var(--hm-button-weight-brand);
}
[data-slot="button"][data-appearance="brand"]:is([data-variant="primary"], [data-variant="secondary"]) {
--hm-button-bevel: var(--hm-button-brand-bevel);
}
/* Solid: flat face, 1px border, white label. */
[data-slot="button"][data-appearance="solid"] {
border-color: var(--hm-button-solid-border);
background: var(--hm-button-solid-background);
color: var(--hm-button-text);
}
/* Brand and Solid: Hover and Pressed are the same Black 20% over the face. */
[data-slot="button"]:is([data-appearance="brand"], [data-appearance="solid"]):is(:hover, :active):not(:disabled) {
--hm-button-state-surface: inset 0 0 0 999px var(--hm-button-state-overlay);
}
/* Outline: white face, 1px role ink. Hover fills with the role ink; Pressed is white with Black 20%. */
[data-slot="button"][data-appearance="outline"] {
border-color: var(--hm-button-outline-ink);
background: var(--hm-button-outline-surface);
color: var(--hm-button-outline-ink);
}
[data-slot="button"][data-appearance="outline"]:hover:not(:disabled) {
background: var(--hm-button-outline-ink);
color: var(--hm-button-text);
}
[data-slot="button"][data-appearance="outline"]:active:not(:disabled) {
background: var(--hm-button-outline-surface);
color: var(--hm-button-outline-ink);
--hm-button-state-surface: inset 0 0 0 999px var(--hm-button-state-overlay);
}
/* Red Outline exception: Hover and Pressed are a Red 8% face, no Black overlay. */
[data-slot="button"][data-appearance="outline"][data-variant="destructive"]:is(:hover, :active):not(:disabled) {
background: var(--hm-button-outline-red-surface);
color: var(--hm-button-outline-ink);
--hm-button-state-surface: 0 0 #0000;
}
/* Disabled keeps the Default paint, dims the whole button, and shows the forbidden cursor. */
[data-slot="button"][data-appearance]:disabled {
opacity: var(--hm-button-disabled-opacity);
cursor: not-allowed;
}
+208
View File
@@ -0,0 +1,208 @@
/* Calendar mapping. ui-viz has no Calendar spec (inventory lists Date Picker by name only), so these are current HM values built
from the HM Semantic / Primitive tokens. react-day-picker keeps its default class names (rdp-*) and puts the state attributes
on the day cell (data-selected, data-today, data-outside, data-disabled, data-focused); the range states are its classes
(rdp-range_start / _middle / _end). Everything is scoped under [data-slot="calendar"]. The Calendar draws no surface of its own
(the container, e.g. Popover or Card, owns it). Nothing here references Button / Input / Popover tokens. */
:root, [data-hm-theme] {
--hm-calendar-padding: 12px;
--hm-calendar-text: var(--hm-sem-fg-primary);
--hm-calendar-month-gap: 16px;
--hm-calendar-caption-height: 32px;
--hm-calendar-caption-font-size: 14px;
--hm-calendar-caption-line-height: 20px;
--hm-calendar-caption-font-weight: 700;
--hm-calendar-nav-size: 28px;
--hm-calendar-nav-radius: var(--hm-radius-control-md);
--hm-calendar-nav-icon-size: 16px;
--hm-calendar-nav-hover-background: var(--hm-sem-surface-highlight);
--hm-calendar-weekday-color: var(--hm-sem-fg-secondary);
--hm-calendar-weekday-font-size: 12px;
--hm-calendar-weekday-line-height: 18px;
--hm-calendar-weekday-font-weight: 500;
--hm-calendar-day-size: 36px;
--hm-calendar-day-radius: var(--hm-radius-control-md);
--hm-calendar-day-font-size: 14px;
--hm-calendar-day-line-height: 20px;
--hm-calendar-day-hover-background: var(--hm-sem-surface-highlight);
--hm-calendar-today-background: var(--hm-sem-bg-subtle);
--hm-calendar-today-font-weight: 700;
/* Single selected and range start / end use the Active Semantic fill; the range middle keeps the soft selection surface
(--accent 45%, text --accent-foreground). */
--hm-calendar-selected-background: var(--hm-sem-state-active);
--hm-calendar-selected-text: var(--hm-sem-on-state-active);
--hm-calendar-range-edge-background: var(--hm-sem-state-active);
--hm-calendar-range-edge-text: var(--hm-sem-on-state-active);
--hm-calendar-range-middle-background: var(--hm-sem-surface-highlight);
--hm-calendar-range-middle-text: var(--accent-foreground);
--hm-calendar-outside-color: var(--hm-sem-fg-secondary);
--hm-calendar-outside-opacity: 0.6;
/* Current HM value for this component only; not a global Disabled rule. */
--hm-calendar-disabled-opacity: 0.4;
}
[data-slot="calendar"] {
position: relative;
width: fit-content;
padding: var(--hm-calendar-padding);
color: var(--hm-calendar-text);
}
[data-slot="calendar"] .rdp-months {
display: flex;
flex-wrap: wrap;
gap: var(--hm-calendar-month-gap);
}
[data-slot="calendar"] .rdp-month {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Caption: centered label; the nav buttons sit on both sides of the first caption row. */
[data-slot="calendar"] .rdp-month_caption {
display: flex;
align-items: center;
justify-content: center;
height: var(--hm-calendar-caption-height);
font-size: var(--hm-calendar-caption-font-size);
line-height: var(--hm-calendar-caption-line-height);
font-weight: var(--hm-calendar-caption-font-weight);
}
[data-slot="calendar"] .rdp-nav {
position: absolute;
top: var(--hm-calendar-padding);
left: var(--hm-calendar-padding);
right: var(--hm-calendar-padding);
display: flex;
align-items: center;
justify-content: space-between;
height: var(--hm-calendar-caption-height);
pointer-events: none;
}
[data-slot="calendar"] .rdp-button_previous,
[data-slot="calendar"] .rdp-button_next {
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--hm-calendar-nav-size);
height: var(--hm-calendar-nav-size);
padding: 0;
border: 0;
border-radius: var(--hm-calendar-nav-radius);
background: transparent;
color: var(--hm-calendar-text);
cursor: pointer;
outline: none;
}
[data-slot="calendar"] .rdp-button_previous:hover:not(:disabled),
[data-slot="calendar"] .rdp-button_next:hover:not(:disabled) {
background: var(--hm-calendar-nav-hover-background);
}
[data-slot="calendar"] .rdp-button_previous:focus-visible,
[data-slot="calendar"] .rdp-button_next:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="calendar"] .rdp-button_previous:disabled,
[data-slot="calendar"] .rdp-button_next:disabled {
opacity: var(--hm-calendar-disabled-opacity);
cursor: not-allowed;
}
[data-slot="calendar"] .rdp-button_previous svg,
[data-slot="calendar"] .rdp-button_next svg {
width: var(--hm-calendar-nav-icon-size);
height: var(--hm-calendar-nav-icon-size);
}
/* Grid */
[data-slot="calendar"] .rdp-month_grid {
border-collapse: collapse;
}
[data-slot="calendar"] .rdp-weekday {
width: var(--hm-calendar-day-size);
padding: 4px 0;
color: var(--hm-calendar-weekday-color);
font-size: var(--hm-calendar-weekday-font-size);
line-height: var(--hm-calendar-weekday-line-height);
font-weight: var(--hm-calendar-weekday-font-weight);
text-align: center;
}
[data-slot="calendar"] .rdp-day {
width: var(--hm-calendar-day-size);
height: var(--hm-calendar-day-size);
padding: 0;
text-align: center;
}
[data-slot="calendar"] .rdp-day_button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: var(--hm-calendar-day-radius);
background: transparent;
color: inherit;
font: inherit;
font-size: var(--hm-calendar-day-font-size);
line-height: var(--hm-calendar-day-line-height);
cursor: pointer;
outline: none;
}
[data-slot="calendar"] .rdp-day_button:hover:not(:disabled) {
background: var(--hm-calendar-day-hover-background);
}
[data-slot="calendar"] .rdp-day_button:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
position: relative;
z-index: 1;
}
/* Today / outside / disabled / selected, from the day cell attributes. */
[data-slot="calendar"] .rdp-day[data-today] .rdp-day_button {
background: var(--hm-calendar-today-background);
font-weight: var(--hm-calendar-today-font-weight);
}
[data-slot="calendar"] .rdp-day[data-outside] .rdp-day_button {
color: var(--hm-calendar-outside-color);
opacity: var(--hm-calendar-outside-opacity);
}
[data-slot="calendar"] .rdp-day[data-disabled] .rdp-day_button {
opacity: var(--hm-calendar-disabled-opacity);
cursor: not-allowed;
}
[data-slot="calendar"] .rdp-day[data-selected] .rdp-day_button,
[data-slot="calendar"] .rdp-day[data-selected] .rdp-day_button:hover:not(:disabled) {
background: var(--hm-calendar-selected-background);
color: var(--hm-calendar-selected-text);
opacity: 1;
}
/* Range: start and end keep the Active fill (inner corners square); the middle uses the soft surface with square corners. */
[data-slot="calendar"] .rdp-day.rdp-range_middle[data-selected] .rdp-day_button,
[data-slot="calendar"] .rdp-day.rdp-range_middle[data-selected] .rdp-day_button:hover:not(:disabled) {
background: var(--hm-calendar-range-middle-background);
color: var(--hm-calendar-range-middle-text);
border-radius: 0;
}
[data-slot="calendar"] .rdp-day.rdp-range_start[data-selected] .rdp-day_button,
[data-slot="calendar"] .rdp-day.rdp-range_start[data-selected] .rdp-day_button:hover:not(:disabled),
[data-slot="calendar"] .rdp-day.rdp-range_end[data-selected] .rdp-day_button,
[data-slot="calendar"] .rdp-day.rdp-range_end[data-selected] .rdp-day_button:hover:not(:disabled) {
background: var(--hm-calendar-range-edge-background);
color: var(--hm-calendar-range-edge-text);
}
[data-slot="calendar"] .rdp-range_start .rdp-day_button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
[data-slot="calendar"] .rdp-range_end .rdp-day_button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
+61
View File
@@ -0,0 +1,61 @@
/* Card mapping: ui_viz Content Card (Type B). HM spec: docs/components/card.md.
Source refs below (content-container.md §7, §10 and content-header.md §7, §9) are external ui_viz docs/patterns/ files, not in this repo.
ui_viz defines no Hover / Focus / Selected / Disabled, no shadow and no footer: those stay shadcn defaults or are absent. */
:root, [data-hm-theme] {
/* ui_viz: content-container.md §7.2 — container-radius 4px = radius.container.default — Confirmed. Not aliased to Input or Button. */
--hm-card-radius: var(--hm-radius-container);
/* ui_viz: content-container.md §7.2 — border 1px, body-padding 24px all sides — SOURCE_ADOPTED (Canonical pattern, Q6) */
--hm-card-border-width: 1px;
--hm-card-padding: 24px;
/* ui_viz: content-container.md §7.2, §10.2 — border colors were LOCAL_EXCEPTION component values (#DEDBD2 / #ECD4A2).
HM maps them to shared Semantic tokens: color.stroke.input.default and color.palette.brown-light-4 (see docs/components/card.md §6.1). */
--hm-card-border-color: var(--hm-sem-stroke-input-default);
--hm-card-border-color-on-content: var(--hm-sem-palette-brown-light-4);
/* ui_viz: content-container.md §7.2 — surface: color.bg.surface (White) or color.bg.content (Beige) — Confirmed */
--hm-card-surface: var(--hm-sem-bg-surface);
--hm-card-surface-content: var(--hm-sem-bg-content);
--hm-card-text: var(--hm-sem-fg-primary);
/* ui_viz: content-header.md §7.1, §9 — content profile: title <-> meta gap 20px, title heading-md 20/28/600 (CANONICAL_OVERRIDE of Source 19px) */
--hm-card-header-gap: 20px;
--hm-card-title-font-size: 20px;
--hm-card-title-line-height: 28px;
--hm-card-title-font-weight: 600;
--hm-card-title-color: var(--hm-sem-fg-primary);
}
/* Default size. size="sm" is a shadcn size that ui_viz does not define, so it keeps the shadcn spacing (12px) from the TSX classes. */
:where([data-slot="card"]:not([data-size="sm"])) {
--card-spacing: var(--hm-card-padding);
}
[data-slot="card"] {
border: var(--hm-card-border-width) solid var(--hm-card-border-color);
border-radius: var(--hm-card-radius);
background: var(--hm-card-surface);
color: var(--hm-card-text);
box-shadow: none;
}
[data-slot="card"][data-surface="content"] {
border-color: var(--hm-card-border-color-on-content);
background: var(--hm-card-surface-content);
}
/* Header: title group at the start, Action (Meta) at the end, centered on the cross axis. */
[data-slot="card-header"] {
column-gap: var(--hm-card-header-gap);
}
[data-slot="card-action"] {
align-self: center;
}
:where([data-slot="card"]:not([data-size="sm"])) [data-slot="card-title"] {
font-size: var(--hm-card-title-font-size);
line-height: var(--hm-card-title-line-height);
font-weight: var(--hm-card-title-font-weight);
color: var(--hm-card-title-color);
}
/* Footer is not defined by ui_viz (shadcn default). Its bottom corners follow the container radius inside the border. */
[data-slot="card-footer"] {
border-bottom-left-radius: calc(var(--hm-card-radius) - var(--hm-card-border-width));
border-bottom-right-radius: calc(var(--hm-card-radius) - var(--hm-card-border-width));
}
+88
View File
@@ -0,0 +1,88 @@
/* Carousel mapping. ui-viz has no Carousel spec (it is not in the earlier inventory), so these are current HM values built from the HM
Semantic / Primitive tokens. Embla owns the scrolling; here only the slide gap and the previous / next controls are styled.
The controls are Carousel's own buttons (they do not use the Button component or its tokens) and sit inside the carousel edge
(8px inset) so they are never clipped by a narrow parent. The slide width is the consumer's (`basis-*` on CarouselItem).
Everything is in `@layer components`, so a consumer's utility classes (gap, control position, size) still win.
Nothing here references Button / Scroll Area tokens. */
:root, [data-hm-theme] {
--hm-carousel-gap: 16px;
--hm-carousel-control-size: 32px;
--hm-carousel-control-inset: 8px;
--hm-carousel-control-icon-size: 16px;
--hm-carousel-control-background: var(--hm-sem-bg-surface);
--hm-carousel-control-text: var(--hm-sem-fg-primary);
--hm-carousel-control-border-width: 1px;
--hm-carousel-control-border-color: var(--hm-sem-stroke-input-default);
--hm-carousel-control-hover-background: var(--accent);
/* Current HM value for this component only; not a global Disabled rule. */
--hm-carousel-disabled-opacity: 0.6;
}
@layer components {
/* Gap: the track pulls back by one gap and every slide pads it (the shadcn approach), so slides keep equal widths. */
[data-slot="carousel"][data-orientation="horizontal"] [data-slot="carousel-track"] {
margin-left: calc(-1 * var(--hm-carousel-gap));
}
[data-slot="carousel"][data-orientation="horizontal"] [data-slot="carousel-item"] {
padding-left: var(--hm-carousel-gap);
}
[data-slot="carousel"][data-orientation="vertical"] [data-slot="carousel-track"] {
margin-top: calc(-1 * var(--hm-carousel-gap));
}
[data-slot="carousel"][data-orientation="vertical"] [data-slot="carousel-item"] {
padding-top: var(--hm-carousel-gap);
}
[data-slot="carousel-content"] {
cursor: grab;
}
[data-slot="carousel-content"]:active {
cursor: grabbing;
}
[data-slot="carousel-previous"],
[data-slot="carousel-next"] {
box-sizing: border-box;
width: var(--hm-carousel-control-size);
height: var(--hm-carousel-control-size);
padding: 0;
border: var(--hm-carousel-control-border-width) solid var(--hm-carousel-control-border-color);
border-radius: 9999px;
background: var(--hm-carousel-control-background);
color: var(--hm-carousel-control-text);
cursor: pointer;
}
[data-slot="carousel-previous"] svg,
[data-slot="carousel-next"] svg {
width: var(--hm-carousel-control-icon-size);
height: var(--hm-carousel-control-icon-size);
}
[data-slot="carousel-previous"]:hover:not(:disabled),
[data-slot="carousel-next"]:hover:not(:disabled) {
background: var(--hm-carousel-control-hover-background);
}
[data-slot="carousel-previous"]:focus-visible,
[data-slot="carousel-next"]:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="carousel-previous"]:disabled,
[data-slot="carousel-next"]:disabled {
opacity: var(--hm-carousel-disabled-opacity);
cursor: not-allowed;
}
/* Position: inside the edge, centered on the cross axis (the vertical carousel turns the arrows 90 degrees). */
[data-slot="carousel"][data-orientation="horizontal"] [data-slot="carousel-previous"] {
left: var(--hm-carousel-control-inset);
}
[data-slot="carousel"][data-orientation="horizontal"] [data-slot="carousel-next"] {
right: var(--hm-carousel-control-inset);
}
[data-slot="carousel"][data-orientation="vertical"] [data-slot="carousel-previous"] {
top: var(--hm-carousel-control-inset);
}
[data-slot="carousel"][data-orientation="vertical"] [data-slot="carousel-next"] {
bottom: var(--hm-carousel-control-inset);
}
}
+54
View File
@@ -0,0 +1,54 @@
/* Checkbox mapping: current HM values built from the shared Semantic tokens (ui-viz has no Checkbox spec; reference only).
States use Radix attributes: [data-state="checked"|"indeterminate"], :hover, :focus-visible, :disabled, [aria-invalid="true"]. */
:root, [data-hm-theme] {
--hm-checkbox-size: 18px;
--hm-checkbox-radius: var(--hm-radius-control-md);
--hm-checkbox-border-width: 1px;
--hm-checkbox-icon-size: 14px;
--hm-checkbox-background: var(--hm-sem-bg-surface);
--hm-checkbox-border-color: var(--hm-sem-stroke-default);
--hm-checkbox-hover-border-color: var(--hm-sem-action-primary);
--hm-checkbox-checked-background: var(--hm-sem-state-active);
--hm-checkbox-checked-border-color: var(--hm-sem-state-active);
--hm-checkbox-checked-hover-overlay: var(--hm-color-action-overlay-hover);
--hm-checkbox-indicator-color: var(--hm-sem-on-state-active);
--hm-checkbox-error-border-color: var(--hm-sem-action-destructive);
}
[data-slot="checkbox"] {
box-sizing: border-box;
width: var(--hm-checkbox-size);
height: var(--hm-checkbox-size);
padding: 0;
border: var(--hm-checkbox-border-width) solid var(--hm-checkbox-border-color);
border-radius: var(--hm-checkbox-radius);
background-color: var(--hm-checkbox-background);
background-image: none;
color: var(--hm-checkbox-indicator-color);
box-shadow: none;
cursor: pointer;
}
[data-slot="checkbox"] svg { width: var(--hm-checkbox-icon-size); height: var(--hm-checkbox-icon-size); }
[data-slot="checkbox"]:hover:not(:disabled):not([data-state="checked"]):not([data-state="indeterminate"]) {
border-color: var(--hm-checkbox-hover-border-color);
}
[data-slot="checkbox"][data-state="checked"],
[data-slot="checkbox"][data-state="indeterminate"] {
border-color: var(--hm-checkbox-checked-border-color);
background-color: var(--hm-checkbox-checked-background);
}
[data-slot="checkbox"][data-state="checked"]:hover:not(:disabled),
[data-slot="checkbox"][data-state="indeterminate"]:hover:not(:disabled) {
background-image: linear-gradient(var(--hm-checkbox-checked-hover-overlay), var(--hm-checkbox-checked-hover-overlay));
}
/* Focus level is the shadcn ring (ui_viz global focus is TBD); keep the ring variables because the theme sets box-shadow: none. */
[data-slot="checkbox"]:focus-visible {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000);
}
[data-slot="checkbox"][aria-invalid="true"] {
border-color: var(--hm-checkbox-error-border-color);
}
[data-slot="checkbox"]:disabled {
cursor: not-allowed;
}
+66
View File
@@ -0,0 +1,66 @@
/* Collapsible mapping. ui-viz has no Collapsible spec, so these are current HM values built from the HM Semantic tokens.
Collapsible is one independent disclosure: a trigger and its content, with no divider system. A plain trigger (no asChild) gets
a text-button look; with asChild the child (for example an HM Button) keeps its own style. Radix attributes drive the states.
No --hm-accordion-* token is referenced. */
:root, [data-hm-theme] {
--hm-collapsible-trigger-text: var(--hm-sem-fg-primary);
--hm-collapsible-trigger-gap: 8px;
--hm-collapsible-trigger-font-size: 14px;
--hm-collapsible-trigger-line-height: 20px;
--hm-collapsible-trigger-font-weight: 500;
--hm-collapsible-icon-size: 16px;
--hm-collapsible-icon-color: var(--hm-sem-fg-secondary);
--hm-collapsible-content-gap: 8px;
--hm-collapsible-content-text: var(--hm-sem-fg-primary);
--hm-collapsible-content-font-size: 14px;
--hm-collapsible-content-line-height: 20px;
/* Current HM value for this component only; not a global Disabled rule. */
--hm-collapsible-disabled-opacity: 0.6;
}
[data-slot="collapsible-trigger"] {
display: inline-flex;
align-items: center;
gap: var(--hm-collapsible-trigger-gap);
padding: 0;
border: 0;
background: transparent;
color: var(--hm-collapsible-trigger-text);
font-size: var(--hm-collapsible-trigger-font-size);
line-height: var(--hm-collapsible-trigger-line-height);
font-weight: var(--hm-collapsible-trigger-font-weight);
text-align: left;
cursor: pointer;
outline: none;
}
[data-slot="collapsible-trigger"]:hover:not(:disabled) {
text-decoration: underline;
text-underline-offset: 4px;
}
[data-slot="collapsible-trigger"]:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="collapsible-trigger"]:disabled {
opacity: var(--hm-collapsible-disabled-opacity);
cursor: not-allowed;
}
/* An icon placed directly in a plain trigger follows the open state. */
[data-slot="collapsible-trigger"] > svg {
width: var(--hm-collapsible-icon-size);
height: var(--hm-collapsible-icon-size);
color: var(--hm-collapsible-icon-color);
flex-shrink: 0;
}
[data-slot="collapsible-trigger"][data-state="open"] > svg {
transform: rotate(180deg);
}
[data-slot="collapsible-content"] {
padding-top: var(--hm-collapsible-content-gap);
color: var(--hm-collapsible-content-text);
font-size: var(--hm-collapsible-content-font-size);
line-height: var(--hm-collapsible-content-line-height);
overflow-wrap: anywhere;
}
+144
View File
@@ -0,0 +1,144 @@
/* Combobox mapping. ui-viz has no Combobox spec (it has a Select and a Search Field, both different components), so these are current
HM values built from the HM Semantic / Primitive tokens. Combobox is a composition (Popover + Command): the popup surface comes
from Popover, the search input and list from Command. Scoped popup overrides align the selection language with Select without
changing either shared component. The popup is as wide as the trigger (an inline style sets its width / padding).
The trigger look is in `@layer components` so a consumer's utility classes (width, height) still win. Nothing here references
Button / Input / Select / Popover / Command tokens. */
:root, [data-hm-theme] {
/* Inner heights; the 1px border is added around them (outer 34 / 44), like the Select trigger. */
--hm-combobox-height-md: 32px;
--hm-combobox-height-lg: 42px;
--hm-combobox-border-width: 1px;
--hm-combobox-padding-x: 12px;
--hm-combobox-gap: 12px;
--hm-combobox-radius: var(--hm-radius-control-md);
--hm-combobox-font-size-md: 14px;
--hm-combobox-line-height-md: 20px;
--hm-combobox-font-size-lg: 16px;
--hm-combobox-line-height-lg: 24px;
--hm-combobox-background: var(--hm-sem-bg-surface);
--hm-combobox-text: var(--hm-sem-fg-primary);
--hm-combobox-placeholder: var(--hm-sem-fg-placeholder);
--hm-combobox-border-color: var(--hm-sem-stroke-input-default);
--hm-combobox-focus-border-color: var(--hm-sem-stroke-input-focus);
--hm-combobox-seam-color: var(--hm-sem-stroke-input-default);
--hm-combobox-icon-size: 18px;
--hm-combobox-icon-color: var(--hm-sem-fg-secondary);
--hm-combobox-check-size: 16px;
--hm-combobox-popup-radius: var(--hm-radius-input);
--hm-combobox-popup-shadow: 0 2px 24px color-mix(in srgb, var(--hm-sem-palette-black) 12%, transparent);
--hm-combobox-option-state-background: var(--hm-sem-surface-highlight);
--hm-combobox-option-state-text: var(--accent-foreground);
/* Current HM value for this component only; not a global Disabled rule. */
--hm-combobox-disabled-opacity: 0.6;
}
/* Share the connecting border via sideOffset=-1; only the seam uses the neutral stroke. */
[data-slot="popover-content"][data-combobox-popup] {
border-color: var(--hm-combobox-focus-border-color);
border-radius: var(--hm-combobox-popup-radius);
box-shadow: var(--hm-combobox-popup-shadow);
--hm-combobox-option-font-size: var(--hm-combobox-font-size-lg);
--hm-combobox-option-line-height: var(--hm-combobox-line-height-lg);
}
[data-slot="popover-content"][data-combobox-popup][data-side="bottom"] {
border-radius: 0 0 var(--hm-combobox-popup-radius) var(--hm-combobox-popup-radius);
border-top-color: var(--hm-combobox-seam-color);
}
[data-slot="popover-content"][data-combobox-popup][data-side="top"] {
border-radius: var(--hm-combobox-popup-radius) var(--hm-combobox-popup-radius) 0 0;
border-bottom-color: var(--hm-combobox-seam-color);
}
[data-combobox-popup][data-size="medium"] {
--hm-combobox-option-font-size: var(--hm-combobox-font-size-md);
--hm-combobox-option-line-height: var(--hm-combobox-line-height-md);
}
[data-combobox-popup] [data-slot="command"] {
border-radius: inherit;
}
[data-combobox-popup] [data-slot="command-item"] {
/* Retain Command's 36px height, 12px horizontal padding and search layout. */
border-radius: 0;
font-size: var(--hm-combobox-option-font-size);
line-height: var(--hm-combobox-option-line-height);
font-weight: 400;
}
[data-combobox-popup] [data-slot="command-item"][data-selected="true"],
[data-combobox-popup] [data-slot="command-item"][data-checked] {
background: var(--hm-combobox-option-state-background);
color: var(--hm-combobox-option-state-text);
}
@layer components {
[data-slot="combobox-trigger"] {
--hm-combobox-height: var(--hm-combobox-height-lg);
--hm-combobox-font-size: var(--hm-combobox-font-size-lg);
--hm-combobox-line-height: var(--hm-combobox-line-height-lg);
box-sizing: border-box;
min-width: 0; /* lets a long value truncate instead of widening a grid / flex parent */
max-width: 100%;
min-height: calc(var(--hm-combobox-height) + 2 * var(--hm-combobox-border-width));
gap: var(--hm-combobox-gap);
padding: 0 var(--hm-combobox-padding-x);
border: var(--hm-combobox-border-width) solid var(--hm-combobox-border-color);
border-radius: var(--hm-combobox-radius);
background: var(--hm-combobox-background);
color: var(--hm-combobox-text);
font-size: var(--hm-combobox-font-size);
line-height: var(--hm-combobox-line-height);
font-weight: 400;
cursor: pointer;
}
[data-slot="combobox-trigger"][data-size="medium"] {
--hm-combobox-height: var(--hm-combobox-height-md);
--hm-combobox-font-size: var(--hm-combobox-font-size-md);
--hm-combobox-line-height: var(--hm-combobox-line-height-md);
}
[data-slot="combobox-trigger"][data-empty] [data-slot="combobox-value"] {
color: var(--hm-combobox-placeholder);
}
[data-slot="combobox-value"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="combobox-icon"] {
flex-shrink: 0;
width: var(--hm-combobox-icon-size);
height: var(--hm-combobox-icon-size);
color: var(--hm-combobox-icon-color);
}
[data-slot="combobox-trigger"]:focus-visible,
[data-slot="combobox-trigger"][data-state="open"] {
border-color: var(--hm-combobox-focus-border-color);
}
[data-slot="combobox-trigger"][data-state="open"][data-side="bottom"] {
border-radius: var(--hm-combobox-radius) var(--hm-combobox-radius) 0 0;
border-bottom-color: var(--hm-combobox-seam-color);
}
[data-slot="combobox-trigger"][data-state="open"][data-side="top"] {
border-radius: 0 0 var(--hm-combobox-radius) var(--hm-combobox-radius);
border-top-color: var(--hm-combobox-seam-color);
}
[data-slot="combobox-trigger"]:disabled {
opacity: var(--hm-combobox-disabled-opacity);
cursor: not-allowed;
}
/* Items inside the popup: a long label is truncated and the check stays at the end. */
[data-slot="combobox-item-label"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="combobox-item-check"] {
margin-left: auto;
width: var(--hm-combobox-check-size);
height: var(--hm-combobox-check-size);
}
}
+127
View File
@@ -0,0 +1,127 @@
/* Command mapping. ui-viz has no Command spec (it has a Search Field / candidate list, a different component), so these are current
HM values built from the HM Semantic / Primitive tokens. cmdk keeps filtering, keyboard and ARIA; the highlighted item is
[data-selected="true"], a disabled one [data-disabled="true"]. The Command draws its own surface (white, radius) but no border or
shadow: it is meant to sit inside a container (Popover, Dialog) or a bordered box. Nothing references Input / Popover /
Dropdown Menu tokens. */
:root, [data-hm-theme] {
--hm-command-background: var(--hm-sem-bg-surface);
--hm-command-text: var(--hm-sem-fg-primary);
--hm-command-radius: var(--hm-radius-container);
--hm-command-input-height: 40px;
--hm-command-input-padding-x: 12px;
--hm-command-input-gap: 8px;
--hm-command-input-font-size: 14px;
--hm-command-input-line-height: 20px;
--hm-command-input-placeholder: var(--hm-sem-fg-placeholder);
--hm-command-input-icon-size: 16px;
--hm-command-input-icon-color: var(--hm-sem-fg-secondary);
--hm-command-divider-color: var(--hm-sem-stroke-input-default);
--hm-command-list-max-height: 280px;
--hm-command-list-padding: 4px;
--hm-command-item-height: 36px;
--hm-command-item-padding-x: 12px;
--hm-command-item-gap: 8px;
--hm-command-item-radius: 2px;
--hm-command-item-font-size: 14px;
--hm-command-item-line-height: 20px;
--hm-command-highlight-background: var(--hm-sem-surface-highlight);
--hm-command-highlight-text: var(--accent-foreground);
--hm-command-group-label-color: var(--hm-sem-fg-secondary);
--hm-command-group-label-font-size: 12px;
--hm-command-group-label-line-height: 18px;
--hm-command-group-label-font-weight: 500;
--hm-command-shortcut-color: var(--hm-sem-fg-secondary);
--hm-command-shortcut-font-size: 12px;
--hm-command-empty-padding-y: 24px;
--hm-command-empty-color: var(--hm-sem-fg-secondary);
/* Current HM value for this component only; not a global Disabled rule. */
--hm-command-disabled-opacity: 0.6;
}
[data-slot="command"] {
box-sizing: border-box;
border-radius: var(--hm-command-radius);
background: var(--hm-command-background);
color: var(--hm-command-text);
}
[data-slot="command-input-wrapper"] {
box-sizing: border-box;
height: var(--hm-command-input-height);
gap: var(--hm-command-input-gap);
padding: 0 var(--hm-command-input-padding-x);
border-bottom: 1px solid var(--hm-command-divider-color);
}
[data-slot="command-input-wrapper"] > svg {
width: var(--hm-command-input-icon-size);
height: var(--hm-command-input-icon-size);
color: var(--hm-command-input-icon-color);
}
[data-slot="command-input"] {
height: 100%;
border: 0;
background: transparent;
color: inherit;
font-size: var(--hm-command-input-font-size);
line-height: var(--hm-command-input-line-height);
}
[data-slot="command-input"]::placeholder {
color: var(--hm-command-input-placeholder);
}
[data-slot="command-list"] {
max-height: var(--hm-command-list-max-height);
padding: var(--hm-command-list-padding);
}
[data-slot="command-empty"] {
padding: var(--hm-command-empty-padding-y) 12px;
color: var(--hm-command-empty-color);
font-size: var(--hm-command-item-font-size);
line-height: var(--hm-command-item-line-height);
text-align: center;
}
[data-slot="command-separator"] {
margin: var(--hm-command-list-padding) calc(-1 * var(--hm-command-list-padding));
background: var(--hm-command-divider-color);
}
[data-slot="command-group"] [cmdk-group-heading] {
padding: 6px var(--hm-command-item-padding-x);
color: var(--hm-command-group-label-color);
font-size: var(--hm-command-group-label-font-size);
line-height: var(--hm-command-group-label-line-height);
font-weight: var(--hm-command-group-label-font-weight);
}
[data-slot="command-item"] {
box-sizing: border-box;
min-height: var(--hm-command-item-height);
gap: var(--hm-command-item-gap);
padding: 0 var(--hm-command-item-padding-x);
border-radius: var(--hm-command-item-radius);
font-size: var(--hm-command-item-font-size);
line-height: var(--hm-command-item-line-height);
}
/* Highlighted: pointer hover and keyboard focus share cmdk's data-selected. */
[data-slot="command-item"][data-selected="true"] {
background: var(--hm-command-highlight-background);
color: var(--hm-command-highlight-text);
}
[data-slot="command-item"][data-disabled="true"] {
opacity: var(--hm-command-disabled-opacity);
cursor: not-allowed;
}
[data-slot="command-shortcut"] {
color: var(--hm-command-shortcut-color);
font-size: var(--hm-command-shortcut-font-size);
letter-spacing: 0.05em;
}
[data-slot="command-item"][data-selected="true"] [data-slot="command-shortcut"] {
color: inherit;
}
+121
View File
@@ -0,0 +1,121 @@
/* Context Menu mapping. Same anchored floating menu family as Dropdown Menu, opened by pointer context (right click / context key / long press).
Surface values come from the Floating Semantic (--hm-sem-floating-*); item and text values are current HM values. Radix state attributes
(data-highlighted, data-disabled, data-state) drive the states. Tokens are this component's own and never reference --hm-dropdown-menu-*. */
:root, [data-hm-theme] {
--hm-context-menu-background: var(--hm-sem-floating-background);
--hm-context-menu-text: var(--hm-sem-fg-primary);
--hm-context-menu-border-width: var(--hm-sem-floating-border-width);
--hm-context-menu-border-color: var(--hm-sem-floating-border-color);
--hm-context-menu-radius: var(--hm-sem-floating-radius);
--hm-context-menu-shadow: var(--hm-sem-floating-shadow);
--hm-context-menu-padding: 4px;
--hm-context-menu-item-height: 36px;
--hm-context-menu-item-padding-x: 12px;
--hm-context-menu-item-gap: 8px;
--hm-context-menu-item-radius: 2px;
--hm-context-menu-font-size: 14px;
--hm-context-menu-line-height: 20px;
--hm-context-menu-font-weight: 400;
/* Highlighted (hover and keyboard) uses the same face as the Select option: the shadcn accent. */
--hm-context-menu-highlight-background: var(--hm-sem-surface-highlight);
--hm-context-menu-highlight-text: var(--accent-foreground);
--hm-context-menu-destructive-text: var(--hm-sem-action-destructive);
--hm-context-menu-destructive-highlight-background: var(--hm-color-action-destructive-hover);
--hm-context-menu-label-color: var(--hm-sem-fg-secondary);
--hm-context-menu-label-font-size: 12px;
--hm-context-menu-label-line-height: 18px;
--hm-context-menu-label-font-weight: 500;
--hm-context-menu-shortcut-color: var(--hm-sem-fg-secondary);
--hm-context-menu-shortcut-font-size: 12px;
--hm-context-menu-separator-color: var(--hm-sem-stroke-input-default);
/* Check / radio indicator column at the start of the item. */
--hm-context-menu-indicator-size: 16px;
--hm-context-menu-radio-dot-size: 8px;
}
[data-slot="context-menu-content"],
[data-slot="context-menu-sub-content"] {
box-sizing: border-box;
padding: var(--hm-context-menu-padding);
border: var(--hm-context-menu-border-width) solid var(--hm-context-menu-border-color);
border-radius: var(--hm-context-menu-radius);
background: var(--hm-context-menu-background);
color: var(--hm-context-menu-text);
box-shadow: var(--hm-context-menu-shadow);
font-size: var(--hm-context-menu-font-size);
line-height: var(--hm-context-menu-line-height);
font-weight: var(--hm-context-menu-font-weight);
}
[data-slot="context-menu-item"],
[data-slot="context-menu-checkbox-item"],
[data-slot="context-menu-radio-item"],
[data-slot="context-menu-sub-trigger"] {
box-sizing: border-box;
min-height: var(--hm-context-menu-item-height);
gap: var(--hm-context-menu-item-gap);
padding: 0 var(--hm-context-menu-item-padding-x);
border-radius: var(--hm-context-menu-item-radius);
}
/* Checkbox / radio items reserve the indicator column; `inset` aligns plain items to the same text start. */
[data-slot="context-menu-checkbox-item"],
[data-slot="context-menu-radio-item"],
[data-slot="context-menu-item"][data-inset],
[data-slot="context-menu-sub-trigger"][data-inset] {
padding-left: calc(var(--hm-context-menu-item-padding-x) + var(--hm-context-menu-indicator-size) + var(--hm-context-menu-item-gap));
}
[data-slot="context-menu-item-indicator"] {
left: var(--hm-context-menu-item-padding-x);
width: var(--hm-context-menu-indicator-size);
height: var(--hm-context-menu-indicator-size);
}
[data-slot="context-menu-radio-dot"] {
width: var(--hm-context-menu-radio-dot-size);
height: var(--hm-context-menu-radio-dot-size);
}
/* Highlighted: Radix sets data-highlighted for pointer hover and keyboard focus alike. An open sub menu keeps its trigger highlighted. */
[data-slot="context-menu-item"][data-highlighted],
[data-slot="context-menu-checkbox-item"][data-highlighted],
[data-slot="context-menu-radio-item"][data-highlighted],
[data-slot="context-menu-sub-trigger"][data-highlighted],
[data-slot="context-menu-sub-trigger"][data-state="open"] {
background: var(--hm-context-menu-highlight-background);
color: var(--hm-context-menu-highlight-text);
}
[data-slot="context-menu-item"][data-variant="destructive"] {
color: var(--hm-context-menu-destructive-text);
}
[data-slot="context-menu-item"][data-variant="destructive"][data-highlighted] {
background: var(--hm-context-menu-destructive-highlight-background);
color: var(--hm-context-menu-destructive-text);
}
[data-slot="context-menu-label"] {
padding: 6px var(--hm-context-menu-item-padding-x);
color: var(--hm-context-menu-label-color);
font-size: var(--hm-context-menu-label-font-size);
line-height: var(--hm-context-menu-label-line-height);
font-weight: var(--hm-context-menu-label-font-weight);
}
[data-slot="context-menu-label"][data-inset] {
padding-left: calc(var(--hm-context-menu-item-padding-x) + var(--hm-context-menu-indicator-size) + var(--hm-context-menu-item-gap));
}
[data-slot="context-menu-separator"] {
margin: var(--hm-context-menu-padding) calc(-1 * var(--hm-context-menu-padding));
background: var(--hm-context-menu-separator-color);
}
[data-slot="context-menu-shortcut"] {
color: var(--hm-context-menu-shortcut-color);
font-size: var(--hm-context-menu-shortcut-font-size);
letter-spacing: 0.05em;
}
[data-slot="context-menu-item"][data-highlighted] [data-slot="context-menu-shortcut"],
[data-slot="context-menu-checkbox-item"][data-highlighted] [data-slot="context-menu-shortcut"],
[data-slot="context-menu-radio-item"][data-highlighted] [data-slot="context-menu-shortcut"] {
color: inherit;
}
+106
View File
@@ -0,0 +1,106 @@
import { runtimeThemeDefaults } from "./default-values.generated"
/** Public Runtime Theme contract v1. Component mappings and derived CSS are private. */
export const runtimeThemeContract = {
"color.bg.content": { property: "--hm-sem-bg-content", defaultValue: runtimeThemeDefaults["--hm-sem-bg-content"], type: "color" },
"color.bg.surface": { property: "--hm-sem-bg-surface", defaultValue: runtimeThemeDefaults["--hm-sem-bg-surface"], type: "color" },
"color.fg.primary": { property: "--hm-sem-fg-primary", defaultValue: runtimeThemeDefaults["--hm-sem-fg-primary"], type: "color" },
"color.fg.secondary": { property: "--hm-sem-fg-secondary", defaultValue: runtimeThemeDefaults["--hm-sem-fg-secondary"], type: "color" },
"color.fg.placeholder": { property: "--hm-sem-fg-placeholder", defaultValue: runtimeThemeDefaults["--hm-sem-fg-placeholder"], type: "color" },
"color.action.primary": { property: "--hm-sem-action-primary", defaultValue: runtimeThemeDefaults["--hm-sem-action-primary"], type: "color" },
"color.action.cancel": { property: "--hm-sem-action-cancel", defaultValue: runtimeThemeDefaults["--hm-sem-action-cancel"], type: "color" },
"color.action.destructive": { property: "--hm-sem-action-destructive", defaultValue: runtimeThemeDefaults["--hm-sem-action-destructive"], type: "color" },
"color.stroke.default": { property: "--hm-sem-stroke-default", defaultValue: runtimeThemeDefaults["--hm-sem-stroke-default"], type: "color" },
"color.stroke.input.default": { property: "--hm-sem-stroke-input-default", defaultValue: runtimeThemeDefaults["--hm-sem-stroke-input-default"], type: "color" },
"color.stroke.input.focus": { property: "--hm-sem-stroke-input-focus", defaultValue: runtimeThemeDefaults["--hm-sem-stroke-input-focus"], type: "color" },
"color.bg.brand.stop.0": { property: "--hm-sem-brand-bg-stop-0", defaultValue: runtimeThemeDefaults["--hm-sem-brand-bg-stop-0"], type: "color" },
"color.bg.brand.stop.12": { property: "--hm-sem-brand-bg-stop-12", defaultValue: runtimeThemeDefaults["--hm-sem-brand-bg-stop-12"], type: "color" },
"color.bg.brand.stop.42": { property: "--hm-sem-brand-bg-stop-42", defaultValue: runtimeThemeDefaults["--hm-sem-brand-bg-stop-42"], type: "color" },
"color.bg.brand.stop.78": { property: "--hm-sem-brand-bg-stop-78", defaultValue: runtimeThemeDefaults["--hm-sem-brand-bg-stop-78"], type: "color" },
"color.bg.brand.stop.100": { property: "--hm-sem-brand-bg-stop-100", defaultValue: runtimeThemeDefaults["--hm-sem-brand-bg-stop-100"], type: "color" },
"surface.action.primary.stop.0": { property: "--hm-sem-action-surface-stop-0", defaultValue: runtimeThemeDefaults["--hm-sem-action-surface-stop-0"], type: "color" },
"surface.action.primary.stop.30": { property: "--hm-sem-action-surface-stop-30", defaultValue: runtimeThemeDefaults["--hm-sem-action-surface-stop-30"], type: "color" },
"surface.action.primary.stop.65": { property: "--hm-sem-action-surface-stop-65", defaultValue: runtimeThemeDefaults["--hm-sem-action-surface-stop-65"], type: "color" },
"surface.action.primary.stop.100": { property: "--hm-sem-action-surface-stop-100", defaultValue: runtimeThemeDefaults["--hm-sem-action-surface-stop-100"], type: "color" },
"surface.action.primary.border.stop.0": { property: "--hm-sem-action-border-stop-0", defaultValue: runtimeThemeDefaults["--hm-sem-action-border-stop-0"], type: "color" },
"surface.action.primary.border.stop.50": { property: "--hm-sem-action-border-stop-50", defaultValue: runtimeThemeDefaults["--hm-sem-action-border-stop-50"], type: "color" },
"surface.action.primary.border.stop.100": { property: "--hm-sem-action-border-stop-100", defaultValue: runtimeThemeDefaults["--hm-sem-action-border-stop-100"], type: "color" },
"radius.input.default": { property: "--hm-radius-input", defaultValue: runtimeThemeDefaults["--hm-radius-input"], type: "radius" },
"radius.container.default": { property: "--hm-radius-container", defaultValue: runtimeThemeDefaults["--hm-radius-container"], type: "radius" },
"color.state.active": { property: "--hm-sem-state-active", defaultValue: runtimeThemeDefaults["--hm-sem-state-active"], type: "color" },
"color.on.state.active": { property: "--hm-sem-on-state-active", defaultValue: runtimeThemeDefaults["--hm-sem-on-state-active"], type: "color" },
"color.fg.inverse": { property: "--hm-sem-on-action-primary", defaultValue: runtimeThemeDefaults["--hm-sem-on-action-primary"], type: "color" },
"color.bg.subtle": { property: "--hm-sem-bg-subtle", defaultValue: runtimeThemeDefaults["--hm-sem-bg-subtle"], type: "color" },
"color.accent": { property: "--hm-sem-accent", defaultValue: runtimeThemeDefaults["--hm-sem-accent"], type: "color" },
"color.on.accent": { property: "--hm-sem-on-accent", defaultValue: runtimeThemeDefaults["--hm-sem-on-accent"], type: "color" },
"color.palette.green-light-4": { property: "--hm-sem-palette-green-light-4", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-light-4"], type: "color" },
"color.palette.green-light-3": { property: "--hm-sem-palette-green-light-3", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-light-3"], type: "color" },
"color.palette.green-light-2": { property: "--hm-sem-palette-green-light-2", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-light-2"], type: "color" },
"color.palette.green-light-1": { property: "--hm-sem-palette-green-light-1", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-light-1"], type: "color" },
"color.palette.green-default": { property: "--hm-sem-palette-green-default", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-default"], type: "color" },
"color.palette.green-dark-1": { property: "--hm-sem-palette-green-dark-1", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-dark-1"], type: "color" },
"color.palette.green-dark-2": { property: "--hm-sem-palette-green-dark-2", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-dark-2"], type: "color" },
"color.palette.brown-light-2": { property: "--hm-sem-palette-brown-light-2", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-light-2"], type: "color" },
"color.palette.brown-light-1": { property: "--hm-sem-palette-brown-light-1", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-light-1"], type: "color" },
"color.palette.brown-default": { property: "--hm-sem-palette-brown-default", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-default"], type: "color" },
"color.palette.brown-dark-1": { property: "--hm-sem-palette-brown-dark-1", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-dark-1"], type: "color" },
"color.palette.brown-light-4": { property: "--hm-sem-palette-brown-light-4", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-light-4"], type: "color" },
"color.palette.orange-light-4": { property: "--hm-sem-palette-orange-light-4", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-light-4"], type: "color" },
"color.palette.orange-default": { property: "--hm-sem-palette-orange-default", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-default"], type: "color" },
"color.palette.warm-neutral-light": { property: "--hm-sem-palette-warm-neutral-light", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-light"], type: "color" },
"color.palette.warm-neutral-mid": { property: "--hm-sem-palette-warm-neutral-mid", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-mid"], type: "color" },
"color.palette.warm-neutral-dark": { property: "--hm-sem-palette-warm-neutral-dark", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-dark"], type: "color" },
"color.palette.beige-light": { property: "--hm-sem-palette-beige-light", defaultValue: runtimeThemeDefaults["--hm-sem-palette-beige-light"], type: "color" },
"color.palette.red-danger": { property: "--hm-sem-palette-red-danger", defaultValue: runtimeThemeDefaults["--hm-sem-palette-red-danger"], type: "color" },
"color.palette.white": { property: "--hm-sem-palette-white", defaultValue: runtimeThemeDefaults["--hm-sem-palette-white"], type: "color" },
"color.palette.black": { property: "--hm-sem-palette-black", defaultValue: runtimeThemeDefaults["--hm-sem-palette-black"], type: "color" },
"color.palette.neutral-gray-500": { property: "--hm-sem-palette-neutral-gray-500", defaultValue: runtimeThemeDefaults["--hm-sem-palette-neutral-gray-500"], type: "color" },
"color.palette.green-tone-1": { property: "--hm-sem-palette-green-tone-1", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-tone-1"], type: "color" },
"color.palette.green-tone-2": { property: "--hm-sem-palette-green-tone-2", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-tone-2"], type: "color" },
"color.palette.green-tone-3": { property: "--hm-sem-palette-green-tone-3", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-tone-3"], type: "color" },
"color.palette.green-tone-4": { property: "--hm-sem-palette-green-tone-4", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-tone-4"], type: "color" },
"color.palette.warm-neutral-pale": { property: "--hm-sem-palette-warm-neutral-pale", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-pale"], type: "color" },
"color.palette.warm-neutral-secondary": { property: "--hm-sem-palette-warm-neutral-secondary", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-secondary"], type: "color" },
"color.palette.warm-neutral-muted": { property: "--hm-sem-palette-warm-neutral-muted", defaultValue: runtimeThemeDefaults["--hm-sem-palette-warm-neutral-muted"], type: "color" },
"color.palette.green-tint": { property: "--hm-sem-palette-green-tint", defaultValue: runtimeThemeDefaults["--hm-sem-palette-green-tint"], type: "color" },
"color.palette.yellow-accent": { property: "--hm-sem-palette-yellow-accent", defaultValue: runtimeThemeDefaults["--hm-sem-palette-yellow-accent"], type: "color" },
"color.palette.brown-deep": { property: "--hm-sem-palette-brown-deep", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-deep"], type: "color" },
"color.palette.brown-highlight": { property: "--hm-sem-palette-brown-highlight", defaultValue: runtimeThemeDefaults["--hm-sem-palette-brown-highlight"], type: "color" },
"color.palette.orange-light": { property: "--hm-sem-palette-orange-light", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-light"], type: "color" },
"color.palette.orange-mid": { property: "--hm-sem-palette-orange-mid", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-mid"], type: "color" },
"color.palette.orange-dark": { property: "--hm-sem-palette-orange-dark", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-dark"], type: "color" },
"color.palette.orange-deep": { property: "--hm-sem-palette-orange-deep", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-deep"], type: "color" },
"color.palette.orange-border": { property: "--hm-sem-palette-orange-border", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-border"], type: "color" },
"color.palette.orange-border-deep": { property: "--hm-sem-palette-orange-border-deep", defaultValue: runtimeThemeDefaults["--hm-sem-palette-orange-border-deep"], type: "color" },
"color.palette.gray": { property: "--hm-sem-palette-gray", defaultValue: runtimeThemeDefaults["--hm-sem-palette-gray"], type: "color" },
"color.palette.gray-dark": { property: "--hm-sem-palette-gray-dark", defaultValue: runtimeThemeDefaults["--hm-sem-palette-gray-dark"], type: "color" },
"color.palette.indicator": { property: "--hm-sem-palette-indicator", defaultValue: runtimeThemeDefaults["--hm-sem-palette-indicator"], type: "color" },
} as const
export type RuntimeThemeToken = keyof typeof runtimeThemeContract
export type RuntimeTheme = { schemaVersion: 1; tokens: Partial<Record<RuntimeThemeToken, string>> }
export const defaultRuntimeTheme: RuntimeTheme = {
schemaVersion: 1,
tokens: Object.fromEntries(Object.entries(runtimeThemeContract).map(([key, value]) => [key, value.defaultValue])),
}
export const runtimeGradientGroups = [
[
"color.bg.brand.stop.0",
"color.bg.brand.stop.12",
"color.bg.brand.stop.42",
"color.bg.brand.stop.78",
"color.bg.brand.stop.100"
],
[
"surface.action.primary.stop.0",
"surface.action.primary.stop.30",
"surface.action.primary.stop.65",
"surface.action.primary.stop.100"
],
[
"surface.action.primary.border.stop.0",
"surface.action.primary.border.stop.50",
"surface.action.primary.border.stop.100"
]
] as const
+149
View File
@@ -0,0 +1,149 @@
/* Dashboard Mode owns its internal navigation and scroll frame. No GNB/LNB CSS variable or responsive dependency. */
:root, [data-hm-theme] {
--hm-dashboard-height: 100dvh;
--hm-dashboard-background: var(--hm-sem-palette-green-light-2);
--hm-dashboard-content-background: var(--hm-sem-bg-content);
--hm-dashboard-content-top-radius: 12px;
--hm-dashboard-content-side-radius: 18px;
--hm-dashboard-sidebar-width: 272px;
--hm-dashboard-min-width: 960px;
--hm-dashboard-header-background: linear-gradient(180deg, var(--hm-sem-action-surface-stop-0) 0%, var(--hm-sem-action-surface-stop-30) 30%, var(--hm-sem-action-surface-stop-65) 65%, var(--hm-sem-action-surface-stop-100) 100%);
--hm-dashboard-nav-text: var(--hm-sem-on-action-primary);
--hm-dashboard-nav-text-secondary: var(--hm-sem-palette-green-tint);
--hm-dashboard-nav-text-stroke-width: 1px;
--hm-dashboard-nav-text-stroke-color: color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent);
--hm-dashboard-nav-focus-outline: 2px solid var(--hm-sem-on-action-primary);
--hm-dashboard-padding: 34px;
--hm-dashboard-gap: 24px;
--hm-dashboard-content-shadow-top: 0 -8px 18px color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 18%, transparent);
--hm-dashboard-content-shadow-side: -8px 0 18px color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 18%, transparent);
}
[data-slot="dashboard-scroll"] {
box-sizing: border-box;
height: var(--hm-dashboard-height);
min-width: 0;
max-width: 100%;
overflow: auto;
}
[data-slot="dashboard-layout"] {
box-sizing: border-box;
height: 100%;
/* 4 × 200px KPI cards + 3 × 24px gaps + 2 × 34px padding + 20px scrollbar allowance.
Also fits the measured 549px compact status bar, its wings and the 125px brand without overlap. */
min-width: var(--hm-dashboard-min-width, 960px);
}
[data-slot="dashboard-layout"][data-sidebar] {
min-width: calc(var(--hm-dashboard-min-width) + var(--hm-dashboard-sidebar-width));
}
[data-slot="dashboard-body"] {
flex: 1 1 0;
min-height: 0;
min-width: 0;
background: var(--hm-dashboard-background);
}
[data-slot="dashboard-header"] {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 48px;
padding: 0 20px;
background: var(--hm-dashboard-header-background);
color: var(--hm-dashboard-nav-text);
}
[data-slot="dashboard-body"] > [data-slot="dashboard-sidebar"] {
flex: none;
width: var(--hm-dashboard-sidebar-width);
min-height: 0;
padding: 28px 20px 22px;
gap: 22px;
background: linear-gradient(90deg, var(--hm-sem-action-primary) 0%, var(--hm-sem-palette-green-light-1) 30%, var(--hm-sem-palette-green-light-2) 50%, var(--hm-sem-palette-green-light-3) 90%, var(--hm-sem-palette-green-light-2) 100%);
color: var(--hm-dashboard-nav-text);
}
[data-slot="dashboard-sidebar"] > nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
[data-slot="dashboard-sidebar"] [data-slot="button"] {
justify-content: flex-start;
width: 100%;
min-height: 44px;
height: auto;
white-space: normal;
color: var(--hm-dashboard-nav-text);
}
[data-slot="dashboard-sidebar"] [data-slot="button"]:hover {
background: color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
}
[data-slot="dashboard-sidebar"] [data-slot="button"][aria-expanded="true"] {
background: color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
}
[data-slot="dashboard-sidebar"] [data-slot="collapsible"][data-current] > [data-slot="button"] {
color: var(--hm-sem-palette-yellow-accent);
}
[data-slot="dashboard-sidebar"] [data-slot="button"][aria-expanded="true"] > svg {
transform: rotate(90deg);
}
[data-slot="dashboard-sidebar"] [aria-current="page"],
[data-slot="dashboard-sidebar"] [aria-current="page"]:hover {
background: var(--hm-dashboard-content-background);
color: var(--hm-sem-action-primary);
}
[data-slot="dashboard-sidebar"] [data-slot="button"]:focus-visible {
outline: var(--hm-dashboard-nav-focus-outline);
}
[data-slot="dashboard-sidebar"] [aria-current="page"]:focus-visible {
outline-color: var(--hm-sem-action-primary);
}
/* Content surface and vertical scroll container, positioned after Dashboard's internal navigation. */
[data-slot="dashboard-main"] {
position: relative;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
min-height: 0;
padding: var(--hm-dashboard-padding);
overflow-x: auto;
overflow-y: auto;
/* Let horizontal wheel/touch gestures chain to the outer Dashboard frame. */
overscroll-behavior-y: contain;
background: var(--hm-dashboard-content-background);
}
/* Dashboard header and sidebar use Dashboard-owned radii. */
[data-slot="dashboard-layout"][data-header] [data-slot="dashboard-main"] {
border-radius: var(--hm-dashboard-content-top-radius) var(--hm-dashboard-content-top-radius) 0 0;
box-shadow: var(--hm-dashboard-content-shadow-top);
}
[data-slot="dashboard-content"] {
gap: var(--hm-dashboard-gap);
min-width: 0;
}
[data-slot="dashboard-content"] > * {
min-width: 0;
}
/* Preserve the desktop filter row inside the minimum-width frame, independent of the viewport's mobile Filter Bar rules. */
[data-slot="dashboard-layout"] [data-slot="filter-bar"] {
flex-wrap: nowrap;
}
[data-slot="dashboard-layout"] [data-slot="filter-bar-search"] {
flex: 0 0 var(--hm-filter-bar-search-width);
}
[data-slot="dashboard-layout"] [data-slot="filter-bar"] > :is([data-slot="select-trigger"], [data-slot="combobox-trigger"], [data-slot="date-picker-trigger"], [data-slot="input"]) {
flex: 0 0 var(--hm-filter-bar-control-width);
width: var(--hm-filter-bar-control-width);
}
[data-slot="dashboard-layout"] [data-slot="filter-bar-actions"] {
flex: 0 0 auto;
}
/* The sidebar remains inside the Dashboard minimum-width frame at every viewport width. */
[data-slot="dashboard-layout"][data-sidebar] [data-slot="dashboard-main"] {
border-radius: var(--hm-dashboard-content-side-radius) 0 0 var(--hm-dashboard-content-side-radius);
box-shadow: var(--hm-dashboard-content-shadow-side);
}
+76
View File
@@ -0,0 +1,76 @@
/* Data Table pattern: wrappers around the Table primitive. Header / cell / row looks are src/theme/table.css; Checkbox, Pagination, Select,
Dropdown Menu, Button and Skeleton keep their own tokens. Only the footer layout, summary text, sort button, empty and loading cells are here. */
:root, [data-hm-theme] {
--hm-data-table-footer-gap: 12px;
--hm-data-table-footer-padding-top: 12px;
--hm-data-table-summary-color: var(--hm-sem-fg-secondary);
--hm-data-table-summary-font-size: 13px;
--hm-data-table-sort-gap: 4px;
--hm-data-table-sort-icon-size: 14px;
--hm-data-table-sort-active-color: var(--hm-sem-palette-green-light-3);
--hm-data-table-empty-padding-y: 48px;
--hm-data-table-empty-color: var(--hm-sem-fg-secondary);
--hm-data-table-skeleton-height: 16px;
}
/* min-width 0 lets the Table container scroll sideways inside a grid / flex parent instead of widening the page. */
[data-slot="data-table"] {
min-width: 0;
}
[data-slot="data-table-footer"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--hm-data-table-footer-gap);
padding-top: var(--hm-data-table-footer-padding-top);
}
[data-slot="data-table-summary"] {
margin: 0;
margin-right: auto;
color: var(--hm-data-table-summary-color);
font-size: var(--hm-data-table-summary-font-size);
}
[data-slot="data-table-page-size"] {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--hm-data-table-summary-color);
font-size: var(--hm-data-table-summary-font-size);
}
[data-slot="data-table-footer"] [data-slot="data-table-pagination"] {
width: auto;
margin: 0;
}
[data-slot="data-table-sort"] {
gap: var(--hm-data-table-sort-gap);
font: inherit;
color: inherit;
cursor: pointer;
}
[data-slot="data-table-sort"] svg {
width: var(--hm-data-table-sort-icon-size);
height: var(--hm-data-table-sort-icon-size);
opacity: 0.6;
}
[data-slot="data-table-sort"][data-sorted] svg {
opacity: 1;
color: var(--hm-data-table-sort-active-color);
}
[data-slot="data-table-sort"]:focus-visible {
outline: 2px solid var(--hm-sem-stroke-input-focus);
outline-offset: 2px;
border-radius: 2px;
}
[data-slot="data-table-empty"] {
padding-top: var(--hm-data-table-empty-padding-y);
padding-bottom: var(--hm-data-table-empty-padding-y);
text-align: center;
color: var(--hm-data-table-empty-color);
}
[data-slot="data-table-skeleton"] {
height: var(--hm-data-table-skeleton-height);
width: 100%;
}
+89
View File
@@ -0,0 +1,89 @@
/* Date Picker mapping. ui-viz lists Date Picker by name only (no spec), so these are current HM values built from the HM Semantic /
Primitive tokens. Date Picker is a composition (Popover + Calendar): the popup surface comes from Popover and the calendar from
Calendar, so only the trigger (a field, in the two Select sizes) is styled here. The Popover padding / width are overridden per
instance through an inline style in the component (the popup hugs the Calendar, which has its own padding).
The trigger look is in `@layer components` so a consumer's utility classes (width, height) still win. Nothing here references
Button / Input / Select / Popover tokens. */
:root, [data-hm-theme] {
/* Inner heights; the 1px border is added around them (outer 34 / 44), like the Select trigger. */
--hm-date-picker-height-md: 32px;
--hm-date-picker-height-lg: 42px;
--hm-date-picker-border-width: 1px;
--hm-date-picker-padding-x: 12px;
--hm-date-picker-gap: 12px;
--hm-date-picker-radius: var(--hm-radius-control-md);
--hm-date-picker-font-size-md: 14px;
--hm-date-picker-line-height-md: 20px;
--hm-date-picker-font-size-lg: 16px;
--hm-date-picker-line-height-lg: 24px;
--hm-date-picker-background: var(--hm-sem-bg-surface);
--hm-date-picker-text: var(--hm-sem-fg-primary);
--hm-date-picker-placeholder: var(--hm-sem-fg-placeholder);
--hm-date-picker-border-color: var(--hm-sem-stroke-input-default);
--hm-date-picker-focus-border-color: var(--hm-sem-stroke-input-focus);
--hm-date-picker-icon-size: 18px;
--hm-date-picker-icon-color: var(--hm-sem-fg-secondary);
/* Current HM value for this component only; not a global Disabled rule. */
--hm-date-picker-disabled-opacity: 0.6;
}
@layer components {
[data-slot="date-picker-trigger"] {
--hm-date-picker-height: var(--hm-date-picker-height-lg);
--hm-date-picker-font-size: var(--hm-date-picker-font-size-lg);
--hm-date-picker-line-height: var(--hm-date-picker-line-height-lg);
box-sizing: border-box;
min-width: 0; /* lets a long value truncate instead of widening a grid / flex parent */
max-width: 100%;
min-height: calc(var(--hm-date-picker-height) + 2 * var(--hm-date-picker-border-width));
gap: var(--hm-date-picker-gap);
padding: 0 var(--hm-date-picker-padding-x);
border: var(--hm-date-picker-border-width) solid var(--hm-date-picker-border-color);
border-radius: var(--hm-date-picker-radius);
background: var(--hm-date-picker-background);
color: var(--hm-date-picker-text);
font-size: var(--hm-date-picker-font-size);
line-height: var(--hm-date-picker-line-height);
font-weight: 400;
cursor: pointer;
}
[data-slot="date-picker-trigger"][data-size="medium"] {
--hm-date-picker-height: var(--hm-date-picker-height-md);
--hm-date-picker-font-size: var(--hm-date-picker-font-size-md);
--hm-date-picker-line-height: var(--hm-date-picker-line-height-md);
}
[data-slot="date-picker-trigger"][data-empty] [data-slot="date-picker-value"] {
color: var(--hm-date-picker-placeholder);
}
[data-slot="date-picker-value"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="date-picker-icon"] {
flex-shrink: 0;
width: var(--hm-date-picker-icon-size);
height: var(--hm-date-picker-icon-size);
color: var(--hm-date-picker-icon-color);
}
/* Focus and open: the whole outline turns to the focus color (no ring, like the Input / Select focus). */
[data-slot="date-picker-trigger"]:focus-visible,
[data-slot="date-picker-trigger"][data-state="open"] {
border-color: var(--hm-date-picker-focus-border-color);
}
/* Existing native aria-invalid is the source; Calendar selection/focus remains independent. */
[data-slot="date-picker-trigger"][aria-invalid="true"] {
border-color: var(--hm-sem-action-destructive);
}
[data-slot="date-picker-trigger"][aria-invalid="true"]:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="date-picker-trigger"]:disabled {
opacity: var(--hm-date-picker-disabled-opacity);
cursor: not-allowed;
}
}
@@ -0,0 +1,77 @@
// Generated by npm run generate:theme-defaults. Do not edit.
// Colors: themes/default.json; geometry/aliases: scripts/theme-templates.
export const runtimeThemeDefaults = {
"--hm-sem-bg-content": "#f5f0e5",
"--hm-sem-bg-surface": "#ffffff",
"--hm-sem-fg-primary": "#0a221d",
"--hm-sem-fg-secondary": "#6e6759",
"--hm-sem-fg-placeholder": "#999999",
"--hm-sem-action-primary": "#123328",
"--hm-sem-action-cancel": "#6e6759",
"--hm-sem-action-destructive": "#d32f2f",
"--hm-sem-stroke-default": "#9a9284",
"--hm-sem-stroke-input-default": "#dedbd2",
"--hm-sem-stroke-input-focus": "#266651",
"--hm-sem-brand-bg-stop-0": "#0a221d",
"--hm-sem-brand-bg-stop-12": "#071e18",
"--hm-sem-brand-bg-stop-42": "#123328",
"--hm-sem-brand-bg-stop-78": "#266651",
"--hm-sem-brand-bg-stop-100": "#205241",
"--hm-sem-action-surface-stop-0": "#1f8265",
"--hm-sem-action-surface-stop-30": "#165c47",
"--hm-sem-action-surface-stop-65": "#114838",
"--hm-sem-action-surface-stop-100": "#08241c",
"--hm-sem-action-border-stop-0": "#0a221d",
"--hm-sem-action-border-stop-50": "#34daaa",
"--hm-sem-action-border-stop-100": "#08241c",
"--hm-radius-input": "4px",
"--hm-radius-container": "4px",
"--hm-sem-state-active": "#266651",
"--hm-sem-on-state-active": "#ffffff",
"--hm-sem-on-action-primary": "#ffffff",
"--hm-sem-bg-subtle": "#f4f1ea",
"--hm-sem-accent": "#bddfd4",
"--hm-sem-on-accent": "#123328",
"--hm-sem-palette-green-light-4": "#34daaa",
"--hm-sem-palette-green-light-3": "#249473",
"--hm-sem-palette-green-light-2": "#266651",
"--hm-sem-palette-green-light-1": "#205241",
"--hm-sem-palette-green-default": "#123328",
"--hm-sem-palette-green-dark-1": "#0a221d",
"--hm-sem-palette-green-dark-2": "#071e18",
"--hm-sem-palette-brown-light-2": "#897445",
"--hm-sem-palette-brown-light-1": "#604f32",
"--hm-sem-palette-brown-default": "#3e3523",
"--hm-sem-palette-brown-dark-1": "#352d1d",
"--hm-sem-palette-brown-light-4": "#ecd4a2",
"--hm-sem-palette-orange-light-4": "#feba88",
"--hm-sem-palette-orange-default": "#cc5200",
"--hm-sem-palette-warm-neutral-light": "#e0dace",
"--hm-sem-palette-warm-neutral-mid": "#9a9284",
"--hm-sem-palette-warm-neutral-dark": "#6e6759",
"--hm-sem-palette-beige-light": "#f5f0e5",
"--hm-sem-palette-red-danger": "#d32f2f",
"--hm-sem-palette-white": "#ffffff",
"--hm-sem-palette-black": "#000000",
"--hm-sem-palette-neutral-gray-500": "#999999",
"--hm-sem-palette-green-tone-1": "#1f8265",
"--hm-sem-palette-green-tone-2": "#165c47",
"--hm-sem-palette-green-tone-3": "#114838",
"--hm-sem-palette-green-tone-4": "#08241c",
"--hm-sem-palette-warm-neutral-pale": "#dedbd2",
"--hm-sem-palette-warm-neutral-secondary": "#ede8db",
"--hm-sem-palette-warm-neutral-muted": "#f4f1ea",
"--hm-sem-palette-green-tint": "#bddfd4",
"--hm-sem-palette-yellow-accent": "#ffc600",
"--hm-sem-palette-brown-deep": "#221d13",
"--hm-sem-palette-brown-highlight": "#a98d53",
"--hm-sem-palette-orange-light": "#d35a00",
"--hm-sem-palette-orange-mid": "#ce5600",
"--hm-sem-palette-orange-dark": "#b44b00",
"--hm-sem-palette-orange-deep": "#783402",
"--hm-sem-palette-orange-border": "#bc4c00",
"--hm-sem-palette-orange-border-deep": "#5e2902",
"--hm-sem-palette-gray": "#767676",
"--hm-sem-palette-gray-dark": "#5a5a5a",
"--hm-sem-palette-indicator": "#2d2d2d"
} as const
+203
View File
@@ -0,0 +1,203 @@
/* Generated from themes/default.json + scripts/theme-templates/default.css. Do not edit. */
@import "./primitives.css";
/* Derived aliases and geometry. Values are generated from themes/default.json. */
:root, [data-hm-theme] {
--hm-color-primary: var(--hm-sem-palette-green-default);
--hm-color-primary-hover: color-mix(in srgb, var(--hm-sem-action-primary) 85%, var(--hm-sem-palette-black));
--hm-color-primary-active: color-mix(in srgb, var(--hm-sem-action-primary) 70%, var(--hm-sem-palette-black));
--hm-color-surface-default: var(--hm-sem-palette-white);
--hm-color-surface-content: var(--hm-sem-palette-beige-light);
--hm-color-surface-subtle: var(--hm-sem-palette-warm-neutral-muted);
--hm-color-text-primary: var(--hm-sem-palette-green-dark-1);
--hm-color-text-secondary: var(--hm-sem-palette-warm-neutral-dark);
--hm-color-text-inverse: var(--hm-sem-palette-white);
--hm-color-text-placeholder: var(--hm-sem-palette-neutral-gray-500);
--hm-color-border-default: var(--hm-sem-palette-warm-neutral-mid);
--hm-color-border-control: var(--hm-sem-palette-warm-neutral-pale);
/* Common Focus color. Trial value: Light 2 (#266651), pending user confirmation. Was --hm-color-primary. */
--hm-color-border-focus: var(--hm-sem-palette-green-light-2);
--hm-color-action-cancel: var(--hm-sem-palette-warm-neutral-dark);
--hm-color-action-destructive: var(--hm-sem-palette-red-danger);
--hm-color-action-destructive-hover: color-mix(in srgb, var(--hm-sem-action-destructive) 8%, var(--hm-sem-bg-surface));
--hm-color-action-overlay-hover: color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-color-action-overlay-active: color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent);
--hm-color-action-shadow-light: color-mix(in srgb, var(--hm-sem-palette-white) 10%, transparent);
--hm-color-action-shadow-dark: color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent);
--hm-radius-control-md: 4px;
--hm-control-height-md: 34px;
--hm-font-family-base: "Noto Sans KR", "Malgun Gothic", sans-serif;
--hm-font-size-button-md: 13px;
--hm-font-weight-medium: 500;
--hm-font-weight-bold: 700;
/* Existing semantic names remain the shadcn and preview adapter. */
--hm-sem-bg-content: var(--hm-color-surface-content);
--hm-sem-bg-surface: var(--hm-color-surface-default);
--hm-sem-fg-primary: var(--hm-color-text-primary);
--hm-sem-fg-secondary: var(--hm-color-text-secondary);
--hm-sem-fg-placeholder: var(--hm-color-text-placeholder);
--hm-sem-action-primary: var(--hm-color-primary);
--hm-sem-action-cancel: var(--hm-color-action-cancel);
--hm-sem-action-destructive: var(--hm-color-action-destructive);
--hm-sem-stroke-default: var(--hm-color-border-default);
--hm-sem-stroke-input-default: var(--hm-color-border-control);
--hm-sem-stroke-input-focus: var(--hm-color-border-focus);
--hm-sem-on-action-primary: var(--hm-color-text-inverse);
/* Active state fill: checked / selected / on (Checkbox, Radio, Switch, Slider, Progress, Calendar).
Sibling of the Focus chain (--hm-color-border-focus): same Light 2 today, but neither references the other. */
--hm-sem-state-active: var(--hm-sem-palette-green-light-2);
--hm-sem-on-state-active: var(--hm-color-text-inverse);
/* Public gradient stop groups; geometry remains internal. */
--hm-sem-brand-bg-stop-0: var(--hm-sem-palette-green-dark-1);
--hm-sem-brand-bg-stop-12: var(--hm-sem-palette-green-dark-2);
--hm-sem-brand-bg-stop-42: var(--hm-sem-palette-green-default);
--hm-sem-brand-bg-stop-78: var(--hm-sem-palette-green-light-2);
--hm-sem-brand-bg-stop-100: var(--hm-sem-palette-green-light-1);
--hm-sem-bg-brand: linear-gradient(to bottom,
var(--hm-sem-brand-bg-stop-0) 0%, var(--hm-sem-brand-bg-stop-12) 12%,
var(--hm-sem-brand-bg-stop-42) 42%, var(--hm-sem-brand-bg-stop-78) 78%,
var(--hm-sem-brand-bg-stop-100) 100%);
--hm-sem-action-surface-stop-0: var(--hm-sem-palette-green-tone-1);
--hm-sem-action-surface-stop-30: var(--hm-sem-palette-green-tone-2);
--hm-sem-action-surface-stop-65: var(--hm-sem-palette-green-tone-3);
--hm-sem-action-surface-stop-100: var(--hm-sem-palette-green-tone-4);
--hm-sem-surface-action-primary: linear-gradient(180deg,
var(--hm-sem-action-surface-stop-0) 0%, var(--hm-sem-action-surface-stop-30) 30%,
var(--hm-sem-action-surface-stop-65) 65%, var(--hm-sem-action-surface-stop-100) 100%);
--hm-sem-action-border-stop-0: var(--hm-sem-palette-green-dark-1);
--hm-sem-action-border-stop-50: var(--hm-sem-palette-green-light-4);
--hm-sem-action-border-stop-100: var(--hm-sem-palette-green-tone-4);
--hm-sem-border-action-primary: linear-gradient(180deg,
var(--hm-sem-action-border-stop-0) 0%, var(--hm-sem-action-border-stop-50) 50%,
var(--hm-sem-action-border-stop-100) 100%);
--hm-radius-button: var(--hm-radius-control-md);
--hm-radius-input: var(--hm-radius-control-md);
--hm-radius-select: var(--hm-radius-control-md);
--hm-radius-container: var(--hm-radius-control-md);
/* Floating surface: anchored panels (Dropdown Menu, Context Menu, Popover, Hover Card, Menubar content, Navigation Menu panel).
Not Tooltip (inverse face), Select, Card or the modal surfaces. Component tokens keep their own names and point here. */
--hm-sem-floating-background: var(--hm-sem-bg-surface);
--hm-sem-floating-border-width: 1px;
--hm-sem-floating-border-color: var(--hm-sem-stroke-input-default);
--hm-sem-floating-radius: var(--hm-radius-container);
--hm-sem-floating-shadow: 0 0 12px color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-sem-bg-subtle: var(--hm-color-surface-subtle);
--hm-sem-accent: var(--hm-sem-palette-green-tint);
--hm-sem-on-accent: #123328;
--hm-sem-palette-green-light-4: var(--hm-green-light-4);
--hm-sem-palette-green-light-3: var(--hm-green-light-3);
--hm-sem-palette-green-light-2: var(--hm-green-light-2);
--hm-sem-palette-green-light-1: var(--hm-green-light-1);
--hm-sem-palette-green-default: var(--hm-green-default);
--hm-sem-palette-green-dark-1: var(--hm-green-dark-1);
--hm-sem-palette-green-dark-2: var(--hm-green-dark-2);
--hm-sem-palette-brown-light-2: var(--hm-brown-light-2);
--hm-sem-palette-brown-light-1: var(--hm-brown-light-1);
--hm-sem-palette-brown-default: var(--hm-brown-default);
--hm-sem-palette-brown-dark-1: var(--hm-brown-dark-1);
--hm-sem-palette-brown-light-4: var(--hm-brown-light-4);
--hm-sem-palette-orange-light-4: var(--hm-orange-light-4);
--hm-sem-palette-orange-default: var(--hm-orange-default);
--hm-sem-palette-warm-neutral-light: var(--hm-warm-neutral-light);
--hm-sem-palette-warm-neutral-mid: var(--hm-warm-neutral-mid);
--hm-sem-palette-warm-neutral-dark: var(--hm-warm-neutral-dark);
--hm-sem-palette-beige-light: var(--hm-beige-light);
--hm-sem-palette-red-danger: var(--hm-red-danger);
--hm-sem-palette-white: var(--hm-white);
--hm-sem-palette-black: var(--hm-black);
--hm-sem-palette-neutral-gray-500: var(--hm-neutral-gray-500);
--hm-sem-palette-green-tone-1: var(--hm-green-tone-1);
--hm-sem-palette-green-tone-2: var(--hm-green-tone-2);
--hm-sem-palette-green-tone-3: var(--hm-green-tone-3);
--hm-sem-palette-green-tone-4: var(--hm-green-tone-4);
--hm-sem-palette-warm-neutral-pale: var(--hm-warm-neutral-pale);
--hm-sem-palette-warm-neutral-secondary: var(--hm-warm-neutral-secondary);
--hm-sem-palette-warm-neutral-muted: var(--hm-warm-neutral-muted);
--hm-sem-palette-green-tint: var(--hm-green-tint);
--hm-sem-palette-yellow-accent: var(--hm-yellow-accent);
--hm-sem-palette-brown-deep: #221d13;
--hm-sem-palette-brown-highlight: #a98d53;
--hm-sem-palette-orange-light: #d35a00;
--hm-sem-palette-orange-mid: #ce5600;
--hm-sem-palette-orange-dark: #b44b00;
--hm-sem-palette-orange-deep: #783402;
--hm-sem-palette-orange-border: #bc4c00;
--hm-sem-palette-orange-border-deep: #5e2902;
--hm-sem-palette-gray: #767676;
--hm-sem-palette-gray-dark: #5a5a5a;
--hm-sem-palette-indicator: #2d2d2d;
--hm-sem-overlay-background: color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
--hm-sem-surface-highlight: color-mix(in srgb, var(--hm-sem-accent) 45%, transparent);
/* Adapter for shadcn/Tailwind. The public contract.ts allowlist limits runtime overrides. */
--background: var(--hm-sem-bg-content);
--foreground: var(--hm-sem-fg-primary);
--card: var(--hm-sem-bg-surface);
--card-foreground: var(--hm-sem-fg-primary);
--popover: var(--hm-sem-bg-surface);
--popover-foreground: var(--hm-sem-fg-primary);
--primary: var(--hm-sem-action-primary);
--primary-foreground: var(--hm-sem-on-action-primary);
--secondary: var(--hm-sem-palette-warm-neutral-secondary);
--secondary-foreground: var(--hm-sem-palette-brown-default);
--muted: var(--hm-sem-bg-subtle);
--muted-foreground: var(--hm-sem-fg-secondary);
--accent: var(--hm-sem-accent);
--accent-foreground: var(--hm-sem-on-accent);
--destructive: var(--hm-sem-action-destructive);
--border: var(--hm-sem-stroke-default);
--input: var(--hm-sem-stroke-input-default);
--ring: var(--hm-sem-stroke-input-focus);
--radius: var(--hm-radius-container);
}
+104
View File
@@ -0,0 +1,104 @@
/* Dialog mapping. ui_viz: docs/components/dialog.md (Dialog / Modal), §1.1, §2, §4, §9. Radix keeps Portal, focus trap, ARIA, Escape, focus return and scroll lock.
Only the look is set here: container, regions (Header / Body / Footer) and text. The Overlay, motion and Close button keep the shadcn defaults. */
:root, [data-hm-theme] {
/* ui_viz: dialog.md §2, §8.1 — Radius 8px (Dialog-local, USER_DECISION) and shadow 0 0 12px rgba(0,0,0,.2) (D-093 D-088) — Confirmed. Dialog-local, not Foundation tokens. */
--hm-dialog-radius: 8px;
--hm-dialog-shadow: 0 0 12px color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
/* ui_viz: dialog.md §2 — Surface color.bg.surface / foreground color.fg.primary — Confirmed reuse */
--hm-dialog-background: var(--hm-sem-bg-surface);
--hm-dialog-text: var(--hm-sem-fg-primary);
/* ui_viz: dialog.md §2 — outer 1px; color #DEDBD2 is Temporary (Token relation TBD, DIALOG-10 FOLLOW_UP). Same value as the Header divider. */
--hm-dialog-border-width: 1px;
--hm-dialog-border-color: var(--hm-sem-stroke-input-default);
--hm-dialog-divider-color: var(--hm-sem-stroke-input-default);
/* ui_viz: dialog.md §9 — Header 24/24/16, Title<->Description 8, Body 24 all sides, Actions 0/24/24, Action gap 8 — Confirmed (D-093, COMPONENT_SCOPED) */
--hm-dialog-padding: 24px;
--hm-dialog-header-padding-bottom: 16px;
--hm-dialog-header-gap: 8px;
--hm-dialog-actions-gap: 8px;
--hm-dialog-footer-padding-top: 16px;
/* ui_viz: dialog.md §1.1 — Title heading-md 20/28/700 (Dialog-local), Description body-sm 14/20/400, Body body-md 16/24/400 */
--hm-dialog-title-font-size: 20px;
--hm-dialog-title-line-height: 28px;
--hm-dialog-title-font-weight: 700;
--hm-dialog-description-font-size: 14px;
--hm-dialog-description-line-height: 20px;
--hm-dialog-body-font-size: 16px;
--hm-dialog-body-line-height: 24px;
/* ui_viz: dialog.md §1.2 — Description foreground color.fg.secondary is REFERENCE_ONLY / PARTIAL (current style kept, not a Confirmed value) */
--hm-dialog-description-color: var(--hm-sem-fg-secondary);
/* ui_viz: dialog.md §2, §9 — size is fit-content; max width = viewport - 2 x edge; max height also subtracts the app common header / footer.
edge 16px is REVIEW (existing reference value, not a global spacing token). The two app heights are Web Mapping names the consuming screen may set (default 0). */
--hm-dialog-edge: 16px;
--hm-dialog-app-header-height: 0px;
--hm-dialog-app-footer-height: 0px;
}
[data-slot="dialog-content"] {
display: flex;
flex-direction: column;
box-sizing: border-box;
/* Content size up to the max width. Not fit-content: with the shadcn `left: 50%` placement, fit-content only gets half the viewport
as available width, so a text Dialog stopped at 50vw (195px at 390px) instead of 100vw - 2 x edge. max-content + max-width gives the same min(content, max). */
width: max-content;
max-width: calc(100vw - 2 * var(--hm-dialog-edge));
max-height: calc(100dvh - var(--hm-dialog-app-header-height) - var(--hm-dialog-app-footer-height) - 2 * var(--hm-dialog-edge));
/* Centered in the area left by the app header / footer. */
top: calc(var(--hm-dialog-app-header-height) + (100dvh - var(--hm-dialog-app-header-height) - var(--hm-dialog-app-footer-height)) / 2);
padding: 0;
border: var(--hm-dialog-border-width) solid var(--hm-dialog-border-color);
border-radius: var(--hm-dialog-radius);
background: var(--hm-dialog-background);
color: var(--hm-dialog-text);
box-shadow: var(--hm-dialog-shadow);
overflow-wrap: anywhere;
}
/* Header: Title (and optional Description) with the inset divider below it (24px each side, not edge to edge).
The divider is the last pixel of the 16px bottom inset (ui_viz draws it inside the padding), so the border width comes out of the padding. */
[data-slot="dialog-header"] {
flex: none;
gap: var(--hm-dialog-header-gap);
margin-inline: var(--hm-dialog-padding);
padding: var(--hm-dialog-padding) 0 calc(var(--hm-dialog-header-padding-bottom) - var(--hm-dialog-border-width));
border-bottom: var(--hm-dialog-border-width) solid var(--hm-dialog-divider-color);
}
[data-slot="dialog-title"] {
font-size: var(--hm-dialog-title-font-size);
line-height: var(--hm-dialog-title-line-height);
font-weight: var(--hm-dialog-title-font-weight);
color: var(--hm-dialog-text);
}
[data-slot="dialog-description"] {
font-size: var(--hm-dialog-description-font-size);
line-height: var(--hm-dialog-description-line-height);
font-weight: 400;
color: var(--hm-dialog-description-color);
}
/* Body: the only region that scrolls. Wide content (tables) scrolls sideways inside it, never the page. */
[data-slot="dialog-body"] {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--hm-dialog-padding);
font-size: var(--hm-dialog-body-font-size);
line-height: var(--hm-dialog-body-line-height);
font-weight: 400;
}
[data-slot="dialog-body"] > :first-child { margin-top: 0; }
[data-slot="dialog-body"] > :last-child { margin-bottom: 0; }
/* Footer (Actions): trailing edge, cancel first and the action last, no divider above. Stacking on a narrow screen is TBD in ui_viz (shadcn stacking kept). */
[data-slot="dialog-footer"] {
flex: none;
gap: var(--hm-dialog-actions-gap);
margin: 0;
padding: var(--hm-dialog-footer-padding-top) var(--hm-dialog-padding) var(--hm-dialog-padding);
border: 0;
border-radius: 0;
background: transparent;
}
+109
View File
@@ -0,0 +1,109 @@
/* Drawer mapping. ui-viz has no Drawer spec, so these are current HM values built from the HM Semantic tokens.
vaul keeps drag-to-dismiss, snap points, scroll / drag arbitration and body scroll lock; Radix keeps Portal, focus trap,
Escape and ARIA. The handle is vaul's drag affordance (bottom direction); only its look is set here. The Overlay keeps the
shadcn default. No --hm-sheet-* or --hm-dialog-* token is referenced. */
:root, [data-hm-theme] {
--hm-drawer-background: var(--hm-sem-bg-surface);
--hm-drawer-text: var(--hm-sem-fg-primary);
--hm-drawer-border-width: 1px;
--hm-drawer-border-color: var(--hm-sem-stroke-input-default);
--hm-drawer-radius: 8px;
--hm-drawer-shadow: 0 0 12px color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
/* Bottom / top: full width, height by content up to max-height. Left / right: fixed width. */
--hm-drawer-max-height: 85dvh;
--hm-drawer-width: 400px;
--hm-drawer-edge-gap: 48px;
--hm-drawer-padding: 24px;
--hm-drawer-header-gap: 8px;
--hm-drawer-footer-gap: 8px;
--hm-drawer-handle-width: 48px;
--hm-drawer-handle-height: 4px;
--hm-drawer-handle-color: var(--hm-sem-stroke-default);
--hm-drawer-handle-margin-top: 12px;
--hm-drawer-title-font-size: 18px;
--hm-drawer-title-line-height: 28px;
--hm-drawer-title-font-weight: 700;
--hm-drawer-description-font-size: 14px;
--hm-drawer-description-line-height: 20px;
--hm-drawer-description-color: var(--hm-sem-fg-secondary);
--hm-drawer-body-font-size: 14px;
--hm-drawer-body-line-height: 20px;
}
[data-slot="drawer-content"] {
box-sizing: border-box;
background: var(--hm-drawer-background);
color: var(--hm-drawer-text);
box-shadow: var(--hm-drawer-shadow);
font-size: var(--hm-drawer-body-font-size);
line-height: var(--hm-drawer-body-line-height);
overflow-wrap: anywhere;
}
[data-slot="drawer-content"][data-vaul-drawer-direction="bottom"] {
max-height: var(--hm-drawer-max-height);
border-top: var(--hm-drawer-border-width) solid var(--hm-drawer-border-color);
border-radius: var(--hm-drawer-radius) var(--hm-drawer-radius) 0 0;
}
[data-slot="drawer-content"][data-vaul-drawer-direction="top"] {
max-height: var(--hm-drawer-max-height);
border-bottom: var(--hm-drawer-border-width) solid var(--hm-drawer-border-color);
border-radius: 0 0 var(--hm-drawer-radius) var(--hm-drawer-radius);
}
[data-slot="drawer-content"][data-vaul-drawer-direction="left"],
[data-slot="drawer-content"][data-vaul-drawer-direction="right"] {
height: 100%;
width: min(var(--hm-drawer-width), calc(100vw - var(--hm-drawer-edge-gap)));
}
[data-slot="drawer-content"][data-vaul-drawer-direction="left"] {
border-right: var(--hm-drawer-border-width) solid var(--hm-drawer-border-color);
border-radius: 0 var(--hm-drawer-radius) var(--hm-drawer-radius) 0;
}
[data-slot="drawer-content"][data-vaul-drawer-direction="right"] {
border-left: var(--hm-drawer-border-width) solid var(--hm-drawer-border-color);
border-radius: var(--hm-drawer-radius) 0 0 var(--hm-drawer-radius);
}
[data-slot="drawer-handle"] {
width: var(--hm-drawer-handle-width);
height: var(--hm-drawer-handle-height);
margin-top: var(--hm-drawer-handle-margin-top);
border-radius: 9999px;
background: var(--hm-drawer-handle-color);
}
[data-slot="drawer-header"] {
flex: none;
gap: var(--hm-drawer-header-gap);
padding: var(--hm-drawer-padding) var(--hm-drawer-padding) 16px;
}
[data-slot="drawer-title"] {
font-size: var(--hm-drawer-title-font-size);
line-height: var(--hm-drawer-title-line-height);
font-weight: var(--hm-drawer-title-font-weight);
color: var(--hm-drawer-text);
}
[data-slot="drawer-description"] {
font-size: var(--hm-drawer-description-font-size);
line-height: var(--hm-drawer-description-line-height);
font-weight: 400;
color: var(--hm-drawer-description-color);
}
/* Body: the only region that scrolls; Header and Footer stay in view. */
[data-slot="drawer-body"] {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 0 var(--hm-drawer-padding);
}
/* Footer: actions stack full width (touch targets on a narrow screen), the primary action first as in shadcn. */
[data-slot="drawer-footer"] {
flex: none;
gap: var(--hm-drawer-footer-gap);
padding: 16px var(--hm-drawer-padding) var(--hm-drawer-padding);
}
+121
View File
@@ -0,0 +1,121 @@
/* Dropdown Menu mapping. ui-viz has no Dropdown Menu spec (inventory: design pending), so these are current HM values built from
the HM Semantic tokens. Radix state attributes (data-highlighted, data-disabled, data-state) drive the states; no HM state attributes.
Floating surface values repeat in Popover / Tooltip / Hover Card, but each component keeps its own tokens. */
:root, [data-hm-theme] {
--hm-dropdown-menu-background: var(--hm-sem-floating-background);
--hm-dropdown-menu-text: var(--hm-sem-fg-primary);
--hm-dropdown-menu-border-width: var(--hm-sem-floating-border-width);
--hm-dropdown-menu-border-color: var(--hm-sem-floating-border-color);
--hm-dropdown-menu-radius: var(--hm-sem-floating-radius);
--hm-dropdown-menu-shadow: var(--hm-sem-floating-shadow);
--hm-dropdown-menu-padding: 4px;
--hm-dropdown-menu-item-height: 36px;
--hm-dropdown-menu-item-padding-x: 12px;
--hm-dropdown-menu-item-gap: 8px;
--hm-dropdown-menu-item-radius: 2px;
--hm-dropdown-menu-font-size: 14px;
--hm-dropdown-menu-line-height: 20px;
--hm-dropdown-menu-font-weight: 400;
/* Highlighted (hover and keyboard) uses the same face as the Select option: the shadcn accent. */
--hm-dropdown-menu-highlight-background: var(--hm-sem-surface-highlight);
--hm-dropdown-menu-highlight-text: var(--accent-foreground);
--hm-dropdown-menu-destructive-text: var(--hm-sem-action-destructive);
--hm-dropdown-menu-destructive-highlight-background: var(--hm-color-action-destructive-hover);
--hm-dropdown-menu-label-color: var(--hm-sem-fg-secondary);
--hm-dropdown-menu-label-font-size: 12px;
--hm-dropdown-menu-label-line-height: 18px;
--hm-dropdown-menu-label-font-weight: 500;
--hm-dropdown-menu-shortcut-color: var(--hm-sem-fg-secondary);
--hm-dropdown-menu-shortcut-font-size: 12px;
--hm-dropdown-menu-separator-color: var(--hm-sem-stroke-input-default);
/* Check / radio indicator column at the start of the item. */
--hm-dropdown-menu-indicator-size: 16px;
--hm-dropdown-menu-radio-dot-size: 8px;
}
[data-slot="dropdown-menu-content"],
[data-slot="dropdown-menu-sub-content"] {
box-sizing: border-box;
padding: var(--hm-dropdown-menu-padding);
border: var(--hm-dropdown-menu-border-width) solid var(--hm-dropdown-menu-border-color);
border-radius: var(--hm-dropdown-menu-radius);
background: var(--hm-dropdown-menu-background);
color: var(--hm-dropdown-menu-text);
box-shadow: var(--hm-dropdown-menu-shadow);
font-size: var(--hm-dropdown-menu-font-size);
line-height: var(--hm-dropdown-menu-line-height);
font-weight: var(--hm-dropdown-menu-font-weight);
}
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-sub-trigger"] {
box-sizing: border-box;
min-height: var(--hm-dropdown-menu-item-height);
gap: var(--hm-dropdown-menu-item-gap);
padding: 0 var(--hm-dropdown-menu-item-padding-x);
border-radius: var(--hm-dropdown-menu-item-radius);
}
/* Checkbox / radio items reserve the indicator column; `inset` aligns plain items to the same text start. */
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-item"][data-inset],
[data-slot="dropdown-menu-sub-trigger"][data-inset] {
padding-left: calc(var(--hm-dropdown-menu-item-padding-x) + var(--hm-dropdown-menu-indicator-size) + var(--hm-dropdown-menu-item-gap));
}
[data-slot="dropdown-menu-item-indicator"] {
left: var(--hm-dropdown-menu-item-padding-x);
width: var(--hm-dropdown-menu-indicator-size);
height: var(--hm-dropdown-menu-indicator-size);
}
[data-slot="dropdown-menu-radio-dot"] {
width: var(--hm-dropdown-menu-radio-dot-size);
height: var(--hm-dropdown-menu-radio-dot-size);
}
/* Highlighted: Radix sets data-highlighted for pointer hover and keyboard focus alike. An open sub menu keeps its trigger highlighted. */
[data-slot="dropdown-menu-item"][data-highlighted],
[data-slot="dropdown-menu-checkbox-item"][data-highlighted],
[data-slot="dropdown-menu-radio-item"][data-highlighted],
[data-slot="dropdown-menu-sub-trigger"][data-highlighted],
[data-slot="dropdown-menu-sub-trigger"][data-state="open"] {
background: var(--hm-dropdown-menu-highlight-background);
color: var(--hm-dropdown-menu-highlight-text);
}
[data-slot="dropdown-menu-item"][data-variant="destructive"] {
color: var(--hm-dropdown-menu-destructive-text);
}
[data-slot="dropdown-menu-item"][data-variant="destructive"][data-highlighted] {
background: var(--hm-dropdown-menu-destructive-highlight-background);
color: var(--hm-dropdown-menu-destructive-text);
}
[data-slot="dropdown-menu-label"] {
padding: 6px var(--hm-dropdown-menu-item-padding-x);
color: var(--hm-dropdown-menu-label-color);
font-size: var(--hm-dropdown-menu-label-font-size);
line-height: var(--hm-dropdown-menu-label-line-height);
font-weight: var(--hm-dropdown-menu-label-font-weight);
}
[data-slot="dropdown-menu-label"][data-inset] {
padding-left: calc(var(--hm-dropdown-menu-item-padding-x) + var(--hm-dropdown-menu-indicator-size) + var(--hm-dropdown-menu-item-gap));
}
[data-slot="dropdown-menu-separator"] {
margin: var(--hm-dropdown-menu-padding) calc(-1 * var(--hm-dropdown-menu-padding));
background: var(--hm-dropdown-menu-separator-color);
}
[data-slot="dropdown-menu-shortcut"] {
color: var(--hm-dropdown-menu-shortcut-color);
font-size: var(--hm-dropdown-menu-shortcut-font-size);
letter-spacing: 0.05em;
}
[data-slot="dropdown-menu-item"][data-highlighted] [data-slot="dropdown-menu-shortcut"],
[data-slot="dropdown-menu-checkbox-item"][data-highlighted] [data-slot="dropdown-menu-shortcut"],
[data-slot="dropdown-menu-radio-item"][data-highlighted] [data-slot="dropdown-menu-shortcut"] {
color: inherit;
}
+93
View File
@@ -0,0 +1,93 @@
/* Filter Bar pattern: layout only. The controls inside (Input, Select, Combobox, Date Picker, Button) keep their own component tokens.
Spacing values are pattern-local (no global spacing scale exists yet). */
:root, [data-hm-theme] {
--hm-filter-bar-gap: 8px;
--hm-filter-bar-control-width: 180px;
--hm-filter-bar-search-width: 280px;
--hm-filter-bar-search-icon-size: 16px;
--hm-filter-bar-search-icon-inset: 12px;
--hm-filter-bar-search-icon-gap: 8px;
--hm-filter-bar-search-text-end: 32px; /* clear button 24px + its 6px right inset + 2px */
--hm-filter-bar-search-icon-color: var(--hm-sem-fg-placeholder);
--hm-filter-bar-search-clear-color: var(--hm-sem-fg-secondary);
--hm-filter-bar-narrow-breakpoint: 640px;
}
[data-slot="filter-bar"] {
gap: var(--hm-filter-bar-gap);
}
/* Select / Combobox / Date Picker triggers and a bare Input are w-fit or w-full by themselves; in the bar they get one control width. */
[data-slot="filter-bar"] > :is([data-slot="select-trigger"], [data-slot="combobox-trigger"], [data-slot="date-picker-trigger"], [data-slot="input"]) {
box-sizing: border-box;
width: var(--hm-filter-bar-control-width);
max-width: 100%;
}
[data-slot="filter-bar-search"] {
flex: 0 1 var(--hm-filter-bar-search-width);
min-width: 0;
}
[data-slot="filter-bar-search-icon"] {
top: 50%;
left: var(--hm-filter-bar-search-icon-inset);
width: var(--hm-filter-bar-search-icon-size);
height: var(--hm-filter-bar-search-icon-size);
transform: translateY(-50%);
color: var(--hm-filter-bar-search-icon-color);
}
/* The text starts after the icon (inset + size + gap) and ends before the clear button. Input's Focus / Error / Readonly rules reset
padding-inline at the same specificity and load later, so the search re-applies its insets for those states: Focus / Error give
back the extra 0.5px border exactly as input.css does (the text does not move), and Readonly (no border) keeps the icon clear. */
[data-slot="filter-bar-search"] {
--hm-filter-bar-search-text-start: calc(var(--hm-filter-bar-search-icon-inset) + var(--hm-filter-bar-search-icon-size) + var(--hm-filter-bar-search-icon-gap));
}
[data-slot="filter-bar-search"] [data-slot="input"] {
padding-left: var(--hm-filter-bar-search-text-start);
padding-right: var(--hm-filter-bar-search-text-end);
}
[data-slot="filter-bar-search"] [data-slot="input"]:is(:focus-visible, [aria-invalid="true"]) {
padding-left: calc(var(--hm-filter-bar-search-text-start) - (var(--hm-input-state-border-width) - var(--hm-input-border-width)));
padding-right: calc(var(--hm-filter-bar-search-text-end) - (var(--hm-input-state-border-width) - var(--hm-input-border-width)));
}
[data-slot="filter-bar-search"] [data-slot="input"][readonly],
[data-slot="filter-bar-search"] [data-slot="input"][readonly]:focus-visible {
padding-left: var(--hm-filter-bar-search-text-start);
padding-right: var(--hm-filter-bar-search-text-end);
}
[data-slot="filter-bar-search"] [data-slot="input"]::-webkit-search-cancel-button {
display: none;
}
[data-slot="filter-bar-search-clear"] {
top: 50%;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
transform: translateY(-50%);
color: var(--hm-filter-bar-search-clear-color);
}
[data-slot="filter-bar-search-clear"] svg {
width: 14px;
height: 14px;
}
[data-slot="filter-bar-actions"] {
gap: var(--hm-filter-bar-gap);
margin-left: auto;
}
/* Narrow screen: search takes a full row, the controls share rows, the actions take their own row. */
@media (max-width: 640px) {
[data-slot="filter-bar-search"] {
flex: 1 1 100%;
}
[data-slot="filter-bar"] > :is([data-slot="select-trigger"], [data-slot="combobox-trigger"], [data-slot="date-picker-trigger"], [data-slot="input"]) {
flex: 1 1 calc(50% - var(--hm-filter-bar-gap));
width: auto;
min-width: 0;
}
[data-slot="filter-bar-actions"] {
flex: 1 1 100%;
justify-content: flex-end;
}
}
+573
View File
@@ -0,0 +1,573 @@
/* GNB pattern (layout visual reference: D:\컴포넌트\ui-viz Patterns/GNB, implementation: current @hm-design/ui primitives).
The bar is the ui-viz green top header: 70px, vertical brand gradient, brand left, menu centered on the whole bar, text-only profile
right, Beige "current" item joined to the content by two concave corners, White 2-depth panel. The Navigation Menu, Dropdown Menu,
Sheet, Button and Avatar keep their own markup, Radix behavior and tokens; here they are only re-colored INSIDE the GNB scope
(`[data-slot="gnb"] …`), so their own Stories do not change. Spacing values are pattern-local (no global spacing scale exists).
Navigation must use `<NavigationMenu viewport={false}>` so each panel opens under its own trigger at the trigger's width. */
:root, [data-hm-theme] {
/* Exposed for GNB Mode content layouts: the bar height and the content join below it. Mobile uses a shorter bar. */
--hm-gnb-height: 70px;
--hm-gnb-join: 12px;
/* Same stops as the shared Semantic action surface gradient family (tone 1-4); painted over bar + join as one gradient. */
--hm-gnb-background: linear-gradient(180deg, var(--hm-sem-action-surface-stop-0) 0%, var(--hm-sem-action-surface-stop-30) 30%, var(--hm-sem-action-surface-stop-65) 65%, var(--hm-sem-action-surface-stop-100) 100%);
--hm-gnb-padding-x: 28px;
--hm-gnb-column-gap: 24px;
/* Below the z-50 of Popover / Dropdown / Sheet / Dialog (portaled). Applies when `sticky`. */
--hm-gnb-z-index: 40;
--hm-gnb-text: var(--hm-sem-on-action-primary);
--hm-gnb-text-secondary: var(--hm-sem-palette-green-tint);
--hm-gnb-text-stroke-color: color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent);
--hm-gnb-text-stroke-width: 1px;
--hm-gnb-hover-background: color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
--hm-gnb-focus-outline: 2px solid var(--hm-sem-on-action-primary);
--hm-gnb-brand-gap: 12px;
--hm-gnb-brand-padding-end: 28px;
--hm-gnb-brand-eyebrow-size: 11px;
--hm-gnb-brand-eyebrow-spacing: 0.12em;
--hm-gnb-brand-name-size: 16px;
--hm-gnb-brand-text-gap: 5px;
--hm-gnb-logo-max-height: 100%;
/* Primary menu: 4px above the item, item face 66px = bar 70; first/last item keeps room for the current join curves. */
--hm-gnb-menu-inset-top: 4px;
--hm-gnb-menu-join: 12px;
--hm-gnb-menu-min-width: 112px;
--hm-gnb-menu-padding-x: 16px;
--hm-gnb-menu-gap: 12px;
--hm-gnb-menu-radius: 4px 4px 0 0;
--hm-gnb-menu-font-size: 16px;
--hm-gnb-menu-font-weight: 700;
--hm-gnb-current-background: var(--hm-sem-bg-content);
--hm-gnb-current-text: var(--hm-sem-action-primary);
--hm-gnb-open-background: var(--hm-sem-bg-surface);
--hm-gnb-open-text: var(--hm-sem-action-primary);
/* 2-depth panel: parent width, White, items centered. Radius and shadow are review values in ui-viz. */
--hm-gnb-panel-background: var(--hm-sem-bg-surface);
--hm-gnb-panel-radius: 0 0 8px 8px;
--hm-gnb-panel-shadow: 0 6px 16px color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
--hm-gnb-panel-item-min-height: 56px;
--hm-gnb-panel-item-padding: 16px;
--hm-gnb-panel-item-font-size: 16px;
--hm-gnb-panel-item-line-height: 24px;
--hm-gnb-panel-item-text: var(--hm-sem-fg-primary);
--hm-gnb-panel-item-hover-background: var(--hm-sem-bg-content);
--hm-gnb-panel-item-hover-text: var(--hm-sem-action-primary);
/* Profile (Avatar default size + name and role; the text is hidden on the mobile bar, the Avatar stays). */
--hm-gnb-user-gap: 3px;
--hm-gnb-user-radius: var(--hm-radius-control-md);
--hm-gnb-user-name-size: 16px;
--hm-gnb-user-description-size: 12px;
--hm-gnb-user-text-max-width: 220px;
/* Mobile menu content (inside the HM Sheet, white face). */
--hm-gnb-mobile-link-height: 44px;
--hm-gnb-mobile-link-padding-x: 12px;
--hm-gnb-mobile-link-radius: var(--hm-radius-control-md);
--hm-gnb-mobile-link-font-size: 14px;
--hm-gnb-mobile-link-line-height: 20px;
--hm-gnb-mobile-link-font-weight: 500;
--hm-gnb-mobile-link-text: var(--hm-sem-fg-primary);
--hm-gnb-mobile-link-hover-background: var(--accent);
--hm-gnb-mobile-link-hover-text: var(--accent-foreground);
--hm-gnb-mobile-link-current-background: var(--hm-sem-bg-content);
--hm-gnb-mobile-link-current-text: var(--hm-sem-action-primary);
--hm-gnb-mobile-link-current-font-weight: 700;
--hm-gnb-mobile-group-label-color: var(--hm-sem-fg-secondary);
--hm-gnb-mobile-group-gap: 16px;
--hm-gnb-mobile-nested-gap: 8px;
--hm-gnb-mobile-nested-indent: var(--hm-gnb-mobile-link-padding-x);
--hm-gnb-mobile-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
/* ── Bar ─────────────────────────────────────────────────────────────────────────────────────── */
[data-slot="gnb"] {
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr);
column-gap: var(--hm-gnb-column-gap);
align-items: start;
width: 100%;
height: var(--hm-gnb-height);
padding: 0 var(--hm-gnb-padding-x);
background: var(--hm-gnb-background) left top / 100% calc(var(--hm-gnb-height) + var(--hm-gnb-join)) no-repeat;
color: var(--hm-gnb-text);
font-size: 16px;
}
/* Continue the gradient only outside the content's rounded top corners. A full strip would cover the content when the sticky
header creates a higher stacking context. Static and sticky share this geometry (Dashboard Layout contract). */
[data-slot="gnb"]::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: var(--hm-gnb-join);
background: var(--hm-gnb-background) 0 calc(-1 * var(--hm-gnb-height)) / 100% calc(var(--hm-gnb-height) + var(--hm-gnb-join)) no-repeat;
mask:
radial-gradient(circle at bottom right, transparent var(--hm-gnb-join), #000 var(--hm-gnb-join)) left top / var(--hm-gnb-join) 100% no-repeat,
radial-gradient(circle at bottom left, transparent var(--hm-gnb-join), #000 var(--hm-gnb-join)) right top / var(--hm-gnb-join) 100% no-repeat;
pointer-events: none;
}
[data-slot="gnb"][data-sticky] {
position: sticky;
top: 0;
z-index: var(--hm-gnb-z-index);
}
/* ── Brand: horizontal; logo (img / svg) only when supplied, then the name. Eyebrow + name are optional. ───────── */
[data-slot="gnb-brand"] {
grid-column: 1;
justify-self: start;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
height: calc(var(--hm-gnb-height) - 2px);
padding-right: var(--hm-gnb-brand-padding-end);
gap: var(--hm-gnb-brand-gap);
color: var(--hm-gnb-text);
text-decoration: none;
outline: none;
}
[data-slot="gnb-brand"]:focus-visible {
outline: var(--hm-gnb-focus-outline);
outline-offset: -2px;
}
[data-slot="gnb-brand"] > :is(img, svg) {
flex: 0 1 auto;
min-width: 0;
width: auto;
height: auto;
max-width: 100%;
max-height: var(--hm-gnb-logo-max-height);
}
[data-slot="gnb-brand"] > span {
display: grid;
gap: var(--hm-gnb-brand-text-gap);
flex: 0 1 auto;
min-width: 0;
-webkit-text-stroke: var(--hm-gnb-text-stroke-width) var(--hm-gnb-text-stroke-color);
paint-order: stroke fill;
}
[data-slot="gnb-brand"] :is(small, strong) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="gnb-brand"] small {
color: var(--hm-gnb-text-secondary);
font-size: var(--hm-gnb-brand-eyebrow-size);
font-weight: 700;
letter-spacing: var(--hm-gnb-brand-eyebrow-spacing);
line-height: 1.2;
}
[data-slot="gnb-brand"] strong {
font-size: var(--hm-gnb-brand-name-size);
font-weight: 700;
}
/* ── Primary navigation: centered on the whole bar by the equal side columns ────────────────────────────────── */
[data-slot="gnb"] [data-slot="gnb-nav"] {
grid-column: 2;
align-items: flex-start;
min-width: 0;
height: var(--hm-gnb-height);
/* `clip` (unlike `hidden`) leaves the vertical axis visible so the open panel below the bar is not cut; too-long labels are cut here. */
overflow-x: clip;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu"] {
flex: 0 1 auto;
align-items: flex-start;
height: 100%;
justify-content: center;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-list"] {
align-items: flex-start;
justify-content: center;
gap: 0;
height: 100%;
padding-inline: var(--hm-gnb-menu-join);
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-item"] {
height: 100%;
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]) {
position: relative;
justify-content: center;
gap: var(--hm-gnb-menu-gap);
min-width: var(--hm-gnb-menu-min-width);
height: calc(var(--hm-gnb-height) - var(--hm-gnb-menu-inset-top));
margin-top: var(--hm-gnb-menu-inset-top);
padding: 0 var(--hm-gnb-menu-padding-x);
border-radius: var(--hm-gnb-menu-radius);
background: transparent;
color: var(--hm-gnb-text);
font-size: var(--hm-gnb-menu-font-size);
font-weight: var(--hm-gnb-menu-font-weight);
white-space: nowrap;
box-shadow: none;
}
/* Arrow optical correction: (12px SVG box - 7px painted width) / 4 = 1.25px.
Keep the total padding, intrinsic trigger width, gap and icon margin unchanged. */
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"]:has(> [data-slot="navigation-menu-trigger-icon"]) {
padding-left: calc(var(--hm-gnb-menu-padding-x) + 1.25px);
padding-right: calc(var(--hm-gnb-menu-padding-x) - 1.25px);
}
/* Hover / focus (not the current item, not an open parent): Black 10% over the green. */
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is(:hover, :focus-visible):not([data-active], [aria-current="page"], [data-state="open"]) {
background: var(--hm-gnb-hover-background);
color: var(--hm-gnb-text);
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):focus-visible {
outline: var(--hm-gnb-focus-outline);
outline-offset: -2px;
}
/* Current location: Beige face joined to the content below by two concave corners. Hover does not repaint it. Radix `active` on a
Link (`data-active`, `aria-current="page"`); a Trigger carries `aria-current="page"` from the consumer. */
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"]) {
z-index: 1;
background: var(--hm-gnb-current-background);
color: var(--hm-gnb-current-text);
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"]):focus-visible {
outline-color: var(--hm-gnb-current-text);
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"])::before,
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"])::after {
content: "";
position: absolute;
bottom: 0;
width: var(--hm-gnb-menu-join);
height: var(--hm-gnb-menu-join);
pointer-events: none;
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"])::before {
right: 100%;
background: radial-gradient(circle at top left, transparent calc(var(--hm-gnb-menu-join) - 0.5px), var(--hm-gnb-current-background) var(--hm-gnb-menu-join));
}
[data-slot="gnb"] [data-slot="gnb-nav"] :is([data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]):is([data-active], [aria-current="page"])::after {
left: 100%;
background: radial-gradient(circle at top right, transparent calc(var(--hm-gnb-menu-join) - 0.5px), var(--hm-gnb-current-background) var(--hm-gnb-menu-join));
}
/* Open parent: reuse the current concave joins with the panel's surface.
alignPanels supplies each side independently after viewport clamping. */
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"] {
z-index: 3;
background: var(--hm-gnb-open-background);
color: var(--hm-gnb-open-text);
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"]::before,
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"]::after {
content: "";
display: block;
position: absolute;
bottom: 0;
height: var(--hm-gnb-menu-join);
pointer-events: none;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"]::before {
right: 100%;
width: min(var(--hm-gnb-panel-left-extension, 0px), var(--hm-gnb-menu-join));
background: radial-gradient(circle at top left, transparent calc(var(--hm-gnb-menu-join) - 0.5px), var(--hm-gnb-panel-background) var(--hm-gnb-menu-join)) right bottom / var(--hm-gnb-menu-join) var(--hm-gnb-menu-join) no-repeat;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"]::after {
left: 100%;
width: min(var(--hm-gnb-panel-right-extension, 0px), var(--hm-gnb-menu-join));
background: radial-gradient(circle at top right, transparent calc(var(--hm-gnb-menu-join) - 0.5px), var(--hm-gnb-panel-background) var(--hm-gnb-menu-join)) left bottom / var(--hm-gnb-menu-join) var(--hm-gnb-menu-join) no-repeat;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-trigger"][data-state="open"]:focus-visible {
outline-color: var(--hm-gnb-open-text);
}
/* ── 2-depth panel (viewport={false}): under its own trigger, same width, White, centered items ─────────────── */
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] {
position: absolute;
z-index: 2;
top: 100%;
left: 0;
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--hm-gnb-panel-radius);
background: var(--hm-gnb-panel-background);
color: var(--hm-gnb-panel-item-text);
box-shadow: var(--hm-gnb-panel-shadow);
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] > ul {
display: grid;
width: auto;
margin: 0;
padding: 0;
gap: 0;
list-style: none;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"] {
flex-direction: row;
justify-content: center;
align-items: center;
box-sizing: border-box;
width: 100%;
height: auto;
min-height: var(--hm-gnb-panel-item-min-height);
margin: 0;
padding: var(--hm-gnb-panel-item-padding);
border-radius: 0;
background: transparent;
color: var(--hm-gnb-panel-item-text);
font-size: var(--hm-gnb-panel-item-font-size);
line-height: var(--hm-gnb-panel-item-line-height);
font-weight: 700;
text-align: center;
white-space: normal;
word-break: keep-all;
overflow-wrap: anywhere;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"]:is(:hover, :focus-visible, [data-active]) {
background: var(--hm-gnb-panel-item-hover-background);
color: var(--hm-gnb-panel-item-hover-text);
outline: none;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"]:focus-visible {
outline: 2px solid var(--hm-gnb-panel-item-hover-text);
outline-offset: -2px;
}
/* ── Utilities and profile ──────────────────────────────────────────────────────────────────────────────── */
[data-slot="gnb-utilities"] {
grid-column: 3;
justify-self: end;
min-width: 0;
height: calc(var(--hm-gnb-height) - 2px);
gap: 4px;
}
/* Optional icon actions (search, notification...) are HM ghost Buttons; on the green bar they are white with the Black 10% hover. */
[data-slot="gnb-utilities"] [data-slot="button"][data-variant="ghost"] {
color: var(--hm-gnb-text);
}
[data-slot="gnb-utilities"] [data-slot="button"][data-variant="ghost"]:is(:hover, [aria-expanded="true"]) {
background: var(--hm-gnb-hover-background);
color: var(--hm-gnb-text);
}
[data-slot="gnb-utilities"] [data-slot="button"][data-variant="ghost"]:focus-visible {
border-color: transparent;
outline: var(--hm-gnb-focus-outline);
outline-offset: -2px;
box-shadow: none;
}
[data-slot="gnb-user"] {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
padding: 0 8px;
gap: 10px;
border: 0;
border-radius: var(--hm-gnb-user-radius);
background: transparent;
color: var(--hm-gnb-text);
text-align: left;
cursor: pointer;
outline: none;
}
[data-slot="gnb-user"]:is(:hover, [data-state="open"]) {
background: var(--hm-gnb-hover-background);
}
[data-slot="gnb-user"]:focus-visible {
outline: var(--hm-gnb-focus-outline);
outline-offset: -2px;
}
[data-slot="gnb-user-text"] {
display: grid;
gap: var(--hm-gnb-user-gap);
min-width: 0;
max-width: var(--hm-gnb-user-text-max-width);
padding-block: 4px;
}
[data-slot="gnb-user-name"],
[data-slot="gnb-user-description"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-text-stroke: var(--hm-gnb-text-stroke-width) var(--hm-gnb-text-stroke-color);
paint-order: stroke fill;
}
[data-slot="gnb-user-name"] {
font-size: var(--hm-gnb-user-name-size);
font-weight: 700;
}
[data-slot="gnb-user-description"] {
color: var(--hm-gnb-text-secondary);
font-size: var(--hm-gnb-user-description-size);
}
/* ── Mobile menu content (inside the HM Sheet): destinations as a plain link list ─────────────────────────── */
[data-slot="gnb-mobile-nav"] {
display: grid;
gap: var(--hm-gnb-mobile-group-gap);
padding-bottom: 16px;
}
[data-slot="gnb-mobile-group-label"] {
margin: 0;
padding: 0 var(--hm-gnb-mobile-link-padding-x) 4px;
color: var(--hm-gnb-mobile-group-label-color);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
/* A group inside a group (a deeper local level, e.g. the LNB 3-depth): one link padding further in, with room above its title. */
[data-slot="gnb-mobile-group"] [data-slot="gnb-mobile-group"] {
margin-top: var(--hm-gnb-mobile-nested-gap);
padding-left: var(--hm-gnb-mobile-nested-indent);
}
[data-slot="gnb-mobile-link"] {
box-sizing: border-box;
min-height: var(--hm-gnb-mobile-link-height);
padding: 0 var(--hm-gnb-mobile-link-padding-x);
border-radius: var(--hm-gnb-mobile-link-radius);
color: var(--hm-gnb-mobile-link-text);
font-size: var(--hm-gnb-mobile-link-font-size);
line-height: var(--hm-gnb-mobile-link-line-height);
font-weight: var(--hm-gnb-mobile-link-font-weight);
text-decoration: none;
overflow-wrap: anywhere;
outline: none;
}
[data-slot="gnb-mobile-link"]:is(:hover, :focus-visible) {
background: var(--hm-gnb-mobile-link-hover-background);
color: var(--hm-gnb-mobile-link-hover-text);
}
[data-slot="gnb-mobile-link"]:focus-visible {
box-shadow: var(--hm-gnb-mobile-focus-ring);
}
[data-slot="gnb-mobile-link"][aria-current="page"] {
background: var(--hm-gnb-mobile-link-current-background);
color: var(--hm-gnb-mobile-link-current-text);
font-weight: var(--hm-gnb-mobile-link-current-font-weight);
}
/* Desktop panels grow to their longest label. The GNB region measures only the
viewport-edge correction; extreme labels scroll within the bounded panel. */
@media (min-width: 1024px) {
[data-slot="gnb"] [data-slot="gnb-nav"] {
overflow-x: visible;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] {
left: var(--hm-gnb-panel-offset-x, 0px);
width: max-content;
min-width: 100%;
max-width: calc(100vw - 2 * var(--hm-gnb-padding-x));
overflow-x: auto;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] > ul {
min-width: max-content;
}
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"] {
white-space: nowrap;
overflow-wrap: normal;
}
/* The concave current-page joins belong to 1Depth only, not panel rows. */
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"]::before,
[data-slot="gnb"] [data-slot="gnb-nav"] [data-slot="navigation-menu-content"] [data-slot="navigation-menu-link"]::after {
display: none;
}
}
/* ── Mobile bar: below 1024px (Tailwind `lg`). Menu → hamburger + Sheet; profile → Avatar only. Keep this block last. ──────── */
@media (max-width: 1023px) {
:root, [data-hm-theme] {
--hm-gnb-height: 56px;
--hm-gnb-padding-x: 16px;
}
[data-slot="gnb"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-slot="gnb"] [data-slot="gnb-nav"] {
display: none;
}
[data-slot="gnb-brand"] {
flex: 0 1 auto;
height: auto;
padding-right: 0;
}
[data-slot="gnb-brand"] small {
display: none;
}
[data-slot="gnb-utilities"] {
flex: none;
height: auto;
margin-left: auto;
}
[data-slot="gnb-user"] {
padding: 0 4px;
}
[data-slot="gnb-user-text"] {
display: none;
}
/* The hamburger is a ghost Button: white on the green bar. */
[data-slot="gnb"] > [data-slot="button"][data-variant="ghost"] {
color: var(--hm-gnb-text);
}
[data-slot="gnb"] > [data-slot="button"][data-variant="ghost"]:is(:hover, [aria-expanded="true"]) {
background: var(--hm-gnb-hover-background);
color: var(--hm-gnb-text);
}
[data-slot="gnb"] > [data-slot="button"][data-variant="ghost"]:focus-visible {
border-color: transparent;
outline: var(--hm-gnb-focus-outline);
outline-offset: -2px;
box-shadow: none;
}
}
/* Opt-in GNB ONLY composition. Reuse LNB's DOM, tokens and responsive menu unchanged.
These layout hooks ship with @hm-design/ui/theme.css; no Story-only stylesheet is required.
The consumer provides the frame height and content padding. */
[data-hm-gnb-layout="sidebar"] {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
[data-hm-gnb-layout="sidebar"] > [data-slot="gnb"] {
z-index: var(--hm-gnb-z-index);
}
[data-hm-gnb-layout="sidebar"] > [data-hm-gnb-body] {
position: relative;
display: grid;
grid-template-columns: var(--hm-lnb-width) minmax(0, 1fr);
min-width: 0;
min-height: 0;
background: var(--hm-sem-palette-green-light-2);
border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0;
}
[data-hm-gnb-layout="sidebar"] > [data-hm-gnb-body] > [data-slot="lnb"][data-hm-gnb-sidebar] {
--hm-lnb-background: var(--hm-sem-palette-green-light-2);
}
[data-hm-gnb-layout="sidebar"] > [data-hm-gnb-body] > [data-hm-gnb-main] {
position: relative;
grid-column: 2;
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--hm-sem-bg-content);
border-radius: var(--hm-lnb-join) var(--hm-gnb-join) 0 var(--hm-lnb-join);
}
@media (max-width: 1023px) {
[data-hm-gnb-layout="sidebar"] > [data-hm-gnb-body] {
grid-template-columns: minmax(0, 1fr);
}
[data-hm-gnb-layout="sidebar"] > [data-hm-gnb-body] > [data-hm-gnb-main] {
grid-column: 1;
border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
/* Hover Card mapping. ui-viz has no Hover Card spec, so these are current HM values from the HM Semantic tokens.
A richer preview than Tooltip (title / description / metadata); it is wider than Popover so a preview reads in one block. */
:root, [data-hm-theme] {
--hm-hover-card-background: var(--hm-sem-floating-background);
--hm-hover-card-text: var(--hm-sem-fg-primary);
--hm-hover-card-border-width: var(--hm-sem-floating-border-width);
--hm-hover-card-border-color: var(--hm-sem-floating-border-color);
--hm-hover-card-radius: var(--hm-sem-floating-radius);
--hm-hover-card-shadow: var(--hm-sem-floating-shadow);
--hm-hover-card-padding: 16px;
--hm-hover-card-width: 320px;
--hm-hover-card-font-size: 14px;
--hm-hover-card-line-height: 20px;
--hm-hover-card-font-weight: 400;
}
[data-slot="hover-card-content"] {
box-sizing: border-box;
width: var(--hm-hover-card-width);
max-width: calc(100vw - 16px);
padding: var(--hm-hover-card-padding);
border: var(--hm-hover-card-border-width) solid var(--hm-hover-card-border-color);
border-radius: var(--hm-hover-card-radius);
background: var(--hm-hover-card-background);
color: var(--hm-hover-card-text);
box-shadow: var(--hm-hover-card-shadow);
font-size: var(--hm-hover-card-font-size);
line-height: var(--hm-hover-card-line-height);
font-weight: var(--hm-hover-card-font-weight);
overflow-wrap: anywhere;
}
+5
View File
@@ -0,0 +1,5 @@
export { runtimeThemeContract, defaultRuntimeTheme } from "./contract"
export type { RuntimeTheme, RuntimeThemeToken } from "./contract"
export { applyRuntimeTheme, resetRuntimeTheme, isRuntimeTheme } from "./runtime"
export { fetchRuntimeTheme, resolveRuntimeTheme, loadRuntimeTheme } from "./loader"
export type { RuntimeThemeLoadOptions } from "./loader"
+107
View File
@@ -0,0 +1,107 @@
/* Input mapping: the control only. Label, Required star and Helper/Error message are consumer composition, not part of Input.
States use native selectors (:focus-visible, [aria-invalid="true"], :disabled, [readonly]); size uses data-size. */
:root, [data-hm-theme] {
/* ui_viz: docs/components/input.md §24, §16 — Confirmed (size, padding-x). Not aliased to Button or the global control height. */
--hm-input-height-sm: 28px;
--hm-input-height-md: 34px;
--hm-input-height-lg: 42px;
--hm-input-padding-x-sm: 10px;
--hm-input-padding-x-md: 12px;
--hm-input-padding-x-lg: 12px;
/* ui_viz: docs/components/input.md §9, §21 INPUT_VALUE_ROLE_FOLLOWS_SIZE — owner decision (value and placeholder per size) */
--hm-input-font-size-sm: 12px;
--hm-input-line-height-sm: 18px;
--hm-input-font-size-md: 14px;
--hm-input-line-height-md: 20px;
--hm-input-font-size-lg: 16px;
--hm-input-line-height-lg: 24px;
--hm-input-font-weight: 400;
/* ui_viz: docs/components/input.md §10, §11, §1.1 D-005 — Confirmed */
--hm-input-radius: var(--hm-radius-input);
--hm-input-border-width: 1px;
--hm-input-state-border-width: 1.5px;
--hm-input-background: var(--hm-sem-bg-surface);
--hm-input-text: var(--hm-sem-fg-primary);
--hm-input-placeholder: var(--hm-sem-fg-placeholder);
--hm-input-border-color: var(--hm-sem-stroke-input-default);
--hm-input-focus-border-color: var(--hm-sem-stroke-input-focus);
--hm-input-error-border-color: var(--hm-sem-action-destructive);
}
/* Large is the default when size is omitted. Height is a min-height, so the control keeps one line of text centered. */
[data-slot="input"] {
--hm-input-height: var(--hm-input-height-lg);
--hm-input-padding-x: var(--hm-input-padding-x-lg);
--hm-input-font-size: var(--hm-input-font-size-lg);
--hm-input-line-height: var(--hm-input-line-height-lg);
box-sizing: border-box;
height: auto;
min-height: var(--hm-input-height);
padding: 0 var(--hm-input-padding-x);
border: var(--hm-input-border-width) solid var(--hm-input-border-color);
border-radius: var(--hm-input-radius);
background: var(--hm-input-background);
color: var(--hm-input-text);
font-size: var(--hm-input-font-size);
line-height: var(--hm-input-line-height);
font-weight: var(--hm-input-font-weight);
box-shadow: none;
}
[data-slot="input"][data-size="medium"] {
--hm-input-height: var(--hm-input-height-md);
--hm-input-padding-x: var(--hm-input-padding-x-md);
--hm-input-font-size: var(--hm-input-font-size-md);
--hm-input-line-height: var(--hm-input-line-height-md);
}
[data-slot="input"][data-size="small"] {
--hm-input-height: var(--hm-input-height-sm);
--hm-input-padding-x: var(--hm-input-padding-x-sm);
--hm-input-font-size: var(--hm-input-font-size-sm);
--hm-input-line-height: var(--hm-input-line-height-sm);
}
[data-slot="input"]::placeholder {
color: var(--hm-input-placeholder);
opacity: 1;
}
/* Focus: the border alone marks it (1.5px, no shadow or glow). Padding gives back the extra 0.5px so the value does not move. */
[data-slot="input"]:focus-visible {
outline: none;
border-width: var(--hm-input-state-border-width);
border-color: var(--hm-input-focus-border-color);
padding-inline: calc(var(--hm-input-padding-x) - (var(--hm-input-state-border-width) - var(--hm-input-border-width)));
}
/* Invalid owns the border; keyboard focus adds the HM 3px / 50% semantic ring independently. */
[data-slot="input"][aria-invalid="true"] {
border-width: var(--hm-input-state-border-width);
border-color: var(--hm-input-error-border-color);
padding-inline: calc(var(--hm-input-padding-x) - (var(--hm-input-state-border-width) - var(--hm-input-border-width)));
}
[data-slot="input"][aria-invalid="true"]:focus-visible {
border-color: var(--hm-input-error-border-color);
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
/* Disabled keeps the white face. Dimming (opacity 0.6) is set in ui/input.tsx: 2026-10-02 user decision, Input and Select only, not a global Disabled token. */
[data-slot="input"]:disabled {
background: var(--hm-input-background);
}
/* Readonly: no box, text only, not dimmed. Always Large metrics, whatever the size. */
[data-slot="input"][readonly] {
--hm-input-height: var(--hm-input-height-lg);
--hm-input-font-size: var(--hm-input-font-size-lg);
--hm-input-line-height: var(--hm-input-line-height-lg);
border-width: 0;
padding-inline: 0;
background: transparent;
}
[data-slot="input"][readonly]:focus-visible {
border-width: 0;
padding-inline: 0;
outline: revert;
}
+17
View File
@@ -0,0 +1,17 @@
/* Label mapping: typography and color only. Required star, Helper and Error message are consumer composition, not part of Label.
ui_viz has no standalone Label spec; the Field Label role is owned by Input / Textarea (docs/components/input.md §9, §10). */
:root, [data-hm-theme] {
/* ui_viz: docs/components/input.md §9 Field Label → label-md 14/20/500 (CANONICAL_OVERRIDE of the 13/700 Source); textarea.md §2 */
--hm-label-font-size: 14px;
--hm-label-line-height: 20px;
--hm-label-font-weight: 500;
/* ui_viz: docs/components/input.md §10, §16 — color.fg.secondary, Confirmed */
--hm-label-text: var(--hm-sem-fg-secondary);
}
[data-slot="label"] {
color: var(--hm-label-text);
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
font-weight: var(--hm-label-font-weight);
}
+531
View File
@@ -0,0 +1,531 @@
/* LNB pattern (layout visual reference: D:\컴포넌트\ui-viz Patterns/LNB, implementation: HM Collapsible + plain elements).
A 272px green column: horizontal 5-stop surface, brand on top, menu in the middle, text-only profile at the bottom. The current
item is a Beige face (left corners rounded) that runs to the content edge and joins it through two concave corners; hover is
Black 10% to the same edge; an open group is a Black 20% face with a 1px green line. Spacing values are pattern-local. */
:root, [data-hm-theme] {
/* Exposed for the layout: grid column width and the surface that continues behind the content's rounded corners. */
--hm-lnb-width: 272px;
--hm-lnb-join: 18px;
--hm-lnb-background: linear-gradient(90deg, var(--hm-sem-action-primary) 0%, var(--hm-sem-palette-green-light-1) 30%, var(--hm-sem-palette-green-light-2) 50%, var(--hm-sem-palette-green-light-3) 90%, var(--hm-sem-palette-green-light-2) 100%);
--hm-lnb-background-end: var(--hm-sem-palette-green-light-2);
--hm-lnb-padding-top: 28px;
--hm-lnb-padding-bottom: 22px;
--hm-lnb-inset-start: 20px;
--hm-lnb-inset-end: 20px;
/* Menu top (ui-viz 20 under the brand). Padding inside the scroll region, so the scroll starts right under the brand; keep it at
least `--hm-lnb-join` so the curve above a current first item is not clipped (the bottom padding is the join for the same reason). */
--hm-lnb-nav-padding-top: 20px;
--hm-lnb-text: var(--hm-sem-on-action-primary);
--hm-lnb-text-secondary: var(--hm-sem-palette-green-tint);
--hm-lnb-text-stroke-color: color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent);
--hm-lnb-focus-outline: 2px solid var(--hm-sem-on-action-primary);
--hm-lnb-brand-padding-x: 30px;
--hm-lnb-brand-padding-bottom: 22px;
--hm-lnb-brand-gap: 12px;
--hm-lnb-brand-eyebrow-size: 11px;
--hm-lnb-brand-eyebrow-spacing: 0.12em;
--hm-lnb-brand-name-size: 17px;
--hm-lnb-brand-text-gap: 5px;
--hm-lnb-item-min-height: 56px;
--hm-lnb-item-padding-start: 18px;
--hm-lnb-item-gap: 12px;
--hm-lnb-item-font-size: 18px;
--hm-lnb-item-line-height: 28px;
--hm-lnb-item-font-weight: 700;
--hm-lnb-item-radius: 4px 0 0 4px;
--hm-lnb-item-icon-size: 20px;
--hm-lnb-item-icon-opacity: 0.5;
--hm-lnb-item-icon-stroke: 1.7;
--hm-lnb-item-text-stroke-width: 1.5px;
--hm-lnb-item-text-shadow: 1px 2px 1px var(--hm-lnb-text-stroke-color);
--hm-lnb-hover-background: color-mix(in srgb, var(--hm-sem-palette-black) 10%, transparent);
--hm-lnb-current-background: var(--hm-sem-bg-content);
--hm-lnb-current-text: var(--hm-sem-action-primary);
--hm-lnb-current-radius: 8px 0 0 8px;
/* Inset depth of the current face: left edge, top, bottom; the last quarter fades so the content side stays open. */
--hm-lnb-current-depth:
linear-gradient(to right, color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent), transparent 6px) 0 0 / 75% 100% no-repeat,
linear-gradient(to bottom, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat,
linear-gradient(to top, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat;
--hm-lnb-group-open-background: color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent);
--hm-lnb-group-open-line: 1px solid var(--hm-sem-palette-green-light-2);
--hm-lnb-group-radius: 8px 0 0 8px;
--hm-lnb-group-current-text: var(--hm-sem-palette-yellow-accent);
--hm-lnb-group-arrow-size: 18px;
--hm-lnb-subitem-padding-start: 68px;
/* Align the 2-depth link surface with the 1-depth group text; retain its existing text inset. */
--hm-lnb-subitem-surface-inset: calc(var(--hm-lnb-item-padding-start) + var(--hm-lnb-group-arrow-size) + var(--hm-lnb-item-gap));
--hm-lnb-group-padding-bottom: 8px;
/* 3-depth: the items of an `LNBGroup` nested in a group. Same 18px; the hierarchy is the extra 20px indent and the connector
(a vertical line at the 2-depth text start, a 12px branch to each row's middle, 8px to the text; it ends at the last row). */
--hm-lnb-third-min-height: 48px;
--hm-lnb-third-padding-block: 12px;
--hm-lnb-third-padding-start: 88px;
--hm-lnb-third-line-height: 24px;
--hm-lnb-third-font-weight: 500;
--hm-lnb-third-current-font-weight: 700;
--hm-lnb-third-connector-x: var(--hm-lnb-subitem-padding-start);
--hm-lnb-third-connector-length: 12px;
--hm-lnb-third-connector-width: 1px;
--hm-lnb-third-connector-color: color-mix(in srgb, var(--hm-sem-palette-green-tint) 40%, transparent);
--hm-lnb-third-connector-current-color: color-mix(in srgb, var(--hm-sem-palette-green-light-2) 40%, transparent);
--hm-lnb-profile-padding-x: 30px;
--hm-lnb-profile-padding-top: 22px;
--hm-lnb-profile-gap: 12px;
--hm-lnb-profile-name-size: 16px;
--hm-lnb-profile-description-size: 12px;
}
[data-slot="lnb"] {
position: relative;
box-sizing: border-box;
width: var(--hm-lnb-width);
min-height: 100%;
padding: var(--hm-lnb-padding-top) 0 var(--hm-lnb-padding-bottom);
background: var(--hm-lnb-background);
color: var(--hm-lnb-text);
}
/* The surface keeps going for `--hm-lnb-join` behind the rounded left corners of the content next to the column. */
[data-slot="lnb"]::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: var(--hm-lnb-join);
background: var(--hm-lnb-background-end);
pointer-events: none;
}
/* Brand (vertical) and profile: text with the dark text border. */
[data-slot="lnb-brand"] {
flex: none;
gap: var(--hm-lnb-brand-gap);
align-items: flex-start;
padding: 0 var(--hm-lnb-brand-padding-x) var(--hm-lnb-brand-padding-bottom);
}
[data-slot="lnb-brand"] > :is(img, svg) {
flex: 0 0 auto;
max-width: 100%;
height: auto;
}
[data-slot="lnb-brand"] > span {
display: grid;
gap: var(--hm-lnb-brand-text-gap);
min-width: 0;
max-width: 100%;
}
[data-slot="lnb-brand"] :is(small, strong),
[data-slot="lnb-profile"] :is(strong, p) {
-webkit-text-stroke: 1px var(--hm-lnb-text-stroke-color);
paint-order: stroke fill;
}
[data-slot="lnb-brand"] small {
color: var(--hm-lnb-text-secondary);
font-size: var(--hm-lnb-brand-eyebrow-size);
font-weight: 700;
letter-spacing: var(--hm-lnb-brand-eyebrow-spacing);
line-height: 1.2;
}
[data-slot="lnb-brand"] strong {
font-size: var(--hm-lnb-brand-name-size);
font-weight: 700;
overflow-wrap: anywhere;
}
[data-slot="lnb"] > [data-slot="lnb-profile"] {
display: flex;
flex: none;
align-items: center;
gap: var(--hm-lnb-profile-gap);
align-self: flex-start;
width: fit-content;
/* GNB profile's 8px inner hit padding; the existing 30px inset still places the Avatar. */
max-width: calc(100% - 2 * (var(--hm-lnb-profile-padding-x) - 8px));
margin-top: auto;
margin-inline: calc(var(--hm-lnb-profile-padding-x) - 8px);
min-height: var(--hm-lnb-third-min-height);
padding: 0 8px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
[data-slot="lnb"] > [data-slot="lnb-profile"]:is(:hover, [data-state="open"]) {
background: var(--hm-lnb-hover-background);
}
[data-slot="lnb"] > [data-slot="lnb-profile"]:focus-visible {
outline: var(--hm-lnb-focus-outline);
outline-offset: -2px;
}
[data-slot="lnb-profile-text"] {
min-width: 0;
}
[data-slot="lnb-profile"] strong {
display: block;
font-size: var(--hm-lnb-profile-name-size);
overflow-wrap: anywhere;
}
[data-slot="lnb-profile"] p {
margin: 4px 0 0;
color: var(--hm-lnb-text-secondary);
font-size: var(--hm-lnb-profile-description-size);
overflow-wrap: anywhere;
}
/* Menu region and the only scroll container of the column (brand and profile are `flex: none`; the column itself never scrolls). In a
column of fixed height it fills the space between them (`min-height: 0` lets it shrink below the menu height), so it starts at the
brand bottom and ends at the profile top. It is `inset-end + 1px` wider than the column's inner area so a current item can overlap
the content by 1px; vertical padding keeps the join curves of the first / last item from being clipped. `direction: rtl` only moves
the native scrollbar to the left edge, so it never takes width from the right side where the current item joins the content; the
menu itself is LTR again (direct children, see "Menu rows" below). */
[data-slot="lnb-nav"] {
flex: 1 1 auto;
min-height: 0;
direction: rtl;
margin: 0 -1px 0 0;
padding: var(--hm-lnb-nav-padding-top) 0 var(--hm-lnb-join) var(--hm-lnb-inset-start);
overflow-x: clip;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--hm-sem-palette-white) 35%, transparent) transparent;
}
/* Layout owns the old top spacing only when this column owns a Profile. */
[data-slot="lnb"]:has(> [data-slot="lnb-profile"]) > [data-slot="lnb-nav"] {
margin-bottom: var(--hm-lnb-profile-padding-top);
}
/* ── Item ───────────────────────────────────────────────────────────────────────────────────────────── */
[data-slot="lnb-item"],
[data-slot="lnb-group-trigger"] {
position: relative;
box-sizing: border-box;
gap: var(--hm-lnb-item-gap);
width: calc(100% - 1px);
min-height: var(--hm-lnb-item-min-height);
padding: 14px calc(var(--hm-lnb-inset-end) + 12px) 14px var(--hm-lnb-item-padding-start);
border: 0;
border-radius: var(--hm-lnb-item-radius);
background: transparent;
color: var(--hm-lnb-text);
font-family: inherit;
font-size: var(--hm-lnb-item-font-size);
line-height: var(--hm-lnb-item-line-height);
font-weight: var(--hm-lnb-item-font-weight);
text-align: left;
text-decoration: none;
word-break: keep-all;
overflow-wrap: anywhere;
cursor: pointer;
outline: none;
-webkit-text-stroke: var(--hm-lnb-item-text-stroke-width) var(--hm-lnb-text-stroke-color);
paint-order: stroke fill;
text-shadow: var(--hm-lnb-item-text-shadow);
}
[data-slot="lnb-item"] > svg:not([data-slot]) {
flex: 0 0 auto;
width: var(--hm-lnb-item-icon-size);
height: var(--hm-lnb-item-icon-size);
opacity: var(--hm-lnb-item-icon-opacity);
stroke-width: var(--hm-lnb-item-icon-stroke);
}
[data-slot="lnb-item"]:not([aria-current="page"]):hover,
[data-slot="lnb-group-trigger"]:hover {
background: var(--hm-lnb-hover-background);
}
[data-slot="lnb-item"]:focus-visible,
[data-slot="lnb-group-trigger"]:focus-visible {
outline: var(--hm-lnb-focus-outline);
outline-offset: -2px;
}
/* Current: Beige face, left corners 8, runs 1px into the content, concave corners above and below, inset depth. No text effects. */
[data-slot="lnb-item"][aria-current="page"] {
z-index: 1;
width: 100%;
background: var(--hm-lnb-current-depth), var(--hm-lnb-current-background);
border-radius: var(--hm-lnb-current-radius);
color: var(--hm-lnb-current-text);
-webkit-text-stroke: 0;
text-shadow: none;
}
[data-slot="lnb-item"][aria-current="page"]:focus-visible {
outline-color: var(--hm-lnb-current-text);
}
[data-slot="lnb-item"][aria-current="page"]::before,
[data-slot="lnb-item"][aria-current="page"]::after {
content: "";
position: absolute;
right: 1px;
width: var(--hm-lnb-join);
height: var(--hm-lnb-join);
pointer-events: none;
}
[data-slot="lnb-item"][aria-current="page"]::before {
bottom: 100%;
background: radial-gradient(circle at top left, transparent calc(var(--hm-lnb-join) - 0.5px), var(--hm-lnb-current-background) var(--hm-lnb-join));
}
[data-slot="lnb-item"][aria-current="page"]::after {
top: 100%;
background: radial-gradient(circle at bottom left, transparent calc(var(--hm-lnb-join) - 0.5px), var(--hm-lnb-current-background) var(--hm-lnb-join));
}
/* ── Group (HM Collapsible) ─────────────────────────────────────────────────────────────────────────── */
[data-slot="lnb-group"] {
position: relative;
width: calc(100% - 1px);
border-radius: var(--hm-lnb-group-radius);
}
[data-slot="lnb-group"][data-state="open"] {
background: var(--hm-lnb-group-open-background);
}
[data-slot="lnb-group"][data-state="open"]::before {
content: "";
position: absolute;
inset: 0;
border: var(--hm-lnb-group-open-line);
border-right: 0;
border-radius: inherit;
pointer-events: none;
}
[data-slot="lnb-group-trigger"] {
width: 100%;
border-radius: var(--hm-lnb-group-radius);
}
[data-slot="lnb-group"][data-state="open"] > [data-slot="lnb-group-trigger"]:hover {
border-radius: 8px 0 0 0;
}
[data-slot="lnb-group"][data-current] > [data-slot="lnb-group-trigger"] {
color: var(--hm-lnb-group-current-text);
}
[data-slot="lnb-group-arrow"] {
flex: 0 0 auto;
width: var(--hm-lnb-group-arrow-size);
height: var(--hm-lnb-group-arrow-size);
stroke-width: 2;
filter: drop-shadow(var(--hm-lnb-item-text-shadow));
transition: transform 0.15s ease;
}
[data-slot="lnb-group-trigger"][data-state="open"] > [data-slot="lnb-group-arrow"] {
transform: rotate(90deg);
}
[data-slot="lnb-group-content"][data-state="open"] {
display: grid;
padding-bottom: var(--hm-lnb-group-padding-bottom);
}
[data-slot="lnb-group-content"] [data-slot="lnb-item"] {
width: 100%;
padding-left: var(--hm-lnb-subitem-padding-start);
border-radius: 0;
}
[data-slot="lnb-group-content"] [data-slot="lnb-item"]:not([aria-current="page"]):hover {
border-radius: var(--hm-lnb-item-radius);
}
[data-slot="lnb-group-content"] [data-slot="lnb-item"][aria-current="page"] {
width: calc(100% + 1px);
margin-right: -1px;
border-radius: var(--hm-lnb-current-radius);
}
/* Only 2-depth destination links: the group keeps its full layout width, while hover, current, focus and click belong to the
inset link. Subtract the same inset from text padding and width to preserve text wrapping and the right content join. */
[data-slot="lnb-nav"] > [data-slot="lnb-group"] > [data-slot="lnb-group-content"] > [data-slot="lnb-item"] {
width: calc(100% - var(--hm-lnb-subitem-surface-inset));
margin-left: var(--hm-lnb-subitem-surface-inset);
padding-left: calc(var(--hm-lnb-subitem-padding-start) - var(--hm-lnb-subitem-surface-inset));
}
[data-slot="lnb-nav"] > [data-slot="lnb-group"] > [data-slot="lnb-group-content"] > [data-slot="lnb-item"][aria-current="page"] {
width: calc(100% + 1px - var(--hm-lnb-subitem-surface-inset));
}
/* ── Menu rows (direct children of the menu region) ─────────────────────────────────────────────────── */
/* Back to LTR. A left scrollbar narrows the content box from the left, so each row starts that much (the scrollbar width, 0 without
one) to the left again: `100%` is the content box, the region is `--hm-lnb-width + 1px` wide with `--hm-lnb-inset-start` padding. The
scrollbar sits in that padding and the rows keep x 20 → 272 (current 273) with or without it. */
[data-slot="lnb-nav"] > * {
direction: ltr;
margin-left: calc(100% + var(--hm-lnb-inset-start) - var(--hm-lnb-width) - 1px);
}
[data-slot="lnb-nav"] > :is([data-slot="lnb-item"], [data-slot="lnb-group"]) {
width: auto;
margin-right: 1px;
}
[data-slot="lnb-nav"] > [data-slot="lnb-item"][aria-current="page"] {
margin-right: 0;
}
/* ── Nested group (2-depth group inside a group) ────────────────────────────────────────────────────── */
/* The outer group already draws the open face and line, so a nested group adds no face, line, padding or width loss; its name
lines up with the 2-depth item text. */
[data-slot="lnb-group-content"] [data-slot="lnb-group"] {
width: 100%;
border-radius: 0;
}
[data-slot="lnb-group-content"] [data-slot="lnb-group"][data-state="open"] {
background: none;
}
[data-slot="lnb-group-content"] [data-slot="lnb-group"][data-state="open"]::before {
content: none;
}
[data-slot="lnb-group-content"] [data-slot="lnb-group-trigger"] {
padding-left: calc(var(--hm-lnb-subitem-padding-start) - var(--hm-lnb-group-arrow-size) - var(--hm-lnb-item-gap));
border-radius: 0;
}
[data-slot="lnb-group-content"] [data-slot="lnb-group"] > [data-slot="lnb-group-trigger"]:hover {
border-radius: var(--hm-lnb-item-radius);
}
[data-slot="lnb-group-content"] [data-slot="lnb-group-content"][data-state="open"] {
padding-bottom: 0;
}
/* 3-depth only: the row owns the existing connector gradients; the link begins at the branch end.
Decoration follows the row's real middle when text wraps and never owns pointer interaction. */
[data-slot="lnb-third-row"] {
--hm-lnb-third-line: var(--hm-lnb-third-connector-color);
--hm-lnb-third-line-end: 100%;
--hm-lnb-third-connector:
linear-gradient(var(--hm-lnb-third-line), var(--hm-lnb-third-line)) var(--hm-lnb-third-connector-x) 0 / var(--hm-lnb-third-connector-width) var(--hm-lnb-third-line-end) no-repeat,
linear-gradient(var(--hm-lnb-third-line), var(--hm-lnb-third-line)) var(--hm-lnb-third-connector-x) calc(50% - var(--hm-lnb-third-connector-width) / 2) / var(--hm-lnb-third-connector-length) var(--hm-lnb-third-connector-width) no-repeat;
position: relative;
padding-left: calc(var(--hm-lnb-third-connector-x) + var(--hm-lnb-third-connector-length));
}
[data-slot="lnb-third-row"]::before {
content: "";
position: absolute;
inset: 0;
background: var(--hm-lnb-third-connector);
pointer-events: none;
}
[data-slot="lnb-group-content"] [data-slot="lnb-third-row"] > [data-slot="lnb-item"] {
min-height: var(--hm-lnb-third-min-height);
padding-top: var(--hm-lnb-third-padding-block);
padding-bottom: var(--hm-lnb-third-padding-block);
padding-left: calc(var(--hm-lnb-third-padding-start) - var(--hm-lnb-third-connector-x) - var(--hm-lnb-third-connector-length));
line-height: var(--hm-lnb-third-line-height);
font-weight: var(--hm-lnb-third-font-weight);
-webkit-text-stroke: 0;
text-shadow: none;
}
/* The vertical line ends at the middle of the last row. */
[data-slot="lnb-third-row"]:last-child {
--hm-lnb-third-line-end: 50%;
}
[data-slot="lnb-group-content"] [data-slot="lnb-third-row"] > [data-slot="lnb-item"][aria-current="page"] {
font-weight: var(--hm-lnb-third-current-font-weight);
}
/* Desktop only: below 1024px the column is gone and the layout column collapses with it. */
@media (max-width: 1023px) {
:root, [data-hm-theme] {
--hm-lnb-width: 0px;
}
[data-slot="lnb"] {
display: none;
}
}
/* LNB Mobile Header is the mobile representation of LNB Mode, independent of GNB/Dashboard. */
:root, [data-hm-theme] {
--hm-lnb-mobile-height: 56px;
--hm-lnb-mobile-join: 12px;
--hm-lnb-mobile-background: linear-gradient(180deg, var(--hm-sem-action-surface-stop-0) 0%, var(--hm-sem-action-surface-stop-30) 30%, var(--hm-sem-action-surface-stop-65) 65%, var(--hm-sem-action-surface-stop-100) 100%);
/* Mobile menu content (inside the HM Sheet, white face). */
--hm-lnb-mobile-link-height: 44px;
--hm-lnb-mobile-link-padding-x: 12px;
--hm-lnb-mobile-link-radius: var(--hm-radius-control-md);
--hm-lnb-mobile-link-font-size: 14px;
--hm-lnb-mobile-link-line-height: 20px;
--hm-lnb-mobile-link-font-weight: 500;
--hm-lnb-mobile-link-text: var(--hm-sem-fg-primary);
--hm-lnb-mobile-link-hover-background: var(--accent);
--hm-lnb-mobile-link-hover-text: var(--accent-foreground);
--hm-lnb-mobile-link-current-background: var(--hm-sem-bg-content);
--hm-lnb-mobile-link-current-text: var(--hm-sem-action-primary);
--hm-lnb-mobile-link-current-font-weight: 700;
--hm-lnb-mobile-group-label-color: var(--hm-sem-fg-secondary);
--hm-lnb-mobile-group-gap: 16px;
--hm-lnb-mobile-nested-gap: 8px;
--hm-lnb-mobile-nested-indent: var(--hm-lnb-mobile-link-padding-x);
--hm-lnb-mobile-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot="lnb-mobile-header"] { display: none; }
[data-slot="lnb-mobile-nav"] {
display: grid;
gap: var(--hm-lnb-mobile-group-gap);
padding-bottom: 16px;
}
[data-slot="lnb-mobile-group-label"] {
margin: 0;
padding: 0 var(--hm-lnb-mobile-link-padding-x) 4px;
color: var(--hm-lnb-mobile-group-label-color);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
/* A group inside a group (a deeper local level, e.g. the LNB 3-depth): one link padding further in, with room above its title. */
[data-slot="lnb-mobile-group"] [data-slot="lnb-mobile-group"] {
margin-top: var(--hm-lnb-mobile-nested-gap);
padding-left: var(--hm-lnb-mobile-nested-indent);
}
[data-slot="lnb-mobile-link"] {
box-sizing: border-box;
min-height: var(--hm-lnb-mobile-link-height);
padding: 0 var(--hm-lnb-mobile-link-padding-x);
border-radius: var(--hm-lnb-mobile-link-radius);
color: var(--hm-lnb-mobile-link-text);
font-size: var(--hm-lnb-mobile-link-font-size);
line-height: var(--hm-lnb-mobile-link-line-height);
font-weight: var(--hm-lnb-mobile-link-font-weight);
text-decoration: none;
overflow-wrap: anywhere;
outline: none;
}
[data-slot="lnb-mobile-link"]:is(:hover, :focus-visible) {
background: var(--hm-lnb-mobile-link-hover-background);
color: var(--hm-lnb-mobile-link-hover-text);
}
[data-slot="lnb-mobile-link"]:focus-visible {
box-shadow: var(--hm-lnb-mobile-focus-ring);
}
[data-slot="lnb-mobile-link"][aria-current="page"] {
background: var(--hm-lnb-mobile-link-current-background);
color: var(--hm-lnb-mobile-link-current-text);
font-weight: var(--hm-lnb-mobile-link-current-font-weight);
}
[data-slot="lnb-mobile-nav"] [data-slot="lnb-profile"] {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px;
border-radius: var(--hm-radius-control-md); color: var(--hm-sem-fg-primary); text-align: left; cursor: pointer;
}
[data-slot="lnb-mobile-nav"] [data-slot="lnb-profile"]:hover { background: var(--accent); }
[data-slot="lnb-mobile-nav"] [data-slot="lnb-profile"]:focus-visible { outline: 2px solid var(--ring); }
[data-slot="lnb-mobile-nav"] [data-slot="lnb-profile"] p { color: var(--hm-sem-fg-secondary); }
@media (max-width: 1023px) {
[data-slot="lnb-mobile-header"] {
position: relative; display: flex; flex: none; align-items: center; gap: 8px;
height: var(--hm-lnb-mobile-height); padding: 0 16px;
background: var(--hm-lnb-mobile-background) left top / 100% calc(var(--hm-lnb-mobile-height) + var(--hm-lnb-mobile-join)) no-repeat;
color: var(--hm-sem-on-action-primary);
}
[data-slot="lnb-mobile-header"]::after {
content: ""; position: absolute; inset: 100% 0 auto; height: var(--hm-lnb-mobile-join);
background: var(--hm-lnb-mobile-background) left bottom / 100% calc(var(--hm-lnb-mobile-height) + var(--hm-lnb-mobile-join)) no-repeat;
pointer-events: none;
}
[data-slot="lnb-mobile-brand"] { min-width: 0; gap: 12px; text-decoration: none; }
[data-slot="lnb-mobile-brand"] :is(span, strong) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-slot="lnb-mobile-brand"] strong { font-size: 16px; font-weight: 700; }
[data-slot="lnb-mobile-brand"] small { display: none; }
[data-slot="lnb-mobile-brand"] > :is(img, svg) { max-height: 32px; width: auto; }
[data-slot="lnb-mobile-header"] > [data-slot="button"] { color: var(--hm-sem-on-action-primary); }
[data-slot="lnb-mobile-header"] > [data-slot="button"]:hover { background: var(--hm-lnb-hover-background); }
[data-slot="lnb-mobile-header"] > [data-slot="button"]:focus-visible { outline: var(--hm-lnb-focus-outline); outline-offset: -2px; box-shadow: none; }
}
+36
View File
@@ -0,0 +1,36 @@
import { defaultRuntimeTheme, type RuntimeTheme } from "./contract"
import { applyRuntimeTheme, isRuntimeTheme, resetRuntimeTheme } from "./runtime"
import { beginThemeRequest, isCurrentThemeRequest } from "./requests"
/** Fetch/validate only. No DOM access, fixed URL, storage or framework dependency. */
export async function fetchRuntimeTheme(url: string | URL, request?: RequestInit): Promise<RuntimeTheme> {
const response = await fetch(url, request)
if (!response.ok) throw new Error(`Theme request failed: HTTP ${response.status}`)
const theme: unknown = await response.json()
if (!isRuntimeTheme(theme)) throw new Error("Invalid Runtime Theme v1 document")
return { schemaVersion: 1, tokens: { ...theme.tokens } }
}
/** Resolve a supplied default + partial override using the existing v1 contract. */
export function resolveRuntimeTheme(theme: unknown, defaults: unknown = defaultRuntimeTheme): RuntimeTheme {
if (!isRuntimeTheme(defaults) || !isRuntimeTheme(theme)) throw new Error("Invalid Runtime Theme v1 document")
return { schemaVersion: 1, tokens: { ...defaultRuntimeTheme.tokens, ...defaults.tokens, ...theme.tokens } }
}
export type RuntimeThemeLoadOptions = { request?: RequestInit; defaults?: RuntimeTheme }
/** Convenience path: CDN/settings URL -> validate -> optional merge -> apply.
* Failure releases this API's override to the host/HM CSS fallback.
* Superseded requests never apply or reset the newer owner's theme.
*/
export async function loadRuntimeTheme(root: HTMLElement, url: string | URL, options: RuntimeThemeLoadOptions = {}): Promise<boolean> {
const request = beginThemeRequest(root)
try {
const theme = await fetchRuntimeTheme(url, options.request)
if (!isCurrentThemeRequest(root, request)) return false
return applyRuntimeTheme(root, options.defaults ? resolveRuntimeTheme(theme, options.defaults) : theme)
} catch {
if (isCurrentThemeRequest(root, request)) resetRuntimeTheme(root)
return false
}
}
+97
View File
@@ -0,0 +1,97 @@
/* Loading Screen pattern (visual reference: ui-viz Dashboard LoadingScreen, dashboard-composition.md §18). Pattern-local values.
The surface uses the HM brand Green (ui-viz local #0a3322 is not an HM Primitive). The spinner never rotates: each of the 24 bars fades
1 → .08 over 1.2s linear, started 0.05s apart clockwise. */
:root, [data-hm-theme] {
--hm-loading-screen-background: var(--hm-sem-action-primary);
--hm-loading-screen-foreground: var(--hm-sem-on-action-primary);
--hm-loading-screen-inline-padding-block: 36px;
--hm-loading-screen-inline-padding-inline: 24px;
--hm-loading-screen-inline-radius: 6px;
--hm-loading-screen-inline-shadow: inset 0 0 24px color-mix(in srgb, var(--hm-sem-palette-black) 45%, transparent);
--hm-loading-screen-gap: 12px;
--hm-loading-screen-z-index: 9999;
--hm-loading-spinner-size: 44px;
--hm-loading-spinner-bar-width: 2.5px;
--hm-loading-spinner-bar-height: 10px;
--hm-loading-spinner-bar-count: 24;
--hm-loading-spinner-bar-color: var(--hm-loading-screen-foreground);
--hm-loading-spinner-bar-min-opacity: 0.08;
--hm-loading-spinner-duration: 1.2s;
--hm-loading-spinner-stagger: 0.05s;
--hm-loading-text-font-size: 14px;
--hm-loading-text-font-weight: 500;
--hm-loading-text-letter-spacing: -0.02em;
--hm-loading-text-color: color-mix(in srgb, var(--hm-loading-screen-foreground) 60%, transparent);
}
[data-slot="loading-screen"] {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: var(--hm-loading-screen-background);
color: var(--hm-loading-screen-foreground);
text-align: center;
}
[data-slot="loading-screen"][data-variant="inline"] {
padding: var(--hm-loading-screen-inline-padding-block) var(--hm-loading-screen-inline-padding-inline);
border-radius: var(--hm-loading-screen-inline-radius);
box-shadow: var(--hm-loading-screen-inline-shadow);
overflow: hidden;
}
/* Fullscreen: the spinner + text group is centered in the viewport, same column / gap as inline (spinner and text share one center axis;
ui-viz's block assembly put the spinner at the group's leading edge, which read as misaligned). */
[data-slot="loading-screen"][data-variant="fullscreen"] {
position: fixed;
inset: 0;
z-index: var(--hm-loading-screen-z-index);
}
[data-slot="loading-screen-content"] {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--hm-loading-screen-gap);
}
[data-slot="loading-spinner"] {
position: relative;
width: var(--hm-loading-spinner-size);
height: var(--hm-loading-spinner-size);
}
[data-slot="loading-spinner"] > span {
position: absolute;
top: 0;
left: calc(50% - var(--hm-loading-spinner-bar-width) / 2);
width: var(--hm-loading-spinner-bar-width);
height: var(--hm-loading-spinner-bar-height);
border-radius: 9999px;
background: var(--hm-loading-spinner-bar-color);
transform-origin: center calc(var(--hm-loading-spinner-size) / 2);
transform: rotate(calc(var(--hm-loading-index) * 360deg / var(--hm-loading-spinner-bar-count)));
opacity: var(--hm-loading-spinner-bar-min-opacity);
animation: hm-loading-fade var(--hm-loading-spinner-duration) linear infinite;
animation-delay: calc(var(--hm-loading-index) * var(--hm-loading-spinner-stagger));
}
@keyframes hm-loading-fade {
from { opacity: 1; }
to { opacity: var(--hm-loading-spinner-bar-min-opacity); }
}
/* Reduced motion: no animation; the bars stay as a static comet (bright start fading clockwise). */
@media (prefers-reduced-motion: reduce) {
[data-slot="loading-spinner"] > span {
animation: none;
opacity: calc(1 - var(--hm-loading-index) / (var(--hm-loading-spinner-bar-count) - 1) * (1 - var(--hm-loading-spinner-bar-min-opacity)));
}
}
[data-slot="loading-text"] {
margin: 0;
font-family: var(--hm-font-family-base);
font-size: var(--hm-loading-text-font-size);
font-weight: var(--hm-loading-text-font-weight);
letter-spacing: var(--hm-loading-text-letter-spacing);
color: var(--hm-loading-text-color);
}
[data-slot="loading-text"]::after {
content: "...";
}
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff. Show more