@postnord/web-components
Version:
PostNord Web Components
797 lines (796 loc) • 32.3 kB
JavaScript
/*!
* 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
}];
}
}