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 (
-
{label}
+
+ {label}
+ {required && (
+ <>
+ {" "}
+
+ *
+
+ >
+ )}
+
+ {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 @@
+
+
+