Files
ThothII/frontend/src/shell/database-management/FleetActionSelector.tsx
T
Codex d8a29bfbdd Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
2026-09-13 14:26:39 +02:00

145 lines
4.8 KiB
TypeScript

import { useI18n } from "../../i18n";
import {
useEffect,
useId,
useMemo,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Play, X } from "lucide-react";
import { Button } from "../../components/ui/button";
export interface FleetActionOption<ActionId extends string = string> {
id: ActionId;
label: string;
group: string;
scopeLabel?: string;
disabled?: boolean;
disabledReason?: string;
tone?: "default" | "destructive";
runLabel?: string;
}
interface FleetActionSelectorProps<ActionId extends string> {
actions: readonly FleetActionOption<ActionId>[];
selectedCount: number;
hiddenSelectedCount?: number;
busy?: boolean;
busyLabel?: string;
selectRef?: RefObject<HTMLSelectElement>;
onRun: (action: ActionId) => void | Promise<void>;
onClear: () => void;
renderContext?: (action: ActionId | "") => ReactNode;
label?: string;
}
/**
* Selection-aware command picker shared by all Fleet Ledger grids. Eligibility
* remains data owned by the calling grid; this module owns only presentation,
* explicit execution and accessible unavailable reasons.
*/
export function FleetActionSelector<ActionId extends string>({
actions,
selectedCount,
hiddenSelectedCount = 0,
busy = false,
selectRef,
onRun,
onClear,
renderContext,
label = "Batch action",
}: FleetActionSelectorProps<ActionId>) {
const { t } = useI18n();
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
const hintId = useId();
const groups = useMemo(() => {
const order: string[] = [];
const grouped = new Map<string, FleetActionOption<ActionId>[]>();
for (const action of actions) {
if (!grouped.has(action.group)) {
order.push(action.group);
grouped.set(action.group, []);
}
grouped.get(action.group)!.push(action);
}
return order.map((group) => ({ group, actions: grouped.get(group)! }));
}, [actions, t]);
const selectedAction = actions.find((action) => action.id === selectedActionId);
const unavailableReason = selectedAction?.disabled
? selectedAction.disabledReason ?? t("This action is not available for the current selection.")
: null;
const actionScope = selectedAction?.scopeLabel;
useEffect(() => {
if (selectedActionId && !actions.some((action) => action.id === selectedActionId)) {
setSelectedActionId("");
}
}, [actions, selectedActionId]);
const run = async () => {
if (!selectedAction || selectedAction.disabled || busy) return;
await onRun(selectedAction.id);
setSelectedActionId("");
};
return (
<div className="thot-fleet-action-selector">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{actionScope ? t(actionScope) : t("{count} selected", { count: selectedCount })}</strong>
{actionScope ? (
<span>{t("Action scope")}</span>
) : hiddenSelectedCount > 0 ? (
<span>{hiddenSelectedCount} {t("hidden by filter")}</span>
) : (
<span>{t("Selected rows")}</span>
)}
</div>
<label className="sr-only" htmlFor={hintId}>{t(label)}</label>
<select
ref={selectRef}
id={hintId}
className="thot-fleet-action-selector__select"
aria-label={t(label)}
value={selectedActionId}
disabled={busy || selectedCount === 0}
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
>
<option value="">{t("Choose an action…")}</option>
{groups.map(({ group, actions: groupActions }) => (
<optgroup key={group} label={t(group)}>
{groupActions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.label)}
{action.disabled && action.disabledReason
? t(", unavailable: {reason}", { reason: t(action.disabledReason) })
: ""}
</option>
))}
</optgroup>
))}
</select>
{renderContext ? renderContext(selectedActionId) : null}
<Button
type="button"
size="sm"
variant="destructive"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={!selectedAction || selectedAction.disabled || busy}
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
onClick={() => void run()}
>
<Play aria-hidden="true" />
{t("Run action")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
<X aria-hidden="true" /> {t("Clear")}</Button>
{unavailableReason ? (
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
{t(unavailableReason)}
</span>
) : null}
</div>
);
}