ButtonWithInput

ButtonWithInput is the collapsed search affordance: an icon at rest, an Input after you click it. It exists so a header can offer search without the width of a full field. Both consumers are headers with something more important in them: the ranked card header and the facility picker popup.

import ButtonWithInput from "@haloid/azucar/button-with-input";

It sits next to packages/azucar/src/ui/, not in it. ranked-shared is a ui/ component that depends on this one. That is the only edge that points out of ui/.

Value and expansion

value and onChange are a normal controlled pair. The query lives in your state. Expansion does not. isExpanded is internal, and onExpandChange is your one window onto it. It reports the state after it changes. There is no expanded prop to drive it back.

Type in the demo, then delete what you typed. Clear it with Escape. Click away from an empty field. The readout follows the state you cannot set.

function HeaderSearch() {
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState(false);
return (
  <div className="flex w-100 flex-col gap-6">
    <div className="flex items-center justify-between">
      <div className="text-[14px] font-medium text-fg-4">Discrepancies</div>
      <ButtonWithInput
        icon={<SFMagnifyingglass size={14} />}
        minInputWidth="200px"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        onClear={() => setQuery("")}
        onExpandChange={setExpanded}
      />
    </div>
    <div className="font-mono text-xs text-fg-2">
      expanded: {String(expanded)} / query: {query || "(empty)"}
    </div>
  </div>
);
}

render(<HeaderSearch />);

Expansion is a click, or Enter or Space while the collapsed form has focus. Collapse has three paths, and all of them belong to the component:

  • Escape calls onClear and collapses. The event still bubbles, so an ancestor popup closes with it.
  • Blur collapses only when the value is empty.
  • Deleting the last character collapses at once, without waiting for blur.

The third rule reads the value you passed in, so it works only while the field is controlled. Leave value off and the component sees an empty string forever: the rule never fires, and blur collapses the field even with text in it.

Force a collapse

There is no fourth path, and sometimes a page needs one. Remount the component with a key. The facility picker does this when its popup closes, so the next open starts collapsed:

function ForcedCollapse() {
const [query, setQuery] = useState("");
const [resetKey, setResetKey] = useState(0);
return (
  <div className="flex flex-col items-center gap-6">
    <ButtonWithInput
      key={resetKey}
      icon={<SFMagnifyingglass size={14} />}
      minInputWidth="180px"
      value={query}
      onChange={(event) => setQuery(event.target.value)}
      onClear={() => setQuery("")}
    />
    <Button
      variant="ghost"
      onClick={() => {
        setQuery("");
        setResetKey((key) => key + 1);
      }}
    >
      Collapse
    </Button>
  </div>
);
}

render(<ForcedCollapse />);

Clear the query in the same handler. The key bump resets the component, not your state.

Width

minInputWidth is required and maxInputWidth is optional. Both are raw CSS length strings, not Tailwind classes. They land on the style of the field. The two consumers want opposite things from that pair:

// ranked card header: room for a customer name, no ceiling
<ButtonWithInput icon={<SFMagnifyingglass size={14} />} minInputWidth="200px" />
// facility picker: no floor, capped to fit the 240px popup
<ButtonWithInput
icon={<SFMagnifyingglass size={14} />}
minInputWidth=""
maxInputWidth="188px"
/>

The empty string is deliberate. It means no minimum, which lets the max do the sizing inside a surface too narrow for a floor.

The surface

One absolutely positioned element sits behind both states: a bg-bg-2 pill, 16px wider and 12px taller than the form, on the house layoutSpring with layout. It fades in on hover and stays while the field is open, so hover and expansion share one surface. origin-right grows it to the left, which is why the component belongs on the right end of a header row.

The icon rides the same spring. Focus arrives 300ms later, after the spring settles. It lands through querySelector("input") on the wrapping form, because Input forwards no ref. It focuses with preventScroll, so an expansion inside a scroller does not jump it.

Typing inside a menu

The field stops propagation on printable characters and on the edit keys (backspace, delete, arrows, home, end). Base ui's menu calls preventDefault on printable keys for its typeahead. Without this, the search in the facility picker would swallow every letter you type. All other keys, including Escape and Enter, still reach ancestors.

API

interface Props {
icon: React.ReactNode; // the entire rest state
minInputWidth: string; // CSS length; "" means no minimum
maxInputWidth?: string; // CSS length
value?: string; // controlled; see the collapse rules
onChange?: React.ChangeEventHandler<HTMLInputElement>;
onClear?: () => void; // Escape; clear your own state here
onSubmit?: () => void; // Enter; it is a form
onExpandChange?: (expanded: boolean) => void;
className?: string;
}

It renders a form, so Enter submits. Both consumers filter as you type and pass no onSubmit.

Limits

  • No expanded prop. A key bump is the only way to collapse it from outside.
  • onExpandChange fires once on mount with false, before any interaction.
  • Escape bubbles after the collapse, which closes an enclosing menu too.
  • The collapsed state is an icon with no accessible name. It takes focus and responds to Enter and Space, but a screen reader has nothing to announce.