From 223558a6a0e65fce22a3e08c6582a19834fa0028 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 3 Apr 2025 00:59:38 +1000 Subject: [PATCH] create generic combobox component --- ui/src/components/Combobox.tsx | 129 +++++++++++++++ ui/src/routes/devices.$id.settings.macros.tsx | 152 +++++++----------- 2 files changed, 186 insertions(+), 95 deletions(-) create mode 100644 ui/src/components/Combobox.tsx diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx new file mode 100644 index 0000000..f1342d4 --- /dev/null +++ b/ui/src/components/Combobox.tsx @@ -0,0 +1,129 @@ +import { useRef } from "react"; +import clsx from "clsx"; + +import { Combobox as HeadlessCombobox, ComboboxProps as HeadlessComboboxProps, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import { cva } from "@/cva.config"; + +import Card from "./Card"; + +export interface ComboboxOption { + value: string; + label: string; +} + +const sizes = { + XS: "h-[24.5px] pl-3 pr-8 text-xs", + SM: "h-[32px] pl-3 pr-8 text-[13px]", + MD: "h-[40px] pl-4 pr-10 text-sm", + LG: "h-[48px] pl-4 pr-10 px-5 text-base", +}; + +const comboboxVariants = cva({ + variants: { size: sizes }, +}); + +interface ComboboxProps extends HeadlessComboboxProps> { + displayValue: (option: ComboboxOption) => string; + onInputChange: (option: string) => void; + options: () => ComboboxOption[]; + placeholder?: string; + emptyMessage?: string; + size?: keyof typeof sizes; + disabledMessage?: string; +} + +export function Combobox({ + onInputChange, + displayValue, + options, + disabled = false, + placeholder = "Search...", + emptyMessage = "No results found", + size = "MD", + onChange, + disabledMessage = "Input disabled", + ...otherProps +}: ComboboxProps) { + const inputRef = useRef(null); + const classes = comboboxVariants({ size }); + + const handleChange = (value: T) => { + if (onChange) { + onChange(value); + inputRef.current?.blur(); + } + }; + + return ( + > + immediate + onChange={handleChange} + {...otherProps} + > + {() => ( + <> + + onInputChange(event.target.value)} + disabled={disabled} + /> + + + {options().length > 0 && ( + + {options().map((option) => ( + + {option.label} + + ))} + + )} + + {options().length === 0 && inputRef.current?.value && ( +
+
+ {emptyMessage} +
+
+ )} + + )} + + ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 9896c5c..0fa6bc0 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,19 +1,19 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo, LuCopy, LuArrowUp, LuArrowDown } from "react-icons/lu"; -import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; -import { KeySequence, useMacrosStore } from "../hooks/stores"; +import { KeySequence, useMacrosStore } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { Button } from "@/components/Button"; import Checkbox from "@/components/Checkbox"; -import { keys, modifiers } from "../keyboardMappings"; -import { useJsonRpc } from "../hooks/useJsonRpc"; -import notifications from "../notifications"; -import { SettingsItem } from "../routes/devices.$id.settings"; +import { keys, modifiers, keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; +import { SettingsItem } from "@/routes/devices.$id.settings"; import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import Fieldset from "@/components/Fieldset"; import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import EmptyCard from "@/components/EmptyCard"; +import { Combobox } from "@/components/Combobox"; const DEFAULT_DELAY = 50; @@ -38,10 +38,15 @@ const generateId = () => { return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; }; -const keyOptions = Object.keys(keys).map(key => ({ - value: key, - label: key, -})); +// Filter out modifier keys since they're handled in the modifiers section +const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; + +const keyOptions = Object.keys(keys) + .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) + .map(key => ({ + value: key, + label: keyDisplayMap[key] || key, + })); const modifierOptions = Object.keys(modifiers).map(modifier => ({ value: modifier, @@ -55,63 +60,6 @@ const groupedModifiers = { Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), }; -interface KeyComboboxProps { - stepIndex: number; - step: MacroStep; - onSelect: (option: KeyOptionData) => void; - query: string; - onQueryChange: (query: string) => void; - getFilteredOptions: () => KeyOption[]; - disabled?: boolean; -} - -function KeyCombobox({ - onSelect, - query, - onQueryChange, - getFilteredOptions, - disabled = false, -}: KeyComboboxProps) { - const inputRef = useRef(null); - - return ( -
- - {() => ( - <> -
- query} - onChange={(event) => onQueryChange(event.target.value)} - disabled={disabled} - /> -
- - - {getFilteredOptions().map((option) => ( - - {option.label} - - ))} - {getFilteredOptions().length === 0 && ( -
- No matching keys found -
- )} -
- - )} -
-
- ); -} const PRESET_DELAYS = [ { value: "50", label: "50ms" }, @@ -256,17 +204,18 @@ function MacroStepCard({ Keys: -
+
{ensureArray(step.keys).map((key, keyIndex) => ( - {key} + {keyDisplayMap[key] || key}
- - = MAX_KEYS_PER_STEP} - /> - - {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && ( - - (max keys reached) - - )} +
+ + onChange={(value: KeyOption) => onKeySelect(value)} + displayValue={() => keyQuery} + onInputChange={onKeyQueryChange} + options={getFilteredKeys} + disabledMessage="Max keys reached" + size="SM" + disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} + placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} + emptyMessage="No matching keys found" + /> +
@@ -330,9 +276,17 @@ const updateStepKeys = ( showTemporaryError: (msg: string) => void ) => { const newSteps = [...steps]; + + // Check if the step at stepIndex exists + if (!newSteps[stepIndex]) { + console.error(`Step at index ${stepIndex} does not exist`); + return steps; // Return original steps to avoid mutation + } + if (keyOption.keys) { newSteps[stepIndex].keys = keyOption.keys; } else if (keyOption.value) { + // Initialize keys array if it doesn't exist if (!newSteps[stepIndex].keys) { newSteps[stepIndex].keys = []; } @@ -536,10 +490,18 @@ export default function SettingsMacrosRoute() { ? (editKeyQueries[stepIndex] || '') : (keyQueries[stepIndex] || ''); + const currentStep = isEditing + ? editingMacro?.steps[stepIndex] + : newMacro.steps?.[stepIndex]; + + const selectedKeys = ensureArray(currentStep?.keys); + + const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); + if (query === '') { - return keyOptions; + return availableKeys; } else { - return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase())); + return availableKeys.filter(option => option.label.toLowerCase().includes(query.toLowerCase())); } }; @@ -1318,15 +1280,15 @@ export default function SettingsMacrosRoute() {

{macro.steps.slice(0, 3).map((step, stepIndex) => { - const modifiersText = ensureArray(step.modifiers).length > 0 - ? ensureArray(step.modifiers).map(m => m.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1")).join(' + ') + const keysText = ensureArray(step.keys).length > 0 + ? ensureArray(step.keys).map(key => keyDisplayMap[key] || key).join(' + ') : ''; - - const keysText = ensureArray(step.keys).length > 0 ? ensureArray(step.keys).join(' + ') : ''; - const combinedText = (modifiersText || keysText) - ? [modifiersText, keysText].filter(Boolean).join(' + ') + const modifiersDisplayText = ensureArray(step.modifiers).length > 0 + ? ensureArray(step.modifiers).map(m => modifierDisplayMap[m] || m).join(' + ') + : ''; + const combinedText = (modifiersDisplayText || keysText) + ? [modifiersDisplayText, keysText].filter(Boolean).join(' + ') : 'Delay only'; - return ( {stepIndex > 0 && }