mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 22:55:58 +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 />;
|
return <EmptyState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid breakpoints per audit §18.7: 1 / 2 / 3 columns at 760 / 1280. The
|
// Grid: 1 col under lg, 2 cols at lg and above. auto-rows-fr distributes
|
||||||
// 4-column breakpoint is reserved for when a 4th card lands; v1 has three
|
// available height evenly across the auto-created rows, and each card uses
|
||||||
// so it is intentionally omitted to avoid a hanging gap on wide displays.
|
// `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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
|
||||||
<LabelFleetStopCard nodes={nodes} />
|
|
||||||
<BulkLabelAssignCard nodes={nodes} />
|
|
||||||
<FleetPruneCard nodes={nodes} />
|
<FleetPruneCard nodes={nodes} />
|
||||||
|
<BulkLabelAssignCard nodes={nodes} />
|
||||||
|
<LabelFleetStopCard nodes={nodes} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,18 +201,13 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
|||||||
title="Label · target"
|
title="Label · target"
|
||||||
meta={`auto-suggested · ${knownLabelNames.length} known`}
|
meta={`auto-suggested · ${knownLabelNames.length} known`}
|
||||||
>
|
>
|
||||||
<Input
|
<LabelAutocomplete
|
||||||
id="fleet-stop-label-input"
|
|
||||||
list="fleet-stop-label-suggestions"
|
|
||||||
value={labelName}
|
value={labelName}
|
||||||
onChange={(e) => setLabelName(e.target.value)}
|
onChange={setLabelName}
|
||||||
placeholder="e.g. production"
|
suggestions={knownLabelNames}
|
||||||
className="h-9 text-sm"
|
|
||||||
disabled={running}
|
disabled={running}
|
||||||
|
placeholder="e.g. production"
|
||||||
/>
|
/>
|
||||||
<datalist id="fleet-stop-label-suggestions">
|
|
||||||
{knownLabelNames.map(n => <option key={n} value={n} />)}
|
|
||||||
</datalist>
|
|
||||||
</SheetSection>
|
</SheetSection>
|
||||||
|
|
||||||
{previewSection}
|
{previewSection}
|
||||||
@@ -308,3 +303,91 @@ function PreviewWell({ perNode }: PreviewWellProps) {
|
|||||||
</div>
|
</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 BlastRadiusTone = 'warning' | 'success' | 'muted';
|
||||||
export type PrimaryActionVariant = 'primary' | 'destructive';
|
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 {
|
export interface FleetActionCardProps {
|
||||||
/** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */
|
/** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */
|
||||||
crumb: string[];
|
crumb: string[];
|
||||||
@@ -107,7 +115,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
|||||||
const headCrumbs = crumb.slice(0, -1);
|
const headCrumbs = crumb.slice(0, -1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0">
|
<Card className="relative overflow-hidden p-0 flex flex-col h-full">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
|
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
|
||||||
@@ -166,7 +174,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6">
|
<div className="px-6 flex-1">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user