From 6aa530ed3054a1cf10eb9ad41cd5bcf74c0ab865 Mon Sep 17 00:00:00 2001 From: Philipp Gfeller <1659006+gfellerph@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:35:47 +0100 Subject: [PATCH] feat(components): post-header (#3837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a provisional post-header component with some basic functionality in place. This component is not finished in this state and is intended to provide a skeleton for the other header components so integration is easier to test. --------- Co-authored-by: Alizé Debray <33580481+alizedebray@users.noreply.github.com> --- .changeset/popular-mirrors-cross.md | 7 + packages/components/package.json | 1 + packages/components/src/components.d.ts | 108 ++++++++++++++ .../components/post-header/post-header.scss | 136 ++++++++++++++++++ .../components/post-header/post-header.tsx | 133 +++++++++++++++++ .../src/components/post-header/readme.md | 10 ++ .../src/components/post-logo/post-logo.scss | 1 - .../src/components/post-logo/post-logo.tsx | 2 +- .../post-mainnavigation.scss | 60 ++++++++ .../post-mainnavigation.tsx | 45 ++++++ .../components/post-mainnavigation/readme.md | 17 +++ .../post-megadropdown-trigger.scss | 3 + .../post-megadropdown-trigger.tsx | 30 ++++ .../post-megadropdown-trigger/readme.md | 17 +++ .../post-megadropdown/post-megadropdown.scss | 71 +++++++++ .../post-megadropdown/post-megadropdown.tsx | 59 ++++++++ .../components/post-megadropdown/readme.md | 69 +++++++++ .../post-popovercontainer/readme.md | 2 + packages/components/src/index.html | 95 +++++++++++- packages/components/src/index.ts | 4 + packages/styles/src/elements/body.scss | 1 + pnpm-lock.yaml | 3 + 22 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 .changeset/popular-mirrors-cross.md create mode 100644 packages/components/src/components/post-header/post-header.scss create mode 100644 packages/components/src/components/post-header/post-header.tsx create mode 100644 packages/components/src/components/post-header/readme.md create mode 100644 packages/components/src/components/post-mainnavigation/post-mainnavigation.scss create mode 100644 packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx create mode 100644 packages/components/src/components/post-mainnavigation/readme.md create mode 100644 packages/components/src/components/post-megadropdown-trigger/post-megadropdown-trigger.scss create mode 100644 packages/components/src/components/post-megadropdown-trigger/post-megadropdown-trigger.tsx create mode 100644 packages/components/src/components/post-megadropdown-trigger/readme.md create mode 100644 packages/components/src/components/post-megadropdown/post-megadropdown.scss create mode 100644 packages/components/src/components/post-megadropdown/post-megadropdown.tsx create mode 100644 packages/components/src/components/post-megadropdown/readme.md diff --git a/.changeset/popular-mirrors-cross.md b/.changeset/popular-mirrors-cross.md new file mode 100644 index 0000000000..a047d3d7cc --- /dev/null +++ b/.changeset/popular-mirrors-cross.md @@ -0,0 +1,7 @@ +--- +'@swisspost/design-system-components': minor +'@swisspost/design-system-components-angular': minor +'@swisspost/design-system-components-react': minor +--- + +Added a provisional post-header component with some basic functionality in place. This component is not finished in this state. diff --git a/packages/components/package.json b/packages/components/package.json index 221a556679..253f6811f7 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -65,6 +65,7 @@ "rimraf": "6.0.1", "rollup-plugin-postcss": "4.0.2", "sass": "1.78.0", + "throttle-debounce": "5.0.2", "ts-jest": "29.2.4", "typescript": "5.5.4" }, diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 5a64d0884d..46ee23749c 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -170,6 +170,8 @@ export namespace Components { */ "update": () => Promise; } + interface PostHeader { + } /** * @class PostIcon - representing a stencil component */ @@ -243,6 +245,30 @@ export namespace Components { */ "url": string | URL; } + interface PostMainnavigation { + } + interface PostMegadropdown { + /** + * Hide megadropdown + * @returns boolean + */ + "hide": () => Promise; + /** + * Show megadropdown + * @param element HTMLElement + * @returns boolean + */ + "show": (element: HTMLElement) => Promise; + /** + * Toggle megadropdown + * @param element HTMLElement + * @param force boolean + * @returns boolean + */ + "toggle": (element: HTMLElement, force?: boolean) => Promise; + } + interface PostMegadropdownTrigger { + } interface PostMenu { /** * Hides the popover menu and restores focus to the previously focused element. @@ -429,6 +455,14 @@ 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; +} export interface PostMenuCustomEvent extends CustomEvent { detail: T; target: HTMLPostMenuElement; @@ -537,6 +571,12 @@ declare global { prototype: HTMLPostCollapsibleTriggerElement; new (): HTMLPostCollapsibleTriggerElement; }; + interface HTMLPostHeaderElement extends Components.PostHeader, HTMLStencilElement { + } + var HTMLPostHeaderElement: { + prototype: HTMLPostHeaderElement; + new (): HTMLPostHeaderElement; + }; /** * @class PostIcon - representing a stencil component */ @@ -581,6 +621,46 @@ 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; + new (): HTMLPostMainnavigationElement; + }; + interface HTMLPostMegadropdownElement extends Components.PostMegadropdown, HTMLStencilElement { + } + var HTMLPostMegadropdownElement: { + prototype: HTMLPostMegadropdownElement; + new (): HTMLPostMegadropdownElement; + }; + interface HTMLPostMegadropdownTriggerElementEventMap { + "postToggle": any; + } + interface HTMLPostMegadropdownTriggerElement extends Components.PostMegadropdownTrigger, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLPostMegadropdownTriggerElement, ev: PostMegadropdownTriggerCustomEvent) => 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: HTMLPostMegadropdownTriggerElement, ev: PostMegadropdownTriggerCustomEvent) => 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 HTMLPostMegadropdownTriggerElement: { + prototype: HTMLPostMegadropdownTriggerElement; + new (): HTMLPostMegadropdownTriggerElement; + }; interface HTMLPostMenuElementEventMap { "toggleMenu": boolean; } @@ -702,11 +782,15 @@ declare global { "post-closebutton": HTMLPostClosebuttonElement; "post-collapsible": HTMLPostCollapsibleElement; "post-collapsible-trigger": HTMLPostCollapsibleTriggerElement; + "post-header": HTMLPostHeaderElement; "post-icon": HTMLPostIconElement; "post-language-option": HTMLPostLanguageOptionElement; "post-list": HTMLPostListElement; "post-list-item": HTMLPostListItemElement; "post-logo": HTMLPostLogoElement; + "post-mainnavigation": HTMLPostMainnavigationElement; + "post-megadropdown": HTMLPostMegadropdownElement; + "post-megadropdown-trigger": HTMLPostMegadropdownTriggerElement; "post-menu": HTMLPostMenuElement; "post-menu-item": HTMLPostMenuItemElement; "post-menu-trigger": HTMLPostMenuTriggerElement; @@ -859,6 +943,8 @@ declare namespace LocalJSX { */ "for"?: string; } + interface PostHeader { + } /** * @class PostIcon - representing a stencil component */ @@ -932,6 +1018,20 @@ 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 { + } + interface PostMegadropdownTrigger { + /** + * Emits after each toggle + */ + "onPostToggle"?: (event: PostMegadropdownTriggerCustomEvent) => void; + } interface PostMenu { /** * Emits when the menu is shown or hidden. The event payload is a boolean: `true` when the menu was opened, `false` when it was closed. @@ -1068,11 +1168,15 @@ declare namespace LocalJSX { "post-closebutton": PostClosebutton; "post-collapsible": PostCollapsible; "post-collapsible-trigger": PostCollapsibleTrigger; + "post-header": PostHeader; "post-icon": PostIcon; "post-language-option": PostLanguageOption; "post-list": PostList; "post-list-item": PostListItem; "post-logo": PostLogo; + "post-mainnavigation": PostMainnavigation; + "post-megadropdown": PostMegadropdown; + "post-megadropdown-trigger": PostMegadropdownTrigger; "post-menu": PostMenu; "post-menu-item": PostMenuItem; "post-menu-trigger": PostMenuTrigger; @@ -1102,6 +1206,7 @@ declare module "@stencil/core" { "post-closebutton": LocalJSX.PostClosebutton & JSXBase.HTMLAttributes; "post-collapsible": LocalJSX.PostCollapsible & JSXBase.HTMLAttributes; "post-collapsible-trigger": LocalJSX.PostCollapsibleTrigger & JSXBase.HTMLAttributes; + "post-header": LocalJSX.PostHeader & JSXBase.HTMLAttributes; /** * @class PostIcon - representing a stencil component */ @@ -1110,6 +1215,9 @@ declare module "@stencil/core" { "post-list": LocalJSX.PostList & JSXBase.HTMLAttributes; "post-list-item": LocalJSX.PostListItem & JSXBase.HTMLAttributes; "post-logo": LocalJSX.PostLogo & JSXBase.HTMLAttributes; + "post-mainnavigation": LocalJSX.PostMainnavigation & JSXBase.HTMLAttributes; + "post-megadropdown": LocalJSX.PostMegadropdown & JSXBase.HTMLAttributes; + "post-megadropdown-trigger": LocalJSX.PostMegadropdownTrigger & JSXBase.HTMLAttributes; "post-menu": LocalJSX.PostMenu & JSXBase.HTMLAttributes; "post-menu-item": LocalJSX.PostMenuItem & JSXBase.HTMLAttributes; "post-menu-trigger": LocalJSX.PostMenuTrigger & JSXBase.HTMLAttributes; diff --git a/packages/components/src/components/post-header/post-header.scss b/packages/components/src/components/post-header/post-header.scss new file mode 100644 index 0000000000..9703e1e3ea --- /dev/null +++ b/packages/components/src/components/post-header/post-header.scss @@ -0,0 +1,136 @@ +@use '@swisspost/design-system-styles/mixins/media'; + +*, +::before, +::after { + box-sizing: border-box; +} + +:host { + --global-header-height: 72px; + --main-header-height: 56px; + --header-height: calc(var(--global-header-height) + var(--main-header-height)); + + @include media.max(lg) { + --global-header-height: 64px; + } +} + +.d-flex { + display: flex; +} + +.space-between { + justify-content: space-between; +} + +.global-header { + background-color: #ffcc00; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + padding-inline-start: 4px; + padding-inline-end: 12px; + + height: var(--global-header-height); + + @include media.max(lg) { + top: 0; + } + + @include media.min(lg) { + top: calc((var(--global-header-height) - 24px) * -1); + } +} + +slot[name='post-logo'] { + align-self: flex-end; +} + +.global-sub { + display: flex; + align-items: center; + gap: 2rem; + height: var(--global-header-height); +} + +.align-end { + align-items: flex-end; +} + +.logo { + flex: 1 0 auto; + height: var(--global-header-height); + width: var(--global-header-height); + min-height: 24px; + align-self: flex-end; + + @include media.min(lg) { + height: calc(var(--global-header-height) - var(--header-scroll-top)); + } +} + +::slotted(ul) { + margin-block: 0; + list-style: none; + display: flex; + padding-left: 0; + gap: 1rem; +} + +.title-header, +.main-navigation { + display: flex; + align-items: center; + padding-inline: 12px; + background: white; +} + +.title-header { + height: var(--main-header-height); + display: flex; + align-items: center; + + @include media.max(lg) { + border-bottom: 1px solid black; + } +} +:host(:not(:has([slot='title']))) .title-header { + display: none; +} + +::slotted(h1) { + margin: 0 !important; + font-size: 28px !important; +} + +.main-navigation { + position: sticky; + top: 24px; + height: var(--main-header-height); + + @include media.min(lg) { + border-bottom: 1px solid black; + } + + @include media.max(lg) { + display: none; + position: absolute; + top: var(--header-height); + bottom: 0; + width: 100%; + background-color: white; + height: auto; + + &.extended { + display: block; + } + } +} + +.mobile-toggle { + @include media.min(lg) { + display: none; + } +} diff --git a/packages/components/src/components/post-header/post-header.tsx b/packages/components/src/components/post-header/post-header.tsx new file mode 100644 index 0000000000..0461f5d135 --- /dev/null +++ b/packages/components/src/components/post-header/post-header.tsx @@ -0,0 +1,133 @@ +import { Component, h, Host, State, Element, Listen } from '@stencil/core'; +import { throttle } from 'throttle-debounce'; +import { version } from '@root/package.json'; + +@Component({ + tag: 'post-header', + shadow: true, + styleUrl: './post-header.scss', +}) +export class PostHeader { + @Element() host: HTMLPostHeaderElement; + @State() device: 'mobile' | 'tablet' | 'desktop' = null; + @State() mobileMenuExtended: boolean = false; + + private scrollParent = null; + private throttledScroll = () => this.handleScrollEvent(); + private throttledResize = throttle(50, () => this.handleResize()); + + componentWillRender() { + this.scrollParent = this.getScrollParent(this.host); + this.scrollParent.addEventListener('scroll', this.throttledScroll, { passive: true }); + window.addEventListener('resize', this.throttledResize, { passive: true }); + this.handleResize(); + this.handleScrollEvent(); + } + + @Listen('postMainNavigationClosed') + handlePostMainNavigationClosed() { + this.mobileMenuExtended = false; + } + + private handleScrollEvent() { + // Credits: "https://github.com/qeremy/so/blob/master/so.dom.js#L426" + const st = Math.max( + 0, + this.scrollParent instanceof Document + ? this.scrollParent.documentElement.scrollTop + : this.scrollParent.scrollTop, + ); + + this.host.style.setProperty('--header-scroll-top', `${st}px`); + } + + private getScrollParent(node: Element): Element | Document { + let currentParent = node.parentElement; + while (currentParent) { + if (currentParent.nodeName === 'BODY') { + return document; + } + if (this.isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document; + } + + private isScrollable(node: Element) { + if (!(node instanceof HTMLElement || node instanceof SVGElement)) { + return false; + } + const style = getComputedStyle(node); + return ['overflow', 'overflow-x', 'overflow-y'].some(propertyName => { + const value = style.getPropertyValue(propertyName); + return value === 'auto' || value === 'scroll'; + }); + } + + private handleResize() { + const width = window?.innerWidth; + if (width >= 1024) { + this.device = 'desktop'; + this.mobileMenuExtended = false; // Close any open mobile menu + } else if (width >= 600) { + this.device = 'tablet'; + } else { + this.device = 'mobile'; + } + } + + private handleMobileMenuToggle() { + this.mobileMenuExtended = !this.mobileMenuExtended; + } + + render() { + const mainNavClasses = ['main-navigation']; + if (this.mobileMenuExtended) { + mainNavClasses.push('extended'); + } + + return ( + + + +
+ +
+ + +
+
+ +
+ {(this.device === 'mobile' || this.device === 'tablet') && ( + + )} + + {(this.device === 'mobile' || this.device === 'tablet') && ( + + )} + {(this.device === 'mobile' || this.device === 'tablet') && ( + + )} +
+
+ ); + } +} diff --git a/packages/components/src/components/post-header/readme.md b/packages/components/src/components/post-header/readme.md new file mode 100644 index 0000000000..0605234dfe --- /dev/null +++ b/packages/components/src/components/post-header/readme.md @@ -0,0 +1,10 @@ +# post-header + + + + + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/components/src/components/post-logo/post-logo.scss b/packages/components/src/components/post-logo/post-logo.scss index e03e4a6e88..ef7dfcf04e 100644 --- a/packages/components/src/components/post-logo/post-logo.scss +++ b/packages/components/src/components/post-logo/post-logo.scss @@ -14,4 +14,3 @@ .description { @include utilities.visuallyhidden; } - diff --git a/packages/components/src/components/post-logo/post-logo.tsx b/packages/components/src/components/post-logo/post-logo.tsx index d5ba21c1da..313cb0389d 100644 --- a/packages/components/src/components/post-logo/post-logo.tsx +++ b/packages/components/src/components/post-logo/post-logo.tsx @@ -41,7 +41,7 @@ export class PostLogo { const LogoTag = logoLink ? 'a' : 'span'; return ( - +