fix(ui): un-cramp the requests filter row
The state pills and the agent select sat 10px apart, and the pill row was sliced mid-chip at the viewport edge with nothing to say it scrolled. - New `ScrollRow`: fades whichever edge still has content behind it, so a clipped chip reads as "scroll for more" instead of as a broken layout. Reused for the admin and connect-modal tab strips. - Drop scroll-snap from the pill row. It clamped the resting scrollLeft to the container's 12px padding, so the first chip sat flush against the screen edge with no gutter; flick-snapping through short chips felt wrong regardless. - Breathing room: pills to select 10px -> 14px, filters to list 16px -> 20px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import type { Agent } from "../api/types";
|
|||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { Button, Input, Textarea } from "./ui";
|
import { Button, Input, Textarea } from "./ui";
|
||||||
import { CodeBlock } from "./CodeBlock";
|
import { CodeBlock } from "./CodeBlock";
|
||||||
|
import { ScrollRow } from "./ScrollRow";
|
||||||
|
|
||||||
// ── Create / edit form ───────────────────────────────────────────────────────────
|
// ── Create / edit form ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -229,7 +230,7 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-4 sm:mx-0 sm:px-0">
|
<ScrollRow wrapperClassName="border-b border-border" className="gap-1" bleed={false}>
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@@ -241,7 +242,7 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
|||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollRow>
|
||||||
|
|
||||||
{tab === "openclaw" && (
|
{tab === "openclaw" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const FADE = 28; // px of taper at each overflowing edge
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontally scrollable row of chips/tabs. Fades whichever edge still has
|
||||||
|
* content behind it, so a clipped item reads as "scroll for more" rather than
|
||||||
|
* as a layout bug — and contains its overscroll so a sideways swipe never
|
||||||
|
* triggers the browser's back gesture.
|
||||||
|
*/
|
||||||
|
export function ScrollRow({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
wrapperClassName = "",
|
||||||
|
bleed = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Applied to the scrolling element (layout of the items). */
|
||||||
|
className?: string;
|
||||||
|
/** Applied to the positioned wrapper (borders, margins). */
|
||||||
|
wrapperClassName?: string;
|
||||||
|
/** Extend to the screen edges on phones so the row reads as scrollable. */
|
||||||
|
bleed?: boolean;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [edges, setEdges] = useState({ start: false, end: false });
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const max = el.scrollWidth - el.clientWidth;
|
||||||
|
setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
update();
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
for (const child of Array.from(el.children)) ro.observe(child);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
|
const mask = `linear-gradient(to right, ${
|
||||||
|
edges.start ? `transparent 0, black ${FADE}px` : "black 0"
|
||||||
|
}, ${edges.end ? `black calc(100% - ${FADE}px), transparent 100%` : "black 100%"})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${bleed ? "-mx-3 sm:mx-0" : ""} ${wrapperClassName}`}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onScroll={update}
|
||||||
|
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||||
|
className={`no-scrollbar flex overflow-x-auto overscroll-x-contain ${
|
||||||
|
bleed ? "px-3 sm:px-0" : ""
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
||||||
import { ConfirmSheet } from "../components/ConfirmSheet";
|
import { ConfirmSheet } from "../components/ConfirmSheet";
|
||||||
|
import { ScrollRow } from "../components/ScrollRow";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
type Tab = "users" | "agents" | "settings" | "audit";
|
type Tab = "users" | "agents" | "settings" | "audit";
|
||||||
@@ -21,7 +22,7 @@ export function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Admin" subtitle="Platform administration." />
|
<PageHeader title="Admin" subtitle="Platform administration." />
|
||||||
<div className="no-scrollbar -mx-3 mb-6 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-3 sm:mx-0 sm:px-0">
|
<ScrollRow wrapperClassName="mb-6 border-b border-border" className="gap-1">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@@ -33,7 +34,7 @@ export function AdminPage() {
|
|||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollRow>
|
||||||
{tab === "users" && <UsersTab />}
|
{tab === "users" && <UsersTab />}
|
||||||
{tab === "agents" && <AgentsTab />}
|
{tab === "agents" && <AgentsTab />}
|
||||||
{tab === "settings" && <SettingsTab />}
|
{tab === "settings" && <SettingsTab />}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui"
|
|||||||
import { StateBadge } from "../components/StateBadge";
|
import { StateBadge } from "../components/StateBadge";
|
||||||
import { AgentAvatar } from "../components/AgentAvatar";
|
import { AgentAvatar } from "../components/AgentAvatar";
|
||||||
import { ChevronRightIcon } from "../components/icons";
|
import { ChevronRightIcon } from "../components/icons";
|
||||||
|
import { ScrollRow } from "../components/ScrollRow";
|
||||||
import { relativeTime } from "../utils";
|
import { relativeTime } from "../utils";
|
||||||
|
|
||||||
const STATES: RequestState[] = [
|
const STATES: RequestState[] = [
|
||||||
@@ -61,7 +62,7 @@ export function RequestsPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
const chip = (active: boolean) =>
|
const chip = (active: boolean) =>
|
||||||
`tap-sm min-h-9 shrink-0 snap-start rounded-full border px-3.5 text-sm font-medium transition ${
|
`tap-sm min-h-9 shrink-0 rounded-full border px-3.5 text-sm font-medium transition ${
|
||||||
active
|
active
|
||||||
? "border-primary/40 bg-primary/15 text-primary"
|
? "border-primary/40 bg-primary/15 text-primary"
|
||||||
: "border-border-strong bg-surface text-muted hover:text-text"
|
: "border-border-strong bg-surface text-muted hover:text-text"
|
||||||
@@ -71,10 +72,11 @@ export function RequestsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
|
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
|
||||||
|
|
||||||
<div className="mb-4 space-y-2.5">
|
<div className="mb-5 space-y-3.5">
|
||||||
{/* Swipeable filter pills — bleeds to the screen edges on phones so the
|
{/* No scroll-snap: it clamps the resting scrollLeft to the container's
|
||||||
row reads as scrollable. */}
|
padding, which eats the left gutter, and flick-snapping through
|
||||||
<div className="no-scrollbar -mx-3 flex snap-x gap-2 overflow-x-auto overscroll-x-contain px-3 pb-0.5 sm:mx-0 sm:flex-wrap sm:px-0">
|
short chips feels wrong anyway. */}
|
||||||
|
<ScrollRow className="gap-2 pb-0.5 sm:flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStateFilter("");
|
setStateFilter("");
|
||||||
@@ -96,7 +98,7 @@ export function RequestsPage() {
|
|||||||
{s.replace("_", " ").toLowerCase()}
|
{s.replace("_", " ").toLowerCase()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollRow>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={agentFilter}
|
value={agentFilter}
|
||||||
|
|||||||
Reference in New Issue
Block a user