Skip to content

Commit

Permalink
recreate debug alerts branch
Browse files Browse the repository at this point in the history
  • Loading branch information
schlawg committed Jul 12, 2024
1 parent 393f932 commit 8fe9e4b
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 60 deletions.
2 changes: 1 addition & 1 deletion ui/bits/src/bits.cropDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function initModule(o?: CropOpts) {
<button class="button submit">submit</button></span>`,
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: () => {
Expand Down
15 changes: 14 additions & 1 deletion ui/common/css/component/_dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
169 changes: 112 additions & 57 deletions ui/common/src/dialog.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,7 +13,7 @@ export interface Dialog {

showModal(): Promise<Dialog>; // resolves on close
show(): Promise<Dialog>; // 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;
}

Expand All @@ -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
Expand All @@ -36,20 +37,21 @@ 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
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<boolean> = site.load.then(async () => {
window.addEventListener('resize', onResize);
if (window.HTMLDialogElement) return true;
Expand All @@ -58,8 +60,39 @@ export const ready: Promise<boolean> = site.load.then(async () => {
return dialogPolyfill !== undefined;
});

// non-blocking window.alert-alike
export async function alert(msg: string): Promise<void> {
await domDialog({
htmlText: escapeHtml(msg),
class: 'alert',
show: 'modal',
});
}

// non-blocking window.confirm-alike
export async function confirm(msg: string): Promise<boolean> {
return (
(
await domDialog({
htmlText: `<div>${escapeHtml(msg)}</div>
<span><button class="button no">no</button><button class="button yes">yes</button></span>`,
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<Dialog> {
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));
Expand All @@ -72,7 +105,8 @@ export async function domDialog(o: DomDialogOpts): Promise<Dialog> {
dialog.appendChild(anchor);
}

const view = $as<HTMLElement>('<div class="dialog-content">');
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;
Expand All @@ -90,9 +124,10 @@ export async function domDialog(o: DomDialogOpts): Promise<Dialog> {
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(
Expand Down Expand Up @@ -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')
Expand All @@ -159,56 +195,49 @@ 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;
}

set returnValue(v: string) {
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<Dialog> => {
this.restore = {
overflow: document.body.style.overflow,
Expand All @@ -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));
Expand All @@ -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';
Expand All @@ -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<HTMLElement>($(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),
),
]);
}

Expand Down
5 changes: 4 additions & 1 deletion ui/site/src/log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { objectStorage, ObjectStorage, DbInfo } from 'common/objectStorage';
import { alert } from 'common/dialog';

const dbInfo: DbInfo = {
db: 'log--db',
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 8fe9e4b

Please sign in to comment.