From 8ae732000cdfc89df0245d63bc9320be04104518 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:01:53 +1000 Subject: [PATCH] feat(site): add UI primitives for the AI settings stack (#25579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 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.
Stack 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.
--- .../components/Avatar/AvatarData.stories.tsx | 10 ++ site/src/components/Avatar/AvatarData.tsx | 27 ++- .../FormField/FormField.stories.tsx | 160 ++++++++++++++++++ site/src/components/FormField/FormField.tsx | 31 +++- site/src/components/PageHeader/PageHeader.tsx | 50 ++++-- .../StarterTemplatePageView.tsx | 2 +- .../pages/TemplatePage/TemplatePageHeader.tsx | 6 +- site/src/theme/externalImages.ts | 1 + site/src/theme/icons.json | 1 + site/static/icon/vercel.svg | 3 + 10 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 site/src/components/FormField/FormField.stories.tsx create mode 100644 site/static/icon/vercel.svg diff --git a/site/src/components/Avatar/AvatarData.stories.tsx b/site/src/components/Avatar/AvatarData.stories.tsx index 22f8cb45d7..62185254c4 100644 --- a/site/src/components/Avatar/AvatarData.stories.tsx +++ b/site/src/components/Avatar/AvatarData.stories.tsx @@ -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) =>
{Story()}
], +}; diff --git a/site/src/components/Avatar/AvatarData.tsx b/site/src/components/Avatar/AvatarData.tsx index 7e2515c934..698e7df608 100644 --- a/site/src/components/Avatar/AvatarData.tsx +++ b/site/src/components/Avatar/AvatarData.tsx @@ -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 = ({ @@ -23,6 +31,7 @@ export const AvatarData: FC = ({ src, imgFallbackText, avatar, + truncate = false, }) => { if (!avatar) { avatar = ( @@ -38,12 +47,24 @@ export const AvatarData: FC = ({
{avatar} -
- +
+ {title} {subtitle && ( - + {subtitle} )} diff --git a/site/src/components/FormField/FormField.stories.tsx b/site/src/components/FormField/FormField.stories.tsx new file mode 100644 index 0000000000..1fee8410fc --- /dev/null +++ b/site/src/components/FormField/FormField.stories.tsx @@ -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 = ({ + id, + label, + description, + helperText, + required, + error, + value = "", +}) => { + const form = useFormik({ + initialValues: { value }, + onSubmit: () => {}, + }); + + return ( + + ); +}; + +const meta: Meta = { + title: "components/FormField", + component: ExampleFormField, + args: { + id: "story-field", + label: "Provider name", + }, +}; + +export default meta; +type Story = StoryObj; + +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", + ); + }, +}; diff --git a/site/src/components/FormField/FormField.tsx b/site/src/components/FormField/FormField.tsx index 0f7ba8df16..e87eb637d6 100644 --- a/site/src/components/FormField/FormField.tsx +++ b/site/src/components/FormField/FormField.tsx @@ -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 = ({ field, label, + description, className, ...inputProps }) => { @@ -19,10 +21,33 @@ export const FormField: FC = ({ 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 (
- + + {description && ( +
+ {description} +
+ )} = ({ {...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 ? ( diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index 0e7852889e..215b88b800 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -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 = ({ ); }; -export const PageHeaderTitle: FC = ({ children }) => { +type PageHeaderTitleProps = React.ComponentPropsWithRef<"h1">; + +export const PageHeaderTitle: FC = ({ + children, + className, + ...props +}) => { return ( -

+

{children}

); }; -interface PageHeaderSubtitleProps { - children?: ReactNode; - condensed?: boolean; -} +type PageHeaderSubtitleProps = React.ComponentPropsWithRef<"h2">; export const PageHeaderSubtitle: FC = ({ children, + className, + ...props }) => { return ( -

+

{children}

); }; -export const PageHeaderCaption: FC = ({ children }) => { +type PageHeaderCaptionProps = React.ComponentPropsWithRef<"span">; + +export const PageHeaderCaption: FC = ({ + children, + className, + ...props +}) => { return ( - + {children} ); diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index 76e6462aea..f1a7de8f72 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -61,7 +61,7 @@ export const StarterTemplatePageView: FC = ({
{starterTemplate.name} - + {starterTemplate.description}
diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index f894adc6d2..05aa9d87e6 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -267,16 +267,14 @@ export const TemplatePageHeader: FC = ({
{template.deprecation_message !== "" ? ( - + {template.deprecation_message} ) : ( template.description !== "" && ( - - {template.description} - + {template.description} ) )}
diff --git a/site/src/theme/externalImages.ts b/site/src/theme/externalImages.ts index 3c60a36bf6..ca2efcd2f6 100644 --- a/site/src/theme/externalImages.ts +++ b/site/src/theme/externalImages.ts @@ -175,6 +175,7 @@ export const defaultParametersForBuiltinIcons = new Map([ ["/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"], diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index 32df0d6119..ec3b21da7d 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -139,6 +139,7 @@ "typescript.svg", "ubuntu.svg", "vault.svg", + "vercel.svg", "vsphere.svg", "webstorm.svg", "widgets.svg", diff --git a/site/static/icon/vercel.svg b/site/static/icon/vercel.svg new file mode 100644 index 0000000000..98ab5dcfe3 --- /dev/null +++ b/site/static/icon/vercel.svg @@ -0,0 +1,3 @@ + + +