Mixins / foldkit-mixins

Quick start

The component author publishes the extension points. Style and Behavior can then be authored elsewhere and attached by the caller.

ts
import { Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import { Attr, Behavior, Capability, Event, Slot, Slots, SlotView, Style } from 'foldkit-mixins'

const Message = defineMessageUnion({ ChangedValue: { value: Schema.String } })
type Message = typeof Message.Type

type FieldInput = { readonly value: string; readonly invalid: boolean }

// 1. The component author publishes the extension points it supports.
const FieldSlots = Slots.define({
  root: Slot.make({ capability: Capability.Container }),
  input: Slot.make({
    capability: Capability.TextInput,
    events: [Event.Input],
    attributes: [Attr.AriaInvalid],
  }),
})

// 2. Appearance can live in a design system or another module.
const FieldStyle = Style.forSlots(FieldSlots)({
  root: Style.compose(Style.class('field'), Style.inline({ display: 'grid' })),
  input: Style.class('field-input'),
})

// 3. Element-level behavior can be authored independently.
const Validation = Behavior.forSlots(FieldSlots)<FieldInput, Message>({
  input: Behavior.slot({
    requires: { capability: Capability.TextInput, attributes: [Attr.AriaInvalid] },
    attributes: ({ input, h }) => [h.AriaInvalid(input.invalid)],
  }),
})

// 4. The pure view renders through the slots. Attachments return a new view;
// the original view is unchanged.
const Field = SlotView.forMessages<Message>()
  .define(FieldSlots, (input: FieldInput, slots, h) =>
    h.label(slots.root.attrs(), [
      h.input(
        slots.input.attrs([
          h.Value(input.value),
          h.OnInput(value => Message.ChangedValue({ value })),
        ]),
      ),
    ]),
  )
  .pipe(Style.attach(FieldStyle), Behavior.attach(Validation))

slots.input.attrs(base) returns the view's base attributes plus every resolved contribution. Base attributes, event Messages and any ChildAttribute survive. The caller did not copy the markup or add a validationBehavior prop to the component.

Render it and interpret the result

Field is a view function. From a parent view with a compatible builder, call Field({ value: model.value, invalid: model.invalid }, h). The input's value comes from the parent; typing emits ChangedValue; the parent update must store that value before the next render. Mixins owns none of that state.

In this example, FieldStyle supplies classes and a grid layout, while Validation supplies aria-invalid. The input's OnInput remains owned by the base view. Adding another OnInput attachment is a conflict, not a way to chain a second application update. Compose the action in the existing Message handler instead.

A Style's compiled rules (Style.pseudo, Style.media, a layer) arrive with the Slot that draws them. In a browser, the first time a Slot draws a class, its CSS is appended to one <style data-foldkit-styles> element, in the microtask after the draw and before the browser paints. The element is made only when there is something to add, and declares the standard layer order first unless a sheet on the page already declares one, which it leaves alone. Classes a stylesheet already on the page carries are left out, so installing Style.stylesheet(...) yourself (for the first paint, or on the server) never duplicates them. Outside a browser nothing is injected: a server puts Style.usedIn(html), the CSS of every compiled class the page's markup uses, in the page's head (with foldkit-ssr, through its head option).