UNPKG

@postnord/web-components

Version:
797 lines (796 loc) 32.3 kB
/*! * Built with Stencil * By PostNord. */ import { awaitTopbar, en, isSmallScreen, reduceMotion, uuidv4 } from "../../../index"; import { Host, h } from "@stencil/core"; import { arrow_right, close } from "pn-design-assets/pn-assets/icons"; import { translations } from "./translation"; /** * The wizard component lets you highlight areas on the page * that is accompanied with a modal for user guidance. * * Either use the label & helpertext props to set a title and description, * or use the default slot to add custom HTML content. * * The steps prop takes an array of querySelectors that the wizard will highlight in order. * Set the step prop to start the wizard at a specific step (0-indexed). -1 closes the wizard. * * @slot - The default slot can be used to add custom content to the wizard modal. * @slot buttons - Use this slot to add custom buttons to the wizard controls area. * Its a good idea to combine these with the `hide-back`, `hide-next` and `hide-finish` props. * * @since v7.19.0 */ export class PnWizard { id = `pn-wizard-${uuidv4()}`; wizardIdLabel; wizardIdHelpertext; wizardElement; wizardContent; wizardArrow; timeoutRerender; hostElement; /** Set a title for the wizard. */ label = ''; /** Set a helpertext for the wizard. */ helpertext = ''; /** Manually set the language. */ language = null; /** Set a custom HTML id. */ pnId = this.id; /** * Assign the current step. * Any value above -1 is valid and will start the wizard. * @category Wizard steps **/ step = -1; /** * The list of querySelectors that the wizards will highlight. * If a selector is invalid or empty, the wizard will be centered on the screen. * * Recommended maximum of 7 steps. * @category Wizard steps **/ steps = []; /** * Choose to display one of 2 built in progress steppers. * - `dots`: The pn-progress-stepper with the `dots` prop. * - `text`: simple text indicator "current/total" * @category Features */ progress = ''; /** Choose where the scrolling will focus on the highlighted elements. @category Features */ scrollBlock = 'center'; /** * By default, the component will hide the overflow on the body element when active. * You may wish to disable this if your HTML/CSS structure is not compatible with this behavior. * @category Features */ displayOverflow = false; /** Hide the back button. @category Buttons */ hideBack = false; /** Hide the next button. @category Buttons */ hideNext = false; /** Hide the finish button. @category Buttons */ hideFinish = false; /** Set a custom label for the back button. @category Buttons */ labelBack = ''; /** Set a custom label for the next button. @category Buttons */ labelNext = ''; /** Set a custom label for the finish button. @category Buttons */ labelFinish = ''; /** Emitted when the wizard step changes. */ wizardStep; /** Emitted when the highlighted area is clicked. */ wizardHighlightClick; /** Emitted when the wizard is canceled or finished. */ wizardClose; handleStepChange(step, prevStep) { if (step === -1) { const finished = prevStep === this.steps.length; this.wizardClose.emit({ step: finished ? prevStep - 1 : prevStep, finished }); } if (!this.isActive()) { this.wizardElement?.close(); setTimeout(() => this.handleDisplayOverflow(), reduceMotion() ? 0 : 400); return; } if (!this.wizardElement.open) { this.runStepLogic(); requestAnimationFrame(() => { this.handleDisplayOverflow(); this.wizardElement?.showModal(); }); } this.wizardStep.emit({ step, next: step > (prevStep ?? -1), prev: step < (prevStep ?? -1), }); } handleIdChange() { this.wizardIdLabel = `${this.pnId || this.id}-label`; this.wizardIdHelpertext = `${this.pnId || this.id}-helpertext`; } handleOverflow() { if (!this.isActive()) return; clearTimeout(this.timeoutRerender); this.timeoutRerender = setTimeout(() => this.runStepLogic(), 250); } async componentWillLoad() { if (this.language === null) await awaitTopbar(this.hostElement); } componentDidRender() { requestAnimationFrame(() => this.isActive() && this.runStepLogic()); } getRect(element) { return element.getBoundingClientRect(); } translate(prop) { return translations?.[prop]?.[this.language || en] || prop; } runStepLogic() { if (this.isOpen()) this.scrollToElement(); this.highlightElement(); this.positionModal(); } handleDisplayOverflow() { if (this.displayOverflow) return; const body = document.body; const scrollbarWidth = innerWidth - document.documentElement.clientWidth; if (this.isActive()) { body.style.setProperty('overflow', 'hidden'); body.style.setProperty('margin-right', `${scrollbarWidth}px`); } else { body.style.removeProperty('overflow'); body.style.removeProperty('margin-right'); } } /** Is the step within the valid range of available {@link steps}. */ isActive(step = this.step) { return step >= 0 && step < this.steps.length; } isOpen() { return this.isActive() && this.wizardElement?.open; } isClosed(step = this.step) { return step === -1; } isFirstStep() { return this.step === 0; } isLastStep(step = this.step) { if (this.steps.length === 1) return true; return step === this.steps.length - 1; } isProgressDots() { return this.progress === 'dots'; } isProgressText() { return this.progress === 'text'; } /** * By default, the `pn-progress-stepper` will always render, * but is hidden from view unless `dots` is assinged to {@link progress}. * * The only exception is when there are more than 7 steps. Something that the stepper does not allow. */ renderProgress() { return 7 >= this.steps.length && (this.isProgressDots() || this.isProgressText()); } showBackButton() { if (this.hideBack || this.isClosed() || this.isFirstStep()) return false; return true; } showNextButton() { if (this.hideNext) return false; return !this.isLastStep() || this.showFinishButton(); } showFinishButton() { return !this.hideFinish && this.isLastStep(); } nextStep() { const next = this.step + 1; this.step = next; } prevStep() { const prev = this.step - 1; this.step = prev; } endWizard() { this.step = -1; } getNextLabel() { const finish = this.labelFinish || 'FINISH'; const next = this.labelNext || 'NEXT'; const prop = this.isLastStep() ? finish : next; return this.translate(prop); } getHighlightTarget() { const selector = this.steps[this.step]; if (!selector) return null; const target = document.querySelector(selector); return target; } getHighlightRect() { const target = this.getHighlightTarget(); if (!target) { this.resetToCenter(); return; } const rect = this.getRect(target); const margin = parseFloat(getComputedStyle(target).margin) || 0; const top = rect.top + window.scrollY - margin; const left = rect.left + window.scrollX - margin; const width = rect.width + margin * 2; const height = rect.height + margin * 2; const radius = parseFloat(getComputedStyle(target).borderRadius) || 4; return { top, left, width, height, radius }; } isInViewport(element) { const { top, left, bottom, right } = element.getBoundingClientRect(); return (top >= 0 && left >= 0 && bottom <= (window.innerHeight || document.documentElement.clientHeight) && right <= (window.innerWidth || document.documentElement.clientWidth)); } scrollToElement() { const target = this.getHighlightTarget(); if (!target || this.isInViewport(target)) return; target.scrollIntoView({ behavior: 'instant', block: this.scrollBlock }); } highlightElement() { const target = this.getHighlightTarget(); if (!target) return this.resetToCenter(); this.setHighlightVars(this.getHighlightRect()); } resetToCenter() { const { width, height } = this.getWizardContentRect(); const top = window.scrollY + window.innerHeight / 2 - height / 2; const left = window.scrollX + window.innerWidth / 2 - width / 2; this.setHighlightVars({ top, left, width: 0, height: 0, radius: 0 }); this.setModalVars({ top, left }); this.setArrowVars('none', 0); } getWizardContentRect() { if (!this.wizardContent) return { width: 0, height: 0 }; return this.getRect(this.wizardContent); } positionModal() { const target = this.getHighlightTarget(); if (!target) return; /** The distance between the highlight and the modal */ const gap = 16; /** The minimum offset from the edge of the viewport */ const minOffset = gap / 2; /** Target = Highlighted element */ const { top: targetTop, left: targetLeft, width: targetWidth, height: targetHeight } = this.getHighlightRect(); const targetCenter = targetLeft + targetWidth / 2; /** Modal = Wizard content */ const { width: modalWidth, height: modalHeight } = this.getWizardContentRect(); /** The X position of the modal */ let contentX = 0; /** The Y position of the modal */ let contentY = 0; /** Get viewport dimensions and scroll position */ const { scrollY, scrollX, innerWidth, innerHeight } = window; const scrollbarWidth = innerWidth - document.documentElement.clientWidth; // Where does the modal fit? const modalFitsDown = targetTop + targetHeight + gap + modalHeight < scrollY + innerHeight; const modalFitsUp = targetTop - scrollY > modalHeight + gap; const modalFitsLeft = targetLeft - gap - modalWidth > scrollX; const modalFitsRight = targetLeft + targetWidth + gap + modalWidth < scrollX + innerWidth; const modalFitsNone = !modalFitsDown && !modalFitsUp && !modalFitsLeft && !modalFitsRight; const fitsY = modalFitsDown || modalFitsUp; const fitsX = modalFitsLeft || modalFitsRight; let maxY = minOffset; let maxX = minOffset; maxY = modalFitsDown ? targetTop + targetHeight + gap : targetTop - modalHeight - gap; maxX = scrollX + innerWidth - modalWidth - scrollbarWidth - minOffset; // Arrow stuff let arrowOffset = 0; let arrowDirection = 'top'; if (fitsY) { contentX = targetCenter - modalWidth / 2; contentY = modalFitsDown ? targetTop + targetHeight + gap : targetTop - modalHeight - gap; arrowDirection = modalFitsDown ? 'top' : 'bottom'; arrowOffset = targetCenter - contentX; } else if (fitsX) { contentX = modalFitsRight ? targetLeft + targetWidth + gap : targetLeft - modalWidth - gap; contentY = targetTop + targetHeight / 2 - modalHeight / 2; maxY = scrollY + innerHeight - modalHeight - minOffset; arrowDirection = modalFitsRight ? 'left' : 'right'; arrowOffset = modalHeight / 2; } else if (modalFitsNone) { contentX = targetCenter - modalWidth / 2; contentY = targetTop + targetHeight - modalHeight / 2; maxY = scrollY - modalHeight - minOffset; arrowDirection = 'top'; arrowOffset = targetCenter - contentX; } // Clamp horizontally within viewport const minX = scrollX + scrollbarWidth || minOffset; contentX = Math.max(minX, Math.min(contentX, maxX)); // Clamp vertically within viewport const minY = minOffset; contentY = Math.max(minY, Math.min(contentY, maxY)); this.setModalVars({ top: contentY, left: contentX }); if (isSmallScreen()) { arrowDirection = 'top'; } // Adjust arrow offset based on final position if (arrowDirection === 'top' || arrowDirection === 'bottom') { arrowOffset = targetCenter - contentX; } else { arrowOffset = targetTop + targetHeight / 2 - contentY; } const modalSize = arrowDirection === 'top' || arrowDirection === 'bottom' ? modalWidth : modalHeight; arrowOffset = Math.max(32, // Minimum offset from edge of modal Math.min(arrowOffset - minOffset, modalSize - 32)); this.setArrowVars(arrowDirection, arrowOffset); } setHighlightVars({ top, left, width, height, radius, }) { // Center and scale the highlight using transform this.hostElement.style.setProperty('--pn-spotlight-x', `${Math.round(left)}px`); this.hostElement.style.setProperty('--pn-spotlight-y', `${Math.round(top)}px`); this.hostElement.style.setProperty('--pn-spotlight-width', `${Math.round(width)}px`); this.hostElement.style.setProperty('--pn-spotlight-height', `${Math.round(height)}px`); this.hostElement.style.setProperty('--pn-spotlight-radius', `${Math.round(radius)}px`); } setModalVars({ top, left }) { this.hostElement.style.setProperty('--pn-wizard-content-x', `${Math.round(left)}px`); this.hostElement.style.setProperty('--pn-wizard-content-y', `${Math.round(top)}px`); } setArrowVars(direction, offset) { this.wizardArrow.setAttribute('data-arrow', direction); this.hostElement.style.setProperty('--pn-arrow-offset', `${Math.round(offset)}px`); } render() { return (h(Host, { key: '3fbeaf58c3adc8ea380a81b93dc66fb8a6bece5c' }, h("dialog", { key: 'f0f94968cba2e2dc8baba4752d80a7bea862c929', id: this.pnId, "aria-labelledby": this.label ? this.wizardIdLabel : null, "aria-describedby": this.helpertext ? this.wizardIdHelpertext : null, class: "pn-wizard", ref: el => (this.wizardElement = el), onCancel: () => this.endWizard(), onClose: () => this.endWizard() }, h("div", { key: '737f8207c19876c82c6a3d2418305fc7268ee251', class: "pn-wizard-overlay" }, h("div", { key: 'd420a48ad3b30828b4dd496f56073eea375bbe34', class: "pn-wizard-highlight", onClick: e => this.wizardHighlightClick.emit(e) })), h("div", { key: '03d6e169d95b7fe7bdb65a31e839b5744db190d0', class: "pn-wizard-content", ref: el => (this.wizardContent = el) }, h("pn-button", { key: '3534ecbbd28f9171b0ba1adfa07a91a4a20a1dd1', class: "pn-wizard-cancel", arialabel: this.translate('CANCEL'), icon: close, iconOnly: true, small: true, appearance: "light", variant: "borderless", onPnClick: () => this.endWizard() }), h("div", { key: 'b1f3f3ddb93efe5201091d19df4ff22acde76391', class: "pn-wizard-text" }, this.label && (h("h2", { key: 'b689ff85832dd65814d30ca85c00b59281aef1d1', class: "pn-wizard-label", id: this.wizardIdLabel }, this.label)), this.helpertext && (h("p", { key: '6a978db802f3faa402435c84c233e84c3456f657', class: "pn-wizard-helpertext", id: this.wizardIdHelpertext }, this.helpertext)), h("slot", { key: '52d0440d5b470fd06eb3a0c21921d540c79c0ac9' }), this.renderProgress() && (h("pn-progress-stepper", { key: '63f08858ddef511b92d622a559fe3888b5a6508a', class: !this.isProgressDots() && 'pn-wizard-sr-only', totalSteps: this.steps.length, currentStep: this.step > 0 ? this.step + 1 : 1, dots: true }))), h("div", { key: 'c5b266d5421f09bbc2ab199d6d71f5938dc8ae72', class: "pn-wizard-arrow", ref: el => (this.wizardArrow = el) }), h("div", { key: '00aef3606f751ca56142fe2b0a6a08a36e4f92f3', class: "pn-wizard-controls" }, this.isProgressText() && (h("span", { key: 'd6c0c85554253580d65aa42742cdabf55158bfe5', class: "pn-wizard-indicator", "aria-hidden": "true" }, this.step + 1, "/", this.steps.length)), h("slot", { key: '76a1b7c69280ffbbc81f210acdfb8812b241329d', name: "buttons" }), this.showBackButton() && (h("pn-button", { key: '560aa03d06aec0b32ae94ee2264b6bd059b5f305', label: this.labelBack || this.translate('PREV'), small: true, appearance: "light", variant: "outlined", onPnClick: () => this.prevStep() })), this.showNextButton() && (h("pn-button", { key: '00d8a13c7ee3413aa2e3d751d101de41e228f866', label: this.getNextLabel(), small: true, icon: arrow_right, onPnClick: () => this.nextStep() }))))))); } static get is() { return "pn-wizard"; } static get originalStyleUrls() { return { "$": ["pn-wizard.scss"] }; } static get styleUrls() { return { "$": ["pn-wizard.css"] }; } static get properties() { return { "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Set a title for the wizard." }, "getter": false, "setter": false, "reflect": false, "attribute": "label", "defaultValue": "''" }, "helpertext": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Set a helpertext for the wizard." }, "getter": false, "setter": false, "reflect": false, "attribute": "helpertext", "defaultValue": "''" }, "language": { "type": "string", "mutable": true, "complexType": { "original": "PnLanguages", "resolved": "\"\" | \"da\" | \"en\" | \"fi\" | \"no\" | \"sv\"", "references": { "PnLanguages": { "location": "import", "path": "@/index", "id": "src/index.ts::PnLanguages", "referenceLocation": "PnLanguages" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Manually set the language." }, "getter": false, "setter": false, "reflect": false, "attribute": "language", "defaultValue": "null" }, "pnId": { "type": "string", "mutable": true, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Set a custom HTML id." }, "getter": false, "setter": false, "reflect": false, "attribute": "pn-id", "defaultValue": "this.id" }, "step": { "type": "number", "mutable": true, "complexType": { "original": "number", "resolved": "number", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Wizard steps" }], "text": "Assign the current step.\nAny value above -1 is valid and will start the wizard." }, "getter": false, "setter": false, "reflect": true, "attribute": "step", "defaultValue": "-1" }, "steps": { "type": "unknown", "mutable": false, "complexType": { "original": "string[]", "resolved": "string[]", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Wizard steps" }], "text": "The list of querySelectors that the wizards will highlight.\nIf a selector is invalid or empty, the wizard will be centered on the screen.\n\nRecommended maximum of 7 steps." }, "getter": false, "setter": false, "defaultValue": "[]" }, "progress": { "type": "string", "mutable": false, "complexType": { "original": "'' | 'dots' | 'text'", "resolved": "\"\" | \"dots\" | \"text\"", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Choose to display one of 2 built in progress steppers.\n- `dots`: The pn-progress-stepper with the `dots` prop.\n- `text`: simple text indicator \"current/total\"" }, "getter": false, "setter": false, "reflect": false, "attribute": "progress", "defaultValue": "''" }, "scrollBlock": { "type": "string", "mutable": false, "complexType": { "original": "'start' | 'center' | 'end' | 'nearest'", "resolved": "\"center\" | \"end\" | \"nearest\" | \"start\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Choose where the scrolling will focus on the highlighted elements." }, "getter": false, "setter": false, "reflect": false, "attribute": "scroll-block", "defaultValue": "'center'" }, "displayOverflow": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "By default, the component will hide the overflow on the body element when active.\nYou may wish to disable this if your HTML/CSS structure is not compatible with this behavior." }, "getter": false, "setter": false, "reflect": false, "attribute": "display-overflow", "defaultValue": "false" }, "hideBack": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Hide the back button." }, "getter": false, "setter": false, "reflect": false, "attribute": "hide-back", "defaultValue": "false" }, "hideNext": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Hide the next button." }, "getter": false, "setter": false, "reflect": false, "attribute": "hide-next", "defaultValue": "false" }, "hideFinish": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Hide the finish button." }, "getter": false, "setter": false, "reflect": false, "attribute": "hide-finish", "defaultValue": "false" }, "labelBack": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Set a custom label for the back button." }, "getter": false, "setter": false, "reflect": false, "attribute": "label-back", "defaultValue": "''" }, "labelNext": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Set a custom label for the next button." }, "getter": false, "setter": false, "reflect": false, "attribute": "label-next", "defaultValue": "''" }, "labelFinish": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Buttons" }], "text": "Set a custom label for the finish button." }, "getter": false, "setter": false, "reflect": false, "attribute": "label-finish", "defaultValue": "''" } }; } static get events() { return [{ "method": "wizardStep", "name": "wizardStep", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the wizard step changes." }, "complexType": { "original": "{ step: number; next: boolean; prev: boolean }", "resolved": "{ step: number; next: boolean; prev: boolean; }", "references": {} } }, { "method": "wizardHighlightClick", "name": "wizardHighlightClick", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the highlighted area is clicked." }, "complexType": { "original": "PointerEvent", "resolved": "PointerEvent", "references": { "PointerEvent": { "location": "global", "id": "global::PointerEvent" } } } }, { "method": "wizardClose", "name": "wizardClose", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the wizard is canceled or finished." }, "complexType": { "original": "{ step: number; finished: boolean }", "resolved": "{ step: number; finished: boolean; }", "references": {} } }]; } static get elementRef() { return "hostElement"; } static get watchers() { return [{ "propName": "step", "methodName": "handleStepChange" }, { "propName": "pnId", "methodName": "handleIdChange", "handlerOptions": { "immediate": true } }]; } static get listeners() { return [{ "name": "resize", "method": "handleOverflow", "target": "window", "capture": false, "passive": true }]; } }