UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

218 lines (193 loc) 7.74 kB
import { classMap } from 'lit/directives/class-map.js' import { customElement, property, state } from 'lit/decorators.js' import { html, nothing, PropertyValues } from 'lit' import { PktElement } from '@/base-elements/element' import { PktSlotController } from '@/controllers/pkt-slot-controller' import { ref } from 'lit/directives/ref.js' import { Ref, createRef } from 'lit/directives/ref.js' import { TPktSize } from '@/types/size' import specs from 'componentSpecs/modal.json' import '@/components/icon' export interface IPktModal { headingText?: string removePadding?: boolean hideCloseButton?: boolean closeOnBackdropClick?: boolean closeButtonSkin?: 'blue' | 'yellow-filled' size?: TPktSize variant?: 'dialog' | 'drawer' drawerPosition?: 'left' | 'right' transparentBackdrop?: boolean } @customElement('pkt-modal') export class PktModal extends PktElement implements IPktModal { // Public properties" @property({ type: String }) headingText?: string = '' @property({ type: Boolean }) removePadding?: boolean = false @property({ type: Boolean }) hideCloseButton?: boolean = specs.props.hideCloseButton.default @property({ type: Boolean }) closeOnBackdropClick?: boolean = specs.props.closeOnBackdropClick.default @property({ type: String }) closeButtonSkin?: 'blue' | 'yellow-filled' = 'blue' @property({ type: String }) size?: TPktSize = specs.props.size.default as TPktSize @property({ type: String }) variant?: 'dialog' | 'drawer' = 'dialog' @property({ type: String }) drawerPosition?: 'left' | 'right' = 'right' @property({ type: Boolean }) transparentBackdrop?: boolean = false defaultSlot: Ref<HTMLElement> = createRef() dialogRef: Ref<HTMLDialogElement> = createRef() @state() _isOpen: boolean = false constructor() { super() this.slotController = new PktSlotController(this, this.defaultSlot) this._isOpen = false } async connectedCallback(): Promise<void> { super.connectedCallback() document.addEventListener('keydown', this.handleKeyDown.bind(this)) document.addEventListener('click', this.handleBackdropClick.bind(this)) } disconnectedCallback(): void { super.disconnectedCallback() document.removeEventListener('keydown', this.handleKeyDown) document.removeEventListener('click', this.handleBackdropClick) } protected async firstUpdated(_changedProperties: PropertyValues): Promise<void> { super.firstUpdated(_changedProperties) if (this.dialogRef.value && !window.HTMLDialogElement && !this.dialogRef.value.showModal) { if ('document' in window && 'createElement' in document) { const dialogPolyfill = await import('dialog-polyfill').then((module) => module.default) dialogPolyfill.registerDialog(this.dialogRef.value) } this.dialogRef.value.addEventListener('close', () => { this.close(new Event('close'), true) }) } } // P R I V A T E M E T H O D S private handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { this.close(event) } } private handleBackdropClick(event: MouseEvent) { if (this.closeOnBackdropClick && event.target === this.dialogRef?.value) { this.close(event) } } private isElementInViewport(element: HTMLElement): boolean { const rect = element.getBoundingClientRect() return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ) } // P U B L I C M E T H O D S public close = (event: Event, bypassNativeClose: boolean = false) => { if (!this._isOpen) return this._isOpen = false document.body.classList.remove('pkt-modal--open') // Scroll to the opener element if it's not in the viewport const openerElement = document.activeElement as HTMLElement if (openerElement && !this.isElementInViewport(openerElement)) { openerElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } // Dispatch close event this.dispatchEvent( new CustomEvent('close', { detail: { origin: event }, bubbles: true, composed: true }), ) if (!bypassNativeClose) this.dialogRef.value?.close() this.requestUpdate() } public showModal = (event: Event | null = null) => { this._isOpen = true this.dialogRef.value?.showModal() const modal = document.querySelector('.pkt-modal') // Prevent the first focusable child from being auto-focused requestAnimationFrame(() => { if (this.dialogRef.value) { this.dialogRef.value?.focus() } }) if (modal) { document.body.classList.add('pkt-modal--open') } this.dispatchEvent( new CustomEvent('showModal', { detail: { origin: event }, bubbles: true, composed: true }), ) this.requestUpdate() } render() { const classes = { 'pkt-modal': true, 'pkt-modal--removePadding': this.removePadding ?? false, 'pkt-modal--noHeadingText': this.headingText === '' || this.headingText === undefined, 'pkt-modal--noShadow': this.closeButtonSkin === 'yellow-filled', 'pkt-modal--transparentBackdrop': this.transparentBackdrop ?? false, [`pkt-modal--${this.size}`]: this.size !== undefined, [`pkt-modal__${this.variant}`]: this.variant !== undefined, [`pkt-modal__drawer--${this.drawerPosition}`]: this.variant === 'drawer', } const headingClasses = { 'pkt-modal__headingText': true, 'pkt-txt-24': true, } const contentClasses = { 'pkt-modal__content': true, 'pkt-txt-18-light': true, } const isCloseButtonSkinDefault = this.closeButtonSkin === 'blue' const closeButtonClasses = { 'pkt-modal__closeButton': true, [`pkt-modal__closeButton--${this.closeButtonSkin}`]: true, } const buttonClasses = { 'pkt-btn': true, [`pkt-btn--${isCloseButtonSkinDefault ? 'tertiary' : 'primary'}`]: true, 'pkt-btn--icon-only': true, 'pkt-btn--medium': true, } return html` <dialog class=${classMap(classes)} ${ref(this.dialogRef)} aria-labelledby="pkt-modal__headingText" aria-describedby="pkt-modal__content" @close=${(event: Event) => this.close(event, true)} > <div class="pkt-modal__wrapper"> ${this.headingText || !this.hideCloseButton ? html`<div class="pkt-modal__header"> <div class="pkt-modal__header-background"></div> ${this.headingText ? html`<h1 id="pkt-modal__headingText" class=${classMap(headingClasses)}> ${this.headingText} </h1>` : html`<div class="pkt-modal__headingText"></div>`} ${!this.hideCloseButton ? html`<div class="${classMap(closeButtonClasses)}"> <pkt-button @click=${(event: Event) => this.close(event)} class=${classMap(buttonClasses)} aria-label="close" iconname="close" variant="icon-only" > Lukk </pkt-button> </div>` : html`<div class="pkt-modal__noCloseButton"></div>`} </div>` : nothing} <div class="pkt-modal__container"> <div id="pkt-modal__content" class=${classMap(contentClasses)} ${ref(this.defaultSlot)} ></div> </div> </div> </dialog> ` } }