Guide

# Draggable regions

Mark a frame `reactive_frame`, a box in it `reactive_box`, and a point `reactive_point`: they drag with a mouse, a finger or the keyboard, stay inside the frame, and their fields always hold where they are as fractions of it — a crop box, a focal point, a hotspot, a selection.

## Try it

Drag the box to move it, or a handle to resize it, and drag the red point to set the focus. Pick a preset to lock the shape: the box re-fits around its centre. Then try the keyboard: **Tab** to the box, **← → ↑ ↓** to move it, **Alt** + arrows to resize it, **Shift** for bigger steps. The fields follow as you drag; each release saves once.

Simulate network latency

Delay every action by 600 ms so the loading states are visible.

![A landscape to crop](https://phlex-reactive.zoolutions.llc/drag-demo/landscape.svg)

crop[x]

crop[y]

crop[width]

crop[height]

focus[x]

focus[y]

Saved: 45% × 60% at 20%, 15% · focus 50%, 40%

```ruby
# frozen_string_literal: true

# The draggable-regions demo (issue #416): a crop box over a picture, with
# aspect presets and a focal point inside it. The box drags and resizes with a
# mouse, a pen, a finger or the keyboard; the fields below follow it as it
# moves, and each release sends :crop (the point: :focus) with the region as
# fractions of the picture. The region lives in signed state — no database —
# so every visitor gets their own.
class RegionPickerComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  PRESETS = { '' => 'Free', '16:9' => '16:9', '1:1' => 'Square', '4:5' => 'Portrait' }.freeze
  CROP = { 'x' => 0.2, 'y' => 0.15, 'width' => 0.45, 'height' => 0.6 }.freeze
  FOCUS = { 'x' => 0.5, 'y' => 0.4 }.freeze

  # The look is the app's: the box dims what it leaves out, and the styling
  # hooks (data-reactive-dragging, data-reactive-frame-active) light it up.
  BOX_CSS = 'outline-2 outline-white shadow-[0_0_0_9999px_rgb(0_0_0/0.45)] focus-visible:outline-4 ' \
            'focus-visible:outline-primary data-[reactive-dragging]:outline-primary'
  HANDLE_CSS = 'size-3 rounded-full border-2 border-white bg-primary focus:outline-none ' \
               'data-[reactive-dragging]:scale-150'
  POINT_CSS = 'size-4 rounded-full border-2 border-white bg-error shadow focus:outline-none ' \
              'focus-visible:ring-4 focus-visible:ring-error/50 data-[reactive-dragging]:scale-125'

  reactive_state :crop, :focus, :aspect

  # The checked preset is a root field named `aspect`: every action collects
  # it, so the server keeps it and renders it checked again.
  action :crop, params: { x: :float, y: :float, width: :float, height: :float, aspect: :string }
  action :focus, params: { x: :float, y: :float, aspect: :string }

  def initialize(crop: CROP, focus: FOCUS, aspect: '')
    @crop = crop
    @focus = focus
    @aspect = aspect
  end

  def id = 'region-picker'

  def crop(x:, y:, width:, height:, aspect: nil)
    return reply.morph unless inside?(x, width) && inside?(y, height)

    @aspect = aspect.to_s
    @crop = { 'x' => x, 'y' => y, 'width' => width, 'height' => height }
    reply.morph
  end

  def focus(x:, y:, aspect: nil)
    return reply.morph unless [x, y].all? { it.between?(0, 1) }

    @aspect = aspect.to_s
    @focus = { 'x' => x, 'y' => y }
    reply.morph
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'region-picker' })) do
      picture_frame
      presets
      fields
      p(class: 'text-sm opacity-70', data: { testid: 'region-saved' }) { saved }
    end
  end

  private

  # The schema casts the fractions; it does not range-check them. A forged
  # release outside the picture changes nothing (the helper would refuse to
  # render it). The fields round to 4 decimals, so x + width may reach 1.0001.
  def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001

  def picture_frame
    div(**mix(reactive_frame, class: 'relative overflow-hidden rounded-box select-none',
                              data: { testid: 'region-frame' })) do
      img(src: '/drag-demo/landscape.svg', alt: 'A landscape to crop', class: 'block w-full', draggable: 'false')
      div(**mix(crop_box, class: BOX_CSS, aria: { label: 'Crop region' }, data: { testid: 'region-box' })) do
        %i[nw n ne e se s sw w].each { span(**mix(reactive_box_handle(it), class: HANDLE_CSS)) }
        span(**mix(focal_point, class: POINT_CSS, aria: { label: 'Focal point' }, data: { testid: 'region-point' }))
      end
    end
  end

  def crop_box
    reactive_box(fields: { x: 'crop[x]', y: 'crop[y]', width: 'crop[width]', height: 'crop[height]' },
                 value: @crop, aspect: 'aspect', min: 0.1, action: :crop)
  end

  def focal_point
    reactive_point(fields: { x: 'focus[x]', y: 'focus[y]' }, value: @focus,
                   within: '[data-reactive-box]', action: :focus)
  end

  def presets
    div(class: 'join', role: 'radiogroup', aria: { label: 'Aspect ratio' }) do
      PRESETS.each do |ratio, label|
        input(type: 'radio', name: 'aspect', value: ratio, checked: ratio == @aspect, aria: { label: },
              class: 'join-item btn btn-sm', data: { testid: "region-aspect-#{label.downcase}" })
      end
    end
  end

  # The fields the box writes: visible here, hidden in a real form.
  def fields
    div(class: 'grid grid-cols-3 gap-2 text-xs sm:grid-cols-6') do
      @crop.each { |key, value| field("crop[#{key}]", value) }
      @focus.each { |key, value| field("focus[#{key}]", value) }
    end
  end

  def field(name, value)
    label(class: 'flex flex-col gap-1') do
      span(class: 'font-mono opacity-70') { name }
      input(type: 'text', name:, value:, readonly: true, class: 'input input-xs font-mono',
            data: { testid: "region-#{name.delete(']').tr('[', '-')}" })
    end
  end

  def saved
    c = @crop.transform_values { (it * 100).round }
    f = @focus.transform_values { (it * 100).round }
    "Saved: #{c['width']}% × #{c['height']}% at #{c['x']}%, #{c['y']}% · focus #{f['x']}%, #{f['y']}%"
  end
end
```

## A box in a frame

`reactive_frame` marks the coordinate space: its padding box is 0..1 on each axis. `reactive_box` goes on an element inside it, with the names of the fields that hold it and where it is:

```ruby
class CropPicker < Phlex::HTML
  include Phlex::Reactive::Component

  reactive_record :photo
  action :crop, params: { x: :float, y: :float, width: :float, height: :float }

  def crop(x:, y:, width:, height:)
    authorize! @photo, :update?
    # The schema casts; it does not range-check (see below).
    return reply.morph unless inside?(x, width) && inside?(y, height)

    @photo.update!(crop: { x:, y:, width:, height: })
    reply.morph
  end

  def view_template
    div(**reactive_root) do
      div(**mix(reactive_frame, class: "relative")) do
        img(src: @photo.url, alt: "", class: "block w-full")
        div(**reactive_box(
          fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
          value: @photo.crop,      # { x: 0.1, y: 0.1, width: 0.5, height: 0.4 }
          min: 0.05,
          action: :crop
        )) do
          %i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
        end
      end
      @photo.crop.each { |key, value| input(type: "hidden", name: "crop[#{key}]", value:) }
    end
  end

  private

  # The fields round to 4 decimals, so x + width may reach 1.0001.
  def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
end
```

The helper renders the box's `left`, `top`, `width` and `height` as percentages of the frame from `value:`, so the first paint is right with no JavaScript, and a re-render with new values moves it. The box is clamped to the frame and never gets smaller than `min:` (a fraction of the frame; 0.05 by default).

Fractions, not pixels: they survive a resize, a responsive layout and a morph, and the server turns them into pixels with the dimensions it already knows (`x * image.width`).

The frame must be positioned — `relative`, or `absolute` for an overlay — which is left to you. The fields can be anywhere in the root: hidden inputs, or visible ones as in the demo.

## Handles

`reactive_box_handle(edge)` goes on an element inside the box. A press on it resizes from that edge or corner, the opposite side staying put; a press anywhere else on the box moves it. The edges are `:nw`, `:n`, `:ne`, `:e`, `:se`, `:s`, `:sw` and `:w`, and the helper places each one on its edge with the matching resize cursor. Its size and look are yours:

```ruby
span(**mix(reactive_box_handle(:se), class: "size-3 rounded-full bg-white"))
```

A drag starts after a 4 px move, so a click still works. The box, its handles and the point are `touch-action: none`: a finger drag moves them instead of scrolling the page. A press on the frame outside the box does nothing.

## A point

`reactive_point` is a point — its centre — that moves inside the frame, with an `x` and a `y` field:

```ruby
span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: { x: 0.5, y: 0.5 }))
```

To keep a point inside a box (a focal point inside a crop), put it in the box and pass `within:`, a selector for the element it sits in. Its fractions are then of that element, and it moves with the box:

```ruby
div(**reactive_box(...)) do
  span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @focus,
                        within: "[data-reactive-box]"))
end
```

The point has to sit inside the `within:` element: its percentages are of that element, which is what lets the server place it with no JavaScript.

## Locking the aspect ratio

`aspect:` locks the box's shape — its width over its height as it appears, in pixels. Pass a Numeric (`16/9r`, `1.5`) for a fixed ratio, or the name of a field whose value is `"16:9"`, a decimal, or `""` for free:

```ruby
div(**reactive_box(fields: { ... }, value: @crop, aspect: "aspect")) { ... }

{ "" => "Free", "16:9" => "16:9", "1:1" => "Square" }.each do |ratio, label|
  label { input(type: "radio", name: "aspect", value: ratio, checked: ratio == @aspect); plain label }
end
```

The field is read when a drag starts. On a corner the axis you moved more decides the size; on an edge the other side grows around the box's centre. When the field changes — a preset is picked, or `set_value` writes it — the box re-fits to the new ratio: the same area, centred where it was, inside the frame. A re-fit is a change like a release: the fields get `change` and the action is sent.

The aspect field is an ordinary field of the root, so every action collects it. Declare it in the action's params (`aspect: :string`) to keep the preset and render it checked again, as the demo does.

## The fields and the action

While a box or point moves, the client writes its fields the way `set_value` does: the live `.value`, a fraction rounded to 4 decimals, and a bubbling `input` — at most once per field per animation frame. So `reactive_show`, compute reducers and other bindings follow the box as it moves. A single `change` fires on each field when you let go.

`action:` is sent once on release, through the root's own pipeline: the signed token, the request queue, `reactive:before-dispatch`, `busy:` (the same values as `on(busy:)`) and the reply. It gets these params; declare them as `:float`:

| Element | Params |
| --- | --- |
| `reactive_box` | `x`, `y`, `width`, `height` — fractions of the frame |
| `reactive_point` | `x`, `y` — fractions of the frame, or of its `within:` element |

A drag that ends where it started sends nothing. Without `action:` the binding is client-only: it needs no token and works on a `Phlex::Reactive::ClientBindings` root, where a form submit sends the fields.

**Check the range before you store it.** The schema casts the numbers but does not range-check them, so a forged request can post `x: 2`. `reactive_box` refuses to render a box outside the frame, so a stored bad value would break every later render of it. Refuse anything outside 0..1, a `width` or `height` of 0, or a box whose `x + width` or `y + height` passes 1, in the action or with a model validation. The fields round to 4 decimals, so allow a sum of up to 1.0001, as the helper does. The `inside?` above (and the demo's) does this.

## Keyboard and screen readers

The box and the point are focusable (`tabindex="0"`); a handle is focusable with a press, but is not a tab stop. On a focused box or point:

| Key | Does |
| --- | --- |
| **← → ↑ ↓** | move it 1% of the frame |
| **Shift** + arrows | move it 10% |
| **Alt** + arrows | resize the box from its bottom-right corner, or from the focused handle |
| **Escape** | while an arrow is held (or during a pointer drag), put it back where it started |

Each arrow writes the fields (`input`); letting go of the key is the release: one `change`, and one request for `action:`, however long the key was held. Each step is announced through an `aria-live="polite"` region: "Moved to 20%, 15%", "Resized to 45% by 60%", "Cancelled". Give the box and the point an `aria-label` that says what they are. Translate the words with `messages:` — `{x}`, `{y}`, `{width}` and `{height}` are filled in as whole percents, so the `%` sign is yours to place:

```ruby
reactive_box(..., messages: {
  moved: t(".moved"),         # "Verschoben nach {x} %, {y} %"
  resized: t(".resized"),
  cancelled: t(".cancelled")
})
```

## Styling the drag

The look is yours. While a pointer drag is on, the client sets these attributes — style them in CSS, or with Tailwind's `data-*` variants as the demo does:

| Attribute | On |
| --- | --- |
| `data-reactive-frame-active` | the frame |
| `data-reactive-dragging` | the box, handle or point being pressed |

The helpers write an inline `style` (the position, `position: absolute`, `touch-action: none`, the cursor). To add your own inline style, mix it in after the helper's, as a String:

```ruby
div(**mix(reactive_box(...), style: "z-index: 10;", class: "outline-2 outline-white"))
```

A border on the box is inside it (`box-sizing: border-box`), so the box covers exactly the region its fields describe. The demo uses an outline and a wide box-shadow to dim what the crop leaves out.

## Morphs

Where a box or point is lives in its inline style, so a server re-render that changes `value:` moves it — after a reply, a broadcast or a page refresh. Fields outside what the morph rendered are brought along.

A morph that arrives during a drag does not interrupt it: the dragged element's style and its fields are held until release, like a field being typed in. The drag's values win, and its release sends them.

## Reference

```ruby
reactive_frame
reactive_box(fields:, value:, aspect: nil, min: 0.05, action: nil, busy: nil, messages: nil)
reactive_box_handle(edge)      # :nw :n :ne :e :se :s :sw :w
reactive_point(fields:, value:, within: nil, action: nil, busy: nil, messages: nil)
```

These are checked when the view renders: missing or unknown `fields:` keys, a `value:` outside 0..1 (or a box of no size, or one that leaves the frame), an unknown handle edge, a blank `within:`, a `min:` that is not above 0, an `aspect:` that is neither a positive Numeric nor a field name (a ratio written as a String, `"16:9"`, says to pass `16/9r`), `busy:` without `action:`, or `action:` on a client-only view raises an `ArgumentError` that says how to fix it. Under `verbose_errors`, an action the component does not declare raises too, as `on` does: once the component declares any action.

The drag code is the client's `drag` feature module (about 3.5 KB gzipped). The default client bundles it; with the split client (`phlex/reactive/core`) a page without a frame never loads it.