Exemples Lit

Exemples de web components Lit incluant les propriétés réactives, les crochets de cycle de vie, les modèles et les patrons modernes de composants

💻 Bonjour Monde Lit typescript

🟢 simple

Exemples de base de composants Lit et applications Bonjour Monde avec décorateurs

// Lit Hello World Examples

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

// 1. Basic functional component
@customElement('hello-world')
export class HelloWorld extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 16px;
            border: 1px solid #ccc;
            border-radius: 8px;
            margin: 16px 0;
            font-family: Arial, sans-serif;
        }

        h1 {
            color: #2c3e50;
            margin: 0 0 8px 0;
        }
    `;

    render() {
        return html`<h1>Hello, World!</h1>`;
    }
}

// 2. Component with properties
@customElement('greeting-component')
export class GreetingComponent extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 16px;
            background: #f8f9fa;
            border-radius: 8px;
            margin: 8px 0;
        }

        h1 {
            color: #3498db;
            margin: 0;
        }
    `;

    @property({ type: String })
    name: string = 'World';

    @property({ type: String })
    greeting: string = 'Hello';

    render() {
        return html`
            <h1>${this.greeting}, ${this.name}!</h1>
        `;
    }
}

// 3. Component with reactive state
@customElement('counter-component')
export class CounterComponent extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #3498db;
            border-radius: 8px;
            text-align: center;
            max-width: 300px;
            margin: 16px auto;
        }

        .count {
            font-size: 2em;
            font-weight: bold;
            color: #2c3e50;
            margin: 16px 0;
        }

        button {
            background: #3498db;
            color: white;
            border: none;
            padding: 8px 16px;
            margin: 0 4px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 1em;
        }

        button:hover {
            background: #2980b9;
        }

        button:active {
            transform: scale(0.98);
        }
    `;

    @state()
    private count: number = 0;

    private increment() {
        this.count++;
    }

    private decrement() {
        this.count--;
    }

    private reset() {
        this.count = 0;
    }

    render() {
        return html`
            <h2>Counter Component</h2>
            <div class="count">${this.count}</div>
            <div>
                <button @click=${this.increment}>+</button>
                <button @click=${this.reset}>Reset</button>
                <button @click=${this.decrement}>-</button>
            </div>
        `;
    }
}

// 4. Component with complex state
interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

@customElement('todo-app')
export class TodoApp extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            max-width: 500px;
            margin: 16px auto;
            font-family: Arial, sans-serif;
        }

        .todo-input {
            width: 100%;
            padding: 8px;
            font-size: 16px;
            border: 2px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            margin-bottom: 8px;
        }

        .todo-input:focus {
            outline: none;
            border-color: #3498db;
        }

        .todo-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }

        .todo-item {
            display: flex;
            align-items: center;
            padding: 8px;
            border: 1px solid #ddd;
            margin-bottom: 4px;
            border-radius: 4px;
            background: white;
        }

        .todo-item.completed {
            background: #f8f9fa;
            opacity: 0.7;
        }

        .todo-item.completed .todo-text {
            text-decoration: line-through;
        }

        .todo-checkbox {
            margin-right: 8px;
        }

        .todo-text {
            flex: 1;
            font-size: 16px;
        }

        .delete-btn {
            background: #e74c3c;
            color: white;
            border: none;
            padding: 4px 8px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 12px;
        }

        .delete-btn:hover {
            background: #c0392b;
        }

        .stats {
            margin-top: 16px;
            padding: 8px;
            background: #f8f9fa;
            border-radius: 4px;
            font-size: 14px;
            color: #7f8c8d;
        }
    `;

    @state()
    private todos: Todo[] = [
        { id: 1, text: 'Learn Lit', completed: false },
        { id: 2, text: 'Build web components', completed: false }
    ];

    @state()
    private newTodoText: string = '';

    private handleInput(event: Event) {
        const target = event.target as HTMLInputElement;
        this.newTodoText = target.value;
    }

    private handleKeyPress(event: KeyboardEvent) {
        if (event.key === 'Enter' && this.newTodoText.trim()) {
            this.addTodo();
        }
    }

    private addTodo() {
        if (this.newTodoText.trim()) {
            const newTodo: Todo = {
                id: Date.now(),
                text: this.newTodoText.trim(),
                completed: false
            };
            this.todos = [...this.todos, newTodo];
            this.newTodoText = '';
        }
    }

    private toggleTodo(id: number) {
        this.todos = this.todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
    }

    private deleteTodo(id: number) {
        this.todos = this.todos.filter(todo => todo.id !== id);
    }

    private get completedCount() {
        return this.todos.filter(todo => todo.completed).length;
    }

    private get activeCount() {
        return this.todos.filter(todo => !todo.completed).length;
    }

    render() {
        return html`
            <h2>Todo App</h2>
            <input
                class="todo-input"
                type="text"
                placeholder="What needs to be done?"
                .value=${this.newTodoText}
                @input=${this.handleInput}
                @keypress=${this.handleKeyPress}
            />

            <ul class="todo-list">
                ${this.todos.map(todo => html`
                    <li class="todo-item ${todo.completed ? 'completed' : ''}">
                        <input
                            class="todo-checkbox"
                            type="checkbox"
                            .checked=${todo.completed}
                            @change=${() => this.toggleTodo(todo.id)}
                        />
                        <span class="todo-text">${todo.text}</span>
                        <button
                            class="delete-btn"
                            @click=${() => this.deleteTodo(todo.id)}
                        >
                            Delete
                        </button>
                    </li>
                `)}
            </ul>

            <div class="stats">
                ${this.activeCount} active, ${this.completedCount} completed
            </div>
        `;
    }
}

// 5. Component with computed properties
@customElement('price-calculator')
export class PriceCalculator extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #27ae60;
            border-radius: 8px;
            max-width: 400px;
            margin: 16px auto;
        }

        h2 {
            color: #27ae60;
            text-align: center;
            margin-top: 0;
        }

        .input-group {
            margin-bottom: 16px;
        }

        label {
            display: block;
            margin-bottom: 4px;
            font-weight: bold;
            color: #2c3e50;
        }

        input {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            font-size: 16px;
        }

        .summary {
            background: #f8f9fa;
            padding: 16px;
            border-radius: 4px;
            border-left: 4px solid #27ae60;
        }

        .summary-row {
            display: flex;
            justify-content: space-between;
            margin-bottom: 8px;
        }

        .summary-row.total {
            font-weight: bold;
            font-size: 1.2em;
            border-top: 1px solid #ddd;
            padding-top: 8px;
        }

        .total {
            color: #27ae60;
        }
    `;

    @property({ type: Number })
    price: number = 100;

    @property({ type: Number })
    quantity: number = 1;

    @property({ type: Number })
    taxRate: number = 0.08;

    private get subtotal() {
        return this.price * this.quantity;
    }

    private get tax() {
        return this.subtotal * this.taxRate;
    }

    private get total() {
        return this.subtotal + this.tax;
    }

    private handlePriceChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.price = parseFloat(target.value) || 0;
    }

    private handleQuantityChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.quantity = parseInt(target.value) || 0;
    }

    private handleTaxRateChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.taxRate = parseFloat(target.value) || 0;
    }

    render() {
        return html`
            <h2>Price Calculator</h2>

            <div class="input-group">
                <label for="price">Price ($)</label>
                <input
                    id="price"
                    type="number"
                    .value=${this.price}
                    @input=${this.handlePriceChange}
                    step="0.01"
                    min="0"
                />
            </div>

            <div class="input-group">
                <label for="quantity">Quantity</label>
                <input
                    id="quantity"
                    type="number"
                    .value=${this.quantity}
                    @input=${this.handleQuantityChange}
                    min="0"
                />
            </div>

            <div class="input-group">
                <label for="taxRate">Tax Rate (${(this.taxRate * 100).toFixed(1)}%)</label>
                <input
                    id="taxRate"
                    type="number"
                    .value=${this.taxRate}
                    @input=${this.handleTaxRateChange}
                    step="0.01"
                    min="0"
                    max="1"
                />
            </div>

            <div class="summary">
                <div class="summary-row">
                    <span>Subtotal:</span>
                    <span>$${this.subtotal.toFixed(2)}</span>
                </div>
                <div class="summary-row">
                    <span>Tax:</span>
                    <span>$${this.tax.toFixed(2)}</span>
                </div>
                <div class="summary-row total">
                    <span>Total:</span>
                    <span class="total">$${this.total.toFixed(2)}</span>
                </div>
            </div>
        `;
    }
}

// 6. Demo app showing all components
@customElement('lit-demo-app')
export class LitDemoApp extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            max-width: 1200px;
            margin: 0 auto;
            font-family: Arial, sans-serif;
        }

        .demo-section {
            margin: 32px 0;
            padding: 20px;
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            background: #fafafa;
        }

        .demo-title {
            color: #2c3e50;
            border-bottom: 2px solid #3498db;
            padding-bottom: 8px;
            margin-bottom: 16px;
        }

        .demo-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
            gap: 20px;
        }
    `;

    render() {
        return html`
            <h1>Lit Components Demo</h1>
            <p>Explore various Lit web component patterns and features:</p>

            <div class="demo-section">
                <h2 class="demo-title">Basic Components</h2>
                <div class="demo-grid">
                    <hello-world></hello-world>
                    <greeting-component name="Lit" greeting="Welcome to"></greeting-component>
                </div>
            </div>

            <div class="demo-section">
                <h2 class="demo-title">Interactive Components</h2>
                <div class="demo-grid">
                    <counter-component></counter-component>
                    <price-calculator></price-calculator>
                </div>
            </div>

            <div class="demo-section">
                <h2 class="demo-title">Complex Application</h2>
                <todo-app></todo-app>
            </div>
        `;
    }
}

export { HelloWorld, GreetingComponent, CounterComponent, TodoApp, PriceCalculator, LitDemoApp };

💻 Propriétés et Réactivité de Lit typescript

🟡 intermediate

Travailler avec les propriétés réactives, la gestion d'état et le flux de données dans les composants Lit

// Lit Properties and Reactivity Examples

import { LitElement, html, css } from 'lit';
import { customElement, property, state, query, queryAll } from 'lit/decorators.js';

// 1. Basic properties with type conversion
@customElement('property-basics')
export class PropertyBasics extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #3498db;
            border-radius: 8px;
            margin: 16px 0;
        }

        .property-display {
            background: #f8f9fa;
            padding: 12px;
            border-radius: 4px;
            margin: 8px 0;
            font-family: monospace;
        }

        input, select {
            margin: 8px;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
    `;

    // Different property types
    @property({ type: String })
    name: string = 'World';

    @property({ type: Number })
    count: number = 0;

    @property({ type: Boolean })
    isActive: boolean = true;

    @property({ type: Array })
    items: string[] = ['apple', 'banana', 'orange'];

    @property({ type: Object })
    config: { theme: string; version: string } = { theme: 'light', version: '1.0.0' };

    private handleNameChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.name = target.value;
    }

    private handleCountChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.count = parseInt(target.value) || 0;
    }

    private toggleActive() {
        this.isActive = !this.isActive;
    }

    private addItem() {
        const newItem = `item-${Date.now()}`;
        this.items = [...this.items, newItem];
    }

    private removeItem(index: number) {
        this.items = this.items.filter((_, i) => i !== index);
    }

    private updateTheme() {
        this.config = { ...this.config, theme: this.config.theme === 'light' ? 'dark' : 'light' };
    }

    render() {
        return html`
            <h3>Property Types Demo</h3>

            <div>
                <label>Name: <input .value=${this.name} @input=${this.handleNameChange}></label>
                <div class="property-display">name: ${this.name}</div>
            </div>

            <div>
                <label>Count: <input type="number" .value=${this.count} @input=${this.handleCountChange}></label>
                <div class="property-display">count: ${this.count}</div>
            </div>

            <div>
                <button @click=${this.toggleActive}>
                    Active: ${this.isActive ? 'Yes' : 'No'}
                </button>
                <div class="property-display">isActive: ${this.isActive}</div>
            </div>

            <div>
                <button @click=${this.addItem}>Add Item</button>
                <div class="property-display">items: ${JSON.stringify(this.items)}</div>
                ${this.items.map((item, index) => html`
                    <div>
                        <span>${item}</span>
                        <button @click=${() => this.removeItem(index)}>Remove</button>
                    </div>
                `)}
            </div>

            <div>
                <button @click=${this.updateTheme}>Toggle Theme</button>
                <div class="property-display">config: ${JSON.stringify(this.config)}</div>
            </div>
        `;
    }
}

// 2. Computed properties and derived state
@customElement('computed-properties')
export class ComputedProperties extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #9b59b6;
            border-radius: 8px;
            margin: 16px 0;
        }

        .stats-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
            gap: 16px;
            margin: 16px 0;
        }

        .stat-card {
            background: #f8f9fa;
            padding: 16px;
            border-radius: 8px;
            text-align: center;
            border-left: 4px solid #9b59b6;
        }

        .stat-value {
            font-size: 2em;
            font-weight: bold;
            color: #9b59b6;
        }

        .stat-label {
            font-size: 0.9em;
            color: #7f8c8d;
            margin-top: 4px;
        }

        .controls {
            margin: 20px 0;
        }

        input {
            margin: 8px;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
    `;

    @property({ type: Number })
    baseValue: number = 10;

    @property({ type: Number })
    multiplier: number = 2;

    @property({ type: Number })
    offset: number = 5;

    @property({ type: Array })
    data: number[] = [1, 2, 3, 4, 5];

    private get doubled() {
        return this.baseValue * this.multiplier;
    }

    private get adjusted() {
        return this.doubled + this.offset;
    }

    private get sum() {
        return this.data.reduce((sum, val) => sum + val, 0);
    }

    private get average() {
        return this.data.length > 0 ? this.sum / this.data.length : 0;
    }

    private get max() {
        return Math.max(...this.data);
    }

    private get min() {
        return Math.min(...this.data);
    }

    private handleBaseValueChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.baseValue = parseFloat(target.value) || 0;
    }

    private handleMultiplierChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.multiplier = parseFloat(target.value) || 1;
    }

    private handleOffsetChange(event: Event) {
        const target = event.target as HTMLInputElement;
        this.offset = parseFloat(target.value) || 0;
    }

    private addDataPoint() {
        const newPoint = Math.floor(Math.random() * 20) + 1;
        this.data = [...this.data, newPoint];
    }

    private clearData() {
        this.data = [];
    }

    render() {
        return html`
            <h3>Computed Properties Demo</h3>

            <div class="controls">
                <label>Base: <input type="number" .value=${this.baseValue} @input=${this.handleBaseValueChange}></label>
                <label>Multiplier: <input type="number" .value=${this.multiplier} @input=${this.handleMultiplierChange}></label>
                <label>Offset: <input type="number" .value=${this.offset} @input=${this.handleOffsetChange}></label>
                <button @click=${this.addDataPoint}>Add Data Point</button>
                <button @click=${this.clearData}>Clear Data</button>
            </div>

            <div class="stats-grid">
                <div class="stat-card">
                    <div class="stat-value">${this.baseValue}</div>
                    <div class="stat-label">Base Value</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.doubled}</div>
                    <div class="stat-label">Doubled</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.adjusted}</div>
                    <div class="stat-label">Adjusted</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.sum}</div>
                    <div class="stat-label">Sum</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.average.toFixed(1)}</div>
                    <div class="stat-label">Average</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.max}</div>
                    <div class="stat-label">Max</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.min}</div>
                    <div class="stat-label">Min</div>
                </div>

                <div class="stat-card">
                    <div class="stat-value">${this.data.length}</div>
                    <div class="stat-label">Count</div>
                </div>
            </div>

            <div class="property-display">
                data: [${this.data.join(', ')}]
            </div>
        `;
    }
}

// 3. State management with controlled components
@customElement('form-controller')
export class FormController extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #e67e22;
            border-radius: 8px;
            margin: 16px 0;
        }

        .form-group {
            margin-bottom: 16px;
        }

        label {
            display: block;
            margin-bottom: 4px;
            font-weight: bold;
            color: #2c3e50;
        }

        input, select, textarea {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            font-size: 16px;
        }

        input:focus, select:focus, textarea:focus {
            outline: none;
            border-color: #e67e22;
        }

        .error {
            color: #e74c3c;
            font-size: 0.9em;
            margin-top: 4px;
        }

        button {
            background: #e67e22;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
            margin-right: 8px;
        }

        button:hover {
            background: #d35400;
        }

        button:disabled {
            background: #bdc3c7;
            cursor: not-allowed;
        }

        .form-summary {
            background: #f8f9fa;
            padding: 16px;
            border-radius: 4px;
            border-left: 4px solid #e67e22;
            margin-top: 20px;
        }

        .checkbox-group {
            display: flex;
            gap: 16px;
            margin-top: 8px;
        }

        .checkbox-item {
            display: flex;
            align-items: center;
        }

        .checkbox-item input {
            width: auto;
            margin-right: 8px;
        }
    `;

    @state()
    private formData = {
        name: '',
        email: '',
        age: '',
        country: '',
        interests: [] as string[],
        message: ''
    };

    @state()
    private errors: Record<string, string> = {};

    @state()
    private isSubmitting: boolean = false;
    @state()
    private isSubmitted: boolean = false;

    private countries = ['USA', 'Canada', 'UK', 'Germany', 'France', 'Japan', 'Australia'];
    private availableInterests = ['Technology', 'Sports', 'Music', 'Reading', 'Travel', 'Cooking'];

    private validateForm(): boolean {
        const newErrors: Record<string, string> = {};

        if (!this.formData.name.trim()) {
            newErrors.name = 'Name is required';
        }

        if (!this.formData.email.trim()) {
            newErrors.email = 'Email is required';
        } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
            newErrors.email = 'Please enter a valid email';
        }

        if (!this.formData.age) {
            newErrors.age = 'Age is required';
        } else if (parseInt(this.formData.age) < 18) {
            newErrors.age = 'You must be at least 18 years old';
        }

        if (!this.formData.country) {
            newErrors.country = 'Please select a country';
        }

        this.errors = newErrors;
        return Object.keys(newErrors).length === 0;
    }

    private handleInputChange(field: string, event: Event) {
        const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
        this.formData = { ...this.formData, [field]: target.value };

        // Clear error for this field
        if (this.errors[field]) {
            this.errors = { ...this.errors, [field]: '' };
        }
    }

    private handleInterestChange(interest: string, event: Event) {
        const target = event.target as HTMLInputElement;
        const currentInterests = [...this.formData.interests];

        if (target.checked) {
            if (!currentInterests.includes(interest)) {
                this.formData.interests = [...currentInterests, interest];
            }
        } else {
            this.formData.interests = currentInterests.filter(i => i !== interest);
        }
    }

    private async handleSubmit() {
        if (!this.validateForm()) {
            return;
        }

        this.isSubmitting = true;

        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 2000));

        this.isSubmitting = false;
        this.isSubmitted = true;

        // Reset form after 3 seconds
        setTimeout(() => {
            this.formData = {
                name: '',
                email: '',
                age: '',
                country: '',
                interests: [],
                message: ''
            };
            this.isSubmitted = false;
        }, 3000);
    }

    private handleReset() {
        this.formData = {
            name: '',
            email: '',
            age: '',
            country: '',
            interests: [],
            message: ''
        };
        this.errors = {};
        this.isSubmitted = false;
    }

    render() {
        return html`
            <h3>Controlled Form Demo</h3>

            <form @submit=${(e: Event) => { e.preventDefault(); this.handleSubmit(); }}>
                <div class="form-group">
                    <label for="name">Name *</label>
                    <input
                        id="name"
                        type="text"
                        .value=${this.formData.name}
                        @input=${(e: Event) => this.handleInputChange('name', e)}
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="error">${this.errors.name || ''}</div>
                </div>

                <div class="form-group">
                    <label for="email">Email *</label>
                    <input
                        id="email"
                        type="email"
                        .value=${this.formData.email}
                        @input=${(e: Event) => this.handleInputChange('email', e)}
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="error">${this.errors.email || ''}</div>
                </div>

                <div class="form-group">
                    <label for="age">Age *</label>
                    <input
                        id="age"
                        type="number"
                        .value=${this.formData.age}
                        @input=${(e: Event) => this.handleInputChange('age', e)}
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="error">${this.errors.age || ''}</div>
                </div>

                <div class="form-group">
                    <label for="country">Country *</label>
                    <select
                        id="country"
                        .value=${this.formData.country}
                        @change=${(e: Event) => this.handleInputChange('country', e)}
                        ?disabled=${this.isSubmitting}
                    >
                        <option value="">Select a country</option>
                        ${this.countries.map(country => html`
                            <option value=${country}>${country}</option>
                        `)}
                    </select>
                    <div class="error">${this.errors.country || ''}</div>
                </div>

                <div class="form-group">
                    <label>Interests</label>
                    <div class="checkbox-group">
                        ${this.availableInterests.map(interest => html`
                            <div class="checkbox-item">
                                <input
                                    type="checkbox"
                                    id=${interest}
                                    .checked=${this.formData.interests.includes(interest)}
                                    @change=${(e: Event) => this.handleInterestChange(interest, e)}
                                    ?disabled=${this.isSubmitting}
                                />
                                <label for=${interest}>${interest}</label>
                            </div>
                        `)}
                    </div>
                </div>

                <div class="form-group">
                    <label for="message">Message</label>
                    <textarea
                        id="message"
                        rows="4"
                        .value=${this.formData.message}
                        @input=${(e: Event) => this.handleInputChange('message', e)}
                        ?disabled=${this.isSubmitting}
                    ></textarea>
                </div>

                <button type="submit" ?disabled=${this.isSubmitting}>
                    ${this.isSubmitting ? 'Submitting...' : 'Submit'}
                </button>
                <button type="button" @click=${this.handleReset} ?disabled=${this.isSubmitting}>
                    Reset
                </button>
            </form>

            ${this.isSubmitted ? html`
                <div class="form-summary">
                    <h4>✅ Form Submitted Successfully!</h4>
                    <pre>${JSON.stringify(this.formData, null, 2)}</pre>
                </div>
            ` : ''}

            <div class="form-summary">
                <h4>Current Form State:</h4>
                <pre>${JSON.stringify(this.formData, null, 2)}</pre>
            </div>
        `;
    }
}

export { PropertyBasics, ComputedProperties, FormController };

💻 Cycle de Vie et Événements de Lit typescript

🟡 intermediate

Crochets de cycle de vie, gestion d'événements, événements personnalisés et communication de composants dans Lit

// Lit Lifecycle and Events Examples

import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';

// 1. Lifecycle hooks demo
@customElement('lifecycle-demo')
export class LifecycleDemo extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #e74c3c;
            border-radius: 8px;
            margin: 16px 0;
        }

        .log {
            background: #2c3e50;
            color: #ecf0f1;
            padding: 12px;
            border-radius: 4px;
            font-family: monospace;
            font-size: 12px;
            max-height: 200px;
            overflow-y: auto;
            margin: 16px 0;
        }

        .log-entry {
            margin: 2px 0;
            padding: 2px 0;
        }

        button {
            background: #e74c3c;
            color: white;
            border: none;
            padding: 8px 16px;
            border-radius: 4px;
            cursor: pointer;
            margin: 4px;
        }

        button:hover {
            background: #c0392b;
        }

        .status {
            background: #f8f9fa;
            padding: 8px;
            border-radius: 4px;
            margin: 8px 0;
            border-left: 4px solid #e74c3c;
        }
    `;

    @state()
    private logs: string[] = [];
    @state()
    private updateCount: number = 0;
    @state()
    private renderCount: number = 0;

    private addLog(message: string) {
        const timestamp = new Date().toLocaleTimeString();
        this.logs = [...this.logs, `[${timestamp}] ${message}`].slice(-10); // Keep last 10 logs
    }

    // Lifecycle: Called when the element is added to the DOM
    connectedCallback() {
        super.connectedCallback();
        this.addLog('connectedCallback: Element added to DOM');
    }

    // Lifecycle: Called when the element is removed from the DOM
    disconnectedCallback() {
        super.disconnectedCallback();
        this.addLog('disconnectedCallback: Element removed from DOM');
    }

    // Lifecycle: Called before the first update
    protected willUpdate(changedProperties: Map<string, any>) {
        this.addLog(`willUpdate: Properties changed - ${Array.from(changedProperties.keys()).join(', ')}`);
        this.updateCount++;
    }

    // Lifecycle: Called after the first update
    protected firstUpdated() {
        this.addLog('firstUpdated: First render completed');
    }

    // Lifecycle: Called after every update
    protected updated(changedProperties: Map<string, any>) {
        this.addLog(`updated: Properties updated - ${Array.from(changedProperties.keys()).join(', ')}`);
        this.renderCount++;
    }

    // Lifecycle: Called when the element is about to be updated
    protected shouldUpdate(changedProperties: Map<string, any>): boolean {
        const shouldUpdate = changedProperties.size > 0;
        this.addLog(`shouldUpdate: ${shouldUpdate ? 'Will update' : 'Skipping update'}`);
        return shouldUpdate;
    }

    private triggerUpdate() {
        this.requestUpdate();
    }

    private clearLogs() {
        this.logs = [];
    }

    render() {
        return html`
            <h3>Lifecycle Hooks Demo</h3>

            <div class="status">
                Updates: ${this.updateCount} | Renders: ${this.renderCount}
            </div>

            <div>
                <button @click=${this.triggerUpdate}>Force Update</button>
                <button @click=${this.clearLogs}>Clear Logs</button>
            </div>

            <div class="log">
                ${this.logs.map(log => html`<div class="log-entry">${log}</div>`)}
            </div>

            <p>Open browser console to see additional lifecycle logs when this component is added/removed from the DOM.</p>
        `;
    }
}

// 2. Event handling demo
@customElement('event-demo')
export class EventDemo extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #f39c12;
            border-radius: 8px;
            margin: 16px 0;
        }

        .button-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 16px;
            margin: 16px 0;
        }

        button {
            padding: 12px 16px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: all 0.2s;
        }

        .primary {
            background: #3498db;
            color: white;
        }

        .success {
            background: #27ae60;
            color: white;
        }

        .warning {
            background: #f39c12;
            color: white;
        }

        .danger {
            background: #e74c3c;
            color: white;
        }

        button:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
        }

        button:active {
            transform: translateY(0);
        }

        .input-group {
            margin: 16px 0;
        }

        label {
            display: block;
            margin-bottom: 4px;
            font-weight: bold;
        }

        input, select {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
        }

        .event-log {
            background: #f8f9fa;
            border: 1px solid #dee2e6;
            border-radius: 4px;
            padding: 12px;
            margin: 16px 0;
            max-height: 150px;
            overflow-y: auto;
        }

        .event-entry {
            padding: 4px 0;
            border-bottom: 1px solid #e9ecef;
            font-size: 14px;
        }

        .interactive-area {
            background: #ecf0f1;
            padding: 20px;
            border-radius: 4px;
            margin: 16px 0;
            text-align: center;
            cursor: pointer;
            user-select: none;
        }

        .coordinates {
            background: #34495e;
            color: white;
            padding: 8px;
            border-radius: 4px;
            font-family: monospace;
            margin: 8px 0;
        }
    `;

    @state()
    private eventLog: string[] = [];
    @state()
    private clickCount: number = 0;
    @state()
    private mousePosition = { x: 0, y: 0 };
    @state()
    private inputValue: string = '';
    @state()
    private selectValue: string = '';

    private addLog(event: string, detail?: any) {
        const timestamp = new Date().toLocaleTimeString();
        const message = detail ? `${event}: ${JSON.stringify(detail)}` : event;
        this.eventLog = [...this.eventLog, `[${timestamp}] ${message}`].slice(-15);
    }

    // Click event handlers
    private handleRegularClick(event: Event) {
        this.clickCount++;
        this.addLog('Regular click', { count: this.clickCount });
    }

    private handleClickWithModifier(event: MouseEvent) {
        const modifiers = [];
        if (event.ctrlKey) modifiers.push('Ctrl');
        if (event.shiftKey) modifiers.push('Shift');
        if (event.altKey) modifiers.push('Alt');
        if (event.metaKey) modifiers.push('Meta');

        this.addLog('Modified click', {
            button: event.button,
            modifiers: modifiers.length ? modifiers : 'none'
        });
    }

    // Keyboard event handlers
    private handleKeyDown(event: KeyboardEvent) {
        this.addLog('Key down', {
            key: event.key,
            code: event.code,
            ctrlKey: event.ctrlKey,
            shiftKey: event.shiftKey
        });
    }

    private handleKeyPress(event: KeyboardEvent) {
        this.addLog('Key press', { key: event.key });
    }

    // Mouse event handlers
    private handleMouseMove(event: MouseEvent) {
        this.mousePosition = { x: event.offsetX, y: event.offsetY };
    }

    private handleMouseEnter() {
        this.addLog('Mouse entered interactive area');
    }

    private handleMouseLeave() {
        this.addLog('Mouse left interactive area');
    }

    // Form event handlers
    private handleInput(event: Event) {
        const target = event.target as HTMLInputElement;
        this.inputValue = target.value;
        this.addLog('Input changed', { value: this.inputValue });
    }

    private handleSelectChange(event: Event) {
        const target = event.target as HTMLSelectElement;
        this.selectValue = target.value;
        this.addLog('Select changed', { value: this.selectValue });
    }

    // Event delegation
    private handleButtonGroupClick(event: MouseEvent) {
        const target = event.target as HTMLElement;
        if (target.tagName === 'BUTTON') {
            this.addLog('Delegated click', { button: target.textContent });
        }
    }

    private clearLog() {
        this.eventLog = [];
    }

    render() {
        return html`
            <h3>Event Handling Demo</h3>

            <div class="button-grid" @click=${this.handleButtonGroupClick}>
                <button class="primary" @click=${this.handleRegularClick}>
                    Click Count: ${this.clickCount}
                </button>
                <button class="success" @click=${this.handleClickWithModifier}>
                    Click with Modifiers
                </button>
                <button class="warning" @dblclick=${() => this.addLog('Double clicked')}>
                    Double Click Me
                </button>
                <button class="danger" @contextmenu=${(e: Event) => { e.preventDefault(); this.addLog('Right clicked'); }}>
                    Right Click Me
                </button>
            </div>

            <div class="input-group">
                <label for="input-field">Type here:</label>
                <input
                    id="input-field"
                    .value=${this.inputValue}
                    @input=${this.handleInput}
                    @keydown=${this.handleKeyDown}
                    @keypress=${this.handleKeyPress}
                    placeholder="Type to see keyboard events"
                />
            </div>

            <div class="input-group">
                <label for="select-field">Select an option:</label>
                <select
                    id="select-field"
                    .value=${this.selectValue}
                    @change=${this.handleSelectChange}
                >
                    <option value="">Choose...</option>
                    <option value="option1">Option 1</option>
                    <option value="option2">Option 2</option>
                    <option value="option3">Option 3</option>
                </select>
            </div>

            <div
                class="interactive-area"
                @mousemove=${this.handleMouseMove}
                @mouseenter=${this.handleMouseEnter}
                @mouseleave=${this.handleMouseLeave}
            >
                <h4>Interactive Area</h4>
                <p>Move your mouse over this area</p>
                <div class="coordinates">
                    Position: (${this.mousePosition.x}, ${this.mousePosition.y})
                </div>
            </div>

            <div style="margin: 16px 0;">
                <button @click=${this.clearLog}>Clear Event Log</button>
            </div>

            <div class="event-log">
                <strong>Event Log:</strong>
                ${this.eventLog.map(log => html`<div class="event-entry">${log}</div>`)}
            </div>
        `;
    }
}

// 3. Custom events demo
@customElement('custom-event-demo')
export class CustomEventDemo extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #16a085;
            border-radius: 8px;
            margin: 16px 0;
        }

        .event-sender {
            background: #ecf0f1;
            padding: 16px;
            border-radius: 4px;
            margin: 16px 0;
        }

        .event-receiver {
            background: #e8f8f5;
            border: 2px solid #16a085;
            padding: 16px;
            border-radius: 4px;
            margin: 16px 0;
            min-height: 100px;
        }

        button {
            background: #16a085;
            color: white;
            border: none;
            padding: 8px 16px;
            border-radius: 4px;
            cursor: pointer;
            margin: 4px;
        }

        button:hover {
            background: #138d75;
        }

        input {
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            margin: 4px;
        }

        .message-display {
            background: #2c3e50;
            color: white;
            padding: 12px;
            border-radius: 4px;
            margin: 8px 0;
            font-family: monospace;
        }

        .event-counter {
            background: #f39c12;
            color: white;
            padding: 4px 8px;
            border-radius: 12px;
            font-size: 12px;
            margin-left: 8px;
        }
    `;

    @state()
    private messages: string[] = [];
    @state()
    private eventCounter: number = 0;
    @state()
    private lastEventData: any = null;

    // Custom event handlers
    private handleCustomMessage(event: CustomEvent) {
        this.eventCounter++;
        this.lastEventData = event.detail;
        const message = `Message: ${event.detail.message} from ${event.detail.source}`;
        this.messages = [...this.messages, message].slice(-5);
        this.requestUpdate();
    }

    private handleUserAction(event: CustomEvent) {
        this.eventCounter++;
        const action = event.detail.action;
        const data = event.detail.data;
        const message = `User action: ${action} with data: ${JSON.stringify(data)}`;
        this.messages = [...this.messages, message].slice(-5);
        this.requestUpdate();
    }

    private handleDataUpdate(event: CustomEvent) {
        this.eventCounter++;
        const { field, oldValue, newValue } = event.detail;
        const message = `Data update: ${field} changed from ${oldValue} to ${newValue}`;
        this.messages = [...this.messages, message].slice(-5);
        this.requestUpdate();
    }

    private clearMessages() {
        this.messages = [];
        this.eventCounter = 0;
        this.lastEventData = null;
    }

    render() {
        return html`
            <h3>Custom Events Demo</h3>

            <div class="event-sender">
                <h4>Event Senders</h4>
                <event-sender-component
                    @custom-message=${this.handleCustomMessage}
                    @user-action=${this.handleUserAction}
                    @data-update=${this.handleDataUpdate}
                ></event-sender-component>
            </div>

            <div class="event-receiver">
                <h4>
                    Event Receiver
                    <span class="event-counter">${this.eventCounter} events</span>
                </h4>

                ${this.lastEventData ? html`
                    <div class="message-display">
                        Last Event: ${JSON.stringify(this.lastEventData, null, 2)}
                    </div>
                ` : ''}

                <div>
                    <strong>Recent Messages:</strong>
                    ${this.messages.map(msg => html`<div style="margin: 4px 0;">• ${msg}</div>`)}
                    ${this.messages.length === 0 ? html`<div style="color: #7f8c8d;">No events received yet</div>` : ''}
                </div>

                <button @click=${this.clearMessages}>Clear Messages</button>
            </div>
        `;
    }
}

// Component that sends custom events
@customElement('event-sender-component')
export class EventSenderComponent extends LitElement {
    static styles = css`
        :host {
            display: block;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap: 12px;
        }

        .control-group {
            display: flex;
            gap: 8px;
            align-items: center;
        }

        button {
            background: #3498db;
            color: white;
            border: none;
            padding: 8px 12px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
        }

        button:hover {
            background: #2980b9;
        }

        input {
            padding: 6px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }

        select {
            padding: 6px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
    `;

    @state()
    private messageText: string = 'Hello from sender!';

    @state()
    private userData: string = 'John Doe';

    @state()
    private userRole: string = 'user';

    private sendMessage() {
        this.dispatchEvent(new CustomEvent('custom-message', {
            detail: {
                message: this.messageText,
                source: 'EventSenderComponent',
                timestamp: Date.now()
            },
            bubbles: true,
            composed: true
        }));
    }

    private sendUserAction() {
        const actions = ['login', 'logout', 'update-profile', 'delete-account'];
        const randomAction = actions[Math.floor(Math.random() * actions.length)];

        this.dispatchEvent(new CustomEvent('user-action', {
            detail: {
                action: randomAction,
                data: {
                    user: this.userData,
                    role: this.userRole,
                    timestamp: Date.now()
                }
            },
            bubbles: true,
            composed: true
        }));
    }

    private sendGenericEvent(type: string) {
        this.dispatchEvent(new CustomEvent(type, {
            detail: { type, value: Math.random() },
            bubbles: true,
            composed: true
        }));
    }

    private handleInputChange(event: Event) {
        const target = event.target as HTMLInputElement;
        const fieldName = target.name;
        const oldValue = fieldName === 'userData' ? this.userData : this.messageText;
        const newValue = target.value;

        // Send data update event
        this.dispatchEvent(new CustomEvent('data-update', {
            detail: {
                field: fieldName,
                oldValue,
                newValue
            },
            bubbles: true,
            composed: true
        }));

        // Update the state
        if (fieldName === 'userData') {
            this.userData = newValue;
        } else {
            this.messageText = newValue;
        }
    }

    render() {
        return html`
            <div class="controls">
                <div class="control-group">
                    <input
                        name="messageText"
                        .value=${this.messageText}
                        @input=${this.handleInputChange}
                        placeholder="Enter message"
                    />
                    <button @click=${this.sendMessage}>Send Message</button>
                </div>

                <div class="control-group">
                    <input
                        name="userData"
                        .value=${this.userData}
                        @input=${this.handleInputChange}
                        placeholder="User name"
                    />
                    <select .value=${this.userRole} @change=${(e: Event) => { this.userRole = (e.target as HTMLSelectElement).value; }}>
                        <option value="user">User</option>
                        <option value="admin">Admin</option>
                        <option value="guest">Guest</option>
                    </select>
                </div>

                <div class="control-group">
                    <button @click=${this.sendUserAction}>Send User Action</button>
                    <button @click=${() => this.sendGenericEvent('custom-event-1')}>Send Event 1</button>
                    <button @click=${() => this.sendGenericEvent('custom-event-2')}>Send Event 2</button>
                </div>
            </div>
        `;
    }
}

export { LifecycleDemo, EventDemo, CustomEventDemo, EventSenderComponent };

💻 Patrons Avancés de Lit typescript

🔴 complex

Patrons avancés de Lit incluant la composition, la tematización, les stratégies de test et l'optimisation des performances

// Lit Advanced Patterns and Best Practices

import { LitElement, html, css, unsafeCSS, CSSResultGroup } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';

// 1. Component composition with slots
@customElement('card-component')
export class CardComponent extends LitElement {
    static styles = css`
        :host {
            display: block;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
            overflow: hidden;
            margin: 16px 0;
            max-width: 400px;
            transition: transform 0.2s, box-shadow 0.2s;
        }

        :host(:hover) {
            transform: translateY(-2px);
            box-shadow: 0 4px 16px rgba(0,0,0,0.15);
        }

        ::slotted(header) {
            background: #3498db;
            color: white;
            padding: 16px;
            margin: 0;
            font-size: 1.2em;
            font-weight: bold;
        }

        ::slotted(main) {
            padding: 16px;
            display: block;
        }

        ::slotted(footer) {
            background: #f8f9fa;
            padding: 12px 16px;
            border-top: 1px solid #dee2e6;
            display: block;
            font-size: 0.9em;
            color: #6c757d;
        }

        .card-actions {
            padding: 16px;
            border-top: 1px solid #dee2e6;
            display: flex;
            gap: 8px;
            justify-content: flex-end;
        }

        .slot-placeholder {
            padding: 16px;
            color: #6c757d;
            font-style: italic;
            text-align: center;
        }
    `;

    @property({ type: String })
    variant: 'default' | 'elevated' | 'outlined' = 'default';

    @property({ type: Boolean })
    interactive: boolean = false;

    @state()
    private isExpanded: boolean = false;

    render() {
        const hasHeader = this.querySelector(':scope > header') !== null;
        const hasMain = this.querySelector(':scope > main') !== null;
        const hasFooter = this.querySelector(':scope > footer') !== null;

        return html`
            <div class="card" data-variant="${this.variant}">
                ${hasHeader ? html`<slot name="header"></slot>` : ''}

                ${hasMain ? html`<slot name="main"></slot>` : html`
                    <div class="slot-placeholder">Main content goes here</div>
                `}

                ${this.interactive ? html`
                    <div class="card-actions">
                        <button @click=${() => this.isExpanded = !this.isExpanded}>
                            ${this.isExpanded ? 'Collapse' : 'Expand'}
                        </button>
                        <button @click=${() => this.toggleComplete?.()}>
                            Complete
                        </button>
                    </div>
                `: ''}

                ${hasFooter ? html`<slot name="footer"></slot>` : ''}

                ${this.interactive && this.isExpanded ? html`
                    <div class="slot-placeholder">Expanded content area</div>
                `: ''}
            </div>
        `;
    }
}

// 2. Themeable component with CSS variables
@customElement('themeable-button')
export class ThemeableButton extends LitElement {
    static styles = css`
        :host {
            --button-bg: #3498db;
            --button-color: white;
            --button-border: none;
            --button-padding: 12px 24px;
            --button-font-size: 16px;
            --button-border-radius: 4px;
            --button-transition: all 0.2s ease;

            --button-hover-bg: #2980b9;
            --button-active-bg: #21618c;
            --button-disabled-bg: #bdc3c7;
            --button-disabled-color: #7f8c8d;

            display: inline-block;
        }

        button {
            background: var(--button-bg);
            color: var(--button-color);
            border: var(--button-border);
            padding: var(--button-padding);
            font-size: var(--button-font-size);
            border-radius: var(--button-border-radius);
            transition: var(--button-transition);
            cursor: pointer;
            font-family: inherit;
            font-weight: 500;
            outline: none;
        }

        button:hover:not(:disabled) {
            background: var(--button-hover-bg);
            transform: translateY(-1px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
        }

        button:active:not(:disabled) {
            background: var(--button-active-bg);
            transform: translateY(0);
        }

        button:disabled {
            background: var(--button-disabled-bg);
            color: var(--button-disabled-color);
            cursor: not-allowed;
            transform: none;
            box-shadow: none;
        }

        /* Size variants */
        :host([size="small"]) {
            --button-padding: 8px 16px;
            --button-font-size: 14px;
        }

        :host([size="large"]) {
            --button-padding: 16px 32px;
            --button-font-size: 18px;
        }

        /* Variant styles */
        :host([variant="success"]) {
            --button-bg: #27ae60;
            --button-hover-bg: #229954;
            --button-active-bg: #1e8449;
        }

        :host([variant="warning"]) {
            --button-bg: #f39c12;
            --button-hover-bg: #e67e22;
            --button-active-bg: #d35400;
        }

        :host([variant="danger"]) {
            --button-bg: #e74c3c;
            --button-hover-bg: #c0392b;
            --button-active-bg: #a93226;
        }

        :host([variant="outline"]) {
            --button-bg: transparent;
            --button-border: 2px solid var(--button-bg, #3498db);
            --button-color: var(--button-bg, #3498db);

            button:hover:not(:disabled) {
                background: var(--button-bg, #3498db);
                color: white;
            }
        }

        /* Loading state */
        .loading {
            display: inline-flex;
            align-items: center;
            gap: 8px;
        }

        .spinner {
            width: 16px;
            height: 16px;
            border: 2px solid transparent;
            border-top: 2px solid currentColor;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
    `;

    @property()
    size: 'small' | 'medium' | 'large' = 'medium';

    @property()
    variant: 'default' | 'success' | 'warning' | 'danger' | 'outline' = 'default';

    @property({ type: Boolean })
    disabled: boolean = false;

    @property({ type: Boolean })
    loading: boolean = false;

    @property({ type: String })
    type: 'button' | 'submit' | 'reset' = 'button';

    private handleClick(event: MouseEvent) {
        if (this.disabled || this.loading) {
            event.preventDefault();
            return;
        }

        this.dispatchEvent(new CustomEvent('button-click', {
            detail: {
                type: this.type,
                variant: this.variant,
                timestamp: Date.now()
            },
            bubbles: true,
            composed: true
        }));
    }

    render() {
        return html`
            <button
                ?disabled=${this.disabled || this.loading}
                type=${this.type}
                @click=${this.handleClick}
            >
                ${this.loading ? html`
                    <span class="loading">
                        <span class="spinner"></span>
                        <slot name="loading-text">Loading...</slot>
                    </span>
                ` : html`
                    <slot></slot>
                `}
            </button>
        `;
    }
}

// 3. Virtual list component for performance
@customElement('virtual-list')
export class VirtualList extends LitElement {
    static styles = css`
        :host {
            display: block;
            height: 400px;
            border: 1px solid #ddd;
            border-radius: 4px;
            overflow: hidden;
            position: relative;
        }

        .viewport {
            height: 100%;
            overflow-y: auto;
            position: relative;
        }

        .spacer {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            pointer-events: none;
        }

        .item {
            position: absolute;
            left: 0;
            right: 0;
            padding: 12px;
            border-bottom: 1px solid #eee;
            box-sizing: border-box;
            background: white;
        }

        .item:hover {
            background: #f8f9fa;
        }

        .loading {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            background: #f8f9fa;
            color: #6c757d;
        }

        .no-items {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            color: #6c757d;
            font-style: italic;
        }
    `;

    @property({ type: Array })
    items: any[] = [];

    @property({ type: Number })
    itemHeight: number = 40;

    @property({ type: Function })
    renderItem: (item: any, index: number) => any = (item, index) => html`<div>Item ${index}: ${JSON.stringify(item)}</div>`;

    @state()
    private scrollTop: number = 0;
    @state()
    private containerHeight: number = 400;

    @query('.viewport')
    private viewport!: HTMLElement;

    @query('.spacer')
    private spacer!: HTMLElement;

    connectedCallback() {
        super.connectedCallback();
        this.addEventListener('scroll', this.handleScroll);
    }

    disconnectedCallback() {
        super.disconnectedCallback();
        this.removeEventListener('scroll', this.handleScroll);
    }

    private handleScroll = (event: Event) => {
        const target = event.target as HTMLElement;
        this.scrollTop = target.scrollTop;
    };

    private get visibleItems() {
        const startIdx = Math.floor(this.scrollTop / this.itemHeight);
        const visibleCount = Math.ceil(this.containerHeight / this.itemHeight);
        const endIdx = Math.min(startIdx + visibleCount + 1, this.items.length);

        return { start: startIdx, end: endIdx };
    }

    private updateSpacerHeight() {
        if (this.spacer) {
            this.spacer.style.height = `${this.items.length * this.itemHeight}px`;
        }
    }

    protected updated() {
        this.updateSpacerHeight();
    }

    render() {
        if (this.items.length === 0) {
            return html`
                <div class="viewport">
                    <div class="no-items">No items to display</div>
                </div>
            `;
        }

        const { start, end } = this.visibleItems();
        const visibleItems = this.items.slice(start, end);

        return html`
            <div class="viewport">
                <div class="spacer"></div>
                ${visibleItems.map((item, index) => {
                    const actualIndex = start + index;
                    return html`
                        <div
                            class="item"
                            style="top: ${actualIndex * this.itemHeight}px; height: ${this.itemHeight}px;"
                            data-index="${actualIndex}"
                        >
                            ${this.renderItem(item, actualIndex)}
                        </div>
                    `;
                })}
            </div>
        `;
    }
}

// 4. Form validation with async validation
@customElement('validated-form')
export class ValidatedForm extends LitElement {
    static styles = css`
        :host {
            display: block;
            padding: 20px;
            border: 2px solid #8e44ad;
            border-radius: 8px;
            max-width: 500px;
            margin: 16px auto;
        }

        .form-group {
            margin-bottom: 16px;
        }

        label {
            display: block;
            margin-bottom: 4px;
            font-weight: bold;
            color: #2c3e50;
        }

        input, select, textarea {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            font-size: 16px;
        }

        input:focus, select:focus, textarea:focus {
            outline: none;
            border-color: #8e44ad;
        }

        input.error, select.error, textarea.error {
            border-color: #e74c3c;
        }

        .error-message {
            color: #e74c3c;
            font-size: 0.9em;
            margin-top: 4px;
        }

        .validation-status {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-top: 4px;
        }

        .spinner {
            width: 16px;
            height: 16px;
            border: 2px solid transparent;
            border-top: 2px solid #8e44ad;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        .valid {
            color: #27ae60;
        }

        .invalid {
            color: #e74c3c;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        button {
            background: #8e44ad;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }

        button:hover:not(:disabled) {
            background: #7d3c98;
        }

        button:disabled {
            background: #bdc3c7;
            cursor: not-allowed;
        }

        .form-summary {
            background: #f8f9fa;
            padding: 16px;
            border-radius: 4px;
            margin-top: 20px;
            border-left: 4px solid #8e44ad;
        }
    `;

    @state()
    private formData = {
        username: '',
        email: '',
        password: '',
        confirmPassword: ''
    };

    @state()
    private validationStatus: Record<string, 'idle' | 'validating' | 'valid' | 'invalid'> = {};
    @state()
    private errors: Record<string, string> = {};

    @state()
    private isSubmitting: boolean = false;
    @state()
    private isSubmitted: boolean = false;

    // Simulate async validation
    private async validateUsername(username: string): Promise<boolean> {
        await new Promise(resolve => setTimeout(resolve, 1000));
        // Simulate API call to check if username exists
        const existingUsernames = ['admin', 'user', 'test', 'john'];
        return !existingUsernames.includes(username.toLowerCase());
    }

    private async validateEmail(email: string): Promise<boolean> {
        await new Promise(resolve => setTimeout(resolve, 500));
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    }

    private validatePassword(password: string): boolean {
        return password.length >= 8;
    }

    private validatePasswordsMatch(password: string, confirmPassword: string): boolean {
        return password === confirmPassword;
    }

    private async validateField(field: string) {
        const value = this.formData[field as keyof typeof this.formData];

        this.validationStatus = { ...this.validationStatus, [field]: 'validating' };
        this.errors = { ...this.errors, [field]: '' };

        try {
            let isValid = false;

            switch (field) {
                case 'username':
                    if (!value.trim()) {
                        throw new Error('Username is required');
                    }
                    isValid = await this.validateUsername(value);
                    if (!isValid) {
                        throw new Error('Username is already taken');
                    }
                    break;

                case 'email':
                    if (!value.trim()) {
                        throw new Error('Email is required');
                    }
                    isValid = await this.validateEmail(value);
                    if (!isValid) {
                        throw new Error('Please enter a valid email address');
                    }
                    break;

                case 'password':
                    if (!value) {
                        throw new Error('Password is required');
                    }
                    isValid = this.validatePassword(value);
                    if (!isValid) {
                        throw new Error('Password must be at least 8 characters long');
                    }
                    break;

                case 'confirmPassword':
                    if (!value) {
                        throw new Error('Please confirm your password');
                    }
                    isValid = this.validatePasswordsMatch(this.formData.password, value);
                    if (!isValid) {
                        throw new Error('Passwords do not match');
                    }
                    break;
            }

            this.validationStatus = { ...this.validationStatus, [field]: 'valid' };
        } catch (error) {
            this.validationStatus = { ...this.validationStatus, [field]: 'invalid' };
            this.errors = { ...this.errors, [field]: error instanceof Error ? error.message : 'Validation failed' };
        }
    }

    private handleInput(field: string, event: Event) {
        const target = event.target as HTMLInputElement;
        this.formData = { ...this.formData, [field]: target.value };

        // Trigger validation after a short delay
        setTimeout(() => this.validateField(field), 300);
    }

    private async handleSubmit() {
        // Validate all fields
        await Promise.all([
            this.validateField('username'),
            this.validateField('email'),
            this.validateField('password'),
            this.validateField('confirmPassword')
        ]);

        // Check if all fields are valid
        const allValid = Object.values(this.validationStatus).every(status => status === 'valid');
        if (!allValid) {
            return;
        }

        this.isSubmitting = true;

        // Simulate form submission
        await new Promise(resolve => setTimeout(resolve, 2000));

        this.isSubmitting = false;
        this.isSubmitted = true;

        // Reset after 3 seconds
        setTimeout(() => {
            this.isSubmitted = false;
            this.formData = {
                username: '',
                email: '',
                password: '',
                confirmPassword: ''
            };
            this.validationStatus = {};
            this.errors = {};
        }, 3000);
    }

    private getValidationIcon(field: string) {
        const status = this.validationStatus[field];
        switch (status) {
            case 'validating':
                return html`<div class="spinner"></div>`;
            case 'valid':
                return html`<span class="valid">✓</span>`;
            case 'invalid':
                return html`<span class="invalid">✗</span>`;
            default:
                return '';
        }
    }

    private isFormValid() {
        return Object.values(this.validationStatus).every(status => status === 'valid');
    }

    render() {
        return html`
            <h3>Advanced Form with Async Validation</h3>

            <form @submit=${(e: Event) => { e.preventDefault(); this.handleSubmit(); }}>
                <div class="form-group">
                    <label for="username">Username *</label>
                    <input
                        id="username"
                        type="text"
                        .value=${this.formData.username}
                        @input=${(e: Event) => this.handleInput('username', e)}
                        class=${this.validationStatus.username === 'invalid' ? 'error' : ''}
                        placeholder="Choose a unique username"
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="validation-status">
                        ${this.getValidationIcon('username')}
                        <span class="error-message">${this.errors.username || ''}</span>
                    </div>
                </div>

                <div class="form-group">
                    <label for="email">Email *</label>
                    <input
                        id="email"
                        type="email"
                        .value=${this.formData.email}
                        @input=${(e: Event) => this.handleInput('email', e)}
                        class=${this.validationStatus.email === 'invalid' ? 'error' : ''}
                        placeholder="[email protected]"
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="validation-status">
                        ${this.getValidationIcon('email')}
                        <span class="error-message">${this.errors.email || ''}</span>
                    </div>
                </div>

                <div class="form-group">
                    <label for="password">Password *</label>
                    <input
                        id="password"
                        type="password"
                        .value=${this.formData.password}
                        @input=${(e: Event) => this.handleInput('password', e)}
                        class=${this.validationStatus.password === 'invalid' ? 'error' : ''}
                        placeholder="Min 8 characters"
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="validation-status">
                        ${this.getValidationIcon('password')}
                        <span class="error-message">${this.errors.password || ''}</span>
                    </div>
                </div>

                <div class="form-group">
                    <label for="confirmPassword">Confirm Password *</label>
                    <input
                        id="confirmPassword"
                        type="password"
                        .value=${this.formData.confirmPassword}
                        @input=${(e: Event) => this.handleInput('confirmPassword', e)}
                        class=${this.validationStatus.confirmPassword === 'invalid' ? 'error' : ''}
                        placeholder="Re-enter your password"
                        ?disabled=${this.isSubmitting}
                    />
                    <div class="validation-status">
                        ${this.getValidationIcon('confirmPassword')}
                        <span class="error-message">${this.errors.confirmPassword || ''}</span>
                    </div>
                </div>

                <button type="submit" ?disabled=${this.isSubmitting || !this.isFormValid()}>
                    ${this.isSubmitting ? 'Submitting...' : 'Create Account'}
                </button>
            </form>

            ${this.isSubmitted ? html`
                <div class="form-summary">
                    <h4>✅ Account Created Successfully!</h4>
                    <pre>${JSON.stringify(this.formData, null, 2)}</pre>
                </div>
            ` : ''}
        `;
    }
}

export { CardComponent, ThemeableButton, VirtualList, ValidatedForm };