From 7137c3cd52e483e9fda923de56bab442e92ae1c7 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 31 Jul 2026 00:11:10 +0000 Subject: [PATCH 1/6] refactor(site): demui `` --- .../IconField/IconField.stories.tsx | 31 ++- site/src/components/IconField/IconField.tsx | 198 ++++++++++++------ .../components/IconPickerField.tsx | 92 +------- 3 files changed, 171 insertions(+), 150 deletions(-) diff --git a/site/src/components/IconField/IconField.stories.tsx b/site/src/components/IconField/IconField.stories.tsx index 698c1d6d356..f9412f25968 100644 --- a/site/src/components/IconField/IconField.stories.tsx +++ b/site/src/components/IconField/IconField.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { IconField } from "./IconField"; const meta: Meta = { title: "components/IconField", component: IconField, args: { - onPickEmoji: action("onPickEmoji"), + onPickEmoji: fn(), + onChange: fn(), }, }; @@ -26,3 +27,29 @@ export const IconSelected: Story = { value: "/icon/fedora.svg", }, }; + +export const WithHelperText: Story = { + args: { + helperText: "Paste an image URL or pick an emoji.", + }, +}; + +export const WithError: Story = { + args: { + error: true, + helperText: "Icon URL is too long.", + value: "https://example.com/very-long-icon-url.png", + }, +}; + +export const OpenPicker: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { + name: "Pick an emoji or icon", + }); + await userEvent.click(button); + await expect(button).toHaveAttribute("aria-expanded", "true"); + await expect(await screen.findByText("Smileys & People")).toBeVisible(); + }, +}; diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx index be48b8449ba..0e26022bc45 100644 --- a/site/src/components/IconField/IconField.tsx +++ b/site/src/components/IconField/IconField.tsx @@ -1,95 +1,159 @@ -import { css, Global, useTheme } from "@emotion/react"; -import InputAdornment from "@mui/material/InputAdornment"; -import TextField, { type TextFieldProps } from "@mui/material/TextField"; -import { type FC, lazy, Suspense, useState } from "react"; -import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; +import { + type ChangeEventHandler, + type FC, + type FocusEventHandler, + lazy, + type ReactNode, + Suspense, + useId, + useState, +} from "react"; +import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { Label } from "#/components/Label/Label"; import { Loader } from "#/components/Loader/Loader"; import { Popover, PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { cn } from "#/utils/cn"; + +const EmojiPicker = lazy(() => import("./EmojiPicker")); -type IconFieldProps = TextFieldProps & { +type IconFieldProps = { + id?: string; + name?: string; + value?: string | number; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + label?: ReactNode; + error?: boolean; + helperText?: ReactNode; + disabled?: boolean; + placeholder?: string; onPickEmoji: (value: string) => void; + /** Accepted for call-site compatibility with former MUI TextField usage. */ + fullWidth?: boolean; }; -const EmojiPicker = lazy(() => import("./EmojiPicker")); - export const IconField: FC = ({ + id: idProp, + name, + value, + onChange, + onBlur, + label = "Icon", + error, + helperText, + disabled, + placeholder, onPickEmoji, - ...textFieldProps + fullWidth: _fullWidth, }) => { - if ( - typeof textFieldProps.value !== "string" && - typeof textFieldProps.value !== "undefined" - ) { - throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`); + if (typeof value !== "string" && typeof value !== "undefined") { + throw new Error(`Invalid icon value "${typeof value}"`); } - const theme = useTheme(); - const hasIcon = textFieldProps.value && textFieldProps.value !== ""; + const generatedId = useId(); + const id = idProp ?? generatedId; + const errorId = `${id}-error`; + const helperId = `${id}-helper`; const [open, setOpen] = useState(false); + const stringValue = value ?? ""; + const hasIcon = stringValue !== ""; return ( -
- +
+ {label ? ( + + ) : null} + + + + {hasIcon && ( + { - e.currentTarget.style.display = "none"; + src={stringValue} + onError={(event) => { + event.currentTarget.style.display = "none"; }} - onLoad={(e) => { - e.currentTarget.style.display = "inline"; + onLoad={(event) => { + event.currentTarget.style.display = "inline"; }} /> - - ) : undefined, - }} - /> - - - - - - - - }> - { - const value = emoji.src ?? `/emojis/${emoji.unified}.png`; - onPickEmoji(value); - setOpen(false); - }} - /> - - - + + )} + + + + + event.stopPropagation()} + > + }> + { + const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; + onPickEmoji(picked); + setOpen(false); + }} + /> + + + + + + {helperText ? ( + + {helperText} + + ) : null} {/* - This component takes a long time to load (easily several seconds), so we diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx index 8f50854f2c0..b961d3712c2 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx @@ -1,20 +1,5 @@ -import { type FC, lazy, Suspense, useState } from "react"; -import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; -import { Button } from "#/components/Button/Button"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "#/components/InputGroup/InputGroup"; -import { Loader } from "#/components/Loader/Loader"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; - -const EmojiPicker = lazy(() => import("#/components/IconField/EmojiPicker")); +import type { FC } from "react"; +import { IconField } from "#/components/IconField/IconField"; interface IconPickerFieldProps { id?: string; @@ -31,70 +16,15 @@ export const IconPickerField: FC = ({ disabled, onChange, }) => { - const [open, setOpen] = useState(false); - const hasIcon = value !== ""; - return ( - - onChange(event.target.value)} - placeholder={placeholder} - disabled={disabled} - className="min-w-0 placeholder:text-content-disabled" - spellCheck={false} - /> - - {hasIcon && ( - - { - event.currentTarget.style.display = "none"; - }} - onLoad={(event) => { - event.currentTarget.style.display = "inline"; - }} - /> - - )} - - - - - event.stopPropagation()} - > - }> - { - const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; - onChange(picked); - setOpen(false); - }} - /> - - - - - + onChange(event.target.value)} + onPickEmoji={onChange} + /> ); }; From 273607676bdfcc88d9ff06fc1649e76a5c7e0119 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 31 Jul 2026 00:16:21 +0000 Subject: [PATCH 2/6] fix: component props --- site/src/components/IconField/IconField.tsx | 25 +++++---------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx index 0e26022bc45..ffb43703915 100644 --- a/site/src/components/IconField/IconField.tsx +++ b/site/src/components/IconField/IconField.tsx @@ -1,7 +1,6 @@ import { - type ChangeEventHandler, + type ComponentPropsWithRef, type FC, - type FocusEventHandler, lazy, type ReactNode, Suspense, @@ -27,17 +26,10 @@ import { cn } from "#/utils/cn"; const EmojiPicker = lazy(() => import("./EmojiPicker")); -type IconFieldProps = { - id?: string; - name?: string; - value?: string | number; - onChange?: ChangeEventHandler; - onBlur?: FocusEventHandler; +type IconFieldProps = Omit, "type"> & { label?: ReactNode; error?: boolean; helperText?: ReactNode; - disabled?: boolean; - placeholder?: string; onPickEmoji: (value: string) => void; /** Accepted for call-site compatibility with former MUI TextField usage. */ fullWidth?: boolean; @@ -45,17 +37,15 @@ type IconFieldProps = { export const IconField: FC = ({ id: idProp, - name, value, - onChange, - onBlur, label = "Icon", error, helperText, disabled, - placeholder, + className, onPickEmoji, fullWidth: _fullWidth, + ...inputProps }) => { if (typeof value !== "string" && typeof value !== "undefined") { throw new Error(`Invalid icon value "${typeof value}"`); @@ -78,18 +68,15 @@ export const IconField: FC = ({ ) : null} From 9cb5dfd9d20edeed9676c61e8e00c7b78bd62949 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 31 Jul 2026 00:54:57 +0000 Subject: [PATCH 3/6] refactor(site): redesign `` --- .../AnnouncementBannerDialog.tsx | 235 ++++++++++-------- .../AnnouncementBannerItem.tsx | 16 +- .../AnnouncementBannerSettings.tsx | 187 +++++++------- .../AppearanceSettingsPageView.tsx | 186 +++++++------- 4 files changed, 340 insertions(+), 284 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx index 0b86ab3a28b..f6fccfd7b0d 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx @@ -1,13 +1,16 @@ import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import DialogActions from "@mui/material/DialogActions"; -import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; import { type FC, useState } from "react"; import { SliderPicker, TwitterPicker } from "react-color"; +import { createPortal } from "react-dom"; import type { BannerConfig } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Dialog, DialogActionButtons } from "#/components/Dialogs/Dialog"; +import { Label } from "#/components/Label/Label"; +import { Textarea } from "#/components/Textarea/Textarea"; import { AnnouncementBannerView } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBannerView"; +import { cn } from "#/utils/cn"; import { getFormHelpers } from "#/utils/formUtils"; interface AnnouncementBannerDialogProps { @@ -22,6 +25,7 @@ export const AnnouncementBannerDialog: FC = ({ onUpdate, }) => { const theme = useTheme(); + const isCreating = !banner.message; const bannerForm = useFormik<{ message: string; @@ -33,115 +37,152 @@ export const AnnouncementBannerDialog: FC = ({ }, onSubmit: (banner) => onUpdate(banner), }); - const bannerFieldHelpers = getFormHelpers(bannerForm); + const getFieldHelpers = getFormHelpers(bannerForm); + const messageField = getFieldHelpers("message", { + helperText: "Markdown bold, italics, and links are supported.", + }); + const messageHelperId = `${messageField.id}-helper`; + const messageErrorId = `${messageField.id}-error`; const [showHuePicker, setShowHuePicker] = useState(false); + const previewMessage = bannerForm.values.message.trim(); return ( - - {/* Banner preview */} -
- -
+ <> + {/* Portal so fixed positioning escapes the dialog transform containing block. */} + {createPortal( +
+ +
, + document.body, + )} -
-

Announcement banner

-
-
-

Message

- -
-
-

Background color

-
- {showHuePicker ? ( - { - await bannerForm.setFieldValue( - "background_color", - color.hex, - ); - }} - /> + +
+

Announcement banner

+
+
+ +