feat(site): add UI primitives for the AI settings stack (#25579)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355)

First PR in a 5-PR stack splitting #25328. Adds the small UI primitives
the AI settings stack depends on.

- `FormField` accepts a `description` prop and renders a required
marker. `aria-describedby` is composed from the description, helper, and
error IDs.
- `PageHeader` title, subtitle, and caption forward `className` and
other intrinsic `h1`/`h2`/`span` props to their root elements.
- `AvatarData` gains an opt-in `truncate` prop that clips overflowing
title and subtitle with an ellipsis. Off by default so existing
consumers passing non-text nodes (icons, badges) do not clip silently.
- Bundles the Vercel provider icon (`vercel.svg`) and registers it in
`icons.json` and `externalImages.ts`.

No new pages or routes here; later PRs in the stack consume these
primitives.

<details>
<summary>Stack</summary>

1. **jakehwll/DEVEX-355/01-primitives, primitives (this PR)**
2. jakehwll/DEVEX-355/02-api, API client and query layer
3. jakehwll/DEVEX-355/03-components, provider form components
4. jakehwll/DEVEX-355/04-pages, pages and routes
5. jakehwll/DEVEX-355/05-section, section reshuffle

Replaces #25328 once the stack lands.
</details>
This commit is contained in:
Jake Howell
2026-05-27 02:01:53 +10:00
committed by GitHub
parent c56af60d12
commit 8ae732000c
10 changed files with 269 additions and 22 deletions
@@ -20,3 +20,13 @@ export const WithImage: Story = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
},
};
export const WithLongTitle: Story = {
args: {
truncate: true,
title: "a-workspace-with-an-unreasonably-long-name-that-should-be-clipped",
subtitle:
"and-an-even-longer-organization-or-template-subtitle-that-truncates",
},
decorators: [(Story) => <div style={{ maxWidth: 240 }}>{Story()}</div>],
};
+24 -3
View File
@@ -1,5 +1,6 @@
import type { FC, ReactNode } from "react";
import { Avatar } from "#/components/Avatar/Avatar";
import { cn } from "#/utils/cn";
interface AvatarDataProps {
title: ReactNode;
@@ -15,6 +16,13 @@ interface AvatarDataProps {
* from the title prop if it is a string.
*/
imgFallbackText?: string;
/**
* When true, the title and subtitle clip with an ellipsis if they overflow
* the available width. Off by default because callers that pass non-text
* nodes (icons, badges) as `title` would otherwise clip silently.
*/
truncate?: boolean;
}
export const AvatarData: FC<AvatarDataProps> = ({
@@ -23,6 +31,7 @@ export const AvatarData: FC<AvatarDataProps> = ({
src,
imgFallbackText,
avatar,
truncate = false,
}) => {
if (!avatar) {
avatar = (
@@ -38,12 +47,24 @@ export const AvatarData: FC<AvatarDataProps> = ({
<div className="flex items-center gap-3">
{avatar}
<div className="flex flex-col">
<span className="text-sm font-semibold text-content-primary">
<div
className={cn("flex flex-col", truncate && "flex-1 overflow-hidden")}
>
<span
className={cn(
"text-sm font-semibold text-content-primary",
truncate && "truncate",
)}
>
{title}
</span>
{subtitle && (
<span className="text-content-secondary text-xs font-medium">
<span
className={cn(
"text-content-secondary text-xs font-medium",
truncate && "truncate",
)}
>
{subtitle}
</span>
)}
@@ -0,0 +1,160 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useFormik } from "formik";
import type { FC } from "react";
import { expect, within } from "storybook/test";
import { FormField } from "./FormField";
interface ExampleFormFieldProps {
id?: string;
label: string;
description?: string;
helperText?: string;
required?: boolean;
error?: string;
value?: string;
}
const ExampleFormField: FC<ExampleFormFieldProps> = ({
id,
label,
description,
helperText,
required,
error,
value = "",
}) => {
const form = useFormik({
initialValues: { value },
onSubmit: () => {},
});
return (
<FormField
id={id}
field={{
name: "value",
id: "value",
value: form.values.value,
onChange: form.handleChange,
onBlur: form.handleBlur,
error: Boolean(error),
helperText: error ?? helperText,
}}
label={label}
description={description}
required={required}
/>
);
};
const meta: Meta<typeof ExampleFormField> = {
title: "components/FormField",
component: ExampleFormField,
args: {
id: "story-field",
label: "Provider name",
},
};
export default meta;
type Story = StoryObj<typeof ExampleFormField>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(input).not.toHaveAttribute("aria-describedby");
await expect(input).not.toHaveAttribute("aria-invalid", "true");
await expect(canvas.queryByText("*")).not.toBeInTheDocument();
},
};
export const Required: Story = {
args: {
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("*")).toBeVisible();
},
};
export const WithDescription: Story = {
args: {
description: "Shown to users when selecting this provider.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(input).toHaveAttribute(
"aria-describedby",
"story-field-description",
);
const description = canvas.getByText(
"Shown to users when selecting this provider.",
);
await expect(description).toHaveAttribute("id", "story-field-description");
},
};
export const WithHelperText: Story = {
args: {
helperText: "Lowercase letters and dashes only.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(input).toHaveAttribute(
"aria-describedby",
"story-field-helper",
);
},
};
export const WithError: Story = {
args: {
error: "Provider name is required.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(input).toHaveAttribute(
"aria-describedby",
"story-field-error",
);
await expect(input).toHaveAttribute("aria-invalid", "true");
await expect(canvas.getByText("Provider name is required.")).toBeVisible();
},
};
export const WithDescriptionAndError: Story = {
args: {
description: "Shown to users when selecting this provider.",
error: "Provider name is required.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(input).toHaveAttribute(
"aria-describedby",
"story-field-description story-field-error",
);
await expect(input).toHaveAttribute("aria-invalid", "true");
},
};
export const RequiredWithDescription: Story = {
args: {
required: true,
description: "Shown to users when selecting this provider.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox", { name: /Provider name/ });
await expect(canvas.getByText("*")).toBeVisible();
await expect(input).toHaveAttribute(
"aria-describedby",
"story-field-description",
);
},
};
+27 -4
View File
@@ -7,11 +7,13 @@ import type { FormHelpers } from "#/utils/formUtils";
type FormFieldProps = React.ComponentPropsWithRef<"input"> & {
field: FormHelpers;
label: ReactNode;
description?: ReactNode;
};
export const FormField: FC<FormFieldProps> = ({
field,
label,
description,
className,
...inputProps
}) => {
@@ -19,10 +21,33 @@ export const FormField: FC<FormFieldProps> = ({
const id = inputProps.id ?? generatedId;
const errorId = `${id}-error`;
const helperId = `${id}-helper`;
const descriptionId = `${id}-description`;
const describedBy = [
description ? descriptionId : null,
field.error ? errorId : field.helperText ? helperId : null,
]
.filter(Boolean)
.join(" ");
const required = inputProps.required ?? false;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id}>
{label}
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
)}
<Input
name={field.name}
value={field.value}
@@ -31,9 +56,7 @@ export const FormField: FC<FormFieldProps> = ({
{...inputProps}
id={id}
aria-invalid={field.error}
aria-describedby={
field.error ? errorId : field.helperText ? helperId : undefined
}
aria-describedby={describedBy || undefined}
className={cn(field.error && "border-border-destructive", className)}
/>
{field.error ? (
+40 -10
View File
@@ -1,4 +1,5 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import type React from "react";
import type { FC, ReactNode } from "react";
import { cn } from "#/utils/cn";
interface PageHeaderProps {
@@ -31,32 +32,61 @@ export const PageHeader: FC<PageHeaderProps> = ({
);
};
export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
type PageHeaderTitleProps = React.ComponentPropsWithRef<"h1">;
export const PageHeaderTitle: FC<PageHeaderTitleProps> = ({
children,
className,
...props
}) => {
return (
<h1 className="text-3xl font-semibold m-0 flex items-center leading-snug">
<h1
className={cn(
"text-3xl font-semibold m-0 flex items-center leading-snug",
className,
)}
{...props}
>
{children}
</h1>
);
};
interface PageHeaderSubtitleProps {
children?: ReactNode;
condensed?: boolean;
}
type PageHeaderSubtitleProps = React.ComponentPropsWithRef<"h2">;
export const PageHeaderSubtitle: FC<PageHeaderSubtitleProps> = ({
children,
className,
...props
}) => {
return (
<h2 className="text-sm text-content-secondary font-normal block m-0 leading-snug">
<h2
className={cn(
"text-sm text-content-secondary font-normal block m-0 leading-snug",
className,
)}
{...props}
>
{children}
</h2>
);
};
export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
type PageHeaderCaptionProps = React.ComponentPropsWithRef<"span">;
export const PageHeaderCaption: FC<PageHeaderCaptionProps> = ({
children,
className,
...props
}) => {
return (
<span className="text-sm text-content-secondary font-medium uppercase tracking-widest">
<span
className={cn(
"text-sm text-content-secondary font-medium uppercase tracking-widest",
className,
)}
{...props}
>
{children}
</span>
);
@@ -61,7 +61,7 @@ export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
</div>
<div>
<PageHeaderTitle>{starterTemplate.name}</PageHeaderTitle>
<PageHeaderSubtitle condensed>
<PageHeaderSubtitle>
{starterTemplate.description}
</PageHeaderSubtitle>
</div>
@@ -267,16 +267,14 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
</div>
{template.deprecation_message !== "" ? (
<PageHeaderSubtitle condensed>
<PageHeaderSubtitle>
<MemoizedInlineMarkdown>
{template.deprecation_message}
</MemoizedInlineMarkdown>
</PageHeaderSubtitle>
) : (
template.description !== "" && (
<PageHeaderSubtitle condensed>
{template.description}
</PageHeaderSubtitle>
<PageHeaderSubtitle>{template.description}</PageHeaderSubtitle>
)
)}
</div>
+1
View File
@@ -175,6 +175,7 @@ export const defaultParametersForBuiltinIcons = new Map<string, string>([
["/icon/rust.svg", "monochrome"],
["/icon/tasks.svg", "monochrome"],
["/icon/terminal.svg", "monochrome"],
["/icon/vercel.svg", "whiteWithColor"],
["/icon/widgets.svg", "monochrome"],
["/icon/windsurf.svg", "monochrome"],
["/icon/zed.svg", "monochrome"],
+1
View File
@@ -139,6 +139,7 @@
"typescript.svg",
"ubuntu.svg",
"vault.svg",
"vercel.svg",
"vsphere.svg",
"webstorm.svg",
"widgets.svg",
+3
View File
@@ -0,0 +1,3 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M128 24L240 224H16L128 24Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 159 B