Page actions

React Select preview

Let the traveler pick a model beside the draft, while your application decides what the choice means.

The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.

Goals

  • Put a model picker inside the composer.
  • Hold the chosen value and the open list in your own state.
  • Refuse a change when the application cannot accept it.
  • Know what the keyboard does before you ship it.

Add a model picker to the composer

Select is a small dropdown for one choice out of a few. The most common home for it is the composer, so that is where this page starts. Pass it to ChatInput as trailing content and it sits between the draft and Send.

import { useState } from 'react';
import { ChatInput, Select, type SelectOption } from '@threadplane/react/chat';
import '@threadplane/react/chat/styles.css';
 
const models: readonly SelectOption[] = [
  { value: 'fast', label: 'Fast', description: 'Quick answers to simple questions.' },
  { value: 'careful', label: 'Careful', description: 'Slower, for trips with several legs.' },
  { value: 'research', label: 'Research', disabled: true },
];
 
export function TripComposer({ busy, accept }: {
  readonly busy: boolean;
  readonly accept: (text: string, model: string) => boolean;
}) {
  const [model, setModel] = useState('fast');
  const [open, setOpen] = useState(false);
  return (
    <ChatInput
      busy={busy}
      onSubmit={(text) => accept(text, model)}
      trailing={
        <Select
          label="Model"
          options={models}
          value={model}
          onValueChange={(next) => {
            if (!busy) setModel(next);
          }}
          open={open}
          onOpenChange={setOpen}
        />
      }
    />
  );
}

There are two pieces of state here, and both are yours. value is the chosen option, and open is whether the list is showing. Select never keeps either one for you.

The model travels with the message because your onSubmit reads it. Select does not know about the agent, and choosing an option never starts any work.

Why do I have to hold the open state?

For me, this is the part that feels like extra typing at first. It earns its keep the first time you need to close the list from somewhere else.

A new conversation, a route change or a finished response can all set open to false. You do not need a ref or an imperative handle to do it. The cost is one more useState in every place you use a Select.

Always pass the value you get from onOpenChange back to open, or decide on purpose not to. If you ignore it, the list will not open, and it will not close.

Refuse a change

onValueChange is a request. The trigger keeps showing the option that matches value until you change value yourself.

In the example above, a choice made while a response is running is dropped. The list still closes, and the trigger still shows the model that is in use. I think this is the right default for a model picker, because a model that changes halfway through an answer is hard to explain to the traveler.

Choosing the option that is already selected closes the list and does not call onValueChange. A disabled option stays visible, cannot be chosen and is skipped by the arrow keys.

What does the keyboard do?

The trigger is a native button, so Tab reaches it in document order. Inside the composer, that is after the draft and before Send.

  • Enter, Space or Arrow Down on the trigger opens the list.
  • Focus starts on the selected option, or on the first enabled one.
  • Arrow Up on the trigger opens the list at the last enabled option.
  • Arrow Up and Arrow Down move between enabled options and wrap at the ends, and Home and End jump to the ends.
  • Enter or Space chooses the focused option.
  • Escape closes the list and returns focus to the trigger.
  • Tab closes the list and moves on.

None of these keys send the draft. The trigger and every option are type="button", so a Select inside the composer form never submits it.

Where does the list render?

The list renders at the end of the document body, not inside the composer. A composer usually sits in a scrolling column at the bottom of the window, and a list rendered in place would be clipped by it.

The list opens above the trigger by default, since that is where the room is (a composer at the bottom of the screen does not leave much below it). It flips below when there is not space above, and it stays inside the viewport on a narrow screen.

The catch is styling. A class on the composer will not reach the list, because the list is not a descendant of the composer. Use className for the trigger and menuClassName for the list. The list keeps the text direction, color scheme and font of the place it was opened from.

Name it for assistive technology

label names the list, so write it as the thing being chosen: "Model", not "Choose". The trigger reads the current option's label, or placeholder when value matches nothing.

Option labels and descriptions are text. They are rendered as written, so markup in a label shows up as characters.

Conclusion

Select gives you the trigger, the list, the keyboard and the positioning. You keep the value, the open state and the decision about what a choice means. For a model picker, that means the model is one more piece of state your onSubmit reads.

What's Next

If the keyboard or the positioning does not hold up in your layout, let me know where it breaks.

Looking for something specific?