pacocoursey / cmdk

Fast, unstyled command menu React component.
https://cmdk.paco.me
MIT License
9.42k stars 265 forks source link

Automatically select the contents of the input field when ⌘K dialog opens #82

Closed aghster closed 6 months ago

aghster commented 1 year ago

In my use case, ⌘K is shown in dialog mode. If I open ⌘K, enter a search string into the input field, then close ⌘K and then re-open ⌘K again, the search string entered before is still there. In general, this is quite useful, as I can easily repeat or amend my previous search.

Usually, however, I rather want to enter a new search. Currently, after ⌘K is re-opened, the input field gets focus with the cursor placed at the end of the previously entered search string. Hence, replacing that search string with a new search string requires a few keystrokes. In my opinion, it would be more convenient if the previously entered search string was automatically selected when ⌘K is re-opened. This way, if I want to enter a new search string, I can just type right away, but I am also still able to use the old search string, if I want to.

Therefore, I am looking for a way to configure ⌘K such that when ⌘K opens, the content of the search input field gets automatically selected (if the input field has no content, of course nothing gets selected). Unfortunately I am not very familiar with React. It tried accessing the input field within a function called by onOpenChange. However, as it seems, when that function is called the input field is not yet part of the DOM and therefore cannot be accessed. Furthermore, I found that Radix UI's Dialog component has an onOpenAutoFocus event handler. Can it be used with ⌘K? Or is there any other simple way to automatically select the contents of the input field when ⌘K opens?

pacocoursey commented 1 year ago

Hey @aghster, I'd need to see a code sample to see what the problem is. By default when using Command.Dialog, the input is unmounted and so the value is cleared. If you're passing value and onValueChange to the input, then the input state will be preserved – and if the input is the first focusable element within the dialog, it will be focused and have the text contents selected already, which is your desired behavior.

Unfortunately I am not very familiar with React. It tried accessing the input field within a function called by onOpenChange. However, as it seems, when that function is called the input field is not yet part of the DOM and therefore cannot be accessed.

You can try useEffect to react to state changes here. It runs after the DOM is synced, so you may have more success:

const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const inputRef = React.useRef(null);

React.useEffect(() => {
  if (open && inputRef.current) {
    inputRef.current.select()
  }
}, [open])

<Command.Input value={search} onValueChange={setSearch} />
aghster commented 1 year ago

Hey @pacocoursey, thanks for your help. Unfortunately, I did not manage to work it out. This is what I tried:

import React from 'react';
import { Command } from 'cmdk';

export default function CommandMenu() {
  const [open, setOpen] = React.useState(false);
  const [search, setSearch] = React.useState('');
  const inputRef = React.useRef(null)

  React.useEffect(() => {
    const down = (e) => {
      if (e.key === ' ' && (e.metaKey || e.ctrlKey)) {
        setOpen((open) => !open)
      }
    }
    document.addEventListener('keydown', down)
    return () => document.removeEventListener('keydown', down)
  }, [])

  React.useEffect(() => {
    if (open && inputRef.current) {
      inputRef.current.select();
    }
  }, [open]);

  return (
    <Command.Dialog open={open} onOpenChange={setOpen} label="Global Command Menu">
      <Command.Input ref={inputRef} autoFocus value={search} onValueChange={setSearch} placeholder="Search..." />
      <Command.List>
        <Command.Empty>No results found.</Command.Empty>

        <Command.Group heading="Letters">
          <Command.Item>a</Command.Item>
          <Command.Item>b</Command.Item>
          <Command.Separator />
          <Command.Item>c</Command.Item>
        </Command.Group>

        <Command.Item>Apple</Command.Item>
      </Command.List>
    </Command.Dialog>
  )
};

To my understanding, the additional useEffect is called as soon as open gets true, but at that time <Command.Input /> is not yet added to the DOM so that inputRef.current is still null and inputRef.current.select() is never called. Debugging in Firefox seems to confirm this:

Bildschirm­foto 2023-02-07 um 20 46 49

By the way, when the dialog gets closed, it's just the other way round: inputRef.current points to the <Command.Input />, but open is false.

Is there anything else I can try?

raunofreiberg commented 1 year ago

You might want to try selecting the input contents after the next tick in the event loop:

  React.useEffect(() => {
    if (open && inputRef.current) {
      setTimeout(() => {
         inputRef.current.select();
      })
    }
  }, [open]);