Component mutations: an alternative to base-modifier class approach - part 2
This article is the second part of Component mutations: an alternative to base-modifier class approach. In that first part I reviewed what is the base-modifier class
approach, a couple of examples and what are the issues I have found working with it for several years.
From here I will describe the approach I am using in my last projects. I call this concept “component mutations”, and it has basically 3 types, although in this moment I am evaluating getting rid of one of them in the near future.
Let’s start by defining what a component mutation is: A mutation is a bidimensional scoped modifier of any type that makes the component change its appearance.
Let’s break down the definition to get into details.
Bidimensional
The base-modifier class
approach relies on the class
attribute which contains a list of classes. In that sense, the class
attribute is unidimensional (like a unidimensional array) because it is a flat list.
A bidimensional approach allows us to write a cleaner code leveraging data
attributes. The two dimensions of a data
attribute are its name
and value
. That allows us to have a single purpose for every attribute.
<button
class="button"
data-type="primary"
data-size="big"
data-width="full-width"
>
Back
</button>
The data-type
attribute handles the button color and it can have just one value. Same for the other attributes: size, width or any aspect we want to mutate.
This code becomes less verbose, less prone to errors, easier to indent and read and still based on CSS 2.1 selectors hugely covered by browsers.
Scoped
Since we are using data
attributes instead of rewriting the class name for the modifiers, the selectors are easier to write and less prone to errors regarding to global styles.
.button {
&[data-type="primary"] {
/* Scoped styles, since it will compile to .button[data-type="primary"] */
}
}
The previous snippet shows how this mutation is attached to the .button
selector, in opposition to the .button-primary
global class we had in previous examples.
Also, the selector can be abstracted to a fairly simple mixin, which will hide its implementation and provide a more sugar syntax code. Later on in this article I will show how I code and use the mixin.
Types of mutations
As stated before, I usually work with 3 types of mutations:
- Variants
- States
- Contexts
A variant is a mutation that changes the component appearance right from the instantiation. The mutations for type
, size
or width
from the previous button example are variants.
A state is similar to a variant but it happens after an event or a user action. For example, the expanded
state in an expandable box, the cross
state in a hamburger button, a collapsed
state in a sticky header…
A context is a variant or a state applied in a parent component, which triggers some different appearance in the child component.
Imagine we have a container component called Section
with a default white background, but in some pages the Section
switches the background to black via its own data-background="black"
variant. In the other hand, a child component called RichText
sets by default the text color to black, so it gets a high contrats with the default white Section
background.
RichText
can implement a context that indicates the text color must change to white when the Section
has the data-background="black"
variant applied. That makes our RichText
component more intelligent so we do not need to synchronize parent and children variants in order to keep the right appearance.
Changes the component appearance (props vs. mutations)
In the definition I have reinforced that a mutation only changes the component appearance, and not the behavior, to separate the concept of mutation from the props that a component receives.
A prop could trigger a mutation, or could just render a tag, or modify the component behavior, or even all these things at once.
In the following Vue.js example, a component receives two properties, but only one of them, the background
triggers a variant, while the title
prop just renders a string.
<template>
<div
class="section"
:data-background="background"
>
<p class="title">
{{ title }}
</p>
<div class="content">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
title: String,
background: String,
},
};
</script>
Coding mutations
Let’s review with an example how to code mutations using SCSS mixins and how they look once implemented in the component styles. I will use an input text Vue component as example. To illustrate more clearly the mutations I will intentionally keep the component simple, so bear with me the lack of few necessary props when coding forms.
The markup for our input text component could be:
<template>
<div class="input-text">
<input type="text" />
</div>
</template>
<script>
export default {};
</script>
An the default styles:
.input-text {
> input {
background: white;
color: black;
border: 1px solid black;
font-size: 16px;
background-color: #ddd;
}
}
Coding variants
A variant changes the component default appearance when the instance is created. Let’s create a couple of variants, one to increase the font size and another one to remove the input border.
First we will add the variants using a couple of component props, that will match to data
attributes: font-size
as a string and borderless
as a boolean.
<template>
<div
class="input-text"
:data-font-size="fontSize"
:data-borderless="borderless"
>
<input type="text" />
</div>
</template>
<script>
export default {
props: {
fontSize: String,
borderless: Boolean,
},
};
</script>
That allows us to instantiate the input text component using the two props.
<InputText
font-size="lg"
borderless
/>
Now we can easily write a couple of selectors to match the new variants we just have added.
.input-text {
> input {
background: white;
color: black;
border: 1px solid black;
font-size: 16px;
background-color: #ddd;
}
&[data-font-size="lg"] {
> input {
font-size: 20px;
}
}
&[data-borderless="true"] {
border: none;
}
}
While the previous code works fine, it’s still sort of hard to write those two selector for the variants. With a very simple SCSS mixin, we can make our code less prone to errors add sugar syntax to our code.
The mixin would hide the implementation details of the selector, so we can later add logic there in case we need to extend it.
@mixin variant($name, $value) {
&[data-#{$name}="#{'' + $value}"] {
@content;
}
}
Once we have the mixin, the styles get cleaner and more readable than before.
.input-text {
> input {
background: white;
color: black;
border: 1px solid black;
font-size: 16px;
background-color: #ddd;
}
@include variant(font-size, lg) {
> input {
font-size: 20px;
}
}
@include variant(borderless, true) {
> input {
border: none;
}
}
}
Coding states
A state is a mutation very similar to the variant, but it gets triggered after a user action or an event, instead of being defined in the component instance.
The SCSS code behind it is really similar, but I have found useful to separate these two concepts in most of the cases, so the developer can think of the variants triggered from the component properties and the state from the component state.
In order to distinguish them, the state has an is-
prefix in its selector. The other main difference is that SCSS states usually do not need a value.
@mixin state($name) {
&[data-is-#{$name}] {
@content;
}
}
Let’s take our input text component and add a valid
state to it.
.input-text {
> input {
background: white;
color: black;
border: 1px solid black;
font-size: 16px;
background-color: #ddd;
}
@include state(valid) {
> input {
background: lightgreen;
}
}
}
Coding contexts
A context is a variant or a state applied to a parent component. Let’s imagine we have a dark form in the footer, and the input text needs to switch its default colors to a dark mode.
We could do this with a variant to change the input color, but a context here makes the input component smarter, since it will change it’s appearance automatically when it is in a dark parent.
This could be the footer, with its own background
variant set to dark.
<footer data-background="dark">
<InputText />
</footer>
The context mixin moves the &
at the end of the selector so it matches a markup like the previous one.
@mixin context($name, $value) {
[data-#{$name}="#{$value}"] & {
@content;
}
}
And finally, the context in the styles would just switch the color to a dark mode.
.input-text {
> input {
background: white;
color: black;
border: 1px solid black;
font-size: 16px;
background-color: #ddd;
}
@include context(background, dark) {
> input {
background: #555;
color: white;
}
}
}
Complex components
For most of the components we usually code in a project at Hanzo, the mutations described above work well, and avoid uncontrolled states while increasing the code readability. But there are some complex cases where we need to apply 2 or 3 types of mutations and combine them.
Following the previous example, an input text in a dark background context, when it’s in a valid state it should show anyway the green background which can clash with the context background. For those cases we use conventions to stablish the order of the SCSS blocks, which I will cover in another article. To avoid repeating much code we can leverage some mixins at component level.
Wrap up
- The
base-modifier
class approach has some issues regarding to side effects, uncontrolled states, code readability and global classes. - Mutations leverage
data
attributes to separate concerns. Everydata
attribute handles an appearance modifier. - Mutation selectors can be scoped to the component class.
- There are three mutation types: variants, states and contexts.
- Variants are defined in the component instance, e.g: colors.
- States are triggered after a user action or an event. e.g: expanded content in accordion.
- Contexts are appearance changes in a child component when it is placed in a modified parent via its own variant or state. e.g: a menu component that switches text color to white when it has a dark background parent.
- This approach and these mixins will probably need to evolve and change depending on the project needs.
The styles, the mixins and the html code can be found in this repo: