mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user