Custom assigners

The escape hatch for elements whose value surface isn't a DOM property. Install a write function per element and decide what lands in storage when an event fires.

Category
Directive binding
Slot
el[assignKey] = fn
Signature
(value, registerValue?) => boolean | undefined
Symbol
Symbol.for('attaform:assign-key')

Click the color swatches to watch the JSON readout flip. The widget isn't an <input>; it's a plain <div> with buttons that dispatches input events and stores its picked color on dataset.color. A custom assigner installed via el[assignKey] = fn translates that to a form write. The Install via assignKey section walks through the wire-up.

Custom Assigner Demo Open in playground
Pick a color (no <input>, just a custom widget)
form.values.color = "#2563eb"

When v-register needs help

v-register's default extractor reads el.value (or el.checked for checkboxes, el.files for files). For elements whose value lives somewhere else (a Web Component with el.color, a custom widget with el.dataset.x, a third-party slider with a method-only surface), the default can't see what to write.

A custom assigner replaces the write step. The directive still fires on the same DOM events; the assigner decides what value to commit.

Install via assignKey

import { onMounted, useTemplateRef } from 'vue'
import { assignKey, type CustomDirectiveRegisterAssignerFn } from 'attaform'

const widgetEl = useTemplateRef<HTMLDivElement>('widget')

const colorAssigner: CustomDirectiveRegisterAssignerFn = (_value, rv) => {
  const el = widgetEl.value
  if (!el || !rv) return false
  rv.setValueWithInternalPath(el.dataset.color ?? '')
  return true
}

onMounted(() => {
  const el = widgetEl.value
  if (el) {
    ;(el as HTMLDivElement & { [k: symbol]: CustomDirectiveRegisterAssignerFn })[assignKey] =
      colorAssigner
  }
})

The template anchor: <div ref="widget" v-register="form.register('color')" />. The v-register binding still goes on the element; the assigner is what reroutes the write step.

assignKey is Symbol.for('attaform:assign-key'), a well-known symbol so multiple installers (your code, a third-party wrapper, Attaform's own built-in assigners) can detect and coordinate.

The function signature

type CustomDirectiveRegisterAssignerFn = (
  value: unknown,
  registerValue?: RegisterValue
) => boolean | undefined
ArgUse
valueWhat the directive extracted from the event, post-transforms and post-coerce. May be undefined when the assigner reads its own state.
registerValueThe RegisterValue for the current binding. Call rv.setValueWithInternalPath(v) to commit a write. Supplied by the directive on every fire regardless of install path, so the assigner doesn't have to capture the RV via closure at install time.

Return true to signal the write was accepted, false to reject. undefined is treated as success, so simple assigners can return nothing.

Reach for transforms first

If you just want to reshape the value before write (uppercase, trim, parse), reach for register transforms instead. Transforms compose into a per-field pipeline declared at the call site, no element-level installation, no symbol indirection.

assignKey is for cases where the extracted value isn't right at all: different source, different shape, different element type. Web Components, custom widgets, third-party libraries that don't speak the standard DOM event surface.

Persistence integrates automatically

If the bound element opted in via register('path', { persist: true }), rv.setValueWithInternalPath(value) auto-attaches the persist meta. The write flows to the configured storage backend without the assigner threading anything explicit.

const colorAssigner: CustomDirectiveRegisterAssignerFn = (_value, rv) => {
  const el = widgetEl.value
  if (!el || !rv) return false
  // No second arg, no meta to assemble: the rv consults the
  // per-element opt-in registered against this element.
  rv.setValueWithInternalPath(el.dataset.color ?? '')
  return true
}

Template pairing: <div ref="widget" v-register="form.register('color', { persist: true })" />. The opt-in lives at the register call site, per the two-gate policy; the assigner just commits the write.

Bypass the auto-attach by passing an explicit second arg, e.g. rv.setValueWithInternalPath(value, { persist: false }) for a transient write that shouldn't persist even when the element is opted in.

Where to next