<!-- https://flowbite.com/docs/forms/input-field/ -->

<section class="m-8">
  <div>
    <label for="first_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Typeahead</label>
    <input type="text" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="use terms like qui, beat, eos, expl" required>
  </div>

  <ul class="max-w-md space-y-1 text-gray-500 list-disc list-inside dark:text-gray-400" id="results-container"></ul>
</section>
const {
  MonoTypeOperatorFunction,
  Observable,
  debounceTime,
  distinctUntilChanged,
  filter,
  from,
  delay,
  switchMap,
  fromEvent,
  map,
  pipe,
  timestamp,
  pairwise,
  timer,
  OperatorFunction,
  takeUntil,
  shareReplay,
  tap
} = rxjs;

interface ITypeaheadOperatorOptions<Out> {
  /**
   * The minimum length of the allowed search term.
   */
  minLength: number;
  /**
   * The amount of time between key presses before making a request.
   */
  debounceTime: number;
  /**
   * Whether to allow empty string to be treated as a valid search term.
   * Useful for when you want to show defaul results when the user clears the search box
   *
   * @default true
   */
  allowEmptyString?: boolean;

  /**
   * The function that will be called to load the results.
   */
  loadFn: (searchTerm: string) => ObservableInput<Out>;
}

export function typeahead<Out>(
  options: ITypeaheadOperatorOptions<Out>
): OperatorFunction<string, Out> {
  const cache: Record<string, Observable<Out>> = {};
  return (source) => {
    return source.pipe(
      debounceTime(options.debounceTime),
      filter((value) => typeof value === "string"),
      filter((value) => {
        if (value === "") {
          return options.allowEmptyString ?? true;
        }
        return value.length >= options.minLength;
      }),
      distinctUntilChanged(),
      // switchMap((searchTerm) => options.loadFn(searchTerm))
      switchMap((searchTerm) => {
        if (!cache[searchTerm]) {
          // Initialize Observable in cache if it doesn't exist
          cache[searchTerm] = from(options.loadFn(searchTerm)).pipe(
            takeUntil(timer(5000)),
            shareReplay(),
          );
        }

        // Return the cached observable
        return cache[searchTerm];
      })
    );
  };
}

const inputEl = document.getElementsByTagName("input").item(0);
const stream$ = fromEvent(inputEl, "input").pipe(map(() => inputEl.value));

const search$ = stream$.pipe(
  typeahead({
    debounceTime: 300,
    minLength: 3,
    loadFn: (searchTerm) =>
      fetch(
        `https://jsonplaceholder.typicode.com/posts?title_like=^${searchTerm}`
      ).then((response) => response.json())
  })
);

const resultsContainerEl = document.getElementById("results-container");
search$.subscribe((results) => {
  console.log(results);
  resultsContainerEl.innerHTML = results
    .map((result) => `<li>${result.title}</li>`)
    .join("");
});
View Compiled
Run Pen

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.8.1/rxjs.umd.min.js