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