diff --git a/src/components/group-page/AddDeviceToGroup.tsx b/src/components/group-page/AddDeviceToGroup.tsx
index 252107818..d0f1d3c4a 100644
--- a/src/components/group-page/AddDeviceToGroup.tsx
+++ b/src/components/group-page/AddDeviceToGroup.tsx
@@ -4,8 +4,8 @@ import type { Device, Group } from "../../types.js";
import { getEndpoints } from "../../utils.js";
import { sendMessage } from "../../websocket/WebSocketManager.js";
import Button from "../Button.js";
-import DevicePicker from "../pickers/DevicePicker.js";
import EndpointPicker from "../pickers/EndpointPicker.js";
+import SearchableDevicePicker from "../pickers/SearchableDevicePicker.js";
interface AddDeviceToGroupProps {
sourceIdx: number;
@@ -36,7 +36,7 @@ const AddDeviceToGroup = memo(({ sourceIdx, devices, group }: AddDeviceToGroupPr
<>
{t(($) => $.add_to_group_header)}
-
$.device, { ns: "zigbee" })} value={deviceIeee} devices={devices} onChange={onDeviceChange} />
+ $.device, { ns: "zigbee" })} value={deviceIeee} devices={devices} onChange={onDeviceChange} />
$.endpoint, { ns: "zigbee" })}
values={endpoints}
diff --git a/src/components/pickers/SearchableDevicePicker.tsx b/src/components/pickers/SearchableDevicePicker.tsx
new file mode 100644
index 000000000..527bf461f
--- /dev/null
+++ b/src/components/pickers/SearchableDevicePicker.tsx
@@ -0,0 +1,207 @@
+import { faAngleDown, faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { AppState } from "../../store.js";
+import type { Device, Group } from "../../types.js";
+
+interface DevicePickerProps {
+ devices: AppState["devices"][number];
+ value: string | number;
+ label?: string;
+ groups?: Group[];
+ disabled?: boolean;
+ onChange(device?: Device | Group): void;
+}
+
+const SearchableDevicePicker = memo(({ devices, value, label, onChange, groups = [], disabled }: DevicePickerProps) => {
+ const { t } = useTranslation("common");
+ const [searchTerm, setSearchTerm] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+ const inputRef = useRef(null);
+ const normalizedSearchTerm = searchTerm.trim().toLowerCase();
+
+ const selectedName = useMemo(() => {
+ if (typeof value === "string" && value) {
+ const device = devices.find((device) => device.ieee_address === value);
+
+ return device?.friendly_name ?? "";
+ }
+
+ if (typeof value === "number") {
+ const group = groups.find((g) => value === g.id);
+
+ return group?.friendly_name ?? "";
+ }
+
+ return "";
+ }, [value, devices, groups]);
+
+ const onSelectDevice = useCallback(
+ (device: Device) => {
+ setSearchTerm("");
+ setIsOpen(false);
+ onChange(device);
+ },
+ [onChange],
+ );
+
+ const onSelectGroup = useCallback(
+ (group: Group) => {
+ setSearchTerm("");
+ setIsOpen(false);
+ onChange(group);
+ },
+ [onChange],
+ );
+
+ const onInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setSearchTerm(e.target.value);
+
+ if (!isOpen) {
+ setIsOpen(true);
+ }
+ },
+ [isOpen],
+ );
+
+ const onInputFocus = useCallback(() => {
+ setIsOpen(true);
+ }, []);
+
+ const onInputKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setSearchTerm("");
+ setIsOpen(false);
+ }
+ }, []);
+
+ const onClearClick = useCallback(() => {
+ setSearchTerm("");
+ inputRef.current?.focus();
+ }, []);
+
+ const onToggleClick = useCallback(() => {
+ setIsOpen((prev) => !prev);
+
+ if (!isOpen) {
+ inputRef.current?.focus();
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ const handleClickOutside = (event: Event) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const items = useMemo(() => {
+ const elements: JSX.Element[] = [];
+
+ const devicesItems = devices
+ .filter(
+ (device) =>
+ normalizedSearchTerm.length === 0 ||
+ device.friendly_name.toLowerCase().includes(normalizedSearchTerm) ||
+ device.definition?.model?.toLowerCase().includes(normalizedSearchTerm) ||
+ device.ieee_address.toLowerCase().includes(normalizedSearchTerm),
+ )
+ .map((device) => (
+
+
+
+ ))
+ .sort((elA, elB) => elA.key!.localeCompare(elB.key!));
+
+ if (groups?.length) {
+ const groupItems = groups
+ .filter((group) => normalizedSearchTerm.length === 0 || group.friendly_name.toLowerCase().includes(normalizedSearchTerm))
+ .map((group) => (
+
+
+
+ ))
+ .sort((elA, elB) => elA.key!.localeCompare(elB.key!));
+
+ if (groupItems.length > 0) {
+ elements.push(
+
+ {t(($) => $.groups)}
+ ,
+ );
+ elements.push(...groupItems);
+ }
+
+ if (devicesItems.length > 0) {
+ elements.push(
+
+ {t(($) => $.devices)}
+ ,
+ );
+ elements.push(...devicesItems);
+ }
+ } else {
+ elements.push(...devicesItems);
+ }
+
+ return elements;
+ }, [devices, groups, normalizedSearchTerm, t, value, onSelectDevice, onSelectGroup]);
+
+ return (
+
+ );
+});
+
+export default SearchableDevicePicker;
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 6b19d8101..903a1eb30 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -96,7 +96,8 @@
"mismatching_types": "Mismatching types",
"sync": "Sync",
"clear_self_as_source": "Clear self as source",
- "sync_reporting": "Sync reporting"
+ "sync_reporting": "Sync reporting",
+ "no_results": "No results found"
},
"devicePage": {
"about": "About",