Skip to main content

Previewing User-Selected CSS While Editing

Front-end Development
User Experience
Drupal

Consider this scenario: A site has multiple color schemes to reflect different brands (let's call them Alpha and Bravo) owned by the company. A given page should be able to reflect the scheme of whichever brand it's talking about.

Easy enough! We can put a taxonomy reference field on the page with our choice. Let's call the field field_brand. Then, we can preprocess the node to add a class name and/or CSS library to handle however the page needs to change.

/**
 * Implements hook_preprocess_HOOK() for node templates.
 */
function mytheme_preprocess_node(&$variables) {
  $node = $variables['node'];
  $view_mode = $variables['view_mode'];

  switch ($view_mode) {
    case 'full':
      // Check if the node has a brand field and the field has a value.
      if (!empty($node->field_brand)) {
        $brand_name = $node->field_brand->entity->name->value;

        $brand_class = Html::cleanCssIdentifier(strtolower($brand_name));
        $variables['attributes']['class'][] = 'brand-' . $brand_class;
        $variables['#attached']['library'][] = 'mytheme/brand_' . str_replace('-', '_', $brand_class);
      }
      break;
  }
}

If a brand is selected, we make a machine name from the taxonomy term name, then inject that as a class name for the node, and pull in a corresponding library in the theme. We can do whatever we want in the CSS and JS of that library to make the page look as desired.

But what about the editor experience? If we have implemented complex editing, especially with something like Layout Paragraphs, the editor will see previews of their content. Ideally, the previewed paragraphs will reflect the style that the page will later present to viewers.

One way we can tackle this is to repeat the library injection logic when on the edit page. A nice home for this is a field widget.

Making custom field formatters and widgets is easy, especially when you are just adding a small twist on something existing. In this case, we want to use the stock OptionsSelectWidget, but add code that will look at the selected value and attach the correct library.

First, we need to extend the base class, and give it our own custom annotation.

/**
 * Widget for selecting brands, while injecting associated styles.
 *
 * @FieldWidget(
 *   id = "brand_select",
 *   label = @Translation("Brand select"),
 *   field_types = {
 *     "entity_reference",
 *   },
 *   multiple_values = TRUE
 * )
 */
class BrandSelect extends OptionsSelectWidget {

It's important to get the field types right here, so that the widget shows up in the options. Speaking of which, we don't want to pollute the widget options for all entity reference fields, so let's restrict it:

  /**
   * {@inheritdoc}
   */
  public static function isApplicable(FieldDefinitionInterface $field_definition) {
    // By default, widgets are available for all fields.
    return $field_definition->getName() == 'field_brand';
  }

The isApplicable() method, if defined, should return TRUE whenever this widget could be used for the field. It's still up to the administrator to choose that widget when they want it. But this way, we don't see our widget every time we make an entity reference field.

We're going to need the Entity Type Manager later, so we can look up a taxonomy term by ID. So let's use dependency injection to grab it when the plugin is constructed.

  /**
   * The entity type manager service.
   */
  protected EntityTypeManagerInterface $entityTypeManager;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $plugin = parent::create($container, $configuration, $plugin_id, $plugin_definition);

    $plugin->entityTypeManager = $container->get('entity_type.manager');

    return $plugin;
  }

This simple pattern works because field formatters implement ContainerFactoryPluginInterface.

Finally, we need to do the actual work of injecting the right library.

  /**
   * {@inheritdoc}
   */
  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
    $element = parent::formElement($items, $delta, $element, $form, $form_state);

    if (!empty($items->first()->getValue())) {
      $selected_brand_id = $items->first()->getValue()['target_id'];
      $brand = $this->entityTypeManager->getStorage('taxonomy_term')->load($selected_brand_id);
      $brand_name = $brand->name->value;

      $brand_class = Html::cleanCssIdentifier(strtolower($brand_name));
      $form['#attributes']['class'][] = 'brand-' . $brand_class;
      $element['#attached']['library'][] = 'mytheme/brand_' . str_replace("-", "_", $brand_class);
    }

    return $element;
  }

 

Next Steps

So that's it! At least as a minimum viable product, anyway. The big problem with this solution is that in order to preview styles correctly, the node will need to be saved with the brand selected. If the brand is saved and then the node is edited again, everything is hunky-dory, but changes to the brand don't update "live."

How could we fix this?

Well, it's complicated. If it were just a matter of changing a class name, the easiest approach would be to write a JS library that monitors for the widget's field changing and updates the class name on that event. That library could be attached right in our field formatter.

But here we are doing more; we are injecting a whole library, which could be quite complex. It might have a bunch of CSS and even JS rules we need to honor. Adding new CSS and JS isn't too bad—we'd probably construct an Ajax endpoint that adds the new library in its payload—but removing the old is another matter.

So, this aspect is an unresolved problem in the general case, even though it can be solved well in narrow ones. The work continues!