Skip to content

Commit 5235bc5

Browse files
committed
feat(app): HTMLContentEditable component
1 parent 0d59f87 commit 5235bc5

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ComponentMountingOptions, VueWrapper, mount } from '@vue/test-utils'
2+
import { DefineComponent } from 'vue'
3+
import merge from 'lodash/merge'
4+
5+
export function useMountWrapper<T extends DefineComponent<any, any, any, any, any>>(component: T) {
6+
const state = {
7+
wrapper: null as VueWrapper<InstanceType<T>> | null,
8+
mount: (options?: ComponentMountingOptions<T>) => {
9+
state.wrapper = mount(component, merge(options))
10+
11+
return state.wrapper!
12+
},
13+
unmount: () => {
14+
state.wrapper?.unmount()
15+
},
16+
}
17+
18+
return state
19+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
import HTMLContentEditable from './HTMLContentEditable.vue'
4+
import { useMountWrapper } from '__tests__/fixtures/component'
5+
6+
describe('HTMLContentEditable', () => {
7+
const component = useMountWrapper(HTMLContentEditable)
8+
9+
afterEach(component.unmount)
10+
11+
it('should render html from modelValue', () => {
12+
const wrapper = component.mount({
13+
props: {
14+
modelValue: '<p>test</p>',
15+
},
16+
})
17+
18+
expect(wrapper.html()).toContain('<p>test</p>')
19+
})
20+
21+
it('should html element should be editable', () => {
22+
const wrapper = component.mount({
23+
props: {
24+
modelValue: '<p>test</p>',
25+
},
26+
})
27+
28+
const editable = wrapper.find('[data-test-id=editable-area]')
29+
30+
expect(editable.exists()).toBe(true)
31+
32+
expect(editable.attributes('contenteditable')).toBe('true')
33+
})
34+
35+
it.each([
36+
['<p>Hello {{ name }}</p>', { name: 'Dio' }, '<p>Hello Dio</p>'],
37+
['<p>Sum {{ 1 + 2 }}</p>', {}, '<p>Sum 3</p>'],
38+
['<p>Sum with variables {{ a + b }}</p>', { a: 1, b: 2 }, '<p>Sum with variables 3</p>'],
39+
])('should render text interpolation %s', (modelValue, state, expected) => {
40+
const wrapper = component.mount({
41+
props: {
42+
modelValue,
43+
state,
44+
},
45+
})
46+
47+
const view = wrapper.find('[data-test-id=view-area]')
48+
49+
expect(view.html({ raw: true })).toBe(`<div data-test-id="view-area">${expected}</div>`)
50+
})
51+
52+
it('should update render html when state variables change', async () => {
53+
const wrapper = component.mount({
54+
props: {
55+
modelValue: '<p>{{ message }}</p>',
56+
state: {
57+
message: 'Hello world',
58+
},
59+
},
60+
})
61+
62+
expect(wrapper.html()).toContain('<p>Hello world</p>')
63+
64+
await wrapper.setProps({ state: { message: 'Hello Dio' } })
65+
66+
expect(wrapper.html()).toContain('<p>Hello Dio</p>')
67+
})
68+
69+
it('should emit update:model-value event when html changes', async () => {
70+
const spy = vi.fn()
71+
72+
const wrapper = component.mount({
73+
props: {
74+
'modelValue': '<p>test</p>',
75+
'onUpdate:model-value': spy,
76+
},
77+
})
78+
79+
const editable = wrapper.find('[data-test-id=editable-area]')
80+
81+
editable.element.innerHTML = '<p>test2</p>'
82+
83+
await editable.trigger('input')
84+
85+
expect(spy).toHaveBeenCalledOnce()
86+
87+
expect(spy).toHaveBeenCalledWith('<p>test2</p>')
88+
})
89+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<script setup lang="ts">
2+
// model
3+
const model = defineModel({
4+
type: String,
5+
default: '',
6+
})
7+
8+
const editableAreaRef = ref<HTMLElement>()
9+
10+
function loadEditableArea() {
11+
if (!editableAreaRef.value) return
12+
13+
editableAreaRef.value.innerHTML = model.value
14+
}
15+
16+
function onInput() {
17+
if (!editableAreaRef.value) return
18+
19+
model.value = editableAreaRef.value?.innerHTML ?? ''
20+
}
21+
22+
watch(model, loadEditableArea)
23+
24+
onMounted(loadEditableArea)
25+
26+
// view
27+
const state = defineProp<Record<string, string>>('state', {
28+
type: Object,
29+
default: () => ({}),
30+
})
31+
32+
const isDynamicRender = computed(() => {
33+
// include {{ }} in text
34+
return model.value.includes('{{') && model.value.includes('}}')
35+
})
36+
37+
const loading = ref(false)
38+
39+
const instance = shallowRef(
40+
defineComponent({
41+
name: 'HTMLContentEditableInnerRenderer',
42+
setup: () => state.value,
43+
template: '<div></div>',
44+
})
45+
)
46+
47+
function loadComponent() {
48+
if (!isDynamicRender.value) return
49+
50+
loading.value = true
51+
52+
instance.value = defineComponent({
53+
name: instance.value.name,
54+
setup: instance.value.setup,
55+
template: `<div>${model.value}</div>`,
56+
})
57+
58+
loading.value = false
59+
}
60+
61+
watch([model, state], loadComponent, { immediate: true })
62+
</script>
63+
<template>
64+
<div>
65+
<component :is="instance" v-if="!loading && isDynamicRender" data-test-id="view-area" />
66+
<div
67+
ref="editableAreaRef"
68+
data-test-id="editable-area"
69+
contenteditable="true"
70+
@input="onInput"
71+
/>
72+
</div>
73+
</template>

0 commit comments

Comments
 (0)