@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
220 lines (194 loc) • 7.83 kB
text/typescript
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
open?: boolean = false
headingText?: string = ''
removePadding?: boolean = false
hideCloseButton?: boolean = specs.props.hideCloseButton.default
closeOnBackdropClick?: boolean = specs.props.closeOnBackdropClick.default
closeButtonSkin?: 'blue' | 'yellow-filled' = 'blue'
size?: ModalSize = specs.props.size.default as ModalSize
variant?: 'dialog' | 'drawer' = 'dialog'
drawerPosition?: 'left' | 'right' = 'right'
transparentBackdrop?: boolean = false
dialogRef: Ref<HTMLDialogElement> = createRef()
_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"
=${(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
=${(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')
}