Headless Components

Instead of the classic approach of offering taylor-made and fully functional and stylish components, fritz2 takes a different approach: So-called headless components.

These basically represent a modular system, that empowers the user to easily build user interfaces supporting typical functionalities. These include, for example, functions such as multiple or also single selection of elements from a given list, modal dialogs, pop-up windows, input fields and much more.

The pure functionality of such elements, in particular the user interaction and the corresponding data handling, such as navigating within a selection list or clicking a button with the mouse, is controlled and provided encapsulated by the headless components. The user only has to worry about the pure display aspects, i.e. the structure of the required tags and the styling.

For background information on why we think headless components are the optimal way to go for fritz2 users, we refer to a detailed one blog post.

Note: for the styling information we use in the examples the tailwindcss framework. Since fritz2 is agnostic about the styling information, you can of course use other frameworks such as Bootstrap or simply some plain CSS.

Setup

In order to use headless components within your project, just replace the dependency of the fritz2 core with headless in the commonMain source-set of your build.gradle.kts file:

kotlin {
// ...
sourceSets {
val commonMain by getting {
dependencies {
// add dependency to headless always in commonMain section!
implementation("dev.fritz2:headless:$fritz2Version")
}
}
// ...
}
}

If you want to use fritz2 together with tailwindcss for the styling, clone our tailwind specific template from GitHub.

Structure of a Component

Each component is created using a factory function. In rare cases there are also variants of factory functions (e.g. with switch). The word "component" therefore always designates in the following sections the associated factory function at the same time and vice versa.

Components, in turn, consist of other building blocks, called "bricks", that are often more deeply nested. These are carried out analogously by factory functions too.

All components and many building blocks have their own scope in which the user haa to make some specific configuration in order to use the functionality of a component. For such a configuration there are essentially three different concepts: simple var fields (hereinafter also called "simple configuration"), Propertys and Hooks. All three allow the user to adapt the structure, the behavior and generally the function of a component to the context in a meaningful way. The different concepts of configuration are discussed in detail in a dedicated section.

Almost without exception, all factories (of components and bricks) always generate a Tag. The user has therefore access to all attributes of the generated tag, such as className or attr!

someComponent(/* params */) {
// Scope of `someComponent` == some `Tag` + specific extra props (`initialize`-Parameter)

someBrick(/* params */) {
// Scope of `SomeBrick` == some `Tag` + specific extra props + props from outer Scope! (`initialize`-Parameter)
}
}

Blocks always have the distinctive part or the full name of the component to which they belong as a prefix, e.g. radioGroupLabel for a label within the radioGroup component.

Almost all factory functions of components and building blocks have the same signature, that resembles the one of a tag by intention:

fun <C : HTMLElement> RenderContext.someComponent(
classes: String? = null, // modify the styling
id: String? = null, // set an explicit ID; will be autogenerated in cases where this is needed
scope: (ScopeContext.() -> Unit) = {}, // set some key-value-pairs into the scope
tag: TagFactory<Tag<C>>, // provide a factory to create some `Tag<C>` (there is *always* an overloaded function with some default `Tag`)
initialize: SomeComponent<C>.() -> Unit // the builder function, that enables the component designer to define the content and access the scope of a component / brick
): Tag<C>

In the API sections of the components, these standard parameters are only listed as a short, untyped list. Blocks often lack the option of explicitly setting an ID.

More important, however, are additional parameters that one or the other brick requires. So they are described with emphasize and thus easy to recognize. In most cases, these are also mandatory parameters.

API-Description

Since each component consists of different bricks and these in turn have some fields with Hooks, Propertys or simple var types, a strongly abstract overview is offered to show the big picture. This focuses on the special headless aspects and also indicates helpful patterns in comments. Standard parameters or fields of the default tags, on the other hand, are omitted.

The following example should introduce the schema. For clarification, the structures are additionally commented here; the real component documentation lacks this meta information.

// component factory
someComponent() {
// fields
val value: DatabindingProperty<Int>
var visibleItems: Int

// bricks
someBrick() { }
someOtherBrick() {
// own scope with further fields and bricks
val msgs: Flow<List<Messages>>
}
// Suggestion of a relevant pattern:
// for each item {
someRepeatingBrick(item: T) {
// ^^^^^^^
// mandatory additional parameter!

// own field
val selected: Flow<Boolean>
}
// }

}

UI = Headless + Tags + Styling

In order to create a functioning UI from the headless components and their building blocks, the given Factory functions can be nested and combined with other Tags in such a way that the desired overall structure is created. In addition, the appearance must be defined by adding styling.

The headless components and modules offer the following starting points in order to specify the function and representation of a component:

  • Use of a specific brick
  • Determine the tag to be generated in factory functions
  • Setting styling specifications in factory functions
  • Configuration in the scope of the component or block

Use of a specific Brick

Bricks often have a functionality in themselves, which is achieved solely through their use within a component. Examples of this are the various labels, which usually set certain ARIA attributes or trigger established functions.

A good example of this are the labels on the text components InputField and TextArea. If these are used, a mouse click on these elements automatically focuses the associated input field.

This function is available without any further efforts as soon as the corresponding Label brick is called inside in the Scope of the component.

Other examples can be found in the various *Toggle blocks (e.g. checkboxGroupOptionToggle), which automatically select or deselect items based on defined user inputs. The user need do nothing more than applying these bricks accordingly in his component.

Determine the Tag to be generated in Factory Functions

The headless components and their bricks always offer a good default type for the Tag to be generated. In order to achieve the greatest possible flexibility, the user is free to choose the type of tag himself.

In this way, he can create the tag that is semantically appropriate for the context.

Again, the Label attributes can serve as an example. These mostly actually generate HTML label-tags. However, simply specifying the tag parameter is enough to override this behavior:

inputField() {
inputFieldLabel(tag = RenderContext::span) { // Scope of `HTMLSpanElement`
+"Some Label Text"
}
}

Setting Styling Specifications in Factory Functions

Almost every factory function accepts a classes parameter, in order to set arbitrary CSS classes to the created Tag. This fine-grained control over the styling for nearly every brick, allows the user to easily shape the desired visual result.

inputField() {
inputFieldLabel("text-indigo-500 p-8 mh-4") {
// ^^^^^^^^^^^^^^^^^^^^^^^^
// Some Styling, almost always first parameter
+"Some Label Text"
}
}

Configuration in the Scope of the Component or Block

As the last and also extremely powerful aspect, the components and bricks provide accessible fields ready for the user, through which he can or even has to carry out the configuration.

As a reminder, the three common configuration concepts are simple configuration via public var fields, Propertys and Hooks.

These configuration properties have often direct impact on the inner state of the component. This is especially true for the (two-way) data-binding aspects lots of components offer. This includes, for example, information about the selection of the selection components (RadioGroup, CheckboxGroup or ListBox), but also the input values for text field components (InputField and TextArea).

In addition, there are often certain 'flows' and 'handlers', via which special states (selected, disabled, focused or open) or the behavior in the event of changes (user clicks on a close button) can define. Those are most of the time derived from the data-binding, so the former is some very important and powerful aspect.

Typical patterns are the dynamic setting of CSS classes depending on a specific state via className or the complete creation or deletion of DOM structures:

checkboxGroup {
// special data-binding property for the selection management.
// component will automatically use the current selections and also set or remove those by user interaction.
value(someStore)

checkboxGroupOption(option) {
// offers `selected: Flow<Boolean>` property -> style checked and unchecked options differently
className(selected.map {
if (it) "ring-2 ring-indigo-500 border-transparent"
else "border-gray-300"
})

// conditionally modify a whole sub-structure: show an Icon only if option is selected
selected.render {
if (it) {
svg("h-5 w-5 text-indigo-600") {
content(HeroIcons.check_circle)
fill("currentColor")
}
}
}
}
}

Basic concepts for configuration

Headless components and modules often require similar mechanisms, regardless of the specific form, how they require and maintain certain data input from a user. Beyond pure data management, the user has to be able to directly modify the rendering or pass customized behaviour into the component or the brick.

There are two basic concepts for this, which will be presented in more detail below:

  • Properties
  • Hooks

Since the data binding is particularly important, this special implementation of a property is explained in a dedicated section too.

Properties

A Property serves as a container for data used by a component or brick for the fulfillment of its relevant task and can or must be configured externally by the user. The property is always created by the respective headless instance and exposed to the user within its scope.

In order to tailor this public API as appropriately as possible, the concept proposes to create tailored invoke methods. Their parameter lists should reflect the need of the component or brick's functionality, but also be tailored to the typical kind of data types of the user's context. This is easily possible by providing different invoke methods for different data types.

class UserProperty : Property<User>() {
// only visible "modifying" API for the client, "hide" the complex type by offering the parameters directly!
operator fun invoke(name: String, alias: String, mail: String) {
value = User(name, alias, mail)
}

// perhaps some other `invoke`s with convenience parameters!
operator fun invoke(ldapData: LdapPrincipal) {
value = User(/* extract the relevant parameters from `LdapPrincipal` instance */)
}
}

// set the data within some headless-component:
someComponentOrBrick {
user("Christian", "Chris", "chris@fritz2.org")
}

A property should be used whenever it is clear from the context that the required data emerge from different types. A suitable invoke function should then be provided for each type, so that a comfortable API for the user arises.

The component itself can then solve the following tasks elegantly thanks to the Property interface:

  • Check with isSet if a value was set. This is important in order to be able to fall back on a default value if necessary.
  • Pass the managed data to another property instance via use(item: T). This is crucial for use cases, where the headless component is encapsulated in some component alike container, which itself exposes this data as public API. This data has to be forwarded to the underlying headless component or brick.
class SomeSpecificComponent {

// create property instance, so external user can provide user data
val user = UserProperty()

fun render() {
someHeadlessComponent() {

// transfer data into the headless component, which requires it!
user.use(this@SomeSpecificComponent.user.value)

someBrick() {
// `someBrick` might check if `user.isSet` and use some default fallback data by itself!
// often the component can check and act in the same way too:
if (user.isSet) {
// apply user data
} else {
// act without user data provided
}
}
}
}
}

Tip: If there is only one data type as a source, you should not use a property implementation, but instead prefer a simple configuration! (cf. Vertical TabGroups where there is only one Enum value to pass)

Databinding

Some headless components support data-binding. This means that the component reacts to dynamic data from the outside, processes and uses this data internally if necessary, and is also able to communicate changes to the outside world. This corresponds to the classic two-way data binding that makes up the core of fritz2.

Due to the importance of this mechanism, there is a specialized Property called DatabindingProperty<T>, which is suitable for most data-binding scenarios.

This property therefore requires the following parameters via invoke for use:

  • id: String? = null: An optional ID, which forms the basis for the further sub-structures' IDs of a headless component.
  • data: Flow<T>: This mandatory data stream delivers the dynamic data from the outside to which the component must react.
  • messages: Flow<List<ComponentValidationMessage>>? = null: This data stream allows for the optional propagation of validation messages. Many headless components already support this aspect natively!
  • handler: ((Flow<T>) -> Unit)? = null: An optional handler that defines how the component propagates internally made changes to the outside world.

Since this information can all be derived from a Store, the property offers a corresponding overloaded invoke method.

Thus, this special property allows the user, very easily and with greatly reduced boilerplate code to expose and manage the data for data-binding.

val name = storeOf("fritz2")

inputField {

// pass the store into the data-binding-property `value`, so that the input-field can be preset with external data
// and also react to user input to update the external store.
value(name)

// ...
}

Hooks

The hook concept is really just a specialized property at its core, which instead of arbitrary data encapsulates a so-called effect. An effect is simply behavior that directly affects the user interface in some way; be it through structures in the DOM, or through reacting to events or even generating events.

The effect, in the case of a hook, is precisely the configuration that must be specified by the user of a component, but is applied by the headless component or brick in a specific place or situation.

Therefore, it makes sense to choose a Property as the base interface: The public API for setting the effect works in the same way as all other configurations thanks to the property concept. The effect itself, on the other hand, is designed to be applicable as extension function on a Tag with some payload parameter. So it can be called anywhere within the RenderContext. On top of that, the effect also has a generic return type, so that the latter can be processed further if needed. The signature looks like that: typealias Effect<C, R, P> = C.(P) -> R

This allows the effect to handle all facets of a UI within the DOM.

The configuration takes place as usual by custom tailored invoke methods.

A recurring pattern in hooks can be traced back to the duality of static and dynamic data: Sometimes a value to be rendered is static, other times it comes from a Flow. Both can be processed by dedicated invoke functions that create the specific effect accordingly and put into the value field of the Property:

class LabelHook : Hook<HTMLElement, Unit, Unit>() {
operator fun invoke(content: String) = this.apply {
this.value = {
// render static content into a label
label { +content }
}
}

operator fun invoke(content: Flow<String>) = this.apply {
this.value = {
// render dynamic content into a label
label { content.renderText() }
}
}
}

A component exposes the previously developed Hook via its public API for user configuration and can then easily apply the effect via a global hook function:

class SomeComponent {
val label = LabelHook()

fun render() {
div {
// apply hook, where needed
hook(label)
// further structure...
input {
// ...
}
}
}
}

// the user can configure the hook with static content...
someCoponent {
label("Hooks are great!")
}

// ... or with dynamic content:
val content = storeOf<String>(/* some initial value */)
someComponent {
label(content.data)
}

Tip: There is an abstract base class for rendering something based upon one value as static T or Flow<T> called TagHook. So implementing such a LabelHook as in the example above should be built upon this foundation class for real projects.

Shared base classes

Closable Content - OpenClose

Some headless components can be opened and closed, for example content expands (Disclosure) in open state or a popup appears (PopOver). These components implement the abstract class OpenClose.

In the scope of these components, there are various Flows and Handlers available, in order to react or manipulate the open-state of the component:

Scope property Typ Description
openState DatabindingProperty<Boolean> Optional (two-way) data-binding for opening and closing.
opened Flow<Boolean> Data stream that provides Boolean values related to the "open" state.
close SimpleHandler<Unit> Handler to close the disclosure from inside.
open SimpleHandler<Unit> handler to open.
toggle SimpleHandler<Unit> handler for switching between open and closed.

The open state of such a component can be set via the data-binding property openState, e.g. to an external Store or Flow. This can be used, for example, to control the visibility of the selection list of a listbox divergent from the standard behavior, e.g. always kept open:

listbox<String> {
//...

listboxItems {
openClose(data = flowOf(true))

characters.forEach { entry ->
listboxItem(entry) {
//...
}
}
}
}

Floating Content - PopUpPanel

Some bricks of the headless components (e.g. the popOverPanel or the listboxItem) are positioned dynamically and hover over the rest of the content. These are often faded in and out dynamically.

These blocks are implemented using the library Popper.js. Bid accordingly they offer a unified configuration interface to the most important attributes.

The following configurations are available in the scope of such a brick that implements the abstract class PopUpPanel in order to influence the positioning of the content:

Scope property Typ Description
placement Placement Defines the position of the building block, e.g. Placement.top, Placement.bottomRight, etc. Default is Placement.auto. The presumably best position is determined automatically based on the available visible space.
strategy Strategy Determines whether the block should be positioned absolute (default) or fixed.
flip Boolean If the block comes too close to the edge of the visible area, the position automatically changes to the other side if more space is available there.
skidding Int Defines the shifting of the block along the reference element in pixels. The default value is 0.
distance Int Defines the distance of the block from the reference element in pixels. The default value is 10.

In addition, an arrow can be added pointing to the reference element. By default, the arrow is 8 pixels wide and inherits the background color of the panel. It can be styled as usual:

popOverPanel {
//...

arrow("h-3 w-3 bg-white")
}