diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 531997eef8..6b4a925596 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -167,6 +167,10 @@ export namespace Components { "update": () => Promise; } interface PostHeader { + /** + * Toggles the mobile navigation. + */ + "toggleMobileMenu": () => Promise; } /** * @class PostIcon - representing a stencil component @@ -457,10 +461,6 @@ export interface PostLanguageOptionCustomEvent extends CustomEvent { detail: T; target: HTMLPostLanguageOptionElement; } -export interface PostMainnavigationCustomEvent extends CustomEvent { - detail: T; - target: HTMLPostMainnavigationElement; -} export interface PostMegadropdownTriggerCustomEvent extends CustomEvent { detail: T; target: HTMLPostMegadropdownTriggerElement; @@ -623,18 +623,7 @@ declare global { prototype: HTMLPostLogoElement; new (): HTMLPostLogoElement; }; - interface HTMLPostMainnavigationElementEventMap { - "postToggle": any; - } interface HTMLPostMainnavigationElement extends Components.PostMainnavigation, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLPostMainnavigationElement, ev: PostMainnavigationCustomEvent) => 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: HTMLPostMainnavigationElement, ev: PostMainnavigationCustomEvent) => 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 HTMLPostMainnavigationElement: { prototype: HTMLPostMainnavigationElement; @@ -1024,10 +1013,6 @@ declare namespace LocalJSX { "url"?: string | URL; } interface PostMainnavigation { - /** - * Gets emitted when a user closes the main navigation on mobile - */ - "onPostToggle"?: (event: PostMainnavigationCustomEvent) => void; } interface PostMegadropdown { } diff --git a/packages/components/src/components/post-header/post-header.tsx b/packages/components/src/components/post-header/post-header.tsx index 0dc1a9a233..838b0537f9 100644 --- a/packages/components/src/components/post-header/post-header.tsx +++ b/packages/components/src/components/post-header/post-header.tsx @@ -1,4 +1,4 @@ -import { Component, h, Host, State, Element, Listen } from '@stencil/core'; +import { Component, h, Host, State, Element, Method } from '@stencil/core'; import { throttle } from 'throttle-debounce'; import { version } from '@root/package.json'; @@ -24,9 +24,14 @@ export class PostHeader { this.handleScrollEvent(); } - @Listen('postMainNavigationClosed') - handlePostMainNavigationClosed() { - this.mobileMenuExtended = false; + /** + * Toggles the mobile navigation. + */ + @Method() + async toggleMobileMenu() { + if (this.device !== 'desktop') { + this.mobileMenuExtended = !this.mobileMenuExtended; + } } private handleScrollEvent() { @@ -78,10 +83,6 @@ export class PostHeader { } } - private handleMobileMenuToggle() { - this.mobileMenuExtended = !this.mobileMenuExtended; - } - render() { const navigationClasses = ['navigation']; if (this.mobileMenuExtended) { @@ -100,7 +101,7 @@ export class PostHeader { {this.device === 'desktop' && } {this.device === 'desktop' && } -
this.handleMobileMenuToggle()} class="mobile-toggle"> +
this.toggleMobileMenu()} class="mobile-toggle">
diff --git a/packages/components/src/components/post-header/readme.md b/packages/components/src/components/post-header/readme.md index 0605234dfe..209d14f699 100644 --- a/packages/components/src/components/post-header/readme.md +++ b/packages/components/src/components/post-header/readme.md @@ -5,6 +5,19 @@ +## Methods + +### `toggleMobileMenu() => Promise` + +Toggles the mobile navigation. + +#### Returns + +Type: `Promise` + + + + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/components/src/components/post-icon/readme.md b/packages/components/src/components/post-icon/readme.md index 638a6ed157..9e6c2f48d8 100644 --- a/packages/components/src/components/post-icon/readme.md +++ b/packages/components/src/components/post-icon/readme.md @@ -27,6 +27,7 @@ some content - [post-breadcrumb-item](../post-breadcrumb-item) - [post-card-control](../post-card-control) - [post-closebutton](../post-closebutton) + - [post-mainnavigation](../post-mainnavigation) - [post-rating](../post-rating) - [post-tag](../post-tag) @@ -38,6 +39,7 @@ graph TD; post-breadcrumb-item --> post-icon post-card-control --> post-icon post-closebutton --> post-icon + post-mainnavigation --> post-icon post-rating --> post-icon post-tag --> post-icon style post-icon fill:#f9f,stroke:#333,stroke-width:4px diff --git a/packages/components/src/components/post-mainnavigation/post-mainnavigation.scss b/packages/components/src/components/post-mainnavigation/post-mainnavigation.scss index 7496c6c553..3c4f0b5b68 100644 --- a/packages/components/src/components/post-mainnavigation/post-mainnavigation.scss +++ b/packages/components/src/components/post-mainnavigation/post-mainnavigation.scss @@ -2,10 +2,13 @@ @use '@swisspost/design-system-styles/mixins/icons'; @use '@swisspost/design-system-styles/mixins/media'; @use '@swisspost/design-system-styles/mixins/utilities'; +@use '@swisspost/design-system-styles/functions/icons' as icon-fn; @use '@swisspost/design-system-styles/functions/tokens'; @use '@swisspost/design-system-styles/tokens/elements'; @use '@swisspost/design-system-styles/variables/animation'; +$nav-height: var(--post-core-dimension-56); + post-mainnavigation { // reset links and buttons post-list-item { @@ -29,15 +32,57 @@ post-mainnavigation { // desktop styles @include media.min(lg) { + + nav { + position: relative; + max-width: 100vw; + max-height: $nav-height; + overflow: hidden; + user-select: none; + + &.scroll-snap-align-start post-list-item { + scroll-snap-align: start; + } + + &.scroll-snap-align-end post-list-item { + scroll-snap-align: end; + } + } + + .scroll-left-button, + .scroll-right-button { + position: absolute; + inset-block: 0; + @include button.reset-button; + background: var(--post-core-color-brand-white); + padding-inline: var(--post-core-dimension-4); + } + + .scroll-left-button { + inset-inline-start: 0; + padding-inline-end: var(--post-core-dimension-12); + background: linear-gradient(90deg, var(--post-core-color-brand-white) 70%, transparent); + } + + .scroll-right-button { + inset-inline-end: 0; + padding-inline-start: var(--post-core-dimension-12); + background: linear-gradient(-90deg, var(--post-core-color-brand-white) 70%, transparent); + } + post-list > [role="list"] { flex-direction: row; + max-width: 100vw; + overflow: auto hidden; + scroll-snap-type: x proximity; + scroll-behavior: smooth; } post-list-item { a, button { padding-inline: var(--post-core-dimension-12); - height: var(--post-core-dimension-56); + height: $nav-height; border-block: var(--post-core-dimension-4) solid transparent; display: flex; align-items: center; diff --git a/packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx b/packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx index dc82772c1b..c0023504c4 100644 --- a/packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx +++ b/packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx @@ -1,33 +1,149 @@ -import { Component, Event, EventEmitter, Host, Listen, h } from '@stencil/core'; +import { Component, Element, Host, Listen, h, State } from '@stencil/core'; +const SCROLL_OFFSET = 50; // Amount to scroll on each scroll button press +const SCROLL_REPEAT_INTERVAL = 100; // Interval for repeated scrolling when holding down scroll button +const NAVBAR_DISABLE_DURATION = 400; // Duration to temporarily disable navbar interactions during scrolling + +/** + * @slot default - Slot for the navigation bar. + * @slot back-button - Slot for the back button (only visible on mobile). + */ @Component({ tag: 'post-mainnavigation', shadow: false, styleUrl: './post-mainnavigation.scss', }) export class PostMainnavigation { + private header: HTMLPostHeaderElement | null; + private navbar: HTMLElement | null; + private currentScrollPosition = 0; + private scrollRepeatTimer: ReturnType; + private navbarDisableTimer: ReturnType; + private observer = new MutationObserver(() => + setTimeout(() => { + this.updateScroll(); // Recalculate scroll position after DOM changes + }, 100), + ); + + @Element() host: HTMLPostMainnavigationElement; + + @State() scrollSnapAlign: 'end' | 'start' = 'end'; + @State() canScrollLeft = false; + @State() canScrollRight = false; + + /** + * Retrieves a reference to the closest 'post-header' element when the main navigation is added to the DOM. + */ + connectedCallback() { + this.header = this.host.closest('post-header'); + } + + /** + * Cleans up references and disconnects the MutationObserver when the main navigation is removed from the DOM. + */ + disconnectedCallback() { + this.header = null; + this.navbar = null; + this.observer.disconnect(); + } + /** - * Gets emitted when a user closes the main navigation on mobile - */ - @Event() postToggle: EventEmitter; - - @Listen('postToggle') - handleMegadropdownToggled(event) { - // Find next element sibling - let megalodon; - let target = event.target; - while (target !== null) { - if (target.tagName === 'POST-MEGADROPDOWN') { - megalodon = target; - break; - } - target = target.nextElementSibling; + * Finds the navbar element, sets up the MutationObserver, and scroll event listener after the main navigation is loaded. + */ + componentDidLoad() { + this.navbar = this.host.querySelector('& > nav > post-list > [role="list"]'); + if (this.navbar) { + setTimeout(() => this.updateScroll()); // Initial scroll state check + this.observer.observe(this.navbar, { childList: true }); + this.navbar.addEventListener('scroll', () => this.updateScroll()); } - if (megalodon) megalodon.toggle(event.target); } + /** + * Stops the repeated scrolling when the mouse is released. + */ + @Listen('mouseup', { target: 'window' }) + stopScrolling() { + if (this.scrollRepeatTimer) clearInterval(this.scrollRepeatTimer); + } + + /** + * Handles the back button click to toggle the mobile menu in the header. + */ private handleBackButtonClick() { - this.postToggle.emit(); + if (this.header) { + this.header.toggleMobileMenu(); + } + } + + /** + * Updates the scroll position and determines if the scroll direction has changed. + */ + private updateScroll() { + this.checkScrollability(); // Check if scroll is possible in either direction + if (!this.canScroll) return; // Exit if scrolling is not possible + + const newScrollSnap = this.currentScrollPosition > this.navbar.scrollLeft ? 'start' : 'end'; + if (this.scrollSnapAlign !== newScrollSnap) this.scrollSnapAlign = newScrollSnap; + + this.currentScrollPosition = this.navbar.scrollLeft; // Update scroll position + } + + /** + * Scrolls the navbar by a given offset (left or right) and sets up repeat scrolling at intervals. + * + * @param {number} offset - The amount to scroll (positive for right, negative for left) + */ + private scrollBy(offset: number) { + if (!this.canScroll) return; // Exit if scrolling is not possible + + this.preventNavbarInteractions(); // Temporarily disable interaction with navbar while scrolling + this.navbar.scrollTo(this.navbar.scrollLeft + offset, 0); // Perform the scroll action + + // Repeat the scrolling action at regular intervals + this.scrollRepeatTimer = setInterval(() => { + this.navbar.scrollTo(this.navbar.scrollLeft + offset, 0); + }, SCROLL_REPEAT_INTERVAL); + } + + /** + * Temporarily disables interactions with the navbar during scrolling. + * Re-enables interactions after a short duration. + */ + private preventNavbarInteractions() { + if (this.navbarDisableTimer) clearTimeout(this.navbarDisableTimer); + + // Disable pointer events (e.g., clicking) + this.navbar.style.pointerEvents = 'none'; + + // Re-enable pointer events after a brief delay + this.navbarDisableTimer = setTimeout(() => { + this.navbar.style.pointerEvents = 'initial'; + }, NAVBAR_DISABLE_DURATION); + } + + /** + * Checks if scrolling is possible in either the left or right direction based on the current scroll position. + * Updates the state of `isScrollLeftEnabled` and `isScrollRightEnabled`. + */ + private checkScrollability() { + const { scrollLeft, scrollWidth, clientWidth } = this.navbar; + + if (scrollWidth === clientWidth) { + // If content is fully visible, disable scrolling in both directions + this.canScrollLeft = this.canScrollRight = false; + } else { + // If not, enable scrolling at the start or end + this.canScrollLeft = scrollLeft !== 0; + this.canScrollRight = scrollLeft !== scrollWidth - clientWidth; + } + } + + /** + * Returns whether scrolling is enabled in either the left or right direction. + */ + private get canScroll(): boolean { + return this.canScrollLeft || this.canScrollRight; } render() { @@ -36,8 +152,26 @@ export class PostMainnavigation {
this.handleBackButtonClick()} class="back-button">
-