From d0dfff7e017c165295eec39c4efdfb4cc74ba0e4 Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 6 Mar 2026 23:43:04 +0300 Subject: [PATCH 1/5] feat: enhance Storybook setup for Vue with Vitest integration ## Summary - Added Vitest for testing Vue components in Storybook - Introduced new test scripts for running and watching tests - Updated package dependencies to include Vitest and related tools - Created new test files for various components to ensure functionality ## Changes - Modified `package.json` in the Storybook Vue package to include test scripts - Updated `vite.config.ts` to configure Vitest for testing - Added new test files for components, including nested marks and overlays - Enhanced focus utilities for better test interactions ## Benefits - Improved testing capabilities for Vue components within Storybook - Streamlined development workflow with integrated testing - Ensured component reliability through comprehensive test coverage --- packages/vue/storybook/package.json | 15 +- .../vue/storybook/src/pages/Base/Base.spec.ts | 185 +++++++ .../src/pages/Base/MarkputHandler.spec.ts | 19 + .../storybook/src/pages/Base/keyboard.spec.ts | 87 ++++ .../storybook/src/pages/Nested/nested.spec.ts | 477 +++++++++++++++++ .../src/pages/Overlay/Overlay.spec.ts | 172 +++++++ .../storybook/src/pages/Slots/slots.spec.ts | 480 ++++++++++++++++++ .../vue/storybook/src/pages/stories.spec.ts | 38 ++ .../vue/storybook/src/shared/lib/focus.ts | 82 +++ .../vue/storybook/src/shared/lib/testUtils.ts | 14 + packages/vue/storybook/vite.config.ts | 18 +- .../content/docs/api/functions/MarkedInput.md | 2 +- .../src/content/docs/api/functions/useMark.md | 2 +- .../content/docs/api/functions/useOverlay.md | 2 +- .../docs/api/interfaces/MarkedInputProps.md | 30 +- .../docs/api/interfaces/OverlayHandler.md | 12 +- pnpm-lock.yaml | 121 ++++- pnpm-workspace.yaml | 1 + 18 files changed, 1729 insertions(+), 28 deletions(-) create mode 100644 packages/vue/storybook/src/pages/Base/Base.spec.ts create mode 100644 packages/vue/storybook/src/pages/Base/MarkputHandler.spec.ts create mode 100644 packages/vue/storybook/src/pages/Base/keyboard.spec.ts create mode 100644 packages/vue/storybook/src/pages/Nested/nested.spec.ts create mode 100644 packages/vue/storybook/src/pages/Overlay/Overlay.spec.ts create mode 100644 packages/vue/storybook/src/pages/Slots/slots.spec.ts create mode 100644 packages/vue/storybook/src/pages/stories.spec.ts create mode 100644 packages/vue/storybook/src/shared/lib/focus.ts create mode 100644 packages/vue/storybook/src/shared/lib/testUtils.ts 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..061f5897 --- /dev/null +++ b/packages/vue/storybook/src/pages/Base/Base.spec.ts @@ -0,0 +1,185 @@ +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, 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) + }, + }) + + await render(withProps(Default, {Mark, defaultValue: ''})) + + const span = page.getByRole('textbox') + await userEvent.type(span, '@[[mark](1)') + + await expect.element(page.getByText('mark')).toBeInTheDocument() + }) + + const FocusableMark = defineComponent({ + setup() { + const mark = useMark() + const elRef = ref(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', + }, + }, + mark.value + ) + }, + }) + + 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..7ba48b9e --- /dev/null +++ b/packages/vue/storybook/src/pages/Nested/nested.spec.ts @@ -0,0 +1,477 @@ +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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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..bd3894a3 --- /dev/null +++ b/packages/vue/storybook/src/pages/Slots/slots.spec.ts @@ -0,0 +1,480 @@ +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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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-span'}, slots.default?.()) + }, + }) + + await render(MarkedInput, { + Mark: TestMark, + value: 'Hello world', + slots: {span: CustomSpan}, + }) + + const span = page.getByTestId('custom-span') + await expect.element(span).toHaveAttribute('contenteditable', 'true') + }) + + it('should respect suppressContentEditableWarning when set', async () => { + const {container} = await render(MarkedInput, { + 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, { + 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, { + 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 onFocus and onBlur handlers from slotProps.container', async () => { + const handleFocus = vi.fn() + const handleBlur = vi.fn() + + const {container} = await render(MarkedInput, { + Mark: TestMark, + value: 'Hello world', + slotProps: { + container: { + onFocus: handleFocus, + onBlur: 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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, { + 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>; ``` -Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:15](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L15) +Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:16](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L16) *** @@ -39,7 +39,7 @@ Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:15](https://github.com/N ref: RefObject; ``` -Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:16](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L16) +Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:17](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L17) *** @@ -49,7 +49,7 @@ Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:16](https://github.com/N select: (value) => void; ``` -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) +Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:15](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L15) #### Parameters @@ -71,7 +71,7 @@ Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:14](https://github.com/N style: object; ``` -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) +Defined in: [react/markput/src/lib/hooks/useOverlay.tsx:10](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/lib/hooks/useOverlay.tsx#L10) #### left diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76cfdc0b..c070df5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: vitest-browser-react: specifier: ^2.0.5 version: 2.0.5 + vitest-browser-vue: + specifier: ^2.0.2 + version: 2.0.2 vue: specifier: ^3.5.13 version: 3.5.29 @@ -326,15 +329,36 @@ importers: '@storybook/vue3-vite': specifier: 'catalog:' version: 10.2.16(esbuild@0.27.3)(rollup@4.59.0)(storybook@10.2.16(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) + '@types/node': + specifier: 'catalog:' + version: 24.12.0 '@vitejs/plugin-vue': specifier: 'catalog:' version: 6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + '@vitest/ui': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18) + playwright: + specifier: 'catalog:' + version: 1.58.2 storybook: specifier: 'catalog:' version: 10.2.16(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: specifier: 'catalog:' version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@24.12.0)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vitest-browser-vue: + specifier: 'catalog:' + version: 2.0.2(vitest@4.0.18)(vue@3.5.29(typescript@5.9.3)) packages/website: dependencies: @@ -1399,6 +1423,9 @@ packages: '@types/react': optional: true + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -2746,6 +2773,13 @@ packages: '@vue/shared@3.5.29': resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3085,6 +3119,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3105,6 +3143,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -3288,6 +3329,11 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} @@ -3691,6 +3737,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3792,6 +3841,15 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -4290,6 +4348,11 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4525,6 +4588,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + pug-attrs@3.0.0: resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} @@ -5407,6 +5473,12 @@ packages: '@types/react-dom': optional: true + vitest-browser-vue@2.0.2: + resolution: {integrity: sha512-/IM/+gOBEPL5Ocu/n28NmAvr1XgqGxzJrgwkPx9O+ioB52iuyg25nDQXlDDPSCm5PJFmwNzA6yycWxFAFTqXYA==} + peerDependencies: + vitest: ^4.0.0-0 + vue: ^3.0.0 + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6831,6 +6903,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@one-ini/wasm@0.1.1': {} + '@oslojs/encoding@1.1.0': {} '@oxfmt/binding-android-arm-eabi@0.36.0': @@ -7887,7 +7961,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 17.0.45 + '@types/node': 24.12.0 '@types/semver@7.7.1': {} @@ -8225,6 +8299,13 @@ snapshots: '@vue/shared@3.5.29': {} + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + + abbrev@2.0.0: {} + abbrev@3.0.1: {} acorn-import-attributes@1.9.5(acorn@8.16.0): @@ -8675,6 +8756,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -8687,6 +8770,11 @@ snapshots: concat-map@0.0.1: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + consola@3.4.2: {} constantinople@4.0.1: @@ -8860,6 +8948,13 @@ snapshots: eastasianwidth@0.2.0: {} + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.7.4 + electron-to-chromium@1.5.307: {} emmet@2.4.11: @@ -9420,6 +9515,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} iron-webcrypto@1.2.1: {} @@ -9508,6 +9605,16 @@ snapshots: jju@1.4.0: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + js-stringify@1.0.2: {} js-tokens@10.0.0: {} @@ -10261,6 +10368,10 @@ snapshots: node-releases@2.0.36: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -10523,6 +10634,8 @@ snapshots: property-information@7.1.0: {} + proto-list@1.2.4: {} + pug-attrs@3.0.0: dependencies: constantinople: 4.0.1 @@ -11461,6 +11574,12 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + vitest-browser-vue@2.0.2(vitest@4.0.18)(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@vue/test-utils': 2.4.6 + vitest: 4.0.18(@types/node@24.12.0)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vue: 3.5.29(typescript@5.9.3) + vitest@4.0.18(@types/node@24.12.0)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a38c3523..98b5fbe2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,5 +25,6 @@ catalog: vite: ^7.3.1 vitest: ^4.0.18 vitest-browser-react: ^2.0.5 + vitest-browser-vue: ^2.0.2 vue: ^3.5.13 vue-tsc: ^3.2.5 From f9e0470bd79046a1bfc56146f22466e8b003bbf1 Mon Sep 17 00:00:00 2001 From: Nowely Date: Sat, 7 Mar 2026 00:18:12 +0300 Subject: [PATCH 2/5] fix: update @types/node dependency version in pnpm-lock.yaml ## Summary - Changed the version of '@types/node' from 24.12.0 to 17.0.45 for '@types/sax' dependency. ## Changes - Updated the pnpm-lock.yaml file to reflect the new dependency version. ## Benefits - Ensures compatibility with the specified version of '@types/sax' and maintains consistency in the dependency tree. --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c070df5d..a37257d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7961,7 +7961,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.12.0 + '@types/node': 17.0.45 '@types/semver@7.7.1': {} From cce755b7674980842e9b81413f0820736bbbfd99 Mon Sep 17 00:00:00 2001 From: Nowely Date: Sat, 7 Mar 2026 01:22:10 +0300 Subject: [PATCH 3/5] refactor: enhance component slot handling and type definitions ## Summary - Updated type definitions to allow `Component` types for `container` and `span` slots. - Improved handling of component references in `Container.vue` to support both Vue component instances and raw elements. - Enhanced `MarkRenderer.vue` to conditionally render components based on child nodes. - Refined `resolveSlot.ts` to return either a string or a `Component` type for slot resolution. ## Benefits - Increases flexibility in slot usage, allowing for more complex component structures. - Improves type safety and clarity in component props, enhancing developer experience. - Ensures better rendering logic in components, leading to more robust UI behavior. --- .../vue/markput/src/components/Container.vue | 2 +- .../markput/src/components/MarkRenderer.vue | 3 +- .../vue/markput/src/lib/utils/resolveSlot.ts | 5 +- packages/vue/markput/src/types.ts | 4 +- .../vue/storybook/src/pages/Base/Base.spec.ts | 40 ++- .../storybook/src/pages/Nested/nested.spec.ts | 158 +++++---- .../storybook/src/pages/Slots/slots.spec.ts | 324 ++++++++++-------- 7 files changed, 315 insertions(+), 221 deletions(-) diff --git a/packages/vue/markput/src/components/Container.vue b/packages/vue/markput/src/components/Container.vue index d02340d6..0089f180 100644 --- a/packages/vue/markput/src/components/Container.vue +++ b/packages/vue/markput/src/components/Container.vue @@ -21,7 +21,7 @@ const containerProps = computed(() => resolveSlotProps('container', slotProps.va