Implement textarea validation for character limits in submission service; enhance user feedback with error messages for minimum and maximum character requirements. Update translations and UI components to support new validation features.

This commit is contained in:
Rotimi Best
2026-03-29 09:50:24 +01:00
parent acccbee110
commit ff2ad59377
24 changed files with 805 additions and 88 deletions
@@ -49,6 +49,14 @@
// Browser detection
const browser = typeof window !== 'undefined';
function normalizeEditorContent(value: HTMLContent | undefined): string {
const html = String(value ?? '').trim();
if (html === '' || html === '<p></p>' || html === '<p><br></p>') return '';
return html;
}
// Handle content persistence
$effect(() => {
if (enablePersistence && browser && content) {
@@ -94,6 +102,17 @@
}
});
$effect(() => {
if (!editor || editor.isDestroyed) return;
const nextContent = normalizeEditorContent(content);
const currentContent = normalizeEditorContent(editor.getHTML());
if (currentContent === nextContent) return;
editor.commands.setContent(nextContent, false);
});
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
if (props?.editor && !props.editor.isDestroyed) {
const newContent = props.editor.getHTML();
@@ -1,12 +1,68 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import {
getExerciseQuestionLabel,
getTextareaCharacterLimits,
sanitizeTextareaCharacterLimit,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Input } from '../../../../base/input';
import { Textarea } from '../../../../base/textarea';
let { labels }: ExerciseQuestionRendererProps = $props();
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const characterLimits = $derived(getTextareaCharacterLimits(question));
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function patchSettings(next: Record<string, unknown>) {
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
}
function updateCharacterLimit(settingKey: 'minCharacters' | 'maxCharacters', value: string) {
const parsedValue = value === '' ? undefined : sanitizeTextareaCharacterLimit(value);
if (parsedValue === undefined) {
patchSettings({ [settingKey]: undefined });
return;
}
patchSettings({ [settingKey]: parsedValue });
}
</script>
<div class="ui:space-y-3">
<Textarea class="ui:w-full" rows={4} disabled={true} placeholder={label('textarea.edit.placeholder')} />
<div class="ui:grid ui:gap-3 ui:md:grid-cols-2">
<div class="ui:space-y-1">
<p class="ui:text-sm ui:font-medium">{label('textarea.edit.min_characters_label')}</p>
<Input
type="number"
min="0"
step="1"
value={characterLimits.minCharacters === undefined ? '' : String(characterLimits.minCharacters)}
{disabled}
placeholder={label('textarea.edit.min_characters_placeholder')}
onchange={(event) => updateCharacterLimit('minCharacters', event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-1">
<p class="ui:text-sm ui:font-medium">{label('textarea.edit.max_characters_label')}</p>
<Input
type="number"
min="0"
step="1"
value={characterLimits.maxCharacters === undefined ? '' : String(characterLimits.maxCharacters)}
{disabled}
placeholder={label('textarea.edit.max_characters_placeholder')}
onchange={(event) => updateCharacterLimit('maxCharacters', event.currentTarget.value)}
/>
</div>
</div>
</div>
@@ -1,7 +1,12 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Editor } from '@tiptap/core';
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import {
getExerciseQuestionLabel,
getTextareaAnswerCharacterCount,
getTextareaCharacterLimits,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Editor as EditorComponent } from '../../../editor';
let {
@@ -16,16 +21,109 @@
getExerciseQuestionLabel(labels, key, fallback);
const content = $derived(answer?.type === 'TEXTAREA' ? answer.text : String(answer ?? ''));
const characterLimits = $derived(getTextareaCharacterLimits(question));
const characterCount = $derived(getTextareaAnswerCharacterCount(content));
const shouldShowCharacterCount = $derived(
characterLimits.minCharacters !== undefined && characterLimits.maxCharacters !== undefined
);
let detachEditorListeners: (() => void) | undefined;
onDestroy(() => {
detachEditorListeners?.();
});
function normalizeAnswerHtml(value: string | null | undefined): string {
const html = String(value ?? '').trim();
if (getTextareaAnswerCharacterCount(html) < 1) return '';
return html;
}
function updateAnswer(value: string | null | undefined) {
onAnswerChange({ type: 'TEXTAREA', text: normalizeAnswerHtml(value) });
}
function getEditorDom(editor: Editor): HTMLElement | null {
try {
return editor.view.dom as HTMLElement;
} catch {
return null;
}
}
function getCurrentCharacterCount(editor: Editor): number {
return getTextareaAnswerCharacterCount(editor.getHTML());
}
function getSelectedCharacterCount(editor: Editor): number {
const { from, to, empty } = editor.state.selection;
if (empty) return 0;
return editor.state.doc.textBetween(from, to, '\n', '\n').length;
}
function canInsertCharacters(editor: Editor, insertedCharacterCount: number): boolean {
const maxCharacters = characterLimits.maxCharacters;
if (maxCharacters === undefined || insertedCharacterCount < 1) return true;
const nextCharacterCount =
getCurrentCharacterCount(editor) - getSelectedCharacterCount(editor) + insertedCharacterCount;
return nextCharacterCount <= maxCharacters;
}
function getBeforeInputCharacterCount(event: InputEvent): number {
switch (event.inputType) {
case 'insertParagraph':
case 'insertLineBreak':
return 1;
default:
return typeof event.data === 'string' ? event.data.length : 0;
}
}
function handleEditorReady(editor: Editor) {
if (!editor?.view?.dom || disabled) return;
detachEditorListeners?.();
const editorDom = getEditorDom(editor);
if (!editorDom || disabled) return;
const blurHandler = () => {
if (!editor.isDestroyed) onAnswerChange({ type: 'TEXTAREA', text: String(editor.getHTML() ?? '') });
if (!editor.isDestroyed) updateAnswer(editor.getHTML());
};
const beforeInputHandler = (event: Event) => {
const inputEvent = event as InputEvent;
if (!inputEvent.inputType?.startsWith('insert')) return;
const insertedCharacterCount = getBeforeInputCharacterCount(inputEvent);
if (canInsertCharacters(editor, insertedCharacterCount)) return;
inputEvent.preventDefault();
};
const pasteHandler = (event: ClipboardEvent) => {
const pastedText = event.clipboardData?.getData('text/plain') ?? '';
if (canInsertCharacters(editor, pastedText.length)) return;
event.preventDefault();
};
editorDom.addEventListener('blur', blurHandler);
editorDom.addEventListener('beforeinput', beforeInputHandler);
editorDom.addEventListener('paste', pasteHandler);
detachEditorListeners = () => {
editorDom.removeEventListener('blur', blurHandler);
editorDom.removeEventListener('beforeinput', beforeInputHandler);
editorDom.removeEventListener('paste', pasteHandler);
};
editor.view.dom.addEventListener('blur', blurHandler);
onDestroy(() => {
if (editor?.view?.dom) editor.view.dom.removeEventListener('blur', blurHandler);
});
}
</script>
@@ -36,7 +134,13 @@
showToolBar={!disabled}
placeholder={label('textarea.take.placeholder')}
editorClass="ui:h-40"
onContentChange={(nextContent) => onAnswerChange({ type: 'TEXTAREA', text: String(nextContent ?? '') })}
onContentChange={updateAnswer}
onEditorReady={handleEditorReady}
/>
{#if shouldShowCharacterCount}
<span class="ui:text-muted-foreground ui:block ui:text-right ui:text-xs">
{characterCount} / {characterLimits.maxCharacters}
</span>
{/if}
</div>
File diff suppressed because one or more lines are too long