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.
145 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|