Skip to content

Commit c6a668c

Browse files
committed
feat(rich-editor): Tab accepts a suggestion; unify list keyboard nav; match chip styling
- Extract useSuggestionKeyboard: one hook owns the @/ menus' active-row state, scroll-into-view, and arrow/enter/tab handling (removes the duplication between the two list components) - Tab now accepts the active item like Enter, matching the chat composer - Render the mention chip like the chat input's mention token: borderless inline icon + label (no pill), 12px icon with brand color via getBareIconStyle, so the styling is consistent across surfaces
1 parent 62461c3 commit c6a668c

5 files changed

Lines changed: 124 additions & 98 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const items: MentionItem[] = [
2323

2424
const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) }
2525
const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) }
26+
const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) }
2627

2728
describe('MentionList keyboard nav', () => {
2829
let container: HTMLElement
@@ -69,6 +70,24 @@ describe('MentionList keyboard nav', () => {
6970
expect(command).toHaveBeenCalledWith(items[1])
7071
})
7172

73+
it('accepts the active item on Tab, like Enter', () => {
74+
const ref = createRef<MentionListHandle>()
75+
const command = vi.fn()
76+
const store = createMentionStore()
77+
78+
act(() => {
79+
root.render(<MentionList ref={ref} query='' command={command} store={store} />)
80+
})
81+
act(() => store.set(items))
82+
83+
let handled: boolean | undefined
84+
act(() => {
85+
handled = ref.current?.onKeyDown(tab)
86+
})
87+
expect(handled).toBe(true)
88+
expect(command).toHaveBeenCalledWith(items[0])
89+
})
90+
7291
it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => {
7392
const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
7493
act(() => {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
1-
import {
2-
forwardRef,
3-
useEffect,
4-
useImperativeHandle,
5-
useMemo,
6-
useRef,
7-
useState,
8-
useSyncExternalStore,
9-
} from 'react'
1+
import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react'
102
import { cn } from '@/lib/core/utils/cn'
113
import {
124
SUGGESTION_GROUP_LABEL_CLASS,
135
SUGGESTION_ITEM_CLASS,
146
SUGGESTION_SCROLL_CLASS,
157
SUGGESTION_SURFACE_CLASS,
168
} from '../menus/suggestion-menu-chrome'
9+
import {
10+
type SuggestionKeyDownHandler,
11+
useSuggestionKeyboard,
12+
} from '../menus/use-suggestion-keyboard'
1713
import type { MentionStore } from './mention-store'
1814
import type { MentionItem } from './types'
1915

20-
export interface MentionListHandle {
21-
onKeyDown: (props: { event: KeyboardEvent }) => boolean
22-
}
16+
export type MentionListHandle = SuggestionKeyDownHandler
2317

2418
interface MentionListProps {
2519
/** The text typed after `@`, used to filter. */
@@ -54,7 +48,6 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
5448
ref
5549
) {
5650
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
57-
const [activeIndex, setActiveIndex] = useState(0)
5851
const containerRef = useRef<HTMLDivElement>(null)
5952

6053
/** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */
@@ -80,44 +73,12 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
8073
return { flat, groups: ordered }
8174
}, [rawItems, query])
8275

83-
useEffect(() => {
84-
setActiveIndex(0)
85-
}, [flat])
86-
87-
useEffect(() => {
88-
containerRef.current
89-
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
90-
?.scrollIntoView({ block: 'nearest' })
91-
}, [activeIndex])
92-
93-
const latest = useRef({ flat, activeIndex, command })
94-
latest.current = { flat, activeIndex, command }
95-
96-
useImperativeHandle(
97-
ref,
98-
() => ({
99-
onKeyDown: ({ event }) => {
100-
const { flat, activeIndex, command } = latest.current
101-
if (flat.length === 0) return false
102-
if (event.key === 'ArrowUp') {
103-
setActiveIndex((i) => (i + flat.length - 1) % flat.length)
104-
return true
105-
}
106-
if (event.key === 'ArrowDown') {
107-
setActiveIndex((i) => (i + 1) % flat.length)
108-
return true
109-
}
110-
if (event.key === 'Enter') {
111-
const item = flat[activeIndex]
112-
if (!item) return false
113-
command(item)
114-
return true
115-
}
116-
return false
117-
},
118-
}),
119-
[]
76+
const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard(
77+
flat,
78+
command,
79+
containerRef
12080
)
81+
useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown])
12182

12283
if (flat.length === 0) {
12384
return (

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Node } from '@tiptap/core'
44
import type { ReactNodeViewProps } from '@tiptap/react'
55
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
66
import { useParams, useRouter } from 'next/navigation'
7-
import { cn } from '@/lib/core/utils/cn'
7+
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
88
import { mentionIcon } from './mention-icon'
99
import { simLinkPath, toSimHref } from './sim-link'
1010
import type { MentionKind } from './types'
@@ -103,16 +103,25 @@ export const MarkdownMention = Node.create({
103103
},
104104
})
105105

106+
/**
107+
* Mirrors the home chat input's mention rendering (the textarea mirror overlay
108+
* in `prompt-editor.tsx`): a borderless inline icon + label that flows with the
109+
* surrounding prose — no pill background, no padding, normal weight, body text
110+
* color, and a 12px icon. Integration icons keep their brand color via
111+
* {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay
112+
* monochrome through the `--text-icon` fallback below.
113+
*/
106114
const CHIP_CLASS =
107-
'mx-px inline-flex items-center gap-1 rounded-[4px] bg-[var(--surface-4)] px-1 align-middle font-medium text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[14px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
115+
'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
108116

109117
/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */
110118
function MentionChipView({ node }: ReactNodeViewProps) {
111119
const router = useRouter()
112120
const params = useParams()
113121
const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined
114122
const { kind, id, label } = node.attrs as MentionAttrs
115-
const Icon = mentionIcon(kind, id)
123+
const Icon = mentionIcon(kind, id) as StyleableIcon | undefined
124+
const iconStyle = Icon ? getBareIconStyle(Icon) : undefined
116125

117126
const handleClick = (event: MouseEvent) => {
118127
if (!(event.metaKey || event.ctrlKey) || !workspaceId) return
@@ -123,8 +132,8 @@ function MentionChipView({ node }: ReactNodeViewProps) {
123132
}
124133

125134
return (
126-
<NodeViewWrapper as='span' className={cn(CHIP_CLASS)} onClick={handleClick} title={label}>
127-
{Icon && <Icon />}
135+
<NodeViewWrapper as='span' className={CHIP_CLASS} onClick={handleClick} title={label}>
136+
{Icon && <Icon style={iconStyle} />}
128137
<span>{label}</span>
129138
</NodeViewWrapper>
130139
)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
type Dispatch,
3+
type RefObject,
4+
type SetStateAction,
5+
useCallback,
6+
useEffect,
7+
useRef,
8+
useState,
9+
} from 'react'
10+
11+
/** The imperative `onKeyDown` every suggestion list forwards from the popup. */
12+
export interface SuggestionKeyDownHandler {
13+
onKeyDown: (props: { event: KeyboardEvent }) => boolean
14+
}
15+
16+
interface SuggestionKeyboard extends SuggestionKeyDownHandler {
17+
activeIndex: number
18+
setActiveIndex: Dispatch<SetStateAction<number>>
19+
}
20+
21+
/**
22+
* Shared arrow/enter/tab navigation for the `/` and `@` suggestion lists. Owns the active-row state,
23+
* resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle
24+
* for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the
25+
* chat composer). The handle is stable and reads live values through a ref, because the suggestion
26+
* plugin captures it once via `ReactRenderer.ref` while the items may still be loading.
27+
*/
28+
export function useSuggestionKeyboard<T>(
29+
items: T[],
30+
onSelect: (item: T) => void,
31+
containerRef: RefObject<HTMLElement | null>
32+
): SuggestionKeyboard {
33+
const [activeIndex, setActiveIndex] = useState(0)
34+
35+
useEffect(() => {
36+
setActiveIndex(0)
37+
}, [items])
38+
39+
useEffect(() => {
40+
containerRef.current
41+
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
42+
?.scrollIntoView({ block: 'nearest' })
43+
}, [activeIndex, containerRef])
44+
45+
const latest = useRef({ items, activeIndex, onSelect })
46+
latest.current = { items, activeIndex, onSelect }
47+
48+
const onKeyDown = useCallback(({ event }: { event: KeyboardEvent }) => {
49+
const { items, activeIndex, onSelect } = latest.current
50+
if (items.length === 0) return false
51+
if (event.key === 'ArrowUp') {
52+
setActiveIndex((i) => (i + items.length - 1) % items.length)
53+
return true
54+
}
55+
if (event.key === 'ArrowDown') {
56+
setActiveIndex((i) => (i + 1) % items.length)
57+
return true
58+
}
59+
if (event.key === 'Enter' || event.key === 'Tab') {
60+
const item = items[activeIndex]
61+
if (!item) return false
62+
onSelect(item)
63+
return true
64+
}
65+
return false
66+
}, [])
67+
68+
return { activeIndex, setActiveIndex, onKeyDown }
69+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
1+
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
22
import { cn } from '@/lib/core/utils/cn'
33
import {
44
SUGGESTION_GROUP_LABEL_CLASS,
55
SUGGESTION_ITEM_CLASS,
66
SUGGESTION_SCROLL_CLASS,
77
SUGGESTION_SURFACE_CLASS,
88
} from '../menus/suggestion-menu-chrome'
9+
import {
10+
type SuggestionKeyDownHandler,
11+
useSuggestionKeyboard,
12+
} from '../menus/use-suggestion-keyboard'
913
import type { SlashCommandItem } from './commands'
1014

11-
export interface SlashCommandListHandle {
12-
onKeyDown: (props: { event: KeyboardEvent }) => boolean
13-
}
15+
export type SlashCommandListHandle = SuggestionKeyDownHandler
1416

1517
interface SlashCommandListProps {
1618
items: SlashCommandItem[]
@@ -24,47 +26,13 @@ interface SlashCommandListProps {
2426
*/
2527
export const SlashCommandList = forwardRef<SlashCommandListHandle, SlashCommandListProps>(
2628
function SlashCommandList({ items, command }, ref) {
27-
const [activeIndex, setActiveIndex] = useState(0)
2829
const containerRef = useRef<HTMLDivElement>(null)
29-
30-
useEffect(() => {
31-
setActiveIndex(0)
32-
}, [items])
33-
34-
useEffect(() => {
35-
containerRef.current
36-
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
37-
?.scrollIntoView({ block: 'nearest' })
38-
}, [activeIndex])
39-
40-
const latest = useRef({ items, activeIndex, command })
41-
latest.current = { items, activeIndex, command }
42-
43-
useImperativeHandle(
44-
ref,
45-
() => ({
46-
onKeyDown: ({ event }) => {
47-
const { items, activeIndex, command } = latest.current
48-
if (items.length === 0) return false
49-
if (event.key === 'ArrowUp') {
50-
setActiveIndex((i) => (i + items.length - 1) % items.length)
51-
return true
52-
}
53-
if (event.key === 'ArrowDown') {
54-
setActiveIndex((i) => (i + 1) % items.length)
55-
return true
56-
}
57-
if (event.key === 'Enter') {
58-
const item = items[activeIndex]
59-
if (!item) return false
60-
command(item)
61-
return true
62-
}
63-
return false
64-
},
65-
}),
66-
[]
30+
const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard(
31+
items,
32+
command,
33+
containerRef
6734
)
35+
useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown])
6836

6937
const groups = useMemo(() => {
7038
const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = []

0 commit comments

Comments
 (0)