DX object to collect and manage HTMX behaviors

Created on 12 May 2025, 2 days ago

Problem/Motivation

We have both attributes and headers as tools to implement actions using HTMX. We also have defined sets of actions in the existing Ajax API that it would be beneficial to provide equivalent actions.

Proposed resolution

This issue is currently a placeholder until the related issues are completed and committed. For now the code for the proposed object is inserted here in the summary.


namespace Drupal\Core\Render\Hypermedia;

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Http\HtmxHeaderInterface;
use Drupal\Core\Http\HtmxResponseHeaders;
use Drupal\Core\Render\Hypermedia\Operations\HtmxOperationInterface;
use Drupal\Core\Render\Hypermedia\Operations\HtmxRequestOperationInterface;
use Drupal\Core\Template\AttributeHelper;
use Drupal\Core\Template\HtmlAttributeInterface;
use Drupal\Core\Template\HtmxAttribute;

/**
 * Collects HTMX attributes and headers and manages complex behaviors.
 *
 * An instance of this object is required to add HTMX behaviors to a render
 * element.
 *
 * Methods to configure common behaviors are provided:
 *
 * @code
 * $htmx = new Htmx();
 * $node_url = Url::fromRoute(
 *    route_name: entity.node.canonical',
 *    route_parameters: ['node' => 123],
 *  );
 * $htmx->insert($node_url, 'div.example', 'article.page')
 *
 * $build = [
 *    '#htmx' => $htmx,
 *  ];
 * @endcode
 *
 * Attributes may also be specified explicitly:
 *
 * @code
 * $htmx = new Htmx();
 * $form_url = Url::fromRoute(
 *   route_name: 'config.export_single',
 *   route_parameters: ['config_type' => $config_type, 'config_name' => $config_name],
 * );
 *
 * $htmx->attributes()
 *   ->post($form_url)
 *   ->select('select[data-drupal-selector="edit-config-name"]')
 *   ->target('select[data-drupal-selector="edit-config-name"]')
 *   ->swap('outerHTML');
 *
 * $build = [
 *   '#htmx' => $htmx,
 * ];
 * @endcode
 *
 * HTMX headers are added in a similar way.
 *
 * @code
 * // Also update the browser URL.
 * $push = Url::fromRoute(
 *   route_name: 'config.export_single',
 *   route_parameters: ['config_type' => $default_type, 'config_name' => $default_name],
 * );
 *
 * $htmx = new Htmx();
 * $htmx->headers()->pushUrl($push);
 * $build = [
 *   '#htmx' => $htmx,
 * ];
 * @endcode
 *
 * @see \Drupal\Core\Template\HtmxAttribute
 * @see \Drupal\Core\Http\HtmxResponseHeaders
 */
class Htmx implements HtmxInterface {

  /**
   * Optional request operation.
   *
   * @var \Drupal\Core\Render\Hypermedia\Operations\HtmxRequestOperationInterface|null
   */
  protected HtmxRequestOperationInterface|null $requestOperation = NULL;

  /**
   * Additional operations that do not depend on a request.
   *
   * @var \Drupal\Core\Render\Hypermedia\Operations\HtmxOperationInterface[]
   */
  protected array $additionalOperations = [];

  public function __construct(
    public readonly HtmxAttribute $attributes = new HtmxAttribute(),
    public readonly HtmxHeaderInterface $headers = new HtmxResponseHeaders(),
  ) {}

  /**
   * {@inheritdoc}
   */
  public function attributes(): HtmxAttribute {
    return $this->attributes;
  }

  /**
   * {@inheritdoc}
   */
  public function headers(): HtmxHeaderInterface {
    return $this->headers;
  }

  /**
   * {@inheritdoc}
   */
  public function getCombinedAttributes(array|HtmlAttributeInterface $attributes): HtmlAttributeInterface|array {
    return AttributeHelper::mergeCollections($attributes, $this->attributes);
  }

  /**
   * {@inheritdoc}
   */
  public function getCombinedHeaders(array $headers): array {
    return NestedArray::mergeDeep($headers, $this->headers->toArray());
  }

  /**
   * {@inheritdoc}
   */
  public function merge(HtmxInterface $htmx): HtmxInterface {
    $this->attributes->merge($htmx->attributes());
    $this->headers->merge($htmx->headers());
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function setRequestOperation(HtmxRequestOperationInterface $operation): HtmxInterface {
    $this->requestOperation = $operation;
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function setAdditionalOperation(HtmxOperationInterface $operation): HtmxInterface {
    if ($operation instanceof HtmxRequestOperationInterface) {
      throw new \ValueError('Htmx::setRequestOperation() must be used to add HtmxRequestOperationInterface operations');
    }
    $this->additionalOperations[] = $operation;
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function hasOperations(): bool {
    return $this->requestOperation instanceof HtmxRequestOperationInterface
    || count($this->additionalOperations) !== 0;
  }

  /**
   * {@inheritdoc}
   */
  public function processOperations(): void {
    if ($this->hasOperations() === FALSE) {
      return;
    }
    $operations = [];
    if (count($this->additionalOperations) !== 0) {
      $operations = $this->additionalOperations;
    }
    if ($this->requestOperation instanceof HtmxRequestOperationInterface) {
      array_unshift($operations, $this->requestOperation);
    }
    foreach ($operations as $operation) {
      $operation->setProperties($this);
    }
  }

Remaining tasks

Implement and improve the proposed data object.

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

πŸ“Œ Task
Status

Active

Version

11.0 πŸ”₯

Component

render system

Created by

πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Production build 0.71.5 2024