Extracting parts of forms into Unpoly modals/popups

Posted . Visible to the public. Repeats.

Say you wrap your index view in a form to apply different filters like pagination or a search query. On submit, your index view changes when the filters are applied (through up-submit and up-target).
Now you want to enable more data-specific filters using a separate "Filters" button that opens a popup to not overload your UI.

Filter bar:

Filter popup:

The problem is that the Unpoly popup will be inserted at the bottom of the DOM, so its inputs will not live inside your form. This will result in losing params because they will not be submitted along.

To fix this, we need to create a dummy form in the popup layer and copy the inputs between the layers.
We will use the following HTML:

%form{ action: my_filter_path, class: 'filter-form', 'up-history': true, 'up-target': '.filter-form, .result-list' }
  %input(type='text' name='query' placeholder='Search...')

  %div(filter-popup)
    # Trigger button for the popup
    %a{ class: 'btn btn-secondary', 'filter-popup-button': '', role: 'button' }

    # The content of the popup form
    .filter-content(filter-popup-content)
      # Your form inputs, nested and styled in filter groups as you wish
      %input(type='checkbox' name='locked')
      # ...
   
      %a{ class: 'btn btn-secondary', role: 'button', 'up-dismiss': true }
        Abbrechen
      %button{ class: 'btn btn-primary', type: 'submit' }
        Anwenden
        
.result-list
  # ...        

app/assets/components/filter_popup.js

import { copyValues } from '../util/form'

up.compiler('[filter-popup]', function(element) {

  const button = element.querySelector('[filter-popup-button]')
  const rootForm = button.closest('form')
  const popupContent = element.querySelector('[filter-popup-content]')

  up.on(button, 'click', async function(evt) {
    evt.stopPropagation() // Without this, the click will bubble up and immediately close the popup

    const { fragment: overlayForm } = await up.render(
      {
        align: 'right',
        class: 'filter',
        dismissable: 'key outside',
        fragment: `<form>${popupContent.outerHTML}</form>`,
        layer: 'swap',
        mode: 'popup',
        navigate: false,
        origin: button,
        position: 'bottom',
      },
    )
    copyValues(rootForm, overlayForm) // Copy all values from the root layer form into the popup form

    up.layer.on('submit', function(evt, overlayForm) {
      evt.preventDefault() // Prevent the form submit because we need to submit the whole root form
      
      // Copy them back to the root form as soon as the form in the layer is submitted
      copyValues(overlayForm, rootForm)
      up.layer.dismiss() // Close the layer
      up.submit(rootForm) // Submit the root form to achieve index changes with the filters
    })
  })

})

app/assets/util/form.js

// Copies input values from sourceForm to targetForm.
export function copyValues(sourceForm, targetForm) {
  for (const sourceField of sourceForm.elements) {
    const targetField = targetForm.elements.namedItem(sourceField.name)
    if (!targetField) continue

    if (targetField instanceof RadioNodeList) {
      for (const radio of targetField) {
        if (radio.value === sourceField.value) {
          radio.checked = sourceField.checked
        }
      }
    } else if (targetField.type === 'checkbox') {
      targetField.checked = sourceField.checked
    } else {
      targetField.value = sourceField.value
    }
  }
}
Dominic Beger
Last edit
Dominic Beger
License
Source code in this card is licensed under the MIT License.
Posted by Dominic Beger to makandra dev (2025-04-28 11:19)