diff --git a/ui/bits/src/bits.cropDialog.ts b/ui/bits/src/bits.cropDialog.ts index 7f89eca2a578..1274626fd30a 100644 --- a/ui/bits/src/bits.cropDialog.ts +++ b/ui/bits/src/bits.cropDialog.ts @@ -71,7 +71,7 @@ export async function initModule(o?: CropOpts) { `, append: [{ where: '.crop-view', node: container }], actions: [ - { selector: '.dialog-actions > .cancel', listener: d => d.close() }, + { selector: '.dialog-actions > .cancel', listener: (_, d) => d.close() }, { selector: '.dialog-actions > .submit', listener: crop }, ], onClose: () => { diff --git a/ui/common/css/component/_dialog.scss b/ui/common/css/component/_dialog.scss index a78efd82223e..b36150d3004c 100644 --- a/ui/common/css/component/_dialog.scss +++ b/ui/common/css/component/_dialog.scss @@ -76,9 +76,22 @@ dialog { } } -// top level to reduce specificity, allow easy overrides .dialog-content { text-align: center; padding: 2em; color: $c-font; + + &.alert { + @extend %flex-column; + gap: 2em; + padding: 2em; + width: unset; + height: unset; + + span { + display: flex; + justify-content: end; + gap: 2em; + } + } } diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index e240d9830230..31de0a00207d 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -1,5 +1,6 @@ import { onInsert, looseH as h, VNode, Attrs, LooseVNodes } from './snabbdom'; import { isTouchDevice } from './device'; +import { escapeHtml } from './common'; import * as xhr from './xhr'; import * as licon from './licon'; @@ -12,7 +13,7 @@ export interface Dialog { showModal(): Promise; // resolves on close show(): Promise; // resolves on close - actions(actions?: Action | Action[]): void; // set new or reattach existing actions + updateActions(actions?: Action | Action[]): void; // set new actions, reattach existing if no arg provided close(): void; } @@ -22,7 +23,7 @@ export interface DialogOpts { htmlText?: string; // content, text will be used as-is cash?: Cash; // content, overrides htmlText, will be cloned and any 'none' class removed htmlUrl?: string; // content, overrides htmlText and cash, url will be xhr'd - append?: { node: HTMLElement; where?: string }[]; // if no where selector, appends to view + append?: { node: HTMLElement; where?: string; how?: 'after' | 'before' | 'child' }[]; // default 'child' attrs?: { dialog?: Attrs; view?: Attrs }; // optional attrs for dialog and view div actions?: Action | Action[]; // if present, add listeners to action buttons onClose?: (dialog: Dialog) => void; // called when dialog closes @@ -36,13 +37,13 @@ export interface DomDialogOpts extends DialogOpts { show?: 'modal' | boolean; // if not falsy, auto-show, and if 'modal' remove from dom on close } -//snabDialog automatically shows as 'modal' on redraw unless onInsert callback is supplied +//snabDialog automatically shows as 'modal' unless onInsert callback is supplied export interface SnabDialogOpts extends DialogOpts { vnodes?: LooseVNodes; // content, overrides other content properties - onInsert?: (dialog: Dialog) => void; // if supplied, call show() or showModal() manually + onInsert?: (dialog: Dialog) => void; // if supplied, you must call show() or showModal() manually } -export type ActionListener = (dialog: Dialog, action: Action, e: Event) => void; +export type ActionListener = (e: Event, dialog: Dialog, action: Action) => void; // Actions are managed listeners / results that are easily refreshed on DOM changes // if no event is specified, then 'click' is assumed @@ -50,6 +51,7 @@ export type Action = | { selector: string; event?: string | string[]; listener: ActionListener } | { selector: string; event?: string | string[]; result: string }; +// Safari versions before 15.4 need a polyfill for dialog. this "ready" promise resolves when that's loaded export const ready: Promise = site.load.then(async () => { window.addEventListener('resize', onResize); if (window.HTMLDialogElement) return true; @@ -58,8 +60,39 @@ export const ready: Promise = site.load.then(async () => { return dialogPolyfill !== undefined; }); +// non-blocking window.alert-alike +export async function alert(msg: string): Promise { + await domDialog({ + htmlText: escapeHtml(msg), + class: 'alert', + show: 'modal', + }); +} + +// non-blocking window.confirm-alike +export async function confirm(msg: string): Promise { + return ( + ( + await domDialog({ + htmlText: `
${escapeHtml(msg)}
+ `, + class: 'alert', + noCloseButton: true, + noClickAway: true, + show: 'modal', + actions: [ + { selector: '.yes', result: 'yes' }, + { selector: '.no', result: 'no' }, + ], + }) + ).returnValue === 'yes' + ); +} + +// when opts contains 'show', this promise resolves as show/showModal (on dialog close) so check returnValue +// if not, this promise resolves once assets are loaded and things are fully constructed but not shown export async function domDialog(o: DomDialogOpts): Promise { - const [html] = await assets(o); + const [html] = await loadAssets(o); const dialog = document.createElement('dialog'); for (const [k, v] of Object.entries(o.attrs?.dialog ?? {})) dialog.setAttribute(k, String(v)); @@ -72,7 +105,8 @@ export async function domDialog(o: DomDialogOpts): Promise { dialog.appendChild(anchor); } - const view = $as('
'); + const view = !html && o.append?.length === 1 ? o.append[0].node : document.createElement('div'); + view.classList.add('dialog-content'); if (o.class) view.classList.add(...o.class.split(/[. ]/).filter(x => x)); for (const [k, v] of Object.entries(o.attrs?.view ?? {})) view.setAttribute(k, String(v)); if (html) view.innerHTML = html; @@ -90,9 +124,10 @@ export async function domDialog(o: DomDialogOpts): Promise { return wrapper; } -// snab dialogs are shown by default, to suppress this pass onInsert callback +// snab dialogs without an onInsert callback are shown as modal by default. use onInsert callback to handle +// this yourself export function snabDialog(o: SnabDialogOpts): VNode { - const ass = assets(o); + const ass = loadAssets(o); let dialog: HTMLDialogElement; return h( @@ -139,7 +174,8 @@ export function snabDialog(o: SnabDialogOpts): VNode { class DialogWrapper implements Dialog { private restore?: { focus?: HTMLElement; overflow: string }; private resolve?: (dialog: Dialog) => void; - private eventCleanup: { el: Element; type: string; listener: EventListener }[] = []; + private actionCleanup: { el: Element; type: string; listener: EventListener }[] = []; + private dialogCleanup: { el: Element; type: string; listener: EventListener }[] = []; private observer: MutationObserver = new MutationObserver(list => { for (const m of list) if (m.type === 'childList') @@ -159,28 +195,42 @@ class DialogWrapper implements Dialog { if (dialogPolyfill) dialogPolyfill.registerDialog(dialog); // ios < 15.4 const justThen = Date.now(); - const cancelOnInterval = () => Date.now() - justThen > 200 && this.close('cancel'); - + const cancelOnInterval = (e: PointerEvent) => { + if (Date.now() - justThen < 200) return; + const r = dialog.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) + this.close('cancel'); + }; this.observer.observe(document.body, { childList: true, subtree: true }); view.parentElement?.style.setProperty('---viewport-height', `${window.innerHeight}px`); - view.addEventListener('click', e => e.stopPropagation()); - - dialog.addEventListener('cancel', () => !this.returnValue && (this.returnValue = 'cancel')); - dialog.addEventListener('close', this.onRemove); - dialog.querySelector('.close-button-anchor > .close-button')?.addEventListener('click', cancelOnInterval); - - if (!o.noClickAway) setTimeout(() => dialog.addEventListener('click', cancelOnInterval)); + this.addEventListener(view, 'click', e => e.stopPropagation()); + + this.addEventListener(dialog, 'cancel', () => !this.returnValue && (this.returnValue = 'cancel')); + this.addEventListener(dialog, 'close', this.onRemove); + this.addEventListener(dialog.querySelector('.close-button-anchor > .close-button'), 'click', () => + this.close('cancel'), + ); + + if (!o.noClickAway) + setTimeout(() => { + this.addEventListener(document.body, 'click', cancelOnInterval); + this.addEventListener(dialog, 'click', cancelOnInterval); + }); for (const app of o.append ?? []) { - (app.where ? view.querySelector(app.where) : view)?.appendChild(app.node); + if (app.node === view) break; + const where = (app.where ? view.querySelector(app.where) : view)!; + if (app.how === 'before') where.before(app.node); + else if (app.how === 'after') where.after(app.node); + else where.appendChild(app.node); } - this.actions(); + this.updateActions(); } - get open() { + get open(): boolean { return this.dialog.open; } - get returnValue() { + get returnValue(): string { return this.dialog.returnValue; } @@ -188,27 +238,6 @@ class DialogWrapper implements Dialog { this.dialog.returnValue = v; } - // attach/reattach existing listeners or provide a set of new ones - actions = (actions = this.o.actions) => { - for (const { el, type, listener } of this.eventCleanup) { - el.removeEventListener(type, listener); - } - this.eventCleanup = []; - if (!actions) return; - for (const a of Array.isArray(actions) ? actions : [actions]) { - for (const event of Array.isArray(a.event) ? a.event : a.event ? [a.event] : ['click']) { - for (const el of this.view.querySelectorAll(a.selector)) { - const listener = (e: Event) => { - if ('listener' in a) a.listener(this, a, e); - else this.close(a.result); - }; - this.eventCleanup.push({ el, type: event, listener }); - el.addEventListener(event, listener); - } - } - } - }; - show = (): Promise => { this.restore = { overflow: document.body.style.overflow, @@ -224,12 +253,11 @@ class DialogWrapper implements Dialog { focus: document.activeElement as HTMLElement, overflow: document.body.style.overflow, }; + (this.view.querySelectorAll(focusQuery)[1] as HTMLElement)?.focus(); - $(focusQuery, this.view)[1]?.focus(); - document.body.style.overflow = 'hidden'; - + this.addEventListener(this.dialog, 'keydown', onModalKeydown); this.view.scrollTop = 0; - this.dialog.addEventListener('keydown', onModalKeydown); + document.body.style.overflow = 'hidden'; this.returnValue = ''; this.dialog.showModal(); return new Promise(resolve => (this.resolve = resolve)); @@ -239,6 +267,31 @@ class DialogWrapper implements Dialog { this.dialog.close(this.returnValue || v || 'ok'); }; + // attach/reattach existing listeners or provide a set of new ones + updateActions = (actions = this.o.actions) => { + for (const { el, type, listener } of this.actionCleanup) { + el.removeEventListener(type, listener); + } + this.actionCleanup = []; + if (!actions) return; + for (const a of Array.isArray(actions) ? actions : [actions]) { + for (const event of Array.isArray(a.event) ? a.event : a.event ? [a.event] : ['click']) { + for (const el of this.view.querySelectorAll(a.selector)) { + const listener = + 'listener' in a ? (e: Event) => a.listener(e, this, a) : () => this.close(a.result); + this.actionCleanup.push({ el, type: event, listener }); + el.addEventListener(event, listener); + } + } + } + }; + + private addEventListener = (el: Element | null, type: string, listener: EventListener) => { + if (!el) return; + this.dialogCleanup.push({ el, type, listener }); + el.addEventListener(type, listener); + }; + private onRemove = () => { this.observer.disconnect(); if (!this.dialog.returnValue) this.dialog.returnValue = 'cancel'; @@ -248,24 +301,26 @@ class DialogWrapper implements Dialog { this.resolve?.(this); this.o.onClose?.(this); this.dialog.remove(); - for (const css of this.o.css ?? []) - 'hashed' in css && site.asset.removeCssPath(css.hashed), 'url' in css && site.asset.removeCss(css.url); + for (const css of this.o.css ?? []) { + if ('hashed' in css) site.asset.removeCssPath(css.hashed); + else if ('url' in css) site.asset.removeCss(css.url); + } + for (const { el, type, listener } of this.dialogCleanup) { + el.removeEventListener(type, listener); + } }; } -function assets(o: DialogOpts) { - const cssPromises = (o.css ?? []).map(css => { - if ('hashed' in css) return site.asset.loadCssPath(css.hashed); - else if ('url' in css) return site.asset.loadCss(css.url); - else return Promise.resolve(); - }); +function loadAssets(o: DialogOpts) { return Promise.all([ o.htmlUrl ? xhr.text(o.htmlUrl) : Promise.resolve( o.cash ? $as($(o.cash).clone().removeClass('none')).outerHTML : o.htmlText, ), - ...cssPromises, + ...(o.css ?? []).map(css => + 'hashed' in css ? site.asset.loadCssPath(css.hashed) : site.asset.loadCss(css.url), + ), ]); } diff --git a/ui/site/src/log.ts b/ui/site/src/log.ts index 933060ff3ec8..f0d74a9d3e81 100644 --- a/ui/site/src/log.ts +++ b/ui/site/src/log.ts @@ -1,4 +1,5 @@ import { objectStorage, ObjectStorage, DbInfo } from 'common/objectStorage'; +import { alert } from 'common/dialog'; const dbInfo: DbInfo = { db: 'log--db', @@ -74,15 +75,17 @@ export default function makeLog(): LichessLog { }; function terseHref(): string { - return window.location.href.replace(/^(https:\/\/)?lichess\.org\//, '/'); + return window.location.href.replace(/^(https:\/\/)?lichess(?:1?)\.org\//, '/'); } window.addEventListener('error', async e => { const loc = e.filename ? ` - (${e.filename}:${e.lineno}:${e.colno})` : ''; log(`${terseHref()} - ${e.message}${loc}\n${e.error?.stack ?? ''}`.trim()); + if (site.debug) alert(`${e.message}${loc}\n${e.error?.stack ?? ''}`); }); window.addEventListener('unhandledrejection', async e => { log(`${terseHref()} - ${e.reason}`); + if (site.debug) alert(`${e.reason}`); }); return log;