UNPKG

@duetds/components

Version:

This package includes Duet Core Components and related tools.

638 lines (637 loc) 27.9 kB
import { Build, h } from "@stencil/core"; import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock/lib/bodyScrollLock.es6.js"; import icons from "@duetds/icons"; import tokens from "@duetds/tokens"; export class DuetNav { constructor() { /** * Own Properties */ this.nav = null; this.navSize = 0; this.isTicking = false; this.lastPosition = -1; /** * State() variables * Inlined decorator, alphabetical order. */ this.isContactOpen = false; this.isLanguageOpen = false; this.isSlideOutOpen = false; this.hasItems = false; this.isFixed = false; this.processedItems = null; /** * Theme of the navigation. Can be one of: "default", "turva". */ this.theme = ""; /** * Accessible label that is shown for screen reader users in the mobile navigation toggle. Not visible for normal users. */ this.accessibleLabel = "Valikko"; /** * URL that the logo link points to. */ this.logoHref = "/"; /** * The href of the current page item that is shown as “active”. */ this.currentHref = "/"; /** * The ID of the element where "skip to content" link should take the user. If empty, the functionality won’t be rendered in the DOM. */ this.skipToId = ""; /** * Region that is shown next to the logo. If empty, region will be hidden. */ this.region = ""; /** * Label of the contact menu. If empty, contact menu will be hidden. */ this.contact = ""; /** * The currently active language. This setting also changes the logo to match the chosen language. Can be one of: "fi", "en", "sv". */ this.language = "fi"; this.refreshItems = () => { this.processedItems = this.processItems(this.items); }; this.getFrame = () => { if (!this.isTicking) { requestAnimationFrame(this.animationLoop); this.isTicking = true; } }; this.animationLoop = () => { // Avoid calculations if not needed const pos = window.pageYOffset; if (this.lastPosition === pos) { this.isTicking = false; } else { this.lastPosition = pos; } if (pos > this.navSize) { this.isFixed = true; } else { this.isFixed = false; } this.isTicking = false; }; /** * Component event handling. */ this.handleClick = (ev, data, active) => { if (data && data["country"]) { this.language = active; this.isLanguageOpen = false; } this.emitChange(ev, data); this.closeMenus(); ev.cancelBubble = true; }; this.handleKeyUp = ev => { // When Esc is pressed if (ev.key === "Escape" || ev.key === "Esc" || ev.keyCode === 27) { this.closeMenus(); } if (this.isContactOpen || this.isLanguageOpen) { // When Space is pressed if (ev.key === "Space" || ev.keyCode === 32) { this.closeMenus(); } } }; this.handleArrowKeys = ev => { if (this.isContactOpen || this.isLanguageOpen) { const prev = ev.target.previousElementSibling; const next = ev.target.nextElementSibling; if (prev) { // Arrow left if (ev.keyCode === 37) { ev.preventDefault(); prev.focus(); } // Arrow up if (ev.keyCode === 38) { ev.preventDefault(); prev.focus(); } } if (next) { // Arrow right if (ev.keyCode === 39) { ev.preventDefault(); next.focus(); } // Arrow down if (ev.keyCode === 40) { ev.preventDefault(); next.focus(); } } } }; this.handleKeyDown = ev => { if (this.isContactOpen || this.isLanguageOpen) { // When Space is pressed if (ev.key === "Space" || ev.keyCode === 32) { ev.preventDefault(); const activeElement = this.element.shadowRoot.activeElement; if (activeElement) { activeElement.click(); } } } }; this.toggleMenu = (ev, type) => { ev.preventDefault(); ev.stopPropagation(); window.addEventListener("keyup", this.handleKeyUp, false); window.addEventListener("keydown", this.handleKeyDown, false); const doc = this.element.shadowRoot; let el; // For language menu if (type === "language") { this.isContactOpen = false; this.isLanguageOpen = !this.isLanguageOpen; el = doc.querySelector(".duet-nav-language-items"); // For contact menu } else if (type === "contact") { this.isLanguageOpen = false; this.isContactOpen = !this.isContactOpen; el = doc.querySelector(".duet-nav-contact-items"); // For mobile menu } else if (type === "mobile") { this.isContactOpen = false; this.isLanguageOpen = false; el = doc.querySelector(".duet-nav-bottom"); this.isSlideOutOpen = !this.isSlideOutOpen; if (this.isSlideOutOpen) { disableBodyScroll(el); } else { enableBodyScroll(el); } } // Focus in the new menu when opened to make them more accessible if (this.isSlideOutOpen || this.isLanguageOpen || this.isContactOpen) { setTimeout(function () { el.querySelector("a").focus(); }, 300); } }; this.closeMenus = () => { if (this.isContactOpen) { const contact = this.element.shadowRoot.querySelector(".duet-nav-contact"); contact.focus({ preventScroll: true, }); } if (this.isLanguageOpen) { const lang = this.element.shadowRoot.querySelector(".duet-nav-language"); lang.focus({ preventScroll: true, }); } this.isSlideOutOpen = false; this.isContactOpen = false; this.isLanguageOpen = false; window.removeEventListener("keyup", this.handleKeyUp); window.removeEventListener("keydown", this.handleKeyDown); enableBodyScroll(this.element.shadowRoot.querySelector(".duet-nav-bottom")); }; } itemsChanged() { this.refresh(); } /** * Component lifecycle events. */ componentWillLoad() { if (this.theme !== "default" && document.documentElement.classList.contains("duet-theme-turva")) { this.theme = "turva"; } this.refreshItems(); } componentDidLoad() { // Grab items from this component for SSR compatibility if (Build.isBrowser) { this.hasItems = !!this.element.shadowRoot.querySelector(".duet-nav-items a"); } // Get navigation size from tokens instead of hard coding it here this.navSize = parseFloat(tokens["sizeHeader"]) * 16; window.addEventListener("scroll", this.getFrame, false); window.addEventListener("resize", this.closeMenus, false); document.addEventListener("click", this.closeMenus, false); } componentDidUnload() { window.removeEventListener("scroll", this.getFrame); window.removeEventListener("resize", this.closeMenus); document.removeEventListener("click", this.closeMenus); } /** * Local methods */ processItems(items) { // If undefined return false immediately if (typeof items === "undefined") { return false; // If valid array, don’t process } else if (this.hasValidItems(items)) { return items; // Convert attribute string into array } else { return new Function("return " + items + ";")(); } } hasValidItems(array) { return Array.isArray(array); } emitChange(ev, data) { this.duetChange.emit({ originalEvent: ev, data: data, component: "duet-nav", }); } /** * Forces render() update for `duet-nav`. Use this when e.g. changing the global language. */ async refresh() { this.refreshItems(); } /** * render() function * Always the last one in the class. */ render() { let skipLabel = "Siirry pääsisältöön"; if (this.language === "en") { skipLabel = "Skip to main content"; } else if (this.language === "sv") { skipLabel = "Hoppa till huvudinnehåll"; } let changeLanguage = "Vaihda kieltä"; if (this.language === "en") { changeLanguage = "Change language"; } else if (this.language === "sv") { changeLanguage = "Ändra Språk"; } return (h("header", { class: { "duet-nav": true, "duet-theme-turva": this.theme === "turva", "duet-nav-back": !!this.back, "duet-nav-inactive": !this.hasItems, } }, h("div", { class: "duet-nav-top" }, this.skipToId !== "" ? (h("a", { href: this.skipToId, class: "duet-nav-skip" }, skipLabel)) : (""), this.back ? (h("duet-button", { url: this.back["href"], id: this.back["id"], theme: this.theme, variation: "plain", color: this.theme === "turva" ? "colorSecondaryTurva" : "colorSecondary", icon: "navigation-arrow-left", "icon-size": "large" }, this.back["label"])) : (""), h("duet-logo", { href: this.logoHref, size: "medium", language: this.language, theme: this.theme }), this.region && !this.back ? h("div", { class: "duet-nav-region" }, this.region) : "", this.hasItems || this.hasValidItems(this.languageItems) || this.hasValidItems(this.contactItems) || this.session || this.user ? (h("button", { class: "duet-nav-toggle" + (this.isSlideOutOpen ? " active" : ""), onClick: event => this.toggleMenu(event, "mobile"), "aria-label": this.accessibleLabel }, h("div", { class: "duet-nav-hamburger" }, h("span", { class: "duet-nav-bar" }), h("span", { class: "duet-nav-bar" }), h("span", { class: "duet-nav-bar" }), h("span", { class: "duet-nav-bar" })))) : ("")), this.back ? ("") : (h("div", { class: { "duet-nav-bottom": true, fixed: this.isFixed, active: this.isSlideOutOpen, inactive: !this.hasItems, } }, h("nav", { class: "duet-nav-items", role: "navigation" }, this.processedItems ? this.processedItems.map((item) => (h("a", { class: this.currentHref === item["href"] ? "active" : "", href: item["href"], id: item["id"], onClick: event => this.handleClick(event, item) }, item["label"], item["badge"] ? h("div", { class: "duet-nav-badge" }) : ""))) : ""), h("div", { class: "duet-nav-utils" }, this.contact !== "" && this.hasValidItems(this.contactItems) ? (h("div", { class: "duet-nav-dropdown" }, h("button", { class: "duet-nav-contact duet-nav-dropdown-toggle" + (this.isContactOpen ? " active" : ""), "aria-haspopup": "listbox", "aria-controls": "duet-nav-contact", "aria-labelledby": "duet-nav-contact-label", "aria-expanded": this.isContactOpen ? "true" : "false", id: "duet-nav-contact-button", onClick: event => this.toggleMenu(event, "contact") }, h("span", { class: "duet-nav-icon", "aria-hidden": "true", innerHTML: icons["navigation-contact-dropdown"].svg }), h("span", { "aria-hidden": "true", id: "duet-nav-contact-label" }, this.contact), h("span", { class: "duet-nav-caret", "aria-hidden": "true", innerHTML: icons["action-arrow-down-small"].svg })), h("div", { tabindex: "-1", role: "menu", id: "duet-nav-contact", "aria-labelledby": "duet-nav-contact-button", class: "duet-nav-dropdown-content duet-nav-contact-items" + (this.isContactOpen ? " active" : "") }, this.hasValidItems(this.contactItems) ? this.contactItems.map((item) => (h("a", { role: "menuitem", href: item["href"], id: item["id"], onClick: event => this.handleClick(event, item), onKeyDown: ev => this.handleArrowKeys(ev) }, item["label"]))) : ""))) : (""), this.language !== "" && this.hasValidItems(this.languageItems) ? (h("div", { class: "duet-nav-dropdown" }, h("button", { "aria-label": changeLanguage, "aria-haspopup": "listbox", "aria-controls": "duet-nav-language", "aria-expanded": this.isLanguageOpen ? "true" : "false", id: "duet-nav-language-button", class: "duet-nav-language duet-nav-dropdown-toggle" + (this.isLanguageOpen ? " active" : ""), onClick: event => this.toggleMenu(event, "language") }, h("span", { class: "duet-nav-icon", "aria-hidden": "true", innerHTML: icons["navigation-language"].svg }), h("span", { "aria-hidden": "true" }, this.language), h("span", { class: "duet-nav-caret", "aria-hidden": "true", innerHTML: icons["action-arrow-down-small"].svg })), h("div", { tabindex: "-1", role: "menu", id: "duet-nav-language", "aria-labelledby": "duet-nav-language-button", class: "duet-nav-dropdown-content duet-nav-language-items" + (this.isLanguageOpen ? " active" : "") }, this.hasValidItems(this.languageItems) ? this.languageItems.map((item) => (h("a", { class: this.language === item["country"] ? "active" : "", "aria-selected": this.language === item["country"] ? true : false, role: "menuitem", id: item["id"], href: item["href"], onClick: event => this.handleClick(event, item, item["country"]), onKeyDown: ev => this.handleArrowKeys(ev) }, item["label"]))) : ""))) : (""), this.user ? (h("a", { href: this.user["href"], id: this.user["id"], class: "duet-nav-user", onClick: event => this.handleClick(event, this.user) }, h("span", { class: "duet-nav-icon", "aria-hidden": "true", innerHTML: icons["navigation-user"].svg }), this.user["label"])) : (""), this.session ? (h("a", { href: this.session["href"], id: this.session["id"], class: "duet-nav-logout", onClick: event => this.handleClick(event, this.session) }, this.session["type"] === "logout" ? (h("span", { class: "duet-nav-icon", "aria-hidden": "true", innerHTML: icons["navigation-logout"].svg })) : (h("span", { class: "duet-nav-icon", "aria-hidden": "true", innerHTML: icons["navigation-login"].svg })), this.session["label"])) : ("")))))); } static get is() { return "duet-nav"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["duet-nav.scss"] }; } static get styleUrls() { return { "$": ["duet-nav.css"] }; } static get properties() { return { "theme": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Theme of the navigation. Can be one of: \"default\", \"turva\"." }, "attribute": "theme", "reflect": false, "defaultValue": "\"\"" }, "accessibleLabel": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Accessible label that is shown for screen reader users in the mobile navigation toggle. Not visible for normal users." }, "attribute": "accessible-label", "reflect": false, "defaultValue": "\"Valikko\"" }, "user": { "type": "unknown", "mutable": false, "complexType": { "original": "object", "resolved": "object", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An object that includes mandatory \"label\" and \"href\" fields for the user profile link. Additionally, you can pass an \"id\" that is added as an HTML identifier for the element. If nothing is passed, user won\u2019t be shown." } }, "logoHref": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "URL that the logo link points to." }, "attribute": "logo-href", "reflect": false, "defaultValue": "\"/\"" }, "currentHref": { "type": "string", "mutable": true, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The href of the current page item that is shown as \u201Cactive\u201D." }, "attribute": "current-href", "reflect": false, "defaultValue": "\"/\"" }, "skipToId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The ID of the element where \"skip to content\" link should take the user. If empty, the functionality won\u2019t be rendered in the DOM." }, "attribute": "skip-to-id", "reflect": false, "defaultValue": "\"\"" }, "items": { "type": "any", "mutable": false, "complexType": { "original": "any", "resolved": "any", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An array of items for the main navigation. Items have to include mandatory \"label\" and \"href\" fields to work. Additionally, you can pass an \"id\" that is added as an HTML identifier for the element." }, "attribute": "items", "reflect": false }, "region": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Region that is shown next to the logo. If empty, region will be hidden." }, "attribute": "region", "reflect": false, "defaultValue": "\"\"" }, "session": { "type": "unknown", "mutable": false, "complexType": { "original": "object", "resolved": "object", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An object that includes mandatory \"label\", \"href\" and \"type\" fields for the session login/logout link. Additionally, you can pass an \"id\" that is added as an HTML identifier for the element. If nothing is passed, this link won\u2019t be shown." } }, "back": { "type": "unknown", "mutable": false, "complexType": { "original": "object", "resolved": "object", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An object that includes mandatory \"label\" and \"href\" fields for the back link. Additionally, you can pass an \"id\" that is added as an HTML identifier for the element. If nothing is passed, back link won\u2019t be shown. **NOTE: The back link should be ONLY used in combination with language and logoHref props.**" } }, "contact": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Label of the contact menu. If empty, contact menu will be hidden." }, "attribute": "contact", "reflect": false, "defaultValue": "\"\"" }, "contactItems": { "type": "unknown", "mutable": false, "complexType": { "original": "any[]", "resolved": "any[]", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An array of items for the contact menu. \"label\" and \"href\" are mandatory. Additionally, you can pass an \"id\" that is added as an HTML identifier for the element. **NOTE: If you\u2019re performing a JavaScript action on click, you still need to pass at least \"#\" for href.**" } }, "language": { "type": "string", "mutable": true, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The currently active language. This setting also changes the logo to match the chosen language. Can be one of: \"fi\", \"en\", \"sv\"." }, "attribute": "language", "reflect": false, "defaultValue": "\"fi\"" }, "languageItems": { "type": "unknown", "mutable": false, "complexType": { "original": "any[]", "resolved": "any[]", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "An array of items for the language menu. If empty, the language menu will be hidden. \"label\", \"country\" and \"href\" are mandatory. Additionally you can pass an \"id\" that is added as an HTML identifier for the element. **NOTE: If you\u2019re performing a JavaScript action on click, you still need to pass at least \"#\" for href.**" } } }; } static get states() { return { "isContactOpen": {}, "isLanguageOpen": {}, "isSlideOutOpen": {}, "hasItems": {}, "isFixed": {}, "processedItems": {} }; } static get events() { return [{ "method": "duetChange", "name": "duetChange", "bubbles": false, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Callback for when an user is about to navigate to another page. You can prevent the default browser functionality by calling **event.detail.originalEvent.preventDefault()** inside yout listener. Additionally, the passed data is available via **event.detail.data**." }, "complexType": { "original": "any", "resolved": "any", "references": {} } }]; } static get methods() { return { "refresh": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "Forces render() update for `duet-nav`. Use this when e.g. changing the global language.", "tags": [] } } }; } static get elementRef() { return "element"; } static get watchers() { return [{ "propName": "items", "methodName": "itemsChanged" }]; } }