From f2debcd9303518e088d7378c10c196e55d09e911 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Aug 2026 10:40:37 +0100 Subject: [PATCH] Create primitive for CardRail --- frontend/editor/src/core/ui/CardRail.css | 48 +++++++++++++ .../editor/src/core/ui/CardRail.stories.tsx | 69 +++++++++++++++++++ frontend/editor/src/core/ui/CardRail.tsx | 56 +++++++++++++++ frontend/editor/src/core/ui/index.ts | 1 + .../editor/src/portal/views/Pipelines.css | 18 ----- .../editor/src/portal/views/Pipelines.tsx | 6 +- 6 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 frontend/editor/src/core/ui/CardRail.css create mode 100644 frontend/editor/src/core/ui/CardRail.stories.tsx create mode 100644 frontend/editor/src/core/ui/CardRail.tsx diff --git a/frontend/editor/src/core/ui/CardRail.css b/frontend/editor/src/core/ui/CardRail.css new file mode 100644 index 0000000000..a4ac526780 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.css @@ -0,0 +1,48 @@ +.sui-card-rail { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + /* A real horizontal scroller: keep the bounce, but don't chain to the browser's back gesture. */ + overscroll-behavior-x: contain; + /* Room for the scrollbar so it doesn't sit under the last row of item content. */ + padding-bottom: 0.25rem; +} + +/* Gap scale mirrors Stack / Inline. */ +.sui-card-rail--gap-0 { + gap: var(--space-0); +} +.sui-card-rail--gap-0_5 { + gap: var(--space-0_5); +} +.sui-card-rail--gap-1 { + gap: var(--space-1); +} +.sui-card-rail--gap-1_5 { + gap: var(--space-1_5); +} +.sui-card-rail--gap-2 { + gap: var(--space-2); +} +.sui-card-rail--gap-3 { + gap: var(--space-3); +} +.sui-card-rail--gap-4 { + gap: var(--space-4); +} +.sui-card-rail--gap-5 { + gap: var(--space-5); +} +.sui-card-rail--gap-6 { + gap: var(--space-6); +} +.sui-card-rail--gap-8 { + gap: var(--space-8); +} + +/* Uniform item sizing: fixed width so items don't stretch/shrink, optional fixed height so a row of + cards is equal-height. Both default to natural sizing when the caller sets no dimension. */ +.sui-card-rail > * { + flex: 0 0 var(--sui-card-rail-item-w, auto); + height: var(--sui-card-rail-item-h, auto); +} diff --git a/frontend/editor/src/core/ui/CardRail.stories.tsx b/frontend/editor/src/core/ui/CardRail.stories.tsx new file mode 100644 index 0000000000..79ce5597a7 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined"; +import GavelOutlinedIcon from "@mui/icons-material/GavelOutlined"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined"; +import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; +import { CardRail } from "@app/ui/CardRail"; +import { OptionCard } from "@app/ui/OptionCard"; + +const items = [ + { + icon: , + title: "Security", + desc: "Redact, sanitize, and watermark every document.", + }, + { + icon: , + title: "Classification", + desc: "Tag each document against your team's labels.", + }, + { + icon: , + title: "Compliance", + desc: "Enforce frameworks and keep an audit trail.", + }, + { + icon: , + title: "Ingestion", + desc: "OCR and flatten documents as they arrive.", + }, + { + icon: , + title: "Routing", + desc: "Send finished documents where they belong.", + }, + { + icon: , + title: "Retention", + desc: "Archive and expire on your schedule.", + }, +]; + +const meta: Meta = { + title: "Primitives/CardRail", + component: CardRail, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** A row of equal-size cards that scrolls sideways when they overflow the container. */ +export const Default: Story = { + render: () => ( + + {items.map((it) => ( + {}} + /> + ))} + + ), +}; diff --git a/frontend/editor/src/core/ui/CardRail.tsx b/frontend/editor/src/core/ui/CardRail.tsx new file mode 100644 index 0000000000..6d989866e1 --- /dev/null +++ b/frontend/editor/src/core/ui/CardRail.tsx @@ -0,0 +1,56 @@ +import type { + CSSProperties, + ElementType, + HTMLAttributes, + ReactNode, +} from "react"; +import type { StackGap } from "@app/ui/Stack"; +import "@app/ui/CardRail.css"; + +export interface CardRailProps extends HTMLAttributes { + /** Token-aligned gap between items (maps to `--space-*`). */ + gap?: StackGap; + /** Fixed width for every item (any CSS length); omit to let items size themselves. */ + itemWidth?: string; + /** Fixed height for every item; omit for natural height. Equal heights line item footers up. */ + itemHeight?: string; + as?: ElementType; + children?: ReactNode; +} + +/** + * A horizontal row of equal-sized items that scrolls sideways rather than wrapping - the "rail" of + * cards motif (template galleries, tier pickers, at-a-glance strips). The scrolling sibling to + * {@link Stack} (vertical) and {@link Inline} (horizontal, wraps): it keeps items on one line, + * contains the overscroll so it doesn't trigger the browser back-gesture, and sizes every child + * uniformly so their footers align. + */ +export function CardRail({ + gap = "3", + itemWidth, + itemHeight, + as, + className, + style, + children, + ...rest +}: CardRailProps) { + const Tag: ElementType = as ?? "div"; + const vars = { + ...(itemWidth ? { "--sui-card-rail-item-w": itemWidth } : {}), + ...(itemHeight ? { "--sui-card-rail-item-h": itemHeight } : {}), + ...style, + } as CSSProperties; + const classes = [ + "sui-card-rail", + `sui-card-rail--gap-${gap}`, + className ?? "", + ] + .filter(Boolean) + .join(" "); + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index 94a14404da..aecd6e97e3 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -11,6 +11,7 @@ export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; export * from "@app/ui/NodeCard"; export * from "@app/ui/OptionCard"; +export * from "@app/ui/CardRail"; export * from "@app/ui/IconPicker"; export * from "@app/ui/NavItem"; export * from "@app/ui/NavSurface"; diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index 04249e950a..cd443cdedd 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -39,24 +39,6 @@ gap: 0.75rem; } -/* Template gallery: a single row of cards that scrolls sideways rather than wrapping. */ -.portal-pipelines__templates-scroll { - display: flex; - flex-wrap: nowrap; - gap: 0.75rem; - overflow-x: auto; - /* A real horizontal scroller: keep the bounce, but don't chain to the browser's back gesture. */ - overscroll-behavior-x: contain; - /* Room for the scrollbar so it doesn't sit under the last row of card text. */ - padding-bottom: 0.25rem; -} - -/* Fixed track height so every OptionCard in the row is the same height and their footers line up. */ -.portal-pipelines__templates-scroll > * { - flex: 0 0 16rem; - height: 12rem; -} - .portal-pipelines__section-title { margin: 0; font-size: 1rem; diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index 2f4581d3f6..214c02f38d 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Banner, Button, EmptyState, Skeleton } from "@app/ui"; +import { Banner, Button, CardRail, EmptyState, Skeleton } from "@app/ui"; import { errorMessage } from "@portal/api/http"; import { useSectionFlags } from "@portal/hooks/useAsync"; import { usePipelines } from "@portal/queries/pipelines"; @@ -311,7 +311,7 @@ export function Pipelines() {

{t("portal.pipelines.templates.title")}

-
+ {galleryEntries.map((entry) => ( ))} -
+ )}