diff --git a/package.json b/package.json index 9d2e2ba..cc6e223 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.73.0", + "version": "0.74.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/http-method-badge/HttpMethodBadge.stories.tsx b/src/components/ui/http-method-badge/HttpMethodBadge.stories.tsx new file mode 100644 index 0000000..c5cd133 --- /dev/null +++ b/src/components/ui/http-method-badge/HttpMethodBadge.stories.tsx @@ -0,0 +1,226 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactElement } from "react"; + +import { HttpMethodBadge } from "./http-method-badge"; +import { + httpMethodBadgeAppearanceIds, + httpMethodBadgeSizeIds, + httpMethodIds, + type HttpMethod, +} from "./http-method-badge-variants"; + +const meta = { + title: "Components/HttpMethodBadge", + component: HttpMethodBadge, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + method: { + options: httpMethodIds, + control: { type: "select" }, + }, + appearance: { + options: httpMethodBadgeAppearanceIds, + control: { type: "radio" }, + }, + size: { + options: httpMethodBadgeSizeIds, + control: { type: "radio" }, + }, + width: { + options: ["auto", "fixed"], + control: { type: "radio" }, + }, + label: { + control: { type: "text" }, + }, + }, + args: { + method: "GET", + appearance: "soft", + size: "md", + width: "auto", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Get: Story = { + args: { method: "GET" }, +}; + +export const Post: Story = { + args: { method: "POST" }, +}; + +export const Put: Story = { + args: { method: "PUT" }, +}; + +export const Patch: Story = { + args: { method: "PATCH" }, +}; + +export const Delete: Story = { + args: { method: "DELETE" }, +}; + +export const SolidAppearance: Story = { + args: { method: "POST", appearance: "solid" }, +}; + +export const OutlineAppearance: Story = { + args: { method: "DELETE", appearance: "outline" }, +}; + +export const AllMethods: Story = { + render: (): ReactElement => ( +
+ {httpMethodBadgeAppearanceIds.map((appearance) => ( +
+ + {appearance} + + {httpMethodIds.map((method) => ( + + ))} +
+ ))} +
+ ), +}; + +export const AllSizes: Story = { + render: (): ReactElement => ( +
+ {httpMethodBadgeSizeIds.map((size) => ( +
+ + {size} + + {(["GET", "POST", "PUT", "PATCH", "DELETE"] satisfies HttpMethod[]).map( + (method) => ( + + ), + )} +
+ ))} +
+ ), +}; + +interface MockEndpoint { + method: HttpMethod; + path: string; + description: string; +} + +const mockEndpoints: MockEndpoint[] = [ + { method: "GET", path: "/api/v1/vaults", description: "List all vaults" }, + { method: "POST", path: "/api/v1/vaults", description: "Create a new vault" }, + { + method: "GET", + path: "/api/v1/vaults/{id}", + description: "Retrieve a vault", + }, + { + method: "PUT", + path: "/api/v1/vaults/{id}", + description: "Replace a vault", + }, + { + method: "PATCH", + path: "/api/v1/vaults/{id}", + description: "Update vault fields", + }, + { + method: "DELETE", + path: "/api/v1/vaults/{id}", + description: "Delete a vault", + }, + { + method: "OPTIONS", + path: "/api/v1/vaults", + description: "Discover available verbs", + }, + { + method: "HEAD", + path: "/api/v1/vaults/{id}", + description: "Check vault existence", + }, +]; + +export const EndpointList: Story = { + parameters: { layout: "padded" }, + render: (): ReactElement => ( +
+

API Endpoints

+
    + {mockEndpoints.map((endpoint) => ( +
  • + + {endpoint.path} + + {endpoint.description} + +
  • + ))} +
+
+ ), +}; + +export const FixedWidthAlignment: Story = { + render: (): ReactElement => ( +
+ {(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] satisfies HttpMethod[]).map( + (method) => ( +
+ + /api/v1/resource +
+ ), + )} +
+ ), +}; + +export const InlineWithText: Story = { + parameters: { layout: "padded" }, + render: (): ReactElement => ( +

+ Send a request to{" "} + + /api/v1/vaults + {" "} + to create a new vault. Use to + modify only specific fields without overwriting the entire resource. +

+ ), +}; + +export const LowercaseInput: Story = { + name: "Accepts lowercase input", + args: { + method: "get" as HttpMethod, + }, +}; + +export const CustomLabel: Story = { + args: { + method: "POST", + label: "POST*", + }, +}; diff --git a/src/components/ui/http-method-badge/http-method-badge-variants.ts b/src/components/ui/http-method-badge/http-method-badge-variants.ts new file mode 100644 index 0000000..b53e97d --- /dev/null +++ b/src/components/ui/http-method-badge/http-method-badge-variants.ts @@ -0,0 +1,27 @@ +export const httpMethodIds = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", + "TRACE", + "CONNECT", +] as const satisfies readonly string[]; +export type HttpMethod = (typeof httpMethodIds)[number]; + +export const httpMethodBadgeAppearanceIds = [ + "solid", + "soft", + "outline", +] as const satisfies readonly string[]; +export type HttpMethodBadgeAppearance = + (typeof httpMethodBadgeAppearanceIds)[number]; + +export const httpMethodBadgeSizeIds = [ + "sm", + "md", + "lg", +] as const satisfies readonly string[]; +export type HttpMethodBadgeSize = (typeof httpMethodBadgeSizeIds)[number]; diff --git a/src/components/ui/http-method-badge/http-method-badge.tsx b/src/components/ui/http-method-badge/http-method-badge.tsx new file mode 100644 index 0000000..94e1564 --- /dev/null +++ b/src/components/ui/http-method-badge/http-method-badge.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import type { ComponentProps, ReactElement, Ref } from "react"; + +import { cn } from "@/lib/utils"; +import { + httpMethodBadgeAppearanceIds, + httpMethodBadgeSizeIds, + httpMethodIds, + type HttpMethod, + type HttpMethodBadgeAppearance, + type HttpMethodBadgeSize, +} from "./http-method-badge-variants"; + +/** + * Color palette per HTTP method. Each method maps to its own semantic colour + * across the three appearance modes (solid / soft / outline). Theme tokens + * (`primary`, `destructive`, `warning`, `muted`) are preferred where the + * semantics line up; the remaining methods use Tailwind colour primitives so + * they still adapt to light/dark mode. + */ +const httpMethodColors: Record< + HttpMethod, + Record +> = { + GET: { + solid: + "bg-sky-600 text-white border-sky-600 dark:bg-sky-500 dark:border-sky-500", + soft: "bg-sky-500/15 text-sky-700 border-sky-500/30 dark:text-sky-300 dark:bg-sky-500/20", + outline: + "bg-transparent text-sky-700 border-sky-500/50 dark:text-sky-300 dark:border-sky-400/60", + }, + POST: { + solid: + "bg-emerald-600 text-white border-emerald-600 dark:bg-emerald-500 dark:border-emerald-500", + soft: "bg-emerald-500/15 text-emerald-700 border-emerald-500/30 dark:text-emerald-300 dark:bg-emerald-500/20", + outline: + "bg-transparent text-emerald-700 border-emerald-500/50 dark:text-emerald-300 dark:border-emerald-400/60", + }, + PUT: { + solid: "bg-warning text-warning-foreground border-warning", + soft: "bg-warning/15 text-warning-foreground border-warning/40 dark:bg-warning/20", + outline: + "bg-transparent text-warning-foreground border-warning/50 dark:border-warning/70", + }, + PATCH: { + solid: + "bg-violet-600 text-white border-violet-600 dark:bg-violet-500 dark:border-violet-500", + soft: "bg-violet-500/15 text-violet-700 border-violet-500/30 dark:text-violet-300 dark:bg-violet-500/20", + outline: + "bg-transparent text-violet-700 border-violet-500/50 dark:text-violet-300 dark:border-violet-400/60", + }, + DELETE: { + solid: "bg-destructive text-white border-destructive", + soft: "bg-destructive/15 text-destructive border-destructive/30", + outline: + "bg-transparent text-destructive border-destructive/50 dark:border-destructive/70", + }, + OPTIONS: { + solid: + "bg-foreground text-background border-foreground", + soft: "bg-muted text-muted-foreground border-border", + outline: "bg-transparent text-muted-foreground border-border", + }, + HEAD: { + solid: + "bg-slate-600 text-white border-slate-600 dark:bg-slate-500 dark:border-slate-500", + soft: "bg-slate-500/15 text-slate-700 border-slate-500/30 dark:text-slate-300 dark:bg-slate-500/20", + outline: + "bg-transparent text-slate-700 border-slate-500/50 dark:text-slate-300 dark:border-slate-400/60", + }, + TRACE: { + solid: + "bg-zinc-600 text-white border-zinc-600 dark:bg-zinc-500 dark:border-zinc-500", + soft: "bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-300 dark:bg-zinc-500/20", + outline: + "bg-transparent text-zinc-700 border-zinc-500/50 dark:text-zinc-300 dark:border-zinc-400/60", + }, + CONNECT: { + solid: + "bg-stone-600 text-white border-stone-600 dark:bg-stone-500 dark:border-stone-500", + soft: "bg-stone-500/15 text-stone-700 border-stone-500/30 dark:text-stone-300 dark:bg-stone-500/20", + outline: + "bg-transparent text-stone-700 border-stone-500/50 dark:text-stone-300 dark:border-stone-400/60", + }, +}; + +const httpMethodBadgeVariants = cva( + "inline-flex items-center justify-center rounded-md border font-mono font-semibold uppercase tracking-wide whitespace-nowrap select-none align-middle transition-colors", + { + variants: { + size: { + sm: "h-5 px-1.5 text-[10px] leading-none", + md: "h-6 px-2 text-xs leading-none", + lg: "h-7 px-2.5 text-sm leading-none", + } satisfies Record, + width: { + auto: "", + // Fixed width keeps badges vertically aligned in endpoint lists. The + // widest method we render is "OPTIONS" / "CONNECT" (7 chars). + fixed: "", + }, + }, + compoundVariants: [ + { size: "sm", width: "fixed", className: "w-[3.75rem]" }, + { size: "md", width: "fixed", className: "w-[4.25rem]" }, + { size: "lg", width: "fixed", className: "w-[5rem]" }, + ], + defaultVariants: { + size: "md", + width: "auto", + }, + }, +); + +type CvaRootProps = VariantProps; + +export interface HttpMethodBadgeProps + extends Omit, "children">, + Omit { + /** HTTP method to render. Case-insensitive — always rendered upper-case. */ + method: HttpMethod | Lowercase; + /** Colour intensity. `soft` (default) is best for dense lists; `solid` for emphasis. */ + appearance?: HttpMethodBadgeAppearance; + /** + * When `fixed`, the badge takes a constant width so methods stack neatly + * in endpoint lists. Defaults to `auto`. + */ + width?: "auto" | "fixed"; + /** Override the label rendered inside the badge. Defaults to the method name. */ + label?: string; + ref?: Ref; +} + +function normalizeMethod(method: HttpMethodBadgeProps["method"]): HttpMethod { + return method.toUpperCase() as HttpMethod; +} + +function HttpMethodBadge({ + className, + method, + appearance = "soft", + size, + width = "auto", + label, + ref, + ...props +}: HttpMethodBadgeProps): ReactElement { + const normalized = normalizeMethod(method); + const palette = httpMethodColors[normalized]; + return ( + + {label ?? normalized} + + ); +} +HttpMethodBadge.displayName = "HttpMethodBadge"; + +export { + HttpMethodBadge, + httpMethodBadgeVariants, + httpMethodColors, + httpMethodIds, + httpMethodBadgeAppearanceIds, + httpMethodBadgeSizeIds, +}; +export type { HttpMethod, HttpMethodBadgeAppearance, HttpMethodBadgeSize }; + +export default HttpMethodBadge; diff --git a/src/components/ui/http-method-badge/index.ts b/src/components/ui/http-method-badge/index.ts new file mode 100644 index 0000000..ae86c0a --- /dev/null +++ b/src/components/ui/http-method-badge/index.ts @@ -0,0 +1,17 @@ +export { + HttpMethodBadge, + HttpMethodBadge as default, + httpMethodBadgeVariants, + httpMethodColors, +} from "./http-method-badge"; +export type { HttpMethodBadgeProps } from "./http-method-badge"; +export { + httpMethodIds, + httpMethodBadgeAppearanceIds, + httpMethodBadgeSizeIds, +} from "./http-method-badge-variants"; +export type { + HttpMethod, + HttpMethodBadgeAppearance, + HttpMethodBadgeSize, +} from "./http-method-badge-variants"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index df4b4a7..4ea4f61 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -294,5 +294,8 @@ export type * from "./time-picker"; export * from "./mark"; export type * from "./mark"; +export * from "./http-method-badge"; +export type * from "./http-method-badge"; + export * from "./browser-frame"; export type * from "./browser-frame";