');
+ if (o.class) view.classList.add(...o.class.split('.'));
+ for (const [k, v] of Object.entries(o.attrs?.view ?? {})) view.setAttribute(k, String(v));
+ if (html) view.innerHTML = html;
+
+ const scrollable = $as
('');
+ scrollable.appendChild(view);
+ dialog.appendChild(scrollable);
+
+ (o.parent ?? document.body).appendChild(dialog);
+
+ const wrapper = new DialogWrapper(dialog, view, o);
+ if (o.show && o.show === 'modal') wrapper.showModal();
+ else if (o.show) wrapper.show();
+
+ return wrapper;
+}
+
+// snab dialogs are shown by default, to suppress this pass onInsert callback
+export function snabDialog(o: SnabDialogOpts): VNode {
+ const ass = assets(o);
+ let dialog: HTMLDialogElement;
+
+ return h(
+ `dialog${isTouchDevice() ? '.touch-scroll' : ''}`,
+ {
+ key: o.class ?? 'dialog',
+ attrs: o.attrs?.dialog,
+ hook: onInsert(el => (dialog = el as HTMLDialogElement)),
+ },
+ [
+ o.noCloseButton ||
+ h(
+ 'div.close-button-anchor',
+ h('button.close-button', { attrs: { 'data-icon': licon.X, 'aria-label': 'Close' } }),
+ ),
+ h(
+ 'div.scrollable',
+ h(
+ 'div.dialog-content' + (o.class ? `.${o.class}` : ''),
+ {
+ attrs: o.attrs?.view,
+ hook: onInsert(async view => {
+ const [html] = await ass;
+ if (html && !o.vnodes) view.innerHTML = html;
+
+ const wrapper = new DialogWrapper(dialog, view, o);
+
+ if (o.onInsert) o.onInsert(wrapper);
+ else wrapper.showModal();
+ }),
+ },
+ o.vnodes,
+ ),
+ ),
+ ],
+ );
+}
+
+class DialogWrapper implements Dialog {
+ restoreFocus?: HTMLElement;
+
+ constructor(
+ readonly dialog: HTMLDialogElement,
+ readonly view: HTMLElement,
+ readonly o: DialogOpts,
+ ) {
+ if (dialogPolyfill) dialogPolyfill.registerDialog(dialog); // ios < 15.4
+
+ view.parentElement?.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); // sigh
+ view.addEventListener('click', e => e.stopPropagation());
+
+ dialog.addEventListener('cancel', () => !this.returnValue && (this.returnValue = 'cancel'));
+ dialog.addEventListener('close', this.onClose);
+ dialog
+ .querySelector('.close-button-anchor > .close-button')
+ ?.addEventListener('click', () => this.close('cancel'));
+
+ if (!o.noClickAway) setTimeout(() => document.addEventListener('click', () => this.close('cancel')), 0);
+ }
+
+ get open() {
+ return this.dialog.open;
+ }
+
+ get returnValue() {
+ return this.dialog.returnValue;
+ }
+
+ set returnValue(v: string) {
+ this.dialog.returnValue = v;
+ }
+
+ show = () => {
+ this.returnValue = '';
+ this.dialog.show();
+ };
+
+ close = (v?: string) => {
+ this.dialog.close(this.returnValue || v || 'ok');
+ };
+
+ onClose = () => {
+ this.o.onClose?.(this);
+ if ('show' in this.o && this.o.show === 'modal') {
+ this.dialog.remove();
+ this.restoreFocus?.focus();
+ }
+ if (!this.dialog.returnValue) this.dialog.returnValue = 'cancel';
+ this.restoreFocus = undefined;
+ };
+
+ showModal = () => {
+ this.restoreFocus = document.activeElement as HTMLElement;
+ $(focusQuery, this.view)[1]?.focus();
+ this.view.scrollTop = 0;
+
+ this.addModalListeners?.();
+ this.returnValue = '';
+ this.dialog.showModal();
+ };
+
+ addModalListeners? = () => {
+ this.dialog.addEventListener('keydown', onModalKeydown);
+ this.addModalListeners = undefined; // only do this once per HTMLDialogElement
+ };
+}
+
+function assets(o: DialogOpts) {
+ return Promise.all([
+ o.htmlUrl
+ ? xhr.text(o.htmlUrl)
+ : Promise.resolve(
+ o.cash ? $as($(o.cash).clone().removeClass('none')).outerHTML : o.htmlText,
+ ),
+ o.cssPath ? lichess.loadCssPath(o.cssPath) : Promise.resolve(),
+ ]);
+}
+
+function onModalKeydown(e: KeyboardEvent) {
+ if (e.key === 'Tab') {
+ const $focii = $(focusQuery, e.currentTarget as Element),
+ first = $as($focii.first()),
+ last = $as($focii.last()),
+ focus = document.activeElement as HTMLElement;
+ if (focus === last && !e.shiftKey) first.focus();
+ else if (focus === first && e.shiftKey) last.focus();
+ else return;
+ e.preventDefault();
+ }
+ e.stopPropagation();
+}
+
+function onResize() {
+ const vh = window.innerHeight * 0.01; // ios safari vh behavior not helpful to us
+ $('dialog > div.scrollable').css('--vh', `${vh}px`);
+}
+
+const focusQuery = ['button', 'input', 'select', 'textarea']
+ .map(sel => `${sel}:not(:disabled)`)
+ .concat(['[href]', '[tabindex="0"]', '[role="tab"]'])
+ .join(',');
diff --git a/ui/common/src/linkPopup.ts b/ui/common/src/linkPopup.ts
index 00fd966dd51..80378c179a9 100644
--- a/ui/common/src/linkPopup.ts
+++ b/ui/common/src/linkPopup.ts
@@ -1,4 +1,4 @@
-import modal from './modal';
+import { domDialog } from './dialog';
export const makeLinkPopups = (dom: HTMLElement | Cash, trans: Trans, selector = 'a[href^="http"]') =>
$(dom).on('click', selector, function (this: HTMLLinkElement) {
@@ -8,29 +8,28 @@ export const makeLinkPopups = (dom: HTMLElement | Cash, trans: Trans, selector =
export const onClick = (a: HTMLLinkElement, trans: Trans): boolean => {
const url = new URL(a.href);
if (isPassList(url)) return true;
- lichess.loadCssPath('linkPopup').then(() =>
- modal({
- content: $(
- `