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 ( +
    + {label && {label}} +
    + + {isOpen && ( +
      + {items.length > 0 ? items :
    • {t(($) => $.no_results)}
    • } +
    + )} +
    +
    + ); +}); + +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",