mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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>],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"typescript.svg",
|
||||
"ubuntu.svg",
|
||||
"vault.svg",
|
||||
"vercel.svg",
|
||||
"vsphere.svg",
|
||||
"webstorm.svg",
|
||||
"widgets.svg",
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user