diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx
index 64ba572..812fcc2 100644
--- a/webui/src/components/table/DataGrid.tsx
+++ b/webui/src/components/table/DataGrid.tsx
@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge";
-import Button from "../Button";
+import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
export type DataGridSortDirection = "asc" | "desc";
@@ -852,58 +852,44 @@ export function DataGridRowActions({
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
}: DataGridRowActionsProps) {
- const { translateText } = usePlatformLanguage();
- const translatedAddLabel = translateText(addLabel);
- const translatedRemoveLabel = translateText(removeLabel);
- const translatedMoveUpLabel = translateText(moveUpLabel);
- const translatedMoveDownLabel = translateText(moveDownLabel);
return (
-
-
-
-
-
-
);
+ ,
+ variant: "primary",
+ disabled,
+ onClick: onAddBelow
+ },
+ {
+ id: "move-up",
+ label: moveUpLabel,
+ icon: ,
+ applicable: Boolean(onMoveUp),
+ disabled,
+ onClick: () => onMoveUp?.()
+ },
+ {
+ id: "move-down",
+ label: moveDownLabel,
+ icon: ,
+ applicable: Boolean(onMoveDown),
+ disabled,
+ onClick: () => onMoveDown?.()
+ },
+ {
+ id: "remove",
+ label: removeLabel,
+ icon: ,
+ variant: "danger",
+ disabled: disabled || removeDisabled,
+ onClick: onRemove
+ }
+ ]}
+ />);
}
@@ -916,22 +902,18 @@ export function DataGridEmptyAction({
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
- const { translateText } = usePlatformLanguage();
- const translatedLabel = translateText(label);
return (
- );
+ ,
+ variant: "primary",
+ disabled,
+ onClick: onAdd
+ }]}
+ />);
}
diff --git a/webui/src/components/table/TableActionGroup.tsx b/webui/src/components/table/TableActionGroup.tsx
new file mode 100644
index 0000000..3104693
--- /dev/null
+++ b/webui/src/components/table/TableActionGroup.tsx
@@ -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, "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 (
+
+ {applicableActions.map((action) => {
+ const translatedLabel = translateText(action.label);
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/webui/src/index.ts b/webui/src/index.ts
index de4af9c..4e1d14c 100644
--- a/webui/src/index.ts
+++ b/webui/src/index.ts
@@ -102,6 +102,8 @@ export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } 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 PublicLandingPage } from "./features/auth/PublicLandingPage";
diff --git a/webui/src/styles/badges.css b/webui/src/styles/badges.css
index 87745c7..7f011b1 100644
--- a/webui/src/styles/badges.css
+++ b/webui/src/styles/badges.css
@@ -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-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-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-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css
index 83fa4df..03edb1c 100644
--- a/webui/src/styles/components.css
+++ b/webui/src/styles/components.css
@@ -117,10 +117,6 @@
font-size: 12px;
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-attachments-input {
min-width: 150px;
@@ -957,15 +953,6 @@
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 {
display: inline-grid;
place-items: center;
@@ -985,11 +972,19 @@
gap: 14px;
}
+.admin-form-grid > .wide {
+ grid-column: 1 / -1;
+}
+
.admin-form-grid.two-columns,
.admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+.admin-form-grid.three-columns {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
.admin-assignment-grid {
display: grid;
gap: 18px;
@@ -1034,6 +1029,7 @@
@media (max-width: 850px) {
.admin-form-grid.two-columns,
+ .admin-form-grid.three-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
@@ -1803,6 +1799,27 @@
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 {
border-color: var(--danger-border-deep) !important;
box-shadow: var(--danger-focus-ring) !important;
diff --git a/webui/src/styles/forms.css b/webui/src/styles/forms.css
index db025f3..edd46ae 100644
--- a/webui/src/styles/forms.css
+++ b/webui/src/styles/forms.css
@@ -1,10 +1,25 @@
.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-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.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); }
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); }
input:disabled:not([type="checkbox"]):not([type="radio"]),
select:disabled,
diff --git a/webui/src/styles/tables.css b/webui/src/styles/tables.css
index 9b3e808..e0cf8fe 100644
--- a/webui/src/styles/tables.css
+++ b/webui/src/styles/tables.css
@@ -653,17 +653,17 @@
padding: 8px 10px;
}
-/* Consistent row-level collection actions for editable DataGrid tables. */
-.data-grid-row-actions {
- display: grid;
- grid-template-columns: repeat(4, 36px);
+/* Consistent, context-aware row actions for tables and DataGrids. */
+.table-action-group {
+ display: flex;
+ flex-wrap: nowrap;
align-items: center;
justify-content: end;
gap: 4px;
width: 100%;
}
-.data-grid-row-action.btn {
+.table-action-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
@@ -672,10 +672,20 @@
padding: 0;
}
-.data-grid-row-action.btn:disabled {
+.table-action-button.btn:disabled {
opacity: .45;
}
+.table-action-button.btn svg {
+ width: 17px;
+ height: 17px;
+}
+
+.table-action-icon {
+ display: inline-grid;
+ place-items: center;
+}
+
.data-grid-empty-message {
color: var(--muted);
min-height: 64px;
diff --git a/webui/tests/data-grid-actions.test.tsx b/webui/tests/data-grid-actions.test.tsx
index 85d1d8e..6e343b7 100644
--- a/webui/tests/data-grid-actions.test.tsx
+++ b/webui/tests/data-grid-actions.test.tsx
@@ -4,6 +4,7 @@ function assertEqual(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
+import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {}
@@ -30,3 +31,32 @@ const emptyAction = renderToStaticMarkup(
);
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
+
+const contextActions = renderToStaticMarkup(
+ I, onClick: noop },
+ { id: "edit", label: "Edit", icon: E, applicable: false, onClick: noop },
+ { id: "remove", label: "Remove", icon: R, 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(
+
+);
+assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json
index d2a9983..14c8fbd 100644
--- a/webui/tsconfig.component-tests.json
+++ b/webui/tsconfig.component-tests.json
@@ -32,6 +32,7 @@
"src/components/dialogInteractions.ts",
"src/components/dialogStack.ts",
"src/components/FormField.tsx",
- "src/components/ToggleSwitch.tsx"
+ "src/components/ToggleSwitch.tsx",
+ "src/components/table/TableActionGroup.tsx"
]
}