mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
fix(fleet): equalize action card heights, swap order, replace native autocomplete (#1138)
Three visual adjustments to the Fleet Actions tab: 1. Drop the xl:grid-cols-3 breakpoint. The cards carry live previews and per-node estimate wells that compress badly at 3-up on a 1440 monitor. 2-up is the new max. Pair with auto-rows-fr so every row equalizes to the tallest content height, and let <FleetActionCard> stretch into the cell via flex flex-col h-full plus a flex-1 body. Footer pins to the bottom; the shorter card's body grows whitespace below its content. 2. Reorder the JSX to Prune, Bulk, Stop. Prune sits top-left with its live "~ N GB reclaimable" estimate populated on first render. Stop, which reads "awaiting target" until the operator types, drops to row 2 below the fold. 3. Replace the native <datalist> on the Stop card with a custom LabelAutocomplete: free-form text input + a Sencho-styled popover (border-glass-border, bg-popover, backdrop-blur) that filters the known-label set as the operator types. Click a suggestion via mousedown + preventDefault so the input stays focused and the selection registers before any blur-driven close. Outside-click and Escape close the popover. Operator can still run against a label that was not in the autocomplete set; the server-side match-preview resolves it. Also: whitespace-nowrap on the primary button so "Stop fleet" / "Prune fleet" do not wrap in tight viewports.
This commit is contained in:
@@ -22,14 +22,17 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// Grid breakpoints per audit §18.7: 1 / 2 / 3 columns at 760 / 1280. The
|
||||
// 4-column breakpoint is reserved for when a 4th card lands; v1 has three
|
||||
// so it is intentionally omitted to avoid a hanging gap on wide displays.
|
||||
// Grid: 1 col under lg, 2 cols at lg and above. auto-rows-fr distributes
|
||||
// available height evenly across the auto-created rows, and each card uses
|
||||
// `flex flex-col h-full` (in <FleetActionCard>) with a `flex-1` body so it
|
||||
// fills its grid cell. Result: all three cards render at the same height,
|
||||
// with shorter cards growing their body whitespace before the footer pins.
|
||||
// Order: Prune top-left, Bulk top-right, Stop on row 2.
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
|
||||
<LabelFleetStopCard nodes={nodes} />
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
|
||||
<FleetPruneCard nodes={nodes} />
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<LabelFleetStopCard nodes={nodes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,18 +201,13 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
title="Label · target"
|
||||
meta={`auto-suggested · ${knownLabelNames.length} known`}
|
||||
>
|
||||
<Input
|
||||
id="fleet-stop-label-input"
|
||||
list="fleet-stop-label-suggestions"
|
||||
<LabelAutocomplete
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
className="h-9 text-sm"
|
||||
onChange={setLabelName}
|
||||
suggestions={knownLabelNames}
|
||||
disabled={running}
|
||||
placeholder="e.g. production"
|
||||
/>
|
||||
<datalist id="fleet-stop-label-suggestions">
|
||||
{knownLabelNames.map(n => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
</SheetSection>
|
||||
|
||||
{previewSection}
|
||||
@@ -308,3 +303,91 @@ function PreviewWell({ perNode }: PreviewWellProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LabelAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
suggestions: string[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Free-form text input with a Sencho-styled suggestion popover. Replaces the
|
||||
// browser-native <datalist> so the dropdown matches the rest of the kit (same
|
||||
// surface tokens as <Combobox>). The operator can still type a label name
|
||||
// that was not in the suggestions list; the server-side match-preview will
|
||||
// resolve it or report 0 nodes match.
|
||||
function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder }: LabelAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.trim().toLowerCase();
|
||||
if (q.length === 0) return suggestions;
|
||||
return suggestions.filter(s => s.toLowerCase().includes(q));
|
||||
}, [value, suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<Input
|
||||
id="fleet-stop-label-input"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (!disabled) setOpen(true); }}
|
||||
placeholder={placeholder}
|
||||
className="h-9 text-sm"
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<ul className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.map((s) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
// mousedown + preventDefault keeps the input focused so the
|
||||
// selection registers before any blur-driven close fires.
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(s); }}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ export type FleetActionClass = 'destructive' | 'transformative' | 'maintenance';
|
||||
export type BlastRadiusTone = 'warning' | 'success' | 'muted';
|
||||
export type PrimaryActionVariant = 'primary' | 'destructive';
|
||||
|
||||
/**
|
||||
* System-Sheet recipe card for the Fleet Actions tab (audit §18, DESIGN §10).
|
||||
*
|
||||
* Layout note: the outer <Card> is `flex flex-col h-full` and the body slot
|
||||
* is `flex-1`, so when a parent gives the card explicit height (e.g. the
|
||||
* equalized-row wrapper in FleetActionsTab.tsx), the body grows and the
|
||||
* footer pins to the bottom. In a content-sized parent this is a no-op.
|
||||
*/
|
||||
export interface FleetActionCardProps {
|
||||
/** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */
|
||||
crumb: string[];
|
||||
@@ -107,7 +115,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
||||
const headCrumbs = crumb.slice(0, -1);
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<Card className="relative overflow-hidden p-0 flex flex-col h-full">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
|
||||
@@ -166,7 +174,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6">
|
||||
<div className="px-6 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user