Files
HMDesign/packages/ui/src/patterns/gnb/gnb.tsx
T

217 lines
11 KiB
TypeScript

"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 includes the visible name and description, followed by "사용자 메뉴". 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}${description ? ` ${description}` : ""} 사용자 메뉴`} 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}{description ? " " : null}</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 }