145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
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 [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]);
|
|
const selectedAction = actions.find((action) => action.id === selectedActionId);
|
|
const unavailableReason = selectedAction?.disabled
|
|
? selectedAction.disabledReason ?? "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 ?? `${selectedCount} selected`}</strong>
|
|
{actionScope ? (
|
|
<span>Action scope</span>
|
|
) : hiddenSelectedCount > 0 ? (
|
|
<span>{hiddenSelectedCount} hidden by filter</span>
|
|
) : (
|
|
<span>Selected rows</span>
|
|
)}
|
|
</div>
|
|
<label className="sr-only" htmlFor={hintId}>{label}</label>
|
|
<select
|
|
ref={selectRef}
|
|
id={hintId}
|
|
className="thot-fleet-action-selector__select"
|
|
aria-label={label}
|
|
value={selectedActionId}
|
|
disabled={busy || selectedCount === 0}
|
|
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
|
|
>
|
|
<option value="">Choose an action…</option>
|
|
{groups.map(({ group, actions: groupActions }) => (
|
|
<optgroup key={group} label={group}>
|
|
{groupActions.map((action) => (
|
|
<option key={action.id} value={action.id}>
|
|
{action.label}
|
|
{action.disabled && action.disabledReason
|
|
? `, unavailable: ${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" />
|
|
Run action
|
|
</Button>
|
|
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
|
|
<X aria-hidden="true" /> Clear
|
|
</Button>
|
|
{unavailableReason ? (
|
|
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
|
|
{unavailableReason}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|