From 1c932aa3f55e4f456689468d607c3e306de6a8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 1 Jul 2026 09:39:37 -0600 Subject: [PATCH 1/7] fix[frontend](dashboards): refactor dashboard view --- .../dashboard/components/DashboardList.tsx | 111 ------------ .../components/DashboardPreviewHeader.tsx | 69 +++++++ .../dashboard/components/DashboardTable.tsx | 133 ++++++++++++++ .../dashboard/components/DashboardTabsBar.tsx | 148 --------------- .../dashboard/pages/DashboardPage.tsx | 170 ++++++++++-------- frontend/src/shared/i18n/locales/de.json | 7 + frontend/src/shared/i18n/locales/en.json | 7 + frontend/src/shared/i18n/locales/es.json | 7 + frontend/src/shared/i18n/locales/fr.json | 7 + frontend/src/shared/i18n/locales/it.json | 7 + frontend/src/shared/i18n/locales/pt.json | 7 + frontend/src/shared/i18n/locales/ru.json | 7 + 12 files changed, 345 insertions(+), 335 deletions(-) delete mode 100644 frontend/src/features/dashboard/components/DashboardList.tsx create mode 100644 frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx create mode 100644 frontend/src/features/dashboard/components/DashboardTable.tsx delete mode 100644 frontend/src/features/dashboard/components/DashboardTabsBar.tsx diff --git a/frontend/src/features/dashboard/components/DashboardList.tsx b/frontend/src/features/dashboard/components/DashboardList.tsx deleted file mode 100644 index 3922227c8..000000000 --- a/frontend/src/features/dashboard/components/DashboardList.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import type { Dashboard } from '@/features/dashboard/types' - -export function DashboardList({ - dashboards, - selectedId, - search, - loading, - onSearchChange, - onSelect, - onCreate, - onRename, - onDelete, -}: { - dashboards: Dashboard[] - selectedId: number | null - search: string - loading: boolean - onSearchChange: (value: string) => void - onSelect: (id: number) => void - onCreate: () => void - onRename: (d: Dashboard) => void - onDelete: (d: Dashboard) => void -}) { - const { t } = useTranslation() - - return ( - - ) -} diff --git a/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx b/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx new file mode 100644 index 000000000..53a12e564 --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { ArrowLeft, Pencil, Trash2 } from 'lucide-react' +import type { Dashboard } from '@/features/dashboard/types' + +export function DashboardPreviewHeader({ + dashboard, + onBack, + onEdit, + onDelete, + right, +}: { + dashboard: Dashboard + onBack: () => void + /** Enter widget-layout edit mode. Omit while already editing or for systemOwner dashboards. */ + onEdit?: () => void + onDelete: (d: Dashboard) => void + /** Consumption controls (time range) or the editor bar while editing. */ + right?: ReactNode +}) { + const { t } = useTranslation() + const canModify = !dashboard.systemOwner + + return ( +
+ + / +
+

{dashboard.name}

+ {dashboard.description && ( +

{dashboard.description}

+ )} +
+ +
+ {right} + {canModify && onEdit && ( + + )} + {canModify && ( + + )} +
+
+ ) +} diff --git a/frontend/src/features/dashboard/components/DashboardTable.tsx b/frontend/src/features/dashboard/components/DashboardTable.tsx new file mode 100644 index 000000000..0b8404a5c --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardTable.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from 'react-i18next' +import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import type { Dashboard } from '@/features/dashboard/types' + +export function DashboardTable({ + dashboards, + loading, + search, + onSearchChange, + onSelect, + onCreate, + onEdit, + onDelete, +}: { + dashboards: Dashboard[] + loading: boolean + search: string + onSearchChange: (value: string) => void + onSelect: (id: number) => void + onCreate: () => void + onEdit: (d: Dashboard) => void + onDelete: (d: Dashboard) => void +}) { + const { t } = useTranslation() + + return ( +
+
+

{t('dashboards.list.title')}

+
+ onSearchChange(e.target.value)} + placeholder={t('dashboards.list.searchPlaceholder') ?? ''} + className="h-9 w-64" + /> + +
+
+ +
+ {loading && ( +
+ + {t('dashboards.list.loading')} +
+ )} + {!loading && dashboards.length === 0 && ( +
+ {t('dashboards.list.empty')} +
+ )} + {!loading && dashboards.length > 0 && ( + + + + + + + + + + {dashboards.map((d) => ( + + ))} + +
{t('dashboards.table.col.name')}{t('dashboards.table.col.description')}{t('dashboards.table.col.modified')} +
+ )} +
+
+ ) +} + +function DashboardRow({ + dashboard: d, + onSelect, + onEdit, + onDelete, +}: { + dashboard: Dashboard + onSelect: (id: number) => void + onEdit: (d: Dashboard) => void + onDelete: (d: Dashboard) => void +}) { + const { t } = useTranslation() + const modified = d.modifiedDate ? new Date(d.modifiedDate).toLocaleString() : '—' + return ( + onSelect(d.id)} + className="cursor-pointer border-b border-border/60 last:border-0 hover:bg-muted/40" + > + {d.name} + {d.description || '—'} + {modified} + e.stopPropagation()}> + {!d.systemOwner && ( +
+ + +
+ )} + + + ) +} diff --git a/frontend/src/features/dashboard/components/DashboardTabsBar.tsx b/frontend/src/features/dashboard/components/DashboardTabsBar.tsx deleted file mode 100644 index 6880a4e4c..000000000 --- a/frontend/src/features/dashboard/components/DashboardTabsBar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState, type ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { BarChart3, MoreVertical, Pencil, Plus, Star, Trash2 } from 'lucide-react' -import { cn } from '@/shared/lib/utils' -import type { Dashboard } from '@/features/dashboard/types' - -/** - * Dashboards-as-tabs: one tab per dashboard (consumption-first, like the Log - * Explorer tabs). The view stays clean — tabs + time range + a single options - * (⋯) menu that holds every management action for the current dashboard plus the - * global ones (new dashboard, visualizations library). - */ -export function DashboardTabsBar({ - dashboards, - selectedId, - defaultId, - onSelect, - onSetDefault, - onCreate, - onRename, - onDelete, - onEdit, - right, -}: { - dashboards: Dashboard[] - selectedId: number | null - defaultId: number | null - onSelect: (id: number) => void - onSetDefault: (id: number | null) => void - onCreate: () => void - onRename: (d: Dashboard) => void - onDelete: (d: Dashboard) => void - /** Enter edit mode for the current dashboard. Omit when it can't be edited. */ - onEdit?: () => void - /** Consumption controls (time range, or the editor bar while editing). */ - right?: ReactNode -}) { - const { t } = useTranslation() - const navigate = useNavigate() - const [menuOpen, setMenuOpen] = useState(false) - const current = dashboards.find((d) => d.id === selectedId) ?? null - - const close = () => setMenuOpen(false) - - return ( -
- {/* Tab strip — scrolls horizontally when dashboards overflow. */} -
- {dashboards.map((d) => { - const active = d.id === selectedId - return ( - - ) - })} -
- - {/* Right: consumption controls + a single options menu. */} -
- {right} -
- - - {menuOpen && ( - <> -
-
- {current && onEdit && ( - { onEdit(); close() }}> - {t('dashboards.actions.edit')} - - )} - {current && ( - { onSetDefault(defaultId === current.id ? null : current.id); close() }} - > - {defaultId === current.id ? t('dashboards.tabs.unsetDefault') : t('dashboards.picker.setDefault')} - - )} - {current && !current.systemOwner && ( - <> - { onRename(current); close() }}> - {t('dashboards.list.rename')} - - { onDelete(current); close() }}> - {t('dashboards.list.delete')} - - - )} -
- { onCreate(); close() }}> - {t('dashboards.tabs.newDashboard')} - - { navigate('/dashboards/visualizations'); close() }}> - {t('dashboards.tabs.visualizations')} - -
- - )} -
-
-
- ) -} - -function MenuItem({ - icon: Icon, - children, - onClick, - danger, -}: { - icon: typeof Star - children: ReactNode - onClick: () => void - danger?: boolean -}) { - return ( - - ) -} diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx index 5729d37be..bdebaac93 100644 --- a/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { LayoutDashboard, Loader2, Plus } from 'lucide-react' import { toast } from 'sonner' @@ -14,54 +14,35 @@ import { DashboardEditorBar } from '@/features/dashboard/components/DashboardEdi import { DashboardFormDialog } from '@/features/dashboard/components/DashboardFormDialog' import { DashboardTimeRange } from '@/features/dashboard/components/DashboardTimeRange' import { AddVisualizationDrawer } from '@/features/dashboard/components/AddVisualizationDrawer' -import { DashboardTabsBar } from '@/features/dashboard/components/DashboardTabsBar' +import { DashboardTable } from '@/features/dashboard/components/DashboardTable' +import { DashboardPreviewHeader } from '@/features/dashboard/components/DashboardPreviewHeader' import { DEFAULT_PAGE_SIZE, DEFAULT_WIDGET_LAYOUT } from '@/features/dashboard/constants' import { nextRow, serializeLayout, toGridItems } from '@/features/dashboard/utils/layout' import type { Dashboard, GridLayoutItem, Visualization } from '@/features/dashboard/types' import { useQueries } from '@tanstack/react-query' import { VISUALIZATIONS_QUERY_KEYS } from '@/features/dashboard/hooks/useVisualizations' -const DEFAULT_DASHBOARD_KEY = 'utmstack-default-dashboard' export function DashboardPage() { const { t } = useTranslation() const [selectedId, setSelectedId] = useState(null) + const [search, setSearch] = useState('') const [time, setTime] = useState(() => presetRange('24h')) const [formOpen, setFormOpen] = useState( null ) const [addOpen, setAddOpen] = useState(false) const [pendingDelete, setPendingDelete] = useState(null) + // When entering the preview from the table's edit action we want the layout + // editor to open automatically once the dashboard data is available. + const pendingEditRef = useRef(false) - const dashboards = useDashboards({ page: 0, size: DEFAULT_PAGE_SIZE }) - const dashboardItems = dashboards.list.data?.data ?? [] - // No dashboards AND no active search → genuinely empty (show the create CTA). - const noDashboards = dashboardItems.length === 0 - - // "Default" dashboard (the one shown on entry), persisted per browser. - const [defaultId, setDefaultId] = useState(() => { - if (typeof window === 'undefined') return null - const raw = window.localStorage.getItem(DEFAULT_DASHBOARD_KEY) - const n = raw == null ? NaN : Number(raw) - return Number.isFinite(n) ? n : null + const dashboards = useDashboards({ + page: 0, + size: DEFAULT_PAGE_SIZE, + name: search || undefined, }) - const setDefaultDashboard = (id: number | null) => { - setDefaultId(id) - try { - if (id == null) window.localStorage.removeItem(DEFAULT_DASHBOARD_KEY) - else window.localStorage.setItem(DEFAULT_DASHBOARD_KEY, String(id)) - } catch { - /* ignore */ - } - } - - // On entry land on the default dashboard → else the first one. Keep the - // selection valid as the list changes. - useEffect(() => { - if (dashboardItems.length === 0) return - const valid = (id: number | null) => id != null && dashboardItems.some((d) => d.id === id) - if (valid(selectedId)) return - setSelectedId(valid(defaultId) ? defaultId : dashboardItems[0].id) - }, [dashboardItems, selectedId, defaultId]) + const dashboardItems = dashboards.list.data?.data ?? [] + const noDashboards = dashboardItems.length === 0 && !search const selectedDashboard = useDashboard(selectedId) @@ -94,6 +75,18 @@ export function DashboardPage() { const initialItems = useMemo(() => toGridItems(layoutRows), [layoutRows]) const editor = useDashboardEditor(initialItems) + // Once the selected dashboard resolves after a table "Edit" click, drop into edit mode. + useEffect(() => { + if (!pendingEditRef.current) return + if (!selectedDashboard.data) return + if (selectedDashboard.data.systemOwner) { + pendingEditRef.current = false + return + } + pendingEditRef.current = false + editor.enter() + }, [selectedDashboard.data, editor]) + const layoutsById = useMemo(() => { const m = new Map() for (const dv of layoutRows) m.set(String(dv.id), dv.id) @@ -219,29 +212,35 @@ export function DashboardPage() { } } + const openFromTable = (id: number, options?: { edit?: boolean }) => { + pendingEditRef.current = !!options?.edit + setSelectedId(id) + } + + const backToList = () => { + if (editor.editing) editor.discard() + setSelectedId(null) + } + const gridItems: GridLayoutItem[] = editor.editing ? editor.working : initialItems const saving = layouts.updateLayout.isPending || layouts.deleteLayout.isPending || layouts.createLayout.isPending + const inPreview = selectedId != null + const previewDashboard = selectedDashboard.data ?? null + const canEditPreview = + previewDashboard != null && !previewDashboard.systemOwner && !editor.editing + return (
- {!noDashboards && ( - setFormOpen({ mode: 'create', target: null })} - onRename={(d) => setFormOpen({ mode: 'rename', target: d })} + {inPreview && previewDashboard && ( + setPendingDelete(d)} - onEdit={ - selectedDashboard.data && !editor.editing && !selectedDashboard.data.systemOwner - ? editor.enter - : undefined - } right={ editor.editing ? ( - ) : selectedId != null ? ( + ) : ( - ) : null + ) } /> )} -
- {noDashboards && !dashboards.list.isLoading ? ( -
- -
-

{t('dashboards.empty.title')}

-

{t('dashboards.empty.body')}

+ {!inPreview && ( + <> + {noDashboards && !dashboards.list.isLoading ? ( +
+ +
+

{t('dashboards.empty.title')}

+

{t('dashboards.empty.body')}

+
+
- -
- ) : selectedId != null && layouts.list.isLoading ? ( -
- - {t('dashboards.page.loading')} -
- ) : selectedId != null && !layouts.list.isLoading ? ( - - ) : null} -
+ ) : ( + openFromTable(id)} + onCreate={() => setFormOpen({ mode: 'create', target: null })} + onEdit={(d) => openFromTable(d.id, { edit: true })} + onDelete={(d) => setPendingDelete(d)} + /> + )} + + )} + + {inPreview && ( +
+ {layouts.list.isLoading ? ( +
+ + {t('dashboards.page.loading')} +
+ ) : ( + + )} +
+ )} Date: Wed, 1 Jul 2026 12:26:13 -0600 Subject: [PATCH 2/7] fix[frontend](shared): added sql autocomplete editor into shared module --- .../sql-editor/SqlAutocompleteDropdown.tsx | 73 ++++ .../components/sql-editor/SqlQueryEditor.tsx | 316 ++++++++++++++++++ .../sql-editor/autocomplete-trie.service.ts | 81 +++++ .../src/shared/components/sql-editor/index.ts | 10 + .../components/sql-editor/sql-keywords.ts | 47 +++ .../sql-editor/useSqlAutocomplete.ts | 61 ++++ 6 files changed, 588 insertions(+) create mode 100644 frontend/src/shared/components/sql-editor/SqlAutocompleteDropdown.tsx create mode 100644 frontend/src/shared/components/sql-editor/SqlQueryEditor.tsx create mode 100644 frontend/src/shared/components/sql-editor/autocomplete-trie.service.ts create mode 100644 frontend/src/shared/components/sql-editor/index.ts create mode 100644 frontend/src/shared/components/sql-editor/sql-keywords.ts create mode 100644 frontend/src/shared/components/sql-editor/useSqlAutocomplete.ts diff --git a/frontend/src/shared/components/sql-editor/SqlAutocompleteDropdown.tsx b/frontend/src/shared/components/sql-editor/SqlAutocompleteDropdown.tsx new file mode 100644 index 000000000..4ef80a38d --- /dev/null +++ b/frontend/src/shared/components/sql-editor/SqlAutocompleteDropdown.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react' +import { cn } from '@/shared/lib/utils' +import type { Suggestion } from './autocomplete-trie.service' + +interface Props { + items: Suggestion[] + activeIndex: number + position: { x: number; y: number } + onPick: (item: Suggestion) => void + onHover: (index: number) => void +} + +const ROW_HEIGHT = 28 +const MAX_VISIBLE = 8 + +export function SqlAutocompleteDropdown({ + items, + activeIndex, + position, + onPick, + onHover, +}: Props) { + const listRef = useRef(null) + + useEffect(() => { + const el = listRef.current?.children[activeIndex] as HTMLElement | undefined + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + if (items.length === 0) return null + + return ( +
    e.preventDefault()} + > + {items.map((item, i) => ( +
  • onHover(i)} + onClick={() => onPick(item)} + > + {item.word} + + {item.tag === 'sql' ? 'SQL' : item.tag === 'index' ? 'index' : 'field'} + +
  • + ))} +
+ ) +} diff --git a/frontend/src/shared/components/sql-editor/SqlQueryEditor.tsx b/frontend/src/shared/components/sql-editor/SqlQueryEditor.tsx new file mode 100644 index 000000000..9afb89b7e --- /dev/null +++ b/frontend/src/shared/components/sql-editor/SqlQueryEditor.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import Prism from 'prismjs' +import 'prismjs/components/prism-sql' +import { cn } from '@/shared/lib/utils' +import { useSqlAutocomplete } from './useSqlAutocomplete' +import type { + SqlAutocompleteField, + SqlAutocompletePattern, +} from './useSqlAutocomplete' +import { SqlAutocompleteDropdown } from './SqlAutocompleteDropdown' +import type { Suggestion } from './autocomplete-trie.service' + +interface Props { + value: string + onChange: (v: string) => void + onRun?: () => void + fields: SqlAutocompleteField[] + patterns: SqlAutocompletePattern[] + placeholder?: string + /** Minimum visible rows. Defaults to 4. */ + minRows?: number + /** Maximum visible rows before scrolling. Defaults to 12. */ + maxRows?: number +} + +const DEFAULT_MIN_ROWS = 4 +const DEFAULT_MAX_ROWS = 12 +const LINE_HEIGHT_PX = 18 +const PADDING_Y = 8 +const TOKEN_CHARS = /[A-Za-z0-9_.@-]/ + +interface TokenSpan { + text: string + start: number + end: number +} + +function tokenAtCaret(value: string, caret: number): TokenSpan | null { + let start = caret + while (start > 0 && TOKEN_CHARS.test(value[start - 1])) start-- + if (start === caret) return null + return { text: value.slice(start, caret), start, end: caret } +} + +function getCaretViewportCoords(textarea: HTMLTextAreaElement): { left: number; top: number } { + const mirror = document.createElement('div') + const cs = window.getComputedStyle(textarea) + const copyProps = [ + 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', + 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', + 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', + 'borderLeftWidth', 'tabSize', 'textIndent', 'textTransform', 'wordSpacing', + ] as const + for (const p of copyProps) { + mirror.style.setProperty(p as string, cs.getPropertyValue(p as string)) + } + mirror.style.position = 'absolute' + mirror.style.visibility = 'hidden' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.wordWrap = 'break-word' + mirror.style.overflow = 'hidden' + mirror.style.top = '0' + mirror.style.left = '-9999px' + document.body.appendChild(mirror) + + const caret = textarea.selectionStart ?? textarea.value.length + mirror.textContent = textarea.value.slice(0, caret) + const marker = document.createElement('span') + marker.textContent = textarea.value.slice(caret, caret + 1) || '.' + mirror.appendChild(marker) + + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + const taRect = textarea.getBoundingClientRect() + document.body.removeChild(mirror) + + return { + left: taRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, + top: taRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, + } +} + +export function SqlQueryEditor({ + value, + onChange, + onRun, + fields, + patterns, + placeholder, + minRows = DEFAULT_MIN_ROWS, + maxRows = DEFAULT_MAX_ROWS, +}: Props) { + const minHeight = minRows * LINE_HEIGHT_PX + PADDING_Y * 2 + const maxHeight = maxRows * LINE_HEIGHT_PX + PADDING_Y * 2 + const textareaRef = useRef(null) + const wrapperRef = useRef(null) + const preRef = useRef(null) + const [height, setHeight] = useState(minHeight) + const [open, setOpen] = useState(false) + const [items, setItems] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) + const [anchor, setAnchor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + const tokenRef = useRef(null) + + const { suggest } = useSqlAutocomplete(fields, patterns) + + const highlighted = useMemo(() => { + const grammar = Prism.languages.sql + if (!grammar) return value + return Prism.highlight(value + '\n', grammar, 'sql') + }, [value]) + + const recalcHeight = useCallback(() => { + const ta = textareaRef.current + if (!ta) return + ta.style.height = 'auto' + const next = Math.max(minHeight, Math.min(maxHeight, ta.scrollHeight)) + ta.style.height = '' + setHeight(next) + }, [minHeight, maxHeight]) + + useLayoutEffect(() => { + recalcHeight() + }, [value, recalcHeight]) + + const closeDropdown = useCallback(() => { + setOpen(false) + setItems([]) + tokenRef.current = null + }, []) + + const refreshSuggestions = useCallback(() => { + const ta = textareaRef.current + if (!ta) return + const caret = ta.selectionStart ?? 0 + const token = tokenAtCaret(value, caret) + if (!token) { + closeDropdown() + return + } + const next = suggest(token.text, 30).filter( + (s) => s.word.toLowerCase() !== token.text.toLowerCase(), + ) + if (next.length === 0) { + closeDropdown() + return + } + tokenRef.current = token + setItems(next) + setActiveIndex(0) + const coords = getCaretViewportCoords(ta) + setAnchor({ x: coords.left, y: coords.top + LINE_HEIGHT_PX + 4 }) + setOpen(true) + }, [closeDropdown, suggest, value]) + + const accept = useCallback( + (item: Suggestion) => { + const ta = textareaRef.current + const span = tokenRef.current + if (!ta || !span) return + const next = value.slice(0, span.start) + item.word + value.slice(span.end) + onChange(next) + closeDropdown() + const newCaret = span.start + item.word.length + requestAnimationFrame(() => { + const t = textareaRef.current + if (!t) return + t.focus() + t.setSelectionRange(newCaret, newCaret) + }) + }, + [closeDropdown, onChange, value], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault() + closeDropdown() + onRun?.() + return + } + if (open) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => (i + 1) % items.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => (i - 1 + items.length) % items.length) + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + const picked = items[activeIndex] + if (picked) accept(picked) + return + } + if (e.key === 'Escape') { + e.preventDefault() + closeDropdown() + return + } + } else if (e.key === 'Enter' && !e.shiftKey && onRun) { + e.preventDefault() + onRun() + } + }, + [accept, activeIndex, closeDropdown, items, onRun, open], + ) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value) + }, + [onChange], + ) + + + useEffect(() => { + if (document.activeElement === textareaRef.current) refreshSuggestions() + }, [value, refreshSuggestions]) + + useEffect(() => { + if (!open) return + const onMouseDown = (e: MouseEvent) => { + const root = wrapperRef.current + if (!root) return + if (!root.contains(e.target as Node)) closeDropdown() + } + document.addEventListener('mousedown', onMouseDown) + return () => document.removeEventListener('mousedown', onMouseDown) + }, [open, closeDropdown]) + + const syncScroll = useCallback(() => { + const ta = textareaRef.current + const pre = preRef.current + if (!ta || !pre) return + pre.scrollTop = ta.scrollTop + pre.scrollLeft = ta.scrollLeft + }, []) + + return ( +
+ +
+        
+      
+