Lesson 8 of 14

Inputs and behaviour

A TextField, a Switch and a Dialog: value bindings, emitted events, and what the archetype brings.

#The idea

An input has a value, raises an event when it changes, and must be named for assistive technology. A switch is one boolean. A dialog opens, traps focus and closes on Escape. None of that is interaction code you write: the archetype composes the behaviours, and the file says which prop is the value, which event carries it, and which part names which.

#What this lesson adds

milda
component TextField "A single-line input with an error state." {
  archetype TextField
  group Forms

  props {
    label : string required "What the field asks for. Also its accessible name."
    value : string
    placeholder : string = "you@example.com"
    invalid : boolean = false
    error : string "Shown under the control while invalid."
    disabled : boolean = false
  }

  events {
    valueChange
  }

  part TextField: container {
    bind state disabled disabled
    bind state error invalid
    layout { stack column; gap space.xs }
  }

  part label: text {
    content bind label
    text size.sm
    weight weight.medium
  }

  part control: input {
    content bind placeholder
    emits {
      valueChange on input payload bind value
    }
    bind value value
    value string
    a11y { labelled-by label; described-by hint }
    layout { size fill }
    fill surface
    ink text
    border line hairline
    corner radius.sm
    inset space.sm space.md
    text size.md
    state focus-visible {
      border accent hairline
    }
    state error {
      border warning hairline
    }
    state disabled {
      opacity 50
    }
  }

  part hint: text {
    present when invalid
    content bind error
    ink warning
    text size.sm
  }
}
milda
component Switch "One boolean, as a sliding switch." {
  archetype BooleanInput
  group Forms

  props {
    checked : boolean = false "Whether it is on."
    label : string required
    disabled : boolean = false
  }

  events {
    checkedChange
  }

  part Switch: container {
    bind state disabled disabled
    layout { stack row; gap space.sm; align center }
  }

  part toggle: input {
    control switch
    a11y { name bind label }
    emits {
      checkedChange on change payload bind checked
    }
    bind value checked
    value boolean
  }
}
milda
component Dialog "A blocking layer that asks one question." {
  archetype Dialog
  group Feedback

  uses presentation(surface: dialog) as P
  uses dismiss as D
  wire D.dismissRequested -> P.close

  props {
    trigger : string = "Open" "The label of the control that opens the dialog."
    title : string required "The dialog's accessible name."
    open : boolean = false
  }

  part Dialog: container {
    bind state open open
  }

  part trigger: control {
    layout { stack row; gap space.sm; align center; distribute center }
    fill accent
    ink on-accent
    corner radius.sm
    inset space.sm space.lg
    state focus-visible {
      ring accent
    }
  }

  part trigger.label: text {
    content bind trigger
    weight weight.medium
  }

  part backdrop: container {
    fill text
    opacity 50
  }

  part surface: container {
    layout { stack column; gap space.md }
    fill surface
    ink text
    corner radius.lg
    inset space.xl
  }

  part surface.title: text {
    content bind title
    text size.lg
    weight weight.bold
  }

  part surface.content: container {
    slot of content default
    ink text-muted
  }
}

#What to notice

  • The input part holds the value. bind value value makes the value prop the part's value, two-way; value string is its type; and emits { valueChange on input payload bind value } raises the event with the new value every time the user types. is “while editing”; would be “once committed”.
  • Accessible naming is by part path. a11y { labelled-by label; described-by hint } says the label part names the control and the hint part describes it. The web realizes this as for, id and aria-describedby; a native target wires its own.
  • present when invalid makes the hint exist only in the error state, and bind state error invalid on the root puts the whole field into that state from the same prop.
  • A boolean input realized as a switch. control switch is a realization choice; the contract would be the same as a checkbox. a11y { name bind label } gives the toggle its name, which this target also shows as the visible label.
  • The Dialog's anatomy comes from its archetype: trigger, backdrop, surface with its title and content. bind state open open ties the open state to the prop. Focus trapping, Escape, the backdrop click and aria-modal are the archetype's obligations.
  • and name the composition. uses presentation(surface: dialog) as P and wire D.dismissRequested -> P.close write down, in the behaviour algebra, what the archetype already does: a dismiss request closes the presentation. The file carries this authored composition.
Partly built

A component can declare uses, wire and gate, and the file carries them. No target realizes an authored composition yet: a component’s behaviour is still its archetype’s.proposal 0024

#What the generator makes of it

The TextField holds its draft in React state, labels the input through a generated id, and renders the hint with aria-describedby only while invalid. The Switch is a hidden native checkbox with role="switch" behind a styled track and thumb. The Dialog portals its surface, listens for Escape and closes on the backdrop.

#Try it

Change on input to on change in the TextField and compare when the event fires in the preview. Then remove the block and read what the panel says about an unnamed input.

Words in this lesson

  • A prop drives something about the part.
  • The type of value an input part holds, such as string for a text field or number for a slider.
  • Which declared event this part raises, and on what interaction.
  • How assistive technology names and describes the part: a name of its own (fixed or bound), or another part’s text by path.
  • How a boolean control is realized: as a checkbox or as a switch.
  • The part exists only while the predicate holds - not hidden, absent.
  • Composes a behaviour atom from the prelude into the component, under an alias that wire and gate refer to.
  • Coordination between composed behaviours: when one atom signals, another acts.

Open a word for its entry, or read them all in the reference.

#The file at the end of this lesson

milda
milda 0.10
prelude 0.0.2

meta { name "Acme" }

context-group ColorScheme { Light Dark }

context-group Variant { Primary Secondary Ghost }

group Actions "Things a person presses to make something happen."
group Forms "Collecting input."
group Feedback "Telling a person what is happening."
group Layout "Surfaces that hold the rest."

foundations {
  layer Base {
    token violet/100 : color = hex(ede9fe)
    token violet/500 : color = hex(8b5cf6)
    token violet/600 : color = hex(7c3aed)
    token violet/700 : color = hex(6d28d9)
    token violet/900 : color = hex(4c1d95)
    token gray/50 : color = hex(f8fafc)
    token gray/200 : color = hex(e2e8f0)
    token gray/500 : color = hex(64748b)
    token gray/800 : color = hex(1e293b)
    token gray/900 : color = hex(0f172a)
    token green/500 : color = hex(22c55e)
    token amber/500 : color = hex(f59e0b)
    token white : color = hex(ffffff)
  }

  layer Semantic {
    token accent : color = alias by ColorScheme { default -> violet.600; Dark -> violet.500 }
    token accent-hover : color = alias by ColorScheme { default -> violet.700; Dark -> violet.600 }
    token accent-subtle : color = alias by ColorScheme { default -> violet.100; Dark -> violet.900 }
    token on-accent : color = alias by ColorScheme { default -> white }
    token surface : color = alias by ColorScheme { default -> white; Dark -> gray.900 }
    token text : color = alias by ColorScheme { default -> gray.900; Dark -> gray.50 }
    token text-muted : color = alias by ColorScheme { default -> gray.500 }
    token line : color = alias by ColorScheme { default -> gray.200; Dark -> gray.800 }
    token success : color = alias by ColorScheme { default -> green.500 }
    token warning : color = alias by ColorScheme { default -> amber.500 }

    token space/xs : spacing = 4px
    token space/sm : spacing = 8px
    token space/md : spacing = 12px
    token space/lg : spacing = 16px
    token space/xl : spacing = 24px

    token radius/sm : radius = 6px
    token radius/lg : radius = 12px
    token radius/pill : radius = pill

    token hairline : border = 1px

    token size/sm : font-size = 13px
    token size/md : font-size = 15px
    token size/lg : font-size = 18px
    token weight/medium : font-weight = 500
    token weight/bold : font-weight = 700
  }
}

component Button "The primary action, in three emphases." {
  archetype Button
  group Actions

  props {
    variant : context Variant = Primary "Which emphasis the button carries."
    disabled : boolean = false
    loading : boolean = false "Shows a spinner in place of the label."
    loadingLabel : string required when loading "Read out while the spinner replaces the label."
    label : string = "Get started"
    icon : string "A glyph shown before the label, by name."
  }

  events {
    click
  }

  part Button: container {
    emits { click on activate }
    bind state disabled disabled
    bind state loading loading
    layout { stack row; gap space.sm; align center; distribute center }
    fill accent
    ink on-accent
    border accent hairline
    corner radius.sm
    inset space.sm space.lg
    when variant = Secondary {
      fill surface
      ink text
      border line hairline
    }
    when variant = Ghost {
      fill surface
      ink accent
      border surface hairline
    }
    state hovered {
      fill accent-hover
      border accent-hover hairline
    }
    state hovered when variant = Secondary {
      fill accent-subtle
    }
    state hovered when variant = Ghost {
      fill accent-subtle
      border accent-subtle hairline
    }
    state focus-visible {
      ring accent
    }
    state disabled {
      opacity 50
    }
  }

  part icon: icon {
    content bind icon
    ink on-accent
    when variant = Secondary {
      ink text
    }
    when variant = Ghost {
      ink accent
    }
  }

  part label: text {
    slot of content default
    content bind label
    text size.md
    weight weight.medium
  }
}

component Badge "A small status pill." {
  archetype Badge
  group Feedback

  props {
    tone : enum(neutral, success, warning) = neutral
    label : string = "New"
  }

  part Badge: container {
    layout { stack row; align center }
    fill accent-subtle
    ink accent
    corner radius.pill
    inset space.xs space.sm
    when tone = success {
      fill success
      ink on-accent
    }
    when tone = warning {
      fill warning
      ink gray.900
    }
  }

  part label: text {
    slot of content default
    content bind label
    text size.sm
    weight weight.bold
  }
}

component Card "A surface composed from the components above." {
  group Layout
  props {
    tone : enum(neutral, success, warning) = neutral
  }

  part Card: container {
    layout { stack column; gap space.md }
    fill surface
    ink text
    border line hairline
    corner radius.lg
    inset space.xl
  }

  part Header: container {
    layout { stack row; gap space.sm; align center }
  }

  part Header.Title: text {
    content "Project Aurora"
    text size.lg
    weight weight.bold
  }

  part Header.Status: instance {
    instance Badge {
      label = "Beta"
      tone = bind tone
    }
  }

  part Body: text {
    slot of content default
    ink text-muted
    text size.md
  }

  part Footer: container {
    layout { stack row; gap space.sm }
  }

  part Footer.Primary: instance {
    instance Button {
      label = "Open"
    }
  }

  part Footer.Secondary: instance {
    instance Button {
      label = "Share"
      variant = "Secondary"
    }
  }
}

component TextField "A single-line input with an error state." {
  archetype TextField
  group Forms

  props {
    label : string required "What the field asks for. Also its accessible name."
    value : string
    placeholder : string = "you@example.com"
    invalid : boolean = false
    error : string "Shown under the control while invalid."
    disabled : boolean = false
  }

  events {
    valueChange
  }

  part TextField: container {
    bind state disabled disabled
    bind state error invalid
    layout { stack column; gap space.xs }
  }

  part label: text {
    content bind label
    text size.sm
    weight weight.medium
  }

  part control: input {
    content bind placeholder
    emits {
      valueChange on input payload bind value
    }
    bind value value
    value string
    a11y { labelled-by label; described-by hint }
    layout { size fill }
    fill surface
    ink text
    border line hairline
    corner radius.sm
    inset space.sm space.md
    text size.md
    state focus-visible {
      border accent hairline
    }
    state error {
      border warning hairline
    }
    state disabled {
      opacity 50
    }
  }

  part hint: text {
    present when invalid
    content bind error
    ink warning
    text size.sm
  }
}

component Switch "One boolean, as a sliding switch." {
  archetype BooleanInput
  group Forms

  props {
    checked : boolean = false "Whether it is on."
    label : string required
    disabled : boolean = false
  }

  events {
    checkedChange
  }

  part Switch: container {
    bind state disabled disabled
    layout { stack row; gap space.sm; align center }
  }

  part toggle: input {
    control switch
    a11y { name bind label }
    emits {
      checkedChange on change payload bind checked
    }
    bind value checked
    value boolean
  }
}

component Dialog "A blocking layer that asks one question." {
  archetype Dialog
  group Feedback

  uses presentation(surface: dialog) as P
  uses dismiss as D
  wire D.dismissRequested -> P.close

  props {
    trigger : string = "Open" "The label of the control that opens the dialog."
    title : string required "The dialog's accessible name."
    open : boolean = false
  }

  part Dialog: container {
    bind state open open
  }

  part trigger: control {
    layout { stack row; gap space.sm; align center; distribute center }
    fill accent
    ink on-accent
    corner radius.sm
    inset space.sm space.lg
    state focus-visible {
      ring accent
    }
  }

  part trigger.label: text {
    content bind trigger
    weight weight.medium
  }

  part backdrop: container {
    fill text
    opacity 50
  }

  part surface: container {
    layout { stack column; gap space.md }
    fill surface
    ink text
    corner radius.lg
    inset space.xl
  }

  part surface.title: text {
    content bind title
    text size.lg
    weight weight.bold
  }

  part surface.content: container {
    slot of content default
    ink text-muted
  }
}