import {keyName} from "w3c-keyname"
import {findTarget} from "./basic.js"
import {gettext} from "./settings.js"
import {initTooltips} from "./tooltip.js"
export interface DialogButtonSpec {
type?: "close" | "cancel" | "ok"
text?: string
classes?: string
click?: (event?: Event) => unknown
icon?: string
dropdown?: boolean
}
export interface DialogOptions {
id?: string | false
classes?: string | false
title?: string
body?: string
restoreActiveElement?: boolean
height?: number | false
width?: number | false
canClose?: boolean
help?: (() => void) | false
note?: {text?: string; display?: boolean}
blur?: boolean
buttons?: DialogButtonSpec[]
beforeClose?: (() => void) | false
onClose?: (() => void) | false
icon?: string | false
scroll?: boolean | false
canEscape?: boolean
fullScreen?: boolean | false
initialFocus?: string | null
}
interface InternalDialogButton {
text: string
classes: string | false
click: (event?: Event) => unknown
icon: string | false
dropdown: boolean
}
interface NoteSpec {
text?: string
display?: boolean
}
const dialogTemplate = ({
id,
classes,
title,
height,
width,
icon,
buttons,
zIndex,
body,
scroll,
help,
canClose,
note,
blur
}: {
id: string | false
classes: string | false
title: string
height: string
width: string
icon: string | false
buttons: InternalDialogButton[]
zIndex: number
body: string
scroll: boolean | false
help: (() => void) | false
canClose: boolean
note: NoteSpec
blur: boolean
}) =>
`
${icon ? `` : ""}
${title}
${
help
? ``
: ""
}
${
canClose
? ``
: ""
}
${note.text ? `
${noteTemplate(note)}
` : ""}
${body}
`
const noteTemplate = (note: NoteSpec): string => {
return note.text
? `${note.text}
`
: ""
}
const buttonsTemplate = ({
buttons
}: {
buttons: InternalDialogButton[]
}): string => buttons.map(button => buttonTemplate(button)).join("")
const buttonTemplate = ({
text,
classes,
icon,
dropdown
}: InternalDialogButton): string => ``
const BUTTON_TYPES: Record<
"close" | "cancel" | "ok",
{text: string; classes: string; click: (dialog: Dialog) => () => void}
> = {
close: {
text: gettext("Close"),
classes: "fw-orange",
click: dialog => () => dialog.close()
},
cancel: {
text: gettext("Cancel"),
classes: "fw-orange",
click: dialog => () => dialog.close()
},
ok: {
text: gettext("OK"),
classes: "fw-dark",
click: dialog => () => dialog.close()
}
}
export class Dialog {
id: string | false
classes: string | false
title: string
body: string
restoreActiveElement: boolean
height: string
width: string
canClose: boolean
help: (() => void) | false
note: NoteSpec
blur: boolean
buttons: InternalDialogButton[]
beforeClose: (() => void) | false
onClose: (() => void) | false
icon: string | false
scroll: boolean | false
canEscape: boolean
dialogEl!: HTMLElement
backdropEl!: HTMLElement
dragging: {x: number; y: number} | false
hasBeenMoved: boolean
listeners: Record void>
fullScreen: boolean | false
initialFocus: string | null
previousActiveElement: Element | null
firstFocusableEl: Element | null
lastFocusableEl: Element | null
focusableEls: Element[] | null
constructor(options: DialogOptions) {
this.id = options.id || false
this.classes = options.classes || false
this.title = options.title || ""
this.body = options.body || ""
this.restoreActiveElement = options.restoreActiveElement !== false // default is true
this.height = options.height ? `${options.height}px` : "auto"
this.width = options.width ? `${options.width}px` : "auto"
this.canClose = "canClose" in options ? options.canClose! : true
this.help = "help" in options ? options.help! : false
this.note = "note" in options ? options.note! : {}
this.blur = "blur" in options ? options.blur! : true
this.buttons = []
if (options.buttons) {
this.setButtons(options.buttons)
}
this.beforeClose = options.beforeClose || false
this.onClose = options.onClose || false
this.icon = options.icon || false
this.scroll = options.scroll || false
this.canEscape =
options.canEscape ??
!!options.buttons?.find(button =>
["cancel", "close"].includes(button.type || "")
)
this.dialogEl = null!
this.backdropEl = null!
this.dragging = false
this.hasBeenMoved = false
this.listeners = {}
this.fullScreen = options.fullScreen ? options.fullScreen : false
this.initialFocus = options.initialFocus || null
this.previousActiveElement = null // Store previously focused element
this.firstFocusableEl = null
this.lastFocusableEl = null
this.focusableEls = null
}
setButtons(buttons: DialogButtonSpec[]): void {
this.buttons = buttons.map(button => ({
text: button.text
? button.text
: button.type
? BUTTON_TYPES[button.type].text
: "",
classes: button.classes
? button.classes
: button.type
? BUTTON_TYPES[button.type].classes
: false,
click: button.click
? button.click
: button.type
? BUTTON_TYPES[button.type].click(this)
: () => {},
icon: button.icon ? button.icon : false,
dropdown: button.dropdown ? true : false
}))
}
open(): void {
if (this.dialogEl) {
return
}
// Store currently focused element to restore later
this.previousActiveElement = this.restoreActiveElement
? document.activeElement
: null
if (this.fullScreen) {
this.height = "85vh"
}
document.body.insertAdjacentHTML(
"beforeend",
dialogTemplate({
id: this.id,
classes: this.classes,
title: this.title,
height: this.height,
width: this.width,
icon: this.icon,
buttons: this.buttons,
zIndex: this.nextDialogZIndex(),
body: this.body,
scroll: this.scroll,
canClose: this.canClose,
help: this.help,
note: this.note,
blur: this.blur
})
)
this.backdropEl = document.body.lastElementChild as HTMLElement
this.dialogEl = this.backdropEl.previousElementSibling as HTMLElement
if (this.fullScreen) {
// The flex layout that keeps the titlebar, scrollable content and
// button pane inside the window comes from the
// `fw-dialog-fullscreen` CSS class; the inline styles remain for
// hosts that load an older copy of the stylesheet.
this.dialogEl.classList.add("fw-dialog-fullscreen")
this.dialogEl.style.width = "98%"
this.dialogEl.style.height = "100%"
this.dialogEl.style.position = "fixed"
this.dialogEl.style.top = "0px"
} else {
// Defer centering until the browser has calculated the dialog's
// intrinsic size. requestAnimationFrame is preferred, but it can be
// throttled in hidden/headless contexts, so fall back to a timeout.
if (typeof requestAnimationFrame !== "undefined") {
requestAnimationFrame(() => this.centerDialog())
setTimeout(() => this.centerDialog(), 0)
} else {
this.centerDialog()
}
}
// Set dialog attributes for accessibility
this.dialogEl.setAttribute("role", "dialog")
this.dialogEl.setAttribute("aria-modal", "true")
if (this.title) {
this.dialogEl.setAttribute("aria-labelledby", "dialog-title")
const titleEl = this.dialogEl.querySelector(
".fw-dialog-title"
) as HTMLElement
titleEl.id = "dialog-title"
}
// Get all focusable elements
this.focusableEls = this.getFocusableElements()
this.firstFocusableEl = this.focusableEls[0] || null
this.lastFocusableEl =
this.focusableEls[this.focusableEls.length - 1] || null
// Set initial focus to the most appropriate element
const initialFocusElement = this.getInitialFocusElement()
if (initialFocusElement) {
setTimeout(() => (initialFocusElement as HTMLElement).focus(), 0)
} else if (this.firstFocusableEl) {
;(this.firstFocusableEl as HTMLElement).focus()
} else {
this.dialogEl.focus()
}
this.bind()
// Make sure tooltips inside the dialog are portal-rendered so they are
// not clipped by the dialog's scrollable content.
initTooltips()
}
refreshButtons(): void {
const buttonSet = this.dialogEl.querySelector(".fw-dialog-buttonset")!
buttonSet.innerHTML = buttonsTemplate({buttons: this.buttons})
}
refreshNote(): void {
const noteContainer = this.dialogEl.querySelector(".fw-note-container")!
noteContainer.innerHTML = noteTemplate(this.note)
}
centerDialog(): void {
const totalWidth = window.innerWidth,
totalHeight = window.innerHeight,
dialogWidth = this.dialogEl.clientWidth,
dialogHeight = this.dialogEl.clientHeight,
scrollTopOffset = window.pageYOffset,
scrollLeftOffset = window.pageXOffset
this.dialogEl.style.top = `${(totalHeight - dialogHeight) / 2 + scrollTopOffset}px`
this.dialogEl.style.left = `${(totalWidth - dialogWidth) / 2 + scrollLeftOffset}px`
}
adjustDialogToScroll(): void {
this.dialogEl.style.top = `${Math.max(
Math.min(
this.dialogEl.offsetTop,
this.backdropEl.scrollHeight -
this.dialogEl.scrollHeight +
window.pageYOffset
),
window.pageYOffset
)}px`
}
moveDialog(x: number, y: number): void {
if (!this.dragging) {
return
}
this.dialogEl.style.top = `${Math.min(
Math.max(y - this.dragging.y, 0),
this.backdropEl.scrollHeight -
this.dialogEl.scrollHeight +
window.pageYOffset
)}px`
this.dialogEl.style.left = `${Math.min(
Math.max(x - this.dragging.x, 0),
document.body.scrollWidth - this.dialogEl.scrollWidth
)}px`
this.hasBeenMoved = true
}
onScroll(_event: Event): void {
if (this.hasBeenMoved) {
// The dialog has been moved manually. We just adjust the position to make it stay in the view.
this.adjustDialogToScroll()
} else {
this.centerDialog()
}
}
onKeydown(event: KeyboardEvent): void {
let name = keyName(event)
if (event.altKey) {
name = "Alt-" + name
}
if (event.ctrlKey) {
name = "Ctrl-" + name
}
if (event.metaKey) {
name = "Meta-" + name
}
if (event.shiftKey) {
name = "Shift-" + name
}
if (name === "Escape" && this.canEscape) {
event.preventDefault()
this.close()
return
} else if (name === "Tab") {
if (document.activeElement === this.lastFocusableEl) {
event.preventDefault()
;(this.firstFocusableEl as HTMLElement).focus()
}
} else if (name === "Shift-Tab") {
if (document.activeElement === this.firstFocusableEl) {
event.preventDefault()
;(this.lastFocusableEl as HTMLElement).focus()
}
}
}
bind(): void {
this.listeners.onKeydown = event =>
this.onKeydown(event as KeyboardEvent)
document.body.addEventListener("keydown", this.listeners.onKeydown)
this.dialogEl.addEventListener("click", event => {
const el: {target?: Element | null} = {}
switch (true) {
case findTarget(event, ".fw-dialog-buttonpane button", el): {
event.preventDefault()
let buttonNumber = 0
let seekItem = el.target as Element
while (seekItem.previousElementSibling) {
buttonNumber++
seekItem = seekItem.previousElementSibling
}
this.buttons[buttonNumber].click(event)
break
}
case findTarget(event, ".fw-dialog-titlebar-close", el):
event.preventDefault()
this.close()
break
case findTarget(event, ".fw-dialog-titlebar-help", el):
event.preventDefault()
if (this.help) {
this.help()
}
break
default:
break
}
})
if (!this.fullScreen) {
this.listeners.onScroll = event => this.onScroll(event)
window.addEventListener("scroll", this.listeners.onScroll, false)
this.dialogEl.addEventListener("mousedown", event => {
const el: {target?: Element | null} = {}
switch (true) {
case findTarget(event, ".fw-dialog-titlebar", el):
this.dragging = {
x:
(event as MouseEvent).clientX -
this.dialogEl.offsetLeft,
y:
(event as MouseEvent).clientY -
this.dialogEl.offsetTop
}
break
default:
break
}
})
this.dialogEl.addEventListener("mouseup", event => {
const el: {target?: Element | null} = {}
switch (true) {
case findTarget(event, ".fw-dialog-titlebar", el):
this.dragging = false
break
default:
break
}
})
this.dialogEl.addEventListener("mousemove", event => {
if (!this.dragging) {
return
}
this.moveDialog(
(event as MouseEvent).clientX,
(event as MouseEvent).clientY
)
})
}
// Prevent clicks outside dialog from moving focus outside
this.backdropEl.addEventListener("click", event => {
event.preventDefault()
if (this.canClose) {
this.close()
}
})
// Prevent focus from leaving dialog when clicking backdrop
this.backdropEl.addEventListener("mousedown", event => {
event.preventDefault()
})
}
nextDialogZIndex(): number {
// Start at the CSS-defined base (1000) so dialogs stay well above
// other positioned UI elements (menus, overlays, alerts, etc.).
let zIndex = 1000
document.querySelectorAll("div.fw-dialog").forEach(dialogEl => {
const computedZIndex = parseInt(
window.getComputedStyle(dialogEl as HTMLElement).zIndex
)
zIndex = Math.max(
zIndex,
Number.isNaN(computedZIndex) ? 1000 : computedZIndex
)
})
zIndex += 2
document.body.style.setProperty(
"--highest-dialog-z-index",
String(zIndex)
)
return zIndex
}
getFocusableElements(): Element[] {
// Get all focusable elements
const focusableSelectors = [
"button:not([disabled])",
"[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
].join(",")
const elements = Array.from(
this.dialogEl.querySelectorAll(focusableSelectors)
)
// Filter out hidden elements
return elements.filter(el => {
const style = window.getComputedStyle(el)
return style.display !== "none" && style.visibility !== "hidden"
})
}
getInitialFocusElement(): Element | undefined {
if (this.initialFocus) {
const customFocusElement = this.dialogEl.querySelector(
this.initialFocus
)
if (customFocusElement) {
return customFocusElement
}
}
// Get all focusable elements
const elements = this.getFocusableElements()
// Try to find the most appropriate initial focus target
const priorityElements = [
// First try to find a text input
elements.find(
el =>
el.tagName === "INPUT" &&
(el as HTMLInputElement).type === "text"
),
// Then try to find the first button in the button pane
elements.find(el => el.closest(".fw-dialog-buttonpane")),
// Then try to find any input
elements.find(el => el.tagName === "INPUT"),
// Then try to find any button except close/help
elements.find(
el =>
el.tagName === "BUTTON" &&
!el.classList.contains("fw-dialog-titlebar-close") &&
!el.classList.contains("fw-dialog-titlebar-help")
)
]
// Return the first element that exists
return priorityElements.find(el => el) || elements[0]
}
close(): void {
if (!this.dialogEl) {
return
}
if (!this.fullScreen) {
window.removeEventListener("scroll", this.listeners.onScroll, false)
}
document.body.removeEventListener("keydown", this.listeners.onKeydown)
if (this.beforeClose) {
this.beforeClose()
}
this.dialogEl.parentElement!.removeChild(this.dialogEl)
this.backdropEl.parentElement!.removeChild(this.backdropEl)
// Restore focus to previous element
if (
this.previousActiveElement &&
(this.previousActiveElement as HTMLElement).focus
) {
;(this.previousActiveElement as HTMLElement).focus()
}
if (this.onClose) {
this.onClose()
}
}
}