217 lines
11 KiB
TypeScript
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 }
|