Skip to content

Directives and .pipe(...) ​

A Craft directive decorates both a component's logic factory and its template — so behaviour and markup travel together, and compose.

Use one when the same behaviour must be added to several components: a tooltip, a highlight, focus management, analytics on interaction. Not when the behaviour belongs to one component — put it in that component's factory.

Directives are applied from left to right.

Event actions and DOM modifiers ​

eventAction(...) is an element directive. Use it when an element must adjust a DOM event before invoking one action. The action stays on the element; no craftMethod wrapper is needed:

ts
import { button, eventAction } from '@craft-ts/component';

button(
  'navToggle',
  {
    type: 'button',
    'aria-expanded': navOpen,
  },
  navOpen.navToggleLabel,
).pipe(
  eventAction({
    click: { action: navOpen.toggle, stopPropagation: true },
  }),
);

Each event entry requires action and can set preventDefault, stopPropagation, or stopImmediatePropagation to true. The modifiers run before the action in the same DOM listener. The action remains in Craft's normal event pipeline, including event hooks and generator callbacks. Use the event name as the key, such as click, submit, or keydown. Do not also put that event in the element's props; eventAction rejects duplicate handlers.

The recommended ESLint rule craft-ts/no-event-only-craft-method and the default architecture rule no-event-only-craft-method report a craftMethod that only modifies an event and delegates to one action, even when that method is declared in a different file from the element.

ts
import {
  button,
  craftComponent,
  craftDirective,
  div,
  p,
  type HostRequiredLogic,
  type HostTemplate,
  type Input,
} from '@craft-ts/component';

InteractivePermissions ​

The examples below use a directive that adds a permissions object to the component context. Its configuration is internal to the directive; the component caller only provides the original user input.

ts
import {
  HostRequiredLogic,
  HostTemplate,
  Input,
  craftDirective,
} from '@craft-ts/component';
import { craftUse } from '@craft-ts/core';

type User = { id?: string; name: string; permissions: readonly string[] };

type RequiresUser = {
  user: Input<User>;
};

type ProvidesPermissions = RequiresUser & {
  permissions: {
    canEdit: () => boolean;
  };
};

const InteractivePermissions = craftDirective(
  'InteractivePermissions',
  {},
  (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {
    const context = baseLogic(user);

    return {
      ...context,
      permissions: {
        canEdit: () => craftUse(user()).permissions.includes('edit'),
      },
    };
  },

  (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>
    baseTemplate(context),
);

Basic composition ​

A directive transforms the existing logic and template:

ts
const Card = craftComponent(
  'Card',
  {},
  (user: Input<User>) => ({ user }),
  ({ user }) => div(user().name),
).pipe(InteractivePermissions);

The result of InteractivePermissions becomes the logic actually executed by Card:

text
component inputs
        ↓
original logic
        ↓
logic added by the directive
        ↓
final context
        ↓
final template

Directive configuration input ​

A fixed configuration can be supplied when the directive is created:

ts
const hasPermission = (permission: Permission) =>
  craftDirective(
    'hasPermission',
    {},
    (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {
      const context = baseLogic(user);

      return {
        ...context,
        permissions: {
          canAccess: () => user().permissions.includes(permission),
        },
      };
    },

    (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>
      context.permissions.canAccess() ? baseTemplate(context) : [],
  );

const Card = craftComponent(
  'Card',
  {},
  (user: Input<User>) => ({ user }),
  ({ user }) => div(user().name),
).pipe(hasPermission('edit'));

edit is internal configuration. The caller of Card does not provide it.

Input supplied by the component caller ​

A directive can also add a public input to the component:

ts
const hasPermissionInput = craftDirective(
  'hasPermissionInput',
  {},
  (baseLogic: HostRequiredLogic<RequiresUser>) =>
    (user: Input<User>, permission: Input<Permission>) => {
      const context = baseLogic(user);

      return {
        ...context,
        permission,
        permissions: {
          canAccess: () => user().permissions.includes(permission()),
        },
      };
    },

  (
    baseTemplate: HostTemplate<{
      user: Input<User>;
      permission: Input<Permission>;
      permissions: {
        canAccess: () => boolean;
      };
    }>,
  ) =>
    (context) => (context.permissions.canAccess() ? baseTemplate(context) : []),
);

const Card = craftComponent(
  'Card',
  {},
  (user: Input<User>) => ({ user }),
  ({ user }) => div(user().name),
).pipe(hasPermissionInput);

Card({
  user: () => currentUser,
  permission: () => 'edit',
});

The directive adds permission to the final logic and to Card's public props. The renderer passes factory arguments in prop order, following the existing convention for functional component factories.

Structural directive ​

A structural directive decides whether the template produces nodes:

ts
import {
  HostRequiredLogic,
  HostTemplate,
  Input,
  craftComponent,
  craftDirective,
  div,
  p,
} from '@craft-ts/component';
import { craftSignal } from '@craft-ts/core';

const isVisible = craftSignal(true);

const whenDirective = craftDirective(
  'whenDirective',
  {},
  (
    baseLogic: HostRequiredLogic<{
      when: Input<boolean>;
    }>,
  ) => baseLogic,

  (
    baseTemplate: HostTemplate<{
      when: Input<boolean>;
    }>,
  ) =>
    (context) => (context.when() ? baseTemplate(context) : []),
);

const Panel = craftComponent(
  'Panel',
  {},
  (when: Input<boolean>) => ({ when }),
  () => div(p('Conditional content')),
).pipe(whenDirective);

Panel({
  when: function* () {
    return isVisible();
  },
});

When when() becomes false, the renderer removes the template output. When it becomes true again, the template is rendered again.

A structural directive can consume context added by a previous directive:

ts
const onlyEditable = craftDirective(
  'onlyEditable',
  {},
  (
    baseLogic: HostRequiredLogic<{
      permissions: {
        canEdit: () => boolean;
      };
    }>,
  ) => baseLogic,

  (
    baseTemplate: HostTemplate<{
      permissions: {
        canEdit: () => boolean;
      };
    }>,
  ) =>
    (context) => (context.permissions.canEdit() ? baseTemplate(context) : []),
);

const EditableCard = craftComponent(
  'EditableCard',
  {},
  (user: Input<User>) => ({ user }),
  ({ user }) => div(user().name),
).pipe(InteractivePermissions, onlyEditable);

The context flows from left to right:

text
original logic
  → InteractivePermissions
  → { user, permissions }
  → onlyEditable
  → template or []

Directives on elements ​

A component template can also apply a structural directive to a hyperscript node:

ts
const message = p('Message').pipe(whenDirective);

The component context is passed to the decorated template. Craft structural directives can therefore transform Craft output without introducing an intermediate component.

Functional DOM directives can also receive their configuration directly and be applied with .pipe(...). The configuration is owned by the directive instead of becoming a DOM attribute:

ts
a({}, 'Tasks').pipe(CraftRouterLink(link));

A field configured with insertSelectFormTree must be selected before it is bound, so its lazy insertions (including validators) are registered:

ts
input({ type: 'email' }).pipe(
  CraftFieldDirective(loginForm.form.selectEmail()),
);

Composition rules ​

  • Create a configurable directive with craftDirective(...), then pass it to .pipe(...).
  • A directive can add public inputs; they appear in the final component props.
  • A directive placed after another receives the already decorated logic and template, so it can consume context added by the previous directive.
  • Generator factories continue to be executed by the Craft runtime. Dependencies from both the original and decorated factories remain part of the component dependency contract.

See Also ​