|
1 | 1 | import { withBase } from "@/components/utils/with-base"; |
| 2 | +import { |
| 3 | + normalizeNavbarLinks, |
| 4 | + type NormalizedNavbarItem, |
| 5 | +} from "./Navbar.utils"; |
2 | 6 | import type { NavbarProps } from "./Navbar.types"; |
3 | 7 |
|
4 | | -export const Navbar = ({ siteName, links }: NavbarProps) => ( |
5 | | - <header className="sticky top-0 z-50 border-b border-secondary-200 bg-white/80 backdrop-blur-sm"> |
6 | | - <nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8"> |
7 | | - <a |
8 | | - href={withBase("/")} |
9 | | - className="text-xl font-bold text-primary-600 hover:text-primary-700" |
10 | | - > |
11 | | - {siteName} |
12 | | - </a> |
13 | | - <ul className="flex items-center gap-6 text-sm font-medium"> |
14 | | - {links.map((link) => ( |
15 | | - <li key={`${link.label}-${link.href}`}> |
16 | | - <a href={withBase(link.href)} className="hover:text-primary-600"> |
17 | | - {link.label} |
| 8 | +interface MobileLinkListProps { |
| 9 | + links: readonly NormalizedNavbarItem[]; |
| 10 | +} |
| 11 | + |
| 12 | +const desktopLinkClassName = |
| 13 | + "inline-flex items-center gap-2 rounded-full px-3 py-2 transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"; |
| 14 | + |
| 15 | +const mobileLinkClassName = |
| 16 | + "block rounded-lg px-3 py-2 text-base transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"; |
| 17 | + |
| 18 | +const desktopDropdownLinkClassName = |
| 19 | + "block rounded-xl px-3 py-2 transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"; |
| 20 | + |
| 21 | +const MobileLinkList = ({ links }: MobileLinkListProps) => ( |
| 22 | + <ul className="flex flex-col gap-2 text-sm font-medium text-secondary-900"> |
| 23 | + {links.map((item) => { |
| 24 | + if (item.type === "single") { |
| 25 | + return ( |
| 26 | + <li key={item.key}> |
| 27 | + <a href={withBase(item.link.href)} className={mobileLinkClassName}> |
| 28 | + {item.link.label} |
18 | 29 | </a> |
19 | 30 | </li> |
20 | | - ))} |
21 | | - </ul> |
22 | | - </nav> |
23 | | - </header> |
| 31 | + ); |
| 32 | + } |
| 33 | + |
| 34 | + return ( |
| 35 | + <li key={item.key}> |
| 36 | + <details className="group"> |
| 37 | + <summary |
| 38 | + className={`${mobileLinkClassName} flex cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden`} |
| 39 | + > |
| 40 | + <span>{item.trigger.label}</span> |
| 41 | + <svg |
| 42 | + aria-hidden="true" |
| 43 | + viewBox="0 0 10 6" |
| 44 | + className="h-2.5 w-2.5 transition-transform group-open:rotate-180" |
| 45 | + > |
| 46 | + <path |
| 47 | + d="M1 1L5 5L9 1" |
| 48 | + fill="none" |
| 49 | + stroke="currentColor" |
| 50 | + strokeWidth="1.5" |
| 51 | + strokeLinecap="round" |
| 52 | + strokeLinejoin="round" |
| 53 | + /> |
| 54 | + </svg> |
| 55 | + </summary> |
| 56 | + <ul className="mt-1 flex flex-col gap-1 border-l border-secondary-200 pl-4"> |
| 57 | + {item.links.map((subItem) => ( |
| 58 | + <li key={subItem.key}> |
| 59 | + <a |
| 60 | + href={withBase(subItem.href)} |
| 61 | + className={mobileLinkClassName} |
| 62 | + > |
| 63 | + {subItem.label} |
| 64 | + </a> |
| 65 | + </li> |
| 66 | + ))} |
| 67 | + </ul> |
| 68 | + </details> |
| 69 | + </li> |
| 70 | + ); |
| 71 | + })} |
| 72 | + </ul> |
24 | 73 | ); |
| 74 | + |
| 75 | +export const Navbar = ({ siteName, links }: NavbarProps) => { |
| 76 | + const normalizedLinks = normalizeNavbarLinks(links); |
| 77 | + |
| 78 | + return ( |
| 79 | + <div className="relative"> |
| 80 | + <input |
| 81 | + id="mobile-navigation-toggle" |
| 82 | + type="checkbox" |
| 83 | + className="peer sr-only" |
| 84 | + aria-hidden="true" |
| 85 | + tabIndex={-1} |
| 86 | + /> |
| 87 | + |
| 88 | + <header className="sticky top-0 z-40 border-b border-secondary-200 bg-white/80 backdrop-blur-sm"> |
| 89 | + <nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8"> |
| 90 | + <a |
| 91 | + href={withBase("/")} |
| 92 | + className="text-xl font-bold text-primary-600 transition-colors hover:text-primary-700" |
| 93 | + > |
| 94 | + {siteName} |
| 95 | + </a> |
| 96 | + |
| 97 | + <label |
| 98 | + htmlFor="mobile-navigation-toggle" |
| 99 | + className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-secondary-200 text-secondary-900 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 sm:hidden" |
| 100 | + aria-controls="mobile-navigation" |
| 101 | + tabIndex={0} |
| 102 | + > |
| 103 | + <span className="sr-only">Open menu</span> |
| 104 | + <svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5"> |
| 105 | + <path |
| 106 | + d="M4 7H20M4 12H20M4 17H20" |
| 107 | + fill="none" |
| 108 | + stroke="currentColor" |
| 109 | + strokeWidth="1.8" |
| 110 | + strokeLinecap="round" |
| 111 | + /> |
| 112 | + </svg> |
| 113 | + </label> |
| 114 | + |
| 115 | + <ul className="hidden items-center gap-3 text-sm font-medium sm:flex"> |
| 116 | + {normalizedLinks.map((item) => { |
| 117 | + if (item.type === "single") { |
| 118 | + return ( |
| 119 | + <li key={item.key}> |
| 120 | + <a |
| 121 | + href={withBase(item.link.href)} |
| 122 | + className={desktopLinkClassName} |
| 123 | + > |
| 124 | + {item.link.label} |
| 125 | + </a> |
| 126 | + </li> |
| 127 | + ); |
| 128 | + } |
| 129 | + |
| 130 | + return ( |
| 131 | + <li key={item.key} className="group relative"> |
| 132 | + <a |
| 133 | + href={withBase(item.trigger.href)} |
| 134 | + className={desktopLinkClassName} |
| 135 | + > |
| 136 | + <span>{item.trigger.label}</span> |
| 137 | + <svg |
| 138 | + aria-hidden="true" |
| 139 | + viewBox="0 0 10 6" |
| 140 | + className="h-2.5 w-2.5 transition-transform group-hover:rotate-180 group-focus-within:rotate-180" |
| 141 | + > |
| 142 | + <path |
| 143 | + d="M1 1L5 5L9 1" |
| 144 | + fill="none" |
| 145 | + stroke="currentColor" |
| 146 | + strokeWidth="1.5" |
| 147 | + strokeLinecap="round" |
| 148 | + strokeLinejoin="round" |
| 149 | + /> |
| 150 | + </svg> |
| 151 | + </a> |
| 152 | + <div className="pointer-events-none invisible absolute right-0 top-full pt-3 opacity-0 transition duration-150 group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100"> |
| 153 | + <ul className="min-w-48 rounded-2xl border border-secondary-200 bg-white p-2 text-secondary-900 shadow-lg shadow-secondary-900/10"> |
| 154 | + {item.links.map((subItem) => ( |
| 155 | + <li key={subItem.key}> |
| 156 | + <a |
| 157 | + href={withBase(subItem.href)} |
| 158 | + className={desktopDropdownLinkClassName} |
| 159 | + > |
| 160 | + {subItem.label} |
| 161 | + </a> |
| 162 | + </li> |
| 163 | + ))} |
| 164 | + </ul> |
| 165 | + </div> |
| 166 | + </li> |
| 167 | + ); |
| 168 | + })} |
| 169 | + </ul> |
| 170 | + </nav> |
| 171 | + </header> |
| 172 | + |
| 173 | + <div |
| 174 | + className="pointer-events-none fixed inset-0 z-50 bg-secondary-900/40 opacity-0 transition-opacity duration-200 peer-checked:pointer-events-auto peer-checked:opacity-100 sm:hidden" |
| 175 | + aria-hidden="true" |
| 176 | + /> |
| 177 | + |
| 178 | + <aside |
| 179 | + id="mobile-navigation" |
| 180 | + aria-label="Navigation menu" |
| 181 | + className="fixed right-0 top-0 z-50 flex h-full w-[min(24rem,100vw)] translate-x-full flex-col border-l border-secondary-200 bg-white px-5 py-5 shadow-2xl transition-transform duration-200 peer-checked:translate-x-0 sm:hidden" |
| 182 | + > |
| 183 | + <div className="flex items-center justify-between gap-4 border-b border-secondary-200 pb-4"> |
| 184 | + <a |
| 185 | + href={withBase("/")} |
| 186 | + className="text-lg font-bold text-primary-600" |
| 187 | + > |
| 188 | + {siteName} |
| 189 | + </a> |
| 190 | + <label |
| 191 | + htmlFor="mobile-navigation-toggle" |
| 192 | + className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-secondary-200 text-secondary-900 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" |
| 193 | + tabIndex={0} |
| 194 | + > |
| 195 | + <span className="sr-only">Close menu</span> |
| 196 | + <svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5"> |
| 197 | + <path |
| 198 | + d="M6 6L18 18M18 6L6 18" |
| 199 | + fill="none" |
| 200 | + stroke="currentColor" |
| 201 | + strokeWidth="1.8" |
| 202 | + strokeLinecap="round" |
| 203 | + /> |
| 204 | + </svg> |
| 205 | + </label> |
| 206 | + </div> |
| 207 | + |
| 208 | + <div className="mt-6 overflow-y-auto"> |
| 209 | + <MobileLinkList links={normalizedLinks} /> |
| 210 | + </div> |
| 211 | + </aside> |
| 212 | + </div> |
| 213 | + ); |
| 214 | +}; |
0 commit comments