datool 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +218 -0
  2. package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  3. package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  4. package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  5. package/client-dist/assets/index-BeRNeRUq.css +1 -0
  6. package/client-dist/assets/index-uoZ4c_I8.js +164 -0
  7. package/client-dist/index.html +13 -0
  8. package/index.html +12 -0
  9. package/package.json +55 -0
  10. package/src/client/App.tsx +885 -0
  11. package/src/client/components/connection-status.tsx +43 -0
  12. package/src/client/components/data-table-cell.tsx +235 -0
  13. package/src/client/components/data-table-col-icon.tsx +73 -0
  14. package/src/client/components/data-table-header-col.tsx +225 -0
  15. package/src/client/components/data-table-search-input.tsx +729 -0
  16. package/src/client/components/data-table.tsx +2014 -0
  17. package/src/client/components/stream-controls.tsx +157 -0
  18. package/src/client/components/theme-provider.tsx +230 -0
  19. package/src/client/components/ui/button.tsx +68 -0
  20. package/src/client/components/ui/combobox.tsx +308 -0
  21. package/src/client/components/ui/context-menu.tsx +261 -0
  22. package/src/client/components/ui/dropdown-menu.tsx +267 -0
  23. package/src/client/components/ui/input-group.tsx +153 -0
  24. package/src/client/components/ui/input.tsx +19 -0
  25. package/src/client/components/ui/textarea.tsx +18 -0
  26. package/src/client/components/viewer-settings.tsx +185 -0
  27. package/src/client/index.css +192 -0
  28. package/src/client/lib/data-table-search.ts +750 -0
  29. package/src/client/lib/datool-icons.ts +37 -0
  30. package/src/client/lib/datool-url-state.ts +159 -0
  31. package/src/client/lib/filterable-table.ts +146 -0
  32. package/src/client/lib/table-search-persistence.ts +94 -0
  33. package/src/client/lib/utils.ts +6 -0
  34. package/src/client/main.tsx +14 -0
  35. package/src/index.ts +19 -0
  36. package/src/node/cli.ts +54 -0
  37. package/src/node/config.ts +231 -0
  38. package/src/node/lines.ts +82 -0
  39. package/src/node/runtime.ts +102 -0
  40. package/src/node/server.ts +403 -0
  41. package/src/node/sources/command.ts +82 -0
  42. package/src/node/sources/file.ts +116 -0
  43. package/src/node/sources/ssh.ts +59 -0
  44. package/src/shared/columns.ts +41 -0
  45. package/src/shared/types.ts +188 -0
@@ -0,0 +1,729 @@
1
+ import * as React from "react"
2
+ import { Extension } from "@tiptap/core"
3
+ import Document from "@tiptap/extension-document"
4
+ import Paragraph from "@tiptap/extension-paragraph"
5
+ import Placeholder from "@tiptap/extension-placeholder"
6
+ import Text from "@tiptap/extension-text"
7
+ import { EditorContent, useEditor, type Editor } from "@tiptap/react"
8
+ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"
9
+ import { Decoration, DecorationSet } from "@tiptap/pm/view"
10
+ import { LoaderCircle, Search, XIcon } from "lucide-react"
11
+
12
+ import { useOptionalDataTableContext } from "./data-table"
13
+ import {
14
+ InputGroup,
15
+ InputGroupAddon,
16
+ InputGroupButton,
17
+ } from "@/components/ui/input-group"
18
+ import {
19
+ applySuggestionToValue,
20
+ getSearchSuggestions,
21
+ getSelectorHighlightRanges,
22
+ type DataTableSearchField,
23
+ type DataTableSearchSuggestion,
24
+ } from "@/lib/data-table-search"
25
+
26
+ export type DataTableSearchInputHandle = {
27
+ focus: () => void
28
+ selectAll: () => void
29
+ }
30
+
31
+ type ControlledDataTableSearchInputProps<Row> = {
32
+ fields: DataTableSearchField<Row>[]
33
+ onSearchChange: (value: string) => void
34
+ value: string
35
+ }
36
+
37
+ type UncontrolledDataTableSearchInputProps = {
38
+ fields?: never
39
+ onSearchChange?: never
40
+ value?: never
41
+ }
42
+
43
+ export type DataTableSearchInputProps<Row> = {
44
+ inputRef?: React.Ref<DataTableSearchInputHandle>
45
+ isLoading?: boolean
46
+ placeholder?: string
47
+ } & (
48
+ | ControlledDataTableSearchInputProps<Row>
49
+ | UncontrolledDataTableSearchInputProps
50
+ )
51
+
52
+ const selectorPluginKey = new PluginKey(
53
+ "data-table-search-selector-highlighter"
54
+ )
55
+ const selectorRefreshKey = "refresh-data-table-search-selector-highlighter"
56
+ const selectorHighlighterState = {
57
+ fields: [] as DataTableSearchField<unknown>[],
58
+ }
59
+
60
+ function createSelectorDecorations(doc: Editor["state"]["doc"]) {
61
+ const ranges = getSelectorHighlightRanges(
62
+ getPlainText(doc),
63
+ selectorHighlighterState.fields
64
+ )
65
+
66
+ return DecorationSet.create(
67
+ doc,
68
+ ranges
69
+ .map((range) => {
70
+ const from = textOffsetToDocPosition(doc, range.start)
71
+ const to = textOffsetToDocPosition(doc, range.end)
72
+
73
+ if (from >= to) {
74
+ return null
75
+ }
76
+
77
+ return Decoration.inline(
78
+ from,
79
+ to,
80
+ {
81
+ class: "data-table-search-input-selector-token",
82
+ "data-table-search-selector": "",
83
+ },
84
+ {
85
+ inclusiveEnd: false,
86
+ inclusiveStart: false,
87
+ }
88
+ )
89
+ })
90
+ .filter((decoration): decoration is Decoration => decoration !== null)
91
+ )
92
+ }
93
+
94
+ const DataTableSelectorHighlighter = Extension.create({
95
+ name: "dataTableSelectorHighlighter",
96
+ addProseMirrorPlugins() {
97
+ return [
98
+ new Plugin({
99
+ key: selectorPluginKey,
100
+ state: {
101
+ init: (_, state) => createSelectorDecorations(state.doc),
102
+ apply: (transaction, decorationSet, _oldState, newState) => {
103
+ if (
104
+ transaction.docChanged ||
105
+ transaction.getMeta(selectorRefreshKey)
106
+ ) {
107
+ return createSelectorDecorations(newState.doc)
108
+ }
109
+
110
+ return decorationSet.map(transaction.mapping, transaction.doc)
111
+ },
112
+ },
113
+ props: {
114
+ decorations(state) {
115
+ return selectorPluginKey.getState(state)
116
+ },
117
+ },
118
+ }),
119
+ ]
120
+ },
121
+ })
122
+
123
+ function assignRefValue<T>(ref: React.Ref<T> | undefined, value: T | null) {
124
+ if (typeof ref === "function") {
125
+ ref(value)
126
+ return
127
+ }
128
+
129
+ if (ref) {
130
+ ref.current = value
131
+ }
132
+ }
133
+
134
+ function createEditorDocument(value: string) {
135
+ return {
136
+ content: value
137
+ ? [
138
+ {
139
+ content: [
140
+ {
141
+ text: value,
142
+ type: "text",
143
+ },
144
+ ],
145
+ type: "paragraph",
146
+ },
147
+ ]
148
+ : [
149
+ {
150
+ type: "paragraph",
151
+ },
152
+ ],
153
+ type: "doc",
154
+ } as const
155
+ }
156
+
157
+ function getPlainText(doc: Editor["state"]["doc"]) {
158
+ return doc.textBetween(0, doc.content.size, "", "")
159
+ }
160
+
161
+ function getCursorOffset(editor: Editor) {
162
+ return editor.state.doc.textBetween(0, editor.state.selection.from, "", "")
163
+ .length
164
+ }
165
+
166
+ function textOffsetToDocPosition(doc: Editor["state"]["doc"], offset: number) {
167
+ const totalLength = getPlainText(doc).length
168
+ const clampedOffset = Math.max(0, Math.min(offset, totalLength))
169
+ let remaining = clampedOffset
170
+ let position = TextSelection.atStart(doc).from
171
+
172
+ doc.descendants((node, pos) => {
173
+ if (!node.isText) {
174
+ return true
175
+ }
176
+
177
+ const text = node.text ?? ""
178
+
179
+ if (remaining <= text.length) {
180
+ position = pos + remaining
181
+ return false
182
+ }
183
+
184
+ remaining -= text.length
185
+ position = pos + text.length
186
+
187
+ return true
188
+ })
189
+
190
+ return position
191
+ }
192
+
193
+ function normalizeSingleLineText(value: string) {
194
+ return value.replace(/\r\n?/g, "\n").replace(/\n/g, " ")
195
+ }
196
+
197
+ function moveActiveIndex(
198
+ currentIndex: number,
199
+ totalItems: number,
200
+ direction: 1 | -1
201
+ ) {
202
+ if (totalItems <= 0) {
203
+ return -1
204
+ }
205
+
206
+ if (currentIndex < 0) {
207
+ return direction === 1 ? 0 : totalItems - 1
208
+ }
209
+
210
+ return (currentIndex + direction + totalItems) % totalItems
211
+ }
212
+
213
+ export function DataTableSearchInput<Row extends Record<string, unknown>>({
214
+ inputRef,
215
+ isLoading = false,
216
+ placeholder = "Search table...",
217
+ ...props
218
+ }: DataTableSearchInputProps<Row>) {
219
+ const context = useOptionalDataTableContext<Row>()
220
+ const fields =
221
+ "fields" in props ? props.fields : (context?.searchFields ?? undefined)
222
+ const value = "value" in props ? props.value : (context?.search ?? undefined)
223
+ const onSearchChange =
224
+ "onSearchChange" in props
225
+ ? props.onSearchChange
226
+ : (context?.setSearch ?? undefined)
227
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
228
+ const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([])
229
+ const valueRef = React.useRef(value ?? "")
230
+ const openRef = React.useRef(false)
231
+ const activeIndexRef = React.useRef(-1)
232
+ const suppressNextFocusOpenRef = React.useRef(false)
233
+ const latestSuggestionsRef = React.useRef<DataTableSearchSuggestion[]>([])
234
+ const applySuggestionRef = React.useRef<
235
+ (
236
+ editorInstance: Editor | null,
237
+ suggestion: DataTableSearchSuggestion
238
+ ) => void
239
+ >(() => {})
240
+ const pendingSelectionRef = React.useRef<{
241
+ end: number
242
+ start: number
243
+ } | null>(null)
244
+ const [cursor, setCursor] = React.useState(0)
245
+ const [open, setOpen] = React.useState(false)
246
+ const [activeIndex, setActiveIndex] = React.useState(-1)
247
+
248
+ if (!fields || value === undefined || !onSearchChange) {
249
+ throw new Error(
250
+ "DataTableSearchInput must be used inside DataTableProvider or receive fields, value, and onSearchChange props."
251
+ )
252
+ }
253
+
254
+ const setActiveSuggestionIndex = React.useCallback((nextIndex: number) => {
255
+ activeIndexRef.current = nextIndex
256
+ setActiveIndex(nextIndex)
257
+ }, [])
258
+
259
+ const suggestions = React.useMemo(
260
+ () => getSearchSuggestions(value, cursor, fields),
261
+ [cursor, fields, value]
262
+ )
263
+ const inputSuggestions = React.useMemo(
264
+ () => suggestions.filter((suggestion) => suggestion.group === "input"),
265
+ [suggestions]
266
+ )
267
+ const valueSuggestions = React.useMemo(
268
+ () => suggestions.filter((suggestion) => suggestion.group === "values"),
269
+ [suggestions]
270
+ )
271
+ const filterSuggestions = React.useMemo(
272
+ () => suggestions.filter((suggestion) => suggestion.group === "filters"),
273
+ [suggestions]
274
+ )
275
+
276
+ const syncEditorValue = React.useCallback(
277
+ (
278
+ editorInstance: Editor,
279
+ nextValue: string,
280
+ selection?: {
281
+ end: number
282
+ start: number
283
+ }
284
+ ) => {
285
+ editorInstance.commands.setContent(createEditorDocument(nextValue), {
286
+ emitUpdate: false,
287
+ parseOptions: { preserveWhitespace: "full" },
288
+ })
289
+
290
+ if (selection) {
291
+ editorInstance.commands.setTextSelection({
292
+ from: textOffsetToDocPosition(
293
+ editorInstance.state.doc,
294
+ selection.start
295
+ ),
296
+ to: textOffsetToDocPosition(editorInstance.state.doc, selection.end),
297
+ })
298
+ }
299
+ },
300
+ []
301
+ )
302
+
303
+ const applySuggestion = React.useCallback(
304
+ (editorInstance: Editor | null, suggestion: DataTableSearchSuggestion) => {
305
+ const nextState = applySuggestionToValue(
306
+ value,
307
+ cursor,
308
+ fields,
309
+ suggestion
310
+ )
311
+
312
+ pendingSelectionRef.current = {
313
+ end: nextState.selectionStart,
314
+ start: nextState.selectionStart,
315
+ }
316
+ setCursor(nextState.selectionStart)
317
+ setOpen(nextState.keepOpen)
318
+
319
+ if (!nextState.keepOpen) {
320
+ suppressNextFocusOpenRef.current = true
321
+ }
322
+
323
+ if (editorInstance) {
324
+ syncEditorValue(
325
+ editorInstance,
326
+ nextState.value,
327
+ pendingSelectionRef.current
328
+ )
329
+ editorInstance.commands.focus()
330
+ }
331
+
332
+ onSearchChange(nextState.value)
333
+ },
334
+ [cursor, fields, onSearchChange, syncEditorValue, value]
335
+ )
336
+
337
+ const editor = useEditor(
338
+ {
339
+ content: createEditorDocument(value),
340
+ editorProps: {
341
+ attributes: {
342
+ autocapitalize: "off",
343
+ autocomplete: "off",
344
+ autocorrect: "off",
345
+ class:
346
+ "data-table-search-input-editor min-w-0 flex-1 overflow-x-auto px-2 py-0 text-sm outline-none whitespace-pre",
347
+ spellcheck: "false",
348
+ },
349
+ handleKeyDown: (_view, event) => {
350
+ if (event.key === "Escape") {
351
+ event.preventDefault()
352
+ suppressNextFocusOpenRef.current = true
353
+ setOpen(false)
354
+ return true
355
+ }
356
+
357
+ if (event.key === "ArrowDown") {
358
+ event.preventDefault()
359
+ setOpen(true)
360
+ setActiveSuggestionIndex(
361
+ moveActiveIndex(
362
+ activeIndexRef.current,
363
+ latestSuggestionsRef.current.length,
364
+ 1
365
+ )
366
+ )
367
+ return true
368
+ }
369
+
370
+ if (event.key === "ArrowUp") {
371
+ event.preventDefault()
372
+ setOpen(true)
373
+ setActiveSuggestionIndex(
374
+ moveActiveIndex(
375
+ activeIndexRef.current,
376
+ latestSuggestionsRef.current.length,
377
+ -1
378
+ )
379
+ )
380
+ return true
381
+ }
382
+
383
+ if (event.key === "Tab") {
384
+ const activeSuggestion =
385
+ latestSuggestionsRef.current[activeIndexRef.current]
386
+
387
+ if (!openRef.current || !activeSuggestion) {
388
+ return false
389
+ }
390
+
391
+ event.preventDefault()
392
+ applySuggestionRef.current(editor, activeSuggestion)
393
+ return true
394
+ }
395
+
396
+ if (event.key === "Enter") {
397
+ event.preventDefault()
398
+ event.stopPropagation()
399
+
400
+ const activeSuggestion =
401
+ latestSuggestionsRef.current[activeIndexRef.current]
402
+
403
+ if (openRef.current && activeSuggestion) {
404
+ applySuggestionRef.current(editor, activeSuggestion)
405
+ } else {
406
+ setCursor(getCursorOffset(editor))
407
+ }
408
+
409
+ return true
410
+ }
411
+
412
+ return false
413
+ },
414
+ handlePaste: (view, event) => {
415
+ const pastedText = event.clipboardData?.getData("text/plain")
416
+
417
+ if (typeof pastedText !== "string") {
418
+ return false
419
+ }
420
+
421
+ event.preventDefault()
422
+ view.dispatch(
423
+ view.state.tr.insertText(
424
+ normalizeSingleLineText(pastedText),
425
+ view.state.selection.from,
426
+ view.state.selection.to
427
+ )
428
+ )
429
+ return true
430
+ },
431
+ },
432
+ extensions: [
433
+ Document,
434
+ Paragraph,
435
+ Text,
436
+ Placeholder.configure({
437
+ placeholder,
438
+ }),
439
+ DataTableSelectorHighlighter,
440
+ Extension.create({
441
+ name: "singleLineSearchBehavior",
442
+ addKeyboardShortcuts() {
443
+ return {
444
+ Enter: () => true,
445
+ }
446
+ },
447
+ }),
448
+ ],
449
+ immediatelyRender: true,
450
+ onSelectionUpdate: ({ editor: nextEditor }) => {
451
+ setCursor(getCursorOffset(nextEditor))
452
+ },
453
+ onUpdate: ({ editor: nextEditor }) => {
454
+ const nextValue = getPlainText(nextEditor.state.doc)
455
+
456
+ setCursor(getCursorOffset(nextEditor))
457
+
458
+ if (nextValue !== valueRef.current) {
459
+ onSearchChange(nextValue)
460
+ }
461
+
462
+ setOpen(true)
463
+ },
464
+ },
465
+ []
466
+ )
467
+
468
+ React.useEffect(() => {
469
+ valueRef.current = value
470
+ }, [value])
471
+
472
+ React.useEffect(() => {
473
+ openRef.current = open
474
+ }, [open])
475
+
476
+ React.useEffect(() => {
477
+ activeIndexRef.current = activeIndex
478
+ }, [activeIndex])
479
+
480
+ React.useEffect(() => {
481
+ latestSuggestionsRef.current = suggestions
482
+ }, [suggestions])
483
+
484
+ React.useEffect(() => {
485
+ applySuggestionRef.current = applySuggestion
486
+ }, [applySuggestion])
487
+
488
+ React.useEffect(() => {
489
+ itemRefs.current.length = suggestions.length
490
+ }, [suggestions.length])
491
+
492
+ React.useEffect(() => {
493
+ selectorHighlighterState.fields = fields as DataTableSearchField<unknown>[]
494
+ }, [fields])
495
+
496
+ React.useEffect(() => {
497
+ if (!editor) {
498
+ return
499
+ }
500
+
501
+ editor.view.dispatch(editor.state.tr.setMeta(selectorRefreshKey, true))
502
+ }, [editor, fields])
503
+
504
+ React.useEffect(() => {
505
+ if (!editor) {
506
+ return
507
+ }
508
+
509
+ const nextSelection = pendingSelectionRef.current
510
+ const editorValue = getPlainText(editor.state.doc)
511
+
512
+ if (editorValue !== value) {
513
+ syncEditorValue(editor, value, nextSelection ?? undefined)
514
+ } else if (nextSelection) {
515
+ editor.commands.setTextSelection({
516
+ from: textOffsetToDocPosition(editor.state.doc, nextSelection.start),
517
+ to: textOffsetToDocPosition(editor.state.doc, nextSelection.end),
518
+ })
519
+ }
520
+
521
+ pendingSelectionRef.current = null
522
+ setCursor(getCursorOffset(editor))
523
+ }, [editor, syncEditorValue, value])
524
+
525
+ React.useEffect(() => {
526
+ const nextCursor = Math.min(cursor, value.length)
527
+
528
+ if (nextCursor !== cursor) {
529
+ setCursor(nextCursor)
530
+ }
531
+ }, [cursor, value.length])
532
+
533
+ React.useEffect(() => {
534
+ const nextIndex =
535
+ suggestions.length > 0
536
+ ? Math.min(activeIndex, suggestions.length - 1)
537
+ : -1
538
+
539
+ setActiveSuggestionIndex(open ? nextIndex : -1)
540
+ }, [activeIndex, open, setActiveSuggestionIndex, suggestions.length])
541
+
542
+ React.useEffect(() => {
543
+ if (!open || activeIndex < 0) {
544
+ return
545
+ }
546
+
547
+ itemRefs.current[activeIndex]?.scrollIntoView({
548
+ block: "nearest",
549
+ })
550
+ }, [activeIndex, open])
551
+
552
+ React.useEffect(() => {
553
+ const handlePointerDown = (event: PointerEvent) => {
554
+ if (!containerRef.current?.contains(event.target as Node)) {
555
+ setOpen(false)
556
+ }
557
+ }
558
+
559
+ window.addEventListener("pointerdown", handlePointerDown)
560
+
561
+ return () => window.removeEventListener("pointerdown", handlePointerDown)
562
+ }, [])
563
+
564
+ React.useEffect(() => {
565
+ if (!inputRef) {
566
+ return
567
+ }
568
+
569
+ assignRefValue(inputRef, {
570
+ focus: () => {
571
+ editor?.commands.focus()
572
+ },
573
+ selectAll: () => {
574
+ editor?.chain().focus().selectAll().run()
575
+ },
576
+ })
577
+
578
+ return () => assignRefValue(inputRef, null)
579
+ }, [editor, inputRef])
580
+
581
+ const showLoading = isLoading
582
+ const groupedSuggestions = [
583
+ { items: inputSuggestions, label: undefined },
584
+ { items: valueSuggestions, label: "Values" },
585
+ { items: filterSuggestions, label: "Filters" },
586
+ ] satisfies Array<{
587
+ items: DataTableSearchSuggestion[]
588
+ label?: string
589
+ }>
590
+
591
+ let flatIndex = 0
592
+
593
+ return (
594
+ <div ref={containerRef} className="relative min-w-64 flex-1">
595
+ <InputGroup className="h-10 w-full text-lg focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
596
+ <InputGroupAddon align="inline-start">
597
+ <span
598
+ aria-hidden="true"
599
+ className="flex size-5 shrink-0 items-center justify-center"
600
+ >
601
+ {showLoading ? (
602
+ <LoaderCircle className="size-4.5 animate-spin" />
603
+ ) : (
604
+ <Search className="size-4.5" />
605
+ )}
606
+ </span>
607
+ </InputGroupAddon>
608
+ <div className="min-w-0 flex-1">
609
+ <EditorContent
610
+ editor={editor}
611
+ onBlur={() => {
612
+ requestAnimationFrame(() => {
613
+ if (!containerRef.current?.contains(document.activeElement)) {
614
+ setOpen(false)
615
+ }
616
+ suppressNextFocusOpenRef.current = false
617
+ })
618
+ }}
619
+ onClick={() => {
620
+ setCursor(editor ? getCursorOffset(editor) : 0)
621
+ suppressNextFocusOpenRef.current = false
622
+ setOpen(true)
623
+ }}
624
+ onFocus={() => {
625
+ setCursor(editor ? getCursorOffset(editor) : 0)
626
+
627
+ if (suppressNextFocusOpenRef.current) {
628
+ suppressNextFocusOpenRef.current = false
629
+ return
630
+ }
631
+
632
+ setOpen(true)
633
+ }}
634
+ />
635
+ </div>
636
+ <InputGroupAddon align="inline-end">
637
+ {value ? (
638
+ <InputGroupButton
639
+ aria-label="Clear search"
640
+ size="icon-xs"
641
+ variant="ghost"
642
+ onClick={() => {
643
+ if (!editor) {
644
+ onSearchChange("")
645
+ setCursor(0)
646
+ setOpen(false)
647
+ return
648
+ }
649
+
650
+ pendingSelectionRef.current = { end: 0, start: 0 }
651
+ setCursor(0)
652
+ setOpen(false)
653
+ syncEditorValue(editor, "", pendingSelectionRef.current)
654
+ editor.commands.focus()
655
+ onSearchChange("")
656
+ }}
657
+ >
658
+ <XIcon className="pointer-events-none" />
659
+ </InputGroupButton>
660
+ ) : null}
661
+ </InputGroupAddon>
662
+ </InputGroup>
663
+
664
+ {open ? (
665
+ <div className="absolute top-full left-0 z-50 mt-1 w-full">
666
+ <div className="max-h-[min(calc(var(--spacing(72)---spacing(9)),calc(100svh---spacing(20)))] overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10">
667
+ {suggestions.length === 0 ? (
668
+ <div className="flex w-full justify-center py-2 text-center text-xs/relaxed text-muted-foreground">
669
+ No items found.
670
+ </div>
671
+ ) : (
672
+ <div className="max-h-[min(calc(var(--spacing(72)---spacing(9)),calc(100svh---spacing(20)))] overflow-y-auto p-1">
673
+ {groupedSuggestions.map((group) => {
674
+ if (group.items.length === 0) {
675
+ return null
676
+ }
677
+
678
+ return (
679
+ <div key={group.label ?? "Input"}>
680
+ {group.label ? (
681
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
682
+ {group.label}
683
+ </div>
684
+ ) : null}
685
+ {group.items.map((item) => {
686
+ const itemIndex = flatIndex
687
+
688
+ flatIndex += 1
689
+
690
+ return (
691
+ <button
692
+ key={item.id}
693
+ ref={(node) => {
694
+ itemRefs.current[itemIndex] = node
695
+ }}
696
+ type="button"
697
+ className={`relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-left text-xs/relaxed outline-hidden select-none hover:bg-accent hover:text-accent-foreground ${
698
+ itemIndex === activeIndex
699
+ ? "bg-accent text-accent-foreground"
700
+ : ""
701
+ }`}
702
+ data-highlighted={
703
+ itemIndex === activeIndex ? "" : undefined
704
+ }
705
+ onClick={() => applySuggestion(editor, item)}
706
+ onMouseDown={(event) => {
707
+ event.preventDefault()
708
+ }}
709
+ onMouseEnter={() =>
710
+ setActiveSuggestionIndex(itemIndex)
711
+ }
712
+ >
713
+ <span className="truncate">{item.label}</span>
714
+ </button>
715
+ )
716
+ })}
717
+ </div>
718
+ )
719
+ })}
720
+ </div>
721
+ )}
722
+ </div>
723
+ </div>
724
+ ) : null}
725
+ </div>
726
+ )
727
+ }
728
+
729
+ export const DataTableSearch = DataTableSearchInput