DEV Community

Lucas Bennett
Lucas Bennett

Posted on

Building Custom Composite Components with STDF in Svelte

STDF is a mobile web component library built with Svelte v5, Tailwind CSS v4, and TypeScript, offering simple, tiny, well-designed, and fast-performing components with no virtual DOM. This guide walks through building custom composite components by combining multiple STDF components to create reusable, production-ready UI patterns. This is part 14 of a series on using STDF with Svelte.

Prerequisites

Before starting, ensure you have:

  • Node.js version 18.x or higher installed
  • SvelteKit project set up (or Svelte v5 project with Vite)
  • Svelte v5 (STDF requires Svelte v5)
  • Tailwind CSS v4 installed and configured
  • Solid understanding of Svelte component composition, reactivity, and props
  • Familiarity with TypeScript (recommended but not required)

Key Concepts to Understand:

  • Component composition: Combining multiple STDF components (like Dialog, Input, Button) to create more complex, reusable components
  • State management: Using Svelte's reactive statements and stores to manage component state across composed components
  • Event handling: Passing events between parent and child components, and handling user interactions in composite components
  • Props forwarding: Using Svelte's $$props and $$restProps to create flexible component APIs that can pass props to underlying STDF components

Installation

Install STDF and its peer dependencies using your preferred package manager:

pnpm add stdf
pnpm add -D svelte@^5.0.0 tailwindcss@^4.0.0
Enter fullscreen mode Exit fullscreen mode

Or with npm:

npm install stdf
npm install -D svelte@^5.0.0 tailwindcss@^4.0.0
Enter fullscreen mode Exit fullscreen mode

Or with yarn:

yarn add stdf
yarn add -D svelte@^5.0.0 tailwindcss@^4.0.0
Enter fullscreen mode Exit fullscreen mode

This will add STDF to your package.json dependencies along with the required peer dependencies.

Project Setup

1. Configure Tailwind CSS

Create or update your main CSS file (typically src/app.css or src/app.postcss) to include Tailwind CSS configuration for STDF:

/* src/app.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
  /* Theme Colors */
  --color-primary-50: oklch(0.979 0.01 267.36);
  --color-primary-100: oklch(0.95 0.024 270.343);
  --color-primary-200: oklch(0.847 0.074 271.188);
  --color-primary-300: oklch(0.741 0.13 272.232);
  --color-primary-400: oklch(0.634 0.193 271.595);
  --color-primary-500: oklch(0.536 0.252 268.66);
  --color-primary: oklch(0.467 0.296 264.886);
  --color-primary-700: oklch(0.397 0.26 264.877);
  --color-primary-800: oklch(0.331 0.221 264.833);
  --color-primary-900: oklch(0.26 0.178 264.428);
  --color-primary-950: oklch(0.192 0.13 266.64);

  /* Functional Colors */
  --color-success: oklch(0.704 0.142 167.084);
  --color-warning: oklch(0.558 0.154 47.186);
  --color-error: oklch(0.564 0.223 28.46);
  --color-info: oklch(0.482 0.14 261.518);

  /* Neutral Colors */
  --color-black: oklch(0 0 0);
  --color-white: oklch(1 0 0);
  --color-gray-50: oklch(0.961 0 0);
  --color-gray-100: oklch(0.925 0 0);
  --color-gray-200: oklch(0.845 0 0);
  --color-gray-300: oklch(0.767 0 0);
  --color-gray-400: oklch(0.683 0 0);
  --color-gray-500: oklch(0.6 0 0);
  --color-gray-600: oklch(0.51 0 0);
  --color-gray-700: oklch(0.42 0 0);
  --color-gray-800: oklch(0.321 0 0);
  --color-gray-900: oklch(0.218 0 0);
  --color-gray-950: oklch(0.159 0 0);
  --color-transparent: transparent;
}
@source "../node_modules/stdf/**/*.svelte";
Enter fullscreen mode Exit fullscreen mode

2. Import CSS in Your App

Make sure to import the CSS file in your main application entry point:

<!-- src/app.html or src/routes/+layout.svelte -->
<script>
  import '../app.css';
</script>
Enter fullscreen mode Exit fullscreen mode

First Example / Basic Usage

Let's start with a simple composite component that combines STDF's Dialog, Input, and Button components to create a reusable confirmation dialog with input:

<!-- src/lib/components/ConfirmInputDialog.svelte -->
<script>
  import { Dialog, Input, Button } from 'stdf';

  export let visible = $bindable(false);
  export let title = 'Confirm Action';
  export let message = 'Please enter your confirmation text:';
  export let confirmText = 'Confirm';
  export let cancelText = 'Cancel';
  export let inputPlaceholder = 'Type to confirm';
  export let requiredText = '';

  let inputValue = '';
  let isValid = false;

  $: isValid = inputValue === requiredText;

  function handleConfirm() {
    if (isValid) {
      // Dispatch custom event with input value
      // Parent component can listen to this
      visible = false;
      inputValue = '';
    }
  }

  function handleCancel() {
    visible = false;
    inputValue = '';
  }

  function handleClose() {
    visible = false;
    inputValue = '';
  }
</script>

<Dialog
  bind:visible
  {title}
  content={message}
  primaryText={confirmText}
  secondaryText={cancelText}
  onprimary={handleConfirm}
  onsecondary={handleCancel}
  onclose={handleClose}
>
  <div class="mt-4">
    <Input
      bind:value={inputValue}
      placeholder={inputPlaceholder}
      onchange={() => {}}
    />
    {#if inputValue && !isValid}
      <p class="text-error text-sm mt-2">
        Input does not match required text
      </p>
    {/if}
  </div>
</Dialog>
Enter fullscreen mode Exit fullscreen mode

Usage in a parent component:

<!-- src/routes/example/+page.svelte -->
<script>
  import ConfirmInputDialog from '$lib/components/ConfirmInputDialog.svelte';
  import { Button } from 'stdf';

  let showDialog = false;

  function openDialog() {
    showDialog = true;
  }
</script>

<Button onclick={openDialog}>Delete Account</Button>

<ConfirmInputDialog
  bind:visible={showDialog}
  title="Delete Account"
  message="This action cannot be undone. Type 'DELETE' to confirm:"
  confirmText="Delete"
  cancelText="Cancel"
  inputPlaceholder="Type DELETE"
  requiredText="DELETE"
/>
Enter fullscreen mode Exit fullscreen mode

Understanding the Basics

When building composite components with STDF, you're essentially creating wrapper components that:

  1. Combine multiple STDF components: Use Dialog, Popup, BottomSheet, Input, Button, etc. together
  2. Manage shared state: Coordinate state between multiple child components
  3. Handle events: Process and forward events from child components to parent
  4. Provide a simplified API: Hide complexity behind a cleaner, domain-specific interface

Key patterns:

  • State binding: Use bind: directives to sync state between components
  • Event forwarding: Use Svelte's event system to communicate between components
  • Props spreading: Use $$restProps to pass additional props to underlying STDF components

Practical Example / Building Something Real

Let's build a more complex composite component: a FormDialog that combines Dialog, multiple Input fields, validation, and Button components to create a reusable form dialog pattern:

<!-- src/lib/components/FormDialog.svelte -->
<script>
  import { Dialog, Input, Button, Cell, CellGroup } from 'stdf';

  export let visible = $bindable(false);
  export let title = 'Form Dialog';
  export let submitText = 'Submit';
  export let cancelText = 'Cancel';
  export let fields = [];
  export let onSubmit = () => {};

  // Form state - dynamically created based on fields
  let formData = {};
  let errors = {};
  let isSubmitting = false;

  // Initialize form data from fields
  $: {
    if (fields.length > 0) {
      formData = fields.reduce((acc, field) => {
        acc[field.name] = field.value || '';
        return acc;
      }, {});
      errors = {};
    }
  }

  // Validation function
  function validateField(name, value, rules) {
    if (!rules) return true;

    if (rules.required && !value) {
      return rules.requiredMessage || `${name} is required`;
    }

    if (rules.pattern && !rules.pattern.test(value)) {
      return rules.patternMessage || `${name} format is invalid`;
    }

    if (rules.minLength && value.length < rules.minLength) {
      return `${name} must be at least ${rules.minLength} characters`;
    }

    if (rules.maxLength && value.length > rules.maxLength) {
      return `${name} must be no more than ${rules.maxLength} characters`;
    }

    if (rules.custom && !rules.custom(value)) {
      return rules.customMessage || `${name} validation failed`;
    }

    return null;
  }

  function handleFieldChange(name, value) {
    formData[name] = value;

    // Validate on change
    const field = fields.find(f => f.name === name);
    if (field?.rules) {
      const error = validateField(name, value, field.rules);
      if (error) {
        errors[name] = error;
      } else {
        delete errors[name];
      }
    }
  }

  function handleSubmit() {
    // Validate all fields
    let hasErrors = false;
    const newErrors = {};

    fields.forEach(field => {
      if (field.rules) {
        const error = validateField(
          formData[field.name],
          formData[field.name],
          field.rules
        );
        if (error) {
          newErrors[field.name] = error;
          hasErrors = true;
        }
      }
    });

    if (hasErrors) {
      errors = newErrors;
      return;
    }

    // Submit form
    isSubmitting = true;
    onSubmit(formData).then(() => {
      isSubmitting = false;
      visible = false;
      // Reset form
      formData = fields.reduce((acc, field) => {
        acc[field.name] = field.value || '';
        return acc;
      }, {});
      errors = {};
    }).catch((error) => {
      isSubmitting = false;
      console.error('Form submission error:', error);
    });
  }

  function handleCancel() {
    visible = false;
    // Reset form
    formData = fields.reduce((acc, field) => {
      acc[field.name] = field.value || '';
      return acc;
    }, {});
    errors = {};
  }

  function handleClose() {
    visible = false;
  }

  $: isValid = Object.keys(errors).length === 0 && 
               fields.every(field => {
                 if (field.rules?.required) {
                   return formData[field.name]?.trim();
                 }
                 return true;
               });
</script>

<Dialog
  bind:visible
  {title}
  primaryText={submitText}
  secondaryText={cancelText}
  onprimary={handleSubmit}
  onsecondary={handleCancel}
  onclose={handleClose}
>
  <div class="mt-4 space-y-4">
    <CellGroup>
      {#each fields as field}
        <Cell>
          <Input
            bind:value={formData[field.name]}
            type={field.type || 'text'}
            placeholder={field.placeholder}
            label={field.label}
            required={field.rules?.required}
            disabled={isSubmitting}
            state={errors[field.name] ? 'error' : 'default'}
            onchange={(e) => handleFieldChange(field.name, e.detail)}
          />
          {#if errors[field.name]}
            <p class="text-error text-sm mt-1 px-4">
              {errors[field.name]}
            </p>
          {/if}
        </Cell>
      {/each}
    </CellGroup>
  </div>
</Dialog>
Enter fullscreen mode Exit fullscreen mode

Now let's create a usage example with a user registration form:

<!-- src/routes/register/+page.svelte -->
<script>
  import FormDialog from '$lib/components/FormDialog.svelte';
  import { Button, Toast } from 'stdf';

  let showForm = false;

  const registrationFields = [
    {
      name: 'username',
      label: 'Username',
      placeholder: 'Enter username',
      type: 'text',
      rules: {
        required: true,
        minLength: 3,
        maxLength: 20,
        pattern: /^[a-zA-Z0-9_]+$/,
        patternMessage: 'Username can only contain letters, numbers, and underscores'
      }
    },
    {
      name: 'email',
      label: 'Email',
      placeholder: 'Enter email address',
      type: 'email',
      rules: {
        required: true,
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
        patternMessage: 'Please enter a valid email address'
      }
    },
    {
      name: 'password',
      label: 'Password',
      placeholder: 'Enter password',
      type: 'password',
      rules: {
        required: true,
        minLength: 8,
        custom: (value) => /[A-Z]/.test(value) && /[a-z]/.test(value) && /[0-9]/.test(value),
        customMessage: 'Password must contain uppercase, lowercase, and numbers'
      }
    },
    {
      name: 'confirmPassword',
      label: 'Confirm Password',
      placeholder: 'Confirm password',
      type: 'password',
      rules: {
        required: true,
        custom: (value, allData) => {
          // Note: This would need access to formData, simplified here
          return value === allData?.password;
        },
        customMessage: 'Passwords do not match'
      }
    }
  ];

  async function handleSubmit(formData) {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));

    Toast.show({
      message: `Registration successful! Welcome, ${formData.username}`,
      duration: 3000
    });

    console.log('Form submitted:', formData);
  }

  function openForm() {
    showForm = true;
  }
</script>

<div class="p-4">
  <Button onclick={openForm} fill="base" state="theme" size="big">
    Register New User
  </Button>
</div>

<FormDialog
  bind:visible={showForm}
  title="User Registration"
  submitText="Register"
  cancelText="Cancel"
  fields={registrationFields}
  onSubmit={handleSubmit}
/>
Enter fullscreen mode Exit fullscreen mode

Common Issues / Troubleshooting

Issue 1: Components Not Styling Correctly

Problem: STDF components appear unstyled or with incorrect colors.

Solution: Ensure your Tailwind CSS configuration includes the @source directive pointing to STDF components:

@source "../node_modules/stdf/**/*.svelte";
Enter fullscreen mode Exit fullscreen mode

Also verify that your CSS file is imported in your app's entry point.

Issue 2: State Not Syncing Between Components

Problem: Changes in child components don't reflect in parent component state.

Solution: Use Svelte's $bindable() rune for two-way binding in Svelte 5:

export let visible = $bindable(false);
Enter fullscreen mode Exit fullscreen mode

For Svelte 4, use bind:visible in the parent component.

Issue 3: Events Not Firing

Problem: Event handlers in composite components aren't being called.

Solution: Make sure you're using the correct event names. STDF components use specific event names like onprimary, onsecondary, onclose. Check the STDF documentation for the correct event names for each component.

Issue 4: Validation Not Working

Problem: Form validation doesn't trigger or show errors.

Solution: Ensure validation runs in reactive statements ($:) and that error state is properly managed. Also verify that field rules are correctly structured with the expected properties.

Next Steps

Now that you understand how to build composite components with STDF, consider exploring:

  • Advanced state management: Using Svelte stores to share state across multiple composite components
  • Component composition patterns: Building component libraries with slots, fragments, and dynamic components
  • Performance optimization: Using Svelte's $derived and $effect runes for efficient reactivity
  • Accessibility: Adding ARIA attributes and keyboard navigation to composite components
  • Testing: Writing unit tests for composite components using testing libraries

Check out other articles in this series for more STDF patterns and use cases.

Summary

This guide demonstrated how to build custom composite components with STDF by combining multiple STDF components into reusable, production-ready patterns. You learned how to create components that manage state, handle validation, and provide clean APIs for common UI patterns. You should now be able to build your own composite components that combine STDF's building blocks into more complex, domain-specific solutions.

Top comments (0)