release : rich text editor (TipTap) (#38)
All checks were successful
Release / release (push) Successful in 1m6s
All checks were successful
Release / release (push) Successful in 1m6s
## Résumé Release de `develop` vers `main` pour déclencher `semantic-release` (publication sur Gitea Packages). Inclut : - **#37** — `feat(input-rich-text) : ajout d'un éditeur de texte riche basé sur TipTap v3` Le commit `feat:` déclenchera un bump **minor** (rétrocompatible). ## Test plan - [x] Tests verts sur `develop` (315/315) - [x] Lint OK (0 erreur sur les fichiers ajoutés) - [x] Histoire build OK - [ ] Vérifier le run du workflow `release.yml` après merge - [ ] Vérifier la nouvelle version publiée sur Gitea Packages Co-authored-by: kevin <kevin@yuno.malio.fr> Co-authored-by: tristan <tristan@yuno.malio.fr> Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr> Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
133
app/components/malio/input/InputRichText.test.ts
Normal file
133
app/components/malio/input/InputRichText.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {afterEach, describe, expect, it} from 'vitest'
|
||||
import {flushPromises, mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import InputRichText from './InputRichText.vue'
|
||||
|
||||
type InputRichTextProps = {
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
minHeight?: string
|
||||
editable?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
outputFormat?: 'markdown' | 'html'
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
}
|
||||
|
||||
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
||||
|
||||
const mountComponent = async (props: InputRichTextProps = {}) => {
|
||||
const wrapper = mount(InputRichTextForTest, {
|
||||
props,
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('MalioInputRichText', () => {
|
||||
it('renders the label and reuses a provided id', async () => {
|
||||
const wrapper = await mountComponent({id: 'custom-rt-id', label: 'Description'})
|
||||
|
||||
const label = wrapper.get('label')
|
||||
expect(label.text()).toBe('Description')
|
||||
expect(label.attributes('for')).toBe('custom-rt-id')
|
||||
expect(wrapper.get('#custom-rt-id').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('generates an id when missing', async () => {
|
||||
const wrapper = await mountComponent({label: 'Description'})
|
||||
|
||||
const labelFor = wrapper.get('label').attributes('for')
|
||||
expect(labelFor?.startsWith('malio-input-rich-text-')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the toolbar buttons in editable mode', async () => {
|
||||
const wrapper = await mountComponent({modelValue: ''})
|
||||
|
||||
const buttons = wrapper.findAll('button[type="button"]')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(13)
|
||||
expect(wrapper.find('button[title="Gras"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[title="Italique"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[title="Lien"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[title="Annuler"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[title="Rétablir"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the toolbar in readonly display mode (editable=false)', async () => {
|
||||
const wrapper = await mountComponent({editable: false, modelValue: '**hi**'})
|
||||
|
||||
expect(wrapper.find('button[title="Gras"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables toolbar buttons when disabled', async () => {
|
||||
const wrapper = await mountComponent({disabled: true, modelValue: ''})
|
||||
|
||||
const boldBtn = wrapper.get('button[title="Gras"]')
|
||||
expect(boldBtn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables toolbar buttons when readonly', async () => {
|
||||
const wrapper = await mountComponent({readonly: true, modelValue: ''})
|
||||
|
||||
const boldBtn = wrapper.get('button[title="Gras"]')
|
||||
expect(boldBtn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows hint message in muted color', async () => {
|
||||
const wrapper = await mountComponent({hint: 'Helpful hint'})
|
||||
|
||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Helpful hint')
|
||||
})
|
||||
|
||||
it('shows error state on wrapper, label and message', async () => {
|
||||
const wrapper = await mountComponent({label: 'Description', error: 'Editor error'})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
|
||||
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
|
||||
})
|
||||
|
||||
it('shows success state on wrapper, label and message', async () => {
|
||||
const wrapper = await mountComponent({label: 'Description', success: 'Editor success'})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-success')
|
||||
expect(wrapper.get('p.text-m-success').text()).toBe('Editor success')
|
||||
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-success')
|
||||
})
|
||||
|
||||
it('prioritizes error over success', async () => {
|
||||
const wrapper = await mountComponent({error: 'Editor error', success: 'Editor success'})
|
||||
|
||||
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
|
||||
})
|
||||
|
||||
it('sets aria-invalid and aria-describedby on the editor content when error', async () => {
|
||||
const wrapper = await mountComponent({id: 'rt-aria', error: 'Boom'})
|
||||
|
||||
const editorContent = wrapper.find('[aria-invalid="true"]')
|
||||
expect(editorContent.exists()).toBe(true)
|
||||
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
|
||||
})
|
||||
|
||||
it('renders initial markdown content visually', async () => {
|
||||
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('Mon titre')
|
||||
expect(html).toContain('Un paragraphe.')
|
||||
})
|
||||
})
|
||||
326
app/components/malio/input/InputRichText.vue
Normal file
326
app/components/malio/input/InputRichText.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div :class="mergedGroupClass">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="editorId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<!-- Mode lecture seule (rendu uniquement) -->
|
||||
<div
|
||||
v-if="!editable"
|
||||
:id="editorId"
|
||||
:class="mergedReadonlyClass"
|
||||
>
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
|
||||
<!-- Mode éditable -->
|
||||
<div
|
||||
v-else
|
||||
:id="editorId"
|
||||
:class="mergedEditorWrapperClass"
|
||||
@click="focusEditor"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-m-border bg-m-bg p-1"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-for="btn in toolbarButtons"
|
||||
:key="btn.key"
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:class="btn.isActive() ? 'bg-m-primary/15 text-m-primary' : ''"
|
||||
:title="btn.title"
|
||||
:disabled="disabled || readonly"
|
||||
:aria-label="btn.title"
|
||||
:aria-pressed="btn.isActive()"
|
||||
@mousedown.prevent
|
||||
@click="btn.action()"
|
||||
>
|
||||
<IconifyIcon :icon="btn.icon" :width="18" :height="18" />
|
||||
</button>
|
||||
|
||||
<span class="mx-1 h-5 w-px bg-m-border" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Annuler"
|
||||
aria-label="Annuler"
|
||||
:disabled="disabled || readonly || !editor?.can().undo()"
|
||||
@mousedown.prevent
|
||||
@click="editor?.chain().focus().undo().run()"
|
||||
>
|
||||
<IconifyIcon icon="mdi:undo" :width="18" :height="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Rétablir"
|
||||
aria-label="Rétablir"
|
||||
:disabled="disabled || readonly || !editor?.can().redo()"
|
||||
@mousedown.prevent
|
||||
@click="editor?.chain().focus().redo().run()"
|
||||
>
|
||||
<IconifyIcon icon="mdi:redo" :width="18" :height="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
class="malio-rich-text flex flex-1 cursor-text"
|
||||
:style="{ minHeight }"
|
||||
:aria-invalid="hasError || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${editorId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, useId, watch } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
||||
|
||||
type OutputFormat = 'markdown' | 'html'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue?: string | null | undefined
|
||||
placeholder?: string
|
||||
minHeight?: string
|
||||
editable?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
outputFormat?: OutputFormat
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: '',
|
||||
minHeight: '160px',
|
||||
editable: true,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
outputFormat: 'markdown',
|
||||
groupClass: '',
|
||||
labelClass: '',
|
||||
editorClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const editor = shallowRef<Editor>()
|
||||
const isFocused = shallowRef(false)
|
||||
|
||||
const editorId = computed(() => props.id?.toString() || `malio-input-rich-text-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isInteractionLocked = computed(() => props.disabled || props.readonly)
|
||||
|
||||
const describedBy = computed(() =>
|
||||
hasError.value || hasSuccess.value || props.hint ? `${editorId.value}-describedby` : undefined,
|
||||
)
|
||||
|
||||
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'mb-1 block text-sm font-medium',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: isFocused.value
|
||||
? 'text-m-primary'
|
||||
: 'text-m-text',
|
||||
props.disabled ? 'text-black/60' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedEditorWrapperClass = computed(() =>
|
||||
twMerge(
|
||||
'rich-text-wrapper flex flex-col overflow-hidden rounded-md border bg-white transition-colors',
|
||||
hasError.value
|
||||
? 'border-m-danger focus-within:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus-within:border-m-success'
|
||||
: isFocused.value
|
||||
? 'border-m-primary'
|
||||
: 'border-m-muted hover:border-m-text/60',
|
||||
props.disabled ? 'cursor-not-allowed bg-m-bg/50 opacity-70' : '',
|
||||
props.editorClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedReadonlyClass = computed(() =>
|
||||
twMerge(
|
||||
'malio-rich-text prose prose-sm max-w-none rounded-md border border-m-border bg-white p-3',
|
||||
'prose-headings:font-semibold prose-a:text-m-primary',
|
||||
'prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none',
|
||||
'prose-pre:bg-m-text prose-pre:text-white',
|
||||
props.editorClass,
|
||||
),
|
||||
)
|
||||
|
||||
const focusEditor = () => {
|
||||
if (isInteractionLocked.value) return
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
|
||||
const promptForLink = () => {
|
||||
if (!editor.value) return
|
||||
const previous = editor.value.getAttributes('link').href as string | undefined
|
||||
const url = window.prompt('URL du lien (vide pour retirer)', previous ?? '')
|
||||
if (url === null) return
|
||||
if (url === '') {
|
||||
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
return
|
||||
}
|
||||
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}
|
||||
|
||||
const toolbarButtons = computed(() => {
|
||||
const e = editor.value
|
||||
return [
|
||||
{ key: 'bold', icon: 'mdi:format-bold', title: 'Gras', isActive: () => !!e?.isActive('bold'), action: () => e?.chain().focus().toggleBold().run() },
|
||||
{ key: 'italic', icon: 'mdi:format-italic', title: 'Italique', isActive: () => !!e?.isActive('italic'), action: () => e?.chain().focus().toggleItalic().run() },
|
||||
{ key: 'strike', icon: 'mdi:format-strikethrough', title: 'Barré', isActive: () => !!e?.isActive('strike'), action: () => e?.chain().focus().toggleStrike().run() },
|
||||
{ key: 'h2', icon: 'mdi:format-header-2', title: 'Titre H2', isActive: () => !!e?.isActive('heading', { level: 2 }), action: () => e?.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ key: 'h3', icon: 'mdi:format-header-3', title: 'Titre H3', isActive: () => !!e?.isActive('heading', { level: 3 }), action: () => e?.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ key: 'bulletList', icon: 'mdi:format-list-bulleted', title: 'Liste à puces', isActive: () => !!e?.isActive('bulletList'), action: () => e?.chain().focus().toggleBulletList().run() },
|
||||
{ key: 'orderedList', icon: 'mdi:format-list-numbered', title: 'Liste numérotée', isActive: () => !!e?.isActive('orderedList'), action: () => e?.chain().focus().toggleOrderedList().run() },
|
||||
{ key: 'blockquote', icon: 'mdi:format-quote-close', title: 'Citation', isActive: () => !!e?.isActive('blockquote'), action: () => e?.chain().focus().toggleBlockquote().run() },
|
||||
{ key: 'code', icon: 'mdi:code-tags', title: 'Code inline', isActive: () => !!e?.isActive('code'), action: () => e?.chain().focus().toggleCode().run() },
|
||||
{ key: 'codeBlock', icon: 'mdi:code-braces-box', title: 'Bloc de code', isActive: () => !!e?.isActive('codeBlock'), action: () => e?.chain().focus().toggleCodeBlock().run() },
|
||||
{ key: 'link', icon: 'mdi:link-variant', title: 'Lien', isActive: () => !!e?.isActive('link'), action: promptForLink },
|
||||
]
|
||||
})
|
||||
|
||||
const getCurrentValue = (): string => {
|
||||
if (!editor.value) return ''
|
||||
if (props.outputFormat === 'html') return editor.value.getHTML()
|
||||
const storage = editor.value.storage.markdown as { getMarkdown: () => string } | undefined
|
||||
return storage ? storage.getMarkdown() : editor.value.getHTML()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = new Editor({
|
||||
content: props.modelValue ?? '',
|
||||
editable: props.editable && !props.disabled && !props.readonly,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
tightLists: true,
|
||||
bulletListMarker: '-',
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white',
|
||||
},
|
||||
},
|
||||
onUpdate: () => {
|
||||
emit('update:modelValue', getCurrentValue())
|
||||
},
|
||||
onFocus: () => {
|
||||
isFocused.value = true
|
||||
},
|
||||
onBlur: () => {
|
||||
isFocused.value = false
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (incoming) => {
|
||||
if (!editor.value) return
|
||||
if ((incoming ?? '') === getCurrentValue()) return
|
||||
editor.value.commands.setContent(incoming ?? '', { emitUpdate: false })
|
||||
})
|
||||
|
||||
watch(() => [props.editable, props.disabled, props.readonly], () => {
|
||||
editor.value?.setEditable(props.editable && !props.disabled && !props.readonly)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.malio-rich-text :deep(.ProseMirror) {
|
||||
outline: none;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.malio-rich-text :deep(.ProseMirror > *:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.malio-rich-text :deep(.ProseMirror > *:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.malio-rich-text :deep(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--m-muted));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
202
app/story/input/inputRichText.story.vue
Normal file
202
app/story/input/inputRichText.story.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<Story title="Input/RichText">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputRichText
|
||||
v-model="simpleValue"
|
||||
label="Note"
|
||||
placeholder="Écrire ici…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
|
||||
<MalioInputRichText
|
||||
v-model="hintValue"
|
||||
label="Description"
|
||||
hint="Mise en forme via la barre d'outils"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputRichText
|
||||
v-model="errorValue"
|
||||
label="Compte-rendu"
|
||||
error="Le compte-rendu doit faire au moins 20 caractères"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputRichText
|
||||
v-model="successValue"
|
||||
label="Compte-rendu"
|
||||
success="Compte-rendu validé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputRichText
|
||||
v-model="disabledValue"
|
||||
label="Note"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputRichText
|
||||
v-model="readonlyValue"
|
||||
label="Note"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 lg:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
|
||||
<MalioInputRichText
|
||||
:model-value="readonlyValue"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 lg:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
|
||||
<MalioInputRichText
|
||||
v-model="htmlValue"
|
||||
label="Article"
|
||||
output-format="html"
|
||||
min-height="200px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<docs lang="md">
|
||||
# MalioInputRichText
|
||||
|
||||
Éditeur de texte riche basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown**.
|
||||
Sortie en **markdown** (par défaut) ou en **HTML**. Aligné sur le thème Malio
|
||||
(couleurs `m-*`, icônes `mdi:*`, états error / success / hint).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Props détaillées
|
||||
|
||||
### id
|
||||
|
||||
- Type: `string`
|
||||
- Description: Identifiant HTML.
|
||||
- Comportement: Généré automatiquement si non fourni (`malio-input-rich-text-…`).
|
||||
|
||||
### label
|
||||
|
||||
- Type: `string`
|
||||
- Description: Label affiché au-dessus de l'éditeur.
|
||||
- Comportement: Change de couleur selon l'état (focus → `m-primary`, error → `m-danger`, success → `m-success`).
|
||||
|
||||
### modelValue
|
||||
|
||||
- Type: `string | null | undefined`
|
||||
- Description: Contenu de l'éditeur (markdown ou HTML selon `outputFormat`).
|
||||
- Comportement: `v-model` ; sync bidirectionnelle.
|
||||
|
||||
### placeholder
|
||||
|
||||
- Type: `string`
|
||||
- Défaut: `''`
|
||||
- Description: Texte affiché quand l'éditeur est vide.
|
||||
|
||||
### minHeight
|
||||
|
||||
- Type: `string`
|
||||
- Défaut: `160px`
|
||||
- Description: Hauteur minimale de la zone d'édition (CSS valid value).
|
||||
|
||||
### editable
|
||||
|
||||
- Type: `boolean`
|
||||
- Défaut: `true`
|
||||
- Description: `false` → mode affichage seul, **toolbar masquée**, contenu rendu en `prose`.
|
||||
|
||||
### disabled
|
||||
|
||||
- Type: `boolean`
|
||||
- Défaut: `false`
|
||||
- Description: Désactive l'édition et la toolbar (opacité réduite).
|
||||
|
||||
### readonly
|
||||
|
||||
- Type: `boolean`
|
||||
- Défaut: `false`
|
||||
- Description: Lecture seule (toolbar visible mais désactivée, pas de saisie).
|
||||
|
||||
### hint / error / success
|
||||
|
||||
- Type: `string`
|
||||
- Description: Messages contextuels affichés sous l'éditeur.
|
||||
- Priorité: `error` > `success` > `hint`.
|
||||
|
||||
### outputFormat
|
||||
|
||||
- Type: `'markdown' | 'html'`
|
||||
- Défaut: `'markdown'`
|
||||
- Description: Format émis dans `update:modelValue`.
|
||||
- `markdown` : utilise `tiptap-markdown` (`getMarkdown()`).
|
||||
- `html` : utilise `editor.getHTML()`.
|
||||
|
||||
### groupClass / labelClass / editorClass
|
||||
|
||||
- Type: `string`
|
||||
- Description: Classes Tailwind additionnelles fusionnées via `twMerge` pour override.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Toolbar
|
||||
|
||||
Boutons (icônes `mdi:*`) :
|
||||
|
||||
- Gras, Italique, Barré
|
||||
- Titre H2, Titre H3
|
||||
- Liste à puces, Liste numérotée
|
||||
- Citation
|
||||
- Code inline, Bloc de code
|
||||
- Lien (prompt URL ; vide pour retirer)
|
||||
- Annuler / Rétablir
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Accessibilité
|
||||
|
||||
- Le label est lié à la zone d'édition via `for` / `id`.
|
||||
- `aria-invalid="true"` sur la zone d'édition en cas d'erreur.
|
||||
- `aria-describedby` référence le message d'erreur / succès / hint.
|
||||
- Boutons toolbar : `aria-pressed` reflète l'état actif, `aria-label` pour l'usage screen-reader.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Events
|
||||
|
||||
### update:modelValue
|
||||
|
||||
- Émis à chaque modification du contenu.
|
||||
- Payload : `string` (markdown ou HTML selon `outputFormat`).
|
||||
|
||||
</docs>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioInputRichText from '../../components/malio/input/InputRichText.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
|
||||
const errorValue = ref('Trop court')
|
||||
const successValue = ref('Tout est bon de mon côté.')
|
||||
const disabledValue = ref('Contenu indisponible.')
|
||||
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
|
||||
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
|
||||
</script>
|
||||
Reference in New Issue
Block a user