From 0dbb9f88d93f6c6130c304ed4d449ddea87fa6ec Mon Sep 17 00:00:00 2001 From: leagrdv Date: Tue, 31 Dec 2024 15:21:09 +0100 Subject: [PATCH 1/4] fixed megadropdown and scrollable content --- .../src/components/post-megadropdown/post-megadropdown.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/post-megadropdown/post-megadropdown.scss b/packages/components/src/components/post-megadropdown/post-megadropdown.scss index 49bfc8fe36..933ec577d5 100644 --- a/packages/components/src/components/post-megadropdown/post-megadropdown.scss +++ b/packages/components/src/components/post-megadropdown/post-megadropdown.scss @@ -41,13 +41,15 @@ post-popovercontainer { @include media.max(lg) { --post-global-header-height: 64px; --post-main-header-height: 48px; - position: absolute; + position: fixed; top: var(--post-header-height) !important; bottom: 0; left: 0; width: 100%; height: auto; + max-height: calc(100vh - var(--header-height)); border-top: unset; + overflow: auto; &.slide-in { animation: slide-in; From aae3204228128d4fb5e08c7eea0cbe3c0c9d9848 Mon Sep 17 00:00:00 2001 From: leagrdv Date: Tue, 7 Jan 2025 08:42:23 +0100 Subject: [PATCH 2/4] not close megadropdown when click outside on mobile --- packages/components/src/components.d.ts | 29 +++++++++++++++++++ .../components/post-header/post-header.tsx | 21 ++++++++++++-- .../src/components/post-header/readme.md | 7 +++++ .../post-megadropdown/post-megadropdown.tsx | 17 ++++++++++- .../post-popovercontainer.tsx | 8 +++-- .../post-popovercontainer/readme.md | 11 +++---- 6 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 76172f1f38..b581606654 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -7,10 +7,12 @@ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { HeadingLevel } from "./types/index"; import { BannerType } from "./components/post-banner/banner-types"; +import { DEVICE_SIZE } from "./components/post-header/post-header"; import { SwitchVariant } from "./components/post-language-switch/switch-variants"; import { Placement } from "@floating-ui/dom"; export { HeadingLevel } from "./types/index"; export { BannerType } from "./components/post-banner/banner-types"; +export { DEVICE_SIZE } from "./components/post-header/post-header"; export { SwitchVariant } from "./components/post-language-switch/switch-variants"; export { Placement } from "@floating-ui/dom"; export namespace Components { @@ -375,6 +377,10 @@ export namespace Components { * Programmatically hide this tooltip */ "hide": () => Promise; + /** + * Whether or not the popover should close when user clicks outside of it + */ + "manualClose": boolean; /** * Defines the placement of the tooltip according to the floating-ui options available at https://floating-ui.com/docs/computePosition#placement. Tooltips are automatically flipped to the opposite side if there is not enough available space and are shifted towards the viewport if they would overlap edge boundaries. */ @@ -494,6 +500,10 @@ export interface PostCollapsibleCustomEvent extends CustomEvent { detail: T; target: HTMLPostCollapsibleElement; } +export interface PostHeaderCustomEvent extends CustomEvent { + detail: T; + target: HTMLPostHeaderElement; +} export interface PostLanguageOptionCustomEvent extends CustomEvent { detail: T; target: HTMLPostLanguageOptionElement; @@ -632,7 +642,18 @@ declare global { prototype: HTMLPostFooterElement; new (): HTMLPostFooterElement; }; + interface HTMLPostHeaderElementEventMap { + "postUpdateDevice": DEVICE_SIZE; + } interface HTMLPostHeaderElement extends Components.PostHeader, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLPostHeaderElement, ev: PostHeaderCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLPostHeaderElement, ev: PostHeaderCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; } var HTMLPostHeaderElement: { prototype: HTMLPostHeaderElement; @@ -1040,6 +1061,10 @@ declare namespace LocalJSX { "label": string; } interface PostHeader { + /** + * An event emitted when the device has changed + */ + "onPostUpdateDevice"?: (event: PostHeaderCustomEvent) => void; } /** * @class PostIcon - representing a stencil component @@ -1191,6 +1216,10 @@ declare namespace LocalJSX { * Gap between the edge of the page and the popover */ "edgeGap"?: number; + /** + * Whether or not the popover should close when user clicks outside of it + */ + "manualClose"?: boolean; /** * Fires whenever the popover gets shown or hidden, passing the new state in event.details as a boolean */ diff --git a/packages/components/src/components/post-header/post-header.tsx b/packages/components/src/components/post-header/post-header.tsx index 3d0cf0487d..a9b72c845c 100644 --- a/packages/components/src/components/post-header/post-header.tsx +++ b/packages/components/src/components/post-header/post-header.tsx @@ -1,10 +1,20 @@ -import { Component, h, Host, State, Element, Method, Watch } from '@stencil/core'; +import { + Component, + h, + Host, + State, + Element, + Method, + Watch, + Event, + EventEmitter, +} from '@stencil/core'; import { throttle } from 'throttle-debounce'; import { version } from '@root/package.json'; import { SwitchVariant } from '@/components'; import { slideDown, slideUp } from '@/animations/slide'; -type DEVICE_SIZE = 'mobile' | 'tablet' | 'desktop' | null; +export type DEVICE_SIZE = 'mobile' | 'tablet' | 'desktop' | null; @Component({ tag: 'post-header', @@ -36,6 +46,11 @@ export class PostHeader { document.body.style.overflow = isMobileMenuExtended ? 'hidden' : ''; } + /** + * An event emitted when the device has changed + */ + @Event() postUpdateDevice: EventEmitter; + /** * Toggles the mobile navigation. */ @@ -107,6 +122,8 @@ export class PostHeader { newDevice = 'mobile'; } + this.postUpdateDevice.emit(newDevice); + // Close any open mobile menu if (newDevice === 'desktop' && this.mobileMenuExtended) { this.toggleMobileMenu(); diff --git a/packages/components/src/components/post-header/readme.md b/packages/components/src/components/post-header/readme.md index 209d14f699..1201790da4 100644 --- a/packages/components/src/components/post-header/readme.md +++ b/packages/components/src/components/post-header/readme.md @@ -5,6 +5,13 @@ +## Events + +| Event | Description | Type | +| ------------------ | -------------------------------------------- | ------------------------------------------------ | +| `postUpdateDevice` | An event emitted when the device has changed | `CustomEvent<"desktop" \| "mobile" \| "tablet">` | + + ## Methods ### `toggleMobileMenu() => Promise` diff --git a/packages/components/src/components/post-megadropdown/post-megadropdown.tsx b/packages/components/src/components/post-megadropdown/post-megadropdown.tsx index 0ccbc975d6..1d94ac4e60 100644 --- a/packages/components/src/components/post-megadropdown/post-megadropdown.tsx +++ b/packages/components/src/components/post-megadropdown/post-megadropdown.tsx @@ -1,3 +1,4 @@ +import { DEVICE_SIZE } from '@/components'; import { Component, Element, Event, EventEmitter, h, Host, Method, State } from '@stencil/core'; @Component({ @@ -7,6 +8,9 @@ import { Component, Element, Event, EventEmitter, h, Host, Method, State } from }) export class PostMegadropdown { private popoverRef: HTMLPostPopovercontainerElement; + private header: HTMLPostHeaderElement | null; + + @State() device: DEVICE_SIZE; @Element() host: HTMLPostMegadropdownElement; @@ -72,6 +76,16 @@ export class PostMegadropdown { } } + connectedCallback() { + this.header = this.host.closest('post-header'); + if (this.header) { + this.header.addEventListener( + 'postUpdateDevice', + (event: CustomEvent) => (this.device = event.detail), + ); + } + } + private handleBackButtonClick() { this.animationClass = 'slide-out'; } @@ -82,7 +96,7 @@ export class PostMegadropdown { private handleFocusout(event: FocusEvent) { const relatedTarget = event.relatedTarget as HTMLElement; - const megadropdown= this.popoverRef.querySelector('.megadropdown'); + const megadropdown = this.popoverRef.querySelector('.megadropdown'); if (!megadropdown.contains(relatedTarget)) { this.hide(); } @@ -93,6 +107,7 @@ export class PostMegadropdown { (this.popoverRef = el)} diff --git a/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx b/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx index cb3e641c92..a601552a05 100644 --- a/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx +++ b/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx @@ -52,6 +52,11 @@ export class PostPopovercontainer { */ @Event() postToggle: EventEmitter; + /** + * Whether or not the popover should close when user clicks outside of it + */ + @Prop() manualClose: boolean = false; + /** * Defines the placement of the tooltip according to the floating-ui options available at https://floating-ui.com/docs/computePosition#placement. * Tooltips are automatically flipped to the opposite side if there is not enough available space and are shifted @@ -70,7 +75,6 @@ export class PostPopovercontainer { @Prop() readonly arrow?: boolean = false; componentDidLoad() { - this.host.setAttribute('popover', ''); this.host.addEventListener('beforetoggle', this.handleToggle.bind(this)); } @@ -213,7 +217,7 @@ export class PostPopovercontainer { render() { return ( - + {this.arrow && ( Date: Tue, 7 Jan 2025 14:49:09 +0100 Subject: [PATCH 3/4] stretch mainnavigation to full height on mobile --- .../src/components/post-header/post-header.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/components/src/components/post-header/post-header.scss b/packages/components/src/components/post-header/post-header.scss index 2b22afd3ed..57ee6f81cb 100644 --- a/packages/components/src/components/post-header/post-header.scss +++ b/packages/components/src/components/post-header/post-header.scss @@ -173,6 +173,16 @@ slot[name='post-logo'] { } } + .navigation.extended { + height: calc(100vh - var(--header-height)); + display: flex; + flex-direction: column; + + ::slotted(post-mainnavigation) { + flex-grow: 1; + } + } + ::slotted(post-mainnavigation) { background-color: var(--post-core-color-sandgrey-002); gap: var(--post-core-dimension-32); From 2f1fe0ee84cbcb4930863b56ab6d01504a71b696 Mon Sep 17 00:00:00 2001 From: leagrdv Date: Tue, 7 Jan 2025 16:05:38 +0100 Subject: [PATCH 4/4] trap focus within dropdowns --- .../components/post-header/post-header.tsx | 59 ++++++++++++++++++- .../post-megadropdown/post-megadropdown.tsx | 33 +++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/post-header/post-header.tsx b/packages/components/src/components/post-header/post-header.tsx index a9b72c845c..2c29db3f4d 100644 --- a/packages/components/src/components/post-header/post-header.tsx +++ b/packages/components/src/components/post-header/post-header.tsx @@ -13,6 +13,7 @@ import { throttle } from 'throttle-debounce'; import { version } from '@root/package.json'; import { SwitchVariant } from '@/components'; import { slideDown, slideUp } from '@/animations/slide'; +import { getFocusableChildren } from '@/utils/get-focusable-children'; export type DEVICE_SIZE = 'mobile' | 'tablet' | 'desktop' | null; @@ -22,6 +23,8 @@ export type DEVICE_SIZE = 'mobile' | 'tablet' | 'desktop' | null; styleUrl: './post-header.scss', }) export class PostHeader { + private firstFocusableEl: HTMLElement | null; + private lastFocusableEl: HTMLElement | null; private scrollParent = null; private mobileMenu: HTMLElement; private mobileMenuAnimation: Animation; @@ -34,6 +37,7 @@ export class PostHeader { window.addEventListener('resize', this.throttledResize, { passive: true }); this.handleResize(); this.handleScrollEvent(); + this.getFocusableElements(); } @Element() host: HTMLPostHeaderElement; @@ -44,6 +48,16 @@ export class PostHeader { @Watch('mobileMenuExtended') frozeBody(isMobileMenuExtended: boolean) { document.body.style.overflow = isMobileMenuExtended ? 'hidden' : ''; + + if (isMobileMenuExtended) { + this.host.addEventListener('keydown', e => { + this.keyboardHandler(e); + }); + } else { + this.host.removeEventListener('keydown', e => { + this.keyboardHandler(e); + }); + } } /** @@ -72,6 +86,47 @@ export class PostHeader { if (!this.mobileMenuExtended) await this.mobileMenuAnimation.finished; } + // Get all the focusable elements in the post-header mobile menu + private getFocusableElements() { + // Get elements in the correct order (different as the DOM order) + const focusableEls = [ + ...Array.from(this.host.querySelectorAll('.list-inline:not([slot="meta-navigation"]) > li')), + ...Array.from( + this.host.querySelectorAll( + 'nav > post-list > div > post-list-item, post-mainnavigation > .back-button, post-megadropdown-trigger', + ), + ), + ...Array.from( + this.host.querySelectorAll( + '.list-inline[slot="meta-navigation"] > li, post-language-option', + ), + ), + ]; + + // Add the main toggle menu button to the list of focusable children + const focusableChildren = [ + this.host.querySelector('post-togglebutton'), + ...focusableEls.flatMap(el => Array.from(getFocusableChildren(el))), + ]; + + this.firstFocusableEl = focusableChildren[0]; + this.lastFocusableEl = focusableChildren[focusableChildren.length - 1]; + } + + private keyboardHandler(e: KeyboardEvent) { + if (e.key === 'Tab') { + if (e.shiftKey && document.activeElement === this.firstFocusableEl) { + // If back tab (Tab + Shift) and first element is focused, focus goes to the last element of the megadropdown + e.preventDefault(); + this.lastFocusableEl.focus(); + } else if (!e.shiftKey && document.activeElement === this.lastFocusableEl) { + // If Tab and last element is focused, focus goes back to the first element of the megadropdown + e.preventDefault(); + this.firstFocusableEl.focus(); + } + } + } + private handleScrollEvent() { // Credits: "https://github.com/qeremy/so/blob/master/so.dom.js#L426" const st = Math.max( @@ -122,8 +177,6 @@ export class PostHeader { newDevice = 'mobile'; } - this.postUpdateDevice.emit(newDevice); - // Close any open mobile menu if (newDevice === 'desktop' && this.mobileMenuExtended) { this.toggleMobileMenu(); @@ -133,6 +186,8 @@ export class PostHeader { // Apply only on change for doing work only when necessary if (newDevice !== previousDevice) { this.device = newDevice; + + this.postUpdateDevice.emit(this.device); window.requestAnimationFrame(() => { this.switchLanguageSwitchMode(); }); diff --git a/packages/components/src/components/post-megadropdown/post-megadropdown.tsx b/packages/components/src/components/post-megadropdown/post-megadropdown.tsx index 1d94ac4e60..8ae77340fa 100644 --- a/packages/components/src/components/post-megadropdown/post-megadropdown.tsx +++ b/packages/components/src/components/post-megadropdown/post-megadropdown.tsx @@ -1,4 +1,5 @@ import { DEVICE_SIZE } from '@/components'; +import { getFocusableChildren } from '@/utils/get-focusable-children'; import { Component, Element, Event, EventEmitter, h, Host, Method, State } from '@stencil/core'; @Component({ @@ -10,6 +11,9 @@ export class PostMegadropdown { private popoverRef: HTMLPostPopovercontainerElement; private header: HTMLPostHeaderElement | null; + private firstFocusableEl: HTMLElement | null; + private lastFocusableEl: HTMLElement | null; + @State() device: DEVICE_SIZE; @Element() host: HTMLPostMegadropdownElement; @@ -42,6 +46,10 @@ export class PostMegadropdown { }); } + componentWillRender() { + this.getFocusableElements(); + } + /** * Toggles the dropdown visibility based on its current state. */ @@ -60,6 +68,7 @@ export class PostMegadropdown { if (this.popoverRef) { await this.popoverRef.show(target); this.animationClass = 'slide-in'; + this.host.addEventListener('keydown', e => this.keyboardHandler(e)); } else { console.error('show: popoverRef is null or undefined'); } @@ -70,6 +79,7 @@ export class PostMegadropdown { */ private hide() { if (this.popoverRef) { + this.host.removeEventListener('keydown', e => this.keyboardHandler(e)); this.popoverRef.hide(); } else { console.error('hide: popoverRef is null or undefined'); @@ -102,6 +112,29 @@ export class PostMegadropdown { } } + private getFocusableElements() { + const focusableEls = Array.from(this.host.querySelectorAll('post-list-item, h3, .back-button')); + const focusableChildren = focusableEls.flatMap(el => Array.from(getFocusableChildren(el))); + + this.firstFocusableEl = focusableChildren[0]; + this.lastFocusableEl = focusableChildren[focusableChildren.length - 1]; + } + + // Loop through the focusable children + private keyboardHandler(e: KeyboardEvent) { + if (e.key === 'Tab' && this.device !== 'desktop') { + if (e.shiftKey && document.activeElement === this.firstFocusableEl) { + // If back tab (TAB + Shift) and first element is focused, focus goes to the last element of the megadropdown + e.preventDefault(); + this.lastFocusableEl.focus(); + } else if (!e.shiftKey && document.activeElement === this.lastFocusableEl) { + // If TAB and last element is focused, focus goes back to the first element of the megadropdown + e.preventDefault(); + this.firstFocusableEl.focus(); + } + } + } + render() { return (