{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-templates",
  "type": "registry:component",
  "title": "Prompt Templates",
  "description": "Searchable prompt template gallery with category filter, variable fill-in form, and onSelect.",
  "dependencies": [
    "@vllnt/ui@^0.2.1",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/prompt-templates/prompt-templates.tsx",
      "content": "\"use client\";\n\nimport {\n  type ChangeEvent,\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { Search, Sparkles } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\nimport { Input } from \"@vllnt/ui\";\n\nconst VARIABLE_PATTERN = /{{\\s*([\\w-]+)\\s*}}/g;\nconst ALL_CATEGORY_VALUE = \"__all__\";\n\n/**\n * One prompt template entry.\n *\n * @public\n */\nexport type PromptTemplate = {\n  /** Optional category (matched against {@link PromptTemplateCategory.name}). */\n  category?: string;\n  /** Sub-headline shown under the title. */\n  description?: ReactNode;\n  /** Stable identifier. */\n  id: string;\n  /**\n   * Raw template body with `{{variable}}` placeholders. Placeholders are\n   * detected automatically; the explicit `variables` array overrides\n   * detection (useful when the same placeholder appears more than once).\n   */\n  template: string;\n  /** Display title. */\n  title: ReactNode;\n  /** Override for detected variable names. */\n  variables?: string[];\n};\n\n/**\n * Category chip for filtering.\n *\n * @public\n */\nexport type PromptTemplateCategory = {\n  /** Optional icon rendered next to the name. */\n  icon?: ReactNode;\n  /** Category display name (matched against {@link PromptTemplate.category}). */\n  name: string;\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type PromptTemplatesLabels = {\n  /** Caption for the all-categories chip. Defaults to `\"All\"`. */\n  allCategory?: string;\n  /** Caption for the cancel button on the variable form. Defaults to `\"Cancel\"`. */\n  cancel?: string;\n  /** Empty-state heading when no templates match. Defaults to `\"No prompts found.\"`. */\n  empty?: string;\n  /** Caption for the use button when filling in variables. Defaults to `\"Insert\"`. */\n  insert?: string;\n  /** Search input placeholder. Defaults to `\"Search prompts…\"`. */\n  searchPlaceholder?: string;\n  /** Caption for the use button on a card. Defaults to `\"Use template\"`. */\n  use?: string;\n  /** Caption above the variable form. Defaults to `\"Fill in the placeholders\"`. */\n  variablesHeading?: string;\n};\n\nconst DEFAULT_LABELS = {\n  allCategory: \"All\",\n  cancel: \"Cancel\",\n  empty: \"No prompts found.\",\n  insert: \"Insert\",\n  searchPlaceholder: \"Search prompts…\",\n  use: \"Use template\",\n  variablesHeading: \"Fill in the placeholders\",\n} as const satisfies Required<PromptTemplatesLabels>;\n\n/**\n * Props for {@link PromptTemplates}.\n *\n * @public\n */\nexport type PromptTemplatesProps = {\n  /** Optional list of categories to render as filter chips. */\n  categories?: PromptTemplateCategory[];\n  /** Localizable strings. */\n  labels?: PromptTemplatesLabels;\n  /**\n   * Fires with the resolved template body. When the template has variables,\n   * the user fills them in first; otherwise this fires on click.\n   */\n  onSelect?: (resolved: string, template: PromptTemplate) => void;\n  /** The template list. */\n  templates: PromptTemplate[];\n} & ComponentPropsWithoutRef<\"section\">;\n\nfunction detectVariables(template: PromptTemplate): string[] {\n  if (template.variables) return template.variables;\n  const matches = [...template.template.matchAll(VARIABLE_PATTERN)];\n  const seen = new Set<string>();\n  return matches.reduce<string[]>((accumulator, match) => {\n    const name = match[1];\n    if (name && !seen.has(name)) {\n      seen.add(name);\n      accumulator.push(name);\n    }\n    return accumulator;\n  }, []);\n}\n\nfunction fillTemplate(\n  template: string,\n  values: Record<string, string>,\n): string {\n  return template.replaceAll(\n    VARIABLE_PATTERN,\n    (_match: string, name: string) => values[name] ?? `{{${name}}}`,\n  );\n}\n\nfunction matchesCategory(template: PromptTemplate, selected: string): boolean {\n  if (selected === ALL_CATEGORY_VALUE) return true;\n  return template.category === selected;\n}\n\nfunction matchesQuery(template: PromptTemplate, query: string): boolean {\n  if (!query) return true;\n  const lowered = query.toLowerCase();\n  const title =\n    typeof template.title === \"string\" ? template.title.toLowerCase() : \"\";\n  const description =\n    typeof template.description === \"string\"\n      ? template.description.toLowerCase()\n      : \"\";\n  return title.includes(lowered) || description.includes(lowered);\n}\n\ntype FilterBarProps = {\n  categories: PromptTemplateCategory[];\n  labels: Required<PromptTemplatesLabels>;\n  onCategoryChange: (value: string) => void;\n  onQueryChange: (value: string) => void;\n  query: string;\n  searchId: string;\n  selectedCategory: string;\n};\n\nfunction FilterBar({\n  categories,\n  labels,\n  onCategoryChange,\n  onQueryChange,\n  query,\n  searchId,\n  selectedCategory,\n}: FilterBarProps): ReactNode {\n  const handleSearch = (event: ChangeEvent<HTMLInputElement>): void => {\n    onQueryChange(event.target.value);\n  };\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"relative\">\n        <Search\n          aria-hidden=\"true\"\n          className=\"absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\"\n        />\n        <Input\n          aria-label={labels.searchPlaceholder}\n          className=\"pl-9\"\n          id={searchId}\n          onChange={handleSearch}\n          placeholder={labels.searchPlaceholder}\n          type=\"search\"\n          value={query}\n        />\n      </div>\n      {categories.length > 0 ? (\n        <div className=\"flex flex-wrap gap-1.5\" role=\"tablist\">\n          <CategoryChip\n            active={selectedCategory === ALL_CATEGORY_VALUE}\n            label={labels.allCategory}\n            onClick={() => {\n              onCategoryChange(ALL_CATEGORY_VALUE);\n            }}\n            value={ALL_CATEGORY_VALUE}\n          />\n          {categories.map((category) => (\n            <CategoryChip\n              active={selectedCategory === category.name}\n              icon={category.icon}\n              key={category.name}\n              label={category.name}\n              onClick={() => {\n                onCategoryChange(category.name);\n              }}\n              value={category.name}\n            />\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\ntype CategoryChipProps = {\n  active: boolean;\n  icon?: ReactNode;\n  label: string;\n  onClick: () => void;\n  value: string;\n};\n\nfunction CategoryChip({\n  active,\n  icon,\n  label,\n  onClick,\n  value,\n}: CategoryChipProps): ReactNode {\n  return (\n    <button\n      aria-selected={active}\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        active\n          ? \"border-primary bg-primary text-primary-foreground\"\n          : \"border-border bg-background text-foreground hover:bg-accent\",\n      )}\n      data-value={value}\n      onClick={onClick}\n      role=\"tab\"\n      type=\"button\"\n    >\n      {icon ? (\n        <span aria-hidden=\"true\" className=\"[&>svg]:h-3.5 [&>svg]:w-3.5\">\n          {icon}\n        </span>\n      ) : null}\n      {label}\n    </button>\n  );\n}\n\ntype CardProps = {\n  active: boolean;\n  labels: Required<PromptTemplatesLabels>;\n  onActivate: (template: PromptTemplate) => void;\n  onCancel: () => void;\n  onResolve: (resolved: string, template: PromptTemplate) => void;\n  onValueChange: (name: string, value: string) => void;\n  template: PromptTemplate;\n  values: Record<string, string>;\n};\n\ntype CardHeaderProps = {\n  category?: string;\n  description?: ReactNode;\n  title: ReactNode;\n};\n\nfunction CardHeader({\n  category,\n  description,\n  title,\n}: CardHeaderProps): ReactNode {\n  return (\n    <header className=\"flex flex-col gap-1\">\n      <h4 className=\"text-sm font-semibold tracking-tight text-foreground\">\n        {title}\n      </h4>\n      {description ? (\n        <p className=\"text-xs text-muted-foreground\">{description}</p>\n      ) : null}\n      {category ? (\n        <Badge className=\"self-start\" variant=\"outline\">\n          {category}\n        </Badge>\n      ) : null}\n    </header>\n  );\n}\n\ntype CardActionsProps = {\n  onUse: () => void;\n  useLabel: string;\n  variableCount: number;\n};\n\nfunction CardActions({\n  onUse,\n  useLabel,\n  variableCount,\n}: CardActionsProps): ReactNode {\n  return (\n    <div className=\"flex items-center justify-between gap-2\">\n      {variableCount > 0 ? (\n        <span className=\"text-xs text-muted-foreground\">\n          {variableCount.toString()} variable{variableCount === 1 ? \"\" : \"s\"}\n        </span>\n      ) : (\n        <span aria-hidden=\"true\" />\n      )}\n      <Button\n        onClick={onUse}\n        size=\"sm\"\n        type=\"button\"\n        variant={variableCount > 0 ? \"outline\" : \"default\"}\n      >\n        <Sparkles aria-hidden=\"true\" className=\"mr-2 size-3.5\" />\n        {useLabel}\n      </Button>\n    </div>\n  );\n}\n\nfunction PromptTemplateCard({\n  active,\n  labels,\n  onActivate,\n  onCancel,\n  onResolve,\n  onValueChange,\n  template,\n  values,\n}: CardProps): ReactNode {\n  const variables = detectVariables(template);\n  const handleUse = useCallback(() => {\n    if (variables.length === 0) {\n      onResolve(template.template, template);\n      return;\n    }\n    if (active) {\n      onResolve(fillTemplate(template.template, values), template);\n      return;\n    }\n    onActivate(template);\n  }, [active, onActivate, onResolve, template, values, variables.length]);\n\n  return (\n    <article\n      className=\"flex flex-col gap-3 rounded-xl border border-border bg-background p-4 shadow-sm\"\n      data-template-id={template.id}\n    >\n      <CardHeader\n        category={template.category}\n        description={template.description}\n        title={template.title}\n      />\n      {active && variables.length > 0 ? (\n        <VariableForm\n          fieldIdPrefix={template.id}\n          labels={labels}\n          onCancel={onCancel}\n          onSubmit={handleUse}\n          onValueChange={onValueChange}\n          values={values}\n          variables={variables}\n        />\n      ) : (\n        <CardActions\n          onUse={handleUse}\n          useLabel={labels.use}\n          variableCount={variables.length}\n        />\n      )}\n    </article>\n  );\n}\n\ntype VariableFormProps = {\n  fieldIdPrefix: string;\n  labels: Required<PromptTemplatesLabels>;\n  onCancel: () => void;\n  onSubmit: () => void;\n  onValueChange: (name: string, value: string) => void;\n  values: Record<string, string>;\n  variables: string[];\n};\n\ntype VariableFieldProps = {\n  fieldId: string;\n  name: string;\n  onValueChange: (name: string, value: string) => void;\n  value: string;\n};\n\nfunction VariableField({\n  fieldId,\n  name,\n  onValueChange,\n  value,\n}: VariableFieldProps): ReactNode {\n  const handleChange = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      onValueChange(name, event.target.value);\n    },\n    [name, onValueChange],\n  );\n  return (\n    <div className=\"flex flex-col gap-1 text-xs\">\n      <label className=\"font-medium text-foreground\" htmlFor={fieldId}>\n        {name}\n      </label>\n      <Input\n        id={fieldId}\n        onChange={handleChange}\n        placeholder={`Value for ${name}`}\n        value={value}\n      />\n    </div>\n  );\n}\n\nfunction VariableForm({\n  fieldIdPrefix,\n  labels,\n  onCancel,\n  onSubmit,\n  onValueChange,\n  values,\n  variables,\n}: VariableFormProps): ReactNode {\n  return (\n    <div className=\"flex flex-col gap-2 rounded-lg border border-dashed border-border bg-muted/30 p-3\">\n      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n        {labels.variablesHeading}\n      </p>\n      <div className=\"flex flex-col gap-2\">\n        {variables.map((name) => (\n          <VariableField\n            fieldId={`${fieldIdPrefix}-${name}`}\n            key={name}\n            name={name}\n            onValueChange={onValueChange}\n            value={values[name] ?? \"\"}\n          />\n        ))}\n      </div>\n      <div className=\"mt-1 flex justify-end gap-2\">\n        <Button onClick={onCancel} size=\"sm\" type=\"button\" variant=\"ghost\">\n          {labels.cancel}\n        </Button>\n        <Button onClick={onSubmit} size=\"sm\" type=\"button\">\n          {labels.insert}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Library / gallery of saved prompt templates with search, category filter,\n * and a built-in fill-in form for `{{variable}}` placeholders. Composes\n * existing {@link Input}, {@link Button}, and {@link Badge} primitives.\n *\n * @example\n * ```tsx\n * <PromptTemplates\n *   templates={[\n *     {\n *       id: \"code-review\",\n *       title: \"Code Review\",\n *       description: \"Review code for bugs and improvements\",\n *       template: \"Review this {{language}} code:\\n\\n{{code}}\",\n *       category: \"Code\",\n *     },\n *   ]}\n *   categories={[{ name: \"Code\" }, { name: \"Writing\" }]}\n *   onSelect={(resolved) => insertIntoComposer(resolved)}\n * />\n * ```\n *\n * @public\n */\ntype ControllerState = {\n  activeId: null | string;\n  filtered: PromptTemplate[];\n  handleActivate: (template: PromptTemplate) => void;\n  handleCancel: () => void;\n  handleCategoryChange: (value: string) => void;\n  handleQueryChange: (value: string) => void;\n  handleResolve: (resolved: string, template: PromptTemplate) => void;\n  handleValueChange: (name: string, value: string) => void;\n  query: string;\n  searchId: string;\n  selectedCategory: string;\n  values: Record<string, string>;\n};\n\nfunction usePromptTemplatesController(\n  templates: PromptTemplate[],\n  onSelect: PromptTemplatesProps[\"onSelect\"],\n): ControllerState {\n  const searchId = useId();\n  const [query, setQuery] = useState(\"\");\n  const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY_VALUE);\n  const [activeId, setActiveId] = useState<null | string>(null);\n  const [values, setValues] = useState<Record<string, string>>({});\n\n  const filtered = useMemo(\n    () =>\n      templates.filter(\n        (template) =>\n          matchesCategory(template, selectedCategory) &&\n          matchesQuery(template, query),\n      ),\n    [query, selectedCategory, templates],\n  );\n\n  const handleActivate = useCallback((template: PromptTemplate) => {\n    setActiveId(template.id);\n    setValues({});\n  }, []);\n\n  const handleCancel = useCallback(() => {\n    setActiveId(null);\n    setValues({});\n  }, []);\n\n  const handleResolve = useCallback(\n    (resolved: string, template: PromptTemplate) => {\n      onSelect?.(resolved, template);\n      setActiveId(null);\n      setValues({});\n    },\n    [onSelect],\n  );\n\n  const handleValueChange = useCallback((name: string, value: string) => {\n    setValues((current) => ({ ...current, [name]: value }));\n  }, []);\n\n  return {\n    activeId,\n    filtered,\n    handleActivate,\n    handleCancel,\n    handleCategoryChange: setSelectedCategory,\n    handleQueryChange: setQuery,\n    handleResolve,\n    handleValueChange,\n    query,\n    searchId,\n    selectedCategory,\n    values,\n  };\n}\n\ntype GridProps = {\n  controller: ControllerState;\n  labels: Required<PromptTemplatesLabels>;\n};\n\nfunction TemplateGrid({ controller, labels }: GridProps): ReactNode {\n  if (controller.filtered.length === 0) {\n    return (\n      <p className=\"rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground\">\n        {labels.empty}\n      </p>\n    );\n  }\n  return (\n    <div\n      className=\"grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3\"\n      role=\"tabpanel\"\n    >\n      {controller.filtered.map((template) => (\n        <PromptTemplateCard\n          active={controller.activeId === template.id}\n          key={template.id}\n          labels={labels}\n          onActivate={controller.handleActivate}\n          onCancel={controller.handleCancel}\n          onResolve={controller.handleResolve}\n          onValueChange={controller.handleValueChange}\n          template={template}\n          values={controller.values}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport const PromptTemplates = forwardRef<HTMLElement, PromptTemplatesProps>(\n  (props, ref) => {\n    const { categories, className, labels, onSelect, templates, ...rest } =\n      props;\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n    const controller = usePromptTemplatesController(templates, onSelect);\n\n    return (\n      <section\n        className={cn(\n          \"flex flex-col gap-4 rounded-2xl border bg-background p-4\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <FilterBar\n          categories={categories ?? []}\n          labels={resolvedLabels}\n          onCategoryChange={controller.handleCategoryChange}\n          onQueryChange={controller.handleQueryChange}\n          query={controller.query}\n          searchId={controller.searchId}\n          selectedCategory={controller.selectedCategory}\n        />\n        <TemplateGrid controller={controller} labels={resolvedLabels} />\n      </section>\n    );\n  },\n);\nPromptTemplates.displayName = \"PromptTemplates\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
