ConversationMentionList.tsx3.5 KBView on GitHub
'use client';

import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Building2 } from 'lucide-react';
import { cn } from '@/lib/utils';

export interface ConversationMentionItem {
  id: string;
  name: string | null;
  companyName: string | null;
  companyLogoUrl: string | null;
}

interface ConversationMentionListProps {
  items: ConversationMentionItem[];
  command: (item: ConversationMentionItem) => void;
  loading: boolean;
}

export interface ConversationMentionListRef {
  onKeyDown: (data: { event: KeyboardEvent }) => boolean;
}

/**
 * Suggestion popup body for the agenda `@` conversation mention.
 * Renders one row per match — name only, no company sub-label, per design §9a.
 */
export const ConversationMentionList = forwardRef<
  ConversationMentionListRef,
  ConversationMentionListProps
>(({ items, command, loading }, ref) => {
  const [selectedIndex, setSelectedIndex] = useState(0);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    setSelectedIndex(0);
  }, [items]);

  useEffect(() => {
    itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
  }, [selectedIndex]);

  useImperativeHandle(ref, () => ({
    onKeyDown: ({ event }) => {
      if (event.key === 'ArrowUp') {
        setSelectedIndex((i) => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1));
        return true;
      }
      if (event.key === 'ArrowDown') {
        setSelectedIndex((i) => (i + 1) % Math.max(items.length, 1));
        return true;
      }
      if (event.key === 'Enter') {
        const item = items[selectedIndex];
        if (item) {
          command(item);
          return true;
        }
        return false;
      }
      return false;
    },
  }));

  if (loading && items.length === 0) {
    return (
      <div className="text-muted-foreground z-99999 w-64 rounded-xl border border-[#E7E7E7] bg-white p-2 text-xs shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
        Searching…
      </div>
    );
  }

  if (items.length === 0) {
    return (
      <div className="text-muted-foreground z-99999 w-64 rounded-xl border border-[#E7E7E7] bg-white p-2 text-xs shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
        No conversations found
      </div>
    );
  }

  return (
    <div className="z-99999 max-h-64 w-64 overflow-y-auto rounded-xl border border-[#E7E7E7] bg-white p-1 shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
      {items.map((item, index) => (
        <button
          key=[redacted]
          ref={(el) => {
            itemRefs.current[index] = el;
          }}
          onMouseDown={(e) => e.preventDefault()}
          onClick={() => command(item)}
          className={cn(
            'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors',
            index === selectedIndex
              ? 'bg-gray-100 dark:bg-[#252525]'
              : 'hover:bg-gray-50 dark:hover:bg-[#1E1E1E]',
          )}
        >
          {item.companyLogoUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={item.companyLogoUrl}
              alt=""
              className="h-4 w-4 shrink-0 rounded-full object-contain"
            />
          ) : (
            <Building2 className="text-muted-foreground h-4 w-4 shrink-0" />
          )}
          <span className="truncate">{item.name ?? item.companyName ?? 'Conversation'}</span>
        </button>
      ))}
    </div>
  );
});

ConversationMentionList.displayName = 'ConversationMentionList';