Many components use JavaScript-enhanced functionality these days. This document explains how we recommend structuring your script file for maximum benefit.
A component script file should be named the same as its template name. This is our general naming convention. For example, for a component template button.twig
, you should create a script file named button.ts
.
Every component that has either styles or some JavaScript functionality should have a script file. This is because we import the component styles in the script.
There are a few globals available to help you in your work.
The gotoAndPlay
object keeps a bunch of static variables commonly used in components. It is always accessible via window.gotoAndPlay
.
assetsPath
. Path to inc
folder where all assets are kept. Useful when you need to load additional assets dynamically.version
. Current version of project.templatePath
. Path to theme directory or to root in styleguide.ajaxPath
. Path to the AJAX endpoint. Use for making AJAX requests.svgPath
. Path to SVG icon sprite. Useful when you need to dynamically render an icon in scripts.cookiePath
. Cookie path, absolute from root. Use for setting cookies for the current website path.cookieDomain
. Current site domain. Use for setting cookies for the current domain, together with cookiePath
.nonce
. Security hash to keep AJAX requests safe.loggedIn
. Boolean that tells if the current user on the page is logged in.headerOffset
. Header height, to offset some scroll positions etc from a fixed header.components
. Object keeping all our component classes.googleMapsApiKey
. Google Maps API key. Use for loading Google Maps properly.sitePath
. Full current site path.ajax
. Data that an AJAX page change requires to be set after the page change.We also have a few common utility classes that are collections of utility functions you can use in your components.
The Helpers
class is available for importing via import Helpers from '../helpers/helpers';
. It includes all kinds of helper functions. See more.
You should add your component method to Helpers instead of keeping it in your component when:
Read the Cookie utility docs here.
The Ajax
class is available for importing via import Ajax from '../ajax/ajax';
. It includes common functions needed when making AJAX requests. See more
Every script file should start with importing its dependencies. The dependencies should be grouped and ordered alphabetically for better readability. There should be an empty line between each group. The groups are:
External dependencies - external modules installed via npm. For example, import loadGoogleMapsApi from 'load-google-maps-api';
. You must import external dependencies directly from the module, not via deep relative imports from node_modules
.
Other components - those that are imported from parent directories. For example, import Component from '../component/component';
. All local components should be imported with relative paths.
Local relative child imports - if you have split some of the functionality to a separate file. For example, import AccordionItem from './accordion-item';
.
Other side-effect dependencies, like images or styles that need to be loaded through webpack. For example, import './button.scss';
.
If your component styles depend on another component’s styles in a way that the loading order of those styles is important, you can affect the order of styles in the bundle by side-effect importing the dependency component. For example, if
@modal
styles depend on@button
styles, you shouldimport '../button/button';
in your modal script file.
Functional logic should be defined as a class. The class should be the default export of your file.
Components that are instance-based (most components) should extend the Component
class that you can import via import Component from '../component/component';
.
Always presume that there can be multiple instances of a component on a single page. This means that all element queries and event handlers must be scoped to the instance.
The Component
class requires a static initSelector
field. All elements that match the selector provided in that field are automatically initialized with an instance of your class on page load. This should almost always be the block selector of your component. For example, if you specify static initSelector: string = '.test';
in export default class Test extends Component {
, all <div class="test" />
elements will have a Test
instance bound to it.
All instance-based component classes should have a constructor method. There is a single required parameter for the constructor - the target element. It should be passed to the super class via super(target);
. The target element is kept in an instance variable called element
, so you can always access it in the class via this.element
.
Example:
constructor(target: HTMLElement) {
super(target);
}
If your component needs to interact with some of its child elements, they should be queried and saved as instance variables in the constructor. These queries must always be scoped for the instance. For example, this.button = this.element.find('.test__button');
.
If the component needs additional configuration, a common practice is to use data attributes to pass that configuration. In your component, you should save the configuration to a settings
instance variable while providing reasonable defaults.
Example:
this.settings = $.extend({
lat: null,
lng: null,
}, this.element.data());
If your component needs to do additional setup (for example, bind event listeners), that should be split into a init
method. The init
method should be called only from the constructor.
When structuring and initializing your code, try to lazy-init as much as possible to affect page load as little as possible. For example, if your component needs to do/listen a bunch of stuff only when it’s in an open state or visible in the viewport, add checks for that.
As an example, a Modal component class should only be initialized when its opening trigger is clicked. To achieve that, you need to add a global click event listener that listens to clicks on elements that may open a modal. It is good practice to use a common attribute for these kinds of listeners, for example [data-js="open-modal"]
. The event handler should create a new instance and save it in the element’s data object.
Example:
$(document).on('click', '[data-js="open-modal"]', (event: JQuery.TriggeredEvent): void => {
event.preventDefault();
const trigger: JQuery<EventTarget> = $(event.currentTarget);
const element: JQuery = $(trigger.attr('href'));
// TODO: replace following with `Modal.create(element)` after GRZ-318 is merged.
const type: string = Component.getDataName(Modal.initSelector);
const dataAttr: string = type + 'Class';
if (element.data(dataAttr)) {
const instance: Modal = element.data(dataAttr);
instance.open();
} else {
const instance: Modal = new Modal(element[0], true);
element.data(dataAttr, instance);
}
});
There are two major types of event handlers:
Local event handlers, that are bound to elements of the component.
These should be bound to elements that are saved as instance variables in the constructor. They are cleaned up automatically when the element is removed from the DOM.
Global event handlers, that are bound to either document
or window
.
These must always be scoped to the current instance. They must be unbound in the destroy
method, since global event handlers are not cleaned up automatically if the component instance is removed. The destroy method is called automatically before an AJAX page change, for example - in order to clean up event handlers from the previous page.
Examples of these event handlers can be:
Keep in mind that all event handler methods should be saved as instance variables and bound with this
in the constructor. This is to make sure that:
this
in the handler method references the instance.import someExternalDependency from 'some-external-dependency';
import Component from '../component/component';
import './test.scss';
export default class Test extends Component {
static initSelector: string = '.test';
constructor(target: HTMLElement) {
super(target);
this.settings = $.extend({
lat: null,
lng: null,
}, this.element.data());
this.button = this.element.find('.test__button');
this.handleButtonClick = this.handleButtonClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleWindowResize = this.handleWindowResize.bind(this);
this.init();
}
init(): void {
this.button.on('click', this.handleButtonClick);
$(document).on('click', this.handleDocumentClick);
$(window).on('resize', this.handleWindowResize);
}
destroy(): void {
$(document).off('click', this.handleDocumentClick);
$(window).off('resize', this.handleWindowResize);
}
handleButtonClick(event): void {
event.preventDefault();
alert('button element was clicked!');
}
handleDocumentClick(event): void {
alert('document was clicked, do something!');
}
handleWindowResize(event): void {
alert('document was clicked, do something!');
}
}