mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 06:41:55 +00:00
Use the dashboard's overlay dialog in the Teams tab, not UIAlert
Adds UIDashboardDialog, a confirm / alert / prompt card built like the share and properties modals: mounted in the dashboard window, revealed after a frame, dismissed by Escape, the close button or a backdrop press-and-release, with a focus trap and focus restored on close. On phones it docks to the bottom as a sheet. The Teams tab's confirms, error alerts, seat-limit warning and rename/create prompts all go through it; UIAlert and UIPrompt are no longer imported there.
This commit is contained in:
1 parent
edaf70aa5c
commit
592943f306
6 files changed
+625
-41
No files matched your search
@@ -17,9 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIPrompt from '../UIPrompt.js';
|
||||
import teamActionButton from './teamActionIcons.js';
|
||||
import UIDashboardDialog from './UIDashboardDialog.js';
|
||||
import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
@@ -47,13 +46,8 @@ const AUDIT_ROWS_PER_PAGE = 10;
|
||||
/** In flight, so `init` and the initial-route `onActivate` don't both load. */
|
||||
let loadPromise = null;
|
||||
|
||||
const modalOptions = ($el_window) => ({
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
});
|
||||
/** Every dialog here is the dashboard's overlay card, mounted in the window. */
|
||||
const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts });
|
||||
|
||||
const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? '';
|
||||
|
||||
@@ -524,38 +518,39 @@ const showSeatLimit = async ($el_window, e) => {
|
||||
const canUpgrade = typeof window.UIUpgradeAccount === 'function';
|
||||
let message = `<p>${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}</p>`;
|
||||
message += `<p>${i18n('teams_seat_limit_upgrade')}</p>`;
|
||||
const answer = await UIAlert({
|
||||
type: 'warning',
|
||||
const answer = await dialog($el_window, {
|
||||
title: i18n('teams_add_account', [], false),
|
||||
tone: 'warning',
|
||||
message,
|
||||
buttons: canUpgrade
|
||||
? [
|
||||
{ label: i18n('teams_see_plans'), value: 'upgrade', type: 'primary' },
|
||||
{ label: i18n('cancel'), value: 'no' },
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' },
|
||||
]
|
||||
: [{ label: i18n('ok'), value: 'no' }],
|
||||
...modalOptions($el_window),
|
||||
: [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }],
|
||||
});
|
||||
if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window();
|
||||
};
|
||||
|
||||
const showError = ($el_window, e) => {
|
||||
if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e);
|
||||
return UIAlert({
|
||||
type: 'error',
|
||||
message: e?.message ?? i18n('error_unknown_cause'),
|
||||
...modalOptions($el_window),
|
||||
return dialog($el_window, {
|
||||
title: i18n('something_went_wrong', [], false),
|
||||
tone: 'error',
|
||||
message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)),
|
||||
});
|
||||
};
|
||||
|
||||
/** `label` names the action and titles the dialog; `kind` colours its button. */
|
||||
const confirm = async ($el_window, message, label, kind = 'danger') => {
|
||||
const answer = await UIAlert({
|
||||
type: 'confirm',
|
||||
const answer = await dialog($el_window, {
|
||||
title: label,
|
||||
tone: kind === 'danger' ? 'danger' : undefined,
|
||||
message,
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label, value: 'yes', type: kind },
|
||||
{ label: i18n('cancel'), value: 'no' },
|
||||
],
|
||||
...modalOptions($el_window),
|
||||
});
|
||||
return answer === 'yes';
|
||||
};
|
||||
@@ -618,7 +613,7 @@ const reissueCredential = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_reissue', { username })}</p>`,
|
||||
i18n('teams_reissue_credential'),
|
||||
i18n('teams_reissue_credential', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
@@ -635,7 +630,7 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_disable', { username })}</p>`,
|
||||
i18n('teams_disable_account'),
|
||||
i18n('teams_disable_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
}
|
||||
@@ -650,11 +645,14 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
|
||||
};
|
||||
|
||||
const renameTeam = async ($el_window) => {
|
||||
const name = await UIPrompt({
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_rename', [], false),
|
||||
message: i18n('teams_rename_prompt'),
|
||||
placeholder: i18n('teams_team_name'),
|
||||
defaultValue: state.selected.name ?? '',
|
||||
...modalOptions($el_window),
|
||||
input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
@@ -669,7 +667,7 @@ const deleteMemberAccount = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_confirm_delete_account', { username }),
|
||||
i18n('teams_delete_account'),
|
||||
i18n('teams_delete_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
@@ -687,7 +685,7 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_directory_confirm'),
|
||||
i18n('teams_directory_confirm_action'),
|
||||
i18n('teams_directory_confirm_action', [], false),
|
||||
// Reversible, and the wording says so — red would overstate it.
|
||||
'primary',
|
||||
);
|
||||
@@ -704,10 +702,14 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
|
||||
};
|
||||
|
||||
const createTeam = async ($el_window) => {
|
||||
const name = await UIPrompt({
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_create_team', [], false),
|
||||
message: i18n('teams_create_team_prompt'),
|
||||
placeholder: i18n('teams_team_name'),
|
||||
...modalOptions($el_window),
|
||||
input: { placeholder: i18n('teams_team_name', [], false) },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
@@ -724,7 +726,7 @@ const deleteTeam = async ($el_window) => {
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}</p>`
|
||||
+ `<p>${i18n('teams_confirm_delete_billing')}</p>`,
|
||||
i18n('teams_delete_team'),
|
||||
i18n('teams_delete_team', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIPrompt.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The tab pulls in the dialog primitives, which pull in the whole window stack.
|
||||
// Only the tab-object contract is under test here.
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIPrompt.js', () => ({ default: vi.fn() }));
|
||||
// The tab's dialogs mount into the window; only the tab-object contract is under test here.
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The dashboard's own confirm / alert / prompt: an overlay card like the
|
||||
// share and properties modals, not a UIWindow. Resolves with the pressed
|
||||
// button's value (or the input's text), and `false` when dismissed.
|
||||
|
||||
const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
|
||||
danger: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
||||
warning: SVG('<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
|
||||
error: SVG('<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>'),
|
||||
};
|
||||
|
||||
const TONES = new Set(['danger', 'warning', 'error']);
|
||||
const BUTTON_TYPES = new Set(['primary', 'danger', 'quiet']);
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.title] Plain text.
|
||||
* @param {string} opts.message HTML the caller has already encoded.
|
||||
* @param {'danger'|'warning'|'error'} [opts.tone] Adds an icon; the primary
|
||||
* button of a `danger` dialog is red.
|
||||
* @param {Array<{ label: string, value: unknown, type?: 'primary'|'danger'|'quiet' }>} [opts.buttons]
|
||||
* Rendered in order, right-aligned. Defaults to a single OK.
|
||||
* @param {{ label?: string, placeholder?: string, value?: string }} [opts.input]
|
||||
* Turns the dialog into a prompt: Enter presses the primary button and the
|
||||
* result is the trimmed text rather than the button's value.
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export default function UIDashboardDialog ({ title, message, tone, buttons, input, $container, returnFocusTo }) {
|
||||
const { html_encode } = window;
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const id = window.uuidv4?.() ?? String(Date.now());
|
||||
const toneClass = TONES.has(tone) ? ` dashboard-dialog-${tone}` : '';
|
||||
const actions = buttons?.length
|
||||
? buttons
|
||||
: [{ label: i18n('ok'), value: true, type: 'primary' }];
|
||||
const primary = actions.find(b => b.type === 'primary' || b.type === 'danger') ?? actions[actions.length - 1];
|
||||
|
||||
let h = '<div class="dashboard-dialog-overlay">';
|
||||
h += `<div class="dashboard-dialog${toneClass}" role="${input ? 'dialog' : 'alertdialog'}" aria-modal="true" tabindex="-1"`;
|
||||
h += ` aria-labelledby="dashboard-dialog-title-${id}" aria-describedby="dashboard-dialog-message-${id}">`;
|
||||
if ( title ) {
|
||||
h += '<div class="dashboard-dialog-header">';
|
||||
h += `<span class="dashboard-dialog-title" id="dashboard-dialog-title-${id}">${html_encode(title)}</span>`;
|
||||
h += `<button type="button" class="dashboard-dialog-close" aria-label="${html_encode(i18n('close', [], false))}">${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '<div class="dashboard-dialog-body">';
|
||||
if ( toneClass ) h += `<div class="dashboard-dialog-icon">${ICONS[tone]}</div>`;
|
||||
h += '<div class="dashboard-dialog-content">';
|
||||
h += `<div class="dashboard-dialog-message" id="dashboard-dialog-message-${id}">${message ?? ''}</div>`;
|
||||
if ( input ) {
|
||||
h += '<label class="dashboard-dialog-field">';
|
||||
if ( input.label ) h += `<span>${html_encode(input.label)}</span>`;
|
||||
h += `<input type="text" class="dashboard-dialog-input" autocomplete="off" spellcheck="false"`;
|
||||
h += ` placeholder="${html_encode(input.placeholder ?? '')}" value="${html_encode(input.value ?? '')}">`;
|
||||
h += '</label>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
h += '<div class="dashboard-dialog-footer">';
|
||||
actions.forEach((button, index) => {
|
||||
const type = BUTTON_TYPES.has(button.type) ? button.type : 'quiet';
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-${type}" data-index="${index}">${html_encode(button.label)}</button>`;
|
||||
});
|
||||
h += '</div></div></div>';
|
||||
|
||||
const $overlay = $(h);
|
||||
$root.append($overlay);
|
||||
requestAnimationFrame(() => $overlay.addClass('dashboard-dialog-show'));
|
||||
|
||||
const el_previous_focus = document.activeElement;
|
||||
const $input = $overlay.find('.dashboard-dialog-input');
|
||||
// A prompt wants the text; anything else, the safest button.
|
||||
const $first = $input.length
|
||||
? $input
|
||||
: $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first();
|
||||
($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true });
|
||||
if ( $input.length ) $input.get(0).select();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let closed = false;
|
||||
const close = (value) => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
$overlay.removeClass('dashboard-dialog-show');
|
||||
$(document).off(`keydown.dashboard-dialog-${id}`);
|
||||
setTimeout(() => $overlay.remove(), 200);
|
||||
const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el));
|
||||
try {
|
||||
el_focus_target?.focus({ preventScroll: true });
|
||||
} catch { /* focus restoration is best-effort */ }
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const submit = (button) => {
|
||||
if ( ! button ) return close(false);
|
||||
if ( $input.length && button === primary ) return close(String($input.val() ?? '').trim());
|
||||
close(button.value);
|
||||
};
|
||||
|
||||
$overlay.on('click', '.dashboard-dialog-btn', function () {
|
||||
submit(actions[Number($(this).attr('data-index'))]);
|
||||
});
|
||||
$overlay.on('click', '.dashboard-dialog-close', () => close(false));
|
||||
|
||||
// Backdrop close goes by where the press started, so a drag that
|
||||
// starts in the card and ends outside does not dismiss it.
|
||||
let backdrop_pressed = false;
|
||||
$overlay.on('mousedown', function (e) {
|
||||
backdrop_pressed = e.target === $overlay[0];
|
||||
// A press inside the overlay must not let initgui's global mousedown
|
||||
// hand focus (and the z-index) back to the window underneath it.
|
||||
window.mouseover_window = undefined;
|
||||
});
|
||||
$overlay.on('click', function (e) {
|
||||
if ( e.target === $overlay[0] && backdrop_pressed ) close(false);
|
||||
});
|
||||
$(document).on(`keydown.dashboard-dialog-${id}`, function (e) {
|
||||
if ( e.key === 'Escape' ) close(false);
|
||||
});
|
||||
$input.on('keydown', function (e) {
|
||||
if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
submit(primary);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the dialog while it's up.
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay
|
||||
.find('button, input, [tabindex]:not([tabindex="-1"])')
|
||||
.filter(':visible:not(:disabled)');
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables.get(0);
|
||||
const last = focusables.get(focusables.length - 1);
|
||||
if ( focusables.index(document.activeElement) === -1 ) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
|
||||
const { default: UIDashboardDialog } = await import('./UIDashboardDialog.js');
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const overlay = () => $('.dashboard-dialog-overlay');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('UIDashboardDialog', () => {
|
||||
it('mounts an overlay card, not a window, and reveals it after a frame', async () => {
|
||||
UIDashboardDialog({ title: 'T', message: 'hello' });
|
||||
expect(overlay()).toHaveLength(1);
|
||||
expect($('.window')).toHaveLength(0);
|
||||
expect(overlay().hasClass('dashboard-dialog-show')).toBe(false);
|
||||
await tick();
|
||||
expect(overlay().hasClass('dashboard-dialog-show')).toBe(true);
|
||||
expect($('.dashboard-dialog-title').text()).toBe('T');
|
||||
expect($('.dashboard-dialog-message').html()).toBe('hello');
|
||||
});
|
||||
|
||||
it('resolves with the pressed button value', async () => {
|
||||
const p = UIDashboardDialog({
|
||||
message: 'm',
|
||||
buttons: [{ label: 'No', value: 'no' }, { label: 'Yes', value: 'yes', type: 'danger' }],
|
||||
});
|
||||
expect($('.dashboard-dialog-btn-danger').text()).toBe('Yes');
|
||||
$('.dashboard-dialog-btn-danger').trigger('click');
|
||||
await expect(p).resolves.toBe('yes');
|
||||
});
|
||||
|
||||
it('resolves false on Escape, on the close button, and on a backdrop press-and-release', async () => {
|
||||
const a = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
$(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
await expect(a).resolves.toBe(false);
|
||||
|
||||
const b = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
$('.dashboard-dialog-close').last().trigger('click');
|
||||
await expect(b).resolves.toBe(false);
|
||||
|
||||
const c = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
const el = overlay().last().get(0);
|
||||
$(el).trigger($.Event('mousedown', { target: el }));
|
||||
$(el).trigger($.Event('click', { target: el }));
|
||||
await expect(c).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not dismiss when a press starts inside the card and ends on the backdrop', async () => {
|
||||
const resolved = vi.fn();
|
||||
UIDashboardDialog({ title: 'T', message: 'm' }).then(resolved);
|
||||
const el = overlay().get(0);
|
||||
const card = $('.dashboard-dialog').get(0);
|
||||
$(card).trigger($.Event('mousedown', { target: card }));
|
||||
$(el).trigger($.Event('click', { target: el }));
|
||||
await tick();
|
||||
expect(resolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('as a prompt, returns the trimmed text from the primary button or Enter', async () => {
|
||||
const a = UIDashboardDialog({
|
||||
message: 'm',
|
||||
input: { value: 'old' },
|
||||
buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }],
|
||||
});
|
||||
expect($('.dashboard-dialog-input').val()).toBe('old');
|
||||
$('.dashboard-dialog-input').val(' new name ');
|
||||
$('.dashboard-dialog-btn-primary').trigger('click');
|
||||
await expect(a).resolves.toBe('new name');
|
||||
|
||||
const b = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Save', value: 'ok', type: 'primary' }] });
|
||||
$('.dashboard-dialog-input').last().val('typed').trigger($.Event('keydown', { key: 'Enter' }));
|
||||
await expect(b).resolves.toBe('typed');
|
||||
|
||||
const c = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }] });
|
||||
$('.dashboard-dialog-btn-quiet').last().trigger('click');
|
||||
await expect(c).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('escapes the title and button labels but trusts the message as HTML', () => {
|
||||
UIDashboardDialog({ title: '<b>x</b>', message: '<p>ok</p>', buttons: [{ label: '<i>y</i>', value: 1 }] });
|
||||
expect($('.dashboard-dialog-title').html()).toBe('<b>x</b>');
|
||||
expect($('.dashboard-dialog-btn').html()).toBe('<i>y</i>');
|
||||
expect($('.dashboard-dialog-message p')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mounts into the given container and marks the tone', () => {
|
||||
document.body.innerHTML = '<div class="host"></div>';
|
||||
UIDashboardDialog({ message: 'm', tone: 'danger', $container: $('.host') });
|
||||
expect($('.host .dashboard-dialog-overlay')).toHaveLength(1);
|
||||
expect($('.dashboard-dialog').hasClass('dashboard-dialog-danger')).toBe(true);
|
||||
expect($('.dashboard-dialog-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes itself from the DOM after the exit transition', async () => {
|
||||
vi.useFakeTimers();
|
||||
const p = UIDashboardDialog({ message: 'm' });
|
||||
$('.dashboard-dialog-btn').trigger('click');
|
||||
await p;
|
||||
expect(overlay()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(overlay()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8980,3 +8980,300 @@ body.dashboard-mode .notifications-close-all {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Dashboard dialog (confirm / alert / prompt)
|
||||
====================================== */
|
||||
|
||||
.dashboard-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5));
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dashboard-dialog-overlay.dashboard-dialog-show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dashboard-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(440px, 100%);
|
||||
max-height: 100%;
|
||||
background: var(--dashboard-card-background);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 40px var(--dashboard-shadow-medium);
|
||||
overflow: hidden;
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
transition: transform 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
.dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dashboard-dialog:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dashboard-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--dashboard-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-dialog-title {
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-dialog-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-dialog-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-dialog-close:hover {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-dialog-close:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-dialog-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 20px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dashboard-dialog-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-dialog-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-danger .dashboard-dialog-icon,
|
||||
.dashboard-dialog-error .dashboard-dialog-icon {
|
||||
background: var(--dashboard-danger-background);
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-dialog-warning .dashboard-dialog-icon {
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
}
|
||||
|
||||
.dashboard-dialog-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-message {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-dialog-message p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-message p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-dialog-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-dialog-input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-input-background);
|
||||
color: var(--dashboard-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-dialog-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--select-color);
|
||||
background: var(--dashboard-card-background);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-card-background);
|
||||
color: var(--dashboard-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-primary {
|
||||
border-color: var(--dashboard-link);
|
||||
background: var(--dashboard-link);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-danger {
|
||||
border-color: #f00808;
|
||||
background: #f00808;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-dialog-btn-quiet:hover {
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-primary:hover {
|
||||
background: var(--dashboard-link-hover);
|
||||
border-color: var(--dashboard-link-hover);
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-danger:hover {
|
||||
background: #d90707;
|
||||
border-color: #d90707;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-dialog-btn {
|
||||
min-height: 44px;
|
||||
height: auto;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: dock to the bottom as a slide-up sheet, like the other modals. */
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-dialog-overlay {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-dialog {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 92vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
|
||||
.dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dashboard-dialog-footer {
|
||||
flex-direction: column-reverse;
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-dialog-overlay,
|
||||
.dashboard-dialog {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user