Documentation Index

Fetch the complete documentation index at: https://docs.unqork.io/llms.txt

Use this file to discover all available pages before exploring further.

Building Components - BYO SDK

Prev Next

This guide walks through building a complete custom component from scratch using the BYO SDK. The example component is a task list that displays tasks, lets Creators mark them complete or incomplete, dispatches events when tasks change, and updates the submission data.

Steps 1–4, 6, 9, and 11 are required for every component. Steps 5, 7, 8, and 10 are optional — useful for production-quality components but not required to get a working component into the Module Builder.

Step 1: Scaffold the Project

If you haven't installed the SDK yet, see Installation in the SDK Overview.

Once installed, scaffold a new project using the following command:

npx --package=./sdk.tgz create task-list --framework vanilla --ts

The command generates the following project structure:

task-list/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
├── src/
│   ├── entry.ts
│   └── TaskList/
│       ├── TaskList.ts
│       ├── TaskList.schema.ts
│       ├── TaskList.test.ts
│       ├── TaskList.mocks.ts
│       └── TaskList.css

Step 2: Define the Schema

Define your component's properties and events using the SDK schema utilities:

// src/TaskList/TaskList.schema.ts
import { z } from 'zod'
import { defineComponent, defineEvent } from '@unqork/byo-sdk/schema'

const TaskSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean().default(false),
})

const taskListSchema = z.object({
  label: z.string().describe('Label').default('Tasks'),
  items: z.array(TaskSchema).default([]),
  allowAdd: z.boolean().describe('Allow adding tasks').default(true),
  allowDelete: z.boolean().describe('Allow deleting tasks').default(true),
})

const taskToggledSchema = z.object({
  name: z.string().default('taskToggled'),
  payload: z.object({
    task: TaskSchema,
    items: z.array(TaskSchema),
  }),
})

const taskAddedSchema = z.object({
  name: z.string().default('taskAdded'),
  payload: z.object({
    task: TaskSchema,
    items: z.array(TaskSchema),
  }),
})

const taskDeletedSchema = z.object({
  name: z.string().default('taskDeleted'),
  payload: z.object({
    task: TaskSchema,
    items: z.array(TaskSchema),
  }),
})

export const taskToggledDefinition = defineEvent({
  name: 'Task Toggled',
  type: 'taskToggled',
  description: 'Fired when a task is toggled',
  stability: 'STABLE',
  schema: taskToggledSchema,
})

export const taskAddedDefinition = defineEvent({
  name: 'Task Added',
  type: 'taskAdded',
  description: 'Fired when a task is added',
  stability: 'STABLE',
  schema: taskAddedSchema,
})

export const taskDeletedDefinition = defineEvent({
  name: 'Task Deleted',
  type: 'taskDeleted',
  description: 'Fired when a task is deleted',
  stability: 'STABLE',
  schema: taskDeletedSchema,
})

export const taskListDefinition = defineComponent({
  name: 'Task List',
  type: 'taskList',
  description: 'A task list component',
  schema: taskListSchema,
  events: [taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition],
})

export type Task = z.infer<typeof TaskSchema>
export type TaskListProps = z.infer<typeof taskListSchema>
export type TaskToggledEvent = z.infer<typeof taskToggledSchema>
export type TaskAddedEvent = z.infer<typeof taskAddedSchema>
export type TaskDeletedEvent = z.infer<typeof taskDeletedSchema>

The schema does the following:

  • Defines component properties and their defaults using Zod.
  • Defines custom events with typed payloads.
  • Generates TypeScript types for your component.
  • Drives manifest generation and the Module Builder UI.

Step 3: Implement the Component

Create a Web Component with Shadow DOM and typed event callbacks:

// src/TaskList/TaskList.ts
import styles from './TaskList.css?raw'
import {
  type Task,
  type TaskListProps,
  type TaskToggledEvent,
  type TaskAddedEvent,
  type TaskDeletedEvent,
} from './TaskList.schema.js'

export class TaskList extends HTMLElement {
  private _label: TaskListProps['label'] = 'Tasks'
  private _items: Task[] = []
  private _allowAdd: TaskListProps['allowAdd'] = true
  private _allowDelete: TaskListProps['allowDelete'] = true

  // Props set by the platform
  set label(value: TaskListProps['label']) {
    this._label = value
    this._render()
  }

  get label(): TaskListProps['label'] {
    return this._label
  }

  set items(value: Task[]) {
    this._items = value
    this._render()
  }

  set allowAdd(value: TaskListProps['allowAdd']) {
    this._allowAdd = value
    this._render()
  }

  set allowDelete(value: TaskListProps['allowDelete']) {
    this._allowDelete = value
    this._render()
  }

  set submissionData(value: unknown) {
    if (Array.isArray(value)) {
      this._items = value as Task[]
      this._render()
    }
  }

  // Callbacks set by the platform
  onTaskToggled: ((event: TaskToggledEvent) => void) | null = null
  onTaskAdded: ((event: TaskAddedEvent) => void) | null = null
  onTaskDeleted: ((event: TaskDeletedEvent) => void) | null = null
  onSubmissionUpdate: ((value: unknown) => void) | null = null

  connectedCallback(): void {
    this.attachShadow({ mode: 'open' })

    // Apply styles once via adoptedStyleSheets
    const sheet = new CSSStyleSheet()
    sheet.replaceSync(styles)
    this.shadowRoot!.adoptedStyleSheets = [sheet]

    this._render()
  }

  private handleToggle(id: string) {
    const updated = this._items.map((item) =>
      item.id === id ? { ...item, completed: !item.completed } : item
    )

    const task = updated.find((item) => item.id === id)!

    this._items = updated
    this._render()

    // Fire event
    this.onTaskToggled?.({
      name: 'taskToggled',
      payload: { task, items: updated },
    })
    this.onSubmissionUpdate?.(updated)
  }

  private handleAdd() {
    const newTask: Task = {
      id: crypto.randomUUID(),
      title: 'New task',
      completed: false,
    }

    const updated = [...this._items, newTask]

    this._items = updated
    this._render()

    this.onTaskAdded?.({
      name: 'taskAdded',
      payload: { task: newTask, items: updated },
    })
    this.onSubmissionUpdate?.(updated)
  }

  private handleDelete(id: string) {
    const task = this._items.find((item) => item.id === id)!
    const updated = this._items.filter((item) => item.id !== id)

    this._items = updated
    this._render()

    this.onTaskDeleted?.({
      name: 'taskDeleted',
      payload: { task, items: updated },
    })
    this.onSubmissionUpdate?.(updated)
  }

  private _render(): void {
    if (!this.shadowRoot) return
    this.shadowRoot.innerHTML = `
      <div class="task-list-root">
        <h3>${this._label}</h3>
        
        <ul class="tasks">
          ${this._items
            .map(
              (item) => `
            <li class="task ${item.completed ? 'completed' : ''}">
              <input
                type="checkbox"
                ${item.completed ? 'checked' : ''}
                data-id="${item.id}"
              />
              <span>${item.title}</span>
              ${
                this._allowDelete
                  ? `<button class="delete" data-id="${item.id}">Delete</button>`
                  : ''
              }
            </li>
          `
            )
            .join('')}
        </ul>
        
        ${this._allowAdd ? '<button class="add">Add Task</button>' : ''}
      </div>
    `

    this._bindEvents()
  }

  private _bindEvents(): void {
    this.shadowRoot?.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
      checkbox.addEventListener('change', (e) => {
        const id = (e.target as HTMLInputElement).dataset.id!
        this.handleToggle(id)
      })
    })

    this.shadowRoot?.querySelectorAll('.delete').forEach((button) => {
      button.addEventListener('click', (e) => {
        const id = (e.target as HTMLButtonElement).dataset.id!
        this.handleDelete(id)
      })
    })

    this.shadowRoot?.querySelector('.add')?.addEventListener('click', () => {
      this.handleAdd()
    })
  }
}

The component uses the following key patterns:

  • Shadow DOM: Encapsulates styles and prevents CSS leakage.
  • Typed callbacks: Ensure type safety for event payloads.
  • Event objects: Include name and payload fields matching the event schema.
  • Property setters: Trigger re-renders.
  • adoptedStyleSheets: Applies CSS once without re-injecting on each render.

Step 4: Export the Component

Register the component in the bundle entry point:

// ---------------------------------------------------------------------------
// BYOC Entry — vanilla web component (TypeScript)
//
// Runtime exports: { view, model } — consumed by the Unqork platform.
// Schema re-exports: { taskListDefinition, taskToggledDefinition, ... } — consumed by
//   byoc build's manifest generator to produce manifest.json automatically.
// ---------------------------------------------------------------------------

// src/entry.ts
import { TaskList as TaskListView } from './TaskList/TaskList.js'
import {
  taskListDefinition,
  taskToggledDefinition,
  taskAddedDefinition,
  taskDeletedDefinition,
} from './TaskList/TaskList.schema.js'

// ---------------------------------------------------------------------------
// Component: taskList
// ---------------------------------------------------------------------------

export const taskList = {
  view: async () => TaskListView as CustomElementConstructor,
  model: async () => taskListDefinition.ModelClass,
}

// ---------------------------------------------------------------------------
// Schema re-exports — manifest generator traces _kind discriminators here
// ---------------------------------------------------------------------------

export { taskListDefinition, taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition }

The platform expects the following exports:

  • view — returns the Web Component constructor.
  • model — returns the generated ModelClass from defineComponent.
  • The schema exports enable automatic manifest generation.

Step 5: Create Mock Scenarios (Optional)

Define test scenarios for the dev harness:

// src/TaskList/TaskList.mocks.ts
import { defineMockScenario } from '@unqork/byo-sdk/testing'

export const defaultScenario = defineMockScenario({
  name: 'Default',
  description: 'Empty task list',
  props: {
    label: 'My Tasks',
    items: [],
  },
})

export const withTasksScenario = defineMockScenario({
  name: 'With Tasks',
  description: 'Task list with some items',
  props: {
    label: 'Project Tasks',
    items: [
      { id: '1', title: 'Design mockups', completed: true },
      { id: '2', title: 'Implement component', completed: false },
      { id: '3', title: 'Write tests', completed: false },
    ],
  },
})

export const readOnlyScenario = defineMockScenario({
  name: 'Read Only',
  description: 'Task list with add/delete disabled',
  props: {
    label: 'Completed Tasks',
    allowAdd: false,
    allowDelete: false,
    items: [
      { id: '1', title: 'Design mockups', completed: true },
      { id: '2', title: 'Implement component', completed: true },
    ],
  },
})

Step 6: Test in the Dev Harness

Start the dev server:

npm run dev

Open http://localhost:5173. The dev harness includes the following features:

  • Shows your component.
  • Provides a scenario drop-down.
  • Logs callback invocations to the console.
  • Updates in real time as you edit code.

Test the following interactions:

  • Switching between scenarios.
  • Toggling tasks and checking the console for onTaskToggled calls.
  • Adding and deleting tasks.
  • Modifying properties in TaskList.mocks.ts.

Step 7: Write Tests (Optional)

Create unit tests using Vitest:

// src/TaskList/TaskList.test.ts
import { describe, it, expect, beforeAll } from 'vitest'
import * as exports from '../entry.js'
import type { Task } from './TaskList.schema.js'

describe('TaskList', () => {
  let element: HTMLElement

  beforeAll(async () => {
    const ViewCtor = await exports.taskList.view()
    const tagName = 'test-task-list'
    if (!customElements.get(tagName)) {
      customElements.define(tagName, ViewCtor)
    }
    element = document.createElement(tagName)
    document.body.appendChild(element)
  })

  it('renders with default label', () => {
    expect(element.shadowRoot?.innerHTML).toContain('Tasks')
  })

  it('updates when label prop changes', () => {
    ;(element as any).label = 'My Tasks'
    expect(element.shadowRoot?.innerHTML).toContain('My Tasks')
  })

  it('renders items', () => {
    const items: Task[] = [
      { id: '1', title: 'Task 1', completed: false },
      { id: '2', title: 'Task 2', completed: true },
    ]

    ;(element as any).items = items

    const taskElements = element.shadowRoot?.querySelectorAll('.task')
    expect(taskElements).toHaveLength(2)
    expect(taskElements?.[0].textContent).toContain('Task 1')
    expect(taskElements?.[1].classList.contains('completed')).toBe(true)
  })

  it('fires onTaskToggled when checkbox clicked', () => {
    const items: Task[] = [{ id: '1', title: 'Task 1', completed: false }]

    ;(element as any).items = items

    let eventFired = false
    ;(element as any).onTaskToggled = (event: any) => {
      eventFired = true
      expect(event.payload.task.completed).toBe(true)
    }

    const checkbox = element.shadowRoot?.querySelector('input[type="checkbox"]') as HTMLInputElement
    checkbox.click()

    expect(eventFired).toBe(true)
  })

  it('hides add button when allowAdd is false', () => {
    ;(element as any).allowAdd = false
    expect(element.shadowRoot?.querySelector('.add')).toBeNull()
  })
})

To run all tests, use the following command:

npm test

Step 8: Add Styling (Optional)

Create the stylesheet that was imported in Step 3:

/* src/TaskList/TaskList.css */
.task-list-root {
  font-family: system-ui, sans-serif;
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

.task-list-root h3 {
  margin: 0 0 1rem 0;
  font-size: 1.25rem;
}

.tasks {
  list-style: none;
  padding: 0;
  margin: 0 0 1rem 0;
}

.task {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem;
  border-bottom: 1px solid #f0f0f0;
}

.task.completed span {
  text-decoration: line-through;
  color: #999;
}

.task input[type="checkbox"] {
  cursor: pointer;
}

.task span {
  flex: 1;
}

.task .delete {
  padding: 0.25rem 0.5rem;
  background: #f44336;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task .delete:hover {
  background: #d32f2f;
}

.add {
  padding: 0.5rem 1rem;
  background: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.add:hover {
  background: #1976d2;
}

The CSS is imported as ?raw and applied through Shadow DOM's adoptedStyleSheets API, as shown in Step 3. This approach does the following:

  • Encapsulates styles in the component.
  • Prevents CSS leakage to the rest of the page.
  • Applies styles once without re-injecting on each render.

Step 9: Build and Validate

Build for production:

npm run build:prod

The command runs the following steps:

  1. Bumps version to 1.0.1.
  2. Bundles code with Vite.
  3. Generates dist/manifest.json.
  4. Creates dist/task-list.tar.gz.

Note: Use npm run build for development builds without version bumping.

Step 10: Preview the Production Build (Optional)

Run the following command to open the dev harness serving the production bundle from dist/:

npm run preview

Before uploading, verify the following:

  • All functionality works as expected.
  • No console errors are present.
  • File size is acceptable.

Step 11: Use in the Module Builder

After uploading task-list.tar.gz to Unqork:

  1. Add the component to your module:

    • Open the Module Builder.
    • Locate your custom component in the component tray under the Custom or BYO section.
    • Drag and drop the Task List component onto the canvas.
  2. Configure the component settings: Use the Settings Panel to configure your component's properties:

    • Label: Set to "Project Tasks".
    • Items: Bind to a data source using ={{components.dataSource.value}}.
    • Allow Add: Toggle to enable or disable adding tasks.
    • Allow Delete: Toggle to enable or disable deleting tasks.
  3. Configure triggers: In the Settings Panel, configure what happens when events fire:

    • onTaskToggled: Set up a trigger that executes when a task is toggled, like updating task status in a database.
    • onTaskAdded: Set up a trigger that executes when a new task is added, like saving the new task.
    • For each trigger, specify the context field to store event data and the target component to execute.

Advanced Patterns

Lazy Loading Large Dependencies

Defer loading heavy libraries until needed:

export class TaskList extends HTMLElement {
  private chartLib: any = null
  
  async loadChartLibrary() {
    if (!this.chartLib) {
      this.chartLib = await import('./chart-library.js')
    }
    return this.chartLib
  }
  
  async showChart() {
    const lib = await this.loadChartLibrary()
    lib.render(this.querySelector('.chart-container'))
  }
}

Multiple Components in One Asset

To bundle multiple components in a single asset, export them all from the same entry.ts file:

// src/entry.ts
import { TaskList as TaskListView } from './TaskList/TaskList.js'
import { taskListDefinition, taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition } from './TaskList/TaskList.schema.js'
import { TaskCard as TaskCardView } from './TaskCard/TaskCard.js'
import { taskCardDefinition, taskCardClickedDefinition } from './TaskCard/TaskCard.schema.js'

// ---------------------------------------------------------------------------
// Component: taskList
// ---------------------------------------------------------------------------

export const taskList = {
  view: async () => TaskListView as CustomElementConstructor,
  model: async () => taskListDefinition.ModelClass,
}

// ---------------------------------------------------------------------------
// Component: taskCard
// ---------------------------------------------------------------------------

export const taskCard = {
  view: async () => TaskCardView as CustomElementConstructor,
  model: async () => taskCardDefinition.ModelClass,
}

// ---------------------------------------------------------------------------
// Schema re-exports — manifest generator traces _kind discriminators here
// ---------------------------------------------------------------------------

export { 
  taskListDefinition, 
  taskToggledDefinition, 
  taskAddedDefinition, 
  taskDeletedDefinition,
  taskCardDefinition,
  taskCardClickedDefinition,
}

Each component in the asset is independently configurable in the Module Builder.

Commands for Imperative Actions

Commands let the platform trigger imperative actions on your component. Events flow from your component to the platform; commands flow from the platform to your component.

Define a command:

// src/TaskList/TaskList.schema.ts
import { defineCommand } from '@unqork/byo-sdk/schema'

const resetSchema = z.object({
  clearCompleted: z.boolean().optional().default(false),
})

export const resetDefinition = defineCommand({
  name: 'Reset Tasks',
  type: 'resetTasks',
  description: 'Reset task list to initial state',
  schema: resetSchema,
})

export const taskListDefinition = defineComponent({
  name: 'Task List',
  type: 'taskList',
  schema: taskListSchema,
  events: [taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition],
  commands: [resetDefinition], // ← Add commands array
})

Listen for commands in your component:

// src/TaskList/TaskList.ts
export class TaskList extends HTMLElement {
  connectedCallback() {
    this.addEventListener('resetTasks', (e: CustomEvent) => {
      const { clearCompleted = false } = e.detail || {}
      
      if (clearCompleted) {
        this._items = this._items.filter(task => !task.completed)
      } else {
        this._items = []
      }
      
      this.onSubmissionUpdate?.(this._items)
      this._render()
    })
  }
}

Trigger from the Module Builder:

Configure a BYOC Execute output on a logic component like an Initializer, Decisions, or Logic Block:

{
  "outputs": [
    {
      "targetId": "taskList1",
      "type": "byocExecute",
      "value": {
        "componentType": "taskList",
        "commandType": "resetTasks",
        "args": { "clearCompleted": true }
      }
    }
  ]
}

When to use commands:

  • Imperative operations: Reset, refresh, focus, validate, and so on.
  • Headless or service components: Payment gateways, authentication, analytics, and so on.
  • Third-party libraries with method-based APIs: D3, Stripe, Leaflet, and so on.

Differences from events:

  • Direction: Commands → component; events → platform.
  • Pattern: fire-and-forget with no return value; events trigger workflows.
  • Use case: Imperative actions; events represent state changes.

Next Steps


Complete Source Code

The complete task list component source is available in the SDK examples:

npx --package=./sdk.tgz create task-list-example --template examples/task-list