Creating a Component Library
Not only is FAST great for creating individual components, but it also shines when creating libraries or systems of components. FAST Foundation exposes several powerful abstractions in order to enable library authors to provide ergonomic APIs to application authors. These APIs ensure that app authors who consume your library have a consistent way to configure, augment, and override various details of the components. For example:
- Custom element tag names are easily configured and disambiguated.
- Custom elements are explicitly registered to avoid tree-shaking.
- Templates, styles, default slotted content, and arbitrary component configurations are all configurable by application authors.
- Shadow DOM options are easily configurable.
All these configuration capabilities are designed to give application authors the flexibility necessary to align library components to application or business requirements. This section walks through the process of defining a component from a library authorβs perspective, detailing component, template, and style creation that supports the configuration capabilities described above.
Defining componentsβ
To opt into the features above, begin by extending your components from the FoundationElement
base class in @microsoft/fast-foundation
rather than FASTElement
. FoundationElement
is a light extension of FASTElement
that supports element configuration during component registration as well as DOM-scoped, overridable templates and styles. Here's an example of a basic Counter
component that inherits from FoundationElement
.
import { FoundationElement } from "@microsoft/fast-foundation";
import { attr } from "@microsoft/fast-element";
export class Counter extends FoundationElement {
@attr count = 0;
increment() {
this.count++;
}
}
One important detail to note is that we do not use the @customElement
decorator in this case. The @customElement
decorator forces immediate template and style resolution, and platform component element name registration. This is fine when building application components, or components not intended to be used as part of a library in another application. However, if used for library components, it prevents the consumer of the component from having the opportunity to customize the component in ways that might be critical for their app. FoundationElement
helps address this challenge.
Define Templates and Stylesβ
With our Counter
component class written, we now need to define its templates and styles. There are two ways to accomplish this. Just like FASTElement
, a component that extends FoundationElement
can be used with the html
and css
tagged template literals. So, we could define our template and styles in the same fashion as any other component, like this:
import { html, css } from "@microsoft/fast-element";
const counterStyles = css`/* ... */`;
const counterTemplate = html`<!-- ... -->`;
However, a FoundationElement
also supports a lazily-defined template and style type. This lazy option is a function invoked with ElementDefinitionContext and FoundationElementDefinition options, providing opportunity to adjust the template or styles based on how the element or other elements are defined. Lazy template and style definitions provide incredible flexibility and are what can allow application authors to re-name component tag names, override default slotted content, and communicate any component-specific configuration options. Here's how we would convert the previous standard templates and styles into lazy templates and styles:
import { html, css } from "@microsoft/fast-element";
import { ElementDefinitionContext, FoundationElementDefinition } from "@microsoft/fast-foundation";
const counterStyles = (
context: ElementDefinitionContext,
definition: FoundationElementDefinition
) => css`/* ... */`;
const counterTemplate = (
context: ElementDefinitionContext,
definition: FoundationElementDefinition
) => html`<!-- ... -->`;
Letβs take a closer look at what the two arguments to these lazily evaluated functions give us.
What is the ElementDefinitionContext
?β
The first argument is the ElementDefinitionContext
. This context object provides information that is available during the attempt to register the element. For example, it will tell you the HTML element tag name
that the component will be defined as. You can also see what the default shadowRootMode
of the DesignSystem
is. Most of the other APIs are used internally by FoundationElement.compose
(see below) to define the element, but one particular API of note is the tagFor()
method of the context. This API is used to inspect the tag-name of other components and allows usage of other library components in a template or stylesheet without knowing ahead of time what the tag-name for the element will be. Here's an example of how the reusable Counter
component would leverage this to ensure that the Button
's tag name is correct, even if the app developer decides to rename Button
in some way.
import { html, css } from "@microsoft/fast-element";
import { Button } from "@microsoft/fast-components";
import type { ElementDefinitionContext } from "@microsoft/fast-foundation";
const counterTemplate = (context: ElementDefinitionContext) => {
const buttonTag = context.tagFor(Button);
return html`
<div>The count is ${x => x.count}.</div>
<${buttonTag} @click=${x => x.increment()}>Count!</${buttonTag}>
`;
}
const counterStyles = (context: ElementDefinitionContext) => {
const buttonTag = context.tagFor(Button);
return css`
${buttonTag} {
margin-inline-start: 12px;
}
`;
}
What is the FoundationElementDefinition
?β
The FoundationElementDefinition
is the configuration for the element itself. This argument allows you to inspect the aggregated options configured for the component, giving you access to the shadowOptions
, elementOptions
, and more.
This definition can also be arbitrarily extended by library authors, allowing custom configuration to flow through the registration process. For example, this mechanism can facilitate default slotted content overrides. To see how our Counter
could leverage this, let's enable the Button
content to be overridden by the application developer. Here's what that would look like:
import { html } from "@microsoft/fast-element";
import { ElementDefinitionContext, FoundationElementDefinition } from "@microsoft/fast-foundation";
import { Button } from "@microsoft/fast-components";
// Extend the configuration with custom properties
interface CounterDefinition extends FoundationElementDefinition {
defaultButtonContent?: string;
}
const counterTemplate = (context: ElementDefinitionContext, definition: CounterDefinition) => {
const buttonTag = context.tagFor(Button);
return html`
<div>The count is ${x => x.count}.</div>
<${buttonTag} @click=${x => x.increment()}>
<slot>${definition.defaultButtonContent}</slot> <!--Use the custom configuration-->
</${buttonTag}>
`;
}
When the library author creates the registration function (see next section), they can provide the default content. Then, during component registration with the DesignSystem
, an application author can optionally provide a defaultButtonContent
field as a configuration to override this setting.
Compose and Export Registrationβ
The final step in the process is to create and export the registration function so that application authors can register the component in their DesignSystem
. Here's how we would create the registration function for Counter
, using our extended CounterDefinition
configuration:
export const counter = Counter.compose<CounterDefinition>({
baseName: 'counter',
template,
styles,
defaultButtonContent: "Count!"
});
Here we provide the base component name of counter
, which will be combined with the DesignSystem
element prefix during registration. We also provide our lazy template and styles. Finally, we specify the defaultButtonContent
value.
Registering Library Components in an Applicationβ
To register the component, an application author will import the registration and register it in their DesignSystem
, overriding any properties as necessary. For example, if this component had been created for the FAST Frame design system, then the following code could be used:
import { counter } from "your-package";
import { provideFASTDesignSystem, fastButton } from "@microsoft/fast-components";
provideFASTDesignSystem()
.register(
fastButton(),
counter({ defaultButtonContent: "Please count." })
);
The advantage of setting defaultButtonContent
here is that it overrides the Counter
's default slotted content for all instances of your-counter
. Instead of having to define it everywhere in the app that the button is used, the app author provides the configuration once in a centralized location. This can be extremely useful in scenarios where components contain default icons that the app author may need to swap.
Defining a design systemβ
The above examples show how to build a single component that can be flexibly registered with an existing design system. But, you can create your own design system as well. To do so, you would start with the base components defined in @microsoft/fast-foundation
and compose them with your own styles. You would also define your own DesignToken
s to be used within your styles. Finally, you would export a "provider function" for your community to use in setting everything up.
FAST design system thinkingβ
When creating a design system, it can be helpful to think in terms of layers and use cases.
- foundation components: A reusable set of component classes and templates, independent of any design system, and designed for maximum flexibility.
- design system: Leverages the flexibility of the foundation components, but adds the unique opinions of the design system (e.g. styles, element prefix, design tokens, theme colors, etc). It exports the component registration functions so that apps can decide what to use. This layer may also choose to "lock down" certain options so that the design system remains coherent when used.
- application: Registers only the components needed by the app itself.
- features: Only uses the components available in the app. Get access to design tokens to create feature-specific styled components in alignment with the design system.
Notice that the strength of the opinions increases as you proceed down the list, with each layer further constraining the system. When creating a design system, it's important to think through these use cases and consider how different scenarios are impacted.
Composing a foundation componentβ
Let's imagine that we want to add a Button
to our design system. We only need to import the @microsoft/fast-foundation
Button
component class and template, and then compose them with our design system's button styles:
import {
Button,
buttonTemplate as template,
} from "@microsoft/fast-foundation";
import { buttonStyles as styles } from "./special-button.styles";
export const specialButton = Button.compose({
baseName: "button",
template,
styles,
shadowOptions: {
delegatesFocus: true,
},
});
export const buttonStyles = styles;
As a practice, always be sure to export your styles independently as well. This enables the consumers of your components to build their own versions of your component, reusing and augmenting your styles as needed.
When authoring your own styles, you'll also want to leverage design tokens. Please see the design token documentation for how to create and use design tokens.
Creating a design system provider functionβ
For convenience, and to ensure consistent design system configuration, it's a good idea to provide a simple function that sets up your design system. If we wanted to create a function for the "special" design system, we would use the following code:
export function provideSpecialDesignSystem(element?: HTMLElement): DesignSystem {
return DesignSystem.getOrCreate(element).withPrefix("special");
}
The underlying DesignSystem.getOrCreate
API gets the design system directly attached to the provided element. If one does not exist, it creates one. By default, if no element is provided, the design system is created on the document.body
. Using this function abstracts those details and provides a much-improved set of ergonomics to those using your components, while also giving you the opportunity to bake in certain configuration yourself, such as defining the default element prefix.
Now consumers of your components can setup your design system in their application with the button component as follows:
provideSpecialDesignSystem()
.register(
specialButton()
);