Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

recreate debug alerts branch #15705

Merged
merged 1 commit into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that might conflict with (user) tooltips, select, mselect and anything adding a layer on top of the window.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming they preventDefault on the click, it shouldn't arrive on the body element. If it does, I think we still want to close in that case.

I added the bounding client rect check because non-modal dialogs don't trap background clicks for us like modal ones. That said, I'm not super attached to even allowing/supporting click away for non-modals.

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\//, '/');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather see (?:lichess|lichess1) for readability and searchability.

}

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
Loading