Single File Components
This project allows developers to provide frontend components in a single file. These components contain Twig, CSS, JS, and PHP code which acts similar to a preprocess function.
The purpose of this module is to combine traditional Drupal theming with some elements of component based design.
Writing components
To define a component, create an .sfc
file in any module or theme's
components
directory. This file format is heavily inspired by Vue.js' .vue
files. An .sfc
file looks like a HTML/PHP file, here's what one may look
like:
<style>
.big-text {
font-size: 100px;
}
</style>
<template>
<div class="big-text">{{ text }}</div>
</template>
This will be parsed into a component plugin that can be used like any other
twig template. Note that the filename determines the ID for the component, so a
file named big_text.sfc
could be included as a template with:
{% include "sfc--big-text.html.twig" with {"text": "This is big!"} %}
Here are all the things you can provide from a *.sfc
component:
<style>/* Styles go here. */</style>
<script>/* Full-page JS goes here. */</script>
<script data-type="attach">/* JS for your component goes here. */</script>
<script data-type="detach">/* JS for your component goes here. */</script>
<template>{# Twig goes here. #}</template>
<?php
$prepareContext = function (&$context) {} // Prepare context for your template.
$selector = ''; // Selector for attach/detach. Defaults to [data-sfc-id="pluginid"].
$dependencies = []; // Library dependencies.
$definition = []; // Additions to the derived plugin definition.
$library = []; // A library definition if your CSS/JS are in different files.
// Form methods for this component.
$buildContextForm = function (array $form, FormStateInterface $form_state, array $default_values = []) {}
$validateContextForm = function (array $form, FormStateInterface $form_state) {}
$submitContextForm = function (array $form, FormStateInterface $form_state) {}
// Actions for this component.
$actions[...] = function () {}
Using your component
Components can be included or extended in Twig templates like any other
template. The name of your component's template is sfc--plugin-id.html.twig
,
where underscores in your plugin ID are converted to dashes. For example, the
"say_hello" plugin could be included like:
{% include "sfc--say-hello.html.twig" %}
Or to pass data, like:
{% extend "sfc--say-hello.html.twig" with {'name': 'Sam'} %}
You can also use components in render arrays with the sfc
element:
$build['say_hello'] = [
'#type' => 'sfc',
'#component_id' => 'say_hello',
'#context' => [
'name' => 'Sam',
],
];
The sfc
element is just a nice wrapper around the inline_template
element.
Aliasing component templates
Components can also provide aliases to make it easier to include them. Aliases are defined in the component definition using the key "aliases".
Here's what this looks like:
<?php
$definition['aliases'] = [
'fancy/button',
'fancy-button',
];
Overriding theme hooks
While some components are abstracted from their use case, sometimes components have a closer relationship with Drupal theme hooks.
In these cases, you can define the "overrides" key in the component definition, which lists theme hooks that this component will override.
For example, this would let a .sfc
component take over the template for
articles:
<?php
$definition['overrides'] = [
'node__article',
];
Typically, the theme hook is the normal template name with dashes converted to
underscores. For example, the theme hook for field--node--created.html.twig
is field__node__created
.
Notes on CSS and libraries
When that component is rendered, the template will be used to render HTML, and
CSS and JS will be written to temporary files which are included like any
traditional Drupal library. Relative url()
rules in CSS will be prefixed with
the providing module's path.
If your component has a library dependency, you can set $dependencies
to an
array of library names.
If you prefer to have CSS/JS in a separate file, there are two options:
- Create a
component_id.css
orcomponent_id.js
file in the same directory as yourcomponent_id.sfc
file. These will be auto-included in a library definition and loaded when your component renders.
Some library dependencies will be included based on the contents of your JS
file, for instance if jQuery
is found, core/jquery
will be added as a
dependency. Additional dependencies can be set by setting
$library['dependencies']
in the PHP section of yoru .sfc
file.
See modules/sfc_example/example_local_assets.sfc/css/js for an example.
- Set
$library
to an array that represents a Drupal library. This library will be included automatically where your template is used, so there's no need to use{{ attach_library() }}
.
Any and all combinations of defining libraries and CSS/JS should work.
Quickly defining behavior attachments
In Drupal, JavaScript that binds behavior to elements is supposed to do so inside a behavior attachment, using the context passed to it, and is then even expected to use the once plugin to prevent common problems like double binding event handlers.
This can become quite monotonous, so as an alternative to using <script>
to
define global JS, you can instead provide <script data-type"attach">
and
<script data-type="detach">
, which contain JS that is ran with this
bound
to the element defined with $selector
.
Note that $selector
defaults to [data-sfc-id="<plugin_id>"]
, so you can add
that attribute to an element in your Twig template to have it be selected.
Here's an example way to use them:
<template>
<button data-sfc-id="click_counter">Clicked 0 times</button>
</template>
<script data-type="attach">
var count = 0;
$(this).on('click', function () {
$(this).text('Clicked ' + ++count + ' times');
});
</script>
The above will result in an output file that looks something like this:
(function ($, Drupal, drupalSettings) {
Drupal.behaviors.sfc_click_counter = {
attach: function attach(context, settings) {
$('[data-sfc-id="click_counter"]', context).once('sfcAttach').each(function () {
var count = 0;
$(this).on('click', function () {
$(this).text('Clicked ' + ++count + ' times');
});
});
},
}
})(jQuery, Drupal, drupalSettings);
If you want to avoid writing out data-sfc-id="..."
in all your components,
you can use {{ sfc_attributes }}
instead. This includes that data attribute
as well as data-sfc-unique-id
, which is a string that is unique to this
render of the component.
Drupal Core has recently started moving away from jQuery, so if you'd like to use vanilla JS with your attachments you can add the "data-vanilla" attribute to your script tag. Note that when using vanilla JS, "element" refers to your current element, not "this". For example:
<template>
<button data-sfc-id="click_counter">Clicked 0 times</button>
</template>
<script data-type="attach" data-vanilla>
var count = 0;
element.onclick = function () {
this.innerText = 'Clicked ' + ++count + ' times';
};
</script>
Adding backend behavior with actions
If your component needs to take some action in the backend - for example submit a form or make an AJAX call, you can use actions.
Actions are callbacks that function like Controllers, returning either a Symfony response or a render array, and are very useful for small pieces of functionality.
To define an action, set $actions['action_name']
in your *.sfc
file to a
function. Your Twig template will then have sfc_actions.action_name
in its
context, which is a URL to your action. You can use this URL for form
actions, AJAX links (<a href="..." class="use-ajax">
), or however else you
would normally use a link to a custom Controller in Drupal.
Here's an example where a component takes in a form submit and reflects input back to the user:
<template>
<form id="example-actions-form" action="{{ sfc_actions.submit }}" method="post">
<label for="example-actions-text">Text</label>
<input type="text" name="text" id="example-actions-text" required />
<input type="submit" value="Submit" />
</form>
</template>
<?php
use Symfony\Component\HttpFoundation\Request;
$actions['submit'] = function (Request $request) {
return [
'#plain_text' => 'You submitted: ' . $request->request->get('text', ''),
];
};
You may notice this action takes in the current request as an argument - see the "Autowiring" section below for details on how that works.
In a real implementation, "submit" would likely write to the database and perform a redirect to another page. Action URLs aren't the most user-friendly thing to see. For more examples, see the sfc_example module.
Action security
All action URLs require CSRF tokens, which require users to have a session. If
you want to let anonymous users perform actions, you will need to start a
session for them. The ComponentController
supports this if you pass
anon_session: 'TRUE'
in the defaults
section of your route.
Beyond all that, you are responsible for performing access checking in your action functions.
Making AJAX easier to handle
Action URLs also come with the query param sfc_unique_id
, which is useful
for AJAX callbacks if you are also using {{ sfc_attributes }}
in your
template. In your callback you can do something like:
use Drupal\Core\Ajax\AjaxResponse;
use Symfony\Component\HttpFoundation\Request;
$actions['do_ajax'] = function (Request $request) {
$unique_id = (string) $request->query->get('sfc_unique_id', '');
$selector = '[data-sfc-unique-id="' . $unique_id . '"]';
$response = new AjaxResponse();
// Add commands that target $selector here...
return $response;
};
And know that you are targeting the correct instance of this component.
Autowiring / dependency injection in callbacks
Any callback in a *.sfc
file can take advantage of dependency injection
instead of calling \Drupal::service()
. To do so, add an argument with the
class or interface you want to have access to, and the plugin deriver will do
all the work to figure out what service best matches that argument.
For example:
use Drupal\Core\Session\AccountProxyInterface;
$prepareContext = function (array &$context, AccountProxyInterface $current_user) {
$context['user_id'] = $current_user->id();
}
Would inject the current_user
service as the second argument. Some services
implement the same interface or have the same class. In those cases, if you
match the ID of the service exactly, replacing .
with _
, the correct
service will be injected. For example, \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyvalue
will match the keyvalue
service even though many other services implement
that interface.
Component caching
Cache metadata for components is collected/bubbled when their template is rendered. SFC provides two features to make caching easier.
Inside your template, you can pass cache tags, cacheable objects, or arrays of
cache tags and cacheable objects to the sfc_cache
function.
For example, if you were rendering a node title, you may write:
{{ node.label() }}
{{ sfc_cache(node) }}
Or for an array of nodes:
{% for node in nodes %}
...
{% endif %}
{{ sfc_cache(nodes) }}
{{ sfc_cache('node_list') }}
To add cache contexts, pass "contexts" as the second arg to sfc_cache
. To set
a max age, pass "max-age".
Inside of your prepareContext
callback, you can also set the cache
Twig
context variable, which is automatically rendered in the compiled template
if present.
For example, to add a cache context to a component, you could write:
public function prepareContext(array &$context) {
$context['cache']['#cache']['contexts'][] = 'user';
}
Although it may be easier to add cache metadata to a render array already
passed in &$context
, since some caching is conditional.
Using components as blocks, layouts, and more
Components can quickly provide a block and/or layout plugin by adding special
configuration to their plugin definition, provided by $definition
.
For blocks, add the block
key, which should contain an array of block plugin
definition values you need. For layouts, use the layout
key, for field
formatters, use the field_formatter
key.
Here's an example of providing a block from a component:
<template>
Hello block!
</template>
<?php
$definition['block']['admin_label'] = 'Say hello';
Components can also implement form methods to be used in plugin configuration forms, or in the sfc_dev component library.
Note that the values from your form will be passed as context to your template, so be sure to properly validate those values and treat them as untrusted input.
Notes for field formatters
When used as a field formatter, the validateContextForm
and
submitContextForm
functions will not be called. Normally your component will
be rendered for each item in the field, with field item properties passed as
context to keep your component agnostic to how it's used.
If your component is meant to handle a single item, your template will receive
item
(of type \Drupal\Core\Field\FieldItemInterface
) and langcode
as
context.
If your component is meant to handle multiple items, like a slider or a list,
add $definition["field_formatter"]["sfc_multiple"] = TRUE
. Then your template
will receive items
(of type \Drupal\Core\Field\FieldItemListInterface
) and
langcode
as context.
Notes for layouts
When used as a layout, make sure you use the attributes
and
region_attributes
variables in your template, to stay compatible with modules
like Layout Builder.
Extending components
Themes can extend components by:
Overriding the Twig template
By creating a Twig template file in your templates/
directory with the same
name as what you use to include your component, ex: sfc--say-hello.html.twig
,
you can override its Twig template.
If you override a component's template, you will probably need to prepend your template with the following code:
{{ sfc_prepare_context('plugin_id') }}
{{ attach_library('sfc/component.plugin_id') }}
Where "plugin_id" is the original plugin ID for the component you're overriding. This code is normally added automatically for convenience sake.
Note that due to core's Twig theme engine, you can only override components
that are included in the format sfc--plugin-id.html.twig
. Components included
without the .html.twig
extension or using an alias will not be recognized.
Overriding CSS/JS
Using the libraries-override
key in your theme's .info.yml
file, you can
override any component's CSS or JS. The library name format for a component is
sfc/component.plugin_id
.
Modules can extend components by:
Extending the class
Since single file components are PHP classes, you can extend them and override any changes you'd like. If you use the same plugin ID as an existing component, the behavior is unclear but your class may take precedence. See this issue for details.
Changing information for existing components
To change the plugin ID or class you can use the plugin alter hook, which is
hook_single_file_component_alter
. Here's an example of changing a component's
class:
<?php
function my_module_single_file_component_alter(&$info) {
$info['say_hello']['class'] = 'Drupal\my_module\ScreamHello';
}
Writing a component class
Single File Components are plugins, and can also be defined by creating a new
file in your module's src/Plugin/SingleFileComponent
directory that contains
a class that extends \Drupal\sfc\ComponentBase or otherwise implements
\Drupal\sfc\ComponentInterface.
A simple component may look like this:
<?php
namespace Drupal\modulename\Plugin\SingleFileComponent;
use Drupal\sfc\ComponentBase;
/**
* Contains an example single file component.
*
* @SingleFileComponent(
* id = "say_hello",
* )
*/
class SayHello extends ComponentBase {
const TEMPLATE = <<<TWIG
<p class="say-hello">Hello {{ name }}!</p>
TWIG;
const CSS = <<<CSS
.say-hello {
color: pink;
}
CSS;
const JS = <<<JS
console.log('hi');
JS;
/**
* {@inheritdoc}
*/
public function prepareContext(array &$context) {
if (!isset($context['name'])) {
$context['name'] = \Drupal::currentUser()->getDisplayName();
}
}
}
Classes may look a little ugly, but are useful for components with a lot of PHP.
Defining inline assets that need a compilation step
By default, single file components provide vanilla CSS/JS exactly as they'll appear in the browser, but you may prefer to use Saas or modern/modular JS instead.
In this case, you should put .sfc
files that need a compilation step into an
alternate directory like components_src
, and use a task runner like Gulp to
replace inline Sass/ES6/etc. with the compiled version.
This is less complicated than you may think, and the sfc_example module provides an example component and gulpfile.js configuration that minimally compiles inline assets using Babel and node-sass.
Alternatively, someone clever could make a module that provides a base component that uses scssphp to compile Saas to CSS as its requested. This may have performance implications but could be cool!
Development tips
Using the sfc_dev sub-module
To help with debugging and testing components, you can enable the sfc_dev
sub-module. Currently the main feature it provides is a component library,
which is accessible at /sfc/library
if you have the use sfc dev
permission.
If you want to filter down to a specific component group, you can visit
/sfc/library/{group}
, where {group}
is a case-sensitive search string.
Since this user interface allows you to enter Twig, the use sfc dev
permission should only be given to trusted users.
Disabling cache
As you write CSS, JS, and Twig, you may grow tired of rebuilding cache. To
disable the caching used by this module, add this code to your site's
services.yml
file:
parameters:
twig.config:
debug: true
and this code to your site's settings.php
file:
$settings['cache']['bins']['data'] = 'cache.backend.null';
This should add debugging output to your component's HTML, and disable caching so that you can just reload your browser to see new changes.
Watching for component asset changes
As an alternative to disabling the data cache bin, you can run the
drush sfc:watch
command to watch for changes in all available components and
automatically write assets, or drush sfc:write <plugin id>
to target a
specific plugin.
If you want to automatically refresh the page when sfc:watch
writes a
component's assets, you can add this setting to settings.php:
$settings['sfc_watch_refresh'] = TRUE;
and rebuild cache. This will add a naive bit of JavaScript to each page that checks for the last time any component was written and refresh if it's out of date. Do not enable this in production.
Testing components
Since single file components are PHP classes, they can be unit and integration tested.
If you're writing kernel tests, you can use the \Drupal\Tests\sfc\Kernel\ComponentTestTrait trait which adds helper methods for rendering components.
For examples of integration testing and mocking, see \Drupal\Tests\sfc_example\Kernel\ExampleClassTest.
If you're writing functional javascript tests, you can use the \Drupal\Tests\sfc\Functional\FunctionalComponentTestTrait trait which adds a helper method that makes visiting a page with your component on it very easy.
For examples of javascript testing, see \Drupal\Tests\sfc_example\FunctionalJavascript\ExampleJsTest, or for Nightwatch see modules/sfc_example/tests/src/Nightwatch/Tests/exampleTest.js. With Nightwatch it should be hypothetically possible to also run accessibility tests but for now that's hypothetical.