feat: icon buttons for the per-account actions

A row now carries up to five actions -- change plan, reissue, suspend or enable,
delete -- and five labelled buttons do not fit the cell. Each becomes a 30px
icon button.

The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and
as visually-hidden text, so a screen reader and a hover both still get it. The
glyphs are `aria-hidden`, since the button already carries the name.

Inline SVG rather than files under `icons/`: these are one-place 24px line
glyphs, and the dashboard already inlines its sidebar chevron the same way. An
`edit` glyph is defined but unused, ready for the account-edit action.

Falsified: removing the visually-hidden label fails "keeps the label reachable
without showing it".
This commit is contained in:
Juan Castro
2026-09-11 10:09:39 -04:00
parent 187e4c8a2f
commit bafe8af71b
4 changed files with 181 additions and 5 deletions
+34 -5
View File
@@ -20,6 +20,7 @@
import UIAlert from '../UIAlert.js';
import UIPrompt from '../UIPrompt.js';
import teamPlanHtml from './teamPlan.js';
import teamActionButton from './teamActionIcons.js';
import {
annotateMembers,
auditActionKey,
@@ -89,15 +90,43 @@ const renderMemberRow = (member) => {
h += `<td>${planCell(member)}</td>`;
h += '<td class="teams-member-actions">';
if ( member.orgOwned ) {
const forMember = { 'data-username': member.username };
if ( window.team_billing_ui && state.plan?.status === 'ready' ) {
h += `<button class="button button-small teams-plan-change" data-username="${username}" data-uuid="${html_encode(member.uuid ?? '')}">${i18n('teams_plan_change')}</button>`;
h += teamActionButton({
className: 'teams-plan-change',
icon: 'plan',
label: i18n('teams_plan_change'),
attrs: { ...forMember, 'data-uuid': member.uuid ?? '' },
});
}
h += `<button class="button button-small teams-reset" data-username="${username}">${i18n('teams_reissue_credential')}</button>`;
h += teamActionButton({
className: 'teams-reset',
icon: 'credential',
label: i18n('teams_reissue_credential'),
attrs: forMember,
});
h += member.disabled
? `<button class="button button-small teams-enable" data-username="${username}">${i18n('teams_enable_account')}</button>`
: `<button class="button button-small button-danger teams-disable" data-username="${username}">${i18n('teams_disable_account')}</button>`;
? teamActionButton({
className: 'teams-enable',
icon: 'enable',
label: i18n('teams_enable_account'),
attrs: forMember,
})
: teamActionButton({
className: 'teams-disable',
icon: 'suspend',
label: i18n('teams_disable_account'),
danger: true,
attrs: forMember,
});
if ( canDeleteAccount(member) ) {
h += `<button class="button button-small button-danger teams-delete-account" data-username="${username}">${i18n('teams_delete_account')}</button>`;
h += teamActionButton({
className: 'teams-delete-account',
icon: 'remove',
label: i18n('teams_delete_account'),
danger: true,
attrs: forMember,
});
}
}
h += '</td></tr>';
@@ -0,0 +1,56 @@
/*
* 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/>.
*/
// A row carries up to five actions, and five labelled buttons do not fit.
// Inline like the sidebar chevron: one-place glyphs, not shared assets.
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>`;
export const TEAM_ACTION_ICONS = {
/** Change plan — a card. */
plan: SVG('<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>'),
/** Reissue credential — a key. */
credential: SVG('<circle cx="8" cy="12" r="4"/><path d="M12 12h9"/><path d="M17 12v4"/><path d="M20 12v3"/>'),
/** Suspend — pause. */
suspend: SVG('<rect x="7" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>'),
/** Re-enable — play. */
enable: SVG('<path d="M7 5l12 7-12 7z"/>'),
/** Delete the account — a bin. */
remove: SVG('<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/>'),
/** Edit the account — a pencil. */
edit: SVG('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
};
/** The label stays as tooltip and screen-reader text, so nothing is lost. */
export const teamActionButton = ({ className, icon, label, danger, attrs }) => {
const safeLabel = window.html_encode(label);
const extra = Object.entries(attrs ?? {})
.map(([k, v]) => ` ${k}="${window.html_encode(v ?? '')}"`)
.join('');
return (
`<button class="button button-small button-icon ${className}` +
`${danger ? ' button-danger' : ''}" title="${safeLabel}"` +
` aria-label="${safeLabel}"${extra}>` +
`${TEAM_ACTION_ICONS[icon] ?? ''}` +
`<span class="sr-only">${safeLabel}</span></button>`
);
};
export default teamActionButton;
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
globalThis.window = { html_encode: (v) => String(v).replace(/[<"]/g, (c) => (c === '<' ? '&lt;' : '&quot;')) };
const { teamActionButton, TEAM_ACTION_ICONS } = await import('./teamActionIcons.js');
describe('an icon action button', () => {
it('keeps the label reachable without showing it', () => {
// Dropping the visible text must not drop it for a screen reader.
const h = teamActionButton({
className: 'teams-reset',
icon: 'credential',
label: 'Reissue password',
});
expect(h).toContain('title="Reissue password"');
expect(h).toContain('aria-label="Reissue password"');
expect(h).toContain('<span class="sr-only">Reissue password</span>');
});
it('carries the class the click handler binds to', () => {
const h = teamActionButton({ className: 'teams-disable', icon: 'suspend', label: 'x' });
expect(h).toContain('teams-disable');
});
it('passes through the data attributes an action needs', () => {
const h = teamActionButton({
className: 'teams-plan-change',
icon: 'plan',
label: 'Change plan',
attrs: { 'data-username': 'ana', 'data-uuid': 'u-1' },
});
expect(h).toContain('data-username="ana"');
expect(h).toContain('data-uuid="u-1"');
});
it('marks only the destructive ones', () => {
expect(teamActionButton({ className: 'a', icon: 'remove', label: 'x', danger: true }))
.toContain('button-danger');
expect(teamActionButton({ className: 'a', icon: 'plan', label: 'x' }))
.not.toContain('button-danger');
});
it('encodes a label and an attribute value', () => {
const h = teamActionButton({
className: 'a',
icon: 'plan',
label: '<script>',
attrs: { 'data-username': '"quoted' },
});
expect(h).not.toContain('<script>');
expect(h).not.toContain('data-username=""quoted"');
});
it('renders an empty glyph rather than breaking on an unknown icon', () => {
const h = teamActionButton({ className: 'a', icon: 'nope', label: 'x' });
expect(h).toContain('<button');
expect(h).not.toContain('undefined');
});
it('hides every glyph from the accessibility tree', () => {
// The label is on the button; the svg would be noise.
for (const svg of Object.values(TEAM_ACTION_ICONS)) {
expect(svg).toContain('aria-hidden="true"');
}
});
});
+25
View File
@@ -7955,6 +7955,31 @@ body.dashboard-mode .notifications-close-all {
margin-bottom: 0;
}
.teams-member-actions .button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
margin-right: 6px;
}
.teams-member-actions .button-icon svg {
width: 15px;
height: 15px;
}
/* Kept in the DOM for screen readers; the tooltip carries it visually. */
.teams-member-actions .sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.teams-plan-list {
list-style: none;
margin: 0;