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 @@ + + +