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.
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.
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"), Property
s and Hook
s. 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.
Since each component consists of different bricks which in turn have some fields with Hook
s, Property
s, 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>
}
// }
}
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 Tag
s 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:
tag
to be generated in factory functionsBricks 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.
Tag
to be generated in Factory FunctionsThe 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"
}
}
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"
}
}
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,
Property
s, and Hook
s.
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")
}
}
}
}
}
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:
Since the data binding Property
is a particularly important and special one, its implementation is explained later in a
dedicated section.
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:
isSet
if a value was set. This is important in order to be able to fall back on a default value if
necessary.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).
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)
// ...
}
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.
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 Flow
s and Handler
s 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) {
//...
}
}
}
}
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 size
parameter. 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
}
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.