@notionhive/menus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/bin/menus.js +16 -0
  2. package/category.config.json +7 -0
  3. package/package.json +24 -0
  4. package/registry/index.json +74 -0
  5. package/registry/menu-01.json +14 -0
  6. package/registry/menu-02.json +12 -0
  7. package/registry/menu-03.json +12 -0
  8. package/registry/menu-04.json +10 -0
  9. package/registry/menu-05.json +10 -0
  10. package/registry/menu-06.json +10 -0
  11. package/templates/components/atoms/SafeImage/SafeImage.jsx +101 -0
  12. package/templates/components/atoms/SafeImage/index.js +1 -0
  13. package/templates/components/hooks/useCarousel.js +73 -0
  14. package/templates/components/molecules/CorporateHeader/CorporateHeader.jsx +174 -0
  15. package/templates/components/molecules/CorporateHeader/CorporateHeader.propTypes.js +22 -0
  16. package/templates/components/molecules/CorporateHeader/index.js +1 -0
  17. package/templates/components/molecules/EquipmentMegaMenu/EquipmentMegaMenu.jsx +202 -0
  18. package/templates/components/molecules/EquipmentMegaMenu/EquipmentMegaMenu.propTypes.js +104 -0
  19. package/templates/components/molecules/EquipmentMegaMenu/index.js +5 -0
  20. package/templates/components/molecules/FullScreenNavOverlay/FullScreenNavOverlay.jsx +117 -0
  21. package/templates/components/molecules/FullScreenNavOverlay/FullScreenNavOverlay.propTypes.js +34 -0
  22. package/templates/components/molecules/FullScreenNavOverlay/index.js +2 -0
  23. package/templates/components/molecules/MobileNavDrawer/MobileNavDrawer.jsx +139 -0
  24. package/templates/components/molecules/MobileNavDrawer/index.js +2 -0
  25. package/templates/components/molecules/SiteHeader/SiteHeader.jsx +177 -0
  26. package/templates/components/molecules/SiteHeader/SiteHeader.propTypes.js +20 -0
  27. package/templates/components/molecules/SiteHeader/index.js +1 -0
  28. package/templates/components/molecules/SlideIndicators/SlideIndicators.jsx +75 -0
  29. package/templates/components/molecules/SlideIndicators/SlideIndicators.propTypes.js +10 -0
  30. package/templates/components/molecules/SlideIndicators/index.js +1 -0
  31. package/templates/components/organisms/Menu01/Menu01.jsx +211 -0
  32. package/templates/components/organisms/Menu01/Menu01.propTypes.js +63 -0
  33. package/templates/components/organisms/Menu01/index.js +1 -0
  34. package/templates/components/organisms/Menu02/Menu02.jsx +133 -0
  35. package/templates/components/organisms/Menu02/Menu02.propTypes.js +71 -0
  36. package/templates/components/organisms/Menu02/index.js +1 -0
  37. package/templates/components/organisms/Menu03/Menu03.jsx +411 -0
  38. package/templates/components/organisms/Menu03/Menu03.propTypes.js +113 -0
  39. package/templates/components/organisms/Menu03/index.js +1 -0
  40. package/templates/components/organisms/Menu04/Menu04.jsx +232 -0
  41. package/templates/components/organisms/Menu04/Menu04.propTypes.js +53 -0
  42. package/templates/components/organisms/Menu04/index.js +2 -0
  43. package/templates/components/organisms/Menu05/Menu05.jsx +318 -0
  44. package/templates/components/organisms/Menu05/Menu05.propTypes.js +79 -0
  45. package/templates/components/organisms/Menu05/index.js +2 -0
  46. package/templates/components/organisms/Menu06/Menu06.jsx +241 -0
  47. package/templates/components/organisms/Menu06/Menu06.propTypes.js +55 -0
  48. package/templates/components/organisms/Menu06/index.js +2 -0
  49. package/templates/public/menus/menu01/agriculture.png +0 -0
  50. package/templates/public/menus/menu01/banner.jpg +0 -0
  51. package/templates/public/menus/menu01/build-your-own.png +0 -0
  52. package/templates/public/menus/menu01/construction.png +0 -0
  53. package/templates/public/menus/menu01/lawn-care.png +0 -0
  54. package/templates/public/menus/menu01/logo.png +0 -0
  55. package/templates/public/menus/menu01/promotions.png +0 -0
  56. package/templates/public/menus/menu03/logo.png +46 -0
  57. package/templates/public/menus/menu04/featured.jpg +0 -0
  58. package/templates/public/menus/menu04/logo.png +0 -0
  59. package/templates/public/menus/menu05/hero-bg.jpg +0 -0
  60. package/templates/public/menus/menu05/logo.png +0 -0
  61. package/templates/public/menus/menu06/pool.jpg +0 -0
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import SafeImage from "../../atoms/SafeImage";
5
+ import {
6
+ equipmentMegaMenuDefaultProps,
7
+ equipmentMegaMenuPropTypes,
8
+ } from "./EquipmentMegaMenu.propTypes";
9
+
10
+ function MegaMenuLink({ link }) {
11
+ const className = [
12
+ "block w-full text-left text-sm leading-5 transition-colors duration-200 ease-out hover:text-brand-500",
13
+ link.highlighted ? "font-medium text-brand-500" : "text-neutral-700",
14
+ ].join(" ");
15
+
16
+ if (link.href) {
17
+ return (
18
+ <a href={link.href} className={className}>
19
+ {link.label}
20
+ </a>
21
+ );
22
+ }
23
+
24
+ return (
25
+ <button type="button" onClick={link.onClick} className={className}>
26
+ {link.label}
27
+ </button>
28
+ );
29
+ }
30
+
31
+ function CategoryColumn({ category }) {
32
+ return (
33
+ <div className="flex min-w-[180px] flex-1 flex-col gap-6 sm:min-w-[200px] sm:gap-8">
34
+ {category.imageSrc ? (
35
+ <div className="relative h-[100px] w-full overflow-hidden rounded sm:h-[125px] sm:w-[200px]">
36
+ <SafeImage
37
+ src={category.imageSrc}
38
+ alt={category.imageAlt || category.title}
39
+ fill
40
+ className="object-contain object-left"
41
+ sizes="200px"
42
+ />
43
+ </div>
44
+ ) : null}
45
+
46
+ <div className="flex flex-col gap-3 sm:gap-4">
47
+ <p
48
+ className={[
49
+ "text-base font-bold leading-5 sm:text-lg",
50
+ category.titleHighlighted ? "text-brand-500" : "text-neutral-800",
51
+ ].join(" ")}
52
+ >
53
+ {category.title}
54
+ </p>
55
+
56
+ {category.links?.length ? (
57
+ <ul className="flex flex-col gap-2 sm:gap-3">
58
+ {category.links.map((link) => (
59
+ <li key={link.label}>
60
+ <MegaMenuLink link={link} />
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ ) : null}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function PromoBlock({ promo, titleHighlighted = false }) {
71
+ if (!promo) return null;
72
+
73
+ const titleClass = [
74
+ "text-base font-bold leading-5 sm:text-lg",
75
+ titleHighlighted ? "text-brand-500" : "text-neutral-800",
76
+ ].join(" ");
77
+
78
+ if (promo.href) {
79
+ return (
80
+ <div className="flex flex-col gap-6 sm:gap-8">
81
+ {promo.imageSrc ? (
82
+ <div className="relative h-[100px] w-full overflow-hidden rounded sm:h-[125px] sm:w-[200px]">
83
+ <SafeImage
84
+ src={promo.imageSrc}
85
+ alt={promo.imageAlt || promo.title}
86
+ fill
87
+ className="object-cover object-center"
88
+ sizes="200px"
89
+ />
90
+ </div>
91
+ ) : null}
92
+ <a href={promo.href} className={`${titleClass} transition-opacity duration-200 hover:opacity-80`}>
93
+ {promo.title}
94
+ </a>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <div className="flex flex-col gap-6 sm:gap-8">
101
+ {promo.imageSrc ? (
102
+ <div className="relative h-[100px] w-full overflow-hidden rounded sm:h-[125px] sm:w-[200px]">
103
+ <SafeImage
104
+ src={promo.imageSrc}
105
+ alt={promo.imageAlt || promo.title}
106
+ fill
107
+ className="object-cover object-center"
108
+ sizes="200px"
109
+ />
110
+ </div>
111
+ ) : null}
112
+ <button type="button" onClick={promo.onClick} className={`text-left ${titleClass}`}>
113
+ {promo.title}
114
+ </button>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /**
120
+ * EquipmentMegaMenu — full-width Koenig Equipment mega menu dropdown.
121
+ *
122
+ * @param {object} props - See EquipmentMegaMenu.propTypes.js.
123
+ */
124
+ export function EquipmentMegaMenu({
125
+ isOpen = equipmentMegaMenuDefaultProps.isOpen,
126
+ onClose,
127
+ categories = equipmentMegaMenuDefaultProps.categories,
128
+ buildYourOwn = equipmentMegaMenuDefaultProps.buildYourOwn,
129
+ promotions = equipmentMegaMenuDefaultProps.promotions,
130
+ showAdPlaceholder = equipmentMegaMenuDefaultProps.showAdPlaceholder,
131
+ adPlaceholderText = equipmentMegaMenuDefaultProps.adPlaceholderText,
132
+ className = "",
133
+ }) {
134
+ useEffect(() => {
135
+ if (!isOpen) return undefined;
136
+
137
+ const handler = (event) => {
138
+ if (event.key === "Escape") onClose?.();
139
+ };
140
+
141
+ window.addEventListener("keydown", handler);
142
+ return () => window.removeEventListener("keydown", handler);
143
+ }, [isOpen, onClose]);
144
+
145
+ if (!isOpen) return null;
146
+
147
+ const adLines = adPlaceholderText.split("\n");
148
+
149
+ return (
150
+ <>
151
+ <button
152
+ type="button"
153
+ aria-label="Close menu"
154
+ onClick={onClose}
155
+ className="fixed inset-0 z-30 hidden bg-black/20 lg:block"
156
+ />
157
+
158
+ <div
159
+ className={[
160
+ "absolute left-0 right-0 top-full z-40 hidden border-b border-neutral-100 bg-white px-4 py-8 shadow-lg sm:px-6 md:px-10 lg:block lg:px-16 lg:py-11",
161
+ "animate-fade-in motion-reduce:animate-none",
162
+ className,
163
+ ]
164
+ .filter(Boolean)
165
+ .join(" ")}
166
+ role="dialog"
167
+ aria-modal="true"
168
+ aria-label="New Equipment menu"
169
+ >
170
+ <div className="mx-auto flex w-full max-w-7xl flex-col gap-8 xl:max-w-[1410px]">
171
+ <div className="flex flex-col gap-10 lg:flex-row lg:items-start lg:justify-between lg:gap-6 xl:gap-8">
172
+ {categories.map((category) => (
173
+ <CategoryColumn key={category.title} category={category} />
174
+ ))}
175
+
176
+ <div className="flex min-w-[180px] flex-1 flex-col gap-8 sm:min-w-[200px] sm:gap-10">
177
+ <PromoBlock promo={buildYourOwn} titleHighlighted />
178
+ <PromoBlock promo={promotions} />
179
+ </div>
180
+
181
+ {showAdPlaceholder ? (
182
+ <div className="hidden min-h-[280px] w-full max-w-[276px] shrink-0 items-center justify-center self-stretch rounded border border-dashed border-neutral-400 bg-neutral-100 lg:flex">
183
+ <p className="px-4 text-center text-sm font-bold leading-6 text-neutral-400">
184
+ {adLines.map((line, index) => (
185
+ <span key={line}>
186
+ {line}
187
+ {index < adLines.length - 1 ? <br /> : null}
188
+ </span>
189
+ ))}
190
+ </p>
191
+ </div>
192
+ ) : null}
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </>
197
+ );
198
+ }
199
+
200
+ EquipmentMegaMenu.propTypes = equipmentMegaMenuPropTypes;
201
+
202
+ export default EquipmentMegaMenu;
@@ -0,0 +1,104 @@
1
+ import PropTypes from "prop-types";
2
+
3
+ const linkShape = PropTypes.shape({
4
+ label: PropTypes.string.isRequired,
5
+ href: PropTypes.string,
6
+ onClick: PropTypes.func,
7
+ highlighted: PropTypes.bool,
8
+ });
9
+
10
+ const categoryShape = PropTypes.shape({
11
+ title: PropTypes.string.isRequired,
12
+ imageSrc: PropTypes.string,
13
+ imageAlt: PropTypes.string,
14
+ titleHighlighted: PropTypes.bool,
15
+ links: PropTypes.arrayOf(linkShape),
16
+ });
17
+
18
+ export const equipmentMegaMenuPropTypes = {
19
+ isOpen: PropTypes.bool,
20
+ onClose: PropTypes.func,
21
+ categories: PropTypes.arrayOf(categoryShape),
22
+ buildYourOwn: PropTypes.shape({
23
+ imageSrc: PropTypes.string,
24
+ imageAlt: PropTypes.string,
25
+ title: PropTypes.string,
26
+ href: PropTypes.string,
27
+ onClick: PropTypes.func,
28
+ }),
29
+ promotions: PropTypes.shape({
30
+ imageSrc: PropTypes.string,
31
+ imageAlt: PropTypes.string,
32
+ title: PropTypes.string,
33
+ href: PropTypes.string,
34
+ onClick: PropTypes.func,
35
+ }),
36
+ showAdPlaceholder: PropTypes.bool,
37
+ adPlaceholderText: PropTypes.string,
38
+ className: PropTypes.string,
39
+ };
40
+
41
+ export const equipmentMegaMenuDefaultProps = {
42
+ isOpen: false,
43
+ categories: [
44
+ {
45
+ title: "Agriculture",
46
+ imageSrc: "/menus/menu01/agriculture.png",
47
+ imageAlt: "John Deere tractor",
48
+ links: [
49
+ { label: "Tractors", href: "#" },
50
+ { label: "Harvesting", href: "#" },
51
+ { label: "Spraying & Application", href: "#" },
52
+ { label: "Planting & Seeding", href: "#" },
53
+ { label: "Tillage", href: "#" },
54
+ { label: "Precision Ag Technology", href: "#" },
55
+ { label: "Hay & Forage", href: "#" },
56
+ { label: "All Brands", href: "#" },
57
+ ],
58
+ },
59
+ {
60
+ title: "Compact Construction Equipment",
61
+ imageSrc: "/menus/menu01/construction.png",
62
+ imageAlt: "Compact construction skid steer",
63
+ links: [
64
+ { label: "Skid Steers", href: "#" },
65
+ { label: "Track Loaders", href: "#" },
66
+ { label: "Excavators", href: "#" },
67
+ { label: "Wheel Loaders", href: "#", highlighted: true },
68
+ { label: "Attachments", href: "#" },
69
+ ],
70
+ },
71
+ {
72
+ title: "Lawn & Grounds Care",
73
+ imageSrc: "/menus/menu01/lawn-care.png",
74
+ imageAlt: "Lawn and grounds care mower",
75
+ links: [
76
+ { label: "Tractor Packages", href: "#" },
77
+ { label: "Compact Utility Tractors & Attachments", href: "#" },
78
+ { label: "Lawn & Garden Tractors", href: "#" },
79
+ { label: "Residential Zero Turn Mowers", href: "#" },
80
+ { label: "Walk Behind Mowers", href: "#" },
81
+ { label: "Commercial Mowing", href: "#" },
82
+ { label: "Gator Utility Vehicles", href: "#" },
83
+ { label: "EGO", href: "#" },
84
+ { label: "Stihl", href: "#" },
85
+ { label: "Ventrac", href: "#" },
86
+ { label: "All Brands", href: "#" },
87
+ ],
88
+ },
89
+ ],
90
+ buildYourOwn: {
91
+ imageSrc: "/menus/menu01/build-your-own.png",
92
+ imageAlt: "Build your own equipment",
93
+ title: "Build Your Own",
94
+ href: "#",
95
+ },
96
+ promotions: {
97
+ imageSrc: "/menus/menu01/promotions.png",
98
+ imageAlt: "Equipment promotions",
99
+ title: "Promotions",
100
+ href: "#",
101
+ },
102
+ showAdPlaceholder: true,
103
+ adPlaceholderText: "PLACEHOLDER\nFOR ADs",
104
+ };
@@ -0,0 +1,5 @@
1
+ export { EquipmentMegaMenu, default } from "./EquipmentMegaMenu";
2
+ export {
3
+ equipmentMegaMenuDefaultProps,
4
+ equipmentMegaMenuPropTypes,
5
+ } from "./EquipmentMegaMenu.propTypes";
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import {
5
+ fullScreenNavOverlayDefaultProps,
6
+ fullScreenNavOverlayPropTypes,
7
+ } from "./FullScreenNavOverlay.propTypes";
8
+
9
+ const SLIDE_CLASSES = {
10
+ right: {
11
+ open: "translate-x-0",
12
+ closed: "translate-x-full",
13
+ },
14
+ left: {
15
+ open: "translate-x-0",
16
+ closed: "-translate-x-full",
17
+ },
18
+ top: {
19
+ open: "translate-y-0",
20
+ closed: "-translate-y-full",
21
+ },
22
+ none: {
23
+ open: "translate-x-0 translate-y-0 opacity-100",
24
+ closed: "translate-x-0 translate-y-0 opacity-0",
25
+ },
26
+ };
27
+
28
+ /**
29
+ * FullScreenNavOverlay — fixed full-viewport navigation shell with slide transitions.
30
+ * Handles body scroll lock and Escape-to-close.
31
+ *
32
+ * @param {object} props - See FullScreenNavOverlay.propTypes.js
33
+ */
34
+ export function FullScreenNavOverlay({
35
+ isOpen = fullScreenNavOverlayDefaultProps.isOpen,
36
+ onClose,
37
+ slideFrom = fullScreenNavOverlayDefaultProps.slideFrom,
38
+ mode = fullScreenNavOverlayDefaultProps.mode,
39
+ showBackdrop = fullScreenNavOverlayDefaultProps.showBackdrop,
40
+ ariaLabel = fullScreenNavOverlayDefaultProps.ariaLabel,
41
+ className = "",
42
+ panelClassName = "",
43
+ backdropClassName = "",
44
+ children,
45
+ }) {
46
+ const slide = SLIDE_CLASSES[slideFrom] ?? SLIDE_CLASSES.right;
47
+ const isFixed = mode === "fixed";
48
+
49
+ useEffect(() => {
50
+ if (!isFixed || !isOpen) return undefined;
51
+ document.body.style.overflow = "hidden";
52
+ return () => {
53
+ document.body.style.overflow = "";
54
+ };
55
+ }, [isFixed, isOpen]);
56
+
57
+ useEffect(() => {
58
+ if (!isOpen) return undefined;
59
+ const handler = (event) => {
60
+ if (event.key === "Escape") onClose?.();
61
+ };
62
+ window.addEventListener("keydown", handler);
63
+ return () => window.removeEventListener("keydown", handler);
64
+ }, [isOpen, onClose]);
65
+
66
+ if (!isOpen && slideFrom === "none") return null;
67
+
68
+ return (
69
+ <div
70
+ className={[
71
+ isFixed ? "fixed inset-0 z-50" : "absolute inset-0 z-20",
72
+ isOpen ? "pointer-events-auto" : "pointer-events-none",
73
+ className,
74
+ ]
75
+ .filter(Boolean)
76
+ .join(" ")}
77
+ aria-hidden={!isOpen}
78
+ >
79
+ {showBackdrop ? (
80
+ <div
81
+ aria-hidden="true"
82
+ onClick={onClose}
83
+ className={[
84
+ "absolute inset-0 bg-black/40 transition-opacity duration-500 ease-out motion-reduce:transition-none",
85
+ isOpen ? "opacity-100" : "opacity-0",
86
+ backdropClassName,
87
+ ]
88
+ .filter(Boolean)
89
+ .join(" ")}
90
+ />
91
+ ) : null}
92
+
93
+ <nav
94
+ role="dialog"
95
+ aria-modal="true"
96
+ aria-label={ariaLabel}
97
+ aria-hidden={!isOpen}
98
+ className={[
99
+ "absolute inset-0 flex transition-transform duration-500 ease-out motion-reduce:transition-none",
100
+ slideFrom === "none"
101
+ ? "transition-opacity duration-500 ease-out motion-reduce:transition-none"
102
+ : "",
103
+ isOpen ? slide.open : slide.closed,
104
+ panelClassName,
105
+ ]
106
+ .filter(Boolean)
107
+ .join(" ")}
108
+ >
109
+ {children}
110
+ </nav>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ FullScreenNavOverlay.propTypes = fullScreenNavOverlayPropTypes;
116
+
117
+ export default FullScreenNavOverlay;
@@ -0,0 +1,34 @@
1
+ import PropTypes from "prop-types";
2
+
3
+ export const fullScreenNavOverlayPropTypes = {
4
+ /** Controls overlay visibility. */
5
+ isOpen: PropTypes.bool,
6
+ /** Called when the overlay should close (Escape, optional backdrop). */
7
+ onClose: PropTypes.func,
8
+ /** Slide direction on enter/exit: right, left, top, or none (fade only). */
9
+ slideFrom: PropTypes.oneOf(["right", "left", "top", "none"]),
10
+ /**
11
+ * `contained` — absolute within a relative parent (gallery / Storybook safe).
12
+ * `fixed` — full viewport overlay (production page use).
13
+ */
14
+ mode: PropTypes.oneOf(["contained", "fixed"]),
15
+ /** Render a dimmed backdrop behind the panel. */
16
+ showBackdrop: PropTypes.bool,
17
+ /** Accessible label for the dialog region. */
18
+ ariaLabel: PropTypes.string,
19
+ /** Extra classes on the outer fixed wrapper. */
20
+ className: PropTypes.string,
21
+ /** Extra classes on the sliding panel. */
22
+ panelClassName: PropTypes.string,
23
+ /** Extra classes on the backdrop. */
24
+ backdropClassName: PropTypes.string,
25
+ children: PropTypes.node,
26
+ };
27
+
28
+ export const fullScreenNavOverlayDefaultProps = {
29
+ isOpen: false,
30
+ slideFrom: "right",
31
+ mode: "contained",
32
+ showBackdrop: false,
33
+ ariaLabel: "Navigation menu",
34
+ };
@@ -0,0 +1,2 @@
1
+ export { FullScreenNavOverlay } from "./FullScreenNavOverlay";
2
+ export { default } from "./FullScreenNavOverlay";
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ function CloseIcon() {
6
+ return (
7
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
8
+ <path
9
+ d="M18 6L6 18M6 6L18 18"
10
+ stroke="currentColor"
11
+ strokeWidth="1.5"
12
+ strokeLinecap="round"
13
+ />
14
+ </svg>
15
+ );
16
+ }
17
+
18
+ /**
19
+ * MobileNavDrawer — slide-in navigation panel from the right edge.
20
+ * Renders a semi-transparent backdrop and a white drawer with nav links.
21
+ *
22
+ * @param {object} props
23
+ * @param {boolean} props.isOpen - Controls visibility.
24
+ * @param {Function} props.onClose - Called when backdrop or close button is clicked.
25
+ * @param {Array} props.navItems - [{ label, href?, onClick? }]
26
+ * @param {string} [props.accentColor] - Optional border/highlight colour (hex or CSS value).
27
+ */
28
+ export function MobileNavDrawer({ isOpen, onClose, navItems = [], accentColor }) {
29
+ /* Prevent body scroll while drawer is open */
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ document.body.style.overflow = "hidden";
33
+ } else {
34
+ document.body.style.overflow = "";
35
+ }
36
+ return () => {
37
+ document.body.style.overflow = "";
38
+ };
39
+ }, [isOpen]);
40
+
41
+ /* Close on Escape key */
42
+ useEffect(() => {
43
+ if (!isOpen) return undefined;
44
+ const handler = (e) => {
45
+ if (e.key === "Escape") onClose?.();
46
+ };
47
+ window.addEventListener("keydown", handler);
48
+ return () => window.removeEventListener("keydown", handler);
49
+ }, [isOpen, onClose]);
50
+
51
+ return (
52
+ <>
53
+ {/* Backdrop */}
54
+ <div
55
+ aria-hidden="true"
56
+ onClick={onClose}
57
+ className={[
58
+ "fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition-opacity duration-300 ease-out",
59
+ isOpen ? "opacity-100" : "pointer-events-none opacity-0",
60
+ ].join(" ")}
61
+ />
62
+
63
+ {/* Drawer panel */}
64
+ <nav
65
+ role="dialog"
66
+ aria-modal="true"
67
+ aria-label="Navigation menu"
68
+ className={[
69
+ "fixed right-0 top-0 z-50 flex h-full w-72 flex-col bg-white shadow-2xl transition-transform duration-300 ease-out motion-reduce:transition-none sm:w-80",
70
+ isOpen ? "translate-x-0" : "translate-x-full",
71
+ ].join(" ")}
72
+ >
73
+ {/* Accent stripe */}
74
+ {accentColor ? (
75
+ <div className="h-1 w-full shrink-0" style={{ backgroundColor: accentColor }} />
76
+ ) : null}
77
+
78
+ {/* Header */}
79
+ <div className="flex items-center justify-between border-b border-neutral-100 px-6 py-5">
80
+ <span className="text-sm font-semibold uppercase tracking-[0.08em] text-neutral-400">
81
+ Menu
82
+ </span>
83
+ <button
84
+ type="button"
85
+ onClick={onClose}
86
+ aria-label="Close menu"
87
+ className="rounded-lg p-2 text-neutral-500 transition-colors duration-200 ease-out hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2"
88
+ >
89
+ <CloseIcon />
90
+ </button>
91
+ </div>
92
+
93
+ {/* Nav links */}
94
+ <ul className="flex-1 overflow-y-auto px-6 py-6">
95
+ {navItems.map((item, index) => {
96
+ const baseClass =
97
+ "flex w-full items-center py-4 text-base font-medium leading-none text-neutral-800 transition-colors duration-200 ease-out hover:text-neutral-500 focus-visible:outline-2 focus-visible:outline-offset-2";
98
+
99
+ const isLast = index === navItems.length - 1;
100
+ const wrapperClass = isLast
101
+ ? ""
102
+ : "border-b border-neutral-100";
103
+
104
+ if (item.href) {
105
+ return (
106
+ <li key={item.label} className={wrapperClass}>
107
+ <a href={item.href} className={baseClass} onClick={onClose}>
108
+ {item.label}
109
+ </a>
110
+ </li>
111
+ );
112
+ }
113
+
114
+ return (
115
+ <li key={item.label} className={wrapperClass}>
116
+ <button
117
+ type="button"
118
+ className={baseClass}
119
+ onClick={() => {
120
+ item.onClick?.();
121
+ onClose?.();
122
+ }}
123
+ >
124
+ {item.label}
125
+ </button>
126
+ </li>
127
+ );
128
+ })}
129
+
130
+ {navItems.length === 0 && (
131
+ <li className="py-4 text-sm text-neutral-400">No items</li>
132
+ )}
133
+ </ul>
134
+ </nav>
135
+ </>
136
+ );
137
+ }
138
+
139
+ export default MobileNavDrawer;
@@ -0,0 +1,2 @@
1
+ export { MobileNavDrawer } from "./MobileNavDrawer";
2
+ export { default } from "./MobileNavDrawer";