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 { 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 (
<div className="data-grid-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={disabled}
onClick={onAddBelow}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={disabled || !onMoveUp}
onClick={() => onMoveUp?.()}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={disabled || !onMoveDown}
onClick={() => onMoveDown?.()}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<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>);
<TableActionGroup
className="data-grid-row-actions"
actions={[
{
id: "add",
label: addLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAddBelow
},
{
id: "move-up",
label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />,
applicable: Boolean(onMoveUp),
disabled,
onClick: () => onMoveUp?.()
},
{
id: "move-down",
label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />,
applicable: Boolean(onMoveDown),
disabled,
onClick: () => onMoveDown?.()
},
{
id: "remove",
label: removeLabel,
icon: <Trash2 size={16} aria-hidden="true" />,
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 (
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedLabel}
title={translatedLabel}
disabled={disabled}
onClick={onAdd}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>);
<TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions"
actions={[{
id: "add",
label,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAdd
}]}
/>);
}

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>
);
}