@sofidevo/astro-dynamic-header 3.1.0 → 4.0.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.
package/src/NavMenu.astro CHANGED
@@ -3,33 +3,13 @@ import type { MenuItem, SecondaryMenuItem, TertiaryMenuItem } from "./index.js";
3
3
  import ChevronIcon from "./ChevronIcon.astro";
4
4
 
5
5
  export interface Props {
6
- /**
7
- * Header type for styling.
8
- * @example "floating"
9
- */
10
6
  type?: "floating" | "fullscreen";
11
- /** Array of menu items to display. */
12
7
  menuItems?: MenuItem[];
13
- /**
14
- * Whether to show the home link in the menu.
15
- * @default true
16
- */
17
8
  showHomeLink?: boolean;
18
- /**
19
- * Text for the home link.
20
- * @default "Home"
21
- */
22
9
  homeText?: string;
23
- /**
24
- * The destination URL for the home link.
25
- * @default "/"
26
- */
27
10
  homeUrl?: string;
28
- /** Extra CSS class(es) for the `<nav>` element. */
29
11
  header__menu__class?: string;
30
- /** Extra CSS class(es) for each top-level `<li>` item. */
31
12
  header__item__class?: string;
32
- /** Extra CSS class(es) for each top-level menu `<a>` link. */
33
13
  menu__link__class?: string;
34
14
  }
35
15
 
@@ -47,105 +27,9 @@ const pagePathname = Astro.url.pathname;
47
27
  ---
48
28
 
49
29
  <script>
50
- function adjustSubsubmenuPositionOnResize() {
51
- const subsubmenus = document.querySelectorAll(
52
- ".subsubmenu",
53
- ) as NodeListOf<HTMLElement>;
54
-
55
- subsubmenus.forEach((subsubmenu) => {
56
- const rect = subsubmenu.getBoundingClientRect();
57
- const viewportWidth = window.innerWidth;
58
-
59
- if (rect.right > viewportWidth) {
60
- subsubmenu.style.right = "100%";
61
- subsubmenu.style.left = "0";
62
- } else {
63
- subsubmenu.style.left = "100%";
64
- subsubmenu.style.right = "0";
65
- }
66
- });
67
- }
30
+ import { initNavMenu } from "./scripts/nav-menu.js";
68
31
 
69
- function handleSubsubmenuInteraction() {
70
- const parentItems = document.querySelectorAll(".submenu__item--secondary");
71
-
72
- parentItems.forEach((parentItem) => {
73
- const subsubmenu = parentItem.querySelector(".subsubmenu");
74
-
75
- if (subsubmenu) {
76
- const adjustPosition = () => {
77
- const viewportWidth = window.innerWidth;
78
-
79
- const originalDisplay = (subsubmenu as HTMLElement).style.display;
80
- const originalVisibility = (subsubmenu as HTMLElement).style
81
- .visibility;
82
-
83
- (subsubmenu as HTMLElement).style.display = "block";
84
- (subsubmenu as HTMLElement).style.visibility = "hidden";
85
- (subsubmenu as HTMLElement).style.left = "100%";
86
- (subsubmenu as HTMLElement).style.transform = "auto";
87
-
88
- const rect = subsubmenu.getBoundingClientRect();
89
-
90
- if (rect.right > viewportWidth) {
91
- (subsubmenu as HTMLElement).style.left = "-100%";
92
- (subsubmenu as HTMLElement).style.right = "auto";
93
- const leftRect = subsubmenu.getBoundingClientRect();
94
-
95
- if (leftRect.left < 0) {
96
- (subsubmenu as HTMLElement).style.left = "100%";
97
- const overflow = rect.right - viewportWidth + 30;
98
- (subsubmenu as HTMLElement).style.transform =
99
- `translateX(-${overflow}px)`;
100
- }
101
- }
102
- (subsubmenu as HTMLElement).style.display = originalDisplay;
103
- (subsubmenu as HTMLElement).style.visibility = originalVisibility;
104
- };
105
-
106
- const showSubsubmenu = () => {
107
- adjustPosition();
108
-
109
- (subsubmenu as HTMLElement).style.opacity = "1";
110
- (subsubmenu as HTMLElement).style.pointerEvents = "all";
111
-
112
- const isPositionedLeft =
113
- (subsubmenu as HTMLElement).style.left === "-100%";
114
- const transformDirection = isPositionedLeft ? "-32px" : "32px";
115
-
116
- const currentTransform = (subsubmenu as HTMLElement).style.transform;
117
- if (currentTransform && currentTransform.includes("translateX")) {
118
- const existingTranslate = currentTransform.match(
119
- /translateX\(([^)]+)\)/,
120
- );
121
- if (existingTranslate) {
122
- const existingValue = existingTranslate[1];
123
- (subsubmenu as HTMLElement).style.transform =
124
- `translateX(calc(${existingValue} + ${transformDirection}))`;
125
- }
126
- } else {
127
- (subsubmenu as HTMLElement).style.transform =
128
- `${currentTransform} translateX(${transformDirection})`.trim();
129
- }
130
- };
131
-
132
- const hideSubsubmenu = () => {
133
- (subsubmenu as HTMLElement).style.opacity = "";
134
- (subsubmenu as HTMLElement).style.pointerEvents = "";
135
- (subsubmenu as HTMLElement).style.transform = "";
136
- };
137
-
138
- parentItem.addEventListener("mouseenter", showSubsubmenu);
139
- parentItem.addEventListener("mouseleave", hideSubsubmenu);
140
- }
141
- });
142
- }
143
-
144
- window.addEventListener("resize", adjustSubsubmenuPositionOnResize);
145
- document.addEventListener("DOMContentLoaded", () => {
146
- adjustSubsubmenuPositionOnResize();
147
- handleSubsubmenuInteraction();
148
- });
32
+ initNavMenu();
149
33
  </script>
150
34
 
151
35
  <nav class:list={["header__menu", header__menu__class]} id="header-menu">
@@ -205,187 +89,195 @@ const pagePathname = Astro.url.pathname;
205
89
  </ul>
206
90
  </nav>
207
91
 
208
- <style is:inline>
209
- .header__menu {
210
- @media (width < 768px) {
211
- display: none;
92
+ <style>
93
+ /*
94
+ * base (resets) < components (ours) < utilities (consumer classNames).
95
+ * Order statement keeps it deterministic regardless of bundling order.
96
+ */
97
+ @layer theme, base, components, utilities;
98
+
99
+ @layer components {
100
+ .header__menu {
101
+ @media (width < 768px) {
102
+ display: none;
103
+ }
212
104
  }
213
- }
214
105
 
215
- .menu {
216
- list-style: none;
217
- display: flex;
218
- align-items: center;
219
- flex-wrap: wrap;
106
+ .menu {
107
+ list-style: none;
108
+ display: flex;
109
+ align-items: center;
110
+ flex-wrap: wrap;
220
111
 
221
- @media (width < 768px) {
222
- flex-direction: column;
112
+ @media (width < 768px) {
113
+ flex-direction: column;
114
+ }
223
115
  }
224
- }
225
-
226
- .menu__link {
227
- text-decoration: none;
228
- padding: 0.5em;
229
- display: flex;
230
- align-items: center;
231
- gap: 0.8em;
232
- color: color-mix(
233
- in srgb,
234
- var(--accent-color, #00ffff) 30%,
235
- var(--text-color, #ffffff) 70%
236
- );
237
- position: relative;
238
- }
239
116
 
240
- @media screen and (min-width: 768px) {
241
- .menu__link::after {
242
- position: absolute;
243
- top: 33px;
244
- left: 0;
245
- content: "";
246
- width: 100%;
247
- transform: scale(0);
248
- transform-origin: 1;
249
- height: 2px;
250
- transition: transform 0.3s ease;
251
- background: var(--accent-color, #00ffff);
117
+ .menu__link {
118
+ text-decoration: none;
119
+ padding: 0.5em;
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 0.8em;
123
+ color: color-mix(
124
+ in srgb,
125
+ var(--accent-color, #00ffff) 30%,
126
+ var(--text-color, #ffffff) 70%
127
+ );
128
+ position: relative;
252
129
  }
253
130
 
254
- .menu__link:hover::after {
255
- transform: scale(1);
131
+ @media screen and (min-width: 768px) {
132
+ .menu__link::after {
133
+ position: absolute;
134
+ top: 33px;
135
+ left: 0;
136
+ content: "";
137
+ width: 100%;
138
+ transform: scale(0);
139
+ transform-origin: 1;
140
+ height: 2px;
141
+ transition: transform 0.3s ease;
142
+ background: var(--accent-color, #00ffff);
143
+ }
144
+
145
+ .menu__link:hover::after {
146
+ transform: scale(1);
147
+ }
256
148
  }
257
- }
258
149
 
259
- .ChevronIcon-arrow {
260
- transition: transform 0.3s linear;
261
- }
150
+ .ChevronIcon-arrow {
151
+ transition: transform 0.3s linear;
152
+ }
262
153
 
263
- .menu__link:hover > .ChevronIcon-arrow {
264
- transform: rotate(180deg);
265
- }
154
+ .menu__link:hover > .ChevronIcon-arrow {
155
+ transform: rotate(180deg);
156
+ }
266
157
 
267
- .submenu,
268
- .subsubmenu {
269
- color: var(--text-color, #ffffff);
270
- background-color: var(--bg-color-opaque, #171717);
271
- border: 0.5px solid
272
- color-mix(in srgb, var(--accent-color, #3e1c71) 30%, transparent);
273
- position: absolute;
274
- flex-direction: column;
275
- gap: 0.4em;
276
- padding: 1em;
277
- border-radius: 10px;
278
- transition: transform 0.3s ease-in-out;
279
- pointer-events: none;
280
- opacity: 0;
281
- text-wrap: nowrap;
282
- }
158
+ .submenu,
159
+ .subsubmenu {
160
+ color: var(--text-color, #ffffff);
161
+ background-color: var(--bg-color-opaque, #171717);
162
+ border: 0.5px solid
163
+ color-mix(in srgb, var(--accent-color, #3e1c71) 30%, transparent);
164
+ position: absolute;
165
+ flex-direction: column;
166
+ gap: 0.4em;
167
+ padding: 1em;
168
+ border-radius: 10px;
169
+ transition: transform 0.3s ease-in-out;
170
+ pointer-events: none;
171
+ opacity: 0;
172
+ text-wrap: nowrap;
173
+ }
283
174
 
284
- .menu__item {
285
- padding: 6px;
286
- position: relative;
287
- list-style: none;
288
- }
175
+ .menu__item {
176
+ padding: 6px;
177
+ position: relative;
178
+ list-style: none;
179
+ }
289
180
 
290
- .submenu a,
291
- .subsubmenu a {
292
- display: flex;
293
- justify-content: space-between;
294
- align-items: center;
295
- width: 100%;
296
- height: 64px;
297
- border-radius: 6px;
298
- color: var(--text-color, #fff);
299
- }
181
+ .submenu a,
182
+ .subsubmenu a {
183
+ display: flex;
184
+ justify-content: space-between;
185
+ align-items: center;
186
+ width: 100%;
187
+ height: 64px;
188
+ border-radius: 6px;
189
+ color: var(--text-color, #fff);
190
+ }
300
191
 
301
- .submenu__item--secondary,
302
- .submenu__item--tertiary {
303
- position: relative;
304
- }
192
+ .submenu__item--secondary,
193
+ .submenu__item--tertiary {
194
+ position: relative;
195
+ }
305
196
 
306
- .submenu__item--secondary a,
307
- .submenu__item--tertiary a {
308
- padding: 26px;
309
- }
197
+ .submenu__item--secondary a,
198
+ .submenu__item--tertiary a {
199
+ padding: 26px;
200
+ }
310
201
 
311
- .submenu__item--secondary a:hover,
312
- .submenu__item--tertiary a:hover {
313
- background: color-mix(
314
- in srgb,
315
- var(--accent-color, #00ffff) 15%,
316
- transparent
317
- );
318
- color: var(--text-color, white);
319
- }
202
+ .submenu__item--secondary a:hover,
203
+ .submenu__item--tertiary a:hover {
204
+ background: color-mix(
205
+ in srgb,
206
+ var(--accent-color, #00ffff) 15%,
207
+ transparent
208
+ );
209
+ color: var(--text-color, white);
210
+ }
320
211
 
321
- .submenu {
322
- padding: 0.9em;
323
- right: calc(50% - 80px);
324
- }
212
+ .submenu {
213
+ padding: 0.9em;
214
+ right: calc(50% - 80px);
215
+ }
325
216
 
326
- .submenu::after {
327
- content: "";
328
- position: absolute;
329
- top: -1.1em;
330
- width: 100%;
331
- z-index: -3;
332
- pointer-events: none;
333
- height: 7em;
334
- right: 0;
335
- }
217
+ .submenu::after {
218
+ content: "";
219
+ position: absolute;
220
+ top: -1.1em;
221
+ width: 100%;
222
+ z-index: -3;
223
+ pointer-events: none;
224
+ height: 7em;
225
+ right: 0;
226
+ }
336
227
 
337
- .subsubmenu {
338
- top: 0;
339
- left: auto;
340
- right: auto;
341
- min-width: 193px;
342
- }
228
+ .subsubmenu {
229
+ top: 0;
230
+ left: auto;
231
+ right: auto;
232
+ min-width: 193px;
233
+ }
343
234
 
344
- .subsubmenu::before {
345
- content: "";
346
- position: absolute;
347
- top: -4em;
348
- z-index: -1;
349
- pointer-events: none;
350
- height: 10em;
351
- width: 20em;
352
- height: 14em;
353
- }
235
+ .subsubmenu::before {
236
+ content: "";
237
+ position: absolute;
238
+ top: -4em;
239
+ z-index: -1;
240
+ pointer-events: none;
241
+ height: 10em;
242
+ width: 20em;
243
+ height: 14em;
244
+ }
354
245
 
355
- .subsubmenu::after {
356
- content: "";
357
- position: absolute;
358
- top: -14px;
359
- right: 3em;
360
- width: 100%;
361
- z-index: -3;
362
- height: 21em;
363
- }
246
+ .subsubmenu::after {
247
+ content: "";
248
+ position: absolute;
249
+ top: -14px;
250
+ right: 3em;
251
+ width: 100%;
252
+ z-index: -3;
253
+ height: 21em;
254
+ }
364
255
 
365
- .submenu li {
366
- width: 100%;
367
- }
256
+ .submenu li {
257
+ width: 100%;
258
+ }
368
259
 
369
- .menu__item:hover > .submenu {
370
- opacity: 1;
371
- pointer-events: all;
372
- transform: translateY(1.4em);
373
- }
260
+ .menu__item:hover > .submenu {
261
+ opacity: 1;
262
+ pointer-events: all;
263
+ transform: translateY(1.4em);
264
+ }
374
265
 
375
- .menu__item:hover > .submenu::after {
376
- pointer-events: all;
377
- }
266
+ .menu__item:hover > .submenu::after {
267
+ pointer-events: all;
268
+ }
378
269
 
379
- .submenu li:hover > .subsubmenu {
380
- opacity: 1;
381
- pointer-events: all;
382
- }
270
+ .submenu li:hover > .subsubmenu {
271
+ opacity: 1;
272
+ pointer-events: all;
273
+ }
383
274
 
384
- .submenu li:hover > .subsubmenu::before {
385
- pointer-events: all;
386
- }
275
+ .submenu li:hover > .subsubmenu::before {
276
+ pointer-events: all;
277
+ }
387
278
 
388
- .submenu li:hover > .subsubmenu::after {
389
- pointer-events: all;
279
+ .submenu li:hover > .subsubmenu::after {
280
+ pointer-events: all;
281
+ }
390
282
  }
391
283
  </style>
package/src/SunIcon.astro CHANGED
@@ -15,20 +15,28 @@ const content = `<g class="gutruu"><path fill-opacity="0" class="xkoryc"><animat
15
15
  />
16
16
 
17
17
  <style is:inline>
18
- .gutruu {
19
- stroke: currentColor;
20
- stroke-linecap: round;
21
- stroke-linejoin: round;
22
- stroke-width: var(--svg-stroke-width--2px, 2px);
23
- }
24
- .xkoryc {
25
- fill: currentColor;
26
- stroke-dasharray: 34;
27
- d: path(
28
- "M12 7c2.76 0 5 2.24 5 5c0 2.76 -2.24 5 -5 5c-2.76 0 -5 -2.24 -5 -5c0 -2.76 2.24 -5 5 -5Z"
29
- );
30
- }
31
- .ft5dvn {
32
- fill: none;
18
+ /*
19
+ * base (resets) < components (ours) < utilities (consumer classNames).
20
+ * Order statement keeps it deterministic regardless of bundling order.
21
+ */
22
+ @layer theme, base, components, utilities;
23
+
24
+ @layer components {
25
+ .gutruu {
26
+ stroke: currentColor;
27
+ stroke-linecap: round;
28
+ stroke-linejoin: round;
29
+ stroke-width: var(--svg-stroke-width--2px, 2px);
30
+ }
31
+ .xkoryc {
32
+ fill: currentColor;
33
+ stroke-dasharray: 34;
34
+ d: path(
35
+ "M12 7c2.76 0 5 2.24 5 5c0 2.76 -2.24 5 -5 5c-2.76 0 -5 -2.24 -5 -5c0 -2.76 2.24 -5 5 -5Z"
36
+ );
37
+ }
38
+ .ft5dvn {
39
+ fill: none;
40
+ }
33
41
  }
34
42
  </style>
package/src/index.ts CHANGED
@@ -129,8 +129,12 @@ export interface DualThemeConfig {
129
129
  * Custom CSS class names for high-level layout & appearance customization.
130
130
  *
131
131
  * These target the structural wrapper elements of the Header. For fine-grained
132
- * control over individual nav links or the logo internals, use the nested
133
- * `xxx__class` props inside the `navigation` or `logo` config objects instead.
132
+ * control over the nav items use the `xxx__class` props inside `navigation`,
133
+ * and style the logo directly on the markup you pass to the `logo` slot.
134
+ *
135
+ * The component styles live in `@layer components`, so any class passed here
136
+ * (Tailwind utilities included) wins over the built-in styles — no
137
+ * `!important` and no `twMerge()` required.
134
138
  *
135
139
  * @example
136
140
  * ```astro
@@ -142,10 +146,6 @@ export interface HeaderClassNames {
142
146
  container?: string;
143
147
  /** Inner `<header>` element — best place for shadows, borders, transitions. */
144
148
  header?: string;
145
- /** Logo anchor `<a>` — add hover states or focus rings here. */
146
- logo?: string;
147
- /** Logo text `<span>` — override typography here. */
148
- logoText?: string;
149
149
  /** Desktop nav wrapper `<div>` — adjust spacing between logo and menu. */
150
150
  nav?: string;
151
151
  /** Mobile nav panel `<nav>` — add slide-in overrides or z-index tweaks. */
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Behaviour for the hamburger button + mobile panel.
3
+ * Extracted from `Header.astro` so it can be unit tested.
4
+ */
5
+
6
+ export class HamburgerController {
7
+ private readonly hamburgerBtn: HTMLElement | null;
8
+ private readonly mobileMenu: HTMLElement | null;
9
+ private readonly links: NodeListOf<HTMLElement>;
10
+
11
+ constructor(doc: Document = document) {
12
+ this.hamburgerBtn = doc.getElementById("hamburger-btn");
13
+ this.mobileMenu = doc.getElementById("mobile-header-menu");
14
+ this.links = doc.querySelectorAll(".mobile-menu__link");
15
+ this.init();
16
+ }
17
+
18
+ private init(): void {
19
+ if (!this.hamburgerBtn || !this.mobileMenu) return;
20
+
21
+ this.hamburgerBtn.addEventListener("click", () => this.toggleMenu());
22
+ this.links.forEach((link) => {
23
+ link.addEventListener("click", () => this.closeMenu());
24
+ });
25
+ }
26
+
27
+ closeMenu(): void {
28
+ this.hamburgerBtn?.classList.remove("is-active");
29
+ this.mobileMenu?.classList.remove("is-active");
30
+ }
31
+
32
+ toggleMenu(): void {
33
+ this.hamburgerBtn?.classList.toggle("is-active");
34
+ this.mobileMenu?.classList.toggle("is-active");
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Wires the hamburger as soon as the DOM is ready (module scripts may run
40
+ * before or after `DOMContentLoaded`, so both paths are handled).
41
+ */
42
+ export function initHamburger(doc: Document = document): HamburgerController | null {
43
+ if (doc.readyState === "loading") {
44
+ let controller: HamburgerController | null = null;
45
+ doc.addEventListener(
46
+ "DOMContentLoaded",
47
+ () => {
48
+ controller = new HamburgerController(doc);
49
+ },
50
+ { once: true },
51
+ );
52
+ return controller;
53
+ }
54
+ return new HamburgerController(doc);
55
+ }