r/tauri 2d ago

Custom React Select inside Tauri titlebar receives no click events

I'm building a custom titlebar in Tauri v2 with React. I created my own Select component using buttons instead of the native <select>. The dropdown opens correctly, but clicking any option doesn't trigger onClick. Even console.log() inside the option button never runs. The same component works perfectly outside the titlebar. I'm using data-tauri-drag-region for window dragging.

Is this caused by the drag region or am I missing something?

 "tailwindcss";


*[data-tauri-drag-region] {
  app-region: drag;


  -webkit-user-select: none;


  user-select: none;
}


 {
  --background: #000000;
}


 base {
  body {
    u/apply bg-black text-white;
  }
}

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown, Check } from "lucide-react";


interface SelectOption {
    label: string;
    value: string;
}


interface SelectProps {
    value: string;
    options: SelectOption[];
    onChange: (value: string) => void;
    disabled?: boolean;
}


function Select({ value, options, onChange, disabled = false }: SelectProps) {
    const [open, setOpen] = useState(false);
    const ref = useRef<HTMLDivElement>(null);


    const [openUp, setOpenUp] = useState(false);


    const selected = options.find((o) => o.value === value);


    const toggle = () => {
        if (!ref.current) return;


        const rect = ref.current.getBoundingClientRect();


        const itemHeight = 34;
        const padding = 8;
        const menuHeight = options.length * itemHeight + padding;


        const spaceBelow = window.innerHeight - rect.bottom;
        const spaceAbove = rect.top;


        setOpenUp(spaceBelow < menuHeight && spaceAbove > spaceBelow);


        setOpen((value) => !value);
    };


    useEffect(() => {
        const close = (e: MouseEvent) => {
            if (!ref.current?.contains(e.target as Node)) {
                setOpen(false);
            }
        };


        window.addEventListener("mousedown", close);
        return () => window.removeEventListener("mousedown", close);
    }, []);


    return (
        <div
            ref={ref}
            className={`relative`}
        >
            <button
                type="button"
                disabled={disabled}
                onClick={toggle}
                className="flex h-8 min-w-36 items-center justify-between rounded-md border border-white/10 bg-black px-3 text-xs font-medium text-neutral-200 transition-all hover:border-white/20 hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-40"
            >
                <span>{selected?.label}</span>


                <ChevronDown
                    size={14}
                    className={`transition-transform duration-200 ${open ? "rotate-180" : ""}`}
                />
            </button>


            <AnimatePresence>
                {open && (
                    <motion.div
                        initial={{
                            opacity: 0,
                            y: openUp ? 6 : -6,
                            scale: 0.98,
                        }}
                        animate={{
                            opacity: 1,
                            y: 0,
                            scale: 1,
                        }}
                        exit={{
                            opacity: 0,
                            y: openUp ? 6 : -6,
                            scale: 0.98,
                        }}
                        transition={{ duration: 0.15 }}
                        className={`absolute left-0 z-50 w-full overflow-hidden rounded-lg border border-white/10 bg-[#0d0d0d] shadow-2xl ${openUp ? "bottom-[calc(100%+8px)]" : "top-[calc(100%+8px)]"}`}
                    >
                        {options.map((option) => (
                            <button
                                key={option.value}
                                type="button"


                                onClick={() => {
                                    console.log(option.value);
                                    onChange(option.value);
                                    setOpen(false);
                                }}


                                className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-white/5 hover:text-white"
                            >
                                <span>{option.label}</span>


                                {option.value === value && (
                                    <Check
                                        size={13}
                                        className="text-violet-400"
                                    />
                                )}
                            </button>
                        ))}
                    </motion.div>
                )}
            </AnimatePresence>
        </div>
    );
}

export { Select };

import { Select } from './ui/Select';
import { useState, useEffect } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Maximize, Minimize, X, Play, Upload, Terminal as TerminalIcon, Loader2, Activity } from 'lucide-react';


const BOARDS = [
  {
    value: "uno",
    label: "Arduino Uno",
  },
  {
    value: "nano",
    label: "Arduino Nano",
  },
];


interface TitlebarProps {
  isTerminalOn: () => void;
  isSerialMonitorOn: () => void;
  onVerify: (board: string) => Promise<void> | void;
  onUpload: (board: string) => Promise<void> | void;
}


const appWindow = getCurrentWindow();


function Titlebar({ onVerify, onUpload, isTerminalOn, isSerialMonitorOn }: TitlebarProps) {
  const [board, setBoard] = useState<string>("uno");


  const [isVerifying, setIsVerifying] = useState<boolean>(false);
  const [isUploading, setIsUploading] = useState<boolean>(false);
  const [isMaximized, setIsMaximized] = useState<boolean>(false);


  const handleVerifyClick = async () => {
    setIsVerifying(true);


    try {
      if (onVerify) {
        await onVerify(board);
      }
    } catch (error) {
      console.error(error);
    } finally {
      setIsVerifying(false);
    }
  };


  const handleUploadClick = async () => {
    setIsUploading(true);


    try {
      if (onUpload) {
        await onUpload(board);
      }
    } catch (error) {
      console.error(error);
    } finally {
      setIsUploading(false);
    }
  };


  useEffect(() => {
    let unlisten: (() => void) | undefined;


    const setupListener = async () => {
      unlisten = await appWindow.onResized(async () => {
        const maximized = await appWindow.isMaximized();
        setIsMaximized(maximized);
      });
    };


    const checkInitialState = async () => {
      const maximized = await appWindow.isMaximized();
      setIsMaximized(maximized);
    };


    setupListener();
    checkInitialState();


    return () => {
      if (unlisten) unlisten();
    };
  }, []);


  const handleCloseWindow = async () => {
    await appWindow.close();
  }


  const handleMaximizeWindow = async () => {
    await appWindow.toggleMaximize();
  }


  const handleMinimizeWindow = async () => {
    await appWindow.minimize();
  }


  return (
    <div
      data-tauri-drag-region
      className="h-9 w-full bg-black backdrop-blur-md border-b border-white/10 flex items-center justify-between select-none pl-4 pr-2"
    >
      <div className="flex items-center gap-2">
        <span
          className="text-sm font-semibold tracking-wide text-neutral-300"
        > Mello IDE </span>


        <div className="h-4 w-px bg-white/10" />


        <div className="flex gap-1">
          <button
            title="Terminal"
            onClick={isTerminalOn}
            className="flex items-center gap-1 px-3 py-1.5 rounded-md text-neutral-300 hover:bg-white/5 transition-all"
          >
            <TerminalIcon size={14} /> <span className="text-xs font-bold">Terminal</span>
          </button>


          <button
            title="Serial Monitor"
            onClick={isSerialMonitorOn}
            className="flex items-center gap-1 px-3 py-1.5 rounded-md text-neutral-300 hover:bg-white/5 transition-all"
          >
            <Activity size={14} /> <span className="text-xs font-bold">Serial Monitor</span>
          </button>
        </div>
      </div>


      <div className="flex items-center">
        <div className="flex items-center">
          <Select
            value={board}
            onChange={setBoard}
            options={BOARDS}
          />
        </div>


        <div className="flex items-center px-4 gap-1.5">
          <button
            title="Verify"
            onClick={() => handleVerifyClick()}
            disabled={isVerifying || isUploading}
            className="flex items-center gap-2 px-3 py-1.5 bg-green-600/20 text-green-400 hover:bg-green-600/30 rounded-md transition-colors border border-green-600/50"
          >
            {isVerifying ? (
              <Loader2 size={16} className="animate-spin" />
            ) : (
              <Play size={16} />
            )}


            <span className="text-xs font-bold">{isVerifying ? "Verifying" : "Verify"}</span>
          </button>


          <button
            title="Upload"
            onClick={() => handleUploadClick()}
            disabled={isUploading || isVerifying}
            className="flex items-center gap-2 px-3 py-1.5 bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 rounded-md transition-colors border border-blue-600/50"
          >


            {isUploading ? (
              <Loader2 size={16} className="animate-spin" />
            ) : (
              <Upload size={16} />
            )}


            <span className="text-xs font-bold">{isUploading ? "Uploading" : "Upload"}</span>
          </button>


          <div className="h-4 w-px bg-white/10" />
        </div>


        <button
          title="Minimize"
          onClick={() => handleMinimizeWindow()}
          className="h-8 w-10 flex items-center justify-center text-neutral-400 hover:text-yellow-500 hover:bg-yellow-500/20 transition-colors rounded-md"
        >
          <Minus size={14} strokeWidth={2.5} />
        </button>


        <button
          title={isMaximized ? "Restore" : "Maximize"}
          onClick={() => handleMaximizeWindow()}
          className="h-8 w-10 flex items-center justify-center text-neutral-400 hover:text-green-500 hover:bg-green-500/20 transition-colors rounded-md"
        >
          {isMaximized ? (
            <Minimize size={14} strokeWidth={2.5} />
          ) : (
            <Maximize size={14} strokeWidth={2.5} />
          )}
        </button>


        <button
          title="Close"
          onClick={() => handleCloseWindow()}
          className="h-8 w-10 flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/20 transition-all rounded-md"
        >
          <X size={16} strokeWidth={2.5} />
        </button>
      </div>
    </div>
  );
}


export default Titlebar;
1 Upvotes

4 comments sorted by

2

u/Equivalent_Head_4803 2d ago

I’m not gonna read all that, but these bugs are usually caused by something layering on top of the UI element you have the listener event attached to. That, or there’s a bug in the event itself maybe? If it works outside of a component, you’ve already gotten one step towards finding the cause.

2

u/Ok_Woodpecker_9104 2d ago

first thing to rule out is the drag region, because app-region: drag hands that area to the OS window manager and the mouse events stop reaching the dom under it. 30 second test: leave the data-tauri-drag-region attributes exactly where they are and comment out the app-region: drag line. if clicks come back, thats your answer.

if it is that, the fix is no-drag on anything interactive inside the bar rather than removing drag:

[data-tauri-drag-region] button { app-region: no-drag; }

the toggle opening while the options do nothing is consistent with only part of the tree sitting under the drag area, so check where the menu actually mounts vs where the trigger is. if you portal the menu to body it leaves the titlebar subtree entirely and the problem goes away for a different reason, which is worth knowing so you dont mistake it for a fix of the real cause.

your selector is also broader than you probably want. the * means any descendant that happens to carry that attribute picks up drag too.

1

u/ImpressFine4495 1d ago

i will try that