Compare commits

...
Author SHA1 Message Date
jelveh e6e1e15892 Land the first ArrowDown on the first <puter-user-menu> item after a click
Opening the menu with the pointer focused the first item without a visible
focus ring, so ArrowDown appeared to skip it. A pointer open now focuses the
menu itself; opening from the keyboard still focuses the first item.
2026-11-07 21:35:29 -07:00
jelveh 9c9d4edccd Fill the send-feedback popup with the feedback dialog
The popup opened by puter.ui.showFeedbackDialog() drew the floating modal
over a scrim. It now fills the window like the custom domain popup, with
the actions pinned to the bottom and the message box taking the spare
height. The full-page styles move from .custom-domain-fullpage to a shared
.app-feedback-fullpage on the modal shell.
2026-11-07 21:33:04 -07:00
7 changed files with 132 additions and 32 deletions

No files matched your search

+8 -6
View File
@@ -40,7 +40,9 @@ let feedback_modal_seq = 0;
* server is the authority on the app's canonical title — then submits to
* `POST /app-feedback`.
*
* @param {{ app?: string, origin?: string, source?: 'app' | 'web' }} options
* @param {{ app?: string, origin?: string, source?: 'app' | 'web', fullpage?: boolean }} options
* `fullpage` fills the viewport instead of floating over it, for a popup
* window that exists only to host this dialog.
* @returns {Promise<boolean>} true iff feedback was submitted successfully.
* Resolves false on cancel/close/unavailable — never rejects, so IPC and
* popup callers can always report an answer.
@@ -71,7 +73,7 @@ async function UIWindowAppFeedback (options) {
const titleId = `${modal_id}-title`;
const h = `
<div class="app-feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal" tabindex="-1">
<div class="app-feedback-head">
<h2 class="app-feedback-title" id="${titleId}">${i18n('app_feedback_title')}</h2>
@@ -80,16 +82,16 @@ async function UIWindowAppFeedback (options) {
</button>
</div>
<div class="app-feedback-body">
<div class="app-feedback-loading">${i18n('loading')}…</div>
<div class="app-feedback-pane app-feedback-loading">${i18n('loading')}…</div>
<div class="app-feedback-unavailable" style="display:none;">
<div class="app-feedback-pane app-feedback-unavailable" style="display:none;">
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-close-btn">${i18n('close')}</button>
</div>
</div>
<div class="app-feedback-form" style="display:none;">
<div class="app-feedback-pane app-feedback-form" style="display:none;">
<div class="app-feedback-target">
<div class="app-feedback-target-title"></div>
<div class="app-feedback-target-name"></div>
@@ -105,7 +107,7 @@ async function UIWindowAppFeedback (options) {
</div>
</div>
<div class="app-feedback-success" style="display:none;">
<div class="app-feedback-pane app-feedback-success" style="display:none;">
<div class="app-feedback-success-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
@@ -0,0 +1,68 @@
/*
* 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/>.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const { default: UIWindowAppFeedback } = await import('./UIWindowAppFeedback.js');
const flush = () => new Promise((r) => setTimeout(r, 0));
const $ = (sel) => jQuery(sel);
const visiblePanes = () => $('.app-feedback-pane').filter((_, el) => el.style.display !== 'none');
/** Opens the dialog against an app that accepts feedback and waits for the form. */
const open = async (options) => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
enabled: true,
app: { title: 'Build with AI', name: 'builder' },
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
UIWindowAppFeedback({ origin: 'https://builder.puter.test', source: 'web', ...options });
await flush();
await flush();
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
window.api_origin = 'https://api.puter.test';
window.auth_token = 'token';
});
afterEach(() => {
$(document).off('keydown');
});
describe('UIWindowAppFeedback', () => {
it('floats over the page by default', async () => {
await open();
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(false);
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
});
it('fills the page when hosted in its own popup', async () => {
await open({ fullpage: true });
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(true);
expect(visiblePanes()).toHaveLength(1);
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
expect($('.app-feedback-target-title').text()).toBe('Build with AI');
});
});
+7 -7
View File
@@ -83,7 +83,7 @@ async function UIWindowAttachCustomDomain (options) {
const titleId = `${modal_id}-title`;
const closeLabel = html_encode(i18n('close'));
const h = `
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
<div class="app-feedback-head custom-domain-head">
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
@@ -95,13 +95,13 @@ async function UIWindowAttachCustomDomain (options) {
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
</div>
<div class="app-feedback-body custom-domain-body">
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
<div class="app-feedback-pane custom-domain-pane" data-pane="loading" aria-busy="true">
<span class="custom-domain-sr">${i18n('loading')}…</span>
<div class="custom-domain-skeleton"><span></span><span></span></div>
<div class="custom-domain-skeleton"><span></span><span></span></div>
</div>
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
<div class="app-feedback-pane custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
@@ -109,7 +109,7 @@ async function UIWindowAttachCustomDomain (options) {
</div>
</div>
<div class="custom-domain-pane" data-pane="list" style="display:none;">
<div class="app-feedback-pane custom-domain-pane" data-pane="list" style="display:none;">
<ul class="custom-domain-list"></ul>
<div class="custom-domain-state custom-domain-empty" style="display:none;">
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
@@ -124,7 +124,7 @@ async function UIWindowAttachCustomDomain (options) {
</div>
</div>
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
<form class="app-feedback-pane custom-domain-pane" data-pane="add" style="display:none;" novalidate>
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
<div class="custom-domain-field">
${ICON_GLOBE}
@@ -143,7 +143,7 @@ async function UIWindowAttachCustomDomain (options) {
</div>
</form>
<div class="custom-domain-pane" data-pane="records" style="display:none;">
<div class="app-feedback-pane custom-domain-pane" data-pane="records" style="display:none;">
<div class="custom-domain-status-card">
<div class="custom-domain-status-icon"></div>
<div class="custom-domain-status-main">
@@ -165,7 +165,7 @@ async function UIWindowAttachCustomDomain (options) {
</div>
</div>
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
<div class="app-feedback-pane custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
<div class="app-feedback-success-check">${ICON_CHECK}</div>
<p class="app-feedback-success-text custom-domain-success-text"></p>
<p class="app-feedback-note custom-domain-success-note"></p>
+26 -15
View File
@@ -2739,15 +2739,17 @@ input.myapps-group-name:focus {
}
}
/* In a popup that exists only for this dialog, the dialog is the page: a
centered column, with the actions pinned to the bottom edge. */
.custom-domain-fullpage {
--cd-column: min(552px, 100% - 48px);
/* In a popup that exists only to host one of these dialogs, the dialog is the
page: a centered column, with the actions pinned to the bottom edge. */
.app-feedback-fullpage {
--afb-column: min(552px, 100% - 48px);
padding: 0;
align-items: stretch;
background: var(--afb-bg);
}
.custom-domain-fullpage .custom-domain-modal {
.app-feedback-fullpage .app-feedback-modal {
display: flex;
flex-direction: column;
width: 100%;
height: 100dvh;
max-height: none;
@@ -2758,39 +2760,39 @@ input.myapps-group-name:focus {
padding-bottom: 0;
overflow: hidden;
}
.custom-domain-fullpage .app-feedback-x {
.app-feedback-fullpage .app-feedback-x {
display: none;
}
.custom-domain-fullpage .custom-domain-head {
width: var(--cd-column);
.app-feedback-fullpage .app-feedback-head {
width: var(--afb-column);
margin: 0 auto;
padding: 28px 0 4px;
}
.custom-domain-fullpage .custom-domain-body {
.app-feedback-fullpage .app-feedback-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px 0 0;
}
.custom-domain-fullpage .custom-domain-pane {
.app-feedback-fullpage .app-feedback-pane {
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
.app-feedback-fullpage .app-feedback-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
box-sizing: border-box;
width: var(--cd-column);
width: var(--afb-column);
margin-left: auto;
margin-right: auto;
}
/* Keeps the actions off the content when it fills the page. */
.custom-domain-fullpage .custom-domain-pane::after {
.app-feedback-fullpage .app-feedback-pane::after {
content: '';
flex: 1 0 20px;
order: 1;
}
.custom-domain-fullpage .app-feedback-actions {
.app-feedback-fullpage .app-feedback-actions {
order: 2;
position: sticky;
bottom: 0;
@@ -2799,9 +2801,18 @@ input.myapps-group-name:focus {
background: var(--afb-bg);
border-top: 1px solid var(--afb-border);
}
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
.app-feedback-fullpage .app-feedback-unavailable .app-feedback-actions,
.app-feedback-fullpage .custom-domain-state .app-feedback-actions {
justify-content: center;
}
/* The message box takes the spare height instead of the spacer. */
.app-feedback-fullpage .app-feedback-form::after {
flex-grow: 0;
}
.app-feedback-fullpage .app-feedback-message {
flex: 1 0 160px;
resize: none;
}
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
rows in the same modal shell. min-width gives the two-line rows room without
+1
View File
@@ -959,6 +959,7 @@ const postAuthActions = async (action) => {
sent = await UIWindowAppFeedback({
origin,
source: 'web',
fullpage: window.embedded_in_popup === true,
});
} catch (e) {
console.error('send-feedback action failed', e);
@@ -540,8 +540,9 @@ class PuterUserMenu extends PuterWebComponent {
else this._closePanels();
});
this.$('.sign-in').addEventListener('click', () => this._signIn());
this.$('.avatar').addEventListener('click', () => {
if ( this.$('.menu').hidden ) this._openMenu();
this.$('.avatar').addEventListener('click', (event) => {
// detail is 0 for a click from Enter or Space.
if ( this.$('.menu').hidden ) this._openMenu({ fromKeyboard: event.detail === 0 });
else this._closePanels();
});
for ( const link of MENU_LINKS ) {
@@ -697,10 +698,15 @@ class PuterUserMenu extends PuterWebComponent {
panel.style.setProperty('--caret-x', `${Math.round(Math.min(width - CARET_INSET, Math.max(CARET_INSET, caret)))}px`);
}
_openMenu () {
/**
* Opened from the keyboard, focus goes to the first item. Opened with the
* pointer, it goes to the menu itself, where no item looks focused, so the
* first ArrowDown lands on the first item.
*/
_openMenu ({ fromKeyboard = false } = {}) {
const menu = this.$('.menu');
this._showPanel(menu);
this.$('.menu-item').focus({ preventScroll: true });
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
}
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
@@ -253,6 +253,18 @@ describe('<puter-user-menu>', () => {
expect(shown(el, '.menu')).toBe(true);
});
it('lands the first ArrowDown on the first item when the menu was opened with the pointer', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.avatar').dispatchEvent(new MouseEvent('click', { detail: 1, bubbles: true, composed: true }));
expect(shown(el, '.menu')).toBe(true);
expect(el.shadowRoot.activeElement).toBe(part(el, '.menu'));
const press = (key) => el.shadowRoot.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
press('ArrowDown');
expect(el.shadowRoot.activeElement.classList[1]).toBe('manage-account');
});
it('hangs the panel under its button with the caret pointing at it', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();