mirror of https://github.com/jetkvm/kvm.git
create generic combobox component
This commit is contained in:
parent
6406400884
commit
223558a6a0
|
@ -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<T> extends HeadlessComboboxProps<T, boolean, React.ExoticComponent<{
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}>> {
|
||||||
|
displayValue: (option: ComboboxOption) => string;
|
||||||
|
onInputChange: (option: string) => void;
|
||||||
|
options: () => ComboboxOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
size?: keyof typeof sizes;
|
||||||
|
disabledMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Combobox<T>({
|
||||||
|
onInputChange,
|
||||||
|
displayValue,
|
||||||
|
options,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "Search...",
|
||||||
|
emptyMessage = "No results found",
|
||||||
|
size = "MD",
|
||||||
|
onChange,
|
||||||
|
disabledMessage = "Input disabled",
|
||||||
|
...otherProps
|
||||||
|
}: ComboboxProps<T>) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const classes = comboboxVariants({ size });
|
||||||
|
|
||||||
|
const handleChange = (value: T) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(value);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeadlessCombobox<T, boolean, React.ExoticComponent<{ children?: React.ReactNode;}>>
|
||||||
|
immediate
|
||||||
|
onChange={handleChange}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||||
|
<ComboboxInput
|
||||||
|
ref={inputRef}
|
||||||
|
className={clsx(
|
||||||
|
classes,
|
||||||
|
|
||||||
|
// General styling
|
||||||
|
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||||
|
|
||||||
|
// Hover
|
||||||
|
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
|
||||||
|
)}
|
||||||
|
placeholder={disabled ? disabledMessage : placeholder}
|
||||||
|
displayValue={displayValue}
|
||||||
|
onChange={(event) => onInputChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{options().length > 0 && (
|
||||||
|
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
|
||||||
|
{options().map((option) => (
|
||||||
|
<ComboboxOption
|
||||||
|
key={option.value}
|
||||||
|
value={option}
|
||||||
|
className={clsx(
|
||||||
|
// General styling
|
||||||
|
"cursor-default select-none py-2 px-4",
|
||||||
|
|
||||||
|
// Hover and active states
|
||||||
|
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ComboboxOption>
|
||||||
|
))}
|
||||||
|
</ComboboxOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{options().length === 0 && inputRef.current?.value && (
|
||||||
|
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
|
||||||
|
<div className="text-slate-500 dark:text-slate-400">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HeadlessCombobox>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,19 +1,19 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo, LuCopy, LuArrowUp, LuArrowDown } from "react-icons/lu";
|
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 { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import Checkbox from "@/components/Checkbox";
|
import Checkbox from "@/components/Checkbox";
|
||||||
import { keys, modifiers } from "../keyboardMappings";
|
import { keys, modifiers, keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "@/notifications";
|
||||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
import { SettingsItem } from "@/routes/devices.$id.settings";
|
||||||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||||
import EmptyCard from "@/components/EmptyCard";
|
import EmptyCard from "@/components/EmptyCard";
|
||||||
|
import { Combobox } from "@/components/Combobox";
|
||||||
|
|
||||||
const DEFAULT_DELAY = 50;
|
const DEFAULT_DELAY = 50;
|
||||||
|
|
||||||
|
@ -38,10 +38,15 @@ const generateId = () => {
|
||||||
return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyOptions = Object.keys(keys).map(key => ({
|
// Filter out modifier keys since they're handled in the modifiers section
|
||||||
value: key,
|
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
|
||||||
label: key,
|
|
||||||
}));
|
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 => ({
|
const modifierOptions = Object.keys(modifiers).map(modifier => ({
|
||||||
value: modifier,
|
value: modifier,
|
||||||
|
@ -55,63 +60,6 @@ const groupedModifiers = {
|
||||||
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
|
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<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Combobox immediate onChange={onSelect} disabled={disabled}>
|
|
||||||
{() => (
|
|
||||||
<>
|
|
||||||
<div className="relative">
|
|
||||||
<ComboboxInput
|
|
||||||
ref={inputRef}
|
|
||||||
className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
||||||
placeholder={disabled ? "Max keys reached" : "Search for key..."}
|
|
||||||
displayValue={() => query}
|
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
|
|
||||||
{getFilteredOptions().map((option) => (
|
|
||||||
<ComboboxOption
|
|
||||||
key={option.value}
|
|
||||||
value={option}
|
|
||||||
className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</ComboboxOption>
|
|
||||||
))}
|
|
||||||
{getFilteredOptions().length === 0 && (
|
|
||||||
<div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
No matching keys found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ComboboxOptions>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESET_DELAYS = [
|
const PRESET_DELAYS = [
|
||||||
{ value: "50", label: "50ms" },
|
{ value: "50", label: "50ms" },
|
||||||
|
@ -256,17 +204,18 @@ function MacroStepCard({
|
||||||
Keys:
|
Keys:
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="macro-key-group flex flex-wrap gap-1 mb-2">
|
<div className="macro-key-group flex flex-wrap gap-1 pb-2">
|
||||||
{ensureArray(step.keys).map((key, keyIndex) => (
|
{ensureArray(step.keys).map((key, keyIndex) => (
|
||||||
<span
|
<span
|
||||||
key={keyIndex}
|
key={keyIndex}
|
||||||
className="inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
|
||||||
>
|
>
|
||||||
<span className="px-1">
|
<span className="px-1">
|
||||||
{key}
|
{keyDisplayMap[key] || key}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
className=""
|
||||||
theme="blank"
|
theme="blank"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
|
const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
|
||||||
|
@ -277,22 +226,19 @@ function MacroStepCard({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full">
|
||||||
<KeyCombobox
|
<Combobox<KeyOption>
|
||||||
stepIndex={stepIndex}
|
onChange={(value: KeyOption) => onKeySelect(value)}
|
||||||
step={step}
|
displayValue={() => keyQuery}
|
||||||
onSelect={onKeySelect}
|
onInputChange={onKeyQueryChange}
|
||||||
query={keyQuery}
|
options={getFilteredKeys}
|
||||||
onQueryChange={onKeyQueryChange}
|
disabledMessage="Max keys reached"
|
||||||
getFilteredOptions={getFilteredKeys}
|
size="SM"
|
||||||
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
|
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"
|
||||||
{ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
|
/>
|
||||||
<span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
</div>
|
||||||
(max keys reached)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
@ -330,9 +276,17 @@ const updateStepKeys = (
|
||||||
showTemporaryError: (msg: string) => void
|
showTemporaryError: (msg: string) => void
|
||||||
) => {
|
) => {
|
||||||
const newSteps = [...steps];
|
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) {
|
if (keyOption.keys) {
|
||||||
newSteps[stepIndex].keys = keyOption.keys;
|
newSteps[stepIndex].keys = keyOption.keys;
|
||||||
} else if (keyOption.value) {
|
} else if (keyOption.value) {
|
||||||
|
// Initialize keys array if it doesn't exist
|
||||||
if (!newSteps[stepIndex].keys) {
|
if (!newSteps[stepIndex].keys) {
|
||||||
newSteps[stepIndex].keys = [];
|
newSteps[stepIndex].keys = [];
|
||||||
}
|
}
|
||||||
|
@ -536,10 +490,18 @@ export default function SettingsMacrosRoute() {
|
||||||
? (editKeyQueries[stepIndex] || '')
|
? (editKeyQueries[stepIndex] || '')
|
||||||
: (keyQueries[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 === '') {
|
if (query === '') {
|
||||||
return keyOptions;
|
return availableKeys;
|
||||||
} else {
|
} 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() {
|
||||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
|
||||||
<span className="flex flex-wrap items-center">
|
<span className="flex flex-wrap items-center">
|
||||||
{macro.steps.slice(0, 3).map((step, stepIndex) => {
|
{macro.steps.slice(0, 3).map((step, stepIndex) => {
|
||||||
const modifiersText = ensureArray(step.modifiers).length > 0
|
const keysText = ensureArray(step.keys).length > 0
|
||||||
? ensureArray(step.modifiers).map(m => m.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1")).join(' + ')
|
? ensureArray(step.keys).map(key => keyDisplayMap[key] || key).join(' + ')
|
||||||
: '';
|
: '';
|
||||||
|
const modifiersDisplayText = ensureArray(step.modifiers).length > 0
|
||||||
const keysText = ensureArray(step.keys).length > 0 ? ensureArray(step.keys).join(' + ') : '';
|
? ensureArray(step.modifiers).map(m => modifierDisplayMap[m] || m).join(' + ')
|
||||||
const combinedText = (modifiersText || keysText)
|
: '';
|
||||||
? [modifiersText, keysText].filter(Boolean).join(' + ')
|
const combinedText = (modifiersDisplayText || keysText)
|
||||||
|
? [modifiersDisplayText, keysText].filter(Boolean).join(' + ')
|
||||||
: 'Delay only';
|
: 'Delay only';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={stepIndex} className="inline-flex items-center my-0.5">
|
<span key={stepIndex} className="inline-flex items-center my-0.5">
|
||||||
{stepIndex > 0 && <span className="mx-1 text-blue-400 dark:text-blue-500">→</span>}
|
{stepIndex > 0 && <span className="mx-1 text-blue-400 dark:text-blue-500">→</span>}
|
||||||
|
|
Loading…
Reference in New Issue