Shopify / liquid

Liquid markup language. Safe, customer facing template language for flexible web apps.
https://shopify.github.io/liquid/
MIT License
11.11k stars 1.39k forks source link

[Idea] Allowing slot for render #1530

Closed bakura10 closed 2 years ago

bakura10 commented 2 years ago

Hi,

Michael from Maestrooo here. We are developing complex themes and, as we move toward a more component-based approach, I face a lot of duplication code that could be simplified by introducing a concept of slot to the render.

The idea would be to allow something like this:

{% render 'dialog' %}
  {% slot 'header' %}
     <p>Something</p>
  {% endslot %}

  {% slot 'content' %}
     <p>Something else</p>
  {% endslot %}
{% endrender %}

And allow the snippet to render those slots:

// 'dialog.liquid'

<dialog-element>
  <div class="dialog__header">
    {{ slot.header }}
  </div>

  {% if slot.content != blank %}
    <div class="dialog__content">
       {{ slot.content }}
    </div>
  {% endif %}
</dialog-element>

A default slot feature could also be used:

{% render 'foo' %}<p>My content</p>{% endrender %}

And in the snippet:

<div>
  {{ slot }}
</div>

I know we can actually capture and pass variables, but this force to create a lot of capture and make the code way less clear.

jaredcwhite commented 2 years ago

FWIW, we developed something very similar to this for Bridgetown. https://edge.bridgetownrb.com/docs/components/liquid#the-with-tag

bakura10 commented 2 years ago

Exactly this ! Having that in Shopify would help tremenduously !

dylanahsmith commented 2 years ago

You can pass variables to a snippet. If you want to declare those variables using blocks, then you could use the capture tag for that purpose. That seems quite similar to what you are proposing.

bakura10 commented 2 years ago

@dylanahsmith yes you can, but this suffers from several issues:

1) Less clear separation of concerns:

I think this is much clearer:

{% render 'button' %}{% render 'icon' with 'foo' %} Click here{% endrender %}

Rather than trying to first capture and then passing everything as an attribute. As soon as you have more named slot, the advantage of having everything inside the block makes it clearer.

2) This avoid leaking the variable scope: by capturing elements it creates new variable that may actually override other templates.

3) Inside the snippet itself this also removes risk of overriding a variable. For instance:

{% capture content %}Foo{% endcapture %}
{% render 'button', content: content %}

Inside the button.liquid:

{% assign content = 'something %}
<button type="button">{{ content }}</button> <== Oops...

By introducing a new slot concept separate from variable allows to make the code cleaner, prevent unexpected side effects and simply open the road of a more "native" way to approach components in Liquid.

To create a clearer separation, a new "component" tag may be created (along support for a new "components" folder in Shopify) to allow a better separation of concern:

// Single slot
{% component 'button', size: 'large' %}{% render 'icon' with 'foo' %}Button{% endcomponent %}

// Multi slot
{% component 'accordion' %}
  {% slot 'button' %}Click here{% endslot %}

  {% slot 'content' %}{{ settings.content }}{% endslot %}
{% endcomponent %}
dylanahsmith commented 2 years ago

The default slot feature seems like a user-defined block, so I can definitely see the appeal of that.

The fact that render tag isn't a block tag is exactly why a clear separation would be needed between the current tag and one that renders with a block passed in, so a separate "component" tag would be a way to avoid that problem. Otherwise, if you tried to move the icon out of your button render block as {% render 'icon' with 'foo' %}{% render 'button' %}Click here{% endrender %} you can see how the ambiguity could lead the button becoming part of the default slot for the icon render, which could result in that block being unused.

I can see how avoiding variables is more of a concern in a template language compared to a programming language given that a template is like a long function without support for declaring variables locally scoped to a more confined block of template code. If there were a way of introducing a local variable scope and local variables, then that might provide similar local reasoning benefits. Although, more generic support for this would inherently still be more verbose than a feature specific way of doing this, e.g.

{% locals header, content %}
  {% capture header %}
    <p>Something</p>
  {% endcapture %}

  {% capture content %}
    <p>Something else</p>
  {% endcapture %}
  {% render 'dialog', header: header, content: content %}
{% endlocals %}

Inside the snippet itself this also removes risk of overriding a variable.

This is again another case of lack of variable scoping, where a template could avoid side-effects of assigning to a variable if they could define a locally scoped variable for some part of the template.

Conceptually, it still feels like passing named slots are quite similar to passing variables. As in, if this is a problem for slots, then it is already a problem for passing variables. If we thought it made sense to introduce a namespace for snippet arguments, then it would be possible to do this more generally by allowing them to all be accessed through a common namespace variable (e.g. args.header instead of slot.header and args.block instead of the default slot slot).

If we think of the proposed slot as just an argument, which happens to be passed in using a block, then having a distinction between components and snippets might not make sense, the distinction would only be in the tag used to render the snippet. E.g.

{% render 'dialog', args: %}
  {% arg 'header' %}
     <p>Something</p>
  {% endarg %}

  {% arg 'content' %}
     <p>Something else</p>
  {% endarg %}
{% endrender %}

could be equivalent to

{% render 'dialog', header: '<p>Something</p>', content: '<p>Something else</p>' %}

and

{% render 'foo', header: '<p>Something</p>', content: %}<p>Something else</p>{% endrender %}
bakura10 commented 2 years ago

Hi @dylanahsmith .

I have made some more tests to figure out what the correct usage would be, and definitely, a proper component with usage for slot would be very useful (here is a template language doing that: https://laravel.com/docs/9.x/blade#components)

The usage would be like this (it is very similar to my initial message, but with a new component tag):

{% component 'button', size: 'sm' %}
  {% render 'icon' with 'warning' %} {{ 'foo.bar.baz' | t }}
{% endcomponent %}

"snippets/button.liquid" (or "components/button.liquid")

<button class="button {% if size == 'sm' %}button--sm{% endif %}>
  {{ slot }} // Default slot
</button>

Example with multi slot:

{% component 'dialog', position: 'right', size: 'large' %}
  {% slot 'header' %}
    Choose {{ option.name }}
  {% endslot %}

  {% slot 'content' %}
    {% for value in option.values %}
      // Do something
    {% endfor %}
  {% endslot %}
{% endcomponent %}

snippets/dialog.liquid (or components/dialog.liquid)

<div class="dialog {% if position == 'right' %}from-right{% endif %}>
  {% if slot.header %}
    <div class="dialog-header">
      {{ slot.header }}
      <button>Close</button>
    </div>
  {% endif %}

  {% if slot.content %}
    <div class="content">
      {{ slot.content }}
    </div>
  {% endif %}
</div>

This approach would be completely backward compatible, offers a predictable approach and would open a real component feature to Liquid. The slot approach also clearly put the separation between the component options (size, position...) and the content injected into them.

@dylanahsmith I don't really understand the args system, it is quite confusing in my opinion, and does not make a clear distinction between a render (which should actually just render a single element) and a real component approach.

bakura10 commented 2 years ago

After playing a bit more with a componentized approach with render, here are a few ideas that may be useful to move Liquid to a better componentize support.

I think it would be very useful to allow a new "components" folder, and allow sub-folder for this. The idea would be to be able to more easily compose components. For instance:

{% component 'combobox', size: 'sm' %}
  {% slot 'values' %}
    {% for value in option.values %}
      {% component 'combobox.value', value: value %}
    {% endfor %}
  {% endslot %}
{% endcomponent %}

When a dot is encountered Shopify would look for this structure:

/components
   -- combobox.liquid
   / combobox
      -- value.liquid

In order to better group related components, for component that are not nested (eg. "combobox"), we could also reference the file through "combobox.liquid" inside the combobox folder:

/components
   / combobox
      -- combobox.liquid // Would first look into the "combobox" folder, and look for "/components/combobox.liquid" as a fallback
      -- value.liquid
bakura10 commented 2 years ago

After more research on how other framework we’re doing, I found an interesting syntax from older versions of EmberJS that would map well to Liquid:

{# button size=‘sm’ #} Your text {# button #}

such as syntax instead of a generic component tag would allow to make it easier to visualise the hierarchy of components when they would be nested.

bakura10 commented 2 years ago

Here are a few more ideas on "syntax" that may be useful. I experimented with various syntax to answer the use cases we have here while trying to migrate to a more component based approach.

The more I try to migrate my code to Tailwind, the more I realize how the "render" based approach is really too limited. It introduces a lot of odd syntax, hard to read code that is really far, far from ideal.

{# drawer: size: 'lg' #}
    {# drawer.header: bordered: true #}{{ size_chart_page.title }}{# end drawer.header #}
    {# drawer.content #}{{ size_chart_page.content }}{# end drawer.content #}
{# end drawer #}

In components/drawer.liquid:

<div class="relative">
    {% if slot.header %}
        <div class="block {% if slot.header.bordered %}border{% endif %}">
          {{ slot.header }}
        </div>
    {% endif %}
</div>

Such an approach would offer a lot of flexibility, by allowing to create arbitrary "slot". They would, in a sense, be close to what can be found in React based systems:

<Drawer>
   <Drawer.Header>Foo</Drawer.Header>
   <Drawer.Content>Bar</Drawer.Content>
</Drawer>

EDIT: an example of an hypothetical syntax with array as slot:

{# dropdown: size: 'lg' #}
  {# dropdown.values as array #}
    {%- for value in option.values -%}
      {# dropdown.value: selected: true #}{{ value }}{# end dropdown.value #}
    {%- endfor -%}
  {# end dropdown.values #}
{# end dropdown #}

<div class="relative">
  {% if slot.values %}
    <div class="list">
      {%- for value in slot.values -%}
        <button {% if value.selected %}aria-selected{% endif %}>{{ value }}</button>
      {%- endfor -%}
    </div>
  {% endif %}
</div>
bakura10 commented 2 years ago

One another great advantage that slot can bring is being able to compose UI. For instance, if you have a dropdown whose button toggle can e button of different kind (a standard button, select button...), this becomes very easy:

{# dropdown #}
  {% slot toggle %}{# button: size: 'lg' #}{% endslot %}
{# dropdown #}

I have experimented with shadow DOM yesterday, which provides great flexibility with slots, unfortunately, the requirement of JS to simply render make them a bit inconvenient for Shopify official themes.

bakura10 commented 2 years ago

Hi @dylanahsmith , I realized I went went too far here with the ideas, and after re-reading your initial proposal, I just realize it perfectly makes sense and would answer most of our use case (the use cases and syntax actually emerged as I am doing experiment on our new theme).

In order to keep this proposal useful, I am closing this one and formalized the idea in #1565