Component Architecture

Building reusable, maintainable components in DesQTA

Component Architecture Overview

DesQTA follows a component-based architecture with clear separation between UI components, business logic, and data. This guide covers component patterns, composition, and best practices.

Component Organization

Directory Structure

src/lib/components/
├── ui/                    # Base UI components (buttons, cards, inputs)
├── layout/                # Layout components (header, sidebar)
├── content/               # Content components (assessments, timetable)
├── [feature]/             # Feature-specific components
└── [shared]/              # Shared utility components

Component Types

1. Presentational Components

Pure UI components that receive props and emit events:

<!-- Button.svelte -->
<script lang="ts">
  interface Props {
    label: string;
    variant?: 'primary' | 'secondary';
    onclick?: () => void;
  }
  
  let { label, variant = 'primary', onclick }: Props = $props();
</script>

<button
  class="btn btn-{variant}"
  onclick={onclick}
>
  {label}
</button>

2. Container Components

Components that manage state and business logic:

<!-- AssessmentList.svelte -->
<script lang="ts">
  import { onMount } from 'svelte';
  import { assessmentService } from '$lib/services/assessmentService';
  import AssessmentCard from './AssessmentCard.svelte';
  
  let assessments = $state<Assessment[]>([]);
  let loading = $state(true);
  
  onMount(async () => {
    assessments = await assessmentService.loadAssessments() || [];
    loading = false;
  });
</script>

{#if loading}
  <LoadingSpinner />
{:else}
  {#each assessments as assessment (assessment.id)}
    <AssessmentCard {assessment} />
  {/each}
{/if}

3. Layout Components

Components that structure the page layout:

<!-- AppLayout.svelte -->
<script lang="ts">
  let { children }: { children: import('svelte').Snippet } = $props();
</script>

<div class="app-layout">
  <AppHeader />
  <div class="content">
    <AppSidebar />
    <main>
      {@render children()}
    </main>
  </div>
</div>

Component Patterns

Pattern 1: Controlled Component

<!-- Input.svelte -->
<script lang="ts">
  interface Props {
    value: string;
    onchange?: (value: string) => void;
    placeholder?: string;
  }
  
  let { value, onchange, placeholder }: Props = $props();
  
  function handleInput(event: Event) {
    const target = event.target as HTMLInputElement;
    onchange?.(target.value);
  }
</script>

<input
  type="text"
  {value}
  {placeholder}
  oninput={handleInput}
/>

Pattern 2: Uncontrolled Component

<!-- Input.svelte -->
<script lang="ts">
  interface Props {
    defaultValue?: string;
    placeholder?: string;
  }
  
  let { defaultValue = '', placeholder }: Props = $props();
  let input: HTMLInputElement;
</script>

<input
  bind:this={input}
  type="text"
  value={defaultValue}
  {placeholder}
/>

<!-- Access value via input.value -->

Pattern 3: Compound Components

<!-- Card.svelte -->
<script lang="ts">
  let { children, header, footer }: {
    children: import('svelte').Snippet;
    header?: import('svelte').Snippet;
    footer?: import('svelte').Snippet;
  } = $props();
</script>

<div class="card">
  {#if header}
    <header>
      {@render header()}
    </header>
  {/if}
  <main>
    {@render children()}
  </main>
  {#if footer}
    <footer>
      {@render footer()}
    </footer>
  {/if}
</div>

<!-- Usage -->
<Card>
  <svelte:fragment slot="header">
    <h2>Title</h2>
  </svelte:fragment>
  <p>Content</p>
  <svelte:fragment slot="footer">
    <button>Action</button>
  </svelte:fragment>
</Card>

Pattern 4: Render Props Pattern

<!-- DataLoader.svelte -->
<script lang="ts">
  interface Props {
    loader: () => Promise<any>;
    children: (data: any, loading: boolean, error: string | null) => import('svelte').Snippet;
  }
  
  let { loader, children }: Props = $props();
  
  let data = $state<any>(null);
  let loading = $state(true);
  let error = $state<string | null>(null);
  
  onMount(async () => {
    try {
      data = await loader();
    } catch (e) {
      error = e instanceof Error ? e.message : 'Unknown error';
    } finally {
      loading = false;
    }
  });
</script>

{@render children(data, loading, error)}

Component Composition

Composition Example

<!-- AssessmentCard.svelte -->
<script lang="ts">
  import { Badge } from '$lib/components/ui';
  import { formatDate } from '$lib/utils/date';
  
  interface Props {
    assessment: Assessment;
    onclick?: () => void;
  }
  
  let { assessment, onclick }: Props = $props();
  
  let status = $derived(
    assessment.dueDate < new Date() ? 'overdue' :
    assessment.dueDate < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) ? 'due-soon' :
    'upcoming'
  );
</script>

<div class="assessment-card" onclick={onclick}>
  <div class="header">
    <h3>{assessment.title}</h3>
    <Badge variant={status}>{status}</Badge>
  </div>
  <div class="body">
    <p>{assessment.subject}</p>
    <p>Due: {formatDate(assessment.dueDate)}</p>
  </div>
</div>

UI Component Library

Base Components

DesQTA uses a custom UI component library based on shadcn-vue patterns:

<!-- src/lib/components/ui/Button.svelte -->
<script lang="ts">
  interface Props {
    variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
    size?: 'sm' | 'md' | 'lg';
    disabled?: boolean;
    onclick?: () => void;
  }
  
  let { 
    variant = 'primary',
    size = 'md',
    disabled = false,
    onclick 
  }: Props = $props();
</script>

<button
  class="btn btn-{variant} btn-{size}"
  {disabled}
  onclick={onclick}
>
  <slot />
</button>

Component Variants

<!-- Card.svelte -->
<script lang="ts">
  interface Props {
    variant?: 'default' | 'outlined' | 'elevated';
    padding?: 'none' | 'sm' | 'md' | 'lg';
  }
  
  let { variant = 'default', padding = 'md' }: Props = $props();
</script>

<div class="card card-{variant} card-padding-{padding}">
  <slot />
</div>

Component State Management

Local State

<script lang="ts">
  let isOpen = $state(false);
  let selectedItem = $state<Item | null>(null);
  
  function toggle() {
    isOpen = !isOpen;
  }
</script>

Props as State

<script lang="ts">
  interface Props {
    initialValue: string;
  }
  
  let { initialValue }: Props = $props();
  let value = $state(initialValue);
  
  // Sync with prop changes
  $effect(() => {
    value = initialValue;
  });
</script>

Derived State

<script lang="ts">
  let items = $state<Item[]>([]);
  let filter = $state('');
  
  let filteredItems = $derived(
    items.filter(item => item.name.includes(filter))
  );
  
  let itemCount = $derived(filteredItems.length);
</script>

Component Lifecycle

onMount

<script lang="ts">
  import { onMount } from 'svelte';
  
  let data = $state<Data | null>(null);
  
  onMount(async () => {
    data = await fetchData();
    
    // Cleanup function
    return () => {
      cleanup();
    };
  });
</script>

onDestroy

<script lang="ts">
  import { onDestroy } from 'svelte';
  
  let interval: ReturnType<typeof setInterval>;
  
  onMount(() => {
    interval = setInterval(() => {
      update();
    }, 1000);
  });
  
  onDestroy(() => {
    clearInterval(interval);
  });
</script>

BeforeUpdate / AfterUpdate

<script lang="ts">
  import { beforeUpdate, afterUpdate } from 'svelte';
  
  let scrollHeight = $state(0);
  
  beforeUpdate(() => {
    scrollHeight = document.documentElement.scrollHeight;
  });
  
  afterUpdate(() => {
    if (document.documentElement.scrollHeight !== scrollHeight) {
      // Height changed
    }
  });
</script>

Component Communication

Props Down

<!-- Parent -->
<ChildComponent data={data} onchange={handleChange} />

<!-- Child -->
<script lang="ts">
  interface Props {
    data: Data;
    onchange?: (value: string) => void;
  }
  
  let { data, onchange }: Props = $props();
</script>

Events Up

<!-- Child -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  
  const dispatch = createEventDispatcher();
  
  function handleClick() {
    dispatch('click', { data: 'value' });
  }
</script>

<button onclick={handleClick}>Click</button>

<!-- Parent -->
<ChildComponent on:click={(e) => console.log(e.detail)} />

Context API

<!-- Provider -->
<script lang="ts">
  import { setContext } from 'svelte';
  
  const themeContext = {
    theme: 'dark',
    toggleTheme: () => { /* ... */ }
  };
  
  setContext('theme', themeContext);
</script>

<!-- Consumer -->
<script lang="ts">
  import { getContext } from 'svelte';
  
  const theme = getContext('theme');
</script>

Component Testing

Unit Testing

// AssessmentCard.test.ts
import { render } from '@testing-library/svelte';
import AssessmentCard from './AssessmentCard.svelte';

test('renders assessment title', () => {
  const { getByText } = render(AssessmentCard, {
    assessment: {
      id: 1,
      title: 'Math Test',
      dueDate: new Date()
    }
  });
  
  expect(getByText('Math Test')).toBeInTheDocument();
});

Best Practices

1. Single Responsibility

<!-- ✅ Good - Focused component -->
<AssessmentCard {assessment} />

<!-- ❌ Avoid - Too many responsibilities -->
<AssessmentCardWithFiltersAndSortingAndExport />

2. Composition Over Configuration

<!-- ✅ Good - Composable -->
<Card>
  <CardHeader>Title</CardHeader>
  <CardContent>Content</CardContent>
</Card>

<!-- ❌ Avoid - Too many props -->
<Card header="Title" content="Content" footer="Footer" />

3. Extract Logic

<!-- ✅ Good - Logic in service -->
<script lang="ts">
  import { assessmentService } from '$lib/services/assessmentService';
  const data = await assessmentService.load();
</script>

<!-- ❌ Avoid - Logic in component -->
<script lang="ts">
  const data = await fetch('/api/assessments').then(r => r.json());
</script>

Next Steps