feat(webui): centralize contextual table actions

This commit is contained in:
2026-07-21 12:22:35 +02:00
parent 249bf63eb8
commit 8e9eb6e1f5
9 changed files with 226 additions and 89 deletions

View File

@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge"; import StatusBadge from "../StatusBadge";
import Button from "../Button"; import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext"; import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
export type DataGridSortDirection = "asc" | "desc"; export type DataGridSortDirection = "asc" | "desc";
@@ -852,58 +852,44 @@ export function DataGridRowActions({
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19", moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957" moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
}: DataGridRowActionsProps) { }: DataGridRowActionsProps) {
const { translateText } = usePlatformLanguage();
const translatedAddLabel = translateText(addLabel);
const translatedRemoveLabel = translateText(removeLabel);
const translatedMoveUpLabel = translateText(moveUpLabel);
const translatedMoveDownLabel = translateText(moveDownLabel);
return ( return (
<div className="data-grid-row-actions"> <TableActionGroup
<Button className="data-grid-row-actions"
type="button" actions={[
variant="primary" {
className="data-grid-row-action is-add" id: "add",
aria-label={translatedAddLabel} label: addLabel,
title={translatedAddLabel} icon: <Plus size={16} aria-hidden="true" />,
disabled={disabled} variant: "primary",
onClick={onAddBelow}> disabled,
onClick: onAddBelow
<Plus size={16} aria-hidden="true" /> },
</Button> {
<Button id: "move-up",
type="button" label: moveUpLabel,
variant="secondary" icon: <ArrowUp size={16} aria-hidden="true" />,
className="data-grid-row-action is-reorder" applicable: Boolean(onMoveUp),
aria-label={translatedMoveUpLabel} disabled,
title={translatedMoveUpLabel} onClick: () => onMoveUp?.()
disabled={disabled || !onMoveUp} },
onClick={() => onMoveUp?.()}> {
id: "move-down",
<ArrowUp size={16} aria-hidden="true" /> label: moveDownLabel,
</Button> icon: <ArrowDown size={16} aria-hidden="true" />,
<Button applicable: Boolean(onMoveDown),
type="button" disabled,
variant="secondary" onClick: () => onMoveDown?.()
className="data-grid-row-action is-reorder" },
aria-label={translatedMoveDownLabel} {
title={translatedMoveDownLabel} id: "remove",
disabled={disabled || !onMoveDown} label: removeLabel,
onClick={() => onMoveDown?.()}> icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
<ArrowDown size={16} aria-hidden="true" /> disabled: disabled || removeDisabled,
</Button> onClick: onRemove
<Button }
type="button" ]}
variant="danger" />);
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={disabled || removeDisabled}
onClick={onRemove}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>);
} }
@@ -916,22 +902,18 @@ export function DataGridEmptyAction({
}: {disabled?: boolean;onAdd: () => void;label?: string;}) { }: {disabled?: boolean;onAdd: () => void;label?: string;}) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
return ( return (
<div className="data-grid-row-actions data-grid-empty-row-actions"> <TableActionGroup
<Button className="data-grid-row-actions data-grid-empty-row-actions"
type="button" actions={[{
variant="primary" id: "add",
className="data-grid-row-action is-add" label,
aria-label={translatedLabel} icon: <Plus size={16} aria-hidden="true" />,
title={translatedLabel} variant: "primary",
disabled={disabled} disabled,
onClick={onAdd}> onClick: onAdd
}]}
<Plus size={16} aria-hidden="true" /> />);
</Button>
</div>);
} }

View File

@@ -0,0 +1,80 @@
import type { MouseEvent, ReactNode } from "react";
import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
export type TableActionDefinition = {
id: string;
label: string;
icon: ReactNode;
onClick: () => void;
/** Set to false when the action does not apply to this row. */
applicable?: boolean;
disabled?: boolean;
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export type TableActionGroupProps = {
actions: readonly (TableActionDefinition | false | null | undefined)[];
label?: string;
className?: string;
};
function isApplicableAction(
action: TableActionDefinition | false | null | undefined
): action is TableActionDefinition {
return Boolean(action && action.applicable !== false);
}
export function runTableAction(
event: Pick<MouseEvent<HTMLButtonElement>, "stopPropagation">,
onClick: () => void
) {
event.stopPropagation();
onClick();
}
/**
* The standard row-level action surface for tables and DataGrids.
*
* Callers describe only actions that make sense for the row. An action may be
* disabled when it applies but is temporarily unavailable; actions marked as
* not applicable are omitted entirely. Labels remain available to assistive
* technology and as native tooltips while the visible controls stay icon-only.
*/
export default function TableActionGroup({
actions,
label = "i18n:govoplan-core.actions.c3cd636a",
className = ""
}: TableActionGroupProps) {
const { translateText } = usePlatformLanguage();
const applicableActions = actions.filter(isApplicableAction);
if (applicableActions.length === 0) return null;
return (
<div
className={`table-action-group${className ? ` ${className}` : ""}`}
role="group"
aria-label={translateText(label)}
>
{applicableActions.map((action) => {
const translatedLabel = translateText(action.label);
return (
<Button
key={action.id}
type="button"
variant={action.variant ?? "secondary"}
className="table-action-button"
aria-label={translatedLabel}
title={translatedLabel}
disabled={action.disabled}
onClick={(event) => runTableAction(event, action.onClick)}
>
<span className="table-action-icon" aria-hidden="true">
{action.icon}
</span>
</Button>
);
})}
</div>
);
}

View File

@@ -102,6 +102,8 @@ export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid"; export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid"; export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export { default as TableActionGroup } from "./components/table/TableActionGroup";
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
export { default as LoginModal } from "./features/auth/LoginModal"; export { default as LoginModal } from "./features/auth/LoginModal";
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage"; export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";

View File

@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; } .status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); } .status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); } .status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); } .status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); } .status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); } .status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }

View File

@@ -117,10 +117,6 @@
font-size: 12px; font-size: 12px;
line-height: 1.35; line-height: 1.35;
} }
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 430px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 92px; text-align: right; }
.recipient-field-input, .recipient-field-input,
.recipient-attachments-input { .recipient-attachments-input {
min-width: 150px; min-width: 150px;
@@ -957,15 +953,6 @@
align-self: start; align-self: start;
} }
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 36px;
justify-content: end;
gap: 5px;
width: 100%;
}
.admin-icon-button.btn { .admin-icon-button.btn {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@@ -985,11 +972,19 @@
gap: 14px; gap: 14px;
} }
.admin-form-grid > .wide {
grid-column: 1 / -1;
}
.admin-form-grid.two-columns, .admin-form-grid.two-columns,
.admin-assignment-grid { .admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-assignment-grid { .admin-assignment-grid {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -1034,6 +1029,7 @@
@media (max-width: 850px) { @media (max-width: 850px) {
.admin-form-grid.two-columns, .admin-form-grid.two-columns,
.admin-form-grid.three-columns,
.admin-assignment-grid, .admin-assignment-grid,
.admin-permission-groups { .admin-permission-groups {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1803,6 +1799,27 @@
white-space: nowrap; white-space: nowrap;
} }
.split-field-action {
gap: 0;
}
.split-field-action > input,
.split-field-action > select,
.split-field-action > textarea {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-field-action > button,
.split-field-action > .button,
.split-field-action > .btn {
align-self: stretch;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
}
.field-input-missing { .field-input-missing {
border-color: var(--danger-border-deep) !important; border-color: var(--danger-border-deep) !important;
box-shadow: var(--danger-focus-ring) !important; box-shadow: var(--danger-focus-ring) !important;

View File

@@ -1,10 +1,25 @@
.form-grid { display: grid; gap: 18px; } .form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; } .form-grid.compact,
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid > .wide { grid-column: 1 / -1; }
.responsive-form-grid { align-items: start; }
.form-field { display: grid; gap: 7px; } .form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); } .form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); } .form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); } input, select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
textarea { resize: vertical; } textarea { resize: vertical; }
@media (max-width: 1100px) {
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
}
@media (max-width: 980px) {
.form-grid.two,
.form-grid.three,
.form-grid.four { grid-template-columns: 1fr; }
}
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); } .form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
input:disabled:not([type="checkbox"]):not([type="radio"]), input:disabled:not([type="checkbox"]):not([type="radio"]),
select:disabled, select:disabled,

View File

@@ -653,17 +653,17 @@
padding: 8px 10px; padding: 8px 10px;
} }
/* Consistent row-level collection actions for editable DataGrid tables. */ /* Consistent, context-aware row actions for tables and DataGrids. */
.data-grid-row-actions { .table-action-group {
display: grid; display: flex;
grid-template-columns: repeat(4, 36px); flex-wrap: nowrap;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 4px; gap: 4px;
width: 100%; width: 100%;
} }
.data-grid-row-action.btn { .table-action-button.btn {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 36px; width: 36px;
@@ -672,10 +672,20 @@
padding: 0; padding: 0;
} }
.data-grid-row-action.btn:disabled { .table-action-button.btn:disabled {
opacity: .45; opacity: .45;
} }
.table-action-button.btn svg {
width: 17px;
height: 17px;
}
.table-action-icon {
display: inline-grid;
place-items: center;
}
.data-grid-empty-message { .data-grid-empty-message {
color: var(--muted); color: var(--muted);
min-height: 64px; min-height: 64px;

View File

@@ -4,6 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid"; import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {} function noop() {}
@@ -30,3 +31,32 @@ const emptyAction = renderToStaticMarkup(
); );
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control"); assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form"); assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
const contextActions = renderToStaticMarkup(
<TableActionGroup
actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
]}
/>
);
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
let propagationStopped = false;
let actionCalled = false;
runTableAction(
{ stopPropagation: () => { propagationStopped = true; } },
() => { actionCalled = true; }
);
assertEqual(propagationStopped, true, "table actions do not trigger clickable row handlers");
assertEqual(actionCalled, true, "table actions still invoke their handler");
const noReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");

View File

@@ -32,6 +32,7 @@
"src/components/dialogInteractions.ts", "src/components/dialogInteractions.ts",
"src/components/dialogStack.ts", "src/components/dialogStack.ts",
"src/components/FormField.tsx", "src/components/FormField.tsx",
"src/components/ToggleSwitch.tsx" "src/components/ToggleSwitch.tsx",
"src/components/table/TableActionGroup.tsx"
] ]
} }