1

Whats the best way to approach this in twig? So I have a component that is used in multiple places, so within this component I set some default classes like so:

{% set navSidebarClasses = {
  heading: 'font-semibold text-lead mb-3',
  navList: 'text-sm ml-2',
  navItem: '',
  navLink: 'text-brand-900 hover:underline inline-flex justify-between items-center py-2',
  active: 'font-bold py-2 flex items-center',
  parentLink: 'mt-3 pt-6 border-t border-gray-200 font-bold hover:underline inline-flex justify-between items-center'
} %}

However, I want to be able to also pass in some additional classes based on the components context, so these classes would be set on the parent where the component is included. How can/should I setup and pass/merge additional classes into the component. I seem to hit this quite often and so I am looking for a better/improved bullet proof way to do this.

Parent 1

{% set navSidebarClasses = {
  navLink: 'hover:text-business-500',
  active: 'text-business-500',
  parentLink: 'text-business-600'
} %}

{% include '_includes/nav/nav-sidebar-services.twig' with {
  navSidebarClasses: navSidebarClasses
} %}

Parent 2

{% set navSidebarClasses = {
  navLink: 'hover:text-family-500',
  active: 'text-family-500',
  parentLink: 'text-family-800'
} %}

{% include '_includes/nav/nav-sidebar-services.twig' with {
  navSidebarClasses: navSidebarClasses
} %}
3

You can solve this using the merge filter. When merging hashes (i.e. associative arrays), properties from the merged hash override properties with the same key in the target hash. Since you're defining the base classes in the component template, you have to do the merging in that base component:

{# _includes/nav/nav-sidebar-services.twig #}
{% set defaultClasses = {
    heading: 'font-semibold text-lead mb-3',
    navList: 'text-sm ml-2',
    navItem: '',
} %}
{% set classOverrides = classOverrides ?? {} %}
{% set finalClasses = defaultClasses|merge(classOverrides) %}

You can also do the same in a single expression, though I prefer using multiple variables for clarity. The important thing is to merge the passed classes with the base variables instead of overriding them. This allows you to only pass the classes you wish to override in the template using the component:

{# parent-1.twig #}
{% include "_includes/nav/nav-sidebar-services" with {
    classOverrides: {
        heading: 'my-custom-class',
    }
} %}

{# parent-2.twig #}
{% include "_includes/nav/nav-sidebar-services" with {
    classOverrides: {
        navList: 'my-custom-class',
    }
} %}

Update: Deep merging classes

In response to the comment by James Smith. The method outlined above differs a bit from the intended result. In the example code, the classes passed from the extending template completely override the default classes for those elements.

If you want to instead add classes to the default classes, you can follow the same idea of using the merge filter, but slightly adjusted to account for deep merging.

Here's an example using the map filter to merge the default classes with the overwrites:

{# _includes/nav/nav-sidebar-services.twig #}
{% set defaultClasses = {
    heading: 'font-semibold text-lead mb-3',
    navList: 'text-sm ml-2',
    navItem: '',
} %}
{% set classOverrides = classOverrides ?? {} %}

{% set finalClasses = defaultClasses|map(
     (class, element) => classOverrides[element] is defined
         ? "#{class} #{classOverrides[element]}"
         : class
) %}

This is slightly messy, so at this point I would either use individual variables for each element's classes and/or represent classes as an array of strings instead of a single string to make merging a bit easier.

One drawback of the current approach is that it's no longer possible to remove any of the default classes. Because of this, I usually make both the class overrides and the default variables overwritabable (using the null coalaescing operator) to allow an extending template to completely overwrite all classes. You can achieve many different variations on how merging/overwriting works using the map and merge filters, it just depends on how you want to work with your templates.

6
  • Unless I've misunderstood the original question, I don't think this does what he's asking for, which is to have additional classes added to the defaults. This method completely overrides the values in a given key if it exists in classOverrides. Aug 9 at 8:55
  • @JamesSmith Fair enough, yes the existing classes would be completely overwritten. But the same principle also works if you want to add classes instead of overwriting them, you just have to make sure to handle deep merging in your code. I'll see if I can provide an example in my answer.
    – MoritzLost
    Aug 9 at 9:03
  • @JamesSmith Done! I added a new section with a slightly adjusted filter for merging which achieves deep merging for the passed and default classes.
    – MoritzLost
    Aug 9 at 9:27
  • 1
    Interesting technique, thanks for sharing! Aug 9 at 9:30
  • 1
    Hey @MoritzLost - thank you for this. I have not tried this code or approach as I did refactor my code to be slightly simpler and then have subsequently tried James' approach below. But thank you for the time to answer this. I will certainly look through and understand, as deep merging is also useful. Aug 14 at 18:18
2

(Whilst this answer does not directly answer your question (instead see the answer by MortizLost), I wanted to add it to show a different approach to the problem you're trying to solve).

Tailwind CSS makes it more important than ever to have proper DRY Twig components, so those kind of overrides are extremely useful. However, rather than passing arbitrary strings of extra classes through to the component, I like to try and nail down a list of semantically-named "variants" based on the use-case. Here's what it might look like for a fairly comprehensive button component. Notice that I'm using both a "variants" parameter and a "classes" parameter to also allow for those arbitrary additions. The arbitrary ones should be relatively rare though - always best if you can abstract a named variant. Here I'm using jumbo, secondary and mini.

{# ==================================
BUTTON

Params:

* link (required unless `element` is not an anchor)
* label
* element (default to anchor)
* classes - string or array (extra classes on the button element)
* attrs - hash to pass into attr() function (https://craftcms.com/docs/3.x/dev/functions.html#attr)
* variants - string for a single variant option, or array of variant options:
    - secondary
    - jumbo
    - mini
* icon - string, one of the following:
    - edit
    - remove
    - ...
    - ...
    - ...

Example:

{% include '_partials/button.twig' with {
    label: block.buttonLabel,
    link: block.buttonLink,
    variants: ['jumbo', 'secondary']
} only %}

{% include '_partials/button.twig' with {
    element: 'button',
    variants: ['secondary','mini'],
    classes: 'js-removeItem inline-flex',
    attrs: { type: 'button' },
    label: 'Remove',
    icon: 'remove'
} only %}

===================================== #}

{% set element = element ?? 'a' %}
{% set variants = variants ?? [] %}
{% set classes = classes ?? [] %}
{% set icon = icon ?? null %}

{# if variants was a string, convert to an array so we can use 'in' on it more reliably... #}
{% set variants = variants is iterable ? variants : [variants] %}

{# ==================================
COLOUR
===================================== #}

{% set _buttonColor = 'secondary' in variants ?
    'bg-gray-500 hover:bg-gray-600 focus:bg-gray-600 text-white' :
    'bg-brand text-body-onBrand ring-offset-brand hover:bg-brand-highlight hover:text-onBrandHighlight focus:bg-brand-highlight font-bold'
%}

{# ==================================
SIZE
===================================== #}

{% set _buttonSize = 'px-4 py-4 md:py-4 md:px-5 w-full fl:text-base' %}
{% if 'jumbo' in variants %}
    {% set _buttonSize = 'px-8 py-4 md:py-6 md:px-10 w-full fl:text-md' %}
{% endif %}
{% if 'mini' in variants %}
    {% set _buttonSize = 'px-2 py-1 text-sm' %}
{% endif %}

{# ==================================
FOCUS STYLES
===================================== #}

{# secondary variant is ok to use the default focus (focus-visible) specified in global.scss... #}
{% set _buttonFocusStyles = 'secondary' in variants ? '' : 'focus:ring-1 ring-inset ring-offset-2 ring-white ring-opacity-30' %}

{# ==================================
ICON
===================================== #}

{% if icon is defined and icon %}
    {% set icon %}
        {% switch icon %}
            {% case 'edit' %}
                <svg xmlns="http://www.w3.org/2000/svg" class="max-h-full max-w-full" width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>

            {% case 'remove' %}
                <svg xmlns="http://www.w3.org/2000/svg" class="max-h-full max-w-full" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /></svg>
        {% endswitch %}
    {% endset %}
{% endif %}

{# ==================================
STITCH EVERYTHING TOGETHER...
===================================== #}

{% set classes = 'leading-none flex items-center justify-center text-center transition relative rounded active:top-px ' ~ ' ' ~ _buttonFocusStyles ~ ' ' ~ _buttonColor ~ ' ' ~ _buttonSize ~ ' ' ~ classes|join(' ') %}

{# ======================================================================= #}

<{{ element }}
    class="{{ classes }}"
    {% if element == 'a' %}href="{{ link }}"{% endif %} {% if attrs is defined %}{{ attr(attrs) }}{% endif %}
>
    {% if icon is defined and icon %}
        <span class="flex items-center justify-center {{ 'mini' not in variants ? 'mr-3' : 'mr-1' }}" style="{{ 'mini' not in variants ? 'height:20px;width:20px;' }}">
            {{ icon|raw }}
        </span>
    {% endif %}
    {{ label ?? null }}
</{{ element }}>

I prefer this for two reasons:

  1. The classes associated with each named variant can all be handled inside the component file itself, so you have a central location for everything to do with the component instead of having those classes spread across any number of separate caller files (I'm using 'caller' to mean the file that does the include-ing, not sure if there's a proper name for that!). This improves both maintainability and component portability.

  2. The include/embed syntax becomes easier to read because the variant has a name (e.g. jumbo), so you can tell more easily what flavour of the component is being used.

3
  • James, this is a great approach. I really like it. I have done similar or rather a more half committed approach and been caught out a few times, hence the post above. But I love this idea of variants array and then setting classes based on this. I have already refactored a banner component that takes this approach. Seems to work really well. I have a few other questions, would you mind if I ping you on Discord to talk through? Aug 14 at 18:13
  • I would be keen to know if/how you handle this situation if you have nested components/includes and variants within those? Aug 25 at 19:17
  • Yes, that's feasible too - just pass in another array that you then pass to your nested partial(s). I use this technique for a "grid" component where I want to have say, a grid of cards, but with the ability to override what the cards look like for each instance of the grid component (in addition to being able to override how the grid behaves, and in addition to being able to completely override the grid item template). Can discuss in detail on Discord... Aug 26 at 8:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.