الإكمال التلقائي المحوّلات
استخدام الإكمال التلقائي من slotsmith مع shadcn/ui وMUI وChakra وAnt Design وRadix Themes.
يمكن أيضًا ضبط المحوّل مرة واحدة للتطبيق كله بدلًا من تمريره إلى كل Autocomplete: <SlotsmithProvider components={{ autocomplete: shadcnComponents }}>. كيف تُحسم فتحات الموفّر.
shadcn/ui
يعرض هذا المحوّل Autocomplete بأجزاء shadcn/ui. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد مكوّنات shadcn/ui هذه، وتضيفها بالأمر npx shadcn@latest add badge button input input-group popover separator. أو ثبّته دفعة واحدة من سجل slotsmith لـ shadcn/ui: أضف "@slotsmith": "https://slotsmith.dev/r/{name}.json" إلى registries في ملف components.json، ثم شغّل npx shadcn@latest add @slotsmith/autocomplete. يكتب الأمر components/slotsmith/autocomplete.tsx وفيه هذه الأجزاء و<Autocomplete /> يعرض بها مباشرة، ويضيف مكوّنات shadcn/ui التي يحتاجها. أما npx shadcn@latest add @slotsmith/provider فيطبّق كل المحوّلات على التطبيق كله عبر SlotsmithProvider واحد.
import { useId } from "react";
import { Autocomplete } from "slotsmith/autocomplete";
import { countriesByRegion, type RegionCountry } from "../../shared/options";
import { shadcnComponents } from "./shadcn";
export default function ShadcnAutocomplete() {
const id = useId();
return (
<div style={{ display: "grid", gap: "0.375rem", maxWidth: 360 }}>
{/* With the search in the trigger the input is the combobox, so a plain <label for> names it. */}
<label htmlFor={id} style={{ fontSize: 14, fontWeight: 500 }}>
Countries
</label>
<Autocomplete<RegionCountry>
id={id}
searchIn="trigger"
multiple
autoHighlight
options={countriesByRegion}
getOptionValue={(country) => country.code}
getOptionLabel={(country) => country.name}
getOptionGroup={(country) => country.region}
optionDisabled={(country) => !!country.disabled}
defaultValue={["dz", "jp"]}
placeholder="Search countries"
components={shadcnComponents}
/>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];العرض داخل موفّر shadcn/ui، كما في أي تطبيق مبني عليها.
shadcn.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
يعرض هذا المحوّل Autocomplete بأجزاء MUI. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @mui/material.
import { useId } from "react";
import { Autocomplete } from "slotsmith/autocomplete";
import { countriesByRegion, type RegionCountry } from "../../shared/options";
import { muiComponents } from "./mui";
export default function MuiAutocomplete() {
const id = useId();
return (
<div style={{ display: "grid", gap: "0.375rem", maxWidth: 360 }}>
{/* With the search in the trigger the input is the combobox, so a plain <label for> names it. */}
<label htmlFor={id} style={{ fontSize: 14, fontWeight: 500 }}>
Countries
</label>
<Autocomplete<RegionCountry>
id={id}
searchIn="trigger"
multiple
autoHighlight
options={countriesByRegion}
getOptionValue={(country) => country.code}
getOptionLabel={(country) => country.name}
getOptionGroup={(country) => country.region}
optionDisabled={(country) => !!country.disabled}
defaultValue={["dz", "jp"]}
placeholder="Search countries"
components={muiComponents}
/>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];العرض داخل موفّر MUI، كما في أي تطبيق مبني عليها.
mui.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
يعرض هذا المحوّل Autocomplete بأجزاء Chakra UI. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @chakra-ui/react.
import { useId } from "react";
import { Autocomplete } from "slotsmith/autocomplete";
import { countriesByRegion, type RegionCountry } from "../../shared/options";
import { chakraComponents } from "./chakra";
export default function ChakraAutocomplete() {
const id = useId();
return (
<div style={{ display: "grid", gap: "0.375rem", maxWidth: 360 }}>
{/* With the search in the trigger the input is the combobox, so a plain <label for> names it. */}
<label htmlFor={id} style={{ fontSize: 14, fontWeight: 500 }}>
Countries
</label>
<Autocomplete<RegionCountry>
id={id}
searchIn="trigger"
multiple
autoHighlight
options={countriesByRegion}
getOptionValue={(country) => country.code}
getOptionLabel={(country) => country.name}
getOptionGroup={(country) => country.region}
optionDisabled={(country) => !!country.disabled}
defaultValue={["dz", "jp"]}
placeholder="Search countries"
components={chakraComponents}
/>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];العرض داخل موفّر Chakra UI، كما في أي تطبيق مبني عليها.
chakra.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
يعرض هذا المحوّل Autocomplete بأجزاء Ant Design. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @ant-design/icons antd.
import { useId } from "react";
import { Autocomplete } from "slotsmith/autocomplete";
import { countriesByRegion, type RegionCountry } from "../../shared/options";
import { antdComponents } from "./antd";
export default function AntdAutocomplete() {
const id = useId();
return (
<div style={{ display: "grid", gap: "0.375rem", maxWidth: 360 }}>
{/* With the search in the trigger the input is the combobox, so a plain <label for> names it. */}
<label htmlFor={id} style={{ fontSize: 14, fontWeight: 500 }}>
Countries
</label>
<Autocomplete<RegionCountry>
id={id}
searchIn="trigger"
multiple
autoHighlight
options={countriesByRegion}
getOptionValue={(country) => country.code}
getOptionLabel={(country) => country.name}
getOptionGroup={(country) => country.region}
optionDisabled={(country) => !!country.disabled}
defaultValue={["dz", "jp"]}
placeholder="Search countries"
components={antdComponents}
/>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];العرض داخل موفّر Ant Design، كما في أي تطبيق مبني عليها.
antd.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
يعرض هذا المحوّل Autocomplete بأجزاء Radix Themes. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @radix-ui/react-icons @radix-ui/themes.
import { useId } from "react";
import { Autocomplete } from "slotsmith/autocomplete";
import { countriesByRegion, type RegionCountry } from "../../shared/options";
import { radixComponents } from "./radix";
export default function RadixAutocomplete() {
const id = useId();
return (
<div style={{ display: "grid", gap: "0.375rem", maxWidth: 360 }}>
{/* With the search in the trigger the input is the combobox, so a plain <label for> names it. */}
<label htmlFor={id} style={{ fontSize: 14, fontWeight: 500 }}>
Countries
</label>
<Autocomplete<RegionCountry>
id={id}
searchIn="trigger"
multiple
autoHighlight
options={countriesByRegion}
getOptionValue={(country) => country.code}
getOptionLabel={(country) => country.name}
getOptionGroup={(country) => country.region}
optionDisabled={(country) => !!country.disabled}
defaultValue={["dz", "jp"]}
placeholder="Search countries"
components={radixComponents}
/>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];العرض داخل موفّر Radix Themes، كما في أي تطبيق مبني عليها.
radix.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,
};