Examples

# Example: client-only ops

Some interactions never need the server — tabs, a menu that closes on an outside click, an accessible drawer with a transition + focus. `on_client` runs a whitelist of DOM ops locally, with ZERO round trips and ZERO custom JavaScript.

## Try it — nothing here hits the server

Switch tabs, open the menu (then click anywhere outside to close it), open the drawer (it fades in and focus lands on its first button). Every one of these is a client op — **no token, no POST, ever**. Open the network tab: you will see nothing. The component declares **no actions** at all.

Overview

Details

The Overview panel — shown by default.

The Details panel — revealed by a pure client op.

Open menu

Menu content — click anywhere outside to close.

Open drawer

First action

Second action

```ruby
# frozen_string_literal: true

# Issue #95: a component whose ENTIRE interactivity is client-side on_client ops —
# tabs that switch panels, a menu that closes on any outside click, and an
# accessible drawer with a transition + focus op — with ZERO server round trips.
# It deliberately declares NO actions: there is no token-bearing trigger anywhere,
# and the system spec's fetch spy proves nothing is ever posted. The root carries
# the window-bound outside-close trigger permanently (a client op costs nothing per
# stray page click, unlike a server action).
class ClientTabsComponent < Phlex::HTML
  include Phlex::Reactive::Component

  def id = 'client-tabs'

  def view_template
    # The fade transition classes for the drawer's js.toggle(transition:). Scoped
    # inline so the demo is self-contained (no global CSS needed).
    style do
      css = '.ct-fade{transition:opacity .2s ease} .ct-fade-from{opacity:0} .ct-fade-to{opacity:1} ' \
            '#ct-panel-1,#ct-panel-2{padding:.75rem 0} .ct-tab.active{font-weight:600;color:var(--color-primary)}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
    div(**mix(reactive_root, on_client(:click, js.hide('#ct-menu'), outside: true),
              class: 'flex flex-col gap-4')) do
      tabs
      panels
      menu
      drawer
    end
  end

  private

  def tabs
    div(role: 'tablist', class: 'flex gap-2 border-b border-base-300') do
      tab_button(1, 'Overview', active: true)
      tab_button(2, 'Details', active: false)
    end
  end

  # One op chain per tab: hide every panel, show the picked one, restyle the tab
  # buttons — the canonical "I had to write a Stimulus controller" case, now one
  # line of declared client ops.
  def tab_button(index, label, active:)
    ops = js.hide('.ct-panel').show("#ct-panel-#{index}")
            .remove_class('.ct-tab', 'active').add_class("#ct-tab-#{index}", 'active')

    button(**mix(on_client(:click, ops),
                 id: "ct-tab-#{index}", class: ['ct-tab px-3 py-1', ('active' if active)].compact,
                 data: { testid: "tab-#{index}" })) { label }
  end

  def panels
    div(id: 'ct-panel-1', class: 'ct-panel', data: { testid: 'panel-1' }) do
      'The Overview panel — shown by default.'
    end
    div(id: 'ct-panel-2', class: 'ct-panel', hidden: true, data: { testid: 'panel-2' }) do
      'The Details panel — revealed by a pure client op.'
    end
  end

  def menu
    div(class: 'flex flex-col gap-2') do
      button(**mix(on_client(:click, js.show('#ct-menu')),
                   class: 'btn btn-sm w-fit', data: { testid: 'menu-open' })) { 'Open menu' }
      div(id: 'ct-menu', hidden: true, class: 'rounded-box border border-base-300 p-3',
          data: { testid: 'menu' }) { 'Menu content — click anywhere outside to close.' }
    end
  end

  # Issue #96: a drawer opened with a TRANSITION (animated fade), an aria-expanded
  # attr op on the trigger, and a FOCUS op landing on the first focusable control
  # inside the drawer — the exact accessible-disclosure pattern, one op chain.
  def drawer
    open = js
           .toggle('#ct-drawer', transition: %w[ct-fade ct-fade-from ct-fade-to])
           .set_attr(:root, 'aria-expanded', 'true')
           .focus_first('#ct-drawer')

    div(class: 'flex flex-col gap-2') do
      button(**mix(on_client(:click, open),
                   id: 'ct-drawer-trigger', class: 'btn btn-sm w-fit',
                   data: { testid: 'drawer-open' })) { 'Open drawer' }
      div(id: 'ct-drawer', hidden: true, class: 'rounded-box border border-base-300 p-3 flex gap-2',
          data: { testid: 'drawer' }) do
        button(class: 'btn btn-xs', data: { testid: 'drawer-first' }) { 'First action' }
        button(class: 'btn btn-xs', data: { testid: 'drawer-second' }) { 'Second action' }
      end
    end
  end
end
```

## How it works

`on_client(:click, ops)` binds a chain of DOM ops to an event and runs them locally through the one generic reactive controller. The ops are a **frozen whitelist** — `show`/`hide`/`toggle`, `add_class`/ `remove_class`/`toggle_class`, `set_attr`/`toggle_attr`/`remove_attr`, `focus`/`focus_first`, `text`, `dispatch` — each a pure, local DOM mutation. Nothing is read back, nothing is sent anywhere.

- **Tabs:** `js.hide(".ct-panel").show("#ct-panel-1")` plus class ops on the tab buttons — the "I had to write a Stimulus controller" case, now one line.
- **Outside-close menu:** `on_client(:click, js.hide("#ct-menu"), outside: true)` on the **root** fires on any click *outside* the menu. A client op costs nothing per stray page click (unlike a server action).
- **Accessible drawer:** `js.toggle("#ct-drawer", transition: [...])` animates it, `set_attr(:root, "aria-expanded", "true")` updates the trigger, and `focus_first("#ct-drawer")` moves focus to the first control inside — the exact accessible-disclosure pattern, one chain.

## The op vocabulary

Ops are built with the server-side `js` helper and serialized into a `data-reactive-ops` attribute the client interprets. An op name not on the whitelist is warn-and-skipped (client-side default-deny) — a stale or newer ops attribute can never break the page. `focus`/`focus_first` are allowed here (an actor's own gesture) but **rejected** from a broadcast, where stealing focus in every subscriber's tab would be hostile.

`text(to, value)` (#159) sets the target's `textContent` — stringified, `nil` clears, **never `innerHTML`** — so a chain can paint a label or a derived number without a round trip. Pair it with `global: true` to reach a node **outside** the component's root (the cross-root text escape a read-only recap needs).

> **Tip:** Reach for `on_client` whenever the interaction is purely presentational — toggling a disclosure, closing a popover, moving focus. Reach for a reactive `action` (a token + POST) only when the server must **decide** or **persist** something.

## Value-conditional visibility (reactive_show)

`on_client` ops are *unconditional* — they can't read the triggering field's value to **decide** show vs hide. `reactive_show` (#161) covers exactly that gap, the Alpine `x-show` / Datastar `data-show` / Livewire `wire:show` case: spread it onto the element to show/hide, name the controlling field, declare **one literal predicate** — and the generic controller toggles the `hidden` attribute from the field's current value on every `input`/`change`. Still client-only: no token, no POST.

```ruby
div(**reactive_show(:mode, not: "off"))        { "shipping details" }
div(**reactive_show(:gift, equals: true))      { "gift message" }    # checkbox: checked
div(**reactive_show(:delivery, equals: "ship")) { "address fields" } # radio: checked value
div(**reactive_show(:size, in: %w[l xl]))      { "surcharge note" }
```

The predicate is a **declared literal match** — `equals:`, `not:`, or `in:` (a list) — never an expression, so there is no eval surface. Exactly one predicate is enforced loudly at render; extra attrs deep-merge through the helper. Render the initial `hidden:` yourself from the same server state that renders the field, so the first paint doesn't flash.

**Compound & numeric predicates (#176).** Two common form shapes fall just outside a single literal match: visibility that depends on **more than one field**, and visibility gated on a **threshold**. Both stay inside the eval-free contract.

`all:` / `any:` fold a list of per-field terms with **one fixed connective** — still no expression surface, just AND vs OR over the same literal vocabulary. One flat binding replaces the wrapper-div nesting AND is the only way to express OR:

```ruby
# visible while type == "individual" AND country != "domestic"
div(**reactive_show(all: [
  { field: :type,    equals: "individual" },
  { field: :country, not:    "domestic" }
]))
# visible while director OR shareholder is checked
div(**reactive_show(any: [
  { field: :director,    equals: true },
  { field: :shareholder, equals: true }
]))
```

`gte:` / `gt:` / `lte:` / `lt:` compare the field value coerced to a Number against a literal number baked into the binding (the RHS must be a real `Numeric` in Ruby — a typo fails at render). A non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default. Numeric predicates work standalone and as a compound term:

```ruby
div(**reactive_show(:quantity, gte: 10))   # visible while qty >= 10
div(**reactive_show(all: [
  { field: :type,   equals: "order" },
  { field: :amount, gte:    5000 }
]))
```

A malformed compound term folds **false** (fail-closed: a broken AND term can't pass, a broken OR term can't reveal) — the same default-deny posture as the rest of the vocabulary. Numeric predicates also carry into `reactive_show_targets` maps.

A plain `reactive_show` is root-scoped by design. When the dependents live **outside** the control's root — a nav tab, a panel in another tab pane, a sidebar note — `reactive_show_targets` (#164) is the declared escape, the visibility parallel of the cross-root text mirror: the component that **owns** the field declares which outside ids it governs, spread on the root. Id selectors only (raise at render, warn-and-skip on the client — two-sided default-deny), same literal predicates, `hidden` only; a target id not on the page is silently skipped.

```ruby
div(**mix(reactive_root, reactive_show_targets(:mode,
  "#advanced-tab" => { equals: "advanced" },
  "#basic-note"   => { not: "advanced" })))
```

Shipping

No shipping

Standard

Express

Shipping details — visible while the select is not "No shipping".

This is a gift

Gift message — visible while the checkbox is checked.

Pickup

Ship

Shipping address — visible while the "Ship" radio is checked.

Type

Individual

Company

Country

Domestic

Foreign

International address — visible while Individual AND not Domestic.

Quantity

Bulk surcharge applies — visible while quantity ≥ 10.

```ruby
# frozen_string_literal: true

# Issue #161: value-conditional visibility — reactive_show, the x-show /
# data-show equivalent. Each dependent section declares which field controls it
# plus ONE literal predicate, and the generic controller toggles `hidden` from
# the field's CURRENT value with NO round trip. Like ClientTabsComponent it
# declares NO actions: there is no token-bearing trigger anywhere. Every section
# renders its initial `hidden:` from the same server state that renders its
# field, so first paint needs no client reconcile (no flash).
class ConditionalFieldsetComponent < Phlex::HTML
  include Phlex::Reactive::Component

  def id = 'conditional-fieldset'

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-4')) do
      shipping_mode
      gift_option
      delivery_choice
      compound_address
      quantity_surcharge
    end
  end

  private

  # A <select> driving a dependent panel: visible WHILE mode != "off".
  def shipping_mode
    div(class: 'flex flex-col gap-2') do
      label(class: 'flex items-center gap-2') do
        span { 'Shipping' }
        select(name: 'mode', class: 'select select-sm w-fit', data: { testid: 'mode' }) do
          option(value: 'off', selected: true) { 'No shipping' }
          option(value: 'standard') { 'Standard' }
          option(value: 'express') { 'Express' }
        end
      end
      # Extra attrs ride THROUGH reactive_show (it deep-merges via mix) — a bare
      # `data:` beside the spread would clobber the binding.
      div(**reactive_show(:mode, not: 'off', hidden: true,
                                 class: 'rounded-box border border-base-300 p-3',
                                 data: { testid: 'mode-details' })) do
        'Shipping details — visible while the select is not "No shipping".'
      end
    end
  end

  # A checkbox: its .value is the constant "on", so the binding compares the
  # CHECKED state — equals: true is the checkbox form.
  def gift_option
    div(class: 'flex flex-col gap-2') do
      label(class: 'flex items-center gap-2') do
        input(type: 'checkbox', name: 'gift', class: 'checkbox checkbox-sm', data: { testid: 'gift' })
        span { 'This is a gift' }
      end
      div(**reactive_show(:gift, equals: true, hidden: true,
                                 class: 'rounded-box border border-base-300 p-3',
                                 data: { testid: 'gift-note' })) do
        'Gift message — visible while the checkbox is checked.'
      end
    end
  end

  # A radio group: the binding reads the CHECKED radio's value.
  def delivery_choice
    div(class: 'flex flex-col gap-2') do
      div(class: 'flex gap-4') do
        label(class: 'flex items-center gap-2') do
          input(type: 'radio', name: 'delivery', value: 'pickup', checked: true,
                class: 'radio radio-sm', data: { testid: 'delivery-pickup' })
          span { 'Pickup' }
        end
        label(class: 'flex items-center gap-2') do
          input(type: 'radio', name: 'delivery', value: 'ship',
                class: 'radio radio-sm', data: { testid: 'delivery-ship' })
          span { 'Ship' }
        end
      end
      div(**reactive_show(:delivery, equals: 'ship', hidden: true,
                                     class: 'rounded-box border border-base-300 p-3',
                                     data: { testid: 'address' })) do
        'Shipping address — visible while the "Ship" radio is checked.'
      end
    end
  end

  # Issue #176 part A: a COMPOUND all: across TWO fields — one flat binding,
  # visible only while type == "individual" AND country != "domestic".
  def compound_address
    div(class: 'flex flex-col gap-2') do
      div(class: 'flex gap-4') do
        label(class: 'flex items-center gap-2') do
          span { 'Type' }
          select(name: 'type', class: 'select select-sm w-fit', data: { testid: 'type' }) do
            option(value: 'individual', selected: true) { 'Individual' }
            option(value: 'company') { 'Company' }
          end
        end
        label(class: 'flex items-center gap-2') do
          span { 'Country' }
          select(name: 'country', class: 'select select-sm w-fit', data: { testid: 'country' }) do
            option(value: 'domestic', selected: true) { 'Domestic' }
            option(value: 'foreign') { 'Foreign' }
          end
        end
      end
      div(**reactive_show(hidden: true,
                          class: 'rounded-box border border-base-300 p-3',
                          data: { testid: 'intl-address' }, all: [
                            { field: :type, equals: 'individual' },
                            { field: :country, not: 'domestic' }
                          ])) do
        'International address — visible while Individual AND not Domestic.'
      end
    end
  end

  # Issue #176 part B: a NUMERIC threshold — reveal while quantity >= 10.
  def quantity_surcharge
    div(class: 'flex flex-col gap-2') do
      label(class: 'flex items-center gap-2') do
        span { 'Quantity' }
        input(type: 'number', name: 'quantity', value: '1',
              class: 'input input-sm w-24', data: { testid: 'quantity' })
      end
      div(**reactive_show(:quantity, gte: 10, hidden: true,
                                     class: 'rounded-box border border-base-300 p-3',
                                     data: { testid: 'surcharge' })) do
        'Bulk surcharge applies — visible while quantity ≥ 10.'
      end
    end
  end
end
```

## The zero-fetch contract

Because the component declares no actions and every trigger is `on_client`, a click here never mints a token and never posts. That is the tested contract: the browser suite spies on `fetch` and asserts it is called **zero** times across every tab switch, menu toggle, and drawer open.