weg-shared-layout 0.0.41 → 0.0.44

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,24 @@ 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';
17
+ const SKIP_TO_CONTENT_LABEL = 'Skip to main content';
18
+ const SKIP_TO_CONTENT_TARGET_ID = 'main-content';
19
+ function focusScrollTarget(element) {
20
+ if (!element.hasAttribute('tabindex')) {
21
+ element.setAttribute('tabindex', '-1');
22
+ }
23
+ element.focus({ preventScroll: true });
24
+ element.scrollIntoView();
25
+ }
11
26
  function normalizeAccountBase(input) {
12
27
  const base = input?.trim() || DEFAULT_ACCOUNT_HOME;
13
28
  return base.replace(/\/$/, '');
@@ -139,9 +154,16 @@ export class WegHeader {
139
154
  resolved = EMPTY_HEADER;
140
155
  menuOpen = false;
141
156
  openDropdown = null;
157
+ headerHidden = false;
142
158
  boundHandleDocumentClick = this.handleDocumentClick.bind(this);
143
159
  boundHandleViewportChange = this.handleViewportChange.bind(this);
144
160
  boundHandleHoverCapabilityChange = this.handleHoverCapabilityChange.bind(this);
161
+ boundHandleScroll = this.handleScroll.bind(this);
162
+ headerResizeObserver;
163
+ lastScrollY = 0;
164
+ scrollTicking = false;
165
+ bodyScrollLocked = false;
166
+ bodyScrollLockY = 0;
145
167
  hoverCloseTimer = null;
146
168
  hoverOpenEnabled = false;
147
169
  desktopMediaQuery;
@@ -162,15 +184,69 @@ export class WegHeader {
162
184
  this.updateHoverDropdownEnabled();
163
185
  this.handleViewportChange();
164
186
  }
187
+ componentDidLoad() {
188
+ const header = this.el.shadowRoot?.querySelector('.header');
189
+ if (!header)
190
+ return;
191
+ this.headerResizeObserver = new ResizeObserver(() => this.syncHeaderHeight());
192
+ this.headerResizeObserver.observe(header);
193
+ this.syncHeaderHeight();
194
+ this.lastScrollY = window.scrollY;
195
+ this.updateHeaderVisibility();
196
+ }
165
197
  connectedCallback() {
166
198
  document.addEventListener('click', this.boundHandleDocumentClick);
199
+ window.addEventListener('scroll', this.boundHandleScroll, { passive: true });
167
200
  }
168
201
  disconnectedCallback() {
169
202
  document.removeEventListener('click', this.boundHandleDocumentClick);
203
+ window.removeEventListener('scroll', this.boundHandleScroll);
204
+ this.unlockBodyScroll();
205
+ this.headerResizeObserver?.disconnect();
170
206
  this.desktopMediaQuery?.removeEventListener('change', this.boundHandleViewportChange);
171
207
  this.hoverCapableMediaQuery?.removeEventListener('change', this.boundHandleHoverCapabilityChange);
172
208
  this.cancelScheduledDropdownClose();
173
209
  }
210
+ syncHeaderHeight() {
211
+ if (this.isMobileMenuActive())
212
+ return;
213
+ const header = this.el.shadowRoot?.querySelector('.header');
214
+ if (!header)
215
+ return;
216
+ this.el.style.setProperty('--weg-header-height', `${header.offsetHeight}px`);
217
+ }
218
+ handleScroll() {
219
+ if (this.scrollTicking)
220
+ return;
221
+ this.scrollTicking = true;
222
+ requestAnimationFrame(() => {
223
+ this.scrollTicking = false;
224
+ this.updateHeaderVisibility();
225
+ });
226
+ }
227
+ updateHeaderVisibility() {
228
+ if (this.isMobileMenuActive()) {
229
+ if (this.headerHidden)
230
+ this.headerHidden = false;
231
+ return;
232
+ }
233
+ const scrollY = window.scrollY;
234
+ const delta = scrollY - this.lastScrollY;
235
+ const threshold = window.innerHeight * HEADER_SCROLL_HIDE_THRESHOLD_VH;
236
+ let nextHidden = this.headerHidden;
237
+ if (scrollY <= threshold) {
238
+ nextHidden = false;
239
+ }
240
+ else if (Math.abs(delta) >= HEADER_SCROLL_DIRECTION_DELTA) {
241
+ nextHidden = delta > 0;
242
+ }
243
+ this.lastScrollY = scrollY;
244
+ if (nextHidden === this.headerHidden)
245
+ return;
246
+ if (nextHidden)
247
+ this.closeDropdown();
248
+ this.headerHidden = nextHidden;
249
+ }
174
250
  /**
175
251
  * Desktop dropdowns open on hover when viewport is md+ and the device reports hover support.
176
252
  * Uses (hover: hover) only — not (pointer: fine), which breaks in DevTools and hybrid devices.
@@ -188,10 +264,74 @@ export class WegHeader {
188
264
  }
189
265
  handleViewportChange() {
190
266
  if (this.desktopMediaQuery?.matches) {
191
- this.closeMenu();
267
+ this.closeMenu({ announce: false, focusToggle: false });
192
268
  }
193
269
  this.updateHoverDropdownEnabled();
194
270
  }
271
+ announceToScreenReader(message) {
272
+ const region = this.el.shadowRoot?.querySelector('[data-weg-sr-live]');
273
+ if (!region)
274
+ return;
275
+ region.textContent = '';
276
+ requestAnimationFrame(() => {
277
+ region.textContent = message;
278
+ });
279
+ }
280
+ getMenuToggleButton() {
281
+ return this.el.shadowRoot?.querySelector('.menu-toggle');
282
+ }
283
+ getFirstNavFocusable() {
284
+ const panel = this.el.shadowRoot?.querySelector('#weg-header-main-nav');
285
+ if (!panel)
286
+ return null;
287
+ const focusables = panel.querySelectorAll('a[href], button:not([disabled])');
288
+ return focusables[0] ?? null;
289
+ }
290
+ focusFirstNavItem() {
291
+ setTimeout(() => {
292
+ const attemptFocus = (retries = 8) => {
293
+ const target = this.getFirstNavFocusable();
294
+ if (target) {
295
+ target.focus();
296
+ return;
297
+ }
298
+ if (retries > 0) {
299
+ requestAnimationFrame(() => attemptFocus(retries - 1));
300
+ }
301
+ };
302
+ attemptFocus();
303
+ }, 0);
304
+ }
305
+ focusMenuToggle() {
306
+ const attemptFocus = (retries = 8) => {
307
+ const toggle = this.getMenuToggleButton();
308
+ if (toggle) {
309
+ toggle.focus();
310
+ return;
311
+ }
312
+ if (retries > 0) {
313
+ requestAnimationFrame(() => attemptFocus(retries - 1));
314
+ }
315
+ };
316
+ attemptFocus();
317
+ }
318
+ getMenuGroupLinkCount(label) {
319
+ const group = this.getActiveMenu().find((item) => isMenuGroup(item) && item.label === label);
320
+ return group?.items?.length ?? 0;
321
+ }
322
+ formatLinkCount(count) {
323
+ return count === 1 ? '1 link' : `${count} links`;
324
+ }
325
+ formatItemCount(count) {
326
+ return count === 1 ? '1 item' : `${count} items`;
327
+ }
328
+ announceSubmenuExpanded(label) {
329
+ const linkCount = this.getMenuGroupLinkCount(label);
330
+ this.announceToScreenReader(`${label} submenu expanded. ${this.formatLinkCount(linkCount)}.`);
331
+ }
332
+ announceSubmenuCollapsed(label) {
333
+ this.announceToScreenReader(`${label} submenu collapsed.`);
334
+ }
195
335
  isMobileMenuActive() {
196
336
  return this.menuOpen && !this.desktopMediaQuery?.matches;
197
337
  }
@@ -201,8 +341,39 @@ export class WegHeader {
201
341
  handleKeyDown(event) {
202
342
  if (event.key !== 'Escape')
203
343
  return;
204
- this.menuOpen = false;
205
- this.closeDropdown();
344
+ if (this.openDropdown) {
345
+ event.preventDefault();
346
+ this.closeDropdownAndFocusTrigger(this.openDropdown);
347
+ return;
348
+ }
349
+ if (this.menuOpen) {
350
+ event.preventDefault();
351
+ this.closeMenu({ focusToggle: true });
352
+ }
353
+ }
354
+ lockBodyScroll() {
355
+ if (this.bodyScrollLocked)
356
+ return;
357
+ this.bodyScrollLockY = window.scrollY;
358
+ document.body.style.overflow = 'hidden';
359
+ document.body.style.position = 'fixed';
360
+ document.body.style.top = `-${this.bodyScrollLockY}px`;
361
+ document.body.style.left = '0';
362
+ document.body.style.right = '0';
363
+ document.body.style.width = '100%';
364
+ this.bodyScrollLocked = true;
365
+ }
366
+ unlockBodyScroll() {
367
+ if (!this.bodyScrollLocked)
368
+ return;
369
+ document.body.style.overflow = '';
370
+ document.body.style.position = '';
371
+ document.body.style.top = '';
372
+ document.body.style.left = '';
373
+ document.body.style.right = '';
374
+ document.body.style.width = '';
375
+ window.scrollTo(0, this.bodyScrollLockY);
376
+ this.bodyScrollLocked = false;
206
377
  }
207
378
  handleDocumentClick(event) {
208
379
  const target = event.target;
@@ -233,10 +404,12 @@ export class WegHeader {
233
404
  }
234
405
  toggleDropdown(label) {
235
406
  if (this.openDropdown === label) {
407
+ this.announceSubmenuCollapsed(label);
236
408
  this.closeDropdown();
237
409
  return;
238
410
  }
239
411
  this.openDropdownMenu(label);
412
+ this.announceSubmenuExpanded(label);
240
413
  }
241
414
  getDropdownPanelId(label) {
242
415
  return `weg-header-dropdown-${label.trim().toLowerCase().replace(/\s+/g, '-')}`;
@@ -289,29 +462,111 @@ export class WegHeader {
289
462
  if (event.key !== 'ArrowDown')
290
463
  return;
291
464
  event.preventDefault();
292
- this.openDropdownMenu(label);
465
+ if (this.openDropdown !== label) {
466
+ this.openDropdownMenu(label);
467
+ this.announceSubmenuExpanded(label);
468
+ }
293
469
  this.focusFirstDropdownLink(itemEl);
294
470
  }
471
+ getDropdownLinks(container) {
472
+ return Array.from(container.querySelectorAll('.nav-dropdown__link'));
473
+ }
474
+ focusDropdownLink(container, index) {
475
+ const attemptFocus = (retries = 8) => {
476
+ const links = this.getDropdownLinks(container);
477
+ if (links[index]) {
478
+ links[index].focus();
479
+ return;
480
+ }
481
+ if (retries > 0) {
482
+ requestAnimationFrame(() => attemptFocus(retries - 1));
483
+ }
484
+ };
485
+ attemptFocus();
486
+ }
295
487
  focusFirstDropdownLink(itemEl) {
296
- setTimeout(() => {
297
- const firstLink = itemEl.querySelector('.nav-dropdown__link');
298
- firstLink?.focus();
299
- }, 0);
488
+ this.focusDropdownLink(itemEl, 0);
489
+ }
490
+ getDropdownContainer(label) {
491
+ const panelId = this.getDropdownPanelId(label);
492
+ return this.el.shadowRoot?.querySelector(`#${panelId}-trigger`)?.closest('.nav-dropdown') ?? null;
493
+ }
494
+ closeDropdownAndFocusTrigger(labelOrContainer) {
495
+ const container = typeof labelOrContainer === 'string'
496
+ ? this.getDropdownContainer(labelOrContainer)
497
+ : labelOrContainer;
498
+ const trigger = container?.querySelector('.nav-dropdown__trigger');
499
+ const label = typeof labelOrContainer === 'string'
500
+ ? labelOrContainer
501
+ : (trigger?.querySelector('.nav-dropdown__label')?.textContent?.trim() ?? '');
502
+ if (label && this.openDropdown === label) {
503
+ this.announceSubmenuCollapsed(label);
504
+ }
505
+ this.closeDropdown();
506
+ const attemptFocus = (retries = 8) => {
507
+ if (trigger && this.el.shadowRoot?.contains(trigger)) {
508
+ trigger.focus();
509
+ return;
510
+ }
511
+ if (retries > 0) {
512
+ requestAnimationFrame(() => attemptFocus(retries - 1));
513
+ }
514
+ };
515
+ attemptFocus();
516
+ }
517
+ handleDropdownLinkKeyDown(event, container) {
518
+ const links = this.getDropdownLinks(container);
519
+ const currentIndex = links.indexOf(event.currentTarget);
520
+ if (currentIndex === -1)
521
+ return;
522
+ if (event.key === 'ArrowDown') {
523
+ event.preventDefault();
524
+ links[(currentIndex + 1) % links.length]?.focus();
525
+ return;
526
+ }
527
+ if (event.key === 'ArrowUp') {
528
+ event.preventDefault();
529
+ links[(currentIndex - 1 + links.length) % links.length]?.focus();
530
+ return;
531
+ }
532
+ if (event.key === 'Escape') {
533
+ event.preventDefault();
534
+ event.stopPropagation();
535
+ this.closeDropdownAndFocusTrigger(container);
536
+ }
300
537
  }
301
538
  toggleMenu() {
302
539
  if (this.menuOpen) {
303
- this.closeMenu();
540
+ this.closeMenu({ focusToggle: false });
304
541
  return;
305
542
  }
306
543
  this.openMenu();
307
544
  }
308
545
  openMenu() {
309
546
  this.menuOpen = true;
547
+ this.headerHidden = false;
310
548
  this.closeDropdown();
549
+ if (!this.desktopMediaQuery?.matches) {
550
+ this.lockBodyScroll();
551
+ const itemCount = this.getActiveMenu().length;
552
+ this.announceToScreenReader(`${MAIN_NAV_LABEL} opened. ${this.formatItemCount(itemCount)}.`);
553
+ this.focusFirstNavItem();
554
+ }
311
555
  }
312
- closeMenu() {
556
+ closeMenu(options = {}) {
557
+ const { announce = true, focusToggle = false } = options;
558
+ const wasMobileActive = this.isMobileMenuActive();
313
559
  this.menuOpen = false;
314
560
  this.closeDropdown();
561
+ this.unlockBodyScroll();
562
+ this.syncHeaderHeight();
563
+ this.updateHeaderVisibility();
564
+ if (announce && wasMobileActive) {
565
+ this.announceToScreenReader(`${MAIN_NAV_LABEL} closed.`);
566
+ }
567
+ if (focusToggle) {
568
+ this.focusMenuToggle();
569
+ }
315
570
  }
316
571
  getAccountHome() {
317
572
  return normalizeAccountBase(this.accountBaseUrl);
@@ -382,13 +637,17 @@ export class WegHeader {
382
637
  }, "aria-expanded": isOpen ? 'true' : 'false', "aria-haspopup": "true", "aria-controls": panelId, id: `${panelId}-trigger`, onClick: (event) => this.handleDropdownTriggerClick(event, item.label), onKeyDown: (event) => {
383
638
  const container = event.currentTarget.closest('.nav-dropdown');
384
639
  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) => {
640
+ } }, 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
641
  const childActive = this.isNavItemActive(child.href, 'exact');
387
642
  const ariaCurrent = this.isAriaCurrentHref(child.href, ariaCurrentHref);
388
- return (h("a", { class: {
643
+ const positionLabel = `${child.label}, link ${index + 1} of ${items.length}`;
644
+ return (h("li", { key: `${item.label}:${child.label}` }, h("a", { class: {
389
645
  'nav-dropdown__link': true,
390
646
  'nav-dropdown__link--active': childActive,
391
- }, href: child.href, key: `${item.label}:${child.label}`, "aria-current": ariaCurrent ? 'page' : undefined, onClick: () => onNavigate?.() }, child.label));
647
+ }, href: child.href, "aria-label": positionLabel, "aria-current": ariaCurrent ? 'page' : undefined, onClick: () => onNavigate?.(), onKeyDown: (event) => {
648
+ const dropdownContainer = event.currentTarget.closest('.nav-dropdown');
649
+ this.handleDropdownLinkKeyDown(event, dropdownContainer);
650
+ } }, child.label)));
392
651
  }))))) : null)));
393
652
  }
394
653
  handleAuthClick(event, action, _href, onNavigate) {
@@ -451,7 +710,7 @@ export class WegHeader {
451
710
  }
452
711
  renderNav(onNavigate) {
453
712
  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)))));
713
+ 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
714
  }
456
715
  renderCompactAuth(onNavigate) {
457
716
  if (this.signedIn) {
@@ -467,15 +726,28 @@ export class WegHeader {
467
726
  onNavigate,
468
727
  });
469
728
  }
729
+ handleSkipToContent(event) {
730
+ event.preventDefault();
731
+ const mainContent = document.querySelector('main') ?? document.getElementById(SKIP_TO_CONTENT_TARGET_ID);
732
+ if (mainContent instanceof HTMLElement) {
733
+ focusScrollTarget(mainContent);
734
+ }
735
+ const headings = document.querySelectorAll('h1');
736
+ if (headings.length > 0 && headings[0] instanceof HTMLElement) {
737
+ focusScrollTarget(headings[0]);
738
+ }
739
+ }
470
740
  render() {
471
741
  const logoHref = this.getLogoHref();
472
742
  const logoSrc = this.getLogoSrc();
473
743
  const closeMenu = () => this.closeMenu();
474
744
  const mobileMenuActive = this.isMobileMenuActive();
475
- return (h("header", { key: '6b932d0c3dc20b53292b61fa3403e26e35182d05', class: {
745
+ return (h("div", { key: 'a769ab7a466cd90019570b8c3cf8e669b3df62c6', class: "weg-header-shell" }, h("a", { key: '43b11ec58387350df5ccf53c5a97cb94296357e5', class: "skip-to-content", href: `#${SKIP_TO_CONTENT_TARGET_ID}`, title: SKIP_TO_CONTENT_LABEL, onClick: (event) => this.handleSkipToContent(event) }, SKIP_TO_CONTENT_LABEL), h("header", { key: '84d893626e535525b235adc5997bac474d785732', class: {
476
746
  header: true,
747
+ 'header--fixed': !mobileMenuActive,
748
+ 'header--hidden': this.headerHidden && !mobileMenuActive,
477
749
  '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)))));
750
+ } }, h("div", { key: '4cfaaf18575bf56d9c09ab6fe0db4383cc9cee94', class: "header-inner" }, h("div", { key: 'b9e2d18284db956c944a4e0669a6927586b6ee32', class: "sr-only", "aria-live": "polite", "aria-atomic": "true", "data-weg-sr-live": true }), h("button", { key: 'f019b74b76c22311c97ee3fc539f396e24ec98fd', 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: 'fe6d460021d059287b487fafb96be2a79a3253c8', href: logoHref, src: logoSrc }), h("div", { key: '2e59fe4e86e183aa7ad41402baba2ff6467967c1', class: "header-actions" }, this.renderCompactAuth(mobileMenuActive ? closeMenu : undefined)), h("div", { key: '90c8861c45b344862c03a3e2d5c2b6b6f37a6174', 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
751
  }
480
752
  static get is() { return "weg-header"; }
481
753
  static get encapsulation() { return "shadow"; }
@@ -600,7 +872,8 @@ export class WegHeader {
600
872
  return {
601
873
  "resolved": {},
602
874
  "menuOpen": {},
603
- "openDropdown": {}
875
+ "openDropdown": {},
876
+ "headerHidden": {}
604
877
  };
605
878
  }
606
879
  static get events() {