UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

220 lines (194 loc) 7.83 kB
import { classMap } from 'lit/directives/class-map.js' import { customElement, property, state } from 'lit/decorators.js' import { html, nothing, PropertyValues } from 'lit' import { PktElementWithSlot } from '@/base-elements/element-with-slot' import { slotContent } from '@/directives/slot-content' 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' type ModalSize = TPktSize | 'fit-content' export interface IPktModal { open?: boolean headingText?: string removePadding?: boolean hideCloseButton?: boolean closeOnBackdropClick?: boolean closeButtonSkin?: 'blue' | 'yellow-filled' size?: ModalSize variant?: 'dialog' | 'drawer' drawerPosition?: 'left' | 'right' transparentBackdrop?: boolean } export class PktModal extends PktElementWithSlot implements IPktModal { // Public properties @property({ type: Boolean, reflect: true }) open?: boolean = false @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?: ModalSize = specs.props.size.default as ModalSize @property({ type: String }) variant?: 'dialog' | 'drawer' = 'dialog' @property({ type: String }) drawerPosition?: 'left' | 'right' = 'right' @property({ type: Boolean }) transparentBackdrop?: boolean = false dialogRef: Ref<HTMLDialogElement> = createRef() @state() _isOpen: boolean = false constructor() { super() 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 updated(changedProperties: PropertyValues): void { super.updated(changedProperties) if (changedProperties.has('open')) { if (this.open && !this._isOpen) { this.showModal() } else if (!this.open && this._isOpen) { this.close(new Event('close'), false) } } } 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 this.open = 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.open = true this.dialogRef.value?.showModal() 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, } return html` <dialog class=${classMap(classes)} ${ref(this.dialogRef)} aria-labelledby=${this.headingText ? 'pkt-modal__headingText' : nothing} 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)} aria-label="close" iconname="close" variant="icon-only" size="medium" skin=${isCloseButtonSkinDefault ? 'tertiary' : 'primary'} > 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)}> ${slotContent(this)} </div> </div> </div> </dialog> ` } } try { customElement('pkt-modal')(PktModal) } catch (e) { console.warn('Forsøker å definere <pkt-modal>, men den er allerede definert') }