Documentation

Widget Development Guide

Complete guide to creating, translating, and testing custom widgets for SveltyCMS.

Last updated: 2/3/2026

Widget Development Guide

This guide covers everything you need to know about building 3-Pillar widgets, from your first component to advanced multilingual support and testing.

πŸš€ Quick Start: Creating a Widget

1. Planning & Folder Structure

Create a new directory in src/widgets/custom/. The folder name must match your widget name exactly.

src/widgets/custom/my-widget/
β”œβ”€β”€ index.ts           # Definition Pillar
β”œβ”€β”€ input.svelte       # Input Pillar
β”œβ”€β”€ display.svelte     # Display Pillar
└── types.ts           # Type Definitions

2. The Definition Pillar (index.ts)

This file defines your widget’s capabilities, validation, and metadata.

import { createWidget } from '@widgets/widgetFactory';
import * as v from 'valibot';

export default createWidget({
	Name: 'my-widget',
	Icon: 'mdi:star',
	Description: 'A custom widget example',

	// Component Paths
	inputComponentPath: '/src/widgets/custom/my-widget/input.svelte',
	displayComponentPath: '/src/widgets/custom/my-widget/display.svelte',

	// Validation Schema (Valibot)
	validationSchema: (field) => {
		const schema = v.string([v.minLength(1)]);
		// Handle multilingual object structure if translated
		return field.translated ? v.record(v.string(), schema) : schema;
	},

	defaults: {
		translated: true
	}
});

3. The Input Pillar (input.svelte)

Handles user interaction and data binding.

<script lang="ts">
	import { app } from '@stores/store.svelte';
	import { DEFAULT_CONTENT_LANGUAGE } from '@src/utils/constants';

	let { field, value = $bindable() } = $props();

	// Reactive Language Selection
	const _language = $derived(field.translated ? app.contentLanguage : DEFAULT_CONTENT_LANGUAGE);

	// Safe Value Access
	const safeValue = $derived(field.translated && value ? (value[_language] ?? '') : (value ?? ''));

	function update(newValue) {
		if (field.translated) {
			value = { ...(value || {}), [_language]: newValue };
		} else {
			value = newValue;
		}
	}
</script>

<input type="text" value={safeValue} oninput={(e) => update(e.currentTarget.value)} placeholder={field.label} />

4. The Display Pillar (display.svelte)

Read-only rendering for lists and previews.

<script lang="ts">
	import { app } from '@stores/store.svelte';

	let { field, value } = $props();

	const displayValue = $derived(() => {
		if (field.translated && value) {
			return value[app.contentLanguage] || 'β€”';
		}
		return value || 'β€”';
	});
</script>

<span>{displayValue}</span>

🌍 Multilingual Support

SveltyCMS uses a dual-language system:

  1. systemLanguage: For UI text (labels, buttons). Handled by Paraglide-JS.
  2. contentLanguage: For user content. Handled by app.contentLanguage.

Best Practices

  • Always check field.translated: Don’t assume data is an object or a string.
  • Use $derived: Language switching must be reactive.
  • Preserve Translations: When updating, merge with existing object properties: value = { ...value, [lang]: new }.
  • Validation: Your schema must accept both string (untranslated) and Record<string, string> (translated).

πŸ§ͺ Testing Your Widget

We use Vitest and Testing Library.

Unit Testing (input.test.ts)

import { render, fireEvent, screen } from '@testing-library/svelte';
import { app } from '@stores/store.svelte';
import Input from './input.svelte';

test('updates correct language', async () => {
	app.contentLanguage = 'en';
	let value = { en: '', de: 'Hallo' };

	const { getByRole } = render(Input, {
		field: { translated: true },
		value
	});

	const input = getByRole('textbox');
	await fireEvent.input(input, { target: { value: 'Hello' } });

	expect(value.en).toBe('Hello');
	expect(value.de).toBe('Hallo'); // Preserved
});

Validation Testing

import { parse } from 'valibot';
import Widget from './index';

test('validates multilingual structure', () => {
	const schema = Widget.validationSchema({ translated: true });

	expect(() => parse(schema, { en: 'Valid' })).not.toThrow();
	expect(() => parse(schema, 'Invalid String')).toThrow();
});

πŸ“‹ Security Checklist

  • Sanitization: Encode HTML output in display.svelte unless explicitly using a sanitizer.
  • Validation: Ensure strict schemas (e.g., regex for colors/phones).
  • Input Limits: Set maxLength defaults to prevent DB bloat.
  • Dependencies: Avoid heavyweight libraries if native browser features suffice.

πŸ›‘οΈ Strict Compliance Checklist (Marketplace Ready)

To ensure your widget is accepted into the SveltyCMS Marketplace, it must meet these strict criteria:

1. Accessibility (WCAG 2.2 AA / WCAG 3.0 Gold)

  • Labels: EVERY input must have a visible <label> with a for attribute matching the input’s id.
    • ❌ <span class="label">Name</span> <input />
    • βœ… <label for="name">Name</label> <input id="name" />
  • Error States: Invalid inputs must have:
    • aria-invalid="true"
    • aria-describedby="field-error" pointing to the error message ID.
  • Keyboard Navigation: All interactive elements (custom dropdowns, ratings, drag-handles) must be focusable (tabindex="0") and operatable via Enter/Space/Arrows.
  • Dynamic Content: Use aria-live="polite" for dynamic updates (loading states, characters remaining).

2. Performance & Architecture

  • Lazy Loading: Import heavy libraries (Maps, Charts, Editors) dynamically inside onMount or event handlers.
    • const module = await import('heavy-lib');
  • Debouncing: Search/API inputs must be debounced (min 300ms) to prevent server flooding.
  • Iconography: Use iconify-icon web component. Do NOT bundle SVG libraries.
  • Styling: Use Tailwind CSS utility classes exclusively. Avoid <style> blocks with custom CSS to ensure theme compatibility.

3. Flexibility

  • No Hardcoded Secrets: Never hardcode API keys. Pass them via publicEnv or widget configuration props.
  • Resilience: Gracefully handle missing configuration (e.g., β€œMap unavailable - API Key missing” instead of crashing).
  • Self-Contained: The widget folder should contain all necessary component logic. Shared logic should come from @utils or @stores.
developerwidgetstutorialtestingi18n