Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# @hm-design/ui
|
||||
|
||||
React 19 UI components and the HM default theme. Published entrypoints contain ESM JavaScript and TypeScript declarations.
|
||||
|
||||
```tsx
|
||||
import { Button, Input } from "@hm-design/ui"
|
||||
import { applyRuntimeTheme, resetRuntimeTheme } from "@hm-design/ui/theme"
|
||||
```
|
||||
|
||||
With Tailwind CSS 4, import these styles in order in the application's CSS entrypoint:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/ui/theme.css";
|
||||
```
|
||||
|
||||
Process this entrypoint with the application's Tailwind CSS 4 integration. The package registers its compiled component classes as Tailwind sources and includes the required shadcn CSS without installing the shadcn CLI. React and React DOM are peer dependencies and are not bundled.
|
||||
|
||||
The default theme, Noto Sans KR font and its OFL license are included. External theme JSON and a Theme CDN are optional and are not included in this package. The runtime theme API remains available from `@hm-design/ui/theme`.
|
||||
|
||||
For Dashboard work status colors, explicitly load the optional status stylesheet after the theme:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/ui/theme.css";
|
||||
@import "@hm-design/ui/dashboard-status.css";
|
||||
```
|
||||
|
||||
The status stylesheet defines only `--hm-dashboard-status-active`, `--hm-dashboard-status-hold`, `--hm-dashboard-status-done` and `--hm-dashboard-status-issue`. It does not automatically style `Badge`, buttons or `data-status` attributes. Consumers must map these variables to their existing component styles; the repository's `docs/patterns/dashboard-layout.md` shows the Badge mapping. `done` resolves the existing white palette token in each `:root` / `[data-hm-theme]` scope. The other colors preserve the approved Dashboard values and are not additional Runtime Theme tokens.
|
||||
|
||||
From the HMDesignSystem repository, run `npm run build -w @hm-design/ui`. `npm pack` runs this build automatically. Only `dist/`, this README and the package manifest are packed; source and Storybook files are excluded.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/tailwind.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@hm-design/ui",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"description": "HM Design System UI components (shadcn + Radix) and the HM Theme.",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./theme": {
|
||||
"types": "./dist/theme/index.d.ts",
|
||||
"import": "./dist/theme/index.js",
|
||||
"default": "./dist/theme/index.js"
|
||||
},
|
||||
"./theme.css": "./dist/theme.css",
|
||||
"./dashboard-status.css": "./dist/dashboard-status.css",
|
||||
"./tailwind.css": "./dist/tailwind.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node ../../scripts/build-package.mjs ui",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-table": "^9.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.49.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react-day-picker": "^10.0.2",
|
||||
"react-resizable-panels": "^4.14.2",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.21.1"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
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