Headless Components

Instead of the classic approach of providing taylor-made, fully functional, and styled components, fritz2 takes a different approach: so-called headless components.

They represent a modular system which empowers the user to easily build user interfaces supporting typical functionalities. These include functions like multiple and single selection of elements from a given list, modal dialogs, pop-up windows, input fields, and many more.

The pure functionality of such elements, in particular user interaction and the corresponding data handling, such as navigating within a selection list or clicking a button, is controlled and encapsulated by the headless components. The user has to worry about the pure display aspects only, 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 you to this detailed blog post.

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

Setup

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

kotlin {
// ...
sourceSets {
commonMain {
dependencies {
// always add the dependency to headless in the 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 variants of factory functions (e.g. with switch). In the following sections, the word "component" always refers to the associated factory function as well, and vice versa.

Components in turn consist of building blocks called "bricks" which are often more deeply nested. They are also represented by factory functions.

All components and many of the building blocks have their own scope in which the user configures the functionality of the component. There are essentially three concepts for this configuration: simple var fields ("simple configuration"), Propertys and Hooks. All three allow adaptation of structure, behavior, and function in general of a component to the context in a meaningful way. These concepts of configuration are discussed in detail in a dedicated section.

Almost without exception, all factories of both components and bricks always generate a Tag, giving the user access to all attributes of the generated tag, such as className or attr.

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

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

Block names are always prefixed with the distinctive part or the full name of the component to which they belong, e.g. radioGroupLabel for a label within the radioGroup component.

Almost all factory functions of components and building blocks have the same signature, which intentionally resembles the signature of a Tag:

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 for the scope
tag: TagFactory<Tag<C>>, // provide a factory to create a `Tag<C>` (there is *always* an overloaded function with a default `Tag`)
initialize: SomeComponent<C>.() -> Unit // the builder function which 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 documentation, these standard parameters are listed as a short, untyped list only. Blocks do not usually provide the option of explicitly setting an ID. The additional parameters that some bricks require are more important, so those are described with emphasis and thus easy to recognize. In most cases, these are mandatory parameters.

API-Description

Since each component consists of different bricks which in turn have some fields with Hooks, Propertys, or simple var types, a strongly abstract overview is offered to show the big picture. It 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 clarifies this approach to documentation. While the following lines of code are explained with additional commentary, the real component documentation omits 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 out of headless components and their building blocks, the given factory functions can be nested and combined with other Tags to create the overall structure. In addition, the appearance must be defined by adding styling.

The headless components and modules offer the following starting points for specifying the function and representation of a component:

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

Use 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. A mouse click on these elements automatically focuses the associated input field.

This function is available without additional efforts as soon as the corresponding Label brick is called inside the component's scope.

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 needs do no 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 default type for the Tag to be generated. However, in order to achieve the greatest possible flexibility, the user can freely to choose the type of tag to generate something that is semantically appropriate for the specific context.

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

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

Set 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
+"Label Text"
}
}

Configure the Scope of the Component or Block

As the last and extremely powerful aspect, the components and bricks provide readily accessible fields for the user through which the configuration is carried out.

Reminder: the three common configuration concepts are simple configuration via public var fields, Propertys, and Hooks.

These configuration properties often have 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 current selection in selection components (RadioGroup, CheckboxGroup, or ListBox), but also the input values for text field components (InputField and TextArea).

In addition, a lot of scopes offer flows and handlers for the handling of special states (selected, disabled, focused, or open), or for the behavior on data change events (e.g. user clicks on a close button). Most of the time, these are derived from the data binding, so this is a very important and powerful aspect of our headless components.

Typical patterns are the dynamic setting of CSS classes depending on a specific state via className, or the complete creation/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 border-gray-300"
})

// conditionally modify the 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 way they require and maintain certain data inputs from a user. Beyond pure data management, the user must be able to directly modify the rendering or pass customized behaviour to 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 Property is a particularly important and special one, its implementation is explained later in a dedicated section.

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's or brick's functionality, but should also be tailored to the typical kind of data types of the user's context. This is easily achieved by providing different invoke methods for different data types.

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

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

// set the data within a 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 emerges from different types. A suitable invoke function should be provided for each type resulting in a comfortable API for the user.

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 another component-like 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 the simple configuration (cf. Vertical TabGroups where there is only one Enum value to pass).

Data Binding

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 requires the following parameters via invoke:

  • id: String? = null: An optional ID which forms the basis for the IDs of a headless component's substructures.
  • 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 all this information can be derived from a Store, the property offers a corresponding overloaded invoke method.

Summing up, this special property allows the user to expose and manage their data binding very easily and with greatly reduced boilerplate code.

val name = storeOf("fritz2", job = Job())

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 a 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.

In case of a hook, the effect is precisely the configuration that must be specified by the user, 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 this: typealias Effect<C, R, P> = C.(P) -> R

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

As usual with our headless components, the configuration is done via 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 */, job = Job())
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 for reacting to or manipulating 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 Floating UI. 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
size PopUpPanelSize Defines the width restrictions of the building block, e.g. PopUpPanelSize.Min, PopUpPanelSize.Max, etc.
placement PlacementValues Defines the position of the building block, e.g. PlacementValues.top, PlacementValues.bottom, etc.
strategy Strategy Determines whether the block should be positioned absolute (default) or fixed.
middleware Array<Middleware> Middleware are plain objects that modify the positioning coordinates in some fashion, or provide useful data for rendering, as calculated by the positioning cycle.

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. You are recommended to only change its width or height by providing any valid CSS expression for that for its sizeparameter. Alongside of changing the size, you usually also have to adapt the offset too, so there is also a parameter to provide the value in pixels:

popOverPanel("bg-gray-200") {
//...
arrow("h-3 w-3", 8) // w-3 -> 12px in tailwindcss, use at least the half of the arrow size for the offset
}

Portalling

Headless Components, which are rendered as a overlay above other elements are rendered using a portalling mechanism. With portalling the Element is not rendered in-place, instead it is rendered in a portalRoot, which is a container at the end of the Document-Body.

To use portalling we have to render the portalRoot manually in our main render {} Block like this:

fun main() {
//...

render {
// custom content
// ...

portalRoot() // should be the last rendered element
}
}

Portalling is already implemented in the Headless-Components listBox, menu, modal, popOver, toast and tooltip. If your are using one of these components, you have only to render the portalRoot like above.

For custom components you have to wrap your render code with a portal like this:

fun Tag<HTMLElement>.myCustomOverlay() = portal { close: suspend (Unit) -> Unit -> // a handler to close the portal 
// ...
}

Beware that there is no z-index handling managed by the portal mechanism - following the zen of headless, the portalling is totally styling agnostic.

Edit this page on Github