refactor ui modals - after #9380

commonModal2
Thibault Duplessis 2021-07-16 14:56:47 +02:00
parent 219e5d5b82
commit c801cd46d3
22 changed files with 226 additions and 183 deletions

View File

@ -30,25 +30,25 @@ lichess.studyTourChapter = function (study) {
'A study can have several chapters.<br>' +
'Each chapter has a distinct move tree,<br>' +
'and can be created in various ways.',
attachTo: '.study__modal label[for=chapter-name] left',
attachTo: '#modal-wrap label[for=chapter-name] left',
},
{
title: 'From initial position',
text: 'Just a board setup for a new game.<br>' + 'Suited to explore openings.',
attachTo: '.study__modal .tabs-horiz .init top',
attachTo: '#modal-wrap .tabs-horiz .init top',
when: onTab('init'),
},
{
title: 'Custom position',
text: 'Setup the board your way.<br>' + 'Suited to explore endgames.',
attachTo: '.study__modal .tabs-horiz .edit bottom',
attachTo: '#modal-wrap .tabs-horiz .edit bottom',
when: onTab('edit'),
},
{
title: 'Load an existing lichess game',
text:
'Paste a lichess game URL<br>' + '(like lichess.org/7fHIU0XI)<br>' + 'to load the game moves in the chapter.',
attachTo: '.study__modal .tabs-horiz .game top',
attachTo: '#modal-wrap .tabs-horiz .game top',
when: onTab('game'),
},
{
@ -57,19 +57,19 @@ lichess.studyTourChapter = function (study) {
'Paste a position in FEN format<br>' +
'<i>4k3/4rb2/8/7p/8/5Q2/1PP5/1K6 w</i><br>' +
'to start the chapter from a position.',
attachTo: '.study__modal .tabs-horiz .fen top',
attachTo: '#modal-wrap .tabs-horiz .fen top',
when: onTab('fen'),
},
{
title: 'From a PGN game',
text: 'Paste a game in PGN format.<br>' + 'to load moves, comments and variations in the chapter.',
attachTo: '.study__modal .tabs-horiz .pgn top',
attachTo: '#modal-wrap .tabs-horiz .pgn top',
when: onTab('pgn'),
},
{
title: 'Studies support variants',
text: 'Yes, you can study crazyhouse,<br>' + 'and all lichess variants!',
attachTo: '.study__modal label[for=chapter-variant] left',
attachTo: '#modal-wrap label[for=chapter-variant] left',
when: onTab('init'),
},
{
@ -81,7 +81,7 @@ lichess.studyTourChapter = function (study) {
action: tour.next,
},
],
attachTo: '.study__modal .help bottom',
attachTo: '#modal-wrap .help bottom',
},
].forEach(function (s) {
tour.addStep(s.title, s);

View File

@ -1,11 +1,9 @@
.study__modal {
&#modal-wrap {
padding: 1rem;
min-width: 80vw;
#modal-wrap {
padding: 1rem;
min-width: 80vw;
@include breakpoint($mq-x-small) {
min-width: 500px;
}
@include breakpoint($mq-x-small) {
min-width: 500px;
}
> div {

View File

@ -200,7 +200,11 @@ export function view(ctrl: AnalyseCtrl): VNode {
? h(
'a.button.button-empty',
{
hook: bind('click', _ => modal($('.continue-with.g_' + d.game.id))),
hook: bind('click', _ =>
modal({
content: $('.continue-with.g_' + d.game.id),
})
),
attrs: dataIcon(''),
},
noarg('continueFromHere')

View File

@ -2,7 +2,7 @@ import * as control from './control';
import * as xhr from 'common/xhr';
import AnalyseCtrl from './ctrl';
import { h, VNode } from 'snabbdom';
import { modal } from './modal';
import { snabModal } from 'common/modal';
import { spinner } from './util';
export const bind = (ctrl: AnalyseCtrl) => {
@ -93,12 +93,12 @@ export const bind = (ctrl: AnalyseCtrl) => {
};
export function view(ctrl: AnalyseCtrl): VNode {
return modal({
return snabModal({
class: 'keyboard-help',
onInsert(el: HTMLElement) {
onInsert($wrap: Cash) {
lichess.loadCssPath('analyse.keyboard');
xhr.text(xhr.url('/analysis/help', { study: !!ctrl.study })).then(html => {
el.querySelector('.scrollable')!.innerHTML = html;
$wrap.find('.scrollable').html(html);
});
},
onClose() {

View File

@ -1,61 +0,0 @@
import { h, VNode } from 'snabbdom';
import { focusFirstChild } from 'common/modal';
import { MaybeVNodes } from './interfaces';
import { bind, onInsert } from './util';
interface Modal {
class?: string;
content: MaybeVNodes;
onInsert?: (el: HTMLElement) => void;
onClose(): void;
noClickAway?: boolean;
}
export function modal(d: Modal): VNode {
return h(
'div#modal-overlay',
{
...(!d.noClickAway ? { hook: bind('mousedown', d.onClose) } : {}),
},
[
h(
'div#modal-wrap.study__modal.' + d.class,
{
hook: onInsert(el => {
focusFirstChild($(el));
el.addEventListener('mousedown', e => e.stopPropagation());
d.onInsert && d.onInsert(el);
}),
},
[
h('span.close', {
attrs: {
'data-icon': '',
role: 'button',
'aria-label': 'Close',
tabindex: '0',
},
hook: onInsert(el => {
el.addEventListener('click', d.onClose);
el.addEventListener('keydown', e => (e.code === 'Enter' || e.code === 'Space' ? d.onClose() : true));
}),
}),
h('div', d.content),
]
),
]
);
}
export function button(name: string): VNode {
return h(
'div.form-actions.single',
h(
'button.button',
{
attrs: { type: 'submit' },
},
name
)
);
}

View File

@ -136,8 +136,8 @@ export default function (element: HTMLElement, ctrl: AnalyseCtrl) {
$panels.on('click', '.embed-howto', function (this: HTMLElement) {
const url = `${baseUrl()}/embed/${data.game.id}${location.hash}`;
const iframe = '<iframe src="' + url + '?theme=auto&bg=auto"\nwidth=600 height=397 frameborder=0></iframe>';
modal(
$(
modal({
content: $(
'<strong style="font-size:1.5em">' +
$(this).html() +
'</strong><br /><br />' +
@ -147,7 +147,7 @@ export default function (element: HTMLElement, ctrl: AnalyseCtrl) {
iframe +
'<br /><br />' +
'<a class="text" data-icon="" href="/developers#embed-game">Read more about embedding games</a>'
)
);
),
});
});
}

View File

@ -1,10 +1,11 @@
import { h, VNode } from 'snabbdom';
import { defined, prop, Prop } from 'common';
import { Redraw } from '../interfaces';
import { bind, bindSubmit, spinner, option, onInsert, emptyRedButton } from '../util';
import * as modal from '../modal';
import * as chapterForm from './chapterNewForm';
import { bind, bindSubmit, spinner, option, onInsert, emptyRedButton } from '../util';
import { ChapterMode, EditChapterData, Orientation, StudyChapterConfig, StudyChapterMeta } from './interfaces';
import { defined, prop, Prop } from 'common';
import { h, VNode } from 'snabbdom';
import { modalButton } from './chapterNewForm';
import { Redraw } from '../interfaces';
import { snabModal } from 'common/modal';
import { StudySocketSend } from '../socket';
export interface StudyChapterEditFormCtrl {
@ -75,7 +76,7 @@ export function ctrl(
export function view(ctrl: StudyChapterEditFormCtrl): VNode | undefined {
const data = ctrl.current();
return data
? modal.modal({
? snabModal({
class: 'edit-' + data.id, // full redraw when changing chapter
onClose() {
ctrl.current(null);
@ -201,6 +202,6 @@ function viewLoaded(ctrl: StudyChapterEditFormCtrl, data: StudyChapterConfig): V
].map(v => option(v[0], data.description ? '1' : '', v[1]))
),
]),
modal.button(ctrl.trans.noarg('saveChapter')),
modalButton(ctrl.trans.noarg('saveChapter')),
];
}

View File

@ -1,16 +1,16 @@
import { h, VNode } from 'snabbdom';
import { defined, prop, Prop } from 'common';
import { storedProp, StoredProp } from 'common/storage';
import * as xhr from 'common/xhr';
import AnalyseCtrl from '../ctrl';
import { bind, bindSubmit, spinner, option, onInsert } from '../util';
import { variants as xhrVariants, importPgn } from './studyXhr';
import * as modal from '../modal';
import { chapter as chapterTour } from './studyTour';
import { ChapterData, ChapterMode, Orientation, StudyChapterMeta } from './interfaces';
import { Redraw } from '../interfaces';
import AnalyseCtrl from '../ctrl';
import { StudySocketSend } from '../socket';
import { defined, prop, Prop } from 'common';
import { h, VNode } from 'snabbdom';
import { parseFen } from 'chessops/fen';
import { Redraw } from '../interfaces';
import { snabModal } from 'common/modal';
import { storedProp, StoredProp } from 'common/storage';
import { StudySocketSend } from '../socket';
import { variants as xhrVariants, importPgn } from './studyXhr';
export const modeChoices = [
['normal', 'normalAnalysis'],
@ -140,7 +140,7 @@ export function view(ctrl: StudyChapterNewFormCtrl): VNode {
: 'normal';
const noarg = trans.noarg;
return modal.modal({
return snabModal({
class: 'chapter-new',
onClose() {
ctrl.close();
@ -342,9 +342,22 @@ export function view(ctrl: StudyChapterNewFormCtrl): VNode {
modeChoices.map(c => option(c[0], mode, noarg(c[1])))
),
]),
modal.button(noarg('createChapter')),
modalButton(noarg('createChapter')),
]
),
],
});
}
export function modalButton(name: string): VNode {
return h(
'div.form-actions.single',
h(
'button.button',
{
attrs: { type: 'submit' },
},
name
)
);
}

View File

@ -1,6 +1,6 @@
import { bind, titleNameToId, onInsert } from '../util';
import { h, VNode } from 'snabbdom';
import { modal } from '../modal';
import { snabModal } from 'common/modal';
import { prop, Prop } from 'common';
import { StudyMemberMap } from './interfaces';
import { AnalyseSocketSend } from '../socket';
@ -45,7 +45,7 @@ export function view(ctrl: ReturnType<typeof makeCtrl>): VNode {
.spectators()
.filter(s => !ctrl.members()[titleNameToId(s)]) // remove existing members
.sort();
return modal({
return snabModal({
class: 'study__invite',
onClose() {
ctrl.open(false);

View File

@ -1,5 +1,5 @@
import { h, VNode } from 'snabbdom';
import * as modal from '../modal';
import { snabModal } from 'common/modal';
import { prop, Prop } from 'common';
import { bind, bindSubmit, emptyRedButton } from '../util';
import { StudyData } from './interfaces';
@ -114,7 +114,7 @@ export function view(ctrl: StudyFormCtrl): VNode {
['member', ctrl.trans.noarg('members')],
['everyone', ctrl.trans.noarg('everyone')],
];
return modal.modal({
return snabModal({
class: 'study-edit',
onClose() {
ctrl.open(false);

View File

@ -1,4 +1,4 @@
import * as modal from '../modal';
import { snabModal } from 'common/modal';
import * as xhr from 'common/xhr';
import { bind, bindSubmit, onInsert } from '../util';
import { h, VNode } from 'snabbdom';
@ -61,7 +61,7 @@ export function view(ctrl: StudyCtrl): VNode {
let tagify: Tagify | undefined;
export function formView(ctrl: TopicsCtrl, userId?: string): VNode {
return modal.modal({
return snabModal({
class: 'study-topics',
onClose() {
ctrl.open(false);

View File

@ -61,8 +61,8 @@ function commandHelp(aliases: string, args: string, desc: string) {
function help() {
lichess.loadCssPath('clinput.help');
modal(
$(
modal({
content: $(
'<h3>Commands</h3>' +
commandHelp('/tv /follow', ' <user>', 'Watch someone play') +
commandHelp('/play /challenge /match', ' <user>', 'Challenge someone to play') +
@ -74,6 +74,6 @@ function help() {
commandHelp('c', '', 'Focus the chat input') +
commandHelp('esc', '', 'Close modals like this one')
),
'clinput-help'
);
class: 'clinput-help',
});
}

View File

@ -1,23 +1,42 @@
export default function modal(content: Cash, cls?: string, onClose?: () => void) {
import { h, VNode } from 'snabbdom';
import { bind, MaybeVNodes, onInsert } from './snabbdom';
interface BaseModal {
class?: string;
onInsert?: ($wrap: Cash) => void;
onClose?(): void;
noClickAway?: boolean;
}
interface Modal extends BaseModal {
content: Cash;
}
interface SnabModal extends BaseModal {
content: MaybeVNodes;
}
export default function modal(opts: Modal) {
modal.close();
const $wrap = $(
'<div id="modal-wrap"><span class="close" role="button" aria-label="Close" data-icon="" tabindex="0"></span></div>'
);
const $overlay = $(`<div id="modal-overlay" class="${cls}">`).on('click', modal.close);
const $overlay = $(`<div id="modal-overlay" class="${opts.class}">`);
if (!opts.noClickAway) $overlay.on('click', modal.close);
$('<a href="#"></a>').appendTo($overlay); // guard against focus escaping to window chrome
$wrap.appendTo($overlay);
$('<a href="#"></a>').appendTo($overlay); // guard against focus escaping to window chrome
content.clone().removeClass('none').appendTo($wrap);
modal.onClose = onClose;
$wrap
.find('.close')
.on('click', modal.close)
.on('keydown', (e: KeyboardEvent) => (e.code === 'Space' || e.code === 'Enter' ? modal.close() : true));
$wrap.on('click', (e: Event) => e.stopPropagation());
opts.content.clone().removeClass('none').appendTo($wrap);
opts.onInsert && opts.onInsert($wrap);
modal.onClose = opts.onClose;
$wrap.find('.close').each(function (this: HTMLElement) {
bindClose(this, modal.close);
});
$('body').addClass('overlayed').prepend($overlay);
focusFirstChild($wrap);
bindWrap($wrap);
return $wrap;
}
modal.close = () => {
$('body').removeClass('overlayed');
$('#modal-overlay').each(function (this: HTMLElement) {
@ -26,14 +45,57 @@ modal.close = () => {
});
delete modal.onClose;
};
modal.onClose = undefined as (() => void) | undefined;
export function snabModal(opts: SnabModal): VNode {
const close = opts.onClose!;
return h(
'div#modal-overlay',
{
...(opts.onClose && !opts.noClickAway ? { hook: bind('click', close) } : {}),
},
[
h(
'div#modal-wrap.' + opts.class,
{
hook: onInsert(el => {
bindWrap($(el));
opts.onInsert && opts.onInsert($(el));
}),
},
[
h('span.close', {
attrs: {
'data-icon': '',
role: 'button',
'aria-label': 'Close',
tabindex: '0',
},
hook: onInsert(el => bindClose(el, close)),
}),
h('div', opts.content),
]
),
]
);
}
const bindClose = (el: HTMLElement, close: () => void) => {
el.addEventListener('click', close);
el.addEventListener('keydown', e => (e.code === 'Enter' || e.code === 'Space' ? close() : true));
};
const bindWrap = ($wrap: Cash) => {
$wrap.on('click', (e: Event) => e.stopPropagation());
focusFirstChild($wrap);
};
const focusableSelectors =
'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]';
export function trapFocus(event: FocusEvent) {
const wrap: HTMLElement | undefined = $('#modal-wrap').get(0);
console.log(wrap);
const wrap: HTMLElement | undefined = $('#modal-wrap')[0];
if (!wrap) return;
const position = wrap.compareDocumentPosition(event.target as HTMLElement);
if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) return;
@ -43,8 +105,8 @@ export function trapFocus(event: FocusEvent) {
event.preventDefault();
}
export function focusFirstChild(parent: Cash) {
export const focusFirstChild = (parent: Cash) => {
const children = parent.find(focusableSelectors);
// prefer child 1 over child 0 because child 0 should be a close button
(children.get(1) ?? children.get(0))?.focus();
}
(children[1] ?? children[0])?.focus();
};

View File

@ -285,7 +285,10 @@ function controls(ctrl: EditorCtrl, state: EditorState): VNode {
},
on: {
click: () => {
if (state.playable) modal($('.continue-with'));
if (state.playable)
modal({
content: $('.continue-with'),
});
},
},
},

View File

@ -90,7 +90,13 @@ export default function LichessLobby(opts: LobbyOpts) {
res.text().then(text => {
if (res.ok) {
lobby.setup.prepareForm(
modal($(text), 'game-setup', () => $startButtons.find('.active').removeClass('active'))
modal({
content: $(text),
class: 'game-setup',
onClose() {
$startButtons.find('.active').removeClass('active');
},
})
);
lichess.contentLoaded();
} else {

View File

@ -153,12 +153,12 @@ export function make(send: SocketSend, ctrl: RoundController): RoundSocket {
},
simulEnd(simul: game.Simul) {
lichess.loadCssPath('modal');
modal(
$(
modal({
content: $(
'<p>Simul complete!</p><br /><br />' +
`<a class="button" href="/simul/${simul.id}">Back to ${simul.name} simul</a>`
)
);
),
});
},
};

View File

@ -49,10 +49,14 @@ export default function (showText: (ctrl: SimulCtrl) => VNode) {
: util.bind('click', () => {
if (ctrl.data.variants.length === 1) xhr.join(ctrl.data.id, ctrl.data.variants[0].key);
else {
modal($('.simul .continue-with'));
$('#modal-wrap .continue-with a').on('click', function (this: HTMLElement) {
modal.close();
xhr.join(ctrl.data.id, $(this).data('variant'));
modal({
content: $('.simul .continue-with'),
onInsert($wrap) {
$wrap.find('button').on('click', function (this: HTMLElement) {
modal.close();
xhr.join(ctrl.data.id, $(this).data('variant'));
});
},
});
}
}),
@ -200,7 +204,7 @@ export default function (showText: (ctrl: SimulCtrl) => VNode) {
'div.continue-with.none',
ctrl.data.variants.map(function (variant) {
return h(
'a.button',
'button.button',
{
attrs: {
'data-variant': variant.key,

View File

@ -135,7 +135,7 @@ export default function (publicKey: string, pricing: Pricing) {
.redirectToCheckout({
sessionId: data.session.id,
})
.then(result => showErrorThenReload(result.error.message));
.then((result: any) => showErrorThenReload(result.error.message));
} else location.assign('/patron');
});
});

View File

@ -5,16 +5,20 @@ lichess.load.then(() => {
$('.forum')
.on('click', 'a.delete', function (this: HTMLAnchorElement) {
const link = this;
const $wrap = modal($('.forum-delete-modal'));
$wrap
.find('form')
.attr('action', link.href)
.on('submit', function (this: HTMLFormElement, e: Event) {
e.preventDefault();
xhr.formToXhr(this);
modal.close();
$(link).closest('.forum-post').hide();
});
modal({
content: $('.forum-delete-modal'),
onInsert($wrap) {
$wrap
.find('form')
.attr('action', link.href)
.on('submit', function (this: HTMLFormElement, e: Event) {
e.preventDefault();
xhr.formToXhr(this);
modal.close();
$(link).closest('.forum-post').hide();
});
},
});
return false;
})
.on('click', 'form.unsub button', function (this: HTMLButtonElement) {

View File

@ -40,23 +40,27 @@ lichess.load.then(() => {
$('#communication').on('click', '.line:not(.lichess)', function (this: HTMLDivElement) {
const $l = $(this);
const roomId = $l.parents('.game').data('room');
const chan = $l.parents('.game').data('chan');
const $wrap = modal($('.timeout-modal'));
$wrap.find('.username').text($l.find('.user-link').text());
$wrap.find('.text').text($l.text().split(' ').slice(1).join(' '));
$wrap.on('click', '.button', function (this: HTMLButtonElement) {
modal.close();
text('/mod/public-chat/timeout', {
method: 'post',
body: form({
roomId,
chan,
userId: $wrap.find('.username').text().toLowerCase(),
reason: this.value,
text: $wrap.find('.text').text(),
}),
}).then(_ => setTimeout(reloadNow, 1000));
modal({
content: $('.timeout-modal'),
onInsert($wrap) {
$wrap.find('.username').text($l.find('.user-link').text());
$wrap.find('.text').text($l.text().split(' ').slice(1).join(' '));
$wrap.on('click', '.button', function (this: HTMLButtonElement) {
const roomId = $l.parents('.game').data('room');
const chan = $l.parents('.game').data('chan');
text('/mod/public-chat/timeout', {
method: 'post',
body: form({
roomId,
chan,
userId: $wrap.find('.username').text().toLowerCase(),
reason: this.value,
text: $wrap.find('.text').text(),
}),
}).then(_ => setTimeout(reloadNow, 1000));
modal.close();
});
},
});
});
};

View File

@ -141,8 +141,8 @@ team {
}
.team-battle__choice {
a {
display: block;
button {
width: 100%;
margin-top: 1rem;
}
}

View File

@ -1,21 +1,24 @@
import TournamentController from '../ctrl';
import { bind, onInsert, playerName } from './util';
import { bind, playerName } from './util';
import { h, VNode } from 'snabbdom';
import { TeamBattle, RankedTeam, MaybeVNode } from '../interfaces';
import modal from 'common/modal';
import modal, { snabModal } from 'common/modal';
export function joinWithTeamSelector(ctrl: TournamentController) {
const onClose = () => {
ctrl.joinWithTeamSelector = false;
ctrl.redraw();
};
const tb = ctrl.data.teamBattle!;
return h(
'div.none',
{
hook: onInsert(el => modal($(el), 'team-battle__choice', onClose)),
return snabModal({
class: 'team-battle__choice',
onInsert($el) {
$el.on('click', '.team-picker__team', e => {
ctrl.join(e.target.dataset['id']);
modal.close();
});
},
[
onClose() {
ctrl.joinWithTeamSelector = false;
ctrl.redraw();
},
content: [
h('div.team-picker', [
h('h2', 'Pick your team'),
h('br'),
@ -24,9 +27,11 @@ export function joinWithTeamSelector(ctrl: TournamentController) {
h('p', 'Which team will you represent in this battle?'),
...tb.joinWith.map(id =>
h(
'a.button',
'button.button.team-picker__team',
{
hook: bind('click', () => ctrl.join(id), ctrl.redraw),
attrs: {
'data-id': id,
},
},
tb.teams[id]
)
@ -51,8 +56,8 @@ export function joinWithTeamSelector(ctrl: TournamentController) {
),
]),
]),
]
);
],
});
}
export function teamStanding(ctrl: TournamentController, klass?: string): VNode | null {