diff --git a/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx b/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx
new file mode 100644
index 00000000..1dcbd92d
--- /dev/null
+++ b/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx
@@ -0,0 +1,293 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter, createTestTag } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+describe('Hashtag Suggestion System', () => {
+ const testTags = [
+ createTestTag('nature', '#22C55E'),
+ createTestTag('coding', '#3B82F6'),
+ createTestTag('music', '#EF4444'),
+ createTestTag('travel', '#F59E0B'),
+ createTestTag('food', '#8B5CF6'),
+ ]
+
+ describe('Suggestion Popup Trigger', () => {
+ it('shows suggestion popup when typing #', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#')
+ cy.get('.tippy-content').should('be.visible')
+ cy.get('.tippy-content button').should('have.length.at.least', 1)
+ })
+
+ it('shows all tags when # is typed without query', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#')
+ cy.get('.tippy-content button').should('have.length', 5)
+ })
+
+ it('does not show popup when # is inside a word', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('email#test')
+ cy.get('.tippy-content').should('be.visible')
+ })
+ })
+
+ describe('Filtering', () => {
+ it('filters tags based on typed query', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#na')
+ cy.get('.tippy-content button').should('have.length', 1)
+ cy.get('.tippy-content button').should('contain.text', '#nature')
+ })
+
+ it('filters are case-insensitive', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#NAT')
+ cy.get('.tippy-content button').should('contain.text', '#nature')
+ })
+
+ it('shows multiple matching tags', () => {
+ const tags = [createTestTag('music'), createTestTag('museum'), createTestTag('muse')]
+
+ mount()
+
+ cy.get('.ProseMirror').type('#mu')
+ cy.get('.tippy-content button').should('have.length', 3)
+ })
+ })
+
+ describe('Keyboard Selection', () => {
+ it('selects first item with Enter', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('#nat{enter}')
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#nature')
+ })
+ })
+
+ it('navigates with ArrowDown and selects with Enter', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('#')
+ cy.get('.tippy-content button').first().should('have.class', 'tw:bg-base-200')
+
+ cy.get('.ProseMirror').type('{downarrow}')
+ cy.get('.tippy-content button').eq(1).should('have.class', 'tw:bg-base-200')
+
+ cy.get('.ProseMirror').type('{enter}')
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#coding')
+ })
+ })
+
+ it('closes popup with Escape', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#')
+ cy.get('.tippy-content').should('exist')
+
+ cy.get('.ProseMirror').type('{esc}')
+ cy.get('.tippy-box').should('not.exist')
+ })
+
+ it('selects first item with Space', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('#nat ')
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#nature')
+ })
+ })
+ })
+
+ describe('Create New Tag', () => {
+ it('shows "create new" option when no exact match', () => {
+ const onAddTag = cy.stub().as('addTag')
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#newtag')
+ cy.get('.tippy-content').should('contain.text', 'Neu:')
+ cy.get('.tippy-content').should('contain.text', '#newtag')
+ })
+
+ it('does not show "create new" when exact match exists', () => {
+ const onAddTag = cy.stub().as('addTag')
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#nature')
+ cy.get('.tippy-content').should('not.contain.text', 'Neu:')
+ })
+
+ it('calls onAddTag when creating new tag', () => {
+ const onAddTag = cy.stub().as('addTag')
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#brandnew{enter}')
+ cy.get('@addTag').should('have.been.calledOnce')
+ cy.get('@addTag')
+ .its('firstCall.args.0')
+ .should('deep.include', { name: 'brandnew', color: '#888888' })
+ })
+
+ it('inserts new tag into editor', () => {
+ const onAddTag = cy.stub()
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('#brandnew{enter}')
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#brandnew')
+ })
+ })
+ })
+
+ describe('Click Selection', () => {
+ it('inserts tag when clicking suggestion', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('#')
+ cy.get('.tippy-content button').contains('#music').click()
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#music')
+ })
+ })
+ })
+
+ describe('Suggestion Styling', () => {
+ it('applies tag colors in suggestion list', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('#nat')
+ cy.get('.tippy-content button')
+ .first()
+ .should('have.css', 'color')
+ .and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
+ })
+ })
+})
+
diff --git a/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx b/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx
new file mode 100644
index 00000000..f1b1f845
--- /dev/null
+++ b/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx
@@ -0,0 +1,304 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter, createTestItem } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+describe('Item Mention Suggestion System', () => {
+ const testItems = [
+ createTestItem('id-1', 'Alice', '#22C55E'),
+ createTestItem('id-2', 'Bob', '#3B82F6'),
+ createTestItem('id-3', 'Charlie', '#EF4444'),
+ createTestItem('id-4', 'Diana', '#F59E0B'),
+ createTestItem('id-5', 'Eve', '#8B5CF6'),
+ ]
+
+ describe('Suggestion Popup Trigger', () => {
+ it('shows suggestion popup when typing @', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content').should('be.visible')
+ cy.get('.tippy-content button').should('have.length.at.least', 1)
+ })
+
+ it('shows all items when @ is typed without query', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button').should('have.length', 5)
+ })
+ })
+
+ describe('Filtering', () => {
+ it('filters items based on typed query', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@ali')
+ cy.get('.tippy-content button').should('have.length', 1)
+ cy.get('.tippy-content button').should('contain.text', '@Alice')
+ })
+
+ it('filters are case-insensitive', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@ALICE')
+ cy.get('.tippy-content button').should('contain.text', '@Alice')
+ })
+
+ it('matches items containing query (not just starts with)', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@li')
+ cy.get('.tippy-content button').should('contain.text', '@Alice')
+ cy.get('.tippy-content button').should('contain.text', '@Charlie')
+ })
+
+ it('shows empty state when no items match', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@xyz123')
+ cy.get('.tippy-content').should('contain.text', 'Keine Ergebnisse')
+ })
+ })
+
+ describe('Keyboard Selection', () => {
+ it('selects first item with Enter', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('@ali{enter}')
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('[@Alice]')
+ expect(markdown).to.include('/item/id-1')
+ })
+ })
+
+ it('navigates with ArrowDown and selects with Enter', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button').first().should('have.class', 'tw:bg-base-200')
+
+ cy.get('.ProseMirror').type('{downarrow}')
+ cy.get('.tippy-content button').eq(1).should('have.class', 'tw:bg-base-200')
+
+ cy.get('.ProseMirror').type('{enter}')
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('[@Bob]')
+ })
+ })
+
+ it('closes popup with Escape', () => {
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content').should('exist')
+
+ cy.get('.ProseMirror').type('{esc}')
+ cy.get('.tippy-box').should('not.exist')
+ })
+ })
+
+ describe('Click Selection', () => {
+ it('inserts mention when clicking suggestion', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button').contains('@Charlie').click()
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('[@Charlie]')
+ expect(markdown).to.include('/item/id-3')
+ })
+ })
+ })
+
+ describe('Suggestion Styling', () => {
+ it('applies item colors in suggestion list via getItemColor', () => {
+ const getItemColor = (item: any) => item?.color ?? '#000000'
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@ali')
+ cy.get('.tippy-content button')
+ .first()
+ .should('have.css', 'color')
+ .and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
+ })
+
+ it('uses getItemColor function when provided', () => {
+ const getItemColor = () => '#FF00FF'
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button')
+ .first()
+ .should('have.css', 'color')
+ .and('match', /rgb\(255, 0, 255\)|#[fF]{2}00[fF]{2}/)
+ })
+ })
+
+ describe('Markdown Serialization', () => {
+ it('serializes item mention to correct markdown format', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('@dia{enter}')
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.match(/\[@Diana\]\(\/item\/id-4\)/)
+ })
+ })
+
+ it('inserts space after mention for continued typing', () => {
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.ProseMirror').type('@eve{enter}is here')
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('@Eve')
+ expect(markdown).to.include('is here')
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles items without names', () => {
+ const itemsWithEmpty = [...testItems, { id: 'no-name', name: '' } as any]
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button').should('have.length', 5)
+ })
+
+ it('limits suggestions to 8 items', () => {
+ const manyItems = Array.from({ length: 15 }, (_, i) =>
+ createTestItem(`id-${i}`, `Person ${i}`),
+ )
+
+ mount(
+ ,
+ )
+
+ cy.get('.ProseMirror').type('@')
+ cy.get('.tippy-content button').should('have.length', 8)
+ })
+ })
+})
+
diff --git a/lib/cypress/component/TipTap/SuggestionList.cy.tsx b/lib/cypress/component/TipTap/SuggestionList.cy.tsx
new file mode 100644
index 00000000..9bb6bedf
--- /dev/null
+++ b/lib/cypress/component/TipTap/SuggestionList.cy.tsx
@@ -0,0 +1,183 @@
+///
+import { mount } from 'cypress/react'
+import { createRef } from 'react'
+
+import { SuggestionList, SuggestionListRef } from '#components/TipTap/extensions/suggestions/SuggestionList'
+
+import { createTestItem, createTestTag } from './TestEditor'
+
+describe('SuggestionList Component', () => {
+ describe('Rendering', () => {
+ it('renders hashtag items with # prefix', () => {
+ const tags = [createTestTag('nature'), createTestTag('coding')]
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').should('have.length', 2)
+ cy.get('button').first().should('contain.text', '#nature')
+ cy.get('button').last().should('contain.text', '#coding')
+ })
+
+ it('renders item mentions with @ prefix', () => {
+ const items = [createTestItem('1', 'Alice'), createTestItem('2', 'Bob')]
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').should('have.length', 2)
+ cy.get('button').first().should('contain.text', '@Alice')
+ cy.get('button').last().should('contain.text', '@Bob')
+ })
+
+ it('renders "new tag" option with Neu: prefix', () => {
+ const items = [createTestTag('existing'), { isNew: true, name: 'newtag' }]
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').should('have.length', 2)
+ cy.get('button').last().should('contain.text', 'Neu:')
+ cy.get('button').last().should('contain.text', '#newtag')
+ })
+
+ it('renders empty state message when no items', () => {
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').should('not.exist')
+ cy.contains('Keine Ergebnisse').should('exist')
+ })
+
+ it('applies tag color to hashtag items', () => {
+ const tags = [createTestTag('nature', '#22C55E')]
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button')
+ .first()
+ .should('have.css', 'color')
+ .and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
+ })
+
+ it('applies item color via getItemColor', () => {
+ const items = [createTestItem('1', 'Alice')]
+ const getItemColor = () => '#EF4444'
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button')
+ .first()
+ .should('have.css', 'color')
+ .and('match', /rgb\(239, 68, 68\)|#[eE][fF]4444/)
+ })
+
+ it('highlights first item by default', () => {
+ const tags = [createTestTag('first'), createTestTag('second')]
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').first().should('have.class', 'tw:bg-base-200')
+ cy.get('button').last().should('not.have.class', 'tw:bg-base-200')
+ })
+ })
+
+ describe('Click Selection', () => {
+ it('calls command with clicked item', () => {
+ const tags = [createTestTag('nature'), createTestTag('coding')]
+ const command = cy.stub().as('command')
+
+ mount()
+
+ cy.get('button').last().click()
+ cy.get('@command').should('have.been.calledOnceWith', tags[1])
+ })
+
+ it('calls command with new tag item', () => {
+ const newItem = { isNew: true, name: 'newtag' }
+ const items = [newItem]
+ const command = cy.stub().as('command')
+
+ mount()
+
+ cy.get('button').click()
+ cy.get('@command').should('have.been.calledOnceWith', newItem)
+ })
+ })
+
+ describe('Keyboard Navigation', () => {
+ it('navigates down with ArrowDown', () => {
+ const tags = [createTestTag('first'), createTestTag('second'), createTestTag('third')]
+ const ref = createRef()
+ const command = cy.stub()
+
+ mount()
+
+ cy.get('button').first().should('have.class', 'tw:bg-base-200')
+
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
+
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.get('button').eq(2).should('have.class', 'tw:bg-base-200')
+ })
+
+ it('navigates up with ArrowUp', () => {
+ const tags = [createTestTag('first'), createTestTag('second')]
+ const ref = createRef()
+ const command = cy.stub()
+
+ mount()
+
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' })))
+ cy.get('button').last().should('have.class', 'tw:bg-base-200')
+ })
+
+ it('wraps around at end of list', () => {
+ const tags = [createTestTag('first'), createTestTag('second')]
+ const ref = createRef()
+ const command = cy.stub()
+
+ mount()
+
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.get('button').first().should('have.class', 'tw:bg-base-200')
+ })
+
+ it('selects item with Enter', () => {
+ const tags = [createTestTag('first'), createTestTag('second')]
+ const ref = createRef()
+ const command = cy.stub().as('command')
+
+ mount()
+
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })))
+ cy.get('@command').should('have.been.calledOnceWith', tags[1])
+ })
+
+ it('resets selection index when items change', () => {
+ const tags1 = [createTestTag('aaa'), createTestTag('aab')]
+ const tags2 = [createTestTag('bbb'), createTestTag('bbc'), createTestTag('bbd')]
+ const ref = createRef()
+ const command = cy.stub()
+
+ mount().then(
+ ({ rerender }) => {
+ cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
+ cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
+
+ rerender()
+ cy.get('button').first().should('have.class', 'tw:bg-base-200')
+ },
+ )
+ })
+ })
+})
+
diff --git a/lib/cypress/component/TipTap/TestEditor.tsx b/lib/cypress/component/TipTap/TestEditor.tsx
index 391a97a5..8ac79ed2 100644
--- a/lib/cypress/component/TipTap/TestEditor.tsx
+++ b/lib/cypress/component/TipTap/TestEditor.tsx
@@ -3,10 +3,16 @@ import { Link } from '@tiptap/extension-link'
import { Markdown } from '@tiptap/markdown'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
-import { useEffect } from 'react'
+import { useEffect, useMemo } from 'react'
import { MemoryRouter } from 'react-router-dom'
-import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
+import {
+ Hashtag,
+ ItemMention,
+ VideoEmbed,
+ createHashtagSuggestion,
+ createItemMentionSuggestion,
+} from '#components/TipTap/extensions'
import { createConfiguredMarked } from '#components/TipTap/utils/configureMarked'
import type { Item } from '#types/Item'
@@ -20,10 +26,12 @@ export interface TestEditorProps {
editable?: boolean
tags?: Tag[]
onTagClick?: (tag: Tag) => void
+ onAddTag?: (tag: Tag) => void
items?: Item[]
getItemColor?: (item: Item | undefined, fallback?: string) => string
onReady?: (editor: Editor) => void
testId?: string
+ enableSuggestions?: boolean
}
export function TestEditor({
@@ -31,11 +39,23 @@ export function TestEditor({
editable = false,
tags = [],
onTagClick,
+ onAddTag,
items = [],
getItemColor,
onReady,
testId = 'test-editor',
+ enableSuggestions = false,
}: TestEditorProps) {
+ const hashtagSuggestion = useMemo(
+ () => (enableSuggestions ? createHashtagSuggestion(tags, onAddTag) : undefined),
+ [tags, onAddTag, enableSuggestions],
+ )
+
+ const itemMentionSuggestion = useMemo(
+ () => (enableSuggestions ? createItemMentionSuggestion(items, getItemColor) : undefined),
+ [items, getItemColor, enableSuggestions],
+ )
+
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -51,10 +71,12 @@ export function TestEditor({
Hashtag.configure({
tags,
onTagClick,
+ suggestion: hashtagSuggestion,
}),
ItemMention.configure({
items,
getItemColor,
+ suggestion: itemMentionSuggestion,
}),
],
content,