diff --git a/package.json b/package.json index cc6e223..1315b1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.74.0", + "version": "0.75.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/description-list/DescriptionList.stories.tsx b/src/components/ui/description-list/DescriptionList.stories.tsx new file mode 100644 index 0000000..e8e8b3e --- /dev/null +++ b/src/components/ui/description-list/DescriptionList.stories.tsx @@ -0,0 +1,287 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactElement } from "react"; +import { Check, X } from "lucide-react"; + +import { + DescriptionList, + DescriptionItem, + DescriptionTerm, + DescriptionDetails, + descriptionListLayoutIds, + descriptionListSizeIds, + descriptionListVariantIds, + type DescriptionListLayoutId, + type DescriptionListSizeId, + type DescriptionListVariantId, +} from "./description-list"; +import { Badge } from "../badge/badge"; + +interface DescriptionListExampleProps { + layout?: DescriptionListLayoutId; + size?: DescriptionListSizeId; + variant?: DescriptionListVariantId; + divided?: boolean; + padded?: boolean; +} + +function DescriptionListExample({ + layout = "responsive", + size = "md", + variant = "default", + divided = true, + padded, +}: DescriptionListExampleProps): ReactElement { + return ( + + + Name + customer-events-v3 + + + Vault + analytics-prod + + + Created + March 14, 2026 at 11:42 UTC + + + Owner + jalexwhitman@schemavaults.com + + + Status + + Published + + + + ); +} + +const meta = { + title: "Components/DescriptionList", + component: DescriptionListExample, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + argTypes: { + layout: { + options: descriptionListLayoutIds, + control: { type: "radio" }, + }, + size: { + options: descriptionListSizeIds, + control: { type: "radio" }, + }, + variant: { + options: descriptionListVariantIds, + control: { type: "radio" }, + }, + divided: { control: { type: "boolean" } }, + padded: { control: { type: "boolean" } }, + }, + args: { + layout: "responsive", + size: "md", + variant: "default", + divided: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Stacked: Story = { + args: { layout: "stacked", divided: false }, +}; + +export const Inline: Story = { + args: { layout: "inline" }, +}; + +export const Grid: Story = { + args: { layout: "grid" }, +}; + +export const Responsive: Story = { + args: { layout: "responsive" }, +}; + +export const Card: Story = { + args: { variant: "card" }, +}; + +export const Muted: Story = { + args: { variant: "muted" }, +}; + +export const Small: Story = { + args: { size: "sm", variant: "card" }, +}; + +export const Large: Story = { + args: { size: "lg" }, +}; + +export const NoDividers: Story = { + args: { divided: false }, +}; + +function LoadingExample(): ReactElement { + return ( + + + Name + customer-events-v3 + + + Vault + + + + Created + + + + Owner + + + + ); +} + +export const Loading: Story = { + render: (): ReactElement => , +}; + +function SchemaFieldsExample(): ReactElement { + return ( + + + + + id + + + +
+
+ string + + + required + +
+

+ Unique identifier for the event. Must be a UUID v4. +

+
+
+
+ + + + customer_id + + + +
+
+ string + + + required + +
+

+ External customer reference, scoped to the workspace. +

+
+
+
+ + + + occurred_at + + + +
+
+ date-time + + + required + +
+

+ ISO 8601 timestamp marking when the event occurred. +

+
+
+
+ + + + metadata + + + +
+
+ object + + + optional + +
+

+ Free-form key/value pairs attached to the event. +

+
+
+
+
+ ); +} + +export const SchemaFields: Story = { + render: (): ReactElement => , +}; + +function InlineKeyValueExample(): ReactElement { + return ( + + + Region + us-east-1 + + + Plan + Enterprise + + + Seats + 42 / 50 + + + Next invoice + July 1, 2026 + + + ); +} + +export const InlineKeyValue: Story = { + render: (): ReactElement => , +}; diff --git a/src/components/ui/description-list/description-list.tsx b/src/components/ui/description-list/description-list.tsx new file mode 100644 index 0000000..fa149ae --- /dev/null +++ b/src/components/ui/description-list/description-list.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import type { HTMLAttributes, ReactElement, Ref } from "react"; + +import { cn } from "@/lib/utils"; +import { Skeleton } from "../skeleton/skeleton"; + +export const descriptionListLayoutIds = [ + "stacked", + "inline", + "grid", + "responsive", +] as const satisfies string[]; + +export type DescriptionListLayoutId = (typeof descriptionListLayoutIds)[number]; + +export const descriptionListSizeIds = ["sm", "md", "lg"] as const satisfies string[]; + +export type DescriptionListSizeId = (typeof descriptionListSizeIds)[number]; + +export const descriptionListVariantIds = [ + "default", + "card", + "muted", +] as const satisfies string[]; + +export type DescriptionListVariantId = (typeof descriptionListVariantIds)[number]; + +const descriptionListVariants = cva("w-full text-foreground", { + variants: { + variant: { + default: "", + card: "rounded-lg border border-border bg-card text-card-foreground shadow-sm", + muted: "rounded-lg bg-muted/40", + } satisfies Record, + size: { + sm: "text-xs [&_dt]:text-xs [&_dd]:text-xs", + md: "text-sm [&_dt]:text-sm [&_dd]:text-sm", + lg: "text-base [&_dt]:text-base [&_dd]:text-base", + } satisfies Record, + padded: { + true: "", + false: "", + }, + divided: { + true: "[&>*+*]:border-t [&>*+*]:border-border", + false: "", + }, + }, + compoundVariants: [ + { variant: "card", padded: true, class: "px-4 py-2" }, + { variant: "muted", padded: true, class: "px-4 py-2" }, + { variant: "default", padded: true, class: "py-1" }, + ], + defaultVariants: { + variant: "default", + size: "md", + padded: false, + divided: false, + }, +}); + +export interface DescriptionListProps + extends HTMLAttributes, + Omit, "padded" | "divided"> { + /** + * Controls how each term/details pair is laid out. + * - `stacked`: dt above dd (default) + * - `inline`: dt and dd on the same row + * - `grid`: 1/3 + 2/3 grid columns on all sizes + * - `responsive`: stacked on mobile, grid on `sm:` and up + */ + layout?: DescriptionListLayoutId; + /** + * Adds horizontal padding so the list sits nicely inside `card`/`muted` + * containers. Defaults to true when `variant` is `card` or `muted`. + */ + padded?: boolean; + /** Add a thin divider between consecutive items. */ + divided?: boolean; + ref?: Ref; +} + +function DescriptionList({ + className, + variant = "default", + size = "md", + layout = "stacked", + padded, + divided = false, + ref, + ...props +}: DescriptionListProps): ReactElement { + const resolvedPadded: boolean = + padded ?? (variant === "card" || variant === "muted"); + return ( +
+ ); +} +DescriptionList.displayName = "DescriptionList"; + +const descriptionItemVariants = cva("", { + variants: { + layout: { + stacked: "flex flex-col gap-1 py-2", + inline: "flex flex-row items-baseline justify-between gap-4 py-2", + grid: "grid grid-cols-3 gap-4 py-2", + responsive: "flex flex-col gap-1 py-2 sm:grid sm:grid-cols-3 sm:gap-4", + } satisfies Record, + }, + defaultVariants: { + layout: "stacked", + }, +}); + +export interface DescriptionItemProps + extends HTMLAttributes, + VariantProps { + ref?: Ref; +} + +function DescriptionItem({ + className, + layout, + ref, + ...props +}: DescriptionItemProps): ReactElement { + return ( +
+ ); +} +DescriptionItem.displayName = "DescriptionItem"; + +export interface DescriptionTermProps + extends HTMLAttributes { + ref?: Ref; +} + +function DescriptionTerm({ + className, + ref, + ...props +}: DescriptionTermProps): ReactElement { + return ( +
+ ); +} +DescriptionTerm.displayName = "DescriptionTerm"; + +export interface DescriptionDetailsProps + extends HTMLAttributes { + /** + * When true, render a skeleton placeholder in place of the value. Useful for + * async data fetches where the term is known but the value is still loading. + */ + loading?: boolean; + ref?: Ref; +} + +function DescriptionDetails({ + className, + loading = false, + children, + ref, + ...props +}: DescriptionDetailsProps): ReactElement { + return ( +
+ {loading ? ( + + ) : ( + children + )} +
+ ); +} +DescriptionDetails.displayName = "DescriptionDetails"; + +export { + DescriptionList, + DescriptionItem, + DescriptionTerm, + DescriptionDetails, + descriptionListVariants, + descriptionItemVariants, +}; diff --git a/src/components/ui/description-list/index.ts b/src/components/ui/description-list/index.ts new file mode 100644 index 0000000..19a953f --- /dev/null +++ b/src/components/ui/description-list/index.ts @@ -0,0 +1,22 @@ +export { + DescriptionList, + DescriptionItem, + DescriptionTerm, + DescriptionDetails, + descriptionListVariants, + descriptionItemVariants, + descriptionListLayoutIds, + descriptionListSizeIds, + descriptionListVariantIds, +} from "./description-list"; +export type { + DescriptionListProps, + DescriptionItemProps, + DescriptionTermProps, + DescriptionDetailsProps, + DescriptionListLayoutId, + DescriptionListSizeId, + DescriptionListVariantId, +} from "./description-list"; + +export { DescriptionList as default } from "./description-list"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 4ea4f61..24113a6 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -117,6 +117,9 @@ export type * from "./skeleton"; export * from "./key-value-with-skeleton"; export type * from "./key-value-with-skeleton"; +export * from "./description-list"; +export type * from "./description-list"; + export * from "./status-blinker"; export type * from "./status-blinker";