dcramer / peated

https://peated.com
Apache License 2.0
63 stars 13 forks source link

Improvements to the search experience #219

Open leerob opened 2 months ago

leerob commented 2 months ago

👋 Saw your X post and wanted to share some suggestions for improving search.

Current State

Based on this commit, it looks like you currently:

  1. Have various places that are passing functions of props into components related to search
  2. Kick off a search by setting and then updating local react state
  3. This calls tRPC to fetch new data based on the search value
  4. A callback function eventually calls router.push to update the URL

There's an opportunity to simplify and consolidate this logic, while improving the UX. Right now, you're mostly using client components and tRPC. While there's nothing wrong with this, you could centralize searching into one component, which triggers a server component to fetch new data when the URL state changes.

Desired State

I believe the UX you want is:

Proposed Solution

Here's one option. It's using a new feature of Next.js (on canary) but it's not required (you could use a normal <form>).

  1. The <Search> component gets the initial search value from searchParams from a Server Component above
  2. This value is stored into local state, as well as a deferred value
  3. When there are changes in the input, the local state is updated and the form is submitted
  4. Submitting the form updates the URL state with the ?search= query parameter (replaced, not pushed onto the stack)
  5. This fires a React transition to the new URL, which means useFormStatus is pending
  6. This now shows a loading spinner inline for the search input, but also adds an attribute to the DOM element
  7. In other parts of our codebase, we can use CSS to look for that element and conditionally add an animation
  8. This also includes a useEffect to focus the input on mount (optional)
'use client';

import { useEffect, useRef, useState, useDeferredValue } from 'react';
import { useFormStatus } from 'react-dom';
import Form from 'next/form';
import { SearchIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';

export function Search({ query: initialQuery }: { query: string }) {
  let [query, setQuery] = useState(initialQuery);
  let deferredQuery = useDeferredValue(query);
  let inputRef = useRef<HTMLInputElement>(null);
  let formRef = useRef<HTMLFormElement>(null);
  let isStale = query !== deferredQuery;

  useEffect(() => {
    if (inputRef.current && document.activeElement !== inputRef.current) {
      inputRef.current.focus();
      inputRef.current.setSelectionRange(
        inputRef.current.value.length,
        inputRef.current.value.length
      );
    }
  }, []);

  function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
    formRef.current?.requestSubmit();
  }

  return (
    <Form
      ref={formRef}
      action="/"
      replace
      className="relative flex flex-1 flex-shrink-0 w-full"
    >
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
      <Input
        ref={inputRef}
        onChange={handleInputChange}
        type="text"
        name="search"
        id="search"
        placeholder="Search..."
        value={query}
        className="w-full rounded-none border-0 px-10 py-6 m-1 focus-visible:ring-0 text-base md:text-sm"
      />
      <LoadingIcon isStale={isStale} />
    </Form>
  );
}

function LoadingIcon({ isStale }: { isStale: boolean }) {
  let { pending } = useFormStatus();
  let loading = pending || isStale;

  return loading ? (
    <div
      data-pending={loading ? '' : undefined}
      className="absolute right-3 top-1/2 -translate-y-1/2"
    >
      <div
        className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"
        role="status"
      >
        <span className="sr-only">Loading...</span>
      </div>
    </div>
  ) : null;
}

This likely isn't perfect yet but it's closer in the direction you're looking for. Happy to help out. I'm trying to build a similar example of searching here: https://next-books-search.vercel.app