nextui-org / nextui

🚀 Beautiful, fast and modern React UI library.
https://nextui.org
MIT License
21.94k stars 1.53k forks source link

[BUG] - Autocomplete doesn't combine custom user inputs with Controlled functionality #3667

Open buchananwill opened 2 months ago

buchananwill commented 2 months ago

NextUI Version

2.4.6

Describe the bug

There doesn't seem to be a decent way to run a controlled AutoComplete that also allows custom inputs. I can make either of two behaviours happen: the custom values are registered, but can't be cleared without manually deleting all the text, or the custom values can be cleared, but don't propagate to the controlling form. It seems that whenever the Autocomplete loses focus, it triggers the onSelectionChange handler, and if the inputValue doesn't correspond to a selection, this passes null. Either I block this, forcing it to keep the custom entry and the value can't be properly cleared, or if allowed as normal then it erases the custom value. This is the component I've been trying to build, using react-hook-form, and the NextUI Autocomplete.

Is there are a way to do this that I've overlooked?

import { Control, Controller, ControllerRenderProps } from 'react-hook-form';
import {
  Autocomplete,
  AutocompleteItem,
  AutocompleteProps,
  SelectItemProps
} from '@nextui-org/react';
import React, {
  ChangeEvent,
  useCallback,
  useMemo,
  useRef,
  useState
} from 'react';
import {
  ItemAccessors,
  SelectableItem
} from '@/components/react-hook-form/ControlledSelect';
import { HasId } from '@/api/types';
import { TypedPaths } from '@/functions/typePaths';

type ControlledAutoCompleteProps<T extends SelectableItem> = {
  selectedKeyAccessor?: string;
  name: string;
  control: Control<any>;
  onChange?: (
    value: React.Key | null,
    onChange: (...event: any[]) => void,
    setInputValue: (value: string) => void
  ) => void;
  selectItemProps?: SelectItemProps;
  itemAccessors?: ItemAccessors<T>;
  items: T[];
} & Omit<AutocompleteProps, 'onChange' | 'children'>;

export function ControlledAutoComplete<T extends SelectableItem>({
  name,
  onChange,
  itemAccessors = defaultItemAccessors as ItemAccessors<T>,
  items,
  selectItemProps,
  selectedKeyAccessor,
  defaultInputValue,
  control,
  ...props
}: ControlledAutoCompleteProps<T>) {
  const { keyAccessor, labelAccessor, valueAccessor } = itemAccessors;
  const childrenDefined = useMemo(() => {
    if (items && itemAccessors) {
      return items.map((kls) => (
        <AutocompleteItem
          {...selectItemProps}
          key={kls[keyAccessor]}
          value={kls[valueAccessor]}
          aria-label={kls[labelAccessor] ?? kls[keyAccessor]}
        >
          {kls[labelAccessor]}
        </AutocompleteItem>
      ));
    } else return [];
  }, [
    itemAccessors,
    items,
    selectItemProps,
    keyAccessor,
    valueAccessor,
    labelAccessor
  ]);

  const [inputValue, setInputValue] = useState<string>(defaultInputValue ?? '');
  const inputRef = useRef('');
  inputRef.current = inputValue;

  const onOpenChange = useCallback(
    (isClosing: boolean, field: ControllerRenderProps) => {
      console.log(
        'open change handler',
        !field.value,
        isClosing,
        props.allowsCustomValue
      );
      const triggerCondition =
        isClosing && props.allowsCustomValue && !field.value;
      console.log('trigger set', triggerCondition);
      if (isClosing && props.allowsCustomValue && !field.value) {
        if (onChange) {
          console.log('updating the field externally');
          onChange(inputRef.current, field.onChange, setInputValue);
        } else {
          console.log('updating the field internally');
          field.onChange(inputRef.current);
        }
      }
    },
    [props.allowsCustomValue]
  );

  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState, formState }) => {
        const currentValue =
          selectedKeyAccessor && field.value
            ? field.value[selectedKeyAccessor] ?? field.value
            : field.value;
        const currentValueString = currentValue
          ? String(currentValue)
          : currentValue;

        console.log(
          currentValue,
          selectedKeyAccessor,
          field.value,
          currentValueString,
          inputValue
        );

        return (
          <Autocomplete
            {...props}
            inputValue={inputValue}
            onInputChange={setInputValue}
            onClear={() => setInputValue('')}
            isInvalid={!!formState.errors?.[name]?.message}
            errorMessage={formState.errors?.[name]?.message?.toString()}
            selectedKey={currentValueString ?? null}
            onClose={() => onOpenChange(true, field)}
            onBlur={(e) => {
              e;
            }}
            onSelectionChange={(value) => {
              console.log('selection change triggered', value);
              const valueOrCustom = value
                ? value
                : props.allowsCustomValue
                  ? inputValue !== ''
                    ? inputValue
                    : null
                  : null;
              if (onChange) {
                onChange(value, field.onChange, setInputValue);
              } else {
                field.onChange(value);
                setInputValue(value ? String(value) : '');
              }
            }}
          >
            {childrenDefined}
          </Autocomplete>
        );
      }}
    ></Controller>
  );
}

export const defaultItemAccessors = {
  valueAccessor: 'id',
  labelAccessor: 'name',
  keyAccessor: 'id'
} as const;

export type SelectableItem = HasId;

export type ItemAccessors<T extends SelectableItem> = {
  valueAccessor: TypedPaths<T, string | number>;
  keyAccessor: TypedPaths<T, string | number>;
  labelAccessor: TypedPaths<T, string | number>;
};

Your Example Website or App

No response

Steps to Reproduce the Bug or Issue

The supplied code renders an Autocomplete component that can be embedded in a form. Attempting to commit a custom value with this version will see the custom value overwritten when the focus is blurred. If this event is blocked (by replacing the value with the valueOrCustom variable in the event handler), then the inputValue can only be cleared via backspace/delete.

Expected behavior

I would expect the API to allow these behaviours to combine. There doesn't seem to be a single source of truth regarding when/how/why selection or inputValue events are triggered; it isn't possible to ascertain from within the onSelectionChange trigger whether a null key is intended, or the side effect of accepting a custom value. If intentional, then the contents should be cleared; the side effect is undesirable in the first place, but without any way to determine that the null IS a side effect, it can't safely be blocked.

Screenshots or Videos

https://github.com/user-attachments/assets/e76d6772-98ee-413e-a946-cc11394be97d

https://github.com/user-attachments/assets/3fe973e2-a991-4fe1-a205-525b40a059c8

Operating System Version

Browser

Chrome

linear[bot] commented 2 months ago

ENG-1281 [BUG] - Autocomplete doesn't combine custom user inputs with Controlled functionality