Adding a Custom Block to Drupal 8's Fluid UI Module

The Fluid UI module for Drupal 8 is a great way to improve the accessibility and 508/ADA compliance of a website. The one problem I have with the module is that it renders it's "accessibility block" outside of Drupal's block system via hook_page_top(). What if you wanted to treat the block like any other? What if you wanted to assign it to one of Drupal's block zones?

Full disclosure -- I'm not a PHP developer. In all honesty, I am a front end dev who works mostly in SASS/CSS with the occasional foray into JavaScript and jQuery. So, I'll admit to starting this project with my feet firmly planted into the deep end of the PHP pool; I'm not really tall enough to keep my head above water. PHP, for me, is less about swimming and more about not drowning. At the same time, any chance to dig into the guts of Drupal and get my hands dirty with code is generally something I welcome. With so much contributed code, it can often feel like Drupal (or rather, the sum of its contributors) is calling the shots. Every now and then, it just feels good to grab the steering wheel--even if you're likely to run well off the road.

Where we Begin

Fluid UI is a module used to improve the accessibility (508/ADA/WCAG compliance) of Drupal sites. It uses hook_page_top() to render it's accessibility options. There may be accessibility reasons for having these options occupy a space outside Drupal's block regions; logically speaking, these controls should be in a place where users can easily recognize and access their features before interacting with a page. Let's leave aside the question of whether rendering the tools as a traditional Drupal block poses issues for compliance (if federal regulations are a concern, you should be auditing your site either way). Instead, let's focus on expanding the flexibility of the module. Rendering it within Drupal's block regions would gain us a little more wiggle room in terms of user experience. So, here's my stated goal: "let's disable the accessibility options rendered by hook_page_top() and render them instead within a traditional Drupal block." I'm working with Drupal 8.91 and the Fluid UI version is 8.x-1.7.

Opening the Hood

The first step in this process will be to have a peek under the hood of the module to see what's going on. I'm gonna throw some assumptions out on the table before I actually crack the module open, though:

  1. You don't need to know how a CPU works in order to physically relocate it; you just need to make sure it reconnects to all the necessary parts once you move it. Likewise, it may not be necessary to understand every single aspect of a module in order to relocate its function to a block.
  2. Failure IS an option: use Git or some other version control system to rewind when everything goes down the toilet.

I won't bother with copying the entire fluidui.module file into this post (this file can be found in the module root folder). Let's look at top level functions only. Heavily abbreviated, here's what we have:


/**
* Implements template_preprocess_page().
*/
function fluidui_preprocess_page(&$variables) {...}

/**
* Implements template_preprocess_html().
*/
function fluidui_preprocess_html(&$variables) {...}

/**
* Implements hook_page_top().
*/
function fluidui_page_top(array &$page_top) {...}

/**
* Implements hook_theme().
*/
function fluidui_theme() {...}

/**
* Implements hook_library_info_alter().
*/
function fluidui_library_info_alter(&$libraries, $extension) {...}

I've already given away that hook_page_top() is the function responsible for rendering the module's accessibility options, but let's take a quick look at the rest of these functions, just so we have a general idea of what's going on.

  • template_preprocess_page() is mainly going to handle meta information like node path, node alias, admin vs. anonymous, configuration settings for the module, and which nodes have been added to the module's blacklist (i.e., which pages not to show the options on).  This is all stuff we'll need to hold on to.
  • template_preprocess_html() looks to handle the Fluid UI cookie; the module will use a cookie to remember user settings. The module needs to read that cookie and render HTML accordingly. We need to keep this as well.
  • hook_theme() looks like it has to do with rendering the theme for the Fluid UI module. Not 100% sure on this one, but I can tell by the syntax it's not actually responsible for rendering the accessibility options themselves. Probably a keeper.
  • hook_library_info_alter() is usually used to modify library info; here it looks like it's mostly used to verify that the library exists. Fluid UI makes use of the Infusion JS Framework. If you wanted, you could install the Framework manually, giving you control of which library you use--I think. This is probably why we have this hook: to make sure the library is actually there.

Now that that's out of the way, let's have a look at the hook that's responsible for actually rendering the modules functionality (the accessibility options): hook_page_top(). Again, this is from the fluidui.module file and should look something like this:


function fluidui_page_top(array &$page_top) {
  $route = \Drupal::routeMatch()->getRouteObject();

  $is_admin = \Drupal::service('router.admin_context')->isAdminRoute($route);

  $current_path = \Drupal::service('path.current')->getPath();

  $url_alias = \Drupal::service('path.alias_manager')->getAliasByPath($current_path);

  $config = \Drupal::config("fluidui.adminsettings");

  $admin_display = $config->get('admin_display');

  $url_blacklist = explode("\r\n", $config->get('url_blacklist'));

  //the url blacklist takes precedence
  if (in_array($current_path, $url_blacklist) || in_array($url_alias, $url_blacklist)){
    $variables['page']['#cache']['contexts'][] = 'fluidui';

    return;
  }

  if ($admin_display){
    $page_top['fluidui'] = [
      '#theme' => 'fluid_ui_block',
    ];
  } else {
    //Don't display the widget on the admin pages.
    if (!$is_admin) {
      $page_top['fluidui'] = [
        '#theme' => 'fluid_ui_block',
      ];
    }
  }
}

Interestingly, this hook is handling the same variables as template_preprocess_page(): all that meta information coming from the module and Drupal (i.e.; path, alias, blacklist, etc.). We'll want to pay particular attention to the $is_admin and $admin_display variables up at the top. The if/else statement near the bottom of the function is also of particular interest. That $page_top variable found in the if/else statement is what's rendering our accessibility controls. Let's see if we can further discern what's going on here.

The $admin_display variable is getting passed to this function from the Fluid UI admin configuration. That configuration is processed by a file in the module located at /docroot/modules/fluidui/src/Form/FluidConfigForm.php (fluidui is the root folder for the module; docroot is the root for Drupal); if you open it, you'll find the setting:


  public function buildForm(array $form, FormStateInterface $form_state){
    $config = $this->config('fluidui.adminsettings');

    $form['admin_display'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Display preferences toolbox on admin pages'),
      '#description' => $this->t('Check this option if you want the toolbox to be displayed on admin pages (such as /admin/content, /admin/structure, etc.)'),
      '#default_value' => $config->get('admin_display'),
    ];

    $form['url_blacklist'] = array(
      '#type' => 'textarea',
      '#title' => $this->t('Hide the toolbox on these pages'),
      '#description' => $this->t('Enter the list of pages where the toolbox will not be displayed. Specify pages by using their paths. Enter one path per line.'),
      '#default_value' => $config->get('url_blacklist'),   
    );

    return parent::buildForm($form, $form_state);
  }

$admin_display is a checkbox that administrators only check if they'd like accessibility options to be displayed on admin pages. This gives us a little context to understand that first if statement. If checked, the variable gets a value. The if statement returns as true and the block renders via hook_page_top() on admin pages. The else statement is then easy to understand as well. The $is_admin variable is coming from Drupal and holds the context of whether the user is an admin. The logic says, "if not an admin, render the accessibility options via hook_page_top()."

This is definitely the code responsible for rendering our controls. Now that we know that, we can see about playing with the module to create a block.

Creating the Block 

Now, there are any number of resources out there that will explain how to build a block as part of a custom module. Most assume that you are starting your module from scratch, however. The process is slightly different for when you already have an existing module (by slightly different, I guess I actually mean "simpler"). The trick is to update and add a few files. Let's start with the module's existing info.yml file. For this module, I'll want to add a line to the fluidui.info.yml file (found in the root of the module folder) declaring my block as a dependency:


name: Fluid UI - Infusion
description: Adds the FluidUI/Infusion libraries in Drupal 8.
type: module
package: 'infusion'
core: '8.x'
core_version_requirement: ^8 || ^9

# Information added by Drupal.org packaging script on 2020-04-11
version: '8.x-1.7'
project: 'fluidui'
datestamp: 1586625271
dependencies:
 - block

There... that was painless.

Moving along, we'll next need to create a block file within a very specific location relative to the module's root. Existing modules will generally have a "src" folder just below the root; we'll want to create a few directories within that folder. In the case of Fluid UI, those directories don't yet exist, but for other modules they may already be there. In our case, we're going to want to create our file in this exact path: fluidui/src/Plugin/Block/FluidBlock.php

Once that's done, we can open the file and get busy with code. The first thing we'll need to do is get some administrative business out of the way. We'll declare namesapce (in case we'd like to reference this code in another module) and make use of Drupal's BlockBase (basic block functions?). Next, we'll also register the block with Drupal. This is actually done in a section of the file that gets commented out. Don't let the fact that it's commented out fool you into thinking this isn't important. It will look something like this (note that ID needs follow the pattern "modulename_blockfilename"):


/**
 * @file
 * Contains \Drupal\fluidui\Plugin\Block\FluidBlock
 */

namespace Drupal\fluidui\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides the Block 'fluidblock'.
 *
 * @Block(
 *  id = "fluidui_FluidBlock",
 *  subject = @Translation("FluidBlock"),
 *  admin_label = @Translation("FluidBlock"),
 *  category = @Translation("Accessibility"),
 * )
 */

Pretty straight forward stuff so far.

In order to actually start building the block, we'll need to establish a class drawing on Drupal core's BlockBase functionality. Here, the class name will need to match our file name, again. This should do:


class FluidBlock extends BlockBase {...}

The question is: what do we put in it?

Abandon the backstroke and let's just focus on staying afloat. Our goal isn't to rebuild the functionality of the module; somebody already built it for us. Here's what we'll try: let's copy the entire contents of the fluidui.module file and locate it within that class. We know that most of those hooks are necessary for the function of the module. It's logical to assume we'd need them for the block as well. DISCLAIMER: in an ideal world, PHP let's us draw on all that code without having to reproduce it in our block--if PHP is capable of doing that, I don't know how; for now, we just copy and paste. The output is going to be really long, so I'll abbreviate the code by only including the top level functions below:


/**
 * @file
 * Contains \Drupal\fluidui\Plugin\Block\FluidBlock
 */

namespace Drupal\fluidui\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides the Block 'fluidblock'.
 *
 * @Block(
 *  id = "fluidui_FluidBlock",
 *  subject = @Translation("FluidBlock"),
 *  admin_label = @Translation("FluidBlock"),
 *  category = @Translation("Accessibility"),
 * )
 */

class FluidBlock extends BlockBase {
/**
 * Implements template_preprocess_page().
 */
function fluidui_preprocess_page(&$variables) {...}

/**
 * Implements template_preprocess_html().
 */
function fluidui_preprocess_html(&$variables) {...}

/**
 * Implements hook_page_top().
 * WE DON'T NEED OR WANT THIS BIT...
 */
/* function fluidui_page_top(array &$page_top) {...} */

/**
 * Implements hook_theme().
 */
function fluidui_theme() {...}

/**
 * Implements hook_library_info_alter().
 */
function fluidui_library_info_alter(&$libraries, $extension) {...}

}

Recall that we don't want the accessibility options to load via hook_page_top(). You can either delete that section or comment it out as I've done above. What that leaves is all the functionality and no mechanism for rendering the actual block. We'll need to use 'public function build()' in order to move forward. We saw that the original module code used $page_top as an array to render the block. Let's see if we can recycle that logic inside 'public function build()':


/* CODE FROM hook_page_top() LOOKED LIKE THIS */
if (!$is_admin){
    $page_top['fluidui'] = [
      '#theme' => 'fluid_ui_block',
    ];
  }

/* LET'S TRY THIS INSIDE OUR BLOCK CLASS: */
public function build() {
    $build['fluidui'] = [
      '#theme' => 'fluid_ui_block',
    ];
    return $build;
}

I chose to locate this bit of code just below the hook_theme() in my block file. Again, abbreviated, it should look like this:


/**
 * @file
 * Contains \Drupal\fluidui\Plugin\Block\FluidBlock
 */

namespace Drupal\fluidui\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides the Block 'fluidblock'.
 *
 * @Block(
 *  id = "fluidui_FluidBlock",
 *  subject = @Translation("FluidBlock"),
 *  admin_label = @Translation("FluidBlock"),
 *  category = @Translation("Accessibility"),
 * )
 */

class FluidBlock extends BlockBase {
/**
 * Implements template_preprocess_page().
 */
function fluidui_preprocess_page(&$variables) {...}

/**
 * Implements template_preprocess_html().
 */
function fluidui_preprocess_html(&$variables) {...}

/**
 * Implements hook_page_top().
 * WE DON'T NEED OR WANT THIS BIT...
 */
/* function fluidui_page_top(array &$page_top) {...} */

/**
 * Implements hook_theme().
 */
function fluidui_theme() {...}

/**
 * BUILD THE BLOCK!!!
 */
public function build() {
    $build['fluidui'] = [
      '#theme' => 'fluid_ui_block',
    ];
    return $build;
}

/**
 * Implements hook_library_info_alter().
 */
function fluidui_library_info_alter(&$libraries, $extension) {...}

}

Believe it or not... that won't work...

That is, it will get the block registering and rendering within Drupal's block zones; you just won't get any functionality out of it. This is, I think, because at this point the fluidui.module file is still rendering a second "block" via hook_page_top(). I haven't yet looked into why this would be an issue, but my guess is that it's something to do with the javascript handling the actual opening of the accessibility options. By commenting out the hook_page_top() section in the fluidui.module file you should find that the block renders and functions without flaw. You can now place that block within whatever block zone you feel is most appropriate.

SECOND DISCLAIMER: there may be much better implementations of this; sometimes you gotta take the win even if it's less than ideal.