Ctrl K
ComunidadGuías…

lib/command-palette/CommandPalette.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * CommandPalette - Main shell.
 *
 * A light view router over:
 * - SearchView: search, results, actions popover
 * - FormView: inline forms for actions that need input
 * - Custom views: host-provided views (e.g. an AI chat), reachable via Tab
 *
 * All the heavy logic lives in the child views and hooks.
 */

"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useState,
  type ReactNode,
} from "react";
import { useMobileLayout } from "@/lib/use-mobile-layout";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { DismissableLayer, FocusScope } from "radix-ui/internal";
import { PaletteConfigProvider, buildCategoryOrder, type ResolvedPaletteConfig } from "./config";
import { createTranslate, type PaletteStrings } from "./i18n";
import { CheckIcon } from "./icons";
import { createActionRegistry } from "./registry/ActionRegistry";
import { ItemActionsProvider } from "./registry/providers/ItemActionsProvider";
import {
  configureSearchStorage,
  recordUsage,
} from "./search/engine";
import { usePaletteStore } from "./store";
import { useQueryHistory } from "./hooks/useQueryHistory";
import { listenToNewKeydowns } from "./hooks/new-keydowns";
import styles from "./styles/CommandPalette.module.css";
import { FormView } from "./views/FormView";
import { SearchView } from "./views/SearchView";
import type { ActionExecutionContext, ActionProvider } from "./registry/types";
import type {
  CategoryDefinition,
  PaletteItem,
  PaletteViewRenderer,
} from "./types";

// =============================================================================
// PROPS
// =============================================================================

export interface CommandPaletteProps {
  /** Whether the palette is open. */
  isOpen: boolean;
  /** Callback when the palette should close. */
  onClose: () => void;
  /** All available items. */
  items: PaletteItem[];
  /**
   * Results found outside the text engine (e.g. by meaning). Appended after
   * the ordered results, in their own category, while a query is typed: the
   * existing rows keep their position, so the active row never jumps. Items
   * already in the results (same id) are skipped. `categories` restricts the
   * filter tabs they appear under (besides "all"). `status` is shown under
   * the search bar when nothing else matches: they are still coming, or none
   * came.
   */
  supplemental?: { items: PaletteItem[]; categories?: string[]; status?: ReactNode };

  // === Behavior ===
  /** Action providers supplying contextual actions per entity type. */
  providers?: ActionProvider[];
  /**
   * Host data/callbacks exposed to actions via ctx.meta
   * (+ optional navigate/showToast overrides below).
   */
  actionContext?: Record<string, unknown>;
  /** Navigate callback exposed to actions (e.g. router.push). */
  onNavigate?: (path: string) => void;
  /** Toast callback exposed to actions. */
  onToast?: (message: string, type: "success" | "error" | "info") => void;
  /** Override item selection entirely (skips the action registry). */
  onSelectItem?: (item: PaletteItem) => void;
  /** Called for the highlighted/hovered item (host-side destination prefetch). */
  onHoverPrefetch?: (item: PaletteItem) => void;

  // === Configuration ===
  /** Locale ("en", "fr", "fr-FR"…). Default "en". */
  locale?: string;
  /** Override any built-in UI string. */
  strings?: PaletteStrings;
  /** Category definitions (tabs order, labels, boosts). */
  categories?: CategoryDefinition[];
  /** localStorage key prefix for usage/history. Default "command-palette". */
  storagePrefix?: string;
  /** Actions popover shortcut key (with ⌘/Ctrl). Default "m". */
  actionsShortcutKey?: string;
  /** Compact mode: shows only the search bar until the user types. */
  compactMode?: boolean;
  /** Enable query history recall (ArrowUp). Default true. */
  history?: boolean;
  /**
   * Quick-AI affordance at the right of the search input (icon + Tab kbd).
   * Empty query → onSelect opens the AI surface; filled query → it auto-sends
   * the typed text.
   */
  quickAi?: { icon: ReactNode; onSelect: (query: string) => void };

  // === View extension ===
  /** Custom views, keyed by view id. */
  views?: Record<string, PaletteViewRenderer>;
  /**
   * Custom view entered when pressing Tab in the search field
   * (e.g. an AI chat view). Must exist in `views`.
   */
  tabView?: { view: string; hint: string };

  // === Misc ===
  /** Initial query when opening. */
  initialQuery?: string;
  /** Initial view when opening. */
  initialView?: string;
  /** Show the success indicator overlay. */
  isSuccess?: boolean;
  /** Called when a form action completes successfully. */
  onFormSuccess?: () => void;
}

// =============================================================================
// COMPONENT
// =============================================================================

/** Matches `paletteSlideOut` in CommandPalette.module.css. */
const EXIT_MS = 120;
/**
 * On mobile the palette opens on its search field with the keyboard up:
 * searching is what it is opened for. Other sheets keep the keyboard down
 * until a tap (lib/mobile-sheet-focus.ts); the search tap primes it.
 */
function focusSearchField(event: Event) {
  if (!(event.target instanceof HTMLElement)) return;
  const field = event.target.querySelector<HTMLInputElement>("input[data-palette-search]");
  if (!field) return;
  event.preventDefault();
  field.focus({ preventScroll: true });
}

/** Covers the mobile sheet's own close transition. */
const MOBILE_EXIT_MS = 300;

export function CommandPalette({
  isOpen,
  supplemental,
  onClose,
  items,
  providers,
  actionContext: actionContextMeta,
  onNavigate,
  onToast,
  onSelectItem,
  onHoverPrefetch,
  locale = "en",
  strings,
  categories = [],
  storagePrefix = "command-palette",
  actionsShortcutKey = "m",
  compactMode = false,
  history = true,
  quickAi,
  views,
  tabView,
  initialQuery = "",
  initialView = "search",
  isSuccess = false,
  onFormSuccess,
}: CommandPaletteProps) {
  const mobile = useMobileLayout() === true;
  const view = usePaletteStore((s) => s.view);
  const isActionsPopoverOpen = usePaletteStore((s) => s.isActionsPopoverOpen);
  const setView = usePaletteStore((s) => s.setView);
  const setQuery = usePaletteStore((s) => s.setQuery);
  const reset = usePaletteStore((s) => s.reset);

  // Query captured when entering the tab view (via Tab)
  const [tabViewQuery, setTabViewQuery] = useState("");

  // Configure the storage prefix once per change
  useEffect(() => {
    configureSearchStorage(storagePrefix);
  }, [storagePrefix]);

  // === Translate function ===
  const t = useMemo(() => createTranslate(locale, strings), [locale, strings]);

  // === Registry ===
  const registry = useMemo(() => {
    const reg = createActionRegistry();
    reg.register(ItemActionsProvider);
    for (const provider of providers ?? []) {
      reg.register(provider);
    }
    return reg;
  }, [providers]);

  // === Resolved config (context value) ===
  const config = useMemo<ResolvedPaletteConfig>(
    () => ({
      t,
      locale,
      categories,
      categoryOrder: buildCategoryOrder(categories),
      registry,
      shortcuts: { actionsKey: actionsShortcutKey.toLowerCase() },
    }),
    [t, locale, categories, registry, actionsShortcutKey]
  );

  // === Query history ===
  const queryHistory = useQueryHistory(storagePrefix);

  // === Exit animation ===
  // Closing keeps the last frame mounted for EXIT_MS so it can animate out;
  // the store is reset once it is gone, so the content does not flash back
  // to an empty search while it fades.
  const [closing, setClosing] = useState(false);
  const [wasOpen, setWasOpen] = useState(isOpen);
  if (isOpen !== wasOpen) {
    setWasOpen(isOpen);
    setClosing(!isOpen);
  }
  useEffect(() => {
    if (!closing) return;
    const timer = window.setTimeout(() => {
      setClosing(false);
      reset();
    }, mobile ? MOBILE_EXIT_MS : EXIT_MS);
    return () => window.clearTimeout(timer);
  }, [closing, mobile, reset]);

  // While it fades out, the view is still mounted with its document keydown
  // handlers: a capture listener swallows keys so Enter cannot run a result
  // the user just dismissed.
  useEffect(() => {
    if (!closing) return;
    const swallow = (event: KeyboardEvent) => event.stopImmediatePropagation();
    document.addEventListener("keydown", swallow, true);
    return () => document.removeEventListener("keydown", swallow, true);
  }, [closing]);

  // === Action execution context ===
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  const execContext = useMemo<ActionExecutionContext>(
    () => ({
      locale,
      translate: t,
      closeMenu: handleClose,
      navigate: onNavigate,
      showToast: onToast,
      meta: actionContextMeta ?? {},
    }),
    [locale, t, handleClose, onNavigate, onToast, actionContextMeta]
  );

  // Reset state when opening
  useEffect(() => {
    if (isOpen) {
      reset();
      setQuery(initialQuery);
      if (initialView !== "search") {
        setView(initialView);
      }
    }
  }, [isOpen, initialQuery, initialView, reset, setQuery, setView]);

  // Click outside closes
  const handleOverlayClick = useCallback(
    (e: React.MouseEvent) => {
      if (e.target === e.currentTarget) {
        handleClose();
      }
    },
    [handleClose]
  );

  // Escape closes (from the search view; other views handle their own Escape).
  // Skipped while the actions popover is open — it closes itself and
  // stopPropagation cannot shield same-node document listeners.
  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape" && view === "search" && !isActionsPopoverOpen) {
        handleClose();
      }
    };

    return listenToNewKeydowns(document, handleKeyDown);
  }, [isOpen, view, isActionsPopoverOpen, handleClose]);

  // Tab in the search field enters the tab view with the current query
  const handleEnterTabView = useCallback(
    (query: string) => {
      if (!tabView) return;
      setTabViewQuery(query);
      setQuery("");
      setView(tabView.view);
    },
    [tabView, setQuery, setView]
  );

  // Record usage on selection for relevance boosting, then delegate
  const handleSelectWithUsage = useMemo(() => {
    if (!onSelectItem) return undefined;
    return (item: PaletteItem) => {
      recordUsage(item.id);
      onSelectItem(item);
    };
  }, [onSelectItem]);

  // Don't render if not open, past the exit animation
  if (!isOpen && !closing) return null;

  // Render current view
  const renderView = () => {
    if (view === "form") {
      return (
        <FormView
          actionContext={execContext}
          onSuccess={onFormSuccess ?? handleClose}
          onCancel={() => setView("search")}
        />
      );
    }

    if (view !== "search") {
      const renderer = views?.[view];
      if (renderer) {
        return renderer({
          initialQuery: tabViewQuery,
          backToSearch: () => {
            setTabViewQuery("");
            setView("search");
          },
          closePalette: handleClose,
        });
      }
      // Unknown view: fall back to search
    }

    return (
      <SearchView
        items={items}
        supplemental={supplemental}
        actionContext={execContext}
        onClose={handleClose}
        onSelectItem={handleSelectWithUsage}
        onEnterTabView={tabView ? handleEnterTabView : undefined}
        tabHint={tabView?.hint}
        quickAi={quickAi}
        compactMode={compactMode}
        historyIndex={history ? queryHistory.historyIndex : -1}
        onHistoryNavigate={history ? queryHistory.navigate : undefined}
        onHistoryReset={history ? queryHistory.reset : undefined}
        onQuerySubmit={history ? queryHistory.submit : undefined}
        onHoverPrefetch={onHoverPrefetch}
      />
    );
  };

  if (mobile) return <PaletteConfigProvider value={config}>
    <Dialog open={isOpen} onOpenChange={(next) => { if (!next) handleClose(); }}>
      <DialogContent className="mobile-command-palette" aria-describedby={undefined} onOpenAutoFocus={focusSearchField}>
        <DialogTitle className="sr-only">{t("search.placeholder")}</DialogTitle>
        {isSuccess ? <div className="flex min-h-32 items-center justify-center"><CheckIcon /></div> : renderView()}
      </DialogContent>
    </Dialog>
  </PaletteConfigProvider>;

  return (
    <PaletteConfigProvider value={config}>
      {/* `desktop-palette-overlay` is a GLOBAL class, not a module: it is
 app/globals.css which reads it, to dig the sliding region of the
 window above this veil (MIN-308). Without it, "click next to
 to close" didn't close anything in the top 60 px — macOS swallowed
 the click and the window started sliding. */}
      <div
        className={`${styles.overlay} desktop-palette-overlay ${closing ? styles.overlayClosing : ""}`}
        onClick={closing ? undefined : handleOverlayClick}
        inert={closing}
      >
        {/* Above a modal dialog (the issue panel, a creation form), the
            palette must be its top layer (MIN-844). Its focus scope pauses
            that dialog's trap, which would otherwise pull the focus back from
            the search field; it is not trapped itself, as the actions popover
            lives in a portal. The search bar focuses itself, and closing
            leaves the focus to whatever the chosen row opened, as before. As
            the top dismissable layer, it keeps Escape from also closing the
            dialog: the palette's own keys still handle it. */}
        <FocusScope.Root
          asChild
          onMountAutoFocus={(event) => event.preventDefault()}
          onUnmountAutoFocus={(event) => event.preventDefault()}
        >
        <DismissableLayer.Root asChild>
        <div
          className={`desktop-palette-surface ${styles.palette} ${isSuccess ? styles.paletteSuccess : ""} ${closing ? styles.paletteClosing : ""}`}
          role="dialog"
          aria-modal="true"
          aria-label={t("search.ariaLabel")}
        >
          {/* Success indicator overlay */}
          {isSuccess && (
            <div className={styles.successOverlay}>
              <div className={styles.successIcon}>
                <CheckIcon className={styles.successCheckIcon} />
              </div>
            </div>
          )}

          {/* Hide content during success animation */}
          {!isSuccess && renderView()}
        </div>
        </DismissableLayer.Root>
        </FocusScope.Root>
      </div>
    </PaletteConfigProvider>
  );
}