Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user