From 0a057e38a94275822bc2eb4ff97eabbd264dd982 Mon Sep 17 00:00:00 2001 From: Tuukka Turu <tuukka.turu@druid.fi> Date: Mon, 26 Oct 2020 14:04:38 +0200 Subject: [PATCH] Nxstage 610 breadcrumb --- src/components.d.ts | 20 ++ .../hy-breadcrumbs/hy-breadcrumbs.scss | 304 +++++++++++++++++ .../hy-breadcrumbs/hy-breadcrumbs.tsx | 305 ++++++++++++++++++ src/components/hy-breadcrumbs/readme.md | 29 ++ src/components/icon/Home.tsx | 4 +- src/components/icon/readme.md | 2 + src/utils/utils.ts | 5 + 7 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 src/components/hy-breadcrumbs/hy-breadcrumbs.scss create mode 100644 src/components/hy-breadcrumbs/hy-breadcrumbs.tsx create mode 100644 src/components/hy-breadcrumbs/readme.md diff --git a/src/components.d.ts b/src/components.d.ts index cbbd32a3..e3f7fb4a 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -5,7 +5,9 @@ * It contains typing information for all components that exist in this project. */ import {HTMLStencilElement, JSXBase} from '@stencil/core/internal'; +import {Breadcrumb} from './components/hy-breadcrumbs/hy-breadcrumbs'; import { + BreadcrumbVariants, ButtonVariants, ColorVariant, CtaLinkButtonVariants, @@ -129,6 +131,11 @@ export namespace Components { */ wrap: boolean; } + interface HyBreadcrumbs { + dataItems: Breadcrumb[] | string; + headerstyle: string; + variant: BreadcrumbVariants; + } interface HyButton { /** * Aria label for the element @@ -557,6 +564,11 @@ declare global { prototype: HTMLHyBoxContainerElement; new (): HTMLHyBoxContainerElement; }; + interface HTMLHyBreadcrumbsElement extends Components.HyBreadcrumbs, HTMLStencilElement {} + var HTMLHyBreadcrumbsElement: { + prototype: HTMLHyBreadcrumbsElement; + new (): HTMLHyBreadcrumbsElement; + }; interface HTMLHyButtonElement extends Components.HyButton, HTMLStencilElement {} var HTMLHyButtonElement: { prototype: HTMLHyButtonElement; @@ -827,6 +839,7 @@ declare global { 'hy-baseline': HTMLHyBaselineElement; 'hy-box': HTMLHyBoxElement; 'hy-box-container': HTMLHyBoxContainerElement; + 'hy-breadcrumbs': HTMLHyBreadcrumbsElement; 'hy-button': HTMLHyButtonElement; 'hy-cta-button': HTMLHyCtaButtonElement; 'hy-cta-link': HTMLHyCtaLinkElement; @@ -971,6 +984,11 @@ declare namespace LocalJSX { */ wrap?: boolean; } + interface HyBreadcrumbs { + dataItems?: Breadcrumb[] | string; + headerstyle?: string; + variant?: BreadcrumbVariants; + } interface HyButton { /** * Aria label for the element @@ -1368,6 +1386,7 @@ declare namespace LocalJSX { 'hy-baseline': HyBaseline; 'hy-box': HyBox; 'hy-box-container': HyBoxContainer; + 'hy-breadcrumbs': HyBreadcrumbs; 'hy-button': HyButton; 'hy-cta-button': HyCtaButton; 'hy-cta-link': HyCtaLink; @@ -1435,6 +1454,7 @@ declare module '@stencil/core' { 'hy-baseline': LocalJSX.HyBaseline & JSXBase.HTMLAttributes<HTMLHyBaselineElement>; 'hy-box': LocalJSX.HyBox & JSXBase.HTMLAttributes<HTMLHyBoxElement>; 'hy-box-container': LocalJSX.HyBoxContainer & JSXBase.HTMLAttributes<HTMLHyBoxContainerElement>; + 'hy-breadcrumbs': LocalJSX.HyBreadcrumbs & JSXBase.HTMLAttributes<HTMLHyBreadcrumbsElement>; 'hy-button': LocalJSX.HyButton & JSXBase.HTMLAttributes<HTMLHyButtonElement>; 'hy-cta-button': LocalJSX.HyCtaButton & JSXBase.HTMLAttributes<HTMLHyCtaButtonElement>; 'hy-cta-link': LocalJSX.HyCtaLink & JSXBase.HTMLAttributes<HTMLHyCtaLinkElement>; diff --git a/src/components/hy-breadcrumbs/hy-breadcrumbs.scss b/src/components/hy-breadcrumbs/hy-breadcrumbs.scss new file mode 100644 index 00000000..6c539421 --- /dev/null +++ b/src/components/hy-breadcrumbs/hy-breadcrumbs.scss @@ -0,0 +1,304 @@ +:host { + display: block; +} + +// Default variant +.hy-breadcrumbs { + display: inline-block; + width: auto; + + &.is-condensed { + width: 100%; + } + + ol { + margin: 0; + padding: 0; + } + + .breadcrumb-container { + color: var(--grayscale-dark); + display: flex; + flex-wrap: nowrap; + font-family: var(--main-font-family); + list-style-type: none; + margin: 0; + min-height: 72px; + overflow: hidden; + padding: 0; + + @include breakpoint($narrow) { + min-height: 76px; + } + @include breakpoint($wide) { + min-height: 86px; + } + @include breakpoint($extrawide) { + min-height: 94px; + } + } + + .breadcrumb-item { + display: flex; + flex-direction: row; + align-items: center; + flex: 0 0 auto; + + a { + color: var(--brand-main-light); + display: flex; + flex-direction: row; + align-items: center; + margin-right: 20px; + position: relative; + text-decoration: none; + + @include breakpoint($medium) { + margin-right: 28px; + } + + @include breakpoint($wide) { + margin-right: 30px; + } + + .breadcrumb-item-caret { + position: absolute; + right: -15px; + top: 50%; + transform: translateY(-50%); + + @include breakpoint($medium) { + right: -18px; + } + + @include breakpoint($wide) { + right: -19px; + } + + &:hover { + cursor: default; + } + } + } + + a.default { + @include font-size(14px, 20px); + + @include breakpoint($narrow) { + @include font-size(16px, 24px); + } + } + + &:focus { + outline: auto; + } + + &.hidden { + display: none; + } + } + + .breadcrumb-item.home { + hy-icon.default { + svg { + fill: var(--brand-main-light); + stroke: var(--brand-main-light); + } + } + } + + .breadcrumb-item.main { + min-width: 0; + } + + .breadcrumb-item__more { + display: none; + flex-direction: row; + align-items: center; + position: relative; + margin-right: 20px; + + @include breakpoint($medium) { + margin-right: 25px; + } + + @include breakpoint($wide) { + margin-right: 30px; + } + + &.visible { + display: flex; + } + + .breadcrumb-item-caret { + position: absolute; + right: -15px; + top: 50%; + transform: translateY(-50%); + + @include breakpoint($medium) { + right: -18px; + } + + &:hover { + cursor: default; + } + + &__drop { + position: absolute; + right: 5.5px; + top: 50%; + transform: translateY(-50%); + } + } + } + + .breadcrumb-item-dropdown-button { + display: flex; + flex-direction: row; + color: var(--brand-main-light); + cursor: pointer; + border: 1.5px solid var(--brand-main-light); + font-size: 1.5rem; + line-height: 10px; + border-radius: 3px; + background-color: var(--grayscale-white); + box-shadow: 0 0 10px 0 rgba(14, 104, 139, 0.1); + padding: 0 25px 9px 10px; + position: relative; + + hy-icon { + svg { + fill: var(--brand-main-light); + margin: 0 0 -3px 10px; + transform: rotate(90deg); + } + } + } + + .breadcrumb-item-dropdown-button.is-open { + background-color: var(--brand-main-light); + color: var(--grayscale-white); + + svg { + fill: var(--grayscale-white); + margin: 0 0 -3px 10px; + transform: rotate(270deg); + } + } + + .breadcrumb-hidden-items { + display: none; + visibility: hidden; + + &__is-open { + background: var(--grayscale-white); + box-shadow: 0 0 10px 0 rgba(14, 104, 139, 0.1); + display: block; + margin-top: -9px; + padding: 32px 16px 6px 16px; + position: absolute; + visibility: visible; + z-index: 5; + + @include breakpoint($narrow) { + padding: 32px 64px 6px 32px; + } + + a { + margin: 0; + padding-bottom: 26px; + } + } + } + + .breadcrumb-item__current { + flex: 0 2 auto; + min-width: 0; + } + + .breadcrumb-item__current a { + color: var(--grayscale-dark); + font-family: var(--main-font-family); + text-decoration: none; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: auto; + + &:hover { + cursor: default; + } + } + + .intermediate { + display: flex; + text-overflow: initial; + } + + .intermediate.hidden { + display: none; + visibility: hidden; + } + + #more, + .more { + display: none; + visibility: hidden; + } + #more.visible, + .more.visible { + display: flex; + visibility: visible; + } +} + +// Large variant. Do not show Breadcrumbs if there is a hero and a sidebar on Large/Medium screens +.hy-breadcrumbs.large.with-sidebar { + display: block; + visibility: visible; + + a.large { + @include font-size(26px, 26px); + color: var(--grayscale-black); + font-weight: 700; + + //@todo change home icon to be 20px x 20px and be as in the specs + + .breadcrumb-item-caret { + // @todo set styles for caret near the home icon + // should be bold + // padding: 0 6px 0 8px; (mobile) && padding: 0 8px 0 10px (tablet + desktop); + } + } + + .breadcrumb-container { + color: var(--grayscale-black); + display: flex; + flex-wrap: nowrap; + font-family: var(--main-font-family); + list-style-type: none; + margin: 0; + min-height: 64px; + overflow: hidden; + padding: 0; + + @include breakpoint($narrow) { + min-height: 84px; + } + + .breadcrumb-item.home { + svg { + fill: var(--grayscale-black); + stroke: var(--grayscale-black); + stroke-width: 30; + } + } + } + + @include breakpoint($extrawide) { + display: none; + visibility: hidden; + } +} diff --git a/src/components/hy-breadcrumbs/hy-breadcrumbs.tsx b/src/components/hy-breadcrumbs/hy-breadcrumbs.tsx new file mode 100644 index 00000000..a3321f64 --- /dev/null +++ b/src/components/hy-breadcrumbs/hy-breadcrumbs.tsx @@ -0,0 +1,305 @@ +export interface Breadcrumb { + url: string; + text: string; +} +let breadcrumbsWidth = null; +import {Component, Element, h, Listen, Prop, State, Watch} from '@stencil/core'; +import {BreadcrumbVariants} from '../../utils/utils'; + +@Component({ + tag: 'hy-breadcrumbs', + styleUrl: 'hy-breadcrumbs.scss', + shadow: false, +}) +export class HyBreadcrumbs { + private _dataItems: Breadcrumb[]; + @Prop() dataItems: Breadcrumb[] | string; + @Prop() variant: BreadcrumbVariants = BreadcrumbVariants.default as any; + @Prop() headerstyle: string = 'with-sidebar'; + + @State() menuOpen: boolean = false; + @Element() el: HTMLElement; + + @Watch('dataItems') + arrayDataWatcher(newValue: Breadcrumb[] | string) { + if (typeof newValue === 'string') { + this._dataItems = JSON.parse(newValue); + } else { + this._dataItems = newValue; + } + } + componentWillLoad() { + this.arrayDataWatcher(this.dataItems); + } + + componentDidLoad() { + let hyMainDiv = this.el.closest('.hy-main'); + if (hyMainDiv) { + if (!hyMainDiv.classList.contains('with-sidebar')) { + this.headerstyle = 'without-sidebar'; + } + } + + // Set breadcumbs width + paddings. + breadcrumbsWidth = this.el.offsetWidth + 64; + if (breadcrumbsWidth >= document.body.scrollWidth) { + this.adjustBreadcrumbsMenuVisibility(); + } + } + + adjustBreadcrumbsMenuVisibility(showMenu = true) { + // Show ... and Hide intermediate links + if (!showMenu) { + this.closeMoreMenu(); + } + + const crumbContainer = document.querySelectorAll('.hy-breadcrumbs')[0]; + const moreDotsItem = document.querySelectorAll('#more')[0]; + const moreDotsItemWrapper = document.querySelectorAll('.breadcrumb-item__more')[0]; + if (moreDotsItem) { + if (showMenu) { + crumbContainer.classList.add('is-condensed'); + moreDotsItem.classList.add('visible'); + moreDotsItemWrapper.classList.add('visible'); + } else { + crumbContainer.classList.remove('is-condensed'); + moreDotsItem.classList.remove('visible'); + moreDotsItemWrapper.classList.remove('visible'); + } + } + + const intermediateItems = document.querySelectorAll('.intermediate'); + if (intermediateItems) { + for (let i = 0; i < intermediateItems.length; i++) { + if (showMenu) { + intermediateItems[i].classList.add('hidden'); + } else { + intermediateItems[i].classList.remove('hidden'); + } + } + } + } + + HomeItem(url) { + const homeItemClass = ['hy-icon-wrapper', this.variant].join(' '); + return ( + <li class="breadcrumb-item home"> + <a href={url} class={homeItemClass}> + <hy-icon icon={'hy-icon-home'} class={`${this.variant}`} size={20} /> + <hy-icon icon={'hy-icon-caret-right'} class={'breadcrumb-item-caret'} size={10} /> + </a> + </li> + ); + } + + BreadcrumbItem(label, url, className = '', withCaret = true) { + const breadcrumbClass = ['breadcrumb-item', className].join(' '); + const caretClass = ['breadcrumb-item-caret', this.variant].join(' '); + if (url) { + if (withCaret) { + return ( + <li class={breadcrumbClass}> + <a href={url} class={`${this.variant}`}> + {label} + <hy-icon icon={'hy-icon-caret-right'} class={caretClass} size={10} /> + </a> + </li> + ); + } else { + return ( + <li class={breadcrumbClass}> + <a href={url} class={`${this.variant}`}> + {label} + </a> + </li> + ); + } + } else { + return ( + <li class={`${breadcrumbClass} breadcrumb-item__current`}> + <a aria-current="page" href={url} class={`${this.variant}`}> + {label} + </a> + </li> + ); + } + } + + BreadcrumbTextItem(label, className = '') { + const breadcrumbClass = ['breadcrumb-item', className].join(' '); + return <li class={breadcrumbClass}>{label}</li>; + } + + DropdownMenuItem() { + return ( + <li class="breadcrumb-item__more"> + <button + type="button" + aria-hidden="true" + aria-expanded="false" + id="more" + key="more" + class="breadcrumb-item-dropdown-button" + > + ... + <hy-icon + icon={'hy-icon-caret-right'} + class={'breadcrumb-item-caret__drop breadcrumb-item__more__icon'} + size={10} + /> + </button> + <hy-icon icon={'hy-icon-caret-right'} class={'breadcrumb-item-caret'} size={10} /> + </li> + ); + } + + adjustHiddenMenuWidth() { + // set width to the menu area equal to the widest link + paddings + const moreMenu = document.querySelectorAll('.breadcrumb-hidden-items')[0]; + if (moreMenu) { + if (document.body.scrollWidth < 480) { + (moreMenu as HTMLElement).style.width = '100%'; + } else { + //maxIntermediateLinkWidth + var maxIntermediateLinkWidth = 0; + const moreMenuLinks = document.querySelectorAll('.breadcrumb-hidden-items .breadcrumb-item a'); + if (moreMenuLinks) { + for (let i = 0; i < moreMenuLinks.length; i++) { + if (maxIntermediateLinkWidth < (moreMenuLinks[i] as HTMLElement).offsetWidth) { + maxIntermediateLinkWidth = (moreMenuLinks[i] as HTMLElement).offsetWidth; + } + } + maxIntermediateLinkWidth = maxIntermediateLinkWidth + 32 + 64; + } + (moreMenu as HTMLElement).style.width = maxIntermediateLinkWidth.toString().concat('px'); + } + } + } + + closeMoreMenu() { + const moreMenu = document.querySelectorAll('.breadcrumb-hidden-items')[0]; + if (moreMenu) { + moreMenu.classList.remove('breadcrumb-hidden-items__is-open'); + this.menuOpen = false; + } + const moreBreadcrumb = document.querySelectorAll('#more')[0]; + if (moreBreadcrumb) { + moreBreadcrumb.classList.remove('is-open'); + } + } + + // When a ... is clicked, show/hide the Menu with hidden breadcrumbs + @Listen('click') + clickEventListener(event) { + if (!event) return; + + const target = event.target; + const moreMenu = document.querySelectorAll('.breadcrumb-hidden-items')[0]; + const moreButton = document.querySelectorAll('.breadcrumb-item-dropdown-button')[0]; + + // Trigger if target is button or svg icon + // TODO: Make this if prettier + if ( + target && + (target.id === 'more' || + ((target.tagName == 'svg' || 'path') && + target.closest('hy-icon').classList.contains('breadcrumb-item__more__icon'))) + ) { + //@todo Show the menu on the right place of the screen + if (moreMenu) { + if (this.menuOpen) { + moreMenu.classList.remove('breadcrumb-hidden-items__is-open'); + moreButton.classList.remove('is-open'); + moreButton.setAttribute('aria-expanded', 'false'); + } else { + moreMenu.classList.add('breadcrumb-hidden-items__is-open'); + moreButton.classList.add('is-open'); + moreButton.setAttribute('aria-expanded', 'true'); + + if (document.body.scrollWidth < 480) { + (moreMenu as HTMLElement).style.left = '16px'; + } else { + var rect = (moreButton as HTMLElement).getBoundingClientRect(); + (moreMenu as HTMLElement).style.left = (rect.left - 64).toString().concat('px'); + this.adjustHiddenMenuWidth(); + } + } + this.menuOpen = !this.menuOpen; + } + } else { + this.closeMoreMenu(); + } + + event.stopPropagation(); + event.stopImmediatePropagation(); + } + + @Listen('resize', {target: 'window'}) + resizeEventListener(event) { + if (!event) return; + + const breadcrumbsElement = document.querySelectorAll('.hy-breadcrumbs')[0]; + if (breadcrumbsElement) { + if (breadcrumbsWidth + 64 >= document.body.scrollWidth) { + this.adjustBreadcrumbsMenuVisibility(true); + } else { + this.adjustBreadcrumbsMenuVisibility(false); + } + } + } + + render() { + //@todo Accesibility + const TOTAL_ITEMS = this._dataItems.length; + const MAX_ITEMS_TO_SHOW = 3; + + let isMenuNeeded = TOTAL_ITEMS > MAX_ITEMS_TO_SHOW; + + let itemsBreadcrumbs = []; + let itemsToShowInMenu = []; + + if (this.variant == BreadcrumbVariants.landingLarge) { + // Landing pages, Large variant + this._dataItems.map((x, index) => { + if (index < 2) { + if (index == 0) { + itemsBreadcrumbs.push(this.HomeItem(x.url)); + } else { + //itemsBreadcrumbs.push(this.BreadcrumbTextItem(x.text, 'main')); + itemsBreadcrumbs.push(this.BreadcrumbItem(x.text, '', 'main')); + } + } + }); + } else { + // Landing and Content pages, Standard variant + this._dataItems.map((x, index) => { + let breadcrumbEl = this.BreadcrumbItem(x.text, x.url, '', false); + + if (isMenuNeeded && index > 1 && index < TOTAL_ITEMS - 1) { + itemsToShowInMenu.push(<div>{breadcrumbEl}</div>); + + if (index === 2) { + itemsBreadcrumbs.push(this.DropdownMenuItem()); + } + itemsBreadcrumbs.push(this.BreadcrumbItem(x.text, x.url, 'intermediate')); + return; + } else { + if (index == 0) { + itemsBreadcrumbs.push(this.HomeItem(x.url)); + } else { + itemsBreadcrumbs.push(this.BreadcrumbItem(x.text, x.url, 'main')); + } + } + }); + } + + const breadcrumbsClass = ['hy-breadcrumbs', this.variant, this.headerstyle].join(' '); + + return ( + <nav aria-label="Breadcrumb" role="navigation" aria-labelledby="system-breadcrumb" class={breadcrumbsClass}> + <ol class="breadcrumb-container">{itemsBreadcrumbs}</ol> + {itemsToShowInMenu && <ol class="breadcrumb-hidden-items">{itemsToShowInMenu}</ol>} + </nav> + ); + } +} diff --git a/src/components/hy-breadcrumbs/readme.md b/src/components/hy-breadcrumbs/readme.md new file mode 100644 index 00000000..4cb1efaa --- /dev/null +++ b/src/components/hy-breadcrumbs/readme.md @@ -0,0 +1,29 @@ +# hy-breadcrumbs + +<!-- Auto Generated Below --> + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------- | ------------- | ----------- | --------------------------------------------------------------- | ----------------------------------- | +| `dataItems` | `data-items` | | `Breadcrumb[] \| string` | `undefined` | +| `headerstyle` | `headerstyle` | | `string` | `'with-sidebar'` | +| `variant` | `variant` | | `BreadcrumbVariants.default \| BreadcrumbVariants.landingLarge` | `BreadcrumbVariants.default as any` | + +## Dependencies + +### Depends on + +- [hy-icon](../icon) + +### Graph + +```mermaid +graph TD; + hy-breadcrumbs --> hy-icon + style hy-breadcrumbs fill:#f9f,stroke:#333,stroke-width:4px +``` + +--- + +Helsinki University Design System diff --git a/src/components/icon/Home.tsx b/src/components/icon/Home.tsx index a2e39101..2473ea70 100644 --- a/src/components/icon/Home.tsx +++ b/src/components/icon/Home.tsx @@ -2,11 +2,11 @@ import {h} from '@stencil/core'; function SvgHome(props) { return ( - <svg viewBox="0 0 1000 1000" {...props}> + <svg viewBox="0 0 1000 1000" {...props} stroke="black" stroke-width="1"> <path d="M345.3,998.7c-30.3,0-55.4-25.2-55.4-55.6c0-30.4,25.1-55.6,55.4-55.6h479.9l0-509.9L500,114.2L174.8,377.6 v565.5c0,30.4-25.1,55.6-55.4,55.6c-30.3,0-55.4-25.2-55.4-55.6l0-567.6c0-31.5,14.6-61.9,38.7-81.9L434.1,25 - c37.7-30.4,93-30.4,130.7,0l332.5,268.6c25.1,19.9,38.7,49.3,38.7,81.9v568.7c0,29.4-25.1,54.6-55.4,54.6L345.3,998.7z" + c37.7-30.4,93-30.4,130.7,0l332.5,268.6c25.1,19.9,38.7,49.3,38.7,81.9v568.7c0,29.4-25.1,54.6-55.4,54.6L345.3,998.7z" /> </svg> ); diff --git a/src/components/icon/readme.md b/src/components/icon/readme.md index 67430d71..82e396ca 100644 --- a/src/components/icon/readme.md +++ b/src/components/icon/readme.md @@ -15,6 +15,7 @@ ### Used by - [hy-accordion-item](../accordion-item) +- [hy-breadcrumbs](../hy-breadcrumbs) - [hy-button](../button) - [hy-cta-button](../cta-button) - [hy-cta-link](../cta-link) @@ -39,6 +40,7 @@ ```mermaid graph TD; hy-accordion-item --> hy-icon + hy-breadcrumbs --> hy-icon hy-button --> hy-icon hy-cta-button --> hy-icon hy-cta-link --> hy-icon diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1df7b92d..5b2c68b2 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -12,6 +12,11 @@ export type IconName = { [key: string]: (props: any) => FunctionalComponent; }; +export enum BreadcrumbVariants { + default = 'default', + landingLarge = 'large', +} + export enum HeadingVarians { default = 'h1', h2 = 'h2', -- GitLab