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:
Nariman Jelveh committed 2026-09-17 18:25:58 -07:00
1 parent edaf70aa5c
commit 592943f306
6 files changed
+625 -41

No files matched your search

+37 -35
View File
@@ -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;
+2 -4
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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('&lt;b&gt;x&lt;/b&gt;');
expect($('.dashboard-dialog-btn').html()).toBe('&lt;i&gt;y&lt;/i&gt;');
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);
});
});
+297
View File
@@ -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;
}
}