refactor(site): demui <IconField /> (#27719)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Replace MUI `TextField` / Emotion theming in `IconField` with
`InputGroup`, `Popover`, and Tailwind.

`IconPickerField` already had the demui'd `InputGroup` + `Popover`
implementation, so that was ported into the shared `IconField`. Per
review feedback, `IconPickerField` is now removed and its call sites
(the MCP server, provider, and OAuth app forms) use `IconField`
directly.
This commit is contained in:
Jake Howell
2026-08-07 09:56:05 +10:00
committed by GitHub
parent 50640063a2
commit 7a9b4250aa
6 changed files with 174 additions and 183 deletions
@@ -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<typeof IconField> = {
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();
},
};
+120 -68
View File
@@ -1,95 +1,147 @@
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 ComponentPropsWithRef,
type FC,
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";
type IconFieldProps = TextFieldProps & {
onPickEmoji: (value: string) => void;
};
import { cn } from "#/utils/cn";
const EmojiPicker = lazy(() => import("./EmojiPicker"));
type IconFieldProps = Omit<ComponentPropsWithRef<"input">, "type"> & {
label?: ReactNode;
error?: boolean;
helperText?: ReactNode;
onPickEmoji: (value: string) => void;
/** Accepted for call-site compatibility with former MUI TextField usage. */
fullWidth?: boolean;
};
export const IconField: FC<IconFieldProps> = ({
id: idProp,
value,
label = "Icon",
error,
helperText,
disabled,
className,
onPickEmoji,
...textFieldProps
fullWidth: _fullWidth,
...inputProps
}) => {
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 (
<div className="flex items-center gap-2">
<TextField
fullWidth
label="Icon"
{...textFieldProps}
InputProps={{
endAdornment: hasIcon ? (
<InputAdornment
position="end"
className="size-6 flex items-center justify-center [&_img]:max-w-full [&_img]:object-contain"
>
<div className="flex w-full flex-col gap-2">
{label ? (
<Label htmlFor={id} className="text-sm">
{label}
</Label>
) : null}
<InputGroup>
<InputGroupInput
{...inputProps}
id={id}
value={stringValue}
disabled={disabled}
aria-invalid={error}
aria-describedby={
helperText ? (error ? errorId : helperId) : undefined
}
className={cn("min-w-0 placeholder:text-content-disabled", className)}
spellCheck={false}
/>
<InputGroupAddon align="inline-end" className="gap-1.5">
{hasIcon && (
<span className="flex size-5 items-center justify-center">
<ExternalImage
alt=""
src={textFieldProps.value}
// This prevent browser to display the ugly error icon if the
// image path is wrong or user didn't finish typing the url
onError={(e) => {
e.currentTarget.style.display = "none";
src={stringValue}
className="max-w-full object-contain"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
onLoad={(e) => {
e.currentTarget.style.display = "inline";
onLoad={(event) => {
event.currentTarget.style.display = "inline";
}}
/>
</InputAdornment>
) : undefined,
}}
/>
<Global
styles={css`
em-emoji-picker {
--rgb-background: ${theme.palette.background.paper};
--rgb-input: ${theme.palette.primary.main};
--rgb-color: ${theme.palette.text.primary};
}
`}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="lg" className="group flex-shrink-0">
Emoji
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent id="emoji" side="bottom" align="end" className="w-min">
<Suspense fallback={<Loader />}>
<EmojiPicker
onEmojiSelect={(emoji) => {
const value = emoji.src ?? `/emojis/${emoji.unified}.png`;
onPickEmoji(value);
setOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="subtle"
size="sm"
className="group h-7 gap-1"
disabled={disabled}
aria-label="Pick an emoji or icon"
>
Emoji
<AnimatedChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="w-min"
// The popover is portaled in the DOM but still a React child of
// InputGroupAddon, whose click handler focuses the text input.
// Stop clicks here so the emoji picker keeps focus.
onClick={(event) => event.stopPropagation()}
>
<Suspense fallback={<Loader />}>
<EmojiPicker
onEmojiSelect={(emoji) => {
const picked = emoji.src ?? `/emojis/${emoji.unified}.png`;
onPickEmoji(picked);
setOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
{helperText ? (
<span
id={error ? errorId : helperId}
className={cn(
"text-xs",
error ? "text-content-destructive" : "text-content-secondary",
)}
>
{helperText}
</span>
) : null}
{/*
- This component takes a long time to load (easily several seconds), so we
@@ -1,100 +0,0 @@
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"));
interface IconPickerFieldProps {
id?: string;
value: string;
placeholder?: string;
disabled?: boolean;
onChange: (value: string) => void;
}
export const IconPickerField: FC<IconPickerFieldProps> = ({
id,
value,
placeholder,
disabled,
onChange,
}) => {
const [open, setOpen] = useState(false);
const hasIcon = value !== "";
return (
<InputGroup>
<InputGroupInput
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="min-w-0 placeholder:text-content-disabled"
spellCheck={false}
/>
<InputGroupAddon align="inline-end" className="gap-1.5">
{hasIcon && (
<span className="flex size-5 items-center justify-center [&_img]:max-w-full [&_img]:object-contain">
<ExternalImage
alt=""
src={value}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
onLoad={(event) => {
event.currentTarget.style.display = "inline";
}}
/>
</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="subtle"
size="sm"
className="group h-7 gap-1"
disabled={disabled}
aria-label="Pick an emoji or icon"
>
Emoji
<AnimatedChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="w-min"
// The popover is portaled in the DOM but still a React child of
// InputGroupAddon, whose click handler focuses the text input.
// Stop clicks here so the emoji picker keeps focus.
onClick={(event) => event.stopPropagation()}
>
<Suspense fallback={<Loader />}>
<EmojiPicker
onEmojiSelect={(emoji) => {
const picked = emoji.src ?? `/emojis/${emoji.unified}.png`;
onChange(picked);
setOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
);
};
@@ -1,6 +1,7 @@
import type { FormikContextType } from "formik";
import { type FC, useId } from "react";
import { Button } from "#/components/Button/Button";
import { IconField } from "#/components/IconField/IconField";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { IconPickerField } from "./IconPickerField";
import { MCPServerAuthSection } from "./MCPServerAuthSection";
import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection";
import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives";
@@ -150,11 +150,17 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
/>
</Field>
<Field label="Icon" htmlFor={`${formId}-icon`}>
<IconPickerField
<IconField
id={`${formId}-icon`}
value={form.values.iconURL}
placeholder="file location"
onChange={(value) => void form.setFieldValue("iconURL", value)}
label={null}
onChange={(event) =>
void form.setFieldValue("iconURL", event.target.value)
}
onPickEmoji={(value) =>
void form.setFieldValue("iconURL", value)
}
disabled={isDisabled}
/>
</Field>
@@ -13,6 +13,7 @@ import { CodeExample } from "#/components/CodeExample/CodeExample";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { Form, FormFields } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { IconField } from "#/components/IconField/IconField";
import { Label } from "#/components/Label/Label";
import { Link as DocsLink } from "#/components/Link/Link";
import {
@@ -24,7 +25,6 @@ import {
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils";
import { CredentialField } from "./CredentialField";
@@ -394,10 +394,12 @@ export const ProviderForm: FC<ProviderFormProps> = ({
<div className="text-xs text-content-secondary">
Optional. URL or emoji shown for this provider.
</div>
<IconPickerField
<IconField
id="icon"
value={form.values.icon}
onChange={handleIconChange}
label={null}
onChange={(event) => handleIconChange(event.target.value)}
onPickEmoji={handleIconChange}
/>
</div>
);
@@ -9,10 +9,10 @@ import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { Form, FormFields } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { IconField } from "#/components/IconField/IconField";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField";
import {
getFormHelpers,
iconValidator,
@@ -109,6 +109,12 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
form.dirty && !form.isSubmitting,
);
const handleIconChange = (value: string) => {
void form.setFieldValue("icon", value);
void form.setFieldTouched("icon", true);
onIconChange?.(value);
};
return (
<Form onSubmit={form.handleSubmit}>
<FormFields>
@@ -134,15 +140,13 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
<div className="text-xs text-content-secondary">
Optional. URL or emoji shown for this application.
</div>
<IconPickerField
<IconField
id="icon"
value={form.values.icon}
disabled={formDisabled}
onChange={(value) => {
void form.setFieldValue("icon", value);
void form.setFieldTouched("icon", true);
onIconChange?.(value);
}}
label={null}
onChange={(event) => handleIconChange(event.target.value)}
onPickEmoji={handleIconChange}
/>
{iconField.error ? (
<span className="text-xs text-content-destructive">