@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
218 lines (193 loc) • 7.74 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 { 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
}
export class PktModal extends PktElement implements IPktModal {
// Public properties"
headingText?: string = ''
removePadding?: boolean = false
hideCloseButton?: boolean = specs.props.hideCloseButton.default
closeOnBackdropClick?: boolean = specs.props.closeOnBackdropClick.default
closeButtonSkin?: 'blue' | 'yellow-filled' = 'blue'
size?: TPktSize = specs.props.size.default as TPktSize
variant?: 'dialog' | 'drawer' = 'dialog'
drawerPosition?: 'left' | 'right' = 'right'
transparentBackdrop?: boolean = false
defaultSlot: Ref<HTMLElement> = createRef()
dialogRef: Ref<HTMLDialogElement> = createRef()
_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"
=${(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)}
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>
`
}
}