# Autocomplete Adapters

> Using the slotsmith Autocomplete with shadcn/ui, MUI, Chakra, Ant Design and Radix Themes.

## shadcn/ui

This adapter renders `Autocomplete` with shadcn/ui parts. Copy the file into your app and pass the map to `components`; every slot you leave out keeps its fallback.

```tsx
/**
 * Autocomplete for shadcn/ui
 *
 * A `components` map that renders the slotsmith autocomplete with shadcn/ui
 * primitives, written for shadcn/ui on Tailwind CSS v4. Copy the file, keep
 * the parts you want, and pass the map as `components={shadcnComponents}`;
 * every slot left out keeps its fallback.
 *
 * The `@/components/ui/*` and `@/lib/utils` imports are the app's own
 * shadcn/ui files.
 */
import { useMemo, type RefObject } from "react";
import type {
  AutocompleteCheckSlotProps,
  AutocompleteClearSlotProps,
  AutocompleteComponents,
  AutocompleteCreateSlotProps,
  AutocompleteEmptySlotProps,
  AutocompleteErrorSlotProps,
  AutocompleteGroupLabelSlotProps,
  AutocompleteGroupSlotProps,
  AutocompleteIndicatorSlotProps,
  AutocompleteListSlotProps,
  AutocompleteLoadMoreSlotProps,
  AutocompleteLoadingSlotProps,
  AutocompleteOptionLabelSlotProps,
  AutocompleteOptionSlotProps,
  AutocompletePopupSlotProps,
  AutocompleteRootSlotProps,
  AutocompleteSearchSlotProps,
  AutocompleteSeparatorSlotProps,
  AutocompleteTagSlotProps,
  AutocompleteToggleSlotProps,
  AutocompleteTriggerInputSlotProps,
  AutocompleteTriggerSlotProps,
  AutocompleteValueSlotProps,
} from "slotsmith/autocomplete";
import { useAutocompleteContext } from "slotsmith/autocomplete";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

/** Stands in for `lucide-react`'s ChevronDown icon. */
const ChevronDown = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4" aria-hidden="true">
    <path d="m6 9 6 6 6-6" />
  </svg>
);

/** Stands in for `lucide-react`'s Check icon. */
const Check = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-3.5" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
);

/** Stands in for `lucide-react`'s X icon: 12px in a badge, 16px like the chevron where it clears. */
const X = ({ className = "size-3" }: { className?: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
    <path d="M18 6 6 18M6 6l12 12" />
  </svg>
);

/** Stands in for `lucide-react`'s Search icon. */
const SearchIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4 shrink-0 opacity-50" aria-hidden="true">
    <circle cx="11" cy="11" r="8" />
    <path d="m21 21-4.3-4.3" />
  </svg>
);

/** Stands in for `lucide-react`'s LoaderCircle icon. */
const Loader = () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    className="size-4 animate-spin"
    aria-hidden="true"
  >
    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
  </svg>
);

/**
 * Shadcn root
 *
 * The positioning context the popup is measured against. It also lays out
 * what no slot renders, so the field holds together without slotsmith's
 * stylesheet: the row of tags and the text field, and the announcement,
 * which only assistive technology hears. Tailwind reads `_` in a selector as
 * a space, so the class names' own underscores are escaped, in `String.raw`
 * so the backslash stays.
 */
const ShadcnRoot = ({ className, ...props }: AutocompleteRootSlotProps) => (
  <div
    className={cn(
      "relative w-full",
      String.raw`[&_.sac\_\_body]:flex [&_.sac\_\_body]:min-w-0 [&_.sac\_\_body]:flex-1 [&_.sac\_\_body]:flex-wrap [&_.sac\_\_body]:items-center [&_.sac\_\_body]:gap-1`,
      String.raw`[&_.sac\_\_sr-only]:sr-only`,
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn trigger
 *
 * shadcn's `InputGroup`: the field frame, with its border, ring and
 * destructive state. As a select it is the combobox itself, a frame rather
 * than a `Button` because it holds the clear button and a button cannot hold
 * another; its ring follows its own focus and the open list. With the search
 * in the trigger it holds the `InputGroupInput`, draws the ring off it, and
 * grows to wrap the badges: its 3px block padding around the 28px input keeps
 * it at the `h-9` of a plain `InputGroup` while it holds one row.
 */
function ShadcnTrigger({ className, ...props }: AutocompleteTriggerSlotProps) {
  const searchIn = (props as Record<string, unknown>)["data-search-in"] !== undefined;
  return (
    <InputGroup
      className={cn(
        "h-auto min-h-9 data-disabled:cursor-not-allowed data-disabled:opacity-50",
        searchIn
          ? "cursor-text gap-1 px-1 py-0.75"
          : [
              "cursor-pointer gap-2 px-3 py-1 text-sm",
              "data-open:border-ring data-open:ring-[3px] data-open:ring-ring/50",
              "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
              "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
            ],
        className,
      )}
      {...props}
    />
  );
}

/**
 * Shadcn trigger input
 *
 * The `InputGroupInput` that is the combobox when the search is in the
 * trigger. It takes the space the badges leave and wraps below them when
 * that is too little. It sits in the part's body beside the badges rather
 * than directly in the group, so the group's own `[&>input]` padding rules
 * never reach it; its `px-2` stands in for them.
 */
const ShadcnTriggerInput = ({ className, ...props }: AutocompleteTriggerInputSlotProps) => (
  <InputGroupInput className={cn("h-7 min-w-16 basis-16 px-2", className)} {...props} />
);

/**
 * Shadcn toggle
 *
 * A 28px icon `InputGroupButton` in the inline-end `InputGroupAddon`,
 * holding the chevron. It stays out of the tab order and never takes focus
 * from the input. The addon's own inline-end padding and pull are physical
 * (right-hand) upstream, so they are cleared and the field's `px-1` insets
 * it instead, the same on either side in a right-to-left layout.
 */
const ShadcnToggle = ({ className, ...props }: AutocompleteToggleSlotProps) => (
  <InputGroupAddon align="inline-end" className="p-0 has-[>button]:mr-0">
    <InputGroupButton size="icon-xs" className={cn("size-7 text-muted-foreground", className)} {...props} />
  </InputGroupAddon>
);

/**
 * Shadcn value
 *
 * The selected label, muted while it is the placeholder.
 */
const ShadcnValue = ({ label, placeholder, empty }: AutocompleteValueSlotProps) => (
  <span data-slot="select-value" className={cn("truncate", empty && "text-muted-foreground")}>
    {empty ? placeholder : label}
  </span>
);

/**
 * Shadcn tag
 *
 * A secondary `Badge` per selected value, with a 20px ghost `Button`
 * for removing it in the muted foreground.
 */
const ShadcnTag = ({ label, onRemove, removeLabel, disabled }: AutocompleteTagSlotProps) => (
  <Badge variant="secondary" className={cn("h-5.5 gap-0.5 rounded-sm py-0", !disabled && "pe-0.5")}>
    {label}
    {!disabled && (
      <Button
        type="button"
        variant="ghost"
        size="icon"
        aria-label={removeLabel}
        className="size-5 rounded-sm text-muted-foreground hover:bg-transparent hover:text-foreground"
        onClick={(event) => {
          event.stopPropagation();
          onRemove();
        }}
      >
        <X />
      </Button>
    )}
  </Badge>
);

/**
 * Shadcn clear
 *
 * A ghost icon `Button` in the muted foreground, like the chevron beside it.
 * The click is stopped so it does not also reach the trigger and reopen the
 * popup.
 *
 * shadcn's `Button` sets no `type`, so inside a form it would submit it;
 * this and every other `Button` here passes `type="button"`.
 *
 * With the search in the trigger it is an icon `InputGroupButton` placed
 * straight in the group, so the group's `gap-1` is all that separates it
 * from the toggle's addon: the two read as one inline-end cluster.
 */
function ShadcnClear({ onClick, ...aria }: AutocompleteClearSlotProps) {
  const { searchIn } = useAutocompleteContext();
  if (searchIn === "trigger") {
    return (
      <InputGroupButton size="icon-xs" className="text-muted-foreground" onClick={onClick} {...aria}>
        <X className="size-4" />
      </InputGroupButton>
    );
  }
  return (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      className="ms-auto text-muted-foreground"
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
      {...aria}
    >
      <X className="size-4" />
    </Button>
  );
}

/**
 * Shadcn indicator
 *
 * A spinner while a first page is loading, the chevron otherwise.
 */
const ShadcnIndicator = ({ open, loading }: AutocompleteIndicatorSlotProps) => (
  <span className={cn("ms-auto text-muted-foreground transition-transform", open && "rotate-180")}>
    {loading ? <Loader /> : <ChevronDown />}
  </span>
);

/**
 * Shadcn popup
 *
 * shadcn's `Popover`, anchored to the trigger, as shadcn's combobox recipe
 * uses it: it portals the list, places and animates it. Focus stays with the
 * autocomplete, which moves it to the search box itself. Inside, a column
 * the list fills, so the list scrolls inside the height it is given and the
 * search row stays put. The surface keeps every prop the part is given, its `id` included, which
 * is how the autocomplete knows the portaled list is still part of it, and
 * from the computed style only the width (the trigger's) and the height cap;
 * the library places it.
 */
function ShadcnPopup({ className, style, ...props }: AutocompletePopupSlotProps) {
  const { position } = useAutocompleteContext();
  const anchor = useMemo(() => ({ current: position.anchor }), [position.anchor]);
  if (!position.anchor) return null;
  return (
    <Popover open>
      <PopoverAnchor virtualRef={anchor as RefObject<HTMLElement>} />
      <PopoverContent
        // A container for the list, which carries the semantics; not a dialog.
        role="presentation"
        align="start"
        side={position.placement === "top-start" ? "top" : "bottom"}
        onOpenAutoFocus={(event) => event.preventDefault()}
        onCloseAutoFocus={(event) => event.preventDefault()}
        className={cn("flex w-auto flex-col overflow-hidden p-0", className)}
        style={{ width: style?.width, minWidth: style?.minWidth, maxHeight: style?.maxHeight }}
        {...props}
      />
    </Popover>
  );
}

/**
 * Shadcn search
 *
 * The `command-input` row: an `Input` with the search icon beside it.
 */
const ShadcnSearch = ({ className, ...props }: AutocompleteSearchSlotProps) => (
  <div data-slot="command-input-wrapper" className="flex shrink-0 items-center gap-2 border-b px-3">
    <SearchIcon />
    <Input className={cn("h-9 border-0 px-0 shadow-none focus-visible:ring-0", className)} {...props} />
  </div>
);

/**
 * Shadcn list
 *
 * The `command-list` scroll box, which is also the `<ul>` the listbox role
 * goes on.
 */
const ShadcnList = ({ className, ...props }: AutocompleteListSlotProps) => (
  <ul data-slot="command-list" className={cn("min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-1 [scrollbar-color:var(--color-border)_transparent] [scrollbar-width:thin]", className)} {...props} />
);

/**
 * Shadcn option
 *
 * The `command-item` row. Disabled rows stay visible and only look
 * unavailable: the engine is what refuses them. The inline-end padding keeps
 * a long label clear of the check mark.
 */
const ShadcnOption = ({ className, ...props }: AutocompleteOptionSlotProps) => (
  <li
    data-slot="command-item"
    className={cn(
      "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-none select-none",
      "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
      "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:opacity-50",
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn group
 *
 * The `command-group`: its own `<ul>`, carrying `role="group"`, inside a
 * bare `<li>`.
 */
const ShadcnGroup = ({ label: _label, labelId: _labelId, className, ...props }: AutocompleteGroupSlotProps) => (
  <li role="none">
    <ul data-slot="command-group" className={cn("overflow-hidden text-foreground", className)} {...props} />
  </li>
);

/**
 * Shadcn group label
 *
 * The `command-group` heading, with the combobox's label classes.
 */
const ShadcnGroupLabel = ({ label, ...props }: AutocompleteGroupLabelSlotProps) => (
  <li data-slot="command-group-heading" className="text-muted-foreground px-2 py-1.5 text-xs font-medium" {...props}>
    {label}
  </li>
);

/**
 * Shadcn separator
 *
 * The `Separator` in a hidden list item, run out to the list's edges like
 * the `command-separator`.
 */
const ShadcnSeparator = ({ className, ...props }: AutocompleteSeparatorSlotProps) => (
  <li className={cn("-mx-1 my-1", className)} {...props}>
    <Separator />
  </li>
);

/**
 * Shadcn option label
 *
 * The row's text.
 */
const ShadcnOptionLabel = ({ label }: AutocompleteOptionLabelSlotProps) => <span className="truncate">{label}</span>;

/**
 * Shadcn check
 *
 * The mark on a selected row, pinned to the row's inline end. The icon is
 * hidden from assistive technology, so it stays out of the option's
 * accessible name.
 */
const ShadcnCheck = ({ selected }: AutocompleteCheckSlotProps) =>
  selected ? (
    <span className="absolute end-2 flex text-primary">
      <Check />
    </span>
  ) : null;

/**
 * Shadcn empty state
 *
 * The `command-empty` row.
 */
const ShadcnEmpty = ({ message }: AutocompleteEmptySlotProps) => (
  <li data-slot="command-empty" className="py-6 text-center text-sm text-muted-foreground">
    {message}
  </li>
);

/**
 * Shadcn loading state
 *
 * A spinner beside the message.
 */
const ShadcnLoading = ({ message }: AutocompleteLoadingSlotProps) => (
  <li data-slot="command-loading" className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
    <Loader />
    {message}
  </li>
);

/**
 * Shadcn error state
 *
 * The message in the destructive color and a retry `Button`.
 */
const ShadcnError = ({ error, onRetry, retryLabel }: AutocompleteErrorSlotProps) => (
  <li data-slot="command-error" className="flex items-center gap-2 px-2 py-3 text-sm">
    <span className="text-destructive">{error}</span>
    {onRetry ? (
      <Button type="button" variant="outline" size="sm" className="ms-auto" onClick={onRetry}>
        {retryLabel}
      </Button>
    ) : null}
  </li>
);

/**
 * Shadcn create row
 *
 * A full-width ghost `Button` offering to create whatever was searched for.
 */
const ShadcnCreate = ({ onCreate, loading, label }: AutocompleteCreateSlotProps) => (
  <li data-slot="command-create">
    <Button type="button" variant="ghost" size="sm" className="w-full justify-start" disabled={loading} onClick={onCreate}>
      {label}
    </Button>
  </li>
);

/**
 * Shadcn load-more row
 *
 * The paging sentinel, which is also a `Button` so the next page is reachable
 * without a pointer.
 */
const ShadcnLoadMore = ({ ref, onLoadMore, loading, label }: AutocompleteLoadMoreSlotProps) => (
  <li ref={ref} data-slot="command-more">
    <Button type="button" variant="ghost" size="sm" className="w-full" disabled={loading} onClick={onLoadMore}>
      {label}
    </Button>
  </li>
);

/**
 * Shadcn components
 *
 * The slot map a shadcn/ui project would pass as `components`: its own
 * primitives where it has them, and the popover and command class sets for
 * the parts it styles by hand.
 */
export const shadcnComponents: Partial<AutocompleteComponents> = {
  Root: ShadcnRoot,
  Trigger: ShadcnTrigger,
  TriggerInput: ShadcnTriggerInput,
  Toggle: ShadcnToggle,
  Value: ShadcnValue,
  Tag: ShadcnTag,
  Clear: ShadcnClear,
  Indicator: ShadcnIndicator,
  Popup: ShadcnPopup,
  Search: ShadcnSearch,
  List: ShadcnList,
  Option: ShadcnOption,
  OptionLabel: ShadcnOptionLabel,
  Check: ShadcnCheck,
  Group: ShadcnGroup,
  GroupLabel: ShadcnGroupLabel,
  Separator: ShadcnSeparator,
  Empty: ShadcnEmpty,
  Loading: ShadcnLoading,
  Error: ShadcnError,
  Create: ShadcnCreate,
  LoadMore: ShadcnLoadMore,
};
```

## MUI

This adapter renders `Autocomplete` with MUI parts. Copy the file into your app and pass the map to `components`; every slot you leave out keeps its fallback.

```tsx
/**
 * Autocomplete for MUI
 *
 * A `components` map that renders the slotsmith autocomplete with MUI
 * primitives, tested against MUI v7. Copy the file, keep the parts you want,
 * and pass the map as `components={muiComponents}`; every slot left out
 * keeps its fallback.
 */
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import InputBase from "@mui/material/InputBase";
import List from "@mui/material/List";
import ListSubheader from "@mui/material/ListSubheader";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { createSvgIcon } from "@mui/material/utils";
import type {
  AutocompleteCheckSlotProps,
  AutocompleteClearSlotProps,
  AutocompleteComponents,
  AutocompleteCreateSlotProps,
  AutocompleteEmptySlotProps,
  AutocompleteErrorSlotProps,
  AutocompleteGroupLabelSlotProps,
  AutocompleteGroupSlotProps,
  AutocompleteIndicatorSlotProps,
  AutocompleteListSlotProps,
  AutocompleteLoadMoreSlotProps,
  AutocompleteLoadingSlotProps,
  AutocompleteOptionLabelSlotProps,
  AutocompleteOptionSlotProps,
  AutocompletePopupSlotProps,
  AutocompleteRootSlotProps,
  AutocompleteSearchSlotProps,
  AutocompleteSeparatorSlotProps,
  AutocompleteTagSlotProps,
  AutocompleteToggleSlotProps,
  AutocompleteTriggerInputSlotProps,
  AutocompleteTriggerSlotProps,
  AutocompleteValueSlotProps,
} from "slotsmith/autocomplete";
import { useAutocompleteContext } from "slotsmith/autocomplete";

/**
 * Is true
 *
 * Whether an `aria-*` value means true: the boolean, or the string `"true"`
 * a consumer may pass through the slot props.
 *
 * @param value - The attribute's value.
 * @returns Whether it is set to true.
 */
const isTrue = (value: unknown) => value === true || value === "true";

/**
 * Root style
 *
 * The positioning context the popup is measured against, and what no slot
 * renders, laid out so the field holds together without slotsmith's
 * stylesheet: the row of tags and the text field, and the announcement,
 * which only assistive technology hears.
 */
const rootSx: SxProps<Theme> = {
  position: "relative",
  "& .sac__body": { display: "flex", flex: 1, minWidth: 0, flexWrap: "wrap", alignItems: "center", gap: 0.5 },
  "& .sac__sr-only": {
    position: "absolute",
    width: 1,
    height: 1,
    m: "-1px",
    p: 0,
    overflow: "hidden",
    clipPath: "inset(50%)",
    whiteSpace: "nowrap",
    border: 0,
  },
};

/**
 * MUI root
 *
 * A `Box` the popup is measured against.
 */
const MuiRoot = (props: AutocompleteRootSlotProps) => <Box sx={rootSx} {...props} />;

/**
 * Arrow drop down icon
 *
 * The path of `@mui/icons-material`'s `ArrowDropDown`, the chevron of MUI's
 * own autocomplete, built with `createSvgIcon` so the adapter needs no icons
 * package.
 */
const ArrowDropDownIcon = createSvgIcon(<path d="M7 10l5 5 5-5z" />, "ArrowDropDown");

/**
 * Close icon
 *
 * The path of `@mui/icons-material`'s `Close`, the clear icon of MUI's own
 * autocomplete.
 */
const CloseIcon = createSvgIcon(
  <path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />,
  "Close",
);

/**
 * MUI trigger
 *
 * An outlined `Box`, styled off the `data-*` attributes the part carries
 * rather than off props, so it stays a plain element part. `Box` reads `color`
 * as a system prop, which is why the slot's props leave the DOM attribute of
 * that name out.
 *
 * An invalid value takes the theme's `error` colour and MUI's `Mui-error`
 * state class, the way an outlined `TextField` with `error` shows one.
 *
 * With the search in the trigger it is the outlined field around the
 * `InputBase`: the primary border while the input has focus, read off
 * `:focus-within`, and the error border off `data-invalid`, since
 * `aria-invalid` is on the input then.
 */
const MuiTrigger = ({ className, ...props }: AutocompleteTriggerSlotProps) => {
  /** The part always passes the `data-invalid` key, so its value is what counts. */
  const invalid = isTrue(props["aria-invalid"]) || (props as Record<string, unknown>)["data-invalid"] !== undefined;
  return (
    <Box
      className={[className, invalid ? "Mui-error" : undefined].filter(Boolean).join(" ") || undefined}
      sx={{
        display: "flex",
        alignItems: "center",
        gap: 0.5,
        minHeight: 40,
        px: 1,
        border: 1,
        borderColor: "divider",
        borderRadius: 1,
        cursor: "pointer",
        "&[data-open]": { borderColor: "primary.main" },
        "&[data-disabled]": { opacity: 0.5, cursor: "default" },
        "&[data-search-in]": { flexWrap: "wrap", py: 0.5, cursor: "text" },
        "&[data-search-in]:focus-within": { borderColor: "primary.main", boxShadow: (theme) => `inset 0 0 0 1px ${theme.palette.primary.main}` },
        "&.Mui-error": { borderColor: "error.main" },
        "&.Mui-error[data-search-in]:focus-within": { boxShadow: (theme) => `inset 0 0 0 1px ${theme.palette.error.main}` },
      }}
      {...props}
    />
  );
};

/**
 * MUI trigger input
 *
 * The `InputBase` that is the combobox when the search is in the trigger.
 * As with the search box, everything that belongs on the input element
 * itself goes through `inputProps`; `error` follows its `aria-invalid`.
 */
const MuiTriggerInput = ({ ref, value, onChange, placeholder, disabled, ...input }: AutocompleteTriggerInputSlotProps) => (
  <InputBase
    size="small"
    inputRef={ref}
    value={value}
    onChange={onChange}
    placeholder={placeholder}
    disabled={disabled}
    error={isTrue(input["aria-invalid"])}
    inputProps={input}
    sx={{ flex: "1 0 80px", minWidth: 80, px: 0.5 }}
  />
);

/**
 * MUI toggle
 *
 * A small `IconButton` holding the chevron, kept out of the tab order, with
 * the 2px padding of MUI's own popup indicator so its hover circle sits
 * inside the field.
 */
const MuiToggle = (props: AutocompleteToggleSlotProps) => <IconButton size="small" sx={{ p: "2px", mr: "2px" }} {...props} />;

/**
 * MUI value
 *
 * The selected label, or the placeholder in the disabled text color.
 */
const MuiValue = ({ label, placeholder, empty }: AutocompleteValueSlotProps) => (
  <Typography component="span" variant="body2" color={empty ? "text.disabled" : "text.primary"} noWrap>
    {empty ? placeholder : label}
  </Typography>
);

/**
 * MUI tag
 *
 * A `Chip` per selected value. `Chip` clones its `deleteIcon` and attaches its
 * own click handler to it, so the icon is an `IconButton` carrying the
 * accessible name: the remove control has to be reachable by keyboard. It
 * takes the size and margins of the chip's own delete icon, so its hover
 * circle stays inside the chip.
 */
const MuiTag = ({ label, onRemove, removeLabel, disabled }: AutocompleteTagSlotProps) => (
  <Chip
    component="span"
    size="small"
    label={label}
    onDelete={disabled ? undefined : onRemove}
    deleteIcon={
      <IconButton aria-label={removeLabel} sx={{ width: 18, height: 18, p: 0, m: "0 3px 0 -5px" }}>
        <CloseIcon sx={{ fontSize: 14 }} />
      </IconButton>
    }
  />
);

/**
 * MUI clear
 *
 * An `IconButton` that empties the selection. The click is stopped so it does
 * not also reach the trigger and reopen the popup.
 */
const MuiClear = ({ onClick, ...aria }: AutocompleteClearSlotProps) => (
  <IconButton
    size="small"
    sx={{ ml: "auto" }}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    <CloseIcon fontSize="small" />
  </IconButton>
);

/**
 * MUI indicator
 *
 * A `CircularProgress` while a first page is loading, a chevron otherwise.
 */
const MuiIndicator = ({ open, loading }: AutocompleteIndicatorSlotProps) =>
  loading ? (
    <CircularProgress size={16} sx={{ ml: "auto" }} />
  ) : (
    <ArrowDropDownIcon fontSize="small" sx={{ ml: "auto", color: "action.active", transform: open ? "rotate(180deg)" : "none" }} />
  );

/**
 * MUI popup
 *
 * MUI's `Popper`, as MUI's own autocomplete uses it: it portals the list,
 * places it against the trigger (flipping when there is no room) and stacks
 * it on the theme's `modal` layer. Inside it, a raised `Paper` column the
 * list fills, so the list scrolls inside the height it is given and the
 * search box stays put. The surface keeps every prop the part is given, its `id` included, which
 * is how the autocomplete knows the portaled list is still part of it, and
 * from the computed style only the width (the trigger's) and the height cap;
 * the library places it.
 */
function MuiPopup({ style, ...props }: AutocompletePopupSlotProps) {
  const { position } = useAutocompleteContext();
  if (!position.anchor) return null;
  return (
    <Popper
      open
      anchorEl={position.anchor}
      placement={position.placement === "top-start" ? "top-start" : "bottom-start"}
      modifiers={[{ name: "offset", options: { offset: [0, 4] } }]}
      sx={{ zIndex: "modal" }}
    >
      <Paper
        elevation={8}
        style={{ width: style?.width, minWidth: style?.minWidth, maxHeight: style?.maxHeight }}
        sx={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
        {...props}
      />
    </Popper>
  );
}

/**
 * MUI search
 *
 * `InputBase` renders a wrapper around its input, so everything that belongs
 * on the input element itself is handed over through `inputProps` — including
 * the `role`, the aria wiring and the key handler the engine sets. Its `size`
 * is a variant rather than the HTML attribute, which is why the slot's props
 * leave `size` out.
 */
const MuiSearch = ({ ref, value, onChange, placeholder, ...input }: AutocompleteSearchSlotProps) => (
  <InputBase
    fullWidth
    size="small"
    inputRef={ref}
    value={value}
    onChange={onChange}
    placeholder={placeholder}
    inputProps={input}
    sx={{ flexShrink: 0, px: 1.5, py: 0.5, borderBottom: 1, borderColor: "divider" }}
  />
);

/**
 * MUI list
 *
 * A dense `List`, which renders the `<ul>` the listbox role goes on, and is
 * the part that scrolls.
 */
const MuiList = (props: AutocompleteListSlotProps) => (
  <List dense disablePadding sx={{ flex: 1, minHeight: 0, overflowY: "auto" }} {...props} />
);

/**
 * MUI option
 *
 * A `MenuItem`, with the part's `data-selected` mapped onto MUI's `selected`
 * prop so the row gets `Mui-selected` styling. Disabled options keep MUI's
 * `disabled` prop off on purpose: the engine already refuses them, and the
 * row has to stay visible and hoverable.
 */
const MuiOption = (props: AutocompleteOptionSlotProps) => (
  <MenuItem
    dense
    selected={props["data-selected" as keyof AutocompleteOptionSlotProps] === true}
    {...props}
  />
);

/**
 * MUI option label
 *
 * The row's text.
 */
const MuiOptionLabel = ({ label }: AutocompleteOptionLabelSlotProps) => (
  <Typography component="span" variant="body2" noWrap>
    {label}
  </Typography>
);

/**
 * MUI check
 *
 * The mark on a selected row, hidden from assistive technology so it stays
 * out of the option's accessible name.
 */
const MuiCheck = ({ selected }: AutocompleteCheckSlotProps) =>
  selected ? (
    <Box component="span" aria-hidden="true" sx={{ ml: "auto", color: "primary.main" }}>
      ✓
    </Box>
  ) : null;

/**
 * MUI group
 *
 * MUI's grouped-list shape: a bare `<li>` holding the group's own `<ul>`,
 * which carries `role="group"` and the label wiring.
 */
const MuiGroup = ({ label: _label, labelId: _labelId, ...props }: AutocompleteGroupSlotProps) => (
  <li role="none">
    <Box component="ul" sx={{ m: 0, p: 0, listStyle: "none" }} {...props} />
  </li>
);

/**
 * MUI group label
 *
 * A `ListSubheader`, compacted to sit above dense rows at 28px, the virtual
 * list's default `groupLabelSize`. It is not sticky, so it never covers the
 * row the keyboard scrolls to.
 */
const MuiGroupLabel = ({ label, ...props }: AutocompleteGroupLabelSlotProps) => (
  <ListSubheader component="li" disableSticky sx={{ lineHeight: "28px", fontSize: "0.75rem" }} {...props}>
    {label}
  </ListSubheader>
);

/**
 * MUI separator
 *
 * A `Divider` rendered as the list item itself; the slot's `role` and
 * `aria-hidden` replace its own `role="separator"`.
 */
const MuiSeparator = (props: AutocompleteSeparatorSlotProps) => (
  <Divider component="li" sx={{ my: 0.5 }} {...props} />
);

/**
 * MUI empty state
 *
 * Secondary `Typography` in place of the rows.
 */
const MuiEmpty = ({ message }: AutocompleteEmptySlotProps) => (
  <li>
    <Typography variant="body2" color="text.secondary" sx={{ px: 2, py: 1 }}>
      {message}
    </Typography>
  </li>
);

/**
 * MUI loading state
 *
 * A `CircularProgress` beside the message.
 */
const MuiLoading = ({ message }: AutocompleteLoadingSlotProps) => (
  <li>
    <Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 2, py: 1 }}>
      <CircularProgress size={16} />
      <Typography variant="body2" color="text.secondary">
        {message}
      </Typography>
    </Box>
  </li>
);

/**
 * MUI error state
 *
 * The message in the error color and a retry `Button`.
 */
const MuiError = ({ error, onRetry, retryLabel }: AutocompleteErrorSlotProps) => (
  <li>
    <Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 2, py: 1 }}>
      <Typography variant="body2" color="error">
        {error}
      </Typography>
      {onRetry ? (
        <Button size="small" onClick={onRetry}>
          {retryLabel}
        </Button>
      ) : null}
    </Box>
  </li>
);

/**
 * MUI create row
 *
 * A full-width `Button` offering to create whatever was searched for.
 */
const MuiCreate = ({ onCreate, loading, label }: AutocompleteCreateSlotProps) => (
  <li>
    <Button size="small" fullWidth disabled={loading} onClick={onCreate}>
      {label}
    </Button>
  </li>
);

/**
 * MUI load-more row
 *
 * The paging sentinel, which is also a `Button` so the next page is reachable
 * without a pointer.
 */
const MuiLoadMore = ({ ref, onLoadMore, loading, label }: AutocompleteLoadMoreSlotProps) => (
  <li ref={ref}>
    <Button size="small" fullWidth disabled={loading} onClick={onLoadMore}>
      {label}
    </Button>
  </li>
);

/**
 * MUI components
 *
 * The slot map an MUI v7 project would pass as `components`, built from the
 * primitives — `Paper`, `MenuItem`, `Chip`, `InputBase` — rather than from
 * MUI's own `Autocomplete`, which is a competing engine.
 */
export const muiComponents: Partial<AutocompleteComponents> = {
  Root: MuiRoot,
  Trigger: MuiTrigger,
  TriggerInput: MuiTriggerInput,
  Toggle: MuiToggle,
  Value: MuiValue,
  Tag: MuiTag,
  Clear: MuiClear,
  Indicator: MuiIndicator,
  Popup: MuiPopup,
  Search: MuiSearch,
  List: MuiList,
  Option: MuiOption,
  OptionLabel: MuiOptionLabel,
  Check: MuiCheck,
  Group: MuiGroup,
  GroupLabel: MuiGroupLabel,
  Separator: MuiSeparator,
  Empty: MuiEmpty,
  Loading: MuiLoading,
  Error: MuiError,
  Create: MuiCreate,
  LoadMore: MuiLoadMore,
};
```

## Chakra UI

This adapter renders `Autocomplete` with Chakra UI parts. Copy the file into your app and pass the map to `components`; every slot you leave out keeps its fallback.

```tsx
/**
 * Autocomplete for Chakra UI
 *
 * A `components` map that renders the slotsmith autocomplete with Chakra UI
 * primitives, tested against Chakra UI v3. Copy the file, keep the parts you
 * want, and pass the map as `components={chakraComponents}`; every slot left
 * out keeps its fallback.
 */
import { Box, Button, chakra, EmptyState, IconButton, Input, List, Popover, Portal, Separator, Span, Spinner, Tag, Text } from "@chakra-ui/react";
import type {
  AutocompleteCheckSlotProps,
  AutocompleteClearSlotProps,
  AutocompleteComponents,
  AutocompleteCreateSlotProps,
  AutocompleteEmptySlotProps,
  AutocompleteErrorSlotProps,
  AutocompleteGroupLabelSlotProps,
  AutocompleteGroupSlotProps,
  AutocompleteIndicatorSlotProps,
  AutocompleteListSlotProps,
  AutocompleteLoadMoreSlotProps,
  AutocompleteLoadingSlotProps,
  AutocompleteOptionLabelSlotProps,
  AutocompleteOptionSlotProps,
  AutocompletePopupSlotProps,
  AutocompleteRootSlotProps,
  AutocompleteSearchSlotProps,
  AutocompleteSeparatorSlotProps,
  AutocompleteTagSlotProps,
  AutocompleteToggleSlotProps,
  AutocompleteTriggerInputSlotProps,
  AutocompleteTriggerSlotProps,
  AutocompleteValueSlotProps,
} from "slotsmith/autocomplete";
import { useAutocompleteContext } from "slotsmith/autocomplete";

/**
 * Root layout
 *
 * What no slot renders, laid out so the field holds together without
 * slotsmith's stylesheet: the row of tags and the text field, and the
 * announcement, which only assistive technology hears.
 */
const rootCss = {
  "& .sac__body": { display: "flex", flex: "1", minWidth: "0", flexWrap: "wrap", alignItems: "center", gap: "1" },
  "& .sac__sr-only": { srOnly: true },
} as const;

/**
 * Chakra root
 *
 * The `Box` the popup is measured against.
 */
const ChakraRoot = (props: AutocompleteRootSlotProps) => <Box position="relative" css={rootCss} {...props} />;

/**
 * No touch zoom
 *
 * Touch browsers zoom into a focused input whose text is under 16px, so on
 * a coarse pointer the search inputs never go below it.
 */
const NO_TOUCH_ZOOM = { "@media (pointer: coarse)": { fontSize: "max(16px, 1em)" } };

/**
 * Glyph
 *
 * A styled `<svg>` from Chakra's factory, 16px like the icons in Chakra's
 * own combobox, so the clear icon and the chevron share one size.
 */
const Glyph = chakra("svg", {
  base: { width: "4", height: "4", flex: "none", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
});

/**
 * Chakra trigger
 *
 * A bordered `Box`, styled off the `data-*` attributes the part carries. An
 * invalid value takes Chakra's `_invalid` condition and its `border.error`
 * token, the way Chakra's own `Input` shows one. Its text is the `sm`
 * style of Chakra's own combobox, which the input inside inherits.
 *
 * With the search in the trigger it is the field around the `Input`: it
 * wraps the tags, and takes Chakra's focus ring while the input inside has
 * focus, through `_focusWithin`. The error border follows `data-invalid`,
 * since `aria-invalid` is on the input then.
 */
const ChakraTrigger = (props: AutocompleteTriggerSlotProps) => (
  <Box
    display="flex"
    alignItems="center"
    gap="2"
    minH="10"
    px="3"
    textStyle="sm"
    borderWidth="1px"
    borderRadius="md"
    cursor="pointer"
    css={{
      "&[data-open]": { borderColor: "colorPalette.solid" },
      "&[data-disabled]": { opacity: 0.5, cursor: "not-allowed" },
      _invalid: { borderColor: "border.error" },
      "&[data-search-in]": { flexWrap: "wrap", gap: "1", px: "1", py: "0.5", cursor: "text" },
      "&[data-search-in]:focus-within": { borderColor: "colorPalette.focusRing", outline: "1px solid", outlineColor: "colorPalette.focusRing" },
      "&[data-search-in][data-invalid]": { borderColor: "border.error" },
      "&[data-search-in][data-invalid]:focus-within": { outlineColor: "border.error" },
    }}
    {...props}
  />
);

/**
 * Chakra trigger input
 *
 * Chakra's `Input`, unstyled, since the field around it draws the border.
 * It is the combobox when the search is in the trigger, and takes the space
 * the tags leave. On a coarse pointer its text stays at 16px or more, so a
 * touch browser does not zoom in on focus.
 */
const ChakraTriggerInput = (props: AutocompleteTriggerInputSlotProps) => (
  <Input unstyled flex="1 0 80px" minW="20" h="8" px="2" bg="transparent" outline="none" css={NO_TOUCH_ZOOM} {...props} />
);

/**
 * Chakra toggle
 *
 * A 24px ghost `IconButton` holding the chevron, kept out of the tab order,
 * centred in the field with room to its edge.
 */
const ChakraToggle = (props: AutocompleteToggleSlotProps) => <IconButton size="2xs" variant="ghost" alignSelf="center" me="1" {...props} />;

/**
 * Chakra value
 *
 * The selected label, or the placeholder in the muted foreground.
 */
const ChakraValue = ({ label, placeholder, empty }: AutocompleteValueSlotProps) => (
  <Text as="span" color={empty ? "fg.muted" : "fg"}>
    {empty ? placeholder : label}
  </Text>
);

/**
 * Chakra tag
 *
 * `Tag.Root` with Chakra's own close trigger, which already renders a button
 * and its icon; the accessible name is the only thing it needs. The trigger
 * sits in `Tag.EndElement`, as in Chakra's closable tag, which is what
 * gives the icon its size: on its own the button has none and the icon does
 * not show.
 */
const ChakraTag = ({ label, onRemove, removeLabel, disabled }: AutocompleteTagSlotProps) => (
  <Tag.Root size="md" variant="subtle">
    <Tag.Label>{label}</Tag.Label>
    {!disabled && (
      <Tag.EndElement>
        <Tag.CloseTrigger
          aria-label={removeLabel}
          onClick={(event) => {
            event.stopPropagation();
            onRemove();
          }}
        />
      </Tag.EndElement>
    )}
  </Tag.Root>
);

/**
 * Chakra clear
 *
 * An `IconButton` that empties the selection. The click is stopped so it does
 * not also reach the trigger and reopen the popup.
 */
const ChakraClear = ({ onClick, ...aria }: AutocompleteClearSlotProps) => (
  <IconButton
    size="2xs"
    variant="ghost"
    ms="auto"
    alignSelf="center"
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    <Glyph viewBox="0 0 24 24" aria-hidden="true">
      <path d="M18 6 6 18M6 6l12 12" />
    </Glyph>
  </IconButton>
);

/**
 * Chakra indicator
 *
 * A `Spinner` while a first page is loading, a chevron otherwise.
 */
const ChakraIndicator = ({ open, loading }: AutocompleteIndicatorSlotProps) =>
  loading ? (
    <Spinner size="xs" ms="auto" />
  ) : (
    <Glyph viewBox="0 0 24 24" aria-hidden="true" ms="auto" color="fg.muted" transition="transform 0.2s" transform={open ? "rotate(180deg)" : undefined}>
      <path d="m6 9 6 6 6-6" />
    </Glyph>
  );

/**
 * Chakra popup
 *
 * Chakra's `Popover`: it portals the list, places it against the trigger
 * and draws Chakra's popover surface, at the `sm` text style of Chakra's
 * combobox rows. The autocomplete owns the open state, the focus and
 * closing, so the popover's own are turned off. Inside, a column the list
 * fills, so the list scrolls inside the height it is given and the search
 * box stays put. The surface keeps every prop the part is given, its `id` included, which
 * is how the autocomplete knows the portaled list is still part of it, and
 * from the computed style only the width (the trigger's) and the height cap;
 * the library places it.
 */
function ChakraPopup({ style, ...props }: AutocompletePopupSlotProps) {
  const { position } = useAutocompleteContext();
  const anchor = position.anchor;
  if (!anchor) return null;
  return (
    <Popover.Root
      open
      // The popover finds its content by this id, and so does the autocomplete.
      ids={{ content: props.id }}
      modal={false}
      autoFocus={false}
      closeOnEscape={false}
      closeOnInteractOutside={false}
      positioning={{
        placement: position.placement === "top-start" ? "top-start" : "bottom-start",
        gutter: 4,
        getAnchorRect: () => anchor.getBoundingClientRect(),
      }}
    >
      <Portal>
        <Popover.Positioner>
          <Popover.Content
            // A container for the list, which carries the semantics; not a dialog.
            role="presentation"
            textStyle="sm"
            display="flex"
            flexDirection="column"
            overflow="hidden"
            p="0"
            width="auto"
            style={{ width: style?.width, minWidth: style?.minWidth, maxHeight: style?.maxHeight }}
            {...props}
          />
        </Popover.Positioner>
      </Portal>
    </Popover.Root>
  );
}

/**
 * Chakra search
 *
 * Chakra's `Input`. Its `size` is a recipe prop rather than the HTML
 * attribute, which is why the slot's props leave `size` out. On a coarse
 * pointer its text stays at 16px or more, as in the trigger input.
 */
const ChakraSearch = (props: AutocompleteSearchSlotProps) => (
  <Input size="sm" flexShrink="0" borderWidth="0" borderBottomWidth="1px" borderRadius="0" css={NO_TOUCH_ZOOM} {...props} />
);

/**
 * Chakra list
 *
 * `List.Root` renders the `<ul>` the listbox role goes on, and is the part
 * that scrolls.
 */
const ChakraList = (props: AutocompleteListSlotProps) => (
  <List.Root listStyleType="none" gap="0" p="1" m="0" flex="1" minH="0" overflowY="auto" {...props} />
);

/**
 * Chakra option
 *
 * `List.Item`, styled off the row's `data-*` attributes. Disabled rows stay
 * visible and only look unavailable: the engine is what refuses them.
 */
const ChakraOption = (props: AutocompleteOptionSlotProps) => (
  <List.Item
    display="flex"
    alignItems="center"
    gap="2"
    px="3"
    py="1.5"
    borderRadius="sm"
    cursor="pointer"
    css={{
      "&[data-highlighted]": { background: "bg.emphasized" },
      "&[data-selected]": { fontWeight: "medium" },
      "&[data-disabled]": { opacity: 0.5, cursor: "not-allowed" },
    }}
    {...props}
  />
);

/**
 * Chakra option label
 *
 * The row's text.
 */
const ChakraOptionLabel = ({ label }: AutocompleteOptionLabelSlotProps) => <Span>{label}</Span>;

/**
 * Chakra group
 *
 * A nested `List.Root` carrying `role="group"`, inside a bare `List.Item`.
 * Chakra's combobox `ItemGroup` needs its own machine's context, so it cannot
 * be used on its own.
 */
const ChakraGroup = ({ label: _label, labelId: _labelId, ...props }: AutocompleteGroupSlotProps) => (
  <List.Item role="none">
    <List.Root listStyleType="none" gap="0" p="0" m="0" {...props} />
  </List.Item>
);

/**
 * Chakra group label
 *
 * The look of the combobox recipe's `itemGroupLabel`: medium weight, the
 * rows' padding, in the muted foreground at the small text style.
 */
const ChakraGroupLabel = ({ label, ...props }: AutocompleteGroupLabelSlotProps) => (
  <List.Item px="3" py="1.5" textStyle="xs" fontWeight="medium" color="fg.muted" cursor="default" {...props}>
    {label}
  </List.Item>
);

/**
 * Chakra separator
 *
 * Chakra's `Separator` in a hidden list item, run out to the list's edges.
 */
const ChakraSeparator = (props: AutocompleteSeparatorSlotProps) => (
  <List.Item my="1" mx="-1" {...props}>
    <Separator />
  </List.Item>
);

/**
 * Chakra check
 *
 * The mark on a selected row, hidden from assistive technology so it stays
 * out of the option's accessible name.
 */
const ChakraCheck = ({ selected }: AutocompleteCheckSlotProps) =>
  selected ? (
    <Span aria-hidden="true" ms="auto" color="colorPalette.fg">
      ✓
    </Span>
  ) : null;

/**
 * Chakra empty state
 *
 * Chakra's `EmptyState` block in place of the rows.
 */
const ChakraEmpty = ({ message }: AutocompleteEmptySlotProps) => (
  <List.Item listStyleType="none">
    <EmptyState.Root size="sm">
      <EmptyState.Content>
        <EmptyState.Title>{message}</EmptyState.Title>
      </EmptyState.Content>
    </EmptyState.Root>
  </List.Item>
);

/**
 * Chakra loading state
 *
 * A `Spinner` beside the message.
 */
const ChakraLoading = ({ message }: AutocompleteLoadingSlotProps) => (
  <List.Item display="flex" alignItems="center" gap="2" px="3" py="2" listStyleType="none">
    <Spinner size="xs" />
    <Text as="span" color="fg.muted">
      {message}
    </Text>
  </List.Item>
);

/**
 * Chakra error state
 *
 * The message in the error foreground and a retry `Button`.
 */
const ChakraError = ({ error, onRetry, retryLabel }: AutocompleteErrorSlotProps) => (
  <List.Item display="flex" alignItems="center" gap="2" px="3" py="2" listStyleType="none">
    <Text as="span" color="fg.error">
      {error}
    </Text>
    {onRetry ? (
      <Button size="xs" variant="outline" onClick={onRetry}>
        {retryLabel}
      </Button>
    ) : null}
  </List.Item>
);

/**
 * Chakra create row
 *
 * A full-width `Button` offering to create whatever was searched for.
 */
const ChakraCreate = ({ onCreate, loading, label }: AutocompleteCreateSlotProps) => (
  <List.Item listStyleType="none">
    <Button size="xs" variant="ghost" width="full" disabled={loading} onClick={onCreate}>
      {label}
    </Button>
  </List.Item>
);

/**
 * Chakra load-more row
 *
 * The paging sentinel, which is also a `Button` so the next page is reachable
 * without a pointer.
 */
const ChakraLoadMore = ({ ref, onLoadMore, loading, label }: AutocompleteLoadMoreSlotProps) => (
  <List.Item ref={ref} listStyleType="none">
    <Button size="xs" variant="ghost" width="full" disabled={loading} onClick={onLoadMore}>
      {label}
    </Button>
  </List.Item>
);

/**
 * Chakra components
 *
 * The slot map a Chakra UI v3 project would pass as `components`.
 */
export const chakraComponents: Partial<AutocompleteComponents> = {
  Root: ChakraRoot,
  Trigger: ChakraTrigger,
  TriggerInput: ChakraTriggerInput,
  Toggle: ChakraToggle,
  Value: ChakraValue,
  Tag: ChakraTag,
  Clear: ChakraClear,
  Indicator: ChakraIndicator,
  Popup: ChakraPopup,
  Search: ChakraSearch,
  List: ChakraList,
  Option: ChakraOption,
  OptionLabel: ChakraOptionLabel,
  Check: ChakraCheck,
  Group: ChakraGroup,
  GroupLabel: ChakraGroupLabel,
  Separator: ChakraSeparator,
  Empty: ChakraEmpty,
  Loading: ChakraLoading,
  Error: ChakraError,
  Create: ChakraCreate,
  LoadMore: ChakraLoadMore,
};
```

## Ant Design

This adapter renders `Autocomplete` with Ant Design parts. Copy the file into your app and pass the map to `components`; every slot you leave out keeps its fallback.

```tsx
/**
 * Autocomplete for Ant Design
 *
 * A `components` map that renders the slotsmith autocomplete with Ant Design
 * primitives, tested against Ant Design v6. Copy the file, keep the parts you
 * want, and pass the map as `components={antdComponents}`; every slot left
 * out keeps its fallback.
 */
import { CheckOutlined, CloseCircleFilled, DownOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, ConfigProvider, Divider, Empty, Input, Popover, Spin, Tag, Typography, theme } from "antd";
import type { GlobalToken, InputRef } from "antd";
import { useCallback, useContext, useId, useState, type FocusEvent, type ReactNode, type Ref } from "react";
import { createPortal } from "react-dom";
import type {
  AutocompleteCheckSlotProps,
  AutocompleteClearSlotProps,
  AutocompleteComponents,
  AutocompleteCreateSlotProps,
  AutocompleteEmptySlotProps,
  AutocompleteErrorSlotProps,
  AutocompleteGroupLabelSlotProps,
  AutocompleteGroupSlotProps,
  AutocompleteIndicatorSlotProps,
  AutocompleteListSlotProps,
  AutocompleteLoadMoreSlotProps,
  AutocompleteLoadingSlotProps,
  AutocompleteOptionLabelSlotProps,
  AutocompleteOptionSlotProps,
  AutocompletePopupSlotProps,
  AutocompleteRootSlotProps,
  AutocompleteSearchSlotProps,
  AutocompleteSeparatorSlotProps,
  AutocompleteTagSlotProps,
  AutocompleteToggleSlotProps,
  AutocompleteTriggerInputSlotProps,
  AutocompleteTriggerSlotProps,
  AutocompleteValueSlotProps,
} from "slotsmith/autocomplete";
import { useAutocompleteContext } from "slotsmith/autocomplete";

/**
 * Ant Design autocomplete parts
 *
 * Built from Ant Design v6 primitives — `Input`, `Tag`, `Button`, `Spin`,
 * `Empty`, `Typography` — rather than from Ant's own `Select` or
 * `AutoComplete`, which are competing engines. The element parts are plain
 * elements given the look of Ant's select from the theme's tokens: their
 * state arrives as `data-*` props, so each one reads its own and picks the
 * matching token.
 */

/**
 * Has flag
 *
 * @param props - An element part's props.
 * @param name - A `data-*` attribute.
 * @returns Whether the engine set it.
 */
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;

/**
 * Field sheet
 *
 * What no slot renders: the row of tags and the text field, and the
 * announcement, which only assistive technology hears. Ant's parts take
 * inline styles, which cannot reach those, so a sheet scoped to the root
 * lays them out, and the field holds together without slotsmith's
 * stylesheet.
 *
 * @param scope - The root's scope class.
 * @param token - The theme's tokens.
 * @returns The sheet.
 */
function fieldSheet(scope: string, token: GlobalToken): string {
  const s = `.${scope}`;
  return `${s} .sac__body { display: flex; flex: 1; min-width: 0; flex-wrap: wrap; align-items: center; gap: ${token.paddingXXS}px; }
${s} .sac__sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }`;
}

/**
 * Ant root
 *
 * The box the popup is measured against, carrying the field's sheet with the
 * nonce from `ConfigProvider`'s `csp`, as Ant's own styles do.
 */
function AntRoot({ style, className, children, ...props }: AutocompleteRootSlotProps) {
  const { token } = theme.useToken();
  const { csp } = useContext(ConfigProvider.ConfigContext);
  const scope = `ss-antd-field-${useId().replace(/[^\w-]/g, "")}`;
  return (
    <div className={className ? `${scope} ${className}` : scope} style={{ position: "relative", ...style }} {...props}>
      <style nonce={csp?.nonce}>{fieldSheet(scope, token)}</style>
      {children}
    </div>
  );
}

/**
 * Ant trigger
 *
 * The outlined field of Ant's select: the primary border and focus ring
 * while open, the disabled fill when disabled, and the error border and
 * ring of `status="error"` when the value is invalid.
 *
 * With the search in the trigger it is the field around the borderless
 * `Input`, and the border and ring also follow focus in that input.
 */
function AntTrigger({ style, onFocus, onBlur, ...props }: AutocompleteTriggerSlotProps) {
  const { token } = theme.useToken();
  const [focused, setFocused] = useState(false);
  const searchInTrigger = has(props, "data-search-in");
  const open = has(props, "data-open") || (searchInTrigger && focused);
  const disabled = has(props, "data-disabled");
  const invalid = has(props, "data-invalid");
  const edge = invalid ? token.colorError : open ? token.colorPrimary : token.colorBorder;
  const ring = invalid ? token.colorErrorOutline : token.controlOutline;
  return (
    <div
      onFocus={(event: FocusEvent<HTMLDivElement>) => {
        setFocused(true);
        onFocus?.(event);
      }}
      onBlur={(event: FocusEvent<HTMLDivElement>) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
        onBlur?.(event);
      }}
      style={{
        display: "flex",
        flexWrap: "wrap",
        alignItems: "center",
        gap: token.paddingXXS,
        minHeight: token.controlHeight,
        paddingBlock: 2,
        paddingInline: token.paddingSM - 1,
        color: token.colorText,
        fontSize: token.fontSize,
        background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
        border: `${token.lineWidth}px ${token.lineType} ${edge}`,
        borderRadius: token.borderRadius,
        boxShadow: open ? `0 0 0 ${token.controlOutlineWidth}px ${ring}` : undefined,
        cursor: disabled ? "not-allowed" : searchInTrigger ? "text" : "pointer",
        transition: `all ${token.motionDurationMid}`,
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant value
 *
 * The selected label, or the placeholder in the placeholder colour.
 */
function AntValue({ label, placeholder, empty }: AutocompleteValueSlotProps) {
  const { token } = theme.useToken();
  return (
    <Typography.Text
      style={{
        flex: 1,
        minWidth: 0,
        overflow: "hidden",
        whiteSpace: "nowrap",
        textOverflow: "ellipsis",
        color: empty ? token.colorTextPlaceholder : undefined,
      }}
    >
      {empty ? placeholder : label}
    </Typography.Text>
  );
}

/**
 * Ant tag
 *
 * A `Tag` per selected value. Ant renders its close icon as a focusable
 * `role="button"` that answers Enter and Space; the `closable` object gives it
 * the accessible name. `onClose` prevents the default, which would hide the
 * tag itself, and stops the click from reaching the trigger.
 */
const AntTag = ({ label, onRemove, removeLabel, disabled }: AutocompleteTagSlotProps) => (
  <Tag
    style={{ marginInlineEnd: 0 }}
    closable={disabled ? false : { "aria-label": removeLabel }}
    onClose={(event) => {
      event.preventDefault();
      event.stopPropagation();
      onRemove();
    }}
  >
    {label}
  </Tag>
);

/**
 * Ant clear
 *
 * A text `Button` with Ant's clear icon, at the select's icon size and in
 * its quaternary text colour, the same as the chevron beside it. The click is
 * stopped so it does not also reach the trigger and reopen the popup.
 */
function AntClear({ onClick, ...aria }: AutocompleteClearSlotProps) {
  const { token } = theme.useToken();
  return (
    <Button
      type="text"
      size="small"
      icon={<CloseCircleFilled style={{ fontSize: token.fontSizeIcon, color: token.colorTextQuaternary }} />}
      style={{ marginInlineStart: "auto" }}
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
      {...aria}
    />
  );
}

/**
 * Ant indicator
 *
 * A small `Spin` while a first page is loading, the select's chevron
 * otherwise.
 */
function AntIndicator({ open, loading }: AutocompleteIndicatorSlotProps) {
  const { token } = theme.useToken();
  return (
    <span aria-hidden="true" style={{ display: "inline-flex", marginInlineStart: "auto", color: token.colorTextQuaternary }}>
      {loading ? <Spin size="small" /> : <DownOutlined rotate={open ? 180 : 0} style={{ fontSize: token.fontSizeIcon }} />}
    </span>
  );
}

/**
 * Ant popup
 *
 * Ant Design's `Popover`: it portals the list, places it against the
 * trigger (flipping when there is no room) and draws Ant's elevated
 * surface, with the text size of Ant's select menu. An Ant popover is placed
 * against its child, so the child is an empty box laid over the trigger,
 * rendered into it. Inside, a column the list fills, so the list scrolls
 * inside the height it is given and the search box stays put. The surface keeps every prop the part is given, its `id` included, which
 * is how the autocomplete knows the portaled list is still part of it, and
 * from the computed style only the width (the trigger's) and the height cap;
 * the library places it.
 */
function AntPopup({ style, ...props }: AutocompletePopupSlotProps) {
  const { position } = useAutocompleteContext();
  const { token } = theme.useToken();
  if (!position.anchor) return null;
  return createPortal(
    <Popover
      open
      arrow={false}
      trigger={[]}
      placement={position.placement === "top-start" ? "topLeft" : "bottomLeft"}
      styles={{ container: { padding: token.paddingXXS } }}
      content={
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            overflow: "hidden",
            fontSize: token.fontSize,
            lineHeight: token.lineHeight,
            width: style?.width,
            minWidth: style?.minWidth,
            maxHeight: style?.maxHeight,
          }}
          {...props}
        />
      }
    >
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
    </Popover>,
    position.anchor,
  );
}

/**
 * Assign ref
 *
 * @param ref - The ref the engine passed.
 * @param value - The element to hand it.
 */
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) ref.current = value;
}

/**
 * Ant search
 *
 * A borderless `Input` with a search icon. Ant's `ref` is an object that
 * holds the input rather than the input itself, so the engine's ref is
 * handed `input` from it. Everything else — the `role`, the aria wiring,
 * the key handler — Ant passes to the `<input>`. Its `size` is a variant
 * rather than the HTML attribute, which is why the slot's props leave `size`
 * out. A caller's `style` is merged over the skin's rather than replacing it.
 */
function AntSearch({ ref, style, ...props }: AutocompleteSearchSlotProps) {
  const { token } = theme.useToken();
  const inputRef = useCallback((instance: InputRef | null) => assignRef(ref, instance?.input ?? null), [ref]);
  return (
    <Input
      ref={inputRef}
      variant="borderless"
      prefix={<SearchOutlined style={{ color: token.colorTextQuaternary }} />}
      style={{ flexShrink: 0, borderBottom: `${token.lineWidth}px ${token.lineType} ${token.colorSplit}`, borderRadius: 0, ...style }}
      {...props}
    />
  );
}

/**
 * Ant trigger input
 *
 * A borderless `Input` that is the combobox when the search is in the
 * trigger, taking the space the tags leave. It shows `status="error"` when
 * invalid. The field around it draws the border and the focus ring, so the
 * input draws none of its own, and it is as tall as a tag of Ant's select,
 * so the field keeps the select's height. As with the search box, the
 * engine's ref is handed the input from Ant's ref object.
 */
function AntTriggerInput({ ref, style, ...props }: AutocompleteTriggerInputSlotProps) {
  const { token } = theme.useToken();
  const inputRef = useCallback((instance: InputRef | null) => assignRef(ref, instance?.input ?? null), [ref]);
  return (
    <Input
      ref={inputRef}
      variant="borderless"
      status={props["aria-invalid"] ? "error" : undefined}
      style={{
        flex: "1 0 80px",
        minWidth: 80,
        height: token.controlHeight - token.paddingXXS * 2,
        paddingBlock: 0,
        paddingInline: 4,
        border: 0,
        outline: "none",
        boxShadow: "none",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant toggle
 *
 * A text `Button` holding the chevron, kept out of the tab order. The
 * chevron goes in as its `icon`, so it is Ant's square icon-only button,
 * the same size as the clear button. Ant's `type` is its look, so the HTML
 * type goes through `htmlType`.
 */
const AntToggle = ({ type: _type, children, ...props }: AutocompleteToggleSlotProps) => (
  <Button type="text" htmlType="button" size="small" icon={children} style={{ marginInlineEnd: 4 }} {...props} />
);

/**
 * Ant list
 *
 * The `<ul>` the listbox role goes on, and the part that scrolls.
 */
const AntList = ({ style, ...props }: AutocompleteListSlotProps) => (
  <ul style={{ flex: 1, minHeight: 0, margin: 0, padding: 0, overflowX: "hidden", overflowY: "auto", listStyle: "none", ...style }} {...props} />
);

/**
 * Ant option
 *
 * A row of Ant's select menu: the hover fill on the highlighted row, the
 * active fill and strong weight on a selected one. Disabled rows stay
 * visible and only look unavailable: the engine is what refuses them.
 */
function AntOption({ style, ...props }: AutocompleteOptionSlotProps) {
  const { token } = theme.useToken();
  const disabled = has(props, "data-disabled");
  const selected = has(props, "data-selected");
  const highlighted = has(props, "data-highlighted");
  return (
    <li
      style={{
        display: "flex",
        alignItems: "center",
        gap: token.paddingXS,
        minHeight: token.controlHeight,
        paddingBlock: (token.controlHeight - token.fontSize * token.lineHeight) / 2,
        paddingInline: token.paddingSM,
        borderRadius: token.borderRadiusSM,
        color: disabled ? token.colorTextDisabled : token.colorText,
        fontWeight: selected ? token.fontWeightStrong : undefined,
        background: selected ? token.controlItemBgActive : highlighted ? token.controlItemBgHover : undefined,
        cursor: disabled ? "not-allowed" : "pointer",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant group
 *
 * The group's own `<ul>`, carrying `role="group"`, inside a bare `<li>`.
 */
const AntGroup = ({ label: _label, labelId: _labelId, style, ...props }: AutocompleteGroupSlotProps) => (
  <li role="none">
    <ul style={{ margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
  </li>
);

/**
 * Ant group label
 *
 * The look of a group title in Ant's select menu, in the description colour
 * at the small font size, compacted to 28px: the virtual list's default
 * `groupLabelSize`.
 */
function AntGroupLabel({ label, ...props }: AutocompleteGroupLabelSlotProps) {
  const { token } = theme.useToken();
  return (
    <li
      style={{
        minHeight: 28,
        paddingBlock: (28 - token.fontSizeSM * token.lineHeightSM) / 2,
        paddingInline: token.paddingSM,
        color: token.colorTextDescription,
        fontSize: token.fontSizeSM,
        cursor: "default",
        boxSizing: "border-box",
      }}
      {...props}
    >
      {label}
    </li>
  );
}

/**
 * Ant separator
 *
 * Ant's `Divider` in a hidden list item, run out to the popup's edges.
 */
function AntSeparator({ style, ...props }: AutocompleteSeparatorSlotProps) {
  const { token } = theme.useToken();
  return (
    <li style={{ marginInline: -token.paddingXXS, ...style }} {...props}>
      <Divider style={{ marginBlock: token.marginXXS }} />
    </li>
  );
}

/**
 * Ant option label
 *
 * The row's text, cut short rather than wrapped.
 */
const AntOptionLabel = ({ label }: AutocompleteOptionLabelSlotProps) => (
  <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{label}</span>
);

/**
 * Ant check
 *
 * The select's check mark on a selected row, hidden from assistive
 * technology so it stays out of the option's accessible name.
 */
function AntCheck({ selected }: AutocompleteCheckSlotProps) {
  const { token } = theme.useToken();
  return selected ? (
    <span aria-hidden="true" style={{ display: "inline-flex", color: token.colorPrimary }}>
      <CheckOutlined />
    </span>
  ) : null;
}

/**
 * Ant empty state
 *
 * Ant's simple `Empty` picture over the message.
 */
const AntEmpty = ({ message }: AutocompleteEmptySlotProps) => (
  <li>
    <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={message} />
  </li>
);

/**
 * Ant status row
 *
 * The padded row the loading and error states share.
 */
function AntStatusRow({ children }: { children: ReactNode }) {
  const { token } = theme.useToken();
  return (
    <li style={{ display: "flex", alignItems: "center", gap: token.paddingXS, padding: `${token.paddingXS}px ${token.paddingSM}px` }}>
      {children}
    </li>
  );
}

/**
 * Ant loading state
 *
 * A small `Spin` beside the message.
 */
const AntLoading = ({ message }: AutocompleteLoadingSlotProps) => (
  <AntStatusRow>
    <Spin size="small" />
    <Typography.Text type="secondary">{message}</Typography.Text>
  </AntStatusRow>
);

/**
 * Ant error state
 *
 * The message in the danger colour and a retry `Button`.
 */
const AntError = ({ error, onRetry, retryLabel }: AutocompleteErrorSlotProps) => (
  <AntStatusRow>
    <Typography.Text type="danger">{error}</Typography.Text>
    {onRetry ? (
      <Button size="small" onClick={onRetry}>
        {retryLabel}
      </Button>
    ) : null}
  </AntStatusRow>
);

/**
 * Ant create row
 *
 * A full-width text `Button` offering to create whatever was searched for.
 */
const AntCreate = ({ onCreate, loading, label }: AutocompleteCreateSlotProps) => (
  <li>
    <Button type="text" block loading={loading} onClick={onCreate}>
      {label}
    </Button>
  </li>
);

/**
 * Ant load-more row
 *
 * The paging sentinel, which is also a `Button` so the next page is
 * reachable without a pointer.
 */
const AntLoadMore = ({ ref, onLoadMore, loading, label }: AutocompleteLoadMoreSlotProps) => (
  <li ref={ref}>
    <Button type="text" block disabled={loading} onClick={onLoadMore}>
      {label}
    </Button>
  </li>
);

/**
 * Ant Design components
 *
 * The slot map an Ant Design v6 project would pass as `components`.
 */
export const antdComponents: Partial<AutocompleteComponents> = {
  Root: AntRoot,
  Trigger: AntTrigger,
  TriggerInput: AntTriggerInput,
  Toggle: AntToggle,
  Value: AntValue,
  Tag: AntTag,
  Clear: AntClear,
  Indicator: AntIndicator,
  Popup: AntPopup,
  Search: AntSearch,
  List: AntList,
  Option: AntOption,
  OptionLabel: AntOptionLabel,
  Check: AntCheck,
  Group: AntGroup,
  GroupLabel: AntGroupLabel,
  Separator: AntSeparator,
  Empty: AntEmpty,
  Loading: AntLoading,
  Error: AntError,
  Create: AntCreate,
  LoadMore: AntLoadMore,
};
```

## Radix Themes

This adapter renders `Autocomplete` with Radix Themes parts. Copy the file into your app and pass the map to `components`; every slot you leave out keeps its fallback.

```tsx
/**
 * Autocomplete for Radix Themes
 *
 * A `components` map that renders the slotsmith autocomplete with Radix Themes
 * primitives, tested against Radix Themes v3. Copy the file, keep the parts
 * you want, and pass the map as `components={radixComponents}`; every slot
 * left out keeps its fallback.
 *
 * This is Radix Themes (`@radix-ui/themes`), the styled library; an app on the
 * bare Radix primitives wants the shadcn/ui adapter, which is built on them.
 */
import { CheckIcon, ChevronDownIcon, Cross2Icon, CrossCircledIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { Badge, Box, Button, Flex, IconButton, Popover, Separator, Spinner, Text, TextField } from "@radix-ui/themes";
import { useId, useMemo, useState, type FocusEvent, type ReactNode, type RefObject } from "react";
import type {
  AutocompleteCheckSlotProps,
  AutocompleteClearSlotProps,
  AutocompleteComponents,
  AutocompleteCreateSlotProps,
  AutocompleteEmptySlotProps,
  AutocompleteErrorSlotProps,
  AutocompleteGroupLabelSlotProps,
  AutocompleteGroupSlotProps,
  AutocompleteIndicatorSlotProps,
  AutocompleteListSlotProps,
  AutocompleteLoadMoreSlotProps,
  AutocompleteLoadingSlotProps,
  AutocompleteOptionLabelSlotProps,
  AutocompleteOptionSlotProps,
  AutocompletePopupSlotProps,
  AutocompleteRootSlotProps,
  AutocompleteSearchSlotProps,
  AutocompleteSeparatorSlotProps,
  AutocompleteTagSlotProps,
  AutocompleteToggleSlotProps,
  AutocompleteTriggerInputSlotProps,
  AutocompleteTriggerSlotProps,
  AutocompleteValueSlotProps,
} from "slotsmith/autocomplete";
import { useAutocompleteContext } from "slotsmith/autocomplete";

/**
 * Radix Themes autocomplete parts
 *
 * Built from Radix Themes v3 — `TextField`, `Badge`, `Button`,
 * `IconButton`, `Spinner`, `Text` and Radix icons — rather than from its
 * `Select` or `Popover`, which would position the popup a second time. The
 * element parts are plain elements given the look of Radix's select from its
 * variables (`--accent-*`, `--gray-*`, `--color-panel-solid`), so they follow
 * the app's `<Theme accentColor appearance>`; their state arrives as
 * `data-*` props, so each one reads its own. The app must import
 * `@radix-ui/themes/styles.css`, as every Radix Themes app already does.
 *
 * Radix's `Button` and `IconButton` render a `<button>` with no `type`, which
 * would submit a surrounding form, so each one here is given
 * `type="button"` ahead of the slot's props.
 */

/**
 * Has flag
 *
 * @param props - An element part's props.
 * @param name - A `data-*` attribute.
 * @returns Whether the engine set it.
 */
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;

/**
 * Field sheet
 *
 * What no slot renders: the row of tags and the text field, and the
 * announcement, which only assistive technology hears. Radix's parts take
 * inline styles, which cannot reach those, so a sheet scoped to the root
 * lays them out, and the field holds together without slotsmith's
 * stylesheet. Radix Themes offers no nonce setting: an app with a strict
 * `style-src` policy adds its nonce to the `<style>` here.
 *
 * @param scope - The root's scope class.
 * @returns The sheet.
 */
function fieldSheet(scope: string): string {
  const s = `.${scope}`;
  return `${s} .sac__body { display: flex; flex: 1; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
${s} .sac__sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }`;
}

/**
 * Radix root
 *
 * The box the popup is measured against, carrying the field's sheet.
 */
function RadixRoot({ style, className, children, ...props }: AutocompleteRootSlotProps) {
  const scope = `ss-radix-field-${useId().replace(/[^\w-]/g, "")}`;
  return (
    <div className={className ? `${scope} ${className}` : scope} style={{ position: "relative", ...style }} {...props}>
      <style>{fieldSheet(scope)}</style>
      {children}
    </div>
  );
}

/**
 * Radix trigger
 *
 * The surface field of Radix's `TextField`: a hairline in the gray scale,
 * Radix's focus outline while open, and the disabled fill when disabled. An
 * invalid value switches the field to the red scale with
 * `data-accent-color="red"`, which is what Radix's own `color="red"` sets.
 *
 * With the search in the trigger it is the field around the `TextField`,
 * and the focus outline also follows focus in that input.
 */
function RadixTrigger({ style, onFocus, onBlur, ...props }: AutocompleteTriggerSlotProps) {
  const [focused, setFocused] = useState(false);
  const searchInTrigger = has(props, "data-search-in");
  const open = has(props, "data-open") || (searchInTrigger && focused);
  const disabled = has(props, "data-disabled");
  const invalid = has(props, "data-invalid");
  return (
    <div
      onFocus={(event: FocusEvent<HTMLDivElement>) => {
        setFocused(true);
        onFocus?.(event);
      }}
      onBlur={(event: FocusEvent<HTMLDivElement>) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
        onBlur?.(event);
      }}
      data-accent-color={invalid ? "red" : undefined}
      style={{
        display: "flex",
        flexWrap: "wrap",
        alignItems: "center",
        gap: "var(--space-1)",
        minHeight: "var(--space-6)",
        paddingBlock: 2,
        paddingInline: "var(--space-2)",
        color: disabled ? "var(--gray-a11)" : "var(--gray-12)",
        fontFamily: "var(--default-font-family)",
        fontSize: "var(--font-size-2)",
        background: disabled ? "var(--gray-a2)" : "var(--color-surface)",
        boxShadow: `inset 0 0 0 1px ${invalid ? "var(--red-a8)" : "var(--gray-a7)"}`,
        borderRadius: "var(--radius-2)",
        outline: open ? `2px solid ${invalid ? "var(--red-8)" : "var(--focus-8)"}` : undefined,
        outlineOffset: -1,
        cursor: disabled ? "not-allowed" : searchInTrigger ? "text" : "pointer",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Radix value
 *
 * The selected label, or the placeholder in Radix's placeholder gray.
 */
const RadixValue = ({ label, placeholder, empty }: AutocompleteValueSlotProps) => (
  <Text size="2" truncate style={{ flex: 1, minWidth: 0, color: empty ? "var(--gray-a10)" : undefined }}>
    {empty ? placeholder : label}
  </Text>
);

/**
 * Radix tag
 *
 * A `Badge` per selected value, with a ghost `IconButton` to remove it. The
 * click is stopped so it does not also reach the trigger and open the popup.
 */
const RadixTag = ({ label, onRemove, removeLabel, disabled }: AutocompleteTagSlotProps) => (
  <Badge size="2" variant="soft">
    {label}
    {!disabled && (
      <IconButton
        type="button"
        size="1"
        variant="ghost"
        radius="full"
        aria-label={removeLabel}
        onClick={(event) => {
          event.stopPropagation();
          onRemove();
        }}
        style={{ margin: 0, width: "auto", height: "auto", padding: 1 }}
      >
        <Cross2Icon width={12} height={12} />
      </IconButton>
    )}
  </Badge>
);

/**
 * Radix clear
 *
 * A ghost `IconButton` with a crossed circle. The click is stopped so it does
 * not also reach the trigger and reopen the popup.
 */
const RadixClear = ({ onClick, ...aria }: AutocompleteClearSlotProps) => (
  <IconButton
    type="button"
    size="1"
    variant="ghost"
    color="gray"
    style={{ marginInlineStart: "auto" }}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    <CrossCircledIcon />
  </IconButton>
);

/**
 * Radix indicator
 *
 * A `Spinner` while a first page is loading, the select's chevron otherwise.
 */
const RadixIndicator = ({ open, loading }: AutocompleteIndicatorSlotProps) => (
  <span aria-hidden="true" style={{ display: "inline-flex", marginInlineStart: "auto", color: "var(--gray-a11)" }}>
    {loading ? <Spinner size="1" /> : <ChevronDownIcon style={{ transform: open ? "rotate(180deg)" : undefined }} />}
  </span>
);

/**
 * Radix popup
 *
 * Radix Themes' `Popover`, anchored to the trigger: it portals the list
 * inside the theme, places it and draws Radix's panel. Focus stays with the
 * autocomplete, which moves it to the search box itself. Inside, a column
 * the list fills, so the list scrolls inside the height it is given and the
 * search box stays put. The surface keeps every prop the part is given, its `id` included, which
 * is how the autocomplete knows the portaled list is still part of it, and
 * from the computed style only the width (the trigger's) and the height cap;
 * the library places it.
 */
function RadixPopup({ style, ...props }: AutocompletePopupSlotProps) {
  const { position } = useAutocompleteContext();
  const anchor = useMemo(() => ({ current: position.anchor }), [position.anchor]);
  if (!position.anchor) return null;
  return (
    <Popover.Root open>
      <Popover.Anchor virtualRef={anchor as RefObject<HTMLElement>} />
      <Popover.Content
        // A container for the list, which carries the semantics; not a dialog.
        role="presentation"
        size="1"
        align="start"
        side={position.placement === "top-start" ? "top" : "bottom"}
        sideOffset={4}
        onOpenAutoFocus={(event) => event.preventDefault()}
        onCloseAutoFocus={(event) => event.preventDefault()}
        style={{
          display: "flex",
          flexDirection: "column",
          overflow: "hidden",
          padding: "var(--space-1)",
          width: style?.width,
          minWidth: style?.minWidth,
          maxHeight: style?.maxHeight,
        }}
        {...props}
      />
    </Popover.Root>
  );
}

/**
 * Radix search
 *
 * A `TextField` with a magnifying glass. `TextField.Root` hands the ref,
 * the `role`, the aria wiring and the key handler to its `<input>`. Its
 * `size` and `color` are variants rather than the HTML attributes, which is
 * why the slot's props leave them out. `TextField` also narrows two HTML
 * props: `value` must be a string or number, and `type` one of the text-like
 * types. The engine's query is always a string, so `value` is passed on as
 * one; a `type` from `slotProps.search` is passed on as given, since only a
 * text-like one makes sense for a search box; and `defaultValue`, which the
 * controlled input never reads, is dropped.
 */
const RadixSearch = ({ value, type, defaultValue: _defaultValue, ...props }: AutocompleteSearchSlotProps) => (
  <Box mb="1" flexShrink="0">
    <TextField.Root
      size="2"
      type={type as TextField.RootProps["type"]}
      value={value === undefined ? undefined : String(value)}
      {...props}
    >
      <TextField.Slot>
        <MagnifyingGlassIcon />
      </TextField.Slot>
    </TextField.Root>
  </Box>
);

/**
 * Radix trigger input
 *
 * A `TextField` that is the combobox when the search is in the trigger. The
 * field around it draws the border and the focus outline, so its own are
 * cleared; it hands the ref, the role, the aria wiring and the key handler
 * to its `<input>`, and narrows `value` and `type` as the search box does.
 */
const RadixTriggerInput = ({ value, type, defaultValue: _defaultValue, style, ...props }: AutocompleteTriggerInputSlotProps) => (
  <TextField.Root
    size="2"
    variant="soft"
    color="gray"
    type={type as TextField.RootProps["type"]}
    value={value === undefined ? undefined : String(value)}
    style={{ flex: "1 0 80px", minWidth: 80, background: "transparent", boxShadow: "none", outline: "none", ...style }}
    {...props}
  />
);

/**
 * Radix toggle
 *
 * A ghost `IconButton` holding the chevron, kept out of the tab order.
 */
const RadixToggle = (props: AutocompleteToggleSlotProps) => (
  <IconButton size="1" variant="ghost" color="gray" style={{ margin: 0 }} {...props} type="button" />
);

/**
 * Radix list
 *
 * The `<ul>` the listbox role goes on, and the part that scrolls.
 */
const RadixList = ({ style, ...props }: AutocompleteListSlotProps) => (
  <ul style={{ flex: 1, minHeight: 0, margin: 0, padding: 0, overflowX: "hidden", overflowY: "auto", listStyle: "none", ...style }} {...props} />
);

/**
 * Radix option
 *
 * A row of Radix's soft select menu: the accent tint on the highlighted row,
 * a lighter one and the medium weight on a selected row. Disabled rows stay
 * visible and only look unavailable: the engine is what refuses them.
 */
function RadixOption({ style, ...props }: AutocompleteOptionSlotProps) {
  const disabled = has(props, "data-disabled");
  const selected = has(props, "data-selected");
  const highlighted = has(props, "data-highlighted");
  return (
    <li
      style={{
        display: "flex",
        alignItems: "center",
        gap: "var(--space-2)",
        minHeight: "var(--space-6)",
        paddingInline: "var(--space-3)",
        borderRadius: "var(--radius-2)",
        color: disabled ? "var(--gray-a8)" : "var(--gray-12)",
        fontWeight: selected ? "var(--font-weight-medium)" : undefined,
        backgroundColor: highlighted && !disabled ? "var(--accent-a4)" : selected ? "var(--accent-a3)" : undefined,
        cursor: disabled ? "not-allowed" : "pointer",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Radix group
 *
 * The group's own `<ul>`, carrying `role="group"`, inside a bare `<li>`.
 * Radix's `Select.Group` needs the select's context, so it cannot be used on
 * its own.
 */
const RadixGroup = ({ label: _label, labelId: _labelId, style, ...props }: AutocompleteGroupSlotProps) => (
  <li role="none">
    <ul style={{ margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
  </li>
);

/**
 * Radix group label
 *
 * The look of `Select.Label`: a row's height and padding in the muted gray,
 * at the small size.
 */
const RadixGroupLabel = ({ label, ...props }: AutocompleteGroupLabelSlotProps) => (
  <li
    style={{
      display: "flex",
      alignItems: "center",
      /* 28px, the virtual list's default groupLabelSize. */
      minHeight: "calc(var(--space-5) + var(--space-1))",
      paddingInline: "var(--space-3)",
      color: "var(--gray-a10)",
      cursor: "default",
      userSelect: "none",
    }}
    {...props}
  >
    <Text size="1">{label}</Text>
  </li>
);

/**
 * Radix separator
 *
 * Radix's `Separator` in a hidden list item, run out to the popup's edges.
 */
const RadixSeparator = ({ style, ...props }: AutocompleteSeparatorSlotProps) => (
  <li style={{ marginBlock: "var(--space-1)", marginInline: "calc(var(--space-1) * -1)", ...style }} {...props}>
    <Separator size="4" />
  </li>
);

/**
 * Radix option label
 *
 * The row's text, cut short rather than wrapped.
 */
const RadixOptionLabel = ({ label }: AutocompleteOptionLabelSlotProps) => (
  <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{label}</span>
);

/**
 * Radix check
 *
 * The select's check mark on a selected row, hidden from assistive
 * technology so it stays out of the option's accessible name.
 */
const RadixCheck = ({ selected }: AutocompleteCheckSlotProps) =>
  selected ? (
    <span aria-hidden="true" style={{ display: "inline-flex", color: "var(--accent-11)" }}>
      <CheckIcon />
    </span>
  ) : null;

/**
 * Radix status row
 *
 * The padded row the empty, loading and error states share.
 */
const RadixStatusRow = ({ children }: { children: ReactNode }) => (
  <Flex asChild align="center" gap="2" px="3" py="2">
    <li>{children}</li>
  </Flex>
);

/**
 * Radix empty state
 *
 * The message in muted text.
 */
const RadixEmpty = ({ message }: AutocompleteEmptySlotProps) => (
  <RadixStatusRow>
    <Text size="2" color="gray">
      {message}
    </Text>
  </RadixStatusRow>
);

/**
 * Radix loading state
 *
 * A `Spinner` beside the message.
 */
const RadixLoading = ({ message }: AutocompleteLoadingSlotProps) => (
  <RadixStatusRow>
    <Spinner size="1" />
    <Text size="2" color="gray">
      {message}
    </Text>
  </RadixStatusRow>
);

/**
 * Radix error state
 *
 * The message in red and a soft retry `Button`.
 */
const RadixError = ({ error, onRetry, retryLabel }: AutocompleteErrorSlotProps) => (
  <RadixStatusRow>
    <Text size="2" color="red">
      {error}
    </Text>
    {onRetry ? (
      <Button type="button" size="1" variant="soft" onClick={onRetry}>
        {retryLabel}
      </Button>
    ) : null}
  </RadixStatusRow>
);

/**
 * Radix create row
 *
 * A full-width ghost `Button` offering to create whatever was searched for;
 * its `loading` swaps the label for a `Spinner`.
 */
const RadixCreate = ({ onCreate, loading, label }: AutocompleteCreateSlotProps) => (
  <li style={{ display: "flex", padding: "var(--space-1)" }}>
    <Button type="button" variant="ghost" loading={loading} onClick={onCreate} style={{ flex: 1, margin: 0, justifyContent: "flex-start" }}>
      {label}
    </Button>
  </li>
);

/**
 * Radix load-more row
 *
 * The paging sentinel, which is also a `Button` so the next page is
 * reachable without a pointer.
 */
const RadixLoadMore = ({ ref, onLoadMore, loading, label }: AutocompleteLoadMoreSlotProps) => (
  <li ref={ref} style={{ display: "flex", padding: "var(--space-1)" }}>
    <Button type="button" variant="ghost" color="gray" disabled={loading} onClick={onLoadMore} style={{ flex: 1, margin: 0 }}>
      {label}
    </Button>
  </li>
);

/**
 * Radix Themes components
 *
 * The slot map a Radix Themes v3 project would pass as `components`.
 */
export const radixComponents: Partial<AutocompleteComponents> = {
  Root: RadixRoot,
  Trigger: RadixTrigger,
  TriggerInput: RadixTriggerInput,
  Toggle: RadixToggle,
  Value: RadixValue,
  Tag: RadixTag,
  Clear: RadixClear,
  Indicator: RadixIndicator,
  Popup: RadixPopup,
  Search: RadixSearch,
  List: RadixList,
  Option: RadixOption,
  OptionLabel: RadixOptionLabel,
  Check: RadixCheck,
  Group: RadixGroup,
  GroupLabel: RadixGroupLabel,
  Separator: RadixSeparator,
  Empty: RadixEmpty,
  Loading: RadixLoading,
  Error: RadixError,
  Create: RadixCreate,
  LoadMore: RadixLoadMore,
};
```
