From 4fe64213c30f136bf8cc9b3cb9596036ae1c7fdb Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 3 Feb 2026 23:04:02 +1100 Subject: [PATCH] feat: refactor `` with alignment to design (#21780) This pull-request refactors filter-related dropdown and input components from MUI to our Tailwind-based design system. This is more inline with the Figma design, controversially we are changing the button group for canned filters and input to two seperate components. - **InputGroup**: Complete rewrite to a compound component pattern (`InputGroup`, `InputGroupAddon`, `InputGroupInput`, `InputGroupButton`) using Tailwind and CVA, replacing the old CSS-in-JS approach - **SearchField**: Migrated from MUI TextField to use the new InputGroup components, with a simplified API and proper ref forwarding - **Filter/PresetMenu**: Replaced MUI Menu with our DropdownMenu component, and updated icon to `SlidersHorizontal` ### Changes | Component | Before | After | |-----------|--------|-------| | InputGroup | CSS-in-JS with MUI margin hacks | Compound component with Tailwind group states | | SearchField | MUI TextField + InputAdornment | InputGroup + InputGroupAddon composition | | PresetMenu | MUI Menu/MenuItem | DropdownMenu/DropdownMenuItem | | MenuSearch | Complex CSS overrides | Single Tailwind class | --- site/src/components/Filter/Filter.tsx | 156 +++++++----------- .../Filter/SelectFilter.stories.tsx | 2 +- site/src/components/Filter/SelectFilter.tsx | 17 +- site/src/components/Filter/UserFilter.tsx | 2 +- site/src/components/Input/Input.tsx | 3 +- .../InputGroup/InputGroup.stories.tsx | 80 +++++---- site/src/components/InputGroup/InputGroup.tsx | 146 ++++++++++------ site/src/components/Menu/MenuSearch.tsx | 16 +- .../components/SearchField/SearchField.tsx | 141 +++++++++------- .../SelectMenu/SelectMenu.stories.tsx | 6 +- site/src/components/SelectMenu/SelectMenu.tsx | 14 +- site/src/modules/tableFiltering/options.tsx | 2 +- .../WorkspaceTiming/Chart/Chart.tsx | 21 +-- .../src/pages/WorkspacesPage/filter/menus.tsx | 2 +- 14 files changed, 333 insertions(+), 275 deletions(-) diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index 1ee162accc..972daa3fab 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -1,7 +1,4 @@ import { useTheme } from "@emotion/react"; -import Divider from "@mui/material/Divider"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; import Skeleton, { type SkeletonProps } from "@mui/material/Skeleton"; import type { Breakpoint } from "@mui/system/createTheme"; import { @@ -10,10 +7,16 @@ import { isApiValidationError, } from "api/errors"; import { Button } from "components/Button/Button"; -import { InputGroup } from "components/InputGroup/InputGroup"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { SearchField } from "components/SearchField/SearchField"; import { useDebouncedFunction } from "hooks/debounce"; -import { ChevronDownIcon, ExternalLinkIcon } from "lucide-react"; +import { ExternalLinkIcon, SlidersHorizontal } from "lucide-react"; import { type FC, type ReactNode, useEffect, useRef, useState } from "react"; type PresetFilter = { @@ -219,39 +222,41 @@ export const Filter: FC = ({ ) : ( <> - - filter.update(query)} - presets={presets} - learnMoreLink={learnMoreLink} - learnMoreLabel2={learnMoreLabel2} - learnMoreLink2={learnMoreLink2} - /> + filter.update(query)} + presets={presets} + learnMoreLink={learnMoreLink} + learnMoreLabel2={learnMoreLabel2} + learnMoreLink2={learnMoreLink2} + /> +
{ setQueryCopy(query); filter.debounceUpdate(query); }} - InputProps={{ - ref: textboxInputRef, - "aria-label": "Filter", - onBlur: () => { - if (queryCopy !== filter.query) { - setQueryCopy(filter.query); - } - }, + onClear={() => { + setQueryCopy(""); + filter.cancelDebounce(); + filter.update(""); }} + onBlur={() => { + if (queryCopy === filter.query) return; + setQueryCopy(filter.query); + }} + placeholder="Search..." /> - + {hasError(error) && ( + + {getValidationErrorMessage(error)} + + )} +
{options} )} @@ -274,80 +279,41 @@ const PresetMenu: FC = ({ learnMoreLink2, onSelect, }) => { - const [isOpen, setIsOpen] = useState(false); - const anchorRef = useRef(null); - const theme = useTheme(); - return ( - <> - - setIsOpen(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "left", - }} - transformOrigin={{ - vertical: "top", - horizontal: "left", - }} - css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }} - > + + + + + {presets.map((presetFilter) => ( - onSelect(presetFilter.query)} key={presetFilter.name} - onClick={() => { - onSelect(presetFilter.query); - setIsOpen(false); - }} > {presetFilter.name} - + ))} + {(learnMoreLink || learnMoreLink2) && } {learnMoreLink && ( - - )} - {learnMoreLink && ( - { - setIsOpen(false); - }} - > - - View advanced filtering - + + + + View advanced filtering + + )} {learnMoreLink2 && learnMoreLabel2 && ( - { - setIsOpen(false); - }} - > - - {learnMoreLabel2} - + + + + {learnMoreLabel2} + + )} - - + + ); }; diff --git a/site/src/components/Filter/SelectFilter.stories.tsx b/site/src/components/Filter/SelectFilter.stories.tsx index cdc68a2f61..e0c70be3b1 100644 --- a/site/src/components/Filter/SelectFilter.stories.tsx +++ b/site/src/components/Filter/SelectFilter.stories.tsx @@ -130,7 +130,7 @@ export const SearchingOption: Story = { value={search} onChange={setSearch} placeholder="Search options..." - inputProps={{ "aria-label": "Search options" }} + aria-label="Search options" /> } /> diff --git a/site/src/components/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx index 786698e230..09453cacdd 100644 --- a/site/src/components/Filter/SelectFilter.tsx +++ b/site/src/components/Filter/SelectFilter.tsx @@ -1,4 +1,5 @@ import { Loader } from "components/Loader/Loader"; +import type { SearchFieldProps } from "components/SearchField/SearchField"; import { SelectMenu, SelectMenuButton, @@ -116,4 +117,18 @@ export const SelectFilter: FC = ({ ); }; -export const SelectFilterSearch = SelectMenuSearch; +export const SelectFilterSearch = ({ + className, + ...props +}: SearchFieldProps) => { + return ( + + ); +}; diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 5f0e680434..2f08ba0ae0 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -99,10 +99,10 @@ export const UserMenu: FC = ({ menu, width, placeholder }) => { selectedOption={menu.selectedOption ?? undefined} selectFilterSearch={ } width={width} diff --git a/site/src/components/Input/Input.tsx b/site/src/components/Input/Input.tsx index 9f3896a1f4..accdf2c6f3 100644 --- a/site/src/components/Input/Input.tsx +++ b/site/src/components/Input/Input.tsx @@ -18,7 +18,8 @@ export const Input = forwardRef< file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary placeholder:text-content-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link - disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit`, + disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit + `, className, )} ref={ref} diff --git a/site/src/components/InputGroup/InputGroup.stories.tsx b/site/src/components/InputGroup/InputGroup.stories.tsx index 728970af93..d2dc9280e6 100644 --- a/site/src/components/InputGroup/InputGroup.stories.tsx +++ b/site/src/components/InputGroup/InputGroup.stories.tsx @@ -1,7 +1,6 @@ -import TextField from "@mui/material/TextField"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Button } from "components/Button/Button"; -import { InputGroup } from "./InputGroup"; +import { SearchIcon } from "lucide-react"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "./InputGroup"; const meta: Meta = { title: "components/InputGroup", @@ -11,70 +10,87 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = { +export const WithIconStart: Story = { args: { children: ( <> - - + + + + ), }, }; -export const FocusedTextField: Story = { +export const WithIconEnd: Story = { args: { children: ( <> - - + + + + ), }, }; -export const ErroredTextField: Story = { +export const WithTextStart: Story = { args: { children: ( <> - - + https:// + ), }, }; -export const FocusedErroredTextField: Story = { +export const WithTextEnd: Story = { args: { children: ( <> - - + + @coder.com ), }, }; -export const WithThreeElements: Story = { +export const WithBothAddons: Story = { args: { children: ( <> - - - + $ + + USD + + ), + }, +}; + +export const Disabled: Story = { + args: { + children: ( + <> + + + + + + ), + }, +}; + +export const Invalid: Story = { + args: { + children: ( + <> + + + + ), }, diff --git a/site/src/components/InputGroup/InputGroup.tsx b/site/src/components/InputGroup/InputGroup.tsx index d2979a860b..fcaafa13e6 100644 --- a/site/src/components/InputGroup/InputGroup.tsx +++ b/site/src/components/InputGroup/InputGroup.tsx @@ -1,52 +1,106 @@ -import type { FC, HTMLProps } from "react"; -export const InputGroup: FC> = (props) => { +import { cva, type VariantProps } from "class-variance-authority"; +import { Button, type ButtonProps } from "components/Button/Button"; +import { Input } from "components/Input/Input"; +import { type FC, forwardRef } from "react"; +import { cn } from "utils/cn"; + +const InputGroup: FC> = ({ + className, + ...props +}) => { return (
*:not(:last-child)": { - marginRight: -1, - }, - - // Ensure the border of the hovered element is visible when borders - // overlap. - "& > *:hover": { - zIndex: 1, - }, - - // Display border elements when focused or in an error state, both of - // which take priority over hover. - "& .Mui-focused, & .Mui-error": { - zIndex: 2, - }, - - "& > *:first-of-type": { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - }, - - "& > *:last-child": { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - - "&.MuiFormControl-root .MuiInputBase-root": { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - }, - }, - - "& > *:not(:first-of-type):not(:last-child)": { - borderRadius: 0, - - "&.MuiFormControl-root .MuiInputBase-root": { - borderRadius: 0, - }, - }, - }} /> ); }; + +const inputGroupAddonVariants = cva( + "text-content-secondary h-auto gap-2 text-sm font-medium flex cursor-text items-center justify-center select-none group-has-[input:disabled]/input-group:opacity-50 [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": "pl-3 pr-2 order-first", + "inline-end": "pl-1 pr-1.5 order-last", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +const InputGroupAddon: FC< + React.ComponentProps<"div"> & VariantProps +> = ({ className, align = "inline-start", ...props }) => { + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Click focuses the input, keyboard users can tab directly to input. +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement + ?.querySelector("input") + ?.focus(); + }} + {...props} + /> + ); +}; + +const InputGroupInput = forwardRef< + HTMLInputElement, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +const InputGroupButton: FC = ({ + className, + size = "sm", + variant = "subtle", + ...props +}) => { + return ( +