weg-shared-layout 0.0.41 → 0.0.42

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.
@@ -5,9 +5,15 @@ import { isAriaCurrentNavHref, isNavLinkActive, resolveAriaCurrentNavKey, } from
5
5
  import { LOGO_SRC } from "./logo-data";
6
6
  const WEG_HOME = 'https://www.warwickemploymentgroup.com';
7
7
  const DEFAULT_ACCOUNT_HOME = 'https://account.warwickemploymentgroup.com';
8
+ /** Stay visible while scrolling down through the first half of the viewport. */
9
+ const HEADER_SCROLL_HIDE_THRESHOLD_VH = 0.5;
10
+ const HEADER_SCROLL_DIRECTION_DELTA = 5;
8
11
  const DEFAULT_LOGO_HREF = `${WEG_HOME}/`;
9
12
  /** Placeholder href — sign-out is handled via `wegAuthClick`, not navigation. */
10
13
  const SIGN_OUT_HREF = '#';
14
+ const MAIN_NAV_LABEL = 'Main navigation';
15
+ const OPEN_MAIN_NAV_LABEL = 'Open main navigation';
16
+ const CLOSE_MAIN_NAV_LABEL = 'Close main navigation';
11
17
  function normalizeAccountBase(input) {
12
18
  const base = input?.trim() || DEFAULT_ACCOUNT_HOME;
13
19
  return base.replace(/\/$/, '');
@@ -139,9 +145,16 @@ export class WegHeader {
139
145
  resolved = EMPTY_HEADER;
140
146
  menuOpen = false;
141
147
  openDropdown = null;
148
+ headerHidden = false;
142
149
  boundHandleDocumentClick = this.handleDocumentClick.bind(this);
143
150
  boundHandleViewportChange = this.handleViewportChange.bind(this);
144
151
  boundHandleHoverCapabilityChange = this.handleHoverCapabilityChange.bind(this);
152
+ boundHandleScroll = this.handleScroll.bind(this);
153
+ headerResizeObserver;
154
+ lastScrollY = 0;
155
+ scrollTicking = false;
156
+ bodyScrollLocked = false;
157
+ bodyScrollLockY = 0;
145
158
  hoverCloseTimer = null;
146
159
  hoverOpenEnabled = false;
147
160
  desktopMediaQuery;
@@ -162,15 +175,69 @@ export class WegHeader {
162
175
  this.updateHoverDropdownEnabled();
163
176
  this.handleViewportChange();
164
177
  }
178
+ componentDidLoad() {
179
+ const header = this.el.shadowRoot?.querySelector('.header');
180
+ if (!header)
181
+ return;
182
+ this.headerResizeObserver = new ResizeObserver(() => this.syncHeaderHeight());
183
+ this.headerResizeObserver.observe(header);
184
+ this.syncHeaderHeight();
185
+ this.lastScrollY = window.scrollY;
186
+ this.updateHeaderVisibility();
187
+ }
165
188
  connectedCallback() {
166
189
  document.addEventListener('click', this.boundHandleDocumentClick);
190
+ window.addEventListener('scroll', this.boundHandleScroll, { passive: true });
167
191
  }
168
192
  disconnectedCallback() {
169
193
  document.removeEventListener('click', this.boundHandleDocumentClick);
194
+ window.removeEventListener('scroll', this.boundHandleScroll);
195
+ this.unlockBodyScroll();
196
+ this.headerResizeObserver?.disconnect();
170
197
  this.desktopMediaQuery?.removeEventListener('change', this.boundHandleViewportChange);
171
198
  this.hoverCapableMediaQuery?.removeEventListener('change', this.boundHandleHoverCapabilityChange);
172
199
  this.cancelScheduledDropdownClose();
173
200
  }
201
+ syncHeaderHeight() {
202
+ if (this.isMobileMenuActive())
203
+ return;
204
+ const header = this.el.shadowRoot?.querySelector('.header');
205
+ if (!header)
206
+ return;
207
+ this.el.style.setProperty('--weg-header-height', `${header.offsetHeight}px`);
208
+ }
209
+ handleScroll() {
210
+ if (this.scrollTicking)
211
+ return;
212
+ this.scrollTicking = true;
213
+ requestAnimationFrame(() => {
214
+ this.scrollTicking = false;
215
+ this.updateHeaderVisibility();
216
+ });
217
+ }
218
+ updateHeaderVisibility() {
219
+ if (this.isMobileMenuActive()) {
220
+ if (this.headerHidden)
221
+ this.headerHidden = false;
222
+ return;
223
+ }
224
+ const scrollY = window.scrollY;
225
+ const delta = scrollY - this.lastScrollY;
226
+ const threshold = window.innerHeight * HEADER_SCROLL_HIDE_THRESHOLD_VH;
227
+ let nextHidden = this.headerHidden;
228
+ if (scrollY <= threshold) {
229
+ nextHidden = false;
230
+ }
231
+ else if (Math.abs(delta) >= HEADER_SCROLL_DIRECTION_DELTA) {
232
+ nextHidden = delta > 0;
233
+ }
234
+ this.lastScrollY = scrollY;
235
+ if (nextHidden === this.headerHidden)
236
+ return;
237
+ if (nextHidden)
238
+ this.closeDropdown();
239
+ this.headerHidden = nextHidden;
240
+ }
174
241
  /**
175
242
  * Desktop dropdowns open on hover when viewport is md+ and the device reports hover support.
176
243
  * Uses (hover: hover) only — not (pointer: fine), which breaks in DevTools and hybrid devices.
@@ -188,10 +255,74 @@ export class WegHeader {
188
255
  }
189
256
  handleViewportChange() {
190
257
  if (this.desktopMediaQuery?.matches) {
191
- this.closeMenu();
258
+ this.closeMenu({ announce: false, focusToggle: false });
192
259
  }
193
260
  this.updateHoverDropdownEnabled();
194
261
  }
262
+ announceToScreenReader(message) {
263
+ const region = this.el.shadowRoot?.querySelector('[data-weg-sr-live]');
264
+ if (!region)
265
+ return;
266
+ region.textContent = '';
267
+ requestAnimationFrame(() => {
268
+ region.textContent = message;
269
+ });
270
+ }
271
+ getMenuToggleButton() {
272
+ return this.el.shadowRoot?.querySelector('.menu-toggle');
273
+ }
274
+ getFirstNavFocusable() {
275
+ const panel = this.el.shadowRoot?.querySelector('#weg-header-main-nav');
276
+ if (!panel)
277
+ return null;
278
+ const focusables = panel.querySelectorAll('a[href], button:not([disabled])');
279
+ return focusables[0] ?? null;
280
+ }
281
+ focusFirstNavItem() {
282
+ setTimeout(() => {
283
+ const attemptFocus = (retries = 8) => {
284
+ const target = this.getFirstNavFocusable();
285
+ if (target) {
286
+ target.focus();
287
+ return;
288
+ }
289
+ if (retries > 0) {
290
+ requestAnimationFrame(() => attemptFocus(retries - 1));
291
+ }
292
+ };
293
+ attemptFocus();
294
+ }, 0);
295
+ }
296
+ focusMenuToggle() {
297
+ const attemptFocus = (retries = 8) => {
298
+ const toggle = this.getMenuToggleButton();
299
+ if (toggle) {
300
+ toggle.focus();
301
+ return;
302
+ }
303
+ if (retries > 0) {
304
+ requestAnimationFrame(() => attemptFocus(retries - 1));
305
+ }
306
+ };
307
+ attemptFocus();
308
+ }
309
+ getMenuGroupLinkCount(label) {
310
+ const group = this.getActiveMenu().find((item) => isMenuGroup(item) && item.label === label);
311
+ return group?.items?.length ?? 0;
312
+ }
313
+ formatLinkCount(count) {
314
+ return count === 1 ? '1 link' : `${count} links`;
315
+ }
316
+ formatItemCount(count) {
317
+ return count === 1 ? '1 item' : `${count} items`;
318
+ }
319
+ announceSubmenuExpanded(label) {
320
+ const linkCount = this.getMenuGroupLinkCount(label);
321
+ this.announceToScreenReader(`${label} submenu expanded. ${this.formatLinkCount(linkCount)}.`);
322
+ }
323
+ announceSubmenuCollapsed(label) {
324
+ this.announceToScreenReader(`${label} submenu collapsed.`);
325
+ }
195
326
  isMobileMenuActive() {
196
327
  return this.menuOpen && !this.desktopMediaQuery?.matches;
197
328
  }
@@ -201,8 +332,39 @@ export class WegHeader {
201
332
  handleKeyDown(event) {
202
333
  if (event.key !== 'Escape')
203
334
  return;
204
- this.menuOpen = false;
205
- this.closeDropdown();
335
+ if (this.openDropdown) {
336
+ event.preventDefault();
337
+ this.closeDropdownAndFocusTrigger(this.openDropdown);
338
+ return;
339
+ }
340
+ if (this.menuOpen) {
341
+ event.preventDefault();
342
+ this.closeMenu({ focusToggle: true });
343
+ }
344
+ }
345
+ lockBodyScroll() {
346
+ if (this.bodyScrollLocked)
347
+ return;
348
+ this.bodyScrollLockY = window.scrollY;
349
+ document.body.style.overflow = 'hidden';
350
+ document.body.style.position = 'fixed';
351
+ document.body.style.top = `-${this.bodyScrollLockY}px`;
352
+ document.body.style.left = '0';
353
+ document.body.style.right = '0';
354
+ document.body.style.width = '100%';
355
+ this.bodyScrollLocked = true;
356
+ }
357
+ unlockBodyScroll() {
358
+ if (!this.bodyScrollLocked)
359
+ return;
360
+ document.body.style.overflow = '';
361
+ document.body.style.position = '';
362
+ document.body.style.top = '';
363
+ document.body.style.left = '';
364
+ document.body.style.right = '';
365
+ document.body.style.width = '';
366
+ window.scrollTo(0, this.bodyScrollLockY);
367
+ this.bodyScrollLocked = false;
206
368
  }
207
369
  handleDocumentClick(event) {
208
370
  const target = event.target;
@@ -233,10 +395,12 @@ export class WegHeader {
233
395
  }
234
396
  toggleDropdown(label) {
235
397
  if (this.openDropdown === label) {
398
+ this.announceSubmenuCollapsed(label);
236
399
  this.closeDropdown();
237
400
  return;
238
401
  }
239
402
  this.openDropdownMenu(label);
403
+ this.announceSubmenuExpanded(label);
240
404
  }
241
405
  getDropdownPanelId(label) {
242
406
  return `weg-header-dropdown-${label.trim().toLowerCase().replace(/\s+/g, '-')}`;
@@ -289,29 +453,111 @@ export class WegHeader {
289
453
  if (event.key !== 'ArrowDown')
290
454
  return;
291
455
  event.preventDefault();
292
- this.openDropdownMenu(label);
456
+ if (this.openDropdown !== label) {
457
+ this.openDropdownMenu(label);
458
+ this.announceSubmenuExpanded(label);
459
+ }
293
460
  this.focusFirstDropdownLink(itemEl);
294
461
  }
462
+ getDropdownLinks(container) {
463
+ return Array.from(container.querySelectorAll('.nav-dropdown__link'));
464
+ }
465
+ focusDropdownLink(container, index) {
466
+ const attemptFocus = (retries = 8) => {
467
+ const links = this.getDropdownLinks(container);
468
+ if (links[index]) {
469
+ links[index].focus();
470
+ return;
471
+ }
472
+ if (retries > 0) {
473
+ requestAnimationFrame(() => attemptFocus(retries - 1));
474
+ }
475
+ };
476
+ attemptFocus();
477
+ }
295
478
  focusFirstDropdownLink(itemEl) {
296
- setTimeout(() => {
297
- const firstLink = itemEl.querySelector('.nav-dropdown__link');
298
- firstLink?.focus();
299
- }, 0);
479
+ this.focusDropdownLink(itemEl, 0);
480
+ }
481
+ getDropdownContainer(label) {
482
+ const panelId = this.getDropdownPanelId(label);
483
+ return this.el.shadowRoot?.querySelector(`#${panelId}-trigger`)?.closest('.nav-dropdown') ?? null;
484
+ }
485
+ closeDropdownAndFocusTrigger(labelOrContainer) {
486
+ const container = typeof labelOrContainer === 'string'
487
+ ? this.getDropdownContainer(labelOrContainer)
488
+ : labelOrContainer;
489
+ const trigger = container?.querySelector('.nav-dropdown__trigger');
490
+ const label = typeof labelOrContainer === 'string'
491
+ ? labelOrContainer
492
+ : (trigger?.querySelector('.nav-dropdown__label')?.textContent?.trim() ?? '');
493
+ if (label && this.openDropdown === label) {
494
+ this.announceSubmenuCollapsed(label);
495
+ }
496
+ this.closeDropdown();
497
+ const attemptFocus = (retries = 8) => {
498
+ if (trigger && this.el.shadowRoot?.contains(trigger)) {
499
+ trigger.focus();
500
+ return;
501
+ }
502
+ if (retries > 0) {
503
+ requestAnimationFrame(() => attemptFocus(retries - 1));
504
+ }
505
+ };
506
+ attemptFocus();
507
+ }
508
+ handleDropdownLinkKeyDown(event, container) {
509
+ const links = this.getDropdownLinks(container);
510
+ const currentIndex = links.indexOf(event.currentTarget);
511
+ if (currentIndex === -1)
512
+ return;
513
+ if (event.key === 'ArrowDown') {
514
+ event.preventDefault();
515
+ links[(currentIndex + 1) % links.length]?.focus();
516
+ return;
517
+ }
518
+ if (event.key === 'ArrowUp') {
519
+ event.preventDefault();
520
+ links[(currentIndex - 1 + links.length) % links.length]?.focus();
521
+ return;
522
+ }
523
+ if (event.key === 'Escape') {
524
+ event.preventDefault();
525
+ event.stopPropagation();
526
+ this.closeDropdownAndFocusTrigger(container);
527
+ }
300
528
  }
301
529
  toggleMenu() {
302
530
  if (this.menuOpen) {
303
- this.closeMenu();
531
+ this.closeMenu({ focusToggle: false });
304
532
  return;
305
533
  }
306
534
  this.openMenu();
307
535
  }
308
536
  openMenu() {
309
537
  this.menuOpen = true;
538
+ this.headerHidden = false;
310
539
  this.closeDropdown();
540
+ if (!this.desktopMediaQuery?.matches) {
541
+ this.lockBodyScroll();
542
+ const itemCount = this.getActiveMenu().length;
543
+ this.announceToScreenReader(`${MAIN_NAV_LABEL} opened. ${this.formatItemCount(itemCount)}.`);
544
+ this.focusFirstNavItem();
545
+ }
311
546
  }
312
- closeMenu() {
547
+ closeMenu(options = {}) {
548
+ const { announce = true, focusToggle = false } = options;
549
+ const wasMobileActive = this.isMobileMenuActive();
313
550
  this.menuOpen = false;
314
551
  this.closeDropdown();
552
+ this.unlockBodyScroll();
553
+ this.syncHeaderHeight();
554
+ this.updateHeaderVisibility();
555
+ if (announce && wasMobileActive) {
556
+ this.announceToScreenReader(`${MAIN_NAV_LABEL} closed.`);
557
+ }
558
+ if (focusToggle) {
559
+ this.focusMenuToggle();
560
+ }
315
561
  }
316
562
  getAccountHome() {
317
563
  return normalizeAccountBase(this.accountBaseUrl);
@@ -382,13 +628,17 @@ export class WegHeader {
382
628
  }, "aria-expanded": isOpen ? 'true' : 'false', "aria-haspopup": "true", "aria-controls": panelId, id: `${panelId}-trigger`, onClick: (event) => this.handleDropdownTriggerClick(event, item.label), onKeyDown: (event) => {
383
629
  const container = event.currentTarget.closest('.nav-dropdown');
384
630
  this.handleDropdownTriggerKeyDown(event, item.label, container);
385
- } }, h("span", { class: "nav-dropdown__label" }, item.label), h(ToggleIcon, { expanded: isOpen })), isOpen ? (h("div", { class: "nav-dropdown__panel", id: panelId, role: "region", "aria-labelledby": `${panelId}-trigger`, onMouseEnter: () => this.handleDropdownPanelPointerEnter(item.label), onMouseLeave: (event) => this.handleDropdownPanelPointerLeave(event) }, h("div", { class: "nav-dropdown__accent" }, h("div", { class: "nav-dropdown__links" }, items.map((child) => {
631
+ } }, h("span", { class: "nav-dropdown__label" }, item.label), h(ToggleIcon, { expanded: isOpen })), isOpen ? (h("div", { class: "nav-dropdown__panel", id: panelId, role: "region", "aria-labelledby": `${panelId}-trigger`, onMouseEnter: () => this.handleDropdownPanelPointerEnter(item.label), onMouseLeave: (event) => this.handleDropdownPanelPointerLeave(event) }, h("div", { class: "nav-dropdown__accent" }, h("ul", { class: "nav-dropdown__links" }, items.map((child, index) => {
386
632
  const childActive = this.isNavItemActive(child.href, 'exact');
387
633
  const ariaCurrent = this.isAriaCurrentHref(child.href, ariaCurrentHref);
388
- return (h("a", { class: {
634
+ const positionLabel = `${child.label}, link ${index + 1} of ${items.length}`;
635
+ return (h("li", { key: `${item.label}:${child.label}` }, h("a", { class: {
389
636
  'nav-dropdown__link': true,
390
637
  'nav-dropdown__link--active': childActive,
391
- }, href: child.href, key: `${item.label}:${child.label}`, "aria-current": ariaCurrent ? 'page' : undefined, onClick: () => onNavigate?.() }, child.label));
638
+ }, href: child.href, "aria-label": positionLabel, "aria-current": ariaCurrent ? 'page' : undefined, onClick: () => onNavigate?.(), onKeyDown: (event) => {
639
+ const dropdownContainer = event.currentTarget.closest('.nav-dropdown');
640
+ this.handleDropdownLinkKeyDown(event, dropdownContainer);
641
+ } }, child.label)));
392
642
  }))))) : null)));
393
643
  }
394
644
  handleAuthClick(event, action, _href, onNavigate) {
@@ -451,7 +701,7 @@ export class WegHeader {
451
701
  }
452
702
  renderNav(onNavigate) {
453
703
  const ariaCurrentHref = this.resolveAriaCurrentHref();
454
- return (h("nav", { class: "main-nav", "aria-label": "Main" }, h("ul", { class: "main-nav__list" }, this.getActiveMenu().map((item) => this.renderNavItem(item, onNavigate, ariaCurrentHref)))));
704
+ return (h("nav", { class: "main-nav", "aria-label": MAIN_NAV_LABEL }, h("ul", { class: "main-nav__list" }, this.getActiveMenu().map((item) => this.renderNavItem(item, onNavigate, ariaCurrentHref)))));
455
705
  }
456
706
  renderCompactAuth(onNavigate) {
457
707
  if (this.signedIn) {
@@ -472,10 +722,12 @@ export class WegHeader {
472
722
  const logoSrc = this.getLogoSrc();
473
723
  const closeMenu = () => this.closeMenu();
474
724
  const mobileMenuActive = this.isMobileMenuActive();
475
- return (h("header", { key: '6b932d0c3dc20b53292b61fa3403e26e35182d05', class: {
725
+ return (h("header", { key: '429d9af0996fa37484ebeea36f1e071fe0ba0789', class: {
476
726
  header: true,
727
+ 'header--fixed': !mobileMenuActive,
728
+ 'header--hidden': this.headerHidden && !mobileMenuActive,
477
729
  'header--menu-open': mobileMenuActive,
478
- } }, h("div", { key: '426395d6e1245188bc103c8a2f72ccfc3819ba7d', class: "header-inner" }, h("button", { key: 'fe6128407500f5b1a1899aa9e7fdd00222f177b3', type: "button", class: "menu-toggle icon-button", "aria-label": mobileMenuActive ? 'Close menu' : 'Open menu', "aria-expanded": mobileMenuActive ? 'true' : 'false', "aria-controls": "weg-header-main-nav", onClick: () => this.toggleMenu() }, mobileMenuActive ? h(CloseIcon, null) : h(HamburgerIcon, null)), h(Logo, { key: 'bb19f9430217686834c4aec615bd7306d276a969', href: logoHref, src: logoSrc }), h("div", { key: 'cebab248840b8412479a90a83c5fc3d0a601476b', class: "header-actions" }, this.renderCompactAuth(mobileMenuActive ? closeMenu : undefined)), h("div", { key: 'c029db0bcd71a9940d93bef5b32e59625f94b76c', class: "main-nav-panel", id: "weg-header-main-nav", role: mobileMenuActive ? 'dialog' : undefined, "aria-modal": mobileMenuActive ? 'true' : undefined, "aria-label": mobileMenuActive ? 'Menu' : undefined }, this.renderNav(mobileMenuActive ? closeMenu : undefined)))));
730
+ } }, h("div", { key: '2c088a3e602ac2c8751d21376872536e35a0ab7d', class: "header-inner" }, h("div", { key: '91089bfc45b2b3274df00b032cad0adaba79b937', class: "sr-only", "aria-live": "polite", "aria-atomic": "true", "data-weg-sr-live": true }), h("button", { key: 'a66e0f0564014f5041a9fa80b39f74ce177f7125', type: "button", class: "menu-toggle icon-button", "aria-label": mobileMenuActive ? CLOSE_MAIN_NAV_LABEL : OPEN_MAIN_NAV_LABEL, "aria-expanded": mobileMenuActive ? 'true' : 'false', "aria-controls": "weg-header-main-nav", onClick: () => this.toggleMenu() }, mobileMenuActive ? h(CloseIcon, null) : h(HamburgerIcon, null)), h(Logo, { key: '917d134565c899e9a25557bafd62bf8b0de75e32', href: logoHref, src: logoSrc }), h("div", { key: '7b6bcd11271e00ddf0ca7d34bc1c8d2ad8342ed4', class: "header-actions" }, this.renderCompactAuth(mobileMenuActive ? closeMenu : undefined)), h("div", { key: '1a6779e99be4704b6f9093501dd230a891b81c17', class: "main-nav-panel", id: "weg-header-main-nav", role: mobileMenuActive ? 'dialog' : undefined, "aria-modal": mobileMenuActive ? 'true' : undefined, "aria-label": mobileMenuActive ? MAIN_NAV_LABEL : undefined }, this.renderNav(mobileMenuActive ? closeMenu : undefined)))));
479
731
  }
480
732
  static get is() { return "weg-header"; }
481
733
  static get encapsulation() { return "shadow"; }
@@ -600,7 +852,8 @@ export class WegHeader {
600
852
  return {
601
853
  "resolved": {},
602
854
  "menuOpen": {},
603
- "openDropdown": {}
855
+ "openDropdown": {},
856
+ "headerHidden": {}
604
857
  };
605
858
  }
606
859
  static get events() {