Skip to main content

Common Types

Shared TypeScript types and interfaces used across all quiz and assessment components in the PicoLMS library.

Overview

The common types module provides:

  • Content Rendering: Function types for rendering different content formats
  • Base Metadata: Standard metadata structure for all entities
  • Media Attachments: Types for handling various media files
  • Difficulty Levels: Standardized difficulty classifications
  • Feedback System: Types for user feedback and validation
  • Accessibility: Configuration for accessible components

Import

import type {
ContentRenderer,
BaseMetadata,
MediaAttachment,
MediaType,
DifficultyLevel,
Feedback,
FeedbackType,
ValidationResult,
AccessibilityConfig,
} from '@scinforma/picolms';

Types

ContentRenderer

A function type for rendering content with context-awareness.

type ContentRenderer = (
content: string,
context?: {
type: 'question' | 'option' | 'instruction' | 'feedback' | 'hint';
questionId?: string;
optionId?: string;
}
) => ReactNode;

Parameters

ParameterTypeRequiredDescription
contentstringYesThe content to render (string, HTML, Markdown, etc.)
contextobjectNoAdditional context about where/how content is used
context.typestringNoType of content being rendered
context.questionIdstringNoAssociated question identifier
context.optionIdstringNoAssociated option identifier

Returns

ReactNode - Rendered React component or element

Example

import { ContentRenderer } from '@scinforma/picolms';
import ReactMarkdown from 'react-markdown';

const markdownRenderer: ContentRenderer = (content, context) => {
if (context?.type === 'question') {
return (
<div className="question-text">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}

if (context?.type === 'feedback') {
return <span className="feedback-message">{content}</span>;
}

return <ReactMarkdown>{content}</ReactMarkdown>;
};

// Usage in component
<Question
config={questionConfig}
contentRenderer={markdownRenderer}
/>

Context Types

  • 'question' - Main question text
  • 'option' - Answer option text
  • 'instruction' - Instructional text
  • 'feedback' - Feedback messages
  • 'hint' - Hint text

MediaType

Enumeration of supported media attachment types.

type MediaType = 'image' | 'video' | 'audio' | 'document';

Values

ValueDescriptionCommon Formats
'image'Image filesPNG, JPEG, GIF, SVG, WebP
'video'Video filesMP4, WebM, OGG
'audio'Audio filesMP3, WAV, OGG
'document'Document filesPDF, DOCX, TXT

Example

const imageMedia: MediaType = 'image';
const videoMedia: MediaType = 'video';

// Use in media validation
function validateMediaType(type: MediaType) {
const allowedTypes: MediaType[] = ['image', 'video'];
return allowedTypes.includes(type);
}

DifficultyLevel

Standardized difficulty level classification.

type DifficultyLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';

Values

LevelDescriptionTypical Use Case
'beginner'Entry-level contentIntroduction, basic concepts
'intermediate'Moderate difficultyApplied knowledge, problem-solving
'advanced'High difficultyComplex scenarios, synthesis
'expert'Expert-levelResearch-level, specialized knowledge

Example

import { DifficultyLevel } from '@scinforma/picolms';

interface QuestionMetadata {
difficulty: DifficultyLevel;
estimatedTime: number;
}

const metadata: QuestionMetadata = {
difficulty: 'intermediate',
estimatedTime: 300, // 5 minutes
};

// Filter questions by difficulty
const filterByDifficulty = (
questions: Question[],
level: DifficultyLevel
) => {
return questions.filter(q => q.metadata?.difficulty === level);
};

FeedbackType

Types of feedback that can be provided to users.

type FeedbackType = 'correct' | 'incorrect' | 'partial' | 'hint';

Values

TypeDescriptionUse Case
'correct'Answer is correctPositive reinforcement
'incorrect'Answer is incorrectError notification
'partial'Answer is partially correctPartial credit scenarios
'hint'Hint or guidanceProgressive assistance

Example

import { FeedbackType } from '@scinforma/picolms';

function getFeedbackStyle(type: FeedbackType): string {
const styles: Record<FeedbackType, string> = {
correct: 'text-green-600 bg-green-50',
incorrect: 'text-red-600 bg-red-50',
partial: 'text-yellow-600 bg-yellow-50',
hint: 'text-blue-600 bg-blue-50',
};

return styles[type];
}

Interfaces

BaseMetadata

Base metadata interface that can be extended by all entities.

interface BaseMetadata {
createdAt?: string;
updatedAt?: string;
createdBy?: string;
tags?: string[];
[key: string]: any;
}

Properties

PropertyTypeRequiredDescription
createdAtstringNoISO 8601 timestamp of creation
updatedAtstringNoISO 8601 timestamp of last update
createdBystringNoIdentifier of the creator (user ID, email, etc.)
tagsstring[]NoArray of tag strings for categorization
[key: string]anyNoAdditional custom properties (index signature)

Example

import { BaseMetadata } from '@scinforma/picolms';

// Basic usage
const metadata: BaseMetadata = {
createdAt: '2024-01-15T10:30:00Z',
updatedAt: '2024-01-16T14:20:00Z',
createdBy: 'user-123',
tags: ['mathematics', 'algebra', 'quadratic-equations'],
};

// Extended with custom fields
interface QuestionMetadata extends BaseMetadata {
difficulty: DifficultyLevel;
estimatedTime: number;
subject: string;
learningObjectives: string[];
}

const questionMeta: QuestionMetadata = {
createdAt: '2024-01-15T10:30:00Z',
createdBy: 'instructor-456',
tags: ['physics', 'mechanics'],
difficulty: 'advanced',
estimatedTime: 600,
subject: 'Classical Mechanics',
learningObjectives: ['Apply Newton\'s laws', 'Analyze force diagrams'],
};

// Access custom properties
const customField = metadata['customProperty']; // Type: any

Timestamp Format

Use ISO 8601 format for all timestamp fields:

const now = new Date().toISOString();
// "2024-12-29T15:30:45.123Z"

const metadata: BaseMetadata = {
createdAt: now,
updatedAt: now,
};

MediaAttachment

Represents a media file attached to content.

interface MediaAttachment {
id: string;
type: MediaType;
url: string;
alt?: string;
caption?: string;
thumbnail?: string;
mimeType?: string;
size?: number;
}

Properties

PropertyTypeRequiredDescription
idstringYesUnique identifier for the media attachment
typeMediaTypeYesType of media ('image', 'video', 'audio', 'document')
urlstringYesURL to access the media file
altstringNoAlternative text for accessibility (recommended for images)
captionstringNoDisplay caption for the media
thumbnailstringNoURL to thumbnail version (recommended for videos/documents)
mimeTypestringNoMIME type of the file (e.g., 'image/png', 'video/mp4')
sizenumberNoFile size in bytes

Example

import { MediaAttachment } from '@scinforma/picolms';

// Image attachment
const imageAttachment: MediaAttachment = {
id: 'media-001',
type: 'image',
url: 'https://example.com/images/diagram.png',
alt: 'Mathematical diagram showing Pythagorean theorem',
caption: 'Figure 1: Pythagorean theorem visualization',
thumbnail: 'https://example.com/images/diagram-thumb.png',
mimeType: 'image/png',
size: 245760, // 240 KB
};

// Video attachment
const videoAttachment: MediaAttachment = {
id: 'media-002',
type: 'video',
url: 'https://example.com/videos/lecture.mp4',
alt: 'Physics lecture on momentum',
caption: 'Lecture 5: Conservation of Momentum',
thumbnail: 'https://example.com/videos/lecture-thumb.jpg',
mimeType: 'video/mp4',
size: 52428800, // 50 MB
};

// Document attachment
const docAttachment: MediaAttachment = {
id: 'media-003',
type: 'document',
url: 'https://example.com/docs/reference.pdf',
caption: 'Reference Material: Chapter 3',
thumbnail: 'https://example.com/docs/reference-preview.png',
mimeType: 'application/pdf',
size: 1048576, // 1 MB
};

// Render media attachment
function MediaDisplay({ media }: { media: MediaAttachment }) {
if (media.type === 'image') {
return (
<figure>
<img src={media.url} alt={media.alt} />
{media.caption && <figcaption>{media.caption}</figcaption>}
</figure>
);
}

if (media.type === 'video') {
return (
<video
src={media.url}
poster={media.thumbnail}
controls
aria-label={media.alt}
>
{media.caption}
</video>
);
}

return null;
}

Common MIME Types

Media TypeCommon MIME Types
Imageimage/png, image/jpeg, image/gif, image/svg+xml, image/webp
Videovideo/mp4, video/webm, video/ogg
Audioaudio/mpeg, audio/wav, audio/ogg
Documentapplication/pdf, application/msword, text/plain

Feedback

Represents user feedback for assessments.

interface Feedback {
type: FeedbackType;
message: string;
showAfter?: 'immediate' | 'submission' | 'grading';
}

Properties

PropertyTypeRequiredDefaultDescription
typeFeedbackTypeYes-Type of feedback
messagestringYes-Feedback message content
showAfter'immediate' | 'submission' | 'grading'No-When to display the feedback

showAfter Values

ValueDescriptionUse Case
'immediate'Show immediately after user interactionReal-time validation, hints
'submission'Show after form submissionPost-attempt feedback
'grading'Show after grading is completeInstructor feedback, detailed explanations

Example

import { Feedback, FeedbackType } from '@scinforma/picolms';

// Correct answer feedback
const correctFeedback: Feedback = {
type: 'correct',
message: 'Excellent work! Your answer is correct.',
showAfter: 'immediate',
};

// Incorrect answer with hint
const incorrectFeedback: Feedback = {
type: 'incorrect',
message: 'Not quite right. Remember to consider the force of friction.',
showAfter: 'immediate',
};

// Partial credit feedback
const partialFeedback: Feedback = {
type: 'partial',
message: 'You\'re on the right track! You identified 3 out of 5 key points.',
showAfter: 'submission',
};

// Progressive hint
const hintFeedback: Feedback = {
type: 'hint',
message: 'Try breaking down the problem into smaller steps.',
showAfter: 'immediate',
};

// Detailed grading feedback
const gradingFeedback: Feedback = {
type: 'incorrect',
message: 'Your approach was creative, but missed the key concept of energy conservation. Review section 4.2 in the textbook.',
showAfter: 'grading',
};

// Render feedback component
function FeedbackDisplay({ feedback }: { feedback: Feedback }) {
const bgColor = {
correct: 'bg-green-50 border-green-200',
incorrect: 'bg-red-50 border-red-200',
partial: 'bg-yellow-50 border-yellow-200',
hint: 'bg-blue-50 border-blue-200',
}[feedback.type];

return (
<div className={`p-4 border rounded ${bgColor}`}>
<p>{feedback.message}</p>
</div>
);
}

// Multiple feedback items
interface QuestionConfig {
id: string;
question: string;
feedback: {
correct?: Feedback;
incorrect?: Feedback;
hints?: Feedback[];
};
}

const questionConfig: QuestionConfig = {
id: 'q1',
question: 'What is the capital of France?',
feedback: {
correct: {
type: 'correct',
message: 'Correct! Paris is the capital of France.',
showAfter: 'immediate',
},
incorrect: {
type: 'incorrect',
message: 'Incorrect. Try again!',
showAfter: 'immediate',
},
hints: [
{
type: 'hint',
message: 'Think about major European cities.',
showAfter: 'immediate',
},
{
type: 'hint',
message: 'It\'s known as the "City of Light".',
showAfter: 'immediate',
},
],
},
};

ValidationResult

Result of a validation operation.

interface ValidationResult {
isValid: boolean;
errors?: string[];
warnings?: string[];
}

Properties

PropertyTypeRequiredDescription
isValidbooleanYesWhether validation passed
errorsstring[]NoArray of error messages (blocks submission)
warningsstring[]NoArray of warning messages (allows submission)

Example

import { ValidationResult } from '@scinforma/picolms';

// Valid result
const validResult: ValidationResult = {
isValid: true,
};

// Invalid with errors
const invalidResult: ValidationResult = {
isValid: false,
errors: [
'Answer is required',
'Answer must be at least 10 characters',
],
};

// Valid with warnings
const validWithWarnings: ValidationResult = {
isValid: true,
warnings: [
'Consider providing more detail',
'This answer is shorter than recommended',
],
};

// Complex validation
const complexResult: ValidationResult = {
isValid: false,
errors: ['Email format is invalid'],
warnings: ['Password should contain special characters'],
};

// Validation function
function validateAnswer(answer: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];

if (!answer || answer.trim().length === 0) {
errors.push('Answer is required');
}

if (answer.length < 10) {
errors.push('Answer must be at least 10 characters');
}

if (answer.length < 50) {
warnings.push('Consider providing more detail for a complete answer');
}

if (!/[A-Z]/.test(answer)) {
warnings.push('Consider starting sentences with capital letters');
}

return {
isValid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
};
}

// Display validation results
function ValidationDisplay({ result }: { result: ValidationResult }) {
return (
<div>
{result.errors && result.errors.length > 0 && (
<div className="errors">
<h4>Errors:</h4>
<ul>
{result.errors.map((error, i) => (
<li key={i} className="text-red-600">{error}</li>
))}
</ul>
</div>
)}

{result.warnings && result.warnings.length > 0 && (
<div className="warnings">
<h4>Warnings:</h4>
<ul>
{result.warnings.map((warning, i) => (
<li key={i} className="text-yellow-600">{warning}</li>
))}
</ul>
</div>
)}

{result.isValid && (
<p className="text-green-600">Validation passed ✓</p>
)}
</div>
);
}

// Use in form submission
function handleSubmit(answer: string) {
const validation = validateAnswer(answer);

if (!validation.isValid) {
// Show errors, prevent submission
showValidationErrors(validation.errors);
return;
}

if (validation.warnings) {
// Show warnings, allow submission
showValidationWarnings(validation.warnings);
}

submitAnswer(answer);
}

AccessibilityConfig

Configuration for accessibility features.

interface AccessibilityConfig {
ariaLabel?: string;
ariaDescribedBy?: string;
screenReaderText?: string;
keyboardShortcuts?: Record<string, string>;
}

Properties

PropertyTypeRequiredDescription
ariaLabelstringNoARIA label for the element
ariaDescribedBystringNoID(s) of elements that describe this element
screenReaderTextstringNoAdditional text for screen readers (visually hidden)
keyboardShortcutsRecord<string, string>NoMap of keyboard shortcuts to their descriptions

Example

import { AccessibilityConfig } from '@scinforma/picolms';

// Basic accessibility
const basicA11y: AccessibilityConfig = {
ariaLabel: 'Multiple choice question',
screenReaderText: 'Select one answer from the options below',
};

// With describedBy
const withDescription: AccessibilityConfig = {
ariaLabel: 'Math equation input',
ariaDescribedBy: 'equation-hint equation-format',
screenReaderText: 'Enter your answer using standard mathematical notation',
};

// With keyboard shortcuts
const withShortcuts: AccessibilityConfig = {
ariaLabel: 'Interactive quiz question',
screenReaderText: 'Use keyboard shortcuts for faster navigation',
keyboardShortcuts: {
'Enter': 'Submit answer',
'Space': 'Select/deselect option',
'Tab': 'Navigate to next element',
'Shift+Tab': 'Navigate to previous element',
'Escape': 'Cancel and return',
'H': 'Show hint',
},
};

// Complete configuration
const completeA11y: AccessibilityConfig = {
ariaLabel: 'Essay question with file upload',
ariaDescribedBy: 'question-instructions file-upload-help',
screenReaderText: 'Type your essay response. You can also upload a document using the upload button below.',
keyboardShortcuts: {
'Ctrl+Enter': 'Submit response',
'Ctrl+S': 'Save draft',
'Ctrl+U': 'Open file upload dialog',
},
};

// Apply to component
function AccessibleQuestion({ config, a11y }: {
config: QuestionConfig;
a11y: AccessibilityConfig;
}) {
return (
<div
role="group"
aria-label={a11y.ariaLabel}
aria-describedby={a11y.ariaDescribedBy}
>
{a11y.screenReaderText && (
<span className="sr-only">{a11y.screenReaderText}</span>
)}

<h3>{config.question}</h3>

{/* Question content */}

{a11y.keyboardShortcuts && (
<div className="keyboard-shortcuts" aria-label="Keyboard shortcuts">
<h4>Keyboard Shortcuts:</h4>
<dl>
{Object.entries(a11y.keyboardShortcuts).map(([key, desc]) => (
<div key={key}>
<dt><kbd>{key}</kbd></dt>
<dd>{desc}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);
}

// Keyboard shortcut handler
function useKeyboardShortcuts(shortcuts: Record<string, string>) {
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
const key = [
e.ctrlKey && 'Ctrl',
e.shiftKey && 'Shift',
e.altKey && 'Alt',
e.key,
].filter(Boolean).join('+');

if (shortcuts[key]) {
e.preventDefault();
handleShortcut(key);
}
};

window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [shortcuts]);
}

Usage Examples

Complete Question Component

import {
ContentRenderer,
BaseMetadata,
MediaAttachment,
DifficultyLevel,
Feedback,
ValidationResult,
AccessibilityConfig,
} from '@scinforma/picolms';

interface QuestionConfig {
id: string;
type: string;
question: string;
difficulty: DifficultyLevel;
media?: MediaAttachment[];
feedback?: {
correct: Feedback;
incorrect: Feedback;
hints: Feedback[];
};
metadata: BaseMetadata;
accessibility: AccessibilityConfig;
}

const questionConfig: QuestionConfig = {
id: 'phys-101-q1',
type: 'short-answer',
question: 'Explain Newton\'s First Law of Motion.',
difficulty: 'beginner',
media: [
{
id: 'img-1',
type: 'image',
url: '/images/newton-first-law.png',
alt: 'Diagram illustrating Newton\'s First Law',
caption: 'Figure 1: Object at rest and in motion',
mimeType: 'image/png',
size: 156000,
},
],
feedback: {
correct: {
type: 'correct',
message: 'Excellent explanation!',
showAfter: 'submission',
},
incorrect: {
type: 'incorrect',
message: 'Review the concept of inertia.',
showAfter: 'submission',
},
hints: [
{
type: 'hint',
message: 'Think about what happens when no force is applied.',
showAfter: 'immediate',
},
],
},
metadata: {
createdAt: '2024-01-15T10:00:00Z',
createdBy: 'instructor-123',
tags: ['physics', 'mechanics', 'newton-laws'],
subject: 'Classical Mechanics',
estimatedTime: 300,
},
accessibility: {
ariaLabel: 'Physics question about Newton\'s First Law',
ariaDescribedBy: 'question-hint-1',
screenReaderText: 'Provide a detailed explanation in the text area below',
keyboardShortcuts: {
'Ctrl+Enter': 'Submit answer',
'H': 'Show hint',
},
},
};

function Question({ config, contentRenderer }: {
config: QuestionConfig;
contentRenderer: ContentRenderer;
}) {
const [answer, setAnswer] = useState('');
const [validation, setValidation] = useState<ValidationResult | null>(null);

const validateAnswer = (): ValidationResult => {
const errors: string[] = [];

if (!answer.trim()) {
errors.push('Answer is required');
}

if (answer.length < 50) {
errors.push('Answer must be at least 50 characters');
}

return {
isValid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
};
};

const handleSubmit = () => {
const result = validateAnswer();
setValidation(result);

if (result.isValid) {
// Submit answer
}
};

return (
<div
role="group"
aria-label={config.accessibility.ariaLabel}
aria-describedby={config.accessibility.ariaDescribedBy}
>
{/* Render question with custom renderer */}
<h3>
{contentRenderer(config.question, {
type: 'question',
questionId: config.id,
})}
</h3>

{/* Display media attachments */}
{config.media?.map(media => (
<figure key={media.id}>
<img src={media.url} alt={media.alt} />
{media.caption && <figcaption>{media.caption}</figcaption>}
</figure>
))}

{/* Answer input */}
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
aria-label="Your answer"
/>

{/* Validation errors */}
{validation && !validation.isValid && (
<div className="errors">
{validation.errors?.map((error, i) => (
<p key={i} className="text-red-600">{error}</p>
))}
</div>
)}

<button onClick={handleSubmit}>Submit</button>
</div>
);
}

TypeScript

All types are fully typed and can be imported individually or together:

// Import specific types
import type { ContentRenderer, MediaAttachment } from '@scinforma/picolms';

// Import multiple types
import type {
BaseMetadata,
DifficultyLevel,
Feedback,
ValidationResult,
} from '@scinforma/picolms';

// Use with generics
interface CustomMetadata extends BaseMetadata {
customField: string;
}

const metadata: CustomMetadata = {
createdAt: '2024-01-15T10:00:00Z',
tags: ['custom'],
customField: 'value',
};

Best Practices

Content Rendering

  1. Sanitize HTML: Always sanitize HTML content to prevent XSS attacks
  2. Support Multiple Formats: Support plain text, HTML, and Markdown
  3. Context-Aware: Use context to apply appropriate styling
import DOMPurify from 'dompurify';
import ReactMarkdown from 'react-markdown';

const safeRenderer: ContentRenderer = (content, context) => {
// Detect format
if (content.includes('<')) {
// HTML content - sanitize
const clean = DOMPurify.sanitize(content);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

if (content.includes('#') || content.includes('**')) {
// Markdown content
return <ReactMarkdown>{content}</ReactMarkdown>;
}

// Plain text
return <span>{content}</span>;
};

Media Attachments

  1. Always Provide Alt Text: Required for accessibility
  2. Use Thumbnails: Improve performance for large files
  3. Validate File Sizes: Prevent uploading excessively large files
  4. Include MIME Types: Enable proper handling

Validation

  1. Separate Errors and Warnings: Errors block submission, warnings don't
  2. Provide Clear Messages: Help users understand what's wrong
  3. Validate Early: Show validation feedback as users type

Accessibility

  1. Provide ARIA Labels: Describe purpose of interactive elements
  2. Document Shortcuts: Make keyboard navigation discoverable
  3. Test with Screen Readers: Verify screen reader text is helpful
  4. Support Keyboard Navigation: All functionality accessible via keyboard

  • useQuestionState: Manage question state with these types
  • BaseQuestion: Base question component using these types
  • Question Components: Specific question types implementing these interfaces

Notes

  • All timestamp fields should use ISO 8601 format
  • Media URLs should be properly validated and sanitized
  • The BaseMetadata index signature allows flexibility while maintaining type safety
  • Content renderers should handle all specified content types
  • Always sanitize user-generated content before rendering