Create primitive for CardRail

This commit is contained in:
James Brunton
2026-08-27 11:39:03 +01:00
parent 61f86cfa50
commit f2debcd930
6 changed files with 177 additions and 21 deletions
+48
View File
@@ -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);
}
@@ -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: <ShieldOutlinedIcon />,
title: "Security",
desc: "Redact, sanitize, and watermark every document.",
},
{
icon: <CategoryOutlinedIcon />,
title: "Classification",
desc: "Tag each document against your team's labels.",
},
{
icon: <GavelOutlinedIcon />,
title: "Compliance",
desc: "Enforce frameworks and keep an audit trail.",
},
{
icon: <LayersOutlinedIcon />,
title: "Ingestion",
desc: "OCR and flatten documents as they arrive.",
},
{
icon: <AltRouteOutlinedIcon />,
title: "Routing",
desc: "Send finished documents where they belong.",
},
{
icon: <ScheduleOutlinedIcon />,
title: "Retention",
desc: "Archive and expire on your schedule.",
},
];
const meta: Meta<typeof CardRail> = {
title: "Primitives/CardRail",
component: CardRail,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof CardRail>;
/** A row of equal-size cards that scrolls sideways when they overflow the container. */
export const Default: Story = {
render: () => (
<CardRail itemWidth="16rem" itemHeight="11rem">
{items.map((it) => (
<OptionCard
key={it.title}
icon={it.icon}
title={it.title}
description={it.desc}
cta="Set up"
onSelect={() => {}}
/>
))}
</CardRail>
),
};
+56
View File
@@ -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<HTMLElement> {
/** 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 (
<Tag className={classes} style={vars} {...rest}>
{children}
</Tag>
);
}
+1
View File
@@ -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";
@@ -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;
@@ -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() {
<h2 className="portal-pipelines__section-title">
{t("portal.pipelines.templates.title")}
</h2>
<div className="portal-pipelines__templates-scroll">
<CardRail itemWidth="16rem" itemHeight="12rem">
{galleryEntries.map((entry) => (
<PipelineTemplateCard
key={entry.category.id}
@@ -321,7 +321,7 @@ export function Pipelines() {
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
</CardRail>
</section>
)}