Skip to content

Formula Editor

Install this component from the Scintillar registry.

Formula

=add({Price}, {Tax})

Result

115

Installation

npx shadcn@latest add @scintillar/formula-editor

Source

"use client"

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { FORMULA_FUNCTIONS, FUNC_NAMES, type FormulaFunction } from "@/lib/formula"

// ── Type checking ───────────────────────────────────────────────

type ValType = "number" | "string" | "boolean" | "date"

function propertyTypeToValType(propType: string): ValType {
  switch (propType) {
    case "number": case "formula": return "number"
    case "date": return "date"
    case "checkbox": return "boolean"
    default: return "string"
  }
}

interface FuncSpec {
  minArgs: number
  maxArgs?: number
  argTypes?: ValType[][]
  returns: ValType
}

const FUNC_SPECS: Record<string, FuncSpec> = {
  add:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "number" },
  sub:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "number" },
  mul:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "number" },
  div:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "number" },
  concat: { minArgs: 1, returns: "string" },
  if:     { minArgs: 3, maxArgs: 3, returns: "string" },
  eq:     { minArgs: 2, maxArgs: 2, returns: "boolean" },
  neq:    { minArgs: 2, maxArgs: 2, returns: "boolean" },
  gt:     { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "boolean" },
  lt:     { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "boolean" },
  gte:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "boolean" },
  lte:    { minArgs: 2, maxArgs: 2, argTypes: [["number"], ["number"]], returns: "boolean" },
  year:   { minArgs: 1, maxArgs: 1, argTypes: [["date"]], returns: "number" },
  month:  { minArgs: 1, maxArgs: 1, argTypes: [["date"]], returns: "number" },
  day:    { minArgs: 1, maxArgs: 1, argTypes: [["date"]], returns: "number" },
  hour:   { minArgs: 1, maxArgs: 1, argTypes: [["date"]], returns: "number" },
}

function inferArgType(arg: string, fieldTypes: Record<string, string>): ValType | null {
  const t = arg.trim()
  if (!t) return null
  const fieldMatch = t.match(/^\{([^}]+)\}$/)
  if (fieldMatch) {
    const propType = fieldTypes[fieldMatch[1]]
    return propType ? propertyTypeToValType(propType) : null
  }
  const fnMatch = t.match(/^(\w+)\(/)
  if (fnMatch) {
    const spec = FUNC_SPECS[fnMatch[1].toLowerCase()]
    return spec?.returns ?? null
  }
  if (t === "true" || t === "false") return "boolean"
  if (/^-?\d+(\.\d+)?$/.test(t)) return "number"
  if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) return "string"
  return null
}

const TYPE_LABELS: Record<ValType, string> = { number: "number", string: "text", boolean: "boolean", date: "date" }

// ── Validation ──────────────────────────────────────────────────

interface FormulaDiagnostic {
  level: "error" | "warning"
  message: string
}

function splitArgs(argsStr: string): string[] {
  const args: string[] = []
  let depth = 0
  let current = ""
  for (const ch of argsStr) {
    if (ch === "(") depth++
    else if (ch === ")") depth--
    else if (ch === "," && depth === 0) { args.push(current); current = ""; continue }
    current += ch
  }
  if (current.trim()) args.push(current)
  return args
}

function extractFunctionCalls(expr: string): { name: string; args: string[]; argCount: number }[] {
  const results: { name: string; args: string[]; argCount: number }[] = []
  function parse(e: string) {
    const trimmed = e.trim()
    const fnMatch = trimmed.match(/^(\w+)\(([\s\S]*)\)$/)
    if (!fnMatch) return
    const name = fnMatch[1]
    const argsStr = fnMatch[2]
    const args = splitArgs(argsStr)
    results.push({ name, args, argCount: argsStr.trim() === "" ? 0 : args.length })
    for (const a of args) parse(a.trim())
  }
  parse(expr)
  return results
}

function closestMatch(input: string, candidates: string[]): string | null {
  const lower = input.toLowerCase()
  return candidates.find((c) => c.toLowerCase().startsWith(lower)) ??
    candidates.find((c) => c.toLowerCase().includes(lower)) ?? null
}

function validateFormula(formula: string, fieldNames: string[], fieldTypes: Record<string, string>): FormulaDiagnostic[] {
  if (!formula.trim()) return []
  const diagnostics: FormulaDiagnostic[] = []

  if (!formula.startsWith("=")) {
    diagnostics.push({ level: "error", message: "Formula must start with = (e.g. =add(1, 2))" })
    return diagnostics
  }

  const expr = formula.slice(1).replace(/\n/g, " ")
  if (!expr.trim()) {
    diagnostics.push({ level: "warning", message: "Empty formula — type a function like add(), concat(), if()" })
    return diagnostics
  }

  let depth = 0
  for (const ch of expr) {
    if (ch === "(") depth++
    if (ch === ")") depth--
    if (depth < 0) break
  }
  if (depth > 0) diagnostics.push({ level: "error", message: `Missing ${depth} closing parenthes${depth > 1 ? "es" : "is"} )` })
  if (depth < 0) diagnostics.push({ level: "error", message: "Extra closing parenthesis )" })

  const calls = extractFunctionCalls(expr)
  for (const call of calls) {
    const lower = call.name.toLowerCase()
    if (!FUNC_NAMES.includes(lower)) {
      diagnostics.push({ level: "error", message: `Unknown function "${call.name}" — did you mean ${closestMatch(call.name, FUNC_NAMES)}?` })
      continue
    }
    const spec = FUNC_SPECS[lower]
    if (spec) {
      if (call.argCount < spec.minArgs) diagnostics.push({ level: "error", message: `${lower}() expects at least ${spec.minArgs} argument${spec.minArgs > 1 ? "s" : ""}, got ${call.argCount}` })
      else if (spec.maxArgs !== undefined && call.argCount > spec.maxArgs) diagnostics.push({ level: "error", message: `${lower}() expects at most ${spec.maxArgs} argument${spec.maxArgs > 1 ? "s" : ""}, got ${call.argCount}` })
      if (spec.argTypes && call.args.length > 0) {
        for (let i = 0; i < call.args.length && i < spec.argTypes.length; i++) {
          const expected = spec.argTypes[i]
          const argType = inferArgType(call.args[i], fieldTypes)
          if (expected && argType && !expected.includes(argType)) {
            const argStr = call.args[i].trim()
            const fieldRef = argStr.match(/^\{([^}]+)\}$/)
            const source = fieldRef ? `field {${fieldRef[1]}} (${TYPE_LABELS[argType]})` : `${TYPE_LABELS[argType]} "${argStr}"`
            diagnostics.push({ level: "error", message: `${lower}() argument ${i + 1}: expected ${expected.map((t) => TYPE_LABELS[t]).join(" or ")}, got ${source}` })
          }
        }
      }
    }
  }

  const fieldRe = /\{([^}]+)\}/g
  let m
  while ((m = fieldRe.exec(expr)) !== null) {
    if (!fieldNames.includes(m[1])) {
      const suggestion = closestMatch(m[1], fieldNames)
      diagnostics.push({
        level: "error",
        message: suggestion
          ? `Unknown field "{${m[1]}}" — did you mean {${suggestion}}?`
          : `Unknown field "{${m[1]}}" — available: ${fieldNames.length > 0 ? fieldNames.slice(0, 4).map((n) => `{${n}}`).join(", ") : "none defined"}`,
      })
    }
  }

  if (expr.match(/\{[^}]*$/)) diagnostics.push({ level: "error", message: "Unclosed field reference — add a closing }" })

  const stripped = expr.replace(/"[^"]*"|'[^']*'/g, "").replace(/\{[^}]*\}/g, "").replace(/\w+\(/g, "(")
  const bareWordRe = /\b([a-zA-Z_]\w*)\b/g
  const seen = new Set<string>()
  let bm
  while ((bm = bareWordRe.exec(stripped)) !== null) {
    const word = bm[1]
    if (word === "true" || word === "false" || seen.has(word)) continue
    seen.add(word)
    const suggestion = closestMatch(word, fieldNames)
    diagnostics.push({
      level: "error",
      message: suggestion ? `Unknown variable "${word}" — did you mean {${suggestion}}?` : `Unknown variable "${word}" — use {${word}} to reference a field`,
    })
  }

  return diagnostics
}

// ── Syntax highlighting ─────────────────────────────────────────

const C_FUNC = "text-purple-500 dark:text-purple-400"
const C_VAR = "text-orange-500 dark:text-orange-400"
const C_STR = "text-teal-500 dark:text-teal-400"
const C_NUM = "text-amber-600 dark:text-amber-400"
const C_PUNC = "text-muted-foreground"
const C_ERR = "text-destructive"

function highlightFormula(formula: string, fieldNames: string[]): React.ReactNode[] {
  if (!formula.startsWith("=")) return [formula]
  const parts: React.ReactNode[] = []
  parts.push(<span key="eq" className={C_PUNC}>=</span>)
  const text = formula.slice(1)
  let pos = 0

  while (pos < text.length) {
    if (text[pos] === "{") {
      const end = text.indexOf("}", pos)
      if (end > pos) {
        const ref = text.slice(pos, end + 1)
        const name = text.slice(pos + 1, end)
        parts.push(<span key={`f${pos}`} className={fieldNames.includes(name) ? C_VAR : C_ERR}>{ref}</span>)
        pos = end + 1; continue
      }
    }
    const remaining = text.slice(pos)
    const funcMatch = remaining.match(/^(\w+)(?=\()/)
    if (funcMatch) {
      const fn = funcMatch[1]
      parts.push(<span key={`fn${pos}`} className={cn(FUNC_NAMES.includes(fn.toLowerCase()) ? C_FUNC : C_ERR, "font-medium")}>{fn}</span>)
      pos += fn.length; continue
    }
    if (text[pos] === '"' || text[pos] === "'") {
      const quote = text[pos]
      const end = text.indexOf(quote, pos + 1)
      if (end > pos) { parts.push(<span key={`s${pos}`} className={C_STR}>{text.slice(pos, end + 1)}</span>); pos = end + 1; continue }
    }
    const numMatch = remaining.match(/^\d+(\.\d+)?/)
    if (numMatch && (pos === 0 || /[,()\s]/.test(text[pos - 1] ?? ""))) {
      parts.push(<span key={`n${pos}`} className={C_NUM}>{numMatch[0]}</span>); pos += numMatch[0].length; continue
    }
    if ("(),".includes(text[pos])) { parts.push(<span key={`p${pos}`} className={C_PUNC}>{text[pos]}</span>); pos++; continue }
    const wordMatch = remaining.match(/^(\w+)/)
    if (wordMatch) {
      const word = wordMatch[1]
      const isConst = word === "true" || word === "false"
      parts.push(<span key={`w${pos}`} className={isConst ? C_NUM : C_ERR}>{word}</span>); pos += word.length; continue
    }
    parts.push(<span key={`d${pos}`}>{text[pos]}</span>); pos++
  }
  return parts
}

function highlightSig(sig: string): React.ReactNode {
  const m = sig.match(/^(\w+)\((.+)\)$/)
  if (!m) return sig
  return (<><span className={cn(C_FUNC, "font-medium")}>{m[1]}</span><span className={C_PUNC}>(</span><span className={C_VAR}>{m[2]}</span><span className={C_PUNC}>)</span></>)
}

// ── FormulaEditor ───────────────────────────────────────────────

export interface FormulaEditorProps {
  /** The formula expression string (e.g. "=add({Price}, {Tax})"). */
  value: string
  /** Called on every keystroke with the updated formula. */
  onChange: (val: string) => void
  /** Called when the editor should close (Escape, Ctrl+Enter, or outside click). */
  onBlur: () => void
  /** Available field names for autocomplete and validation. */
  fieldNames: string[]
  /** Map of field name to property type (e.g. "number", "date", "text") for type checking. */
  fieldTypes?: Record<string, string>
  /** Render inline instead of as a floating dialog. Use this inside transformed containers (e.g. preview canvases). */
  inline?: boolean
  /** Additional CSS classes for the anchor element. */
  className?: string
}

/**
 * Interactive formula editor with syntax highlighting, inline validation,
 * autocomplete for functions and field references, and keyboard navigation.
 */
export function FormulaEditor({ value, onChange, onBlur, fieldNames, fieldTypes = {}, inline = false, className }: FormulaEditorProps) {
  const [showSuggestions, setShowSuggestions] = useState(false)
  const [selectedIdx, setSelectedIdx] = useState(0)
  const [query, setQuery] = useState("")
  const [suggestionType, setSuggestionType] = useState<"function" | "field">("function")
  const [contextFunc, setContextFunc] = useState<FormulaFunction | null>(null)
  const inputRef = useRef<HTMLTextAreaElement>(null)
  const dialogRef = useRef<HTMLDivElement>(null)
  const anchorRef = useRef<HTMLDivElement>(null)
  const [pos, setPos] = useState<{ top?: number; bottom?: number; left?: number; right?: number }>({})

  useLayoutEffect(() => {
    const anchor = anchorRef.current
    if (!anchor) return
    const rect = anchor.getBoundingClientRect()
    const vw = window.innerWidth
    const vh = window.innerHeight
    const dialogW = Math.min(380, vw - 32)
    const dialogH = 220
    const p: typeof pos = {}
    if (rect.bottom + dialogH + 8 < vh) p.top = rect.bottom + 4
    else p.bottom = vh - rect.top + 4
    if (rect.left + dialogW < vw) p.left = rect.left
    else p.right = vw - rect.right
    // eslint-disable-next-line react-hooks/set-state-in-effect -- compute position from DOM on mount
    setPos(p)
  }, [])

  useEffect(() => {
    function handleMouseDown(e: MouseEvent) {
      if (dialogRef.current && !dialogRef.current.contains(e.target as Node) &&
          anchorRef.current && !anchorRef.current.contains(e.target as Node)) onBlur()
    }
    document.addEventListener("mousedown", handleMouseDown)
    return () => document.removeEventListener("mousedown", handleMouseDown)
  }, [onBlur])

  useEffect(() => {
    const el = inputRef.current
    if (el) { el.focus(); el.style.height = "auto"; el.style.height = `${el.scrollHeight}px` }
  }, [])

  function findEnclosingFunc(before: string): FormulaFunction | null {
    let depth = 0
    for (let i = before.length - 1; i >= 0; i--) {
      if (before[i] === ")") depth++
      if (before[i] === "(") {
        if (depth === 0) {
          const nameMatch = before.slice(0, i).match(/(\w+)$/)
          if (nameMatch) return FORMULA_FUNCTIONS.find((f) => f.name === nameMatch[1].toLowerCase()) ?? null
          return null
        }
        depth--
      }
    }
    return null
  }

  function updateSuggestions(val: string, cursorPos: number) {
    const before = val.slice(0, cursorPos)
    setContextFunc(findEnclosingFunc(before))
    const fieldMatch = before.match(/\{([^}]*)$/)
    if (fieldMatch) { setQuery(fieldMatch[1].toLowerCase()); setSuggestionType("field"); setShowSuggestions(true); setSelectedIdx(0); return }
    const funcMatch = before.match(/(?:^=|[,(])?\s*(\w*)$/)
    if (funcMatch && funcMatch[1] && val.startsWith("=")) { setQuery(funcMatch[1].toLowerCase()); setSuggestionType("function"); setShowSuggestions(true); setSelectedIdx(0); return }
    setShowSuggestions(false)
  }

  const filteredFunctions = query ? FORMULA_FUNCTIONS.filter((f) => f.name.startsWith(query)) : FORMULA_FUNCTIONS
  const filteredFields = query ? fieldNames.filter((f) => f.toLowerCase().includes(query)) : fieldNames
  const suggestions = suggestionType === "function"
    ? filteredFunctions.map((f) => ({ id: f.name, label: f.sig, desc: f.desc }))
    : filteredFields.map((f) => ({ id: f, label: `{${f}}`, desc: "Field reference" }))

  function insertSuggestion(item: { id: string }) {
    const el = inputRef.current
    if (!el) return
    const cursorPos = el.selectionStart ?? value.length
    const before = value.slice(0, cursorPos)
    const after = value.slice(cursorPos)
    if (suggestionType === "field") {
      const braceIdx = before.lastIndexOf("{")
      onChange(before.slice(0, braceIdx) + `{${item.id}}` + after)
    } else {
      const match = before.match(/(\w*)$/)
      const replaceFrom = cursorPos - (match?.[1]?.length ?? 0)
      onChange(before.slice(0, replaceFrom) + item.id + "(" + after)
    }
    setShowSuggestions(false)
    setTimeout(() => el.focus(), 0)
  }

  function handleKeyDown(e: React.KeyboardEvent) {
    if (showSuggestions && suggestions.length > 0) {
      if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => (i + 1) % suggestions.length); return }
      if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => (i + suggestions.length - 1) % suggestions.length); return }
      if (e.key === "Tab" || (e.key === "Enter" && suggestions.length > 0)) { e.preventDefault(); insertSuggestion(suggestions[selectedIdx]); return }
      if (e.key === "Escape") { e.preventDefault(); setShowSuggestions(false); return }
    }
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !showSuggestions) { e.preventDefault(); onBlur() }
    if (e.key === "Escape" && !showSuggestions) { e.preventDefault(); onBlur() }
  }

  const dialogCn = inline
    ? "relative w-full rounded-lg border bg-popover shadow-xl"
    : "fixed z-50 w-[min(380px,calc(100vw-2rem))] rounded-lg border bg-popover shadow-xl"

  return (
    <>
      {!inline && <div ref={anchorRef} className={cn("w-full h-0", className)} />}
      <div ref={dialogRef} className={dialogCn} style={inline ? undefined : { ...pos }}>
        <div className="relative px-3 py-2.5 border-b">
          <div className="pointer-events-none absolute inset-x-3 inset-y-2.5 font-mono text-sm whitespace-pre-wrap break-words" aria-hidden>
            {highlightFormula(value, fieldNames)}
          </div>
          <textarea
            ref={inputRef}
            value={value}
            onChange={(e) => {
              onChange(e.target.value)
              updateSuggestions(e.target.value, e.target.selectionStart ?? e.target.value.length)
              e.target.style.height = "auto"
              e.target.style.height = `${e.target.scrollHeight}px`
            }}
            onClick={(e) => updateSuggestions(value, (e.target as HTMLTextAreaElement).selectionStart ?? value.length)}
            onKeyDown={handleKeyDown}
            placeholder="=add({Price}, {Tax})"
            className="w-full bg-transparent outline-none font-mono text-sm text-transparent caret-foreground resize-none overflow-hidden"
            rows={1}
            style={{ minHeight: "1.75rem" }}
            spellCheck={false}
          />
        </div>

        {contextFunc && !showSuggestions && (
          <div className="px-3 py-1.5 border-b bg-muted/30 flex items-center gap-2">
            <span className="font-mono text-xs">{highlightSig(contextFunc.sig)}</span>
            <span className="text-[11px] text-muted-foreground">{contextFunc.desc}</span>
          </div>
        )}

        {(() => {
          const diags = validateFormula(value, fieldNames, fieldTypes)
          if (diags.length === 0) return null
          return (
            <div className="px-3 py-2 border-b space-y-1">
              {diags.map((d, i) => (
                <p key={i} className={cn("text-xs flex items-start gap-1.5", d.level === "error" ? "text-destructive" : "text-amber-600 dark:text-amber-400")}>
                  <span className="shrink-0 mt-0.5">{d.level === "error" ? "✕" : "⚠"}</span>
                  <span>{d.message}</span>
                </p>
              ))}
            </div>
          )
        })()}

        {showSuggestions && suggestions.length > 0 && (
          <div className="max-h-48 overflow-y-auto py-1">
            {suggestions.map((item, i) => (
              <button
                key={item.id}
                onMouseDown={(e) => { e.preventDefault(); insertSuggestion(item) }}
                className={cn("flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left transition-colors cursor-pointer", i === selectedIdx ? "bg-muted" : "hover:bg-muted/50")}
              >
                <span className="font-mono shrink-0">
                  {suggestionType === "function" ? highlightSig(item.label) : <span className={C_VAR}>{item.label}</span>}
                </span>
                <span className="text-muted-foreground truncate">{item.desc}</span>
              </button>
            ))}
          </div>
        )}

        <div className="px-3 py-1.5 border-t text-[10px] text-muted-foreground flex items-center gap-3">
          <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Tab</kbd> accept</span>
          <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">{"{"}</kbd> field ref</span>
          <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Ctrl+↵</kbd> save</span>
          <span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px] font-mono">Esc</kbd> close</span>
        </div>
      </div>
    </>
  )
}

/** Read-only formula display with syntax highlighting */
export function FormulaDisplay({ /** The formula expression string to display. */ formula, /** Available field names for syntax highlighting validation. */ fieldNames }: { formula: string; fieldNames: string[] }) {
  return <span className="font-mono text-xs">{highlightFormula(formula, fieldNames)}</span>
}