mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-01 17:47:07 +00:00
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:
@@ -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 === '<' ? '<' : '"')) };
|
||||
|
||||
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"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user