diff --git a/packages/vue/markput/src/components/Container.vue b/packages/vue/markput/src/components/Container.vue
index d02340d6..a03e52dd 100644
--- a/packages/vue/markput/src/components/Container.vue
+++ b/packages/vue/markput/src/components/Container.vue
@@ -1,31 +1,37 @@
-
+ const containerTag = computed(() => resolveSlot('container', slots.value))
+ const containerProps = computed(() => resolveSlotProps('container', slotProps.value))
-
-
-
-
-
+ return () =>
+ h(
+ containerTag.value,
+ {
+ ref: (el: any) => {
+ store.refs.container = el?.$el ?? el
+ },
+ ...containerProps.value,
+ class: className.value,
+ style: style.value,
+ },
+ tokens.value.map(token => h(Token, {key: key.get(token), mark: token}))
+ )
+ },
+})
+
diff --git a/packages/vue/markput/src/components/MarkRenderer.vue b/packages/vue/markput/src/components/MarkRenderer.vue
index 8a9ec892..4b50092e 100644
--- a/packages/vue/markput/src/components/MarkRenderer.vue
+++ b/packages/vue/markput/src/components/MarkRenderer.vue
@@ -50,7 +50,8 @@ const resolved = computed(() => {
-
+
+
diff --git a/packages/vue/markput/src/components/Token.vue b/packages/vue/markput/src/components/Token.vue
index e63b838f..8b16ed5d 100644
--- a/packages/vue/markput/src/components/Token.vue
+++ b/packages/vue/markput/src/components/Token.vue
@@ -1,30 +1,28 @@
-
-
-
-
- {{ mark.content }}
-
-
diff --git a/packages/vue/markput/src/lib/utils/resolveSlot.ts b/packages/vue/markput/src/lib/utils/resolveSlot.ts
index 76ca36dc..86e3af2c 100644
--- a/packages/vue/markput/src/lib/utils/resolveSlot.ts
+++ b/packages/vue/markput/src/lib/utils/resolveSlot.ts
@@ -1,5 +1,6 @@
import type {CoreSlotProps, CoreSlots} from '@markput/core'
import {convertDataAttrs} from '@markput/core'
+import type {Component} from 'vue'
export type SlotName = 'container' | 'span'
@@ -8,9 +9,9 @@ const defaultSlots: Record = {
span: 'span',
}
-export function resolveSlot(slotName: SlotName, slots: CoreSlots | undefined): string {
+export function resolveSlot(slotName: SlotName, slots: CoreSlots | undefined): string | Component {
if (slots?.[slotName]) {
- return slots[slotName] as string
+ return slots[slotName] as string | Component
}
return defaultSlots[slotName]
diff --git a/packages/vue/markput/src/types.ts b/packages/vue/markput/src/types.ts
index b7aa95bf..bc28d644 100644
--- a/packages/vue/markput/src/types.ts
+++ b/packages/vue/markput/src/types.ts
@@ -37,8 +37,8 @@ export interface MarkedInputProps}`, string | number | boolean | undefined>
diff --git a/packages/vue/storybook/package.json b/packages/vue/storybook/package.json
index 94fc2776..eb155666 100644
--- a/packages/vue/storybook/package.json
+++ b/packages/vue/storybook/package.json
@@ -4,7 +4,11 @@
"type": "module",
"scripts": {
"dev": "storybook dev",
- "build": "storybook build -o ./dist"
+ "build": "storybook build -o ./dist",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:ui": "vitest --ui",
+ "coverage": "vitest run --coverage"
},
"dependencies": {
"@markput/vue": "workspace:*",
@@ -15,8 +19,15 @@
"@storybook/addon-links": "catalog:",
"@storybook/cli": "catalog:",
"@storybook/vue3-vite": "catalog:",
+ "@types/node": "catalog:",
"@vitejs/plugin-vue": "catalog:",
+ "@vitest/browser-playwright": "catalog:",
+ "@vitest/coverage-v8": "catalog:",
+ "@vitest/ui": "catalog:",
+ "playwright": "catalog:",
"storybook": "catalog:",
- "vite": "catalog:"
+ "vite": "catalog:",
+ "vitest": "catalog:",
+ "vitest-browser-vue": "catalog:"
}
}
diff --git a/packages/vue/storybook/src/pages/Base/Base.spec.ts b/packages/vue/storybook/src/pages/Base/Base.spec.ts
new file mode 100644
index 00000000..912c0c4c
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Base/Base.spec.ts
@@ -0,0 +1,187 @@
+import type {Markup} from '@markput/vue'
+import {useMark} from '@markput/vue'
+import {composeStories} from '@storybook/vue3-vite'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {page, userEvent} from 'vitest/browser'
+import {defineComponent, h, onMounted, ref, type ComponentPublicInstance} from 'vue'
+
+import {focusAtEnd, focusAtStart} from '../../shared/lib/focus'
+import {withProps} from '../../shared/lib/testUtils'
+import * as BaseStories from './Base.stories'
+
+const {Default} = composeStories(BaseStories)
+
+describe('Component: MarkedInput', () => {
+ it.todo('should set readOnly on selection')
+
+ it('should correct process an annotation type', async () => {
+ const Mark = defineComponent({
+ props: {value: String, meta: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ const {container} = await render(withProps(Default, {Mark, defaultValue: ''}))
+
+ const [span] = container.querySelectorAll('span')
+ await expect.element(span).toHaveTextContent('')
+
+ await userEvent.type(span, '@[[mark](1)')
+
+ await expect.element(page.getByText('mark')).toBeInTheDocument()
+ })
+
+ const FocusableMark = defineComponent({
+ setup() {
+ const mark = useMark({controlled: true})
+ const elRef = ref(null)
+
+ onMounted(() => {
+ if (elRef.value) elRef.value.textContent = mark.value ?? null
+ })
+
+ return () =>
+ h('abbr', {
+ ref: (el: Element | ComponentPublicInstance | null) => {
+ elRef.value = el as HTMLElement | null
+ mark.ref.current = el as HTMLElement | null
+ },
+ title: mark.meta,
+ contentEditable: true,
+ style: {
+ outline: 'none',
+ whiteSpace: 'pre-wrap',
+ },
+ })
+ },
+ })
+
+ const RemovableMark = defineComponent({
+ setup() {
+ const mark = useMark()
+ return () => h('mark', {onClick: () => mark.remove()}, mark.value)
+ },
+ })
+
+ it('should support ref focusing target', async () => {
+ await render(
+ withProps(Default, {
+ Mark: FocusableMark,
+ value: 'Hello, @[focusable](By key operations) abbreviation @[world](Hello! Hello!)!',
+ })
+ )
+
+ const spans = document.querySelectorAll('span[contenteditable]')
+ const [firstSpan, secondSpan] = Array.from(spans) as HTMLElement[]
+ const abbrs = document.querySelectorAll('abbr')
+ const [firstAbbr] = Array.from(abbrs) as HTMLElement[]
+ const firstSpanLength = firstSpan.textContent?.length ?? 0
+ const firstAbbrLength = firstAbbr.textContent?.length ?? 0
+
+ await focusAtStart(firstSpan)
+ await expect.element(firstSpan).toHaveFocus()
+
+ await userEvent.keyboard(`{ArrowRight>${firstSpanLength + 1}/}`)
+ await expect.element(firstAbbr).toHaveFocus()
+
+ await userEvent.keyboard(`{ArrowLeft>2/}`)
+ await expect.element(firstSpan).toHaveFocus()
+
+ await userEvent.keyboard(`{ArrowRight>2/}`)
+ await expect.element(firstAbbr).toHaveFocus()
+
+ await userEvent.keyboard(`{ArrowRight>${firstAbbrLength + 1}/}`)
+ await expect.element(secondSpan).toHaveFocus()
+ })
+
+ it('should support remove itself', async () => {
+ await render(
+ withProps(Default, {
+ Mark: RemovableMark,
+ value: 'I @[contain]( ) @[removable]( ) by click @[marks]( )!',
+ })
+ )
+
+ let mark = page.getByText('contain')
+ await userEvent.click(mark)
+ await expect.element(page.getByText('contain')).not.toBeInTheDocument()
+
+ mark = page.getByText('marks')
+ await userEvent.click(mark)
+ await expect.element(page.getByText('marks')).not.toBeInTheDocument()
+ })
+
+ it('should support editable marks', async () => {
+ await render(
+ withProps(Default, {
+ Mark: FocusableMark,
+ value: 'Hello, @[focusable](By key operations) abbreviation @[world](Hello! Hello!)!',
+ })
+ )
+
+ const worldElement = page.getByText('world').first().element() as HTMLElement
+ await focusAtEnd(worldElement)
+ await userEvent.keyboard('123')
+
+ await expect.element(page.getByText('world123').first()).toBeInTheDocument()
+ })
+
+ it('should support to pass a forward overlay', async () => {
+ const Overlay = defineComponent({
+ setup() {
+ return () => h('span', null, "I'm here!")
+ },
+ })
+
+ await render(
+ withProps(Default, {
+ Mark: defineComponent({setup: () => () => null}),
+ Overlay,
+ showOverlayOn: 'selectionChange',
+ defaultValue: 'Hello @',
+ })
+ )
+
+ const span = page.getByText(/hello/i)
+ await focusAtEnd(span.element() as HTMLElement)
+ await userEvent.keyboard('{ArrowRight}')
+ await expect.element(span).toHaveFocus()
+
+ await expect.element(page.getByText("I'm here!")).toBeInTheDocument()
+ })
+
+ it('should not create empty mark when pressing Enter in overlay without selection', async () => {
+ const Mark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ await render(
+ withProps(Default, {
+ Mark,
+ options: [
+ {
+ markup: '@[__value__](test:__meta__)' as Markup,
+ overlay: {trigger: '@', data: ['one', 'two', 'three']},
+ },
+ ],
+ defaultValue: 'Hello @',
+ })
+ )
+
+ const span = page.getByText(/hello/i)
+ await focusAtEnd(span.element() as HTMLElement)
+ await userEvent.keyboard('{ArrowRight}')
+ await userEvent.keyboard('{Enter}')
+
+ await expect.element(page.getByText('one')).not.toBeInTheDocument()
+ await expect.element(page.getByText('two')).not.toBeInTheDocument()
+ await expect.element(page.getByText('three')).not.toBeInTheDocument()
+ })
+
+ it.todo('should be selectable')
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/Base/MarkputHandler.spec.ts b/packages/vue/storybook/src/pages/Base/MarkputHandler.spec.ts
new file mode 100644
index 00000000..211fed0c
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Base/MarkputHandler.spec.ts
@@ -0,0 +1,19 @@
+import type {MarkputHandler} from '@markput/vue'
+import {composeStories} from '@storybook/vue3-vite'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {ref} from 'vue'
+
+import * as BaseStories from './Base.stories'
+
+const {Default} = composeStories(BaseStories)
+
+describe('API: MarkputHandler', () => {
+ it('should support the ref prop for accessing component handler', async () => {
+ const handler = ref(null)
+
+ await render(Default, {ref: handler})
+
+ expect(handler.value?.container).not.toBeNull()
+ })
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/Base/keyboard.spec.ts b/packages/vue/storybook/src/pages/Base/keyboard.spec.ts
new file mode 100644
index 00000000..d37ce609
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Base/keyboard.spec.ts
@@ -0,0 +1,87 @@
+import {composeStories} from '@storybook/vue3-vite'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {page, userEvent} from 'vitest/browser'
+
+import {focusAtEnd, focusAtStart} from '../../shared/lib/focus'
+import {withProps} from '../../shared/lib/testUtils'
+import * as BaseStories from './Base.stories'
+
+const {Default} = composeStories(BaseStories)
+
+describe('Api: keyboard', () => {
+ it('should support the "Backspace" button', async () => {
+ await render(withProps(Default, {defaultValue: 'Hello @[world](1)!'}))
+
+ const tailSpan = page.getByText('!').element() as HTMLElement
+ await focusAtEnd(tailSpan)
+
+ await userEvent.keyboard('{Backspace}')
+ await expect.element(tailSpan).toHaveTextContent('')
+
+ const mark = page.getByText(/world/)
+ await expect.element(mark).toBeInTheDocument()
+ await userEvent.keyboard('{Backspace}')
+ await expect.element(mark).not.toBeInTheDocument()
+ await expect.element(tailSpan).not.toBeInTheDocument()
+
+ const headSpan = page.getByText(/Hello/).element() as HTMLElement
+ await focusAtEnd(headSpan)
+ await expect.element(headSpan).toHaveTextContent('Hello')
+ await expect.element(headSpan).toHaveFocus()
+ await userEvent.keyboard('{Backspace>7/}')
+ expect(headSpan.textContent).toBe('')
+ })
+
+ it('should support the "Delete" button', async () => {
+ await render(withProps(Default, {defaultValue: 'Hello @[world](1)!'}))
+
+ const firstSpan = page.getByText(/Hello/).element() as HTMLElement
+ await focusAtStart(firstSpan)
+
+ await userEvent.keyboard('{Delete>6/}')
+ await expect.element(firstSpan).toHaveTextContent('')
+
+ const mark = page.getByText(/world/)
+ await expect.element(mark).toBeInTheDocument()
+ await userEvent.keyboard('{Delete}')
+ await expect.element(mark).not.toBeInTheDocument()
+ await expect.element(firstSpan).not.toBeInTheDocument()
+
+ const secondSpan = page.getByText('!').element() as HTMLElement
+ await expect.element(secondSpan).toHaveFocus()
+ await expect.element(secondSpan).toHaveTextContent('!')
+ await userEvent.keyboard('{Delete>2/}')
+ await expect.element(secondSpan).toHaveTextContent('')
+ })
+
+ it('should support focus navigation between spans', async () => {
+ await render(withProps(Default, {defaultValue: 'Hello @[world](1)!'}))
+
+ const firstSpan = page.getByText(/Hello/).element() as HTMLElement
+ await focusAtStart(firstSpan)
+
+ const secondSpan = page.getByText('!').element() as HTMLElement
+ const firstSpanLength = firstSpan.textContent?.length ?? 0
+ await userEvent.keyboard(`{ArrowRight>${firstSpanLength + 1}/}`)
+ await expect.element(secondSpan).toHaveFocus()
+
+ await userEvent.keyboard(`{ArrowLeft>1/}`)
+ await expect.element(firstSpan).toHaveFocus()
+ })
+
+ it.skip('should select all text with keyboard shortcut "Ctrl+A"', async () => {
+ const {container} = await render(withProps(Default, {defaultValue: 'Hello @[world](1)!'}))
+
+ expect(window.getSelection()?.toString()).toBe('')
+
+ await userEvent.click(container)
+ await userEvent.keyboard('{ControlOrMeta>}a{/ControlOrMeta}')
+
+ expect(window.getSelection()?.toString()).toBe(container.textContent)
+ })
+
+ it.todo('should replace all content when Ctrl+A then type')
+ it.todo('should replace all content when Ctrl+A then paste')
+ it.todo('should clear all content when Ctrl+A then delete')
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/Nested/nested.spec.ts b/packages/vue/storybook/src/pages/Nested/nested.spec.ts
new file mode 100644
index 00000000..e07fdb96
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Nested/nested.spec.ts
@@ -0,0 +1,515 @@
+import type {Markup} from '@markput/vue'
+import {MarkedInput, useMark} from '@markput/vue'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {page} from 'vitest/browser'
+import {defineComponent, h} from 'vue'
+
+describe('Nested Marks Rendering', () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () =>
+ h(
+ 'span',
+ {
+ 'data-testid': `mark-depth-${mark.depth}`,
+ 'data-depth': mark.depth,
+ 'data-has-children': mark.hasChildren,
+ },
+ slots.default?.() ?? props.value
+ )
+ },
+ })
+
+ it('should render simple nested marks', async () => {
+ const markup: Markup = '@[__nested__]'
+ const value = '@[outer @[inner]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const outerMark = page.getByTestId('mark-depth-0')
+ const innerMark = page.getByTestId('mark-depth-1')
+
+ await expect.element(outerMark).toBeInTheDocument()
+ await expect.element(innerMark).toBeInTheDocument()
+ expect(outerMark.element().getAttribute('data-has-children')).toBe('true')
+ expect(innerMark.element().getAttribute('data-has-children')).toBe('false')
+ })
+
+ it('should render multiple nesting levels', async () => {
+ const markup: Markup = '@[__nested__]'
+ const value = '@[level0 @[level1 @[level2]]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const level0 = page.getByTestId('mark-depth-0')
+ const level1 = page.getByTestId('mark-depth-1')
+ const level2 = page.getByTestId('mark-depth-2')
+
+ await expect.element(level0).toBeInTheDocument()
+ await expect.element(level1).toBeInTheDocument()
+ await expect.element(level2).toBeInTheDocument()
+ })
+
+ it('should render multiple nested marks at same level', async () => {
+ const markup: Markup = '@[__nested__]'
+ const value = '@[outer @[first] and @[second]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const outerMark = page.getByTestId('mark-depth-0')
+ const nestedMarks = page.getByTestId('mark-depth-1').all()
+
+ await expect.element(outerMark).toBeInTheDocument()
+ expect(nestedMarks).toHaveLength(2)
+ })
+
+ it('should render different markup types nested', async () => {
+ const TagMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ const isTag = mark.content.startsWith('#')
+ return () =>
+ h(
+ 'span',
+ {
+ 'data-testid': isTag ? 'tag-mark' : 'mention-mark',
+ 'data-depth': mark.depth,
+ },
+ slots.default?.() ?? props.value
+ )
+ },
+ })
+
+ const tagMarkup: Markup = '#[__nested__]'
+ const mentionMarkup: Markup = '@[__nested__]'
+ const value = '#[tag with @[mention]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TagMark,
+ value,
+ options: [
+ {markup: tagMarkup, overlay: {trigger: '#'}},
+ {markup: mentionMarkup, overlay: {trigger: '@'}},
+ ],
+ },
+ })
+
+ const tagMark = page.getByTestId('tag-mark')
+ const mentionMark = page.getByTestId('mention-mark')
+
+ await expect.element(tagMark).toBeInTheDocument()
+ await expect.element(mentionMark).toBeInTheDocument()
+ expect(tagMark.element().getAttribute('data-depth')).toBe('0')
+ expect(mentionMark.element().getAttribute('data-depth')).toBe('1')
+ })
+
+ it('should handle empty nested marks', async () => {
+ const markup: Markup = '@[__nested__]'
+ const value = '@[@[]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const marks = page.getByTestId(/mark-depth-/).all()
+ expect(marks).toHaveLength(2)
+ })
+
+ it('should pass children to Mark component for nested content', async () => {
+ let hasChildrenAtDepthZero = false
+
+ const CapturingMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ if (mark.depth === 0 && mark.hasChildren) {
+ hasChildrenAtDepthZero = slots.default != null
+ }
+ return () =>
+ h(
+ 'span',
+ {'data-testid': 'mark'},
+ slots.default?.() ?? (!mark.hasChildren ? props.value : undefined)
+ )
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[before @[nested] after]'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: CapturingMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ expect(container.textContent).toContain('before')
+ expect(container.textContent).toContain('after')
+ expect(container.textContent).toContain('nested')
+ expect(hasChildrenAtDepthZero).toBe(true)
+ })
+})
+
+describe('Nested Marks Tree Navigation', () => {
+ it('should provide correct depth information', async () => {
+ const DepthMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () => h('span', {'data-depth': mark.depth}, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[d0 @[d1 @[d2]]]'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: DepthMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const depths = Array.from(container.querySelectorAll('[data-depth]')).map(el => el.getAttribute('data-depth'))
+
+ expect(depths).toEqual(['0', '1', '2'])
+ })
+
+ it('should provide hasChildren information', async () => {
+ const ChildrenMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () => h('span', {'data-has-children': mark.hasChildren}, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[parent @[child]]'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: ChildrenMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const elements = Array.from(container.querySelectorAll('[data-has-children]'))
+ const hasChildrenValues = elements.map(el => el.getAttribute('data-has-children'))
+
+ expect(hasChildrenValues).toEqual(['true', 'false'])
+ })
+
+ it('should provide children array', async () => {
+ let capturedChildrenCount = 0
+
+ const ChildrenCountMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ if (mark.depth === 0) {
+ capturedChildrenCount = mark.tokens.length
+ }
+ return () => h('span', null, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[parent @[child1] text @[child2]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: ChildrenCountMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ expect(capturedChildrenCount).toBeGreaterThan(0)
+ })
+})
+
+describe('Backward Compatibility', () => {
+ it('should work with flat marks (no nesting)', async () => {
+ const FlatMark = defineComponent({
+ props: {value: String, meta: String, children: {type: null}},
+ setup(props) {
+ return () => h('span', {'data-testid': 'flat-mark'}, props.value)
+ },
+ })
+
+ const markup: Markup = '@[__value__](__meta__)'
+ const value = '@[test](meta)'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: FlatMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const mark = page.getByTestId('flat-mark')
+ await expect.element(mark).toBeInTheDocument()
+ expect(mark.element().textContent).toBe('test')
+ })
+
+ it('should ignore children prop in flat marks', async () => {
+ const FlatMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props) {
+ return () => h('span', {'data-testid': 'flat-mark'}, props.value)
+ },
+ })
+
+ const markup: Markup = '@[__value__]'
+ const value = '@[test]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: FlatMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const mark = page.getByTestId('flat-mark')
+ await expect.element(mark).toBeInTheDocument()
+ expect(mark.element().textContent).toBe('test')
+ })
+
+ it('should not parse nested content in __value__ placeholders', async () => {
+ const FlatMark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('span', {'data-testid': 'flat-mark'}, props.value)
+ },
+ })
+
+ const markup: Markup = '@[__value__]'
+ const value = '@[text with @[nested]]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: FlatMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const marks = page.getByTestId('flat-mark').all()
+ expect(marks).toHaveLength(1)
+ expect(marks[0].element().textContent).toContain('text with @[nested]')
+ })
+})
+
+describe('Complex Nesting Scenarios', () => {
+ it('should handle adjacent nested marks', async () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ return () => h('span', {'data-testid': 'mark'}, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[first]@[second]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const marks = page.getByTestId('mark').all()
+ expect(marks).toHaveLength(2)
+ })
+
+ it('should handle deeply nested structure', async () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () => h('span', {'data-depth': mark.depth}, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[@[@[@[@[deep]]]]]'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ const depths = Array.from(container.querySelectorAll('[data-depth]')).map(el =>
+ parseInt(el.getAttribute('data-depth') || '0')
+ )
+
+ expect(Math.max(...depths)).toBe(4)
+ })
+
+ it('should handle mixed nested and flat marks', async () => {
+ const MixedMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () =>
+ h(
+ 'span',
+ {
+ 'data-testid': 'mark',
+ 'data-has-children': mark.hasChildren,
+ },
+ slots.default?.() ?? props.value
+ )
+ },
+ })
+
+ const nestedMarkup: Markup = '@[__nested__]'
+ const value = '@[nested @[child]] @[another]'
+
+ await render(MarkedInput, {
+ props: {
+ Mark: MixedMark,
+ value,
+ options: [{markup: nestedMarkup}],
+ },
+ })
+
+ const marks = page.getByTestId('mark').all()
+ expect(marks.length).toBeGreaterThanOrEqual(3)
+ })
+
+ it('should render nested structure when Mark component renders children', async () => {
+ const RenderingMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ const mark = useMark()
+ return () =>
+ h('span', {'data-testid': 'rendering-mark'}, mark.hasChildren ? slots.default?.() : props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[Hello @[World] from @[Nested] marks]'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: RenderingMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ expect(container.textContent).toContain('Hello')
+ expect(container.textContent).toContain('World')
+ expect(container.textContent).toContain('from')
+ expect(container.textContent).toContain('Nested')
+ expect(container.textContent).toContain('marks')
+ })
+})
+
+describe('Edge Cases', () => {
+ it('should handle empty input', async () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ return () => h('span', null, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = ''
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ expect(container.textContent).toBe('')
+ })
+
+ it('should handle input with no marks', async () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ return () => h('span', null, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = 'Just plain text'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ expect(container.textContent).toBe('Just plain text')
+ })
+
+ it('should handle malformed nested marks gracefully', async () => {
+ const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ return () => h('span', {'data-testid': 'mark'}, slots.default?.() ?? props.value)
+ },
+ })
+
+ const markup: Markup = '@[__nested__]'
+ const value = '@[unclosed @[nested'
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value,
+ options: [{markup}],
+ },
+ })
+
+ await expect.element(container).toBeInTheDocument()
+ })
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/Overlay/Overlay.spec.ts b/packages/vue/storybook/src/pages/Overlay/Overlay.spec.ts
new file mode 100644
index 00000000..3b4e3549
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Overlay/Overlay.spec.ts
@@ -0,0 +1,172 @@
+import {composeStories} from '@storybook/vue3-vite'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {page, userEvent} from 'vitest/browser'
+import {defineComponent, h} from 'vue'
+
+import {focusAtEnd, verifyCaretPosition} from '../../shared/lib/focus'
+import {withProps} from '../../shared/lib/testUtils'
+import * as BaseStories from '../Base/Base.stories'
+import * as OverlayStories from './Overlay.stories'
+
+const {Default} = composeStories(BaseStories)
+const {DefaultOverlay} = composeStories(OverlayStories)
+
+describe('API: Overlay and Triggers', () => {
+ it('should work with empty options array', async () => {
+ await render(withProps(DefaultOverlay, {options: []}))
+
+ const element = document.querySelector('span[contenteditable]') as HTMLElement
+ await focusAtEnd(element)
+ await userEvent.keyboard('abc')
+
+ await expect.element(page.getByText(/abc$/)).toBeInTheDocument()
+ })
+
+ it('should typed with default values of options', async () => {
+ await render(DefaultOverlay)
+
+ const element = document.querySelector('span[contenteditable]') as HTMLElement
+ await focusAtEnd(element)
+ await userEvent.keyboard('abc')
+
+ await expect.element(page.getByText(/abc$/)).toBeInTheDocument()
+ })
+
+ it('should appear a overlay component by trigger', async () => {
+ const Mark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ await render(
+ withProps(Default, {
+ Mark,
+ defaultValue: 'Hello ',
+ options: [
+ {
+ markup: '@[__label__](__value__)',
+ overlay: {
+ trigger: '@',
+ data: ['Item'],
+ },
+ },
+ ],
+ })
+ )
+
+ const element = document.querySelector('span[contenteditable]') as HTMLElement
+ await focusAtEnd(element)
+ await userEvent.keyboard('@')
+
+ await expect.element(page.getByText('Item')).toBeInTheDocument()
+ })
+
+ it('should reopen overlay after closing', async () => {
+ const Mark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ await render(
+ withProps(Default, {
+ Mark,
+ defaultValue: 'Hello ',
+ options: [
+ {
+ markup: '@[__label__](__value__)',
+ overlay: {
+ trigger: '@',
+ data: ['Item'],
+ },
+ },
+ ],
+ })
+ )
+
+ const element = document.querySelector('span[contenteditable]') as HTMLElement
+ await focusAtEnd(element)
+
+ await userEvent.keyboard('@')
+ await expect.element(page.getByText('Item')).toBeInTheDocument()
+
+ await userEvent.keyboard('{Escape}')
+ await expect.element(page.getByText('Item')).not.toBeInTheDocument()
+
+ await userEvent.keyboard(' @')
+ await expect.element(page.getByText('Item')).toBeInTheDocument()
+ })
+
+ it('should convert selection to mark token, not raw annotation', async () => {
+ const Mark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ await render(
+ withProps(Default, {
+ Mark,
+ defaultValue: 'Hello ',
+ options: [
+ {
+ markup: '@[__value__](__meta__)',
+ overlay: {
+ trigger: '@',
+ data: ['Item'],
+ },
+ },
+ ],
+ })
+ )
+
+ const element = document.querySelector('span[contenteditable]') as HTMLElement
+ await focusAtEnd(element)
+ await userEvent.keyboard('@')
+ await expect.element(page.getByText('Item')).toBeInTheDocument()
+
+ await page.getByText('Item').click()
+
+ await expect.element(page.getByRole('mark')).toBeInTheDocument()
+ })
+
+ it('should restore focus after selection from overlay', async () => {
+ const Mark = defineComponent({
+ props: {value: String},
+ setup(props) {
+ return () => h('mark', null, props.value)
+ },
+ })
+
+ const {container} = await render(
+ withProps(Default, {
+ Mark,
+ defaultValue: 'Start @[A](0) mid @[B](0) end',
+ options: [
+ {
+ markup: '@[__value__](__meta__)',
+ overlay: {
+ trigger: '@',
+ data: ['Item'],
+ },
+ },
+ ],
+ })
+ )
+
+ const editableContainer = container.firstElementChild as HTMLElement
+ const middleSpan = editableContainer.children[2] as HTMLElement
+ await focusAtEnd(middleSpan)
+ await userEvent.keyboard('@')
+ await expect.element(page.getByText('Item')).toBeInTheDocument()
+
+ await page.getByText('Item').click()
+
+ verifyCaretPosition(editableContainer, 16)
+ })
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/Slots/slots.spec.ts b/packages/vue/storybook/src/pages/Slots/slots.spec.ts
new file mode 100644
index 00000000..738bb8f5
--- /dev/null
+++ b/packages/vue/storybook/src/pages/Slots/slots.spec.ts
@@ -0,0 +1,532 @@
+import {MarkedInput} from '@markput/vue'
+import {describe, expect, it, vi} from 'vitest'
+import {render} from 'vitest-browser-vue'
+import {page, userEvent} from 'vitest/browser'
+import {defineComponent, h} from 'vue'
+
+const TestMark = defineComponent({
+ props: {value: String, children: {type: null}},
+ setup(props, {slots}) {
+ return () => h('mark', null, slots.default?.() ?? props.value)
+ },
+})
+
+describe('Slots API', () => {
+ describe('Container slot', () => {
+ it('should use default div component when no slot is provided', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ 'data-testid': 'container',
+ },
+ })
+
+ const containerDiv = container.querySelector('div') as HTMLElement
+ await expect.element(containerDiv).toBeInTheDocument()
+ })
+
+ it('should use custom component from slots.container', async () => {
+ const CustomContainer = defineComponent({
+ setup(_, {slots}) {
+ return () => h('div', {'data-testid': 'custom-container'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {
+ container: CustomContainer,
+ },
+ },
+ })
+
+ await expect.element(page.getByTestId('custom-container')).toBeInTheDocument()
+ })
+
+ it('should pass slotProps.container to the container component', async () => {
+ const handleKeyDown = vi.fn()
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ onKeydown: handleKeyDown,
+ dataCustom: 'test-value',
+ },
+ },
+ },
+ })
+
+ const containerDiv = container.querySelector('div') as HTMLElement
+ await expect.element(containerDiv).toHaveAttribute('data-custom', 'test-value')
+ })
+
+ it('should merge className from slotProps with default className', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ className: 'default-class',
+ slotProps: {
+ container: {
+ className: 'custom-class',
+ },
+ },
+ },
+ })
+
+ const containerDiv = container.querySelector('div') as HTMLElement
+ await expect.element(containerDiv).toHaveClass('default-class')
+ })
+
+ it('should merge style from slotProps with default style', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ style: {color: 'red'},
+ slotProps: {
+ container: {
+ style: {backgroundColor: 'blue'},
+ },
+ },
+ },
+ })
+
+ const containerDiv = container.querySelector('div') as HTMLElement
+ await expect.element(containerDiv).toHaveStyle({color: 'rgb(255, 0, 0)', backgroundColor: 'rgb(0, 0, 255)'})
+ })
+ })
+
+ describe('Span slot', () => {
+ it('should use default span component when no slot is provided', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable]') as HTMLElement
+ await expect.element(textSpan).toBeInTheDocument()
+ await expect.element(textSpan).toHaveTextContent('Hello world')
+ })
+
+ it('should use custom component from slots.span', async () => {
+ const CustomSpan = defineComponent({
+ setup(_, {slots}) {
+ return () => h('span', {'data-testid': 'custom-span'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {
+ span: CustomSpan,
+ },
+ },
+ })
+
+ await expect.element(page.getByTestId('custom-span')).toBeInTheDocument()
+ })
+
+ it('should pass slotProps.span to the span component', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ span: {
+ className: 'custom-span-class',
+ dataSpanCustom: 'span-value',
+ },
+ },
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable]') as HTMLElement
+ await expect.element(textSpan).toHaveClass('custom-span-class')
+ await expect.element(textSpan).toHaveAttribute('data-span-custom', 'span-value')
+ })
+
+ it('should merge style from slotProps.span', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ span: {
+ style: {fontWeight: 'bold', fontSize: '16px'},
+ },
+ },
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable]') as HTMLElement
+ await expect.element(textSpan).toHaveStyle({fontWeight: 'bold', fontSize: '16px'})
+ })
+ })
+
+ describe('Both slots', () => {
+ it('should allow overriding both container and span slots simultaneously', async () => {
+ const CustomContainer = defineComponent({
+ setup(_, {slots}) {
+ return () => h('div', {'data-testid': 'custom-container'}, slots.default?.())
+ },
+ })
+
+ const CustomSpan = defineComponent({
+ setup(_, {slots}) {
+ return () => h('span', {'data-testid': 'custom-span'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {
+ container: CustomContainer,
+ span: CustomSpan,
+ },
+ slotProps: {
+ container: {
+ dataContainerProp: 'container',
+ },
+ span: {
+ dataSpanProp: 'span',
+ },
+ },
+ },
+ })
+
+ const container = page.getByTestId('custom-container')
+ const span = page.getByTestId('custom-span')
+
+ await expect.element(container).toBeInTheDocument()
+ await expect.element(container).toHaveAttribute('data-container-prop', 'container')
+
+ await expect.element(span).toBeInTheDocument()
+ await expect.element(span).toHaveAttribute('data-span-prop', 'span')
+ })
+ })
+
+ describe('TypeScript integration', () => {
+ it('should work with valid slot types', async () => {
+ const CustomDiv = defineComponent({
+ setup(_, {slots}) {
+ return () => h('div', null, slots.default?.())
+ },
+ })
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello',
+ slots: {
+ container: CustomDiv,
+ span: 'span',
+ },
+ slotProps: {
+ container: {
+ onKeydown: () => {},
+ className: 'test',
+ },
+ span: {
+ style: {color: 'red'},
+ },
+ },
+ },
+ })
+
+ await expect.element(container).toBeInTheDocument()
+ })
+
+ it('should support camelCase data attributes in slotProps', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ dataTestId: 'my-container',
+ dataUserId: 'user-123',
+ dataUserName: 'John',
+ },
+ },
+ },
+ })
+
+ const containerDiv = container.querySelector('div') as HTMLElement
+ await expect.element(containerDiv).toHaveAttribute('data-test-id', 'my-container')
+ await expect.element(containerDiv).toHaveAttribute('data-user-id', 'user-123')
+ await expect.element(containerDiv).toHaveAttribute('data-user-name', 'John')
+ })
+ })
+
+ describe('Span contentEditable attribute', () => {
+ it('should have contentEditable="true" by default on editable span', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable="true"]') as HTMLElement
+ await expect.element(textSpan).toBeInTheDocument()
+ })
+
+ it('should have contentEditable="false" when readOnly is true', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ readOnly: true,
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable="false"]') as HTMLElement
+ await expect.element(textSpan).toBeInTheDocument()
+ })
+
+ it('should maintain contentEditable on span with custom slot', async () => {
+ const CustomSpan = defineComponent({
+ setup(_, {slots}) {
+ return () => h('span', {'data-testid': 'custom-editable-span'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {span: CustomSpan},
+ },
+ })
+
+ const span = page.getByTestId('custom-editable-span')
+ await expect.element(span).toHaveAttribute('contenteditable', 'true')
+ })
+
+ it('should respect suppressContentEditableWarning when set', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ },
+ })
+
+ const textSpan = container.querySelector('span[contenteditable]') as HTMLElement
+ await expect.element(textSpan).toBeInTheDocument()
+ })
+ })
+
+ describe('Event handlers in slotProps', () => {
+ it('should call onKeydown handler from slotProps.container', async () => {
+ const handleKeyDown = vi.fn()
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ onKeydown: handleKeyDown,
+ },
+ },
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await userEvent.click(div)
+ await userEvent.keyboard('{Enter}')
+
+ expect(handleKeyDown).toHaveBeenCalled()
+ })
+
+ it('should call onClick handler from slotProps.container', async () => {
+ const handleClick = vi.fn()
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ onClick: handleClick,
+ },
+ },
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await userEvent.click(div)
+
+ expect(handleClick).toHaveBeenCalled()
+ })
+
+ it('should call onFocusin and onFocusout handlers from slotProps.container', async () => {
+ const handleFocus = vi.fn()
+ const handleBlur = vi.fn()
+
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ onFocusin: handleFocus,
+ onFocusout: handleBlur,
+ },
+ },
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await userEvent.click(div)
+ expect(handleFocus).toHaveBeenCalled()
+
+ await userEvent.click(page.getByRole('document'))
+ expect(handleBlur).toHaveBeenCalled()
+ })
+ })
+
+ describe('Custom slot components', () => {
+ it('should pass all required props to custom container slot', async () => {
+ const CustomContainer = defineComponent({
+ setup(_, {slots}) {
+ return () => h('div', {'data-testid': 'custom-container'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ className: 'outer-class',
+ style: {color: 'red'},
+ slots: {container: CustomContainer},
+ slotProps: {
+ container: {
+ className: 'inner-class',
+ style: {backgroundColor: 'blue'},
+ },
+ },
+ },
+ })
+
+ const container = page.getByTestId('custom-container')
+ await expect.element(container).toHaveClass('outer-class')
+ await expect.element(container).toHaveClass('inner-class')
+ await expect.element(container).toHaveStyle({color: 'rgb(255, 0, 0)', backgroundColor: 'rgb(0, 0, 255)'})
+ })
+
+ it('should allow native HTML elements as slots', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {
+ container: 'article',
+ span: 'div',
+ },
+ },
+ })
+
+ const article = container.querySelector('article') as HTMLElement
+ const div = container.querySelector('div[contenteditable]') as HTMLElement
+
+ await expect.element(article).toBeInTheDocument()
+ await expect.element(div).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge cases', () => {
+ it('should handle empty value', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: '',
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await expect.element(div).toBeInTheDocument()
+ })
+
+ it('should handle undefined slotProps gracefully', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: undefined,
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await expect.element(div).toBeInTheDocument()
+ })
+
+ it('should handle empty className in slotProps', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slotProps: {
+ container: {
+ className: '',
+ },
+ },
+ },
+ })
+
+ const div = container.querySelector('div') as HTMLElement
+ await expect.element(div).toBeInTheDocument()
+ })
+
+ it('should handle multiple marked values with custom slots', async () => {
+ const {container} = await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: '@[hello] world @[test]',
+ slots: {
+ span: defineComponent({
+ setup(_, {slots}) {
+ return () => h('span', {'data-testid': 'text-span'}, slots.default?.())
+ },
+ }),
+ },
+ },
+ })
+
+ const spans = container.querySelectorAll('[data-testid="text-span"]')
+ expect(spans.length).toBeGreaterThan(0)
+ })
+
+ it('should preserve slot functionality when no slotProps provided', async () => {
+ const CustomContainer = defineComponent({
+ setup(_, {slots}) {
+ return () => h('div', {'data-testid': 'custom-container'}, slots.default?.())
+ },
+ })
+
+ await render(MarkedInput, {
+ props: {
+ Mark: TestMark,
+ value: 'Hello world',
+ slots: {container: CustomContainer},
+ },
+ })
+
+ await expect.element(page.getByTestId('custom-container')).toBeInTheDocument()
+ })
+ })
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/pages/stories.spec.ts b/packages/vue/storybook/src/pages/stories.spec.ts
new file mode 100644
index 00000000..cae50dfc
--- /dev/null
+++ b/packages/vue/storybook/src/pages/stories.spec.ts
@@ -0,0 +1,38 @@
+import {composeStories} from '@storybook/vue3-vite'
+import {describe, expect, it} from 'vitest'
+import {render} from 'vitest-browser-vue'
+
+const storiesModules = import.meta.glob('./**/*.stories.ts', {eager: true})
+
+const storiesByCategory = new Map>()
+
+for (const [path, module] of Object.entries(storiesModules)) {
+ const match = path.match(/\.\/([^/]+)\//)
+ if (!match) continue
+
+ const category = match[1]
+ const stories = composeStories(module as any)
+
+ if (!storiesByCategory.has(category)) {
+ storiesByCategory.set(category, {})
+ }
+
+ const categoryStories = storiesByCategory.get(category)!
+ Object.assign(categoryStories, stories)
+}
+
+const getTests =
+ () =>
+ ([name, Story]: [string, any]) =>
+ it(`Story ${name}`, async () => {
+ const {container} = await render(Story)
+ expect(container.textContent?.length).toBeTruthy()
+ })
+
+describe('Component: stories', () => {
+ for (const [category, stories] of storiesByCategory.entries()) {
+ describe(`${category} stories`, () => {
+ Object.entries(stories).map(getTests())
+ })
+ }
+})
\ No newline at end of file
diff --git a/packages/vue/storybook/src/shared/lib/focus.ts b/packages/vue/storybook/src/shared/lib/focus.ts
new file mode 100644
index 00000000..8a82720b
--- /dev/null
+++ b/packages/vue/storybook/src/shared/lib/focus.ts
@@ -0,0 +1,82 @@
+import {expect} from 'vitest'
+import {userEvent} from 'vitest/browser'
+
+function setCaretPosition(element: HTMLElement, offset: number) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+
+ if (!selection) return
+
+ let currentOffset = 0
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
+
+ let node = walker.nextNode()
+ while (node) {
+ const nodeLength = node.textContent?.length || 0
+ if (currentOffset + nodeLength >= offset) {
+ range.setStart(node, offset - currentOffset)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ return
+ }
+ currentOffset += nodeLength
+ node = walker.nextNode()
+ }
+
+ range.selectNodeContents(element)
+ range.collapse(false)
+ selection.removeAllRanges()
+ selection.addRange(range)
+}
+
+export async function focusAtStart(element: HTMLElement) {
+ await userEvent.click(element)
+ setCaretPosition(element, 0)
+ await expect.element(element).toHaveFocus()
+
+ verifyCaretPosition(element, 0)
+}
+
+export async function focusAtEnd(element: HTMLElement) {
+ await userEvent.click(element)
+ const textLength = element.textContent?.length || 0
+ setCaretPosition(element, textLength)
+ await expect.element(element).toHaveFocus()
+
+ verifyCaretPosition(element, textLength)
+}
+
+export async function focusAtOffset(element: HTMLElement, offset: number) {
+ await userEvent.click(element)
+ setCaretPosition(element, offset)
+ await expect.element(element).toHaveFocus()
+
+ verifyCaretPosition(element, offset)
+}
+
+export function verifyCaretPosition(element: HTMLElement, expectedOffset: number) {
+ const position = getCaretPosition()
+ expect(position, 'Caret position not available').not.toBeNull()
+
+ const length = measureTextLength(element, position!.node, position!.offset)
+ expect(length).toBe(expectedOffset)
+
+ function getCaretPosition() {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return null
+
+ const range = selection.getRangeAt(0)
+ return {
+ node: range.startContainer,
+ offset: range.startOffset,
+ }
+ }
+
+ function measureTextLength(containerElement: HTMLElement, endNode: Node, endOffset: number) {
+ const range = document.createRange()
+ range.selectNodeContents(containerElement)
+ range.setEnd(endNode, endOffset)
+ return range.toString().length
+ }
+}
\ No newline at end of file
diff --git a/packages/vue/storybook/src/shared/lib/testUtils.ts b/packages/vue/storybook/src/shared/lib/testUtils.ts
new file mode 100644
index 00000000..8e5bf102
--- /dev/null
+++ b/packages/vue/storybook/src/shared/lib/testUtils.ts
@@ -0,0 +1,14 @@
+import type {Component} from 'vue'
+import {defineComponent, h} from 'vue'
+
+/**
+ * Helper to create a wrapper component that renders a story with custom props
+ * This allows overriding story args in tests
+ */
+export function withProps(story: Component, props: Record) {
+ return defineComponent({
+ setup(_, {slots}) {
+ return () => h(story, props, slots)
+ },
+ })
+}
\ No newline at end of file
diff --git a/packages/vue/storybook/vite.config.ts b/packages/vue/storybook/vite.config.ts
index 126f5d54..63fecb69 100644
--- a/packages/vue/storybook/vite.config.ts
+++ b/packages/vue/storybook/vite.config.ts
@@ -1,9 +1,25 @@
import vue from '@vitejs/plugin-vue'
-import {defineConfig} from 'vite'
+import {playwright} from '@vitest/browser-playwright'
+import {defineConfig} from 'vitest/config'
export default defineConfig({
plugins: [vue()],
resolve: {
dedupe: ['vue'],
},
+ test: {
+ globals: true,
+ include: ['src/pages/**/*.spec.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ },
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ instances: [{browser: 'chromium'}],
+ viewport: {width: 1280, height: 720},
+ headless: true,
+ },
+ },
})
\ No newline at end of file
diff --git a/packages/website/src/content/docs/api/functions/MarkedInput.md b/packages/website/src/content/docs/api/functions/MarkedInput.md
index ea402a16..9d80cd90 100644
--- a/packages/website/src/content/docs/api/functions/MarkedInput.md
+++ b/packages/website/src/content/docs/api/functions/MarkedInput.md
@@ -9,7 +9,7 @@ title: "MarkedInput"
function MarkedInput(props): Element;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:76](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L76)
+Defined in: [react/markput/src/components/MarkedInput.tsx:78](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L78)
## Type Parameters
diff --git a/packages/website/src/content/docs/api/functions/useMark.md b/packages/website/src/content/docs/api/functions/useMark.md
index 89e1f3eb..a6798c8f 100644
--- a/packages/website/src/content/docs/api/functions/useMark.md
+++ b/packages/website/src/content/docs/api/functions/useMark.md
@@ -9,7 +9,7 @@ title: "useMark"
function useMark(options?): MarkHandler;
```
-Defined in: [react/markput/src/lib/hooks/useMark.tsx:12](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useMark.tsx#L12)
+Defined in: [react/markput/src/lib/hooks/useMark.tsx:13](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useMark.tsx#L13)
## Type Parameters
diff --git a/packages/website/src/content/docs/api/functions/useOverlay.md b/packages/website/src/content/docs/api/functions/useOverlay.md
index 4d290136..4c454ceb 100644
--- a/packages/website/src/content/docs/api/functions/useOverlay.md
+++ b/packages/website/src/content/docs/api/functions/useOverlay.md
@@ -9,7 +9,7 @@ title: "useOverlay"
function useOverlay(): OverlayHandler;
```
-Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:19](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L19)
+Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:20](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L20)
## Returns
diff --git a/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md
index 55b0dbef..72b2f6e1 100644
--- a/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md
+++ b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md
@@ -5,7 +5,7 @@ prev: false
title: "MarkedInputProps"
---
-Defined in: [react/markput/src/components/MarkedInput.tsx:32](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L32)
+Defined in: [react/markput/src/components/MarkedInput.tsx:34](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L34)
Props for MarkedInput component.
@@ -36,7 +36,7 @@ Props for MarkedInput component.
optional block: boolean;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:73](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L73)
+Defined in: [react/markput/src/components/MarkedInput.tsx:75](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L75)
Enable Notion-like draggable blocks with drag handles for reordering
@@ -48,7 +48,7 @@ Enable Notion-like draggable blocks with drag handles for reordering
optional className: string;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:46](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L46)
+Defined in: [react/markput/src/components/MarkedInput.tsx:48](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L48)
Additional classes
@@ -60,7 +60,7 @@ Additional classes
optional defaultValue: string;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:67](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L67)
+Defined in: [react/markput/src/components/MarkedInput.tsx:69](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L69)
Initial value for uncontrolled mode
@@ -72,7 +72,7 @@ Initial value for uncontrolled mode
optional Mark: ComponentType;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:36](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L36)
+Defined in: [react/markput/src/components/MarkedInput.tsx:38](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L38)
Global component used for rendering markups (fallback for option.mark.slot)
@@ -84,7 +84,7 @@ Global component used for rendering markups (fallback for option.mark.slot)
optional onChange: (value) => void;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:69](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L69)
+Defined in: [react/markput/src/components/MarkedInput.tsx:71](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L71)
Change event handler
@@ -106,7 +106,7 @@ Change event handler
optional options: Option[];
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:44](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L44)
+Defined in: [react/markput/src/components/MarkedInput.tsx:46](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L46)
Configuration options for markups and overlays.
Each option can specify its own slot component via mark.slot or overlay.slot.
@@ -120,7 +120,7 @@ Falls back to global Mark/Overlay components when not specified.
optional Overlay: ComponentType;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:38](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L38)
+Defined in: [react/markput/src/components/MarkedInput.tsx:40](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L40)
Global component used for rendering overlays (fallback for option.overlay.slot)
@@ -132,7 +132,7 @@ Global component used for rendering overlays (fallback for option.overlay.slot)
optional readOnly: boolean;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:71](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L71)
+Defined in: [react/markput/src/components/MarkedInput.tsx:73](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L73)
Read-only mode
@@ -144,7 +144,7 @@ Read-only mode
optional ref: Ref;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:34](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L34)
+Defined in: [react/markput/src/components/MarkedInput.tsx:36](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L36)
Ref to handler
@@ -156,7 +156,7 @@ Ref to handler
optional showOverlayOn: OverlayTrigger;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:63](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L63)
+Defined in: [react/markput/src/components/MarkedInput.tsx:65](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L65)
Events that trigger overlay display
@@ -174,7 +174,7 @@ Events that trigger overlay display
optional slotProps: SlotProps;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:58](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L58)
+Defined in: [react/markput/src/components/MarkedInput.tsx:60](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L60)
Props to pass to slot components
@@ -192,7 +192,7 @@ slotProps={{ container: { onKeyDown: handler }, span: { className: 'custom' } }}
optional slots: Slots;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:53](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L53)
+Defined in: [react/markput/src/components/MarkedInput.tsx:55](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L55)
Override internal components using slots
@@ -210,7 +210,7 @@ slots={{ container: 'div', span: 'span' }}
optional style: CSSProperties;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:48](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L48)
+Defined in: [react/markput/src/components/MarkedInput.tsx:50](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L50)
Additional style
@@ -222,6 +222,6 @@ Additional style
optional value: string;
```
-Defined in: [react/markput/src/components/MarkedInput.tsx:65](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L65)
+Defined in: [react/markput/src/components/MarkedInput.tsx:67](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L67)
Annotated text with markups
diff --git a/packages/website/src/content/docs/api/interfaces/OverlayHandler.md b/packages/website/src/content/docs/api/interfaces/OverlayHandler.md
index c090c342..b2ab8904 100644
--- a/packages/website/src/content/docs/api/interfaces/OverlayHandler.md
+++ b/packages/website/src/content/docs/api/interfaces/OverlayHandler.md
@@ -5,7 +5,7 @@ prev: false
title: "OverlayHandler"
---
-Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:8](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L8)
+Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:9](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L9)
## Properties
@@ -15,7 +15,7 @@ Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:8](https://github.com/No
close: () => void;
```
-Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:13](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L13)
+Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:14](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L14)
#### Returns
@@ -29,7 +29,7 @@ Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:13](https://github.com/N
match: OverlayMatch