Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scoped slots - Passing data to slots and accessing them in fills #494

Closed
JuroOravec opened this issue May 12, 2024 · 20 comments · Fixed by #495
Closed

Scoped slots - Passing data to slots and accessing them in fills #494

JuroOravec opened this issue May 12, 2024 · 20 comments · Fixed by #495
Assignees

Comments

@JuroOravec
Copy link
Collaborator

JuroOravec commented May 12, 2024

After #491 I wanted to do a small one now.

Another feature that I'm missing as a component author is what Vue calls "scoped slots". Basically, it means that you can pass data to slots and then access them in fills.

Why is it useful?

Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template:

@component.register("my_comp")
class MyComp(component.Component):
	template = """
		<div>
			{% slot "content" default %}
				input: {{ input }}
			{% endslot %}
		</div>
	"""

	def get_context_data(self, input):
		processed_input = do_something(input)
		return {"input": processed_input}

Currently, if I decide to override the "content" slot, then I cannot make use of the input variable (if I'm using the isolated option for context_behavior). I would either have to recompute input myself, or pass the slot content as a function to MyComp. Recomputing is not ideal if the computation is expensive. And passing slot fills as functions means we cannot separate Python and HTML logic.

Vue's approach is that instead, the component with the slot can decide to pass some variables to the slot. And these variables can then be optionally used by the slot fill:

<!-- Child component -->
<div>
	<!-- Note: "name" identifies the slot, same as we do {% slot "content" %} -->
	<slot name="content" :text="greetingMessage" :count="1" />
</div>

<!-- Parent component -->
<Child>
	<template #content="slotProps">
		text: {{ slotProps.text }}
		count: {{ slotProps.count }}
	</template>
</Child>

This has also been implemented in django-web-component (DWC). DWC allows to pass only one variable to the slot. The name under which it is then accessed in the slot fill is defined by :let. Also notice that the example below uses a default slot, so :let is defined on the component itself.

{# unordered_list component #}
<li>
    <!-- We are passing the `entry` as the second argument to render_slot -->
    {% render_slot slots.inner_block entry %}
</li>

{# parent component #}
{% unordered_list :let="fruit" entries=entries %}
    I like {{ fruit }}!
{% endunordered_list %}

Implementation notes:

  • API for passing data to slots - I'm thinking about two approaches:

    1. All extra slot kwargs are passed through:

      {% slot "my_slot" key1=value1 key2="value2" default %}

      would collect key1=value1 key2="value2" into a dictionary and pass that dict
      to the fill tag.

    2. The slot data would be defined under a specific keyword, like data. And we could
      also use the prefix:key=val construct here:

      {% slot "my_slot" data:key1=value1 data:key2="value2" default %}
      {# or #}
      {% slot "my_slot" data=data default %}
      {# or #}
      {% slot "my_slot" data default %}

    Don't have a preference here. Option 2 is more future-proof, as it allows for new inputs to be added to slot tag without disruption. But if I look at Vue or DWC, then it seems there's a low chance of additional keys being added.

    Actually, maybe option 2 is slightly better, because in option 1, we cannot pass kwarg "default", because it would collide with the default flag. But in the option 2, we can do data:default=....

  • API for accessing slot data in fills

    1. Use a simple kwarg to define the name of the variable:
      {% fill "my_slot" data="slot_data" %}
      	{{ slot_data.key1 }}
      	{{ slot_data.key2 }}
      {% endfill %}
      would assign the data passed from the slot to variable slot_data.

    DWC used the prefixed :let because their "slot attributes" feature allows to pass
    data INTO component via fills, so any kwargs not prefixed with : are interpreted to be
    set as "slot's attributes". Personally I'm not a fan of that design decision, so IMO we don't
    need to prefix anything here.

  • NOTE: I like the idea that we'd use the same keyword on both slot and fill tags, to hint that these are the same thing. Like in the examples above it's data on both slot and fill.

  • NOTE: This should be compatible with other fill's as var syntax:

     {% fill "my_slot" data="slot_data" as "my_fill" %}
     	{{ my_fill.default }}
     	{{ slot_data.key1 }}
     {% endfill %}

    In the example above, the requirements are that as "xxx" needs to be last, and fill "yyy" needs to be first.

@JuroOravec JuroOravec self-assigned this May 12, 2024
@EmilStenstrom
Copy link
Owner

EmilStenstrom commented May 12, 2024

Why are we not passing all slot variables to fills automatically? I don't think stopping this is what context_behavior="isolated" means, just like isolating the slot from it's component isn't what you expect?

Thinking about it, that might lead to params shadowing, so maybe opting in to it in the fill tag still makes sense. Maybe that should also be the way to access the default data, instead of the unergonomic "as variable" we have now.

{% slot "content" %}
    Input: {{ input }}
{% endslot %}
{% component "card" %}
    {% slot "content" slot_data="slot_data" %}
        Input: {{ slot_data.input }}
    {% endslot %}
{% endcomponent %}

No strong opinions here, just thinking out loud.

@JuroOravec
Copy link
Collaborator Author

JuroOravec commented May 13, 2024

@EmilStenstrom could you elaborate what you mean by "default data"? Do you mean variables referenced inside the {% slot %} tag?

Yeah, shadowing is one problem.

Secondly, from the perspective of a component library author, the variables I expose via the slot are my public API, so I want to have a control over that. So automatically exposing variables inside the slot tag may not be desirable, as I may want to expose additional variables, or exclude some.


NOTE: I also updated the original description. Accidentally, when I was writing it, I was thinking that fill tag has an only flag, but it does not.

@EmilStenstrom
Copy link
Owner

@JuroOravec "default data" = The default slot content that you can access via "as variable". I'm saying that maybe slot_data.__default__ is a better API than using as variable and then variable.default...

About shadowing, this solves this well:

{% slot "content" slot_data="slot_data" %}

That leaves letting people only expose some parts of the slot data to fill templates. Are you sure this is really necessary? Exposing nothing by default, and then everything with slot_data should be a very large part of all use-cases, don't you think?

@JuroOravec
Copy link
Collaborator Author

JuroOravec commented May 14, 2024

@EmilStenstrom Interesting with the __default__. But it still leaves room for possible conflict with user-supplied slot data. Options that come to mind:

  1. We could assign the default slot to a variable via kwarg like default="def_var", and drop the .default part:

    {% fill "my_slot" default_slot="default_slot" %}
    	{{ default_slot }}
    {% endfill %}
  2. We could assign the slots to a variable, like django-web-components does, e.g. component_vars.slots.my_slot. That, in combination with exposing slot data, would mean that it would become the decision/responsibility of the component author on whether they allow component users to use the default slot:

    So the child component would be like:

    {% slot "my_slot" data:default_slot=component_vars.slots.my_slot data:key=val %}
    	default text
    {% endslot %}

    And the parent would use the slot like so:

    {% fill "my_slot" slot_data="data" %}
    	{{ data.default_slot }}
    {% endfill %}
  3. Since the default slot is a template, we could make a decision to NOT treat it as a variable. In that case we could have a special tag, e.g. {% default_fill %}, that would render the default IF inside a fill, or would raise error if used outside:

    {% fill "my_slot" slot_data="data" %}
    	{% default_fill %}
    	bla bla
    {% endfill %}

Also, and this is related to the topic below, but whichever option we choose, the way I think about it, I think that the default_fill should be rendered with the context where the slot was defined (that's actually how it's done now), NOT the context available inside fill (that's actually how it's done now). Which means that the variables inside the default slot fill can be different from those exposed via slot, and the default fill will still render fine.


That leaves letting people only expose some parts of the slot data to fill templates. Are you sure this is really necessary?
Exposing nothing by default, and then everything with slot_data should be a very large part of all use-cases, don't you think?

Just as a reminder to myself, this all is relevant only for isolated mode of CONTEXT_BEHAVIOR. In django mode, the fill automatically inherited the context of the child component (that defined the slot), so there's no need to "expose" the slot data.

But hard to say, as it very much depends on the component design. From my experience with Vue/Vuetify, accessing the slot's data was common for UI library components that were more complicated like tables (e.g. to customize how table rows are rendered, for which one needed to access the cell value), but for simpler components like layouts (e.g. card), buttons or forms fields, this was not that common. For business logic components, I'd say maybe 30-50% of the time. But again, other people might do things differently.

So, in this case, my hunch would be to go with an explicit API, so it's clear which variable comes from the slot, and which from get_context_data.

@JuroOravec
Copy link
Collaborator Author

JuroOravec commented May 14, 2024

One more idea for accessing slot_data - we could do a bit of magic, similar to the idea with {% default_fill %}, and define a variable that would be populated only when inside a fill, and would raise an error if accessed outside of a fill. E.g. {{ component_vars.slot_data }}.

So this way we wouldn't need to define the slot_data key on the fill tag, but would still make it clear where the variables are coming from.

{% fill "my_slot" %}
	{{ component_vars.slot_data.abc }}
{% endfill %}

@EmilStenstrom
Copy link
Owner

About default template content: I like the first version with default_slot, think it fits nicely with slot_data. No need to use component_vars at all.

About the second point: My suggestion is that we don't change the slot tag, and instead just control it with {% slot "content" slot_data="slot_data" %} <- if slot_data is specified we expose all variables from the slot. Without slot_data defined we would not expose anything. No changes to the slot tag.

@EmilStenstrom
Copy link
Owner

Hmm... the {{ component_var.slot_data }} idea is also a nice one. Then I guess we would put the default template there too? I think that would work as well.

@JuroOravec
Copy link
Collaborator Author

About default template content: I like the first version with default_slot, think it fits nicely with slot_data. No need to use component_vars at all.

Hmm... the {{ component_var.slot_data }} idea is also a nice one. Then I guess we would put the default template there too? I think that would work as well.

I don't have any strong preference between the two. @dylanjcastillo what do you think?

To recap, the default_slot approach would be:

{% fill "my_slot" default_slot="default_slot" %}
	{{ default_slot }}
{% endfill %}

The component_vars approach would be:

{% fill "my_slot" %}
	{{ component_vars.default_slot }}
{% endfill %}

Actually, implementation wise, I'm slightly more in favour of default_slot approach, as it follows a pattern that's already known (using kwargs), as opposed to introducing a new concept (that component_vars changes behaviour depending on where in the template it is accessed).

About the second point: My suggestion is that we don't change the slot tag, and instead just control it with {% slot "content" slot_data="slot_data" %} <- if slot_data is specified we expose all variables from the slot. Without slot_data defined we would not expose anything. No changes to the slot tag.

Yeah, that's what I meant too, if I understand correctly. But correct me if I misunderstood:

  • slot_data not set => no variables exposed
  • slot_data="slot_data" => variables exposed via the variable slot_data

@EmilStenstrom
Copy link
Owner

EmilStenstrom commented May 14, 2024

In you original suggestion you had {% slot "my_slot" key1=value1 key2="value2" default %}. I suggested that we skip that part and pass on all variables. If you don't specify slot_data, they get thrown away.

{% slot "my_slot" default %}{{ input }}{% endslot %}

context_behaviour="django"

{% fill "my_slot" %}{{ input }}{% endfill %}  <-- Works!

context_behaviour="isolated"

{% fill "my_slot" %}Input not accessible{% endfill %}  <-- Error!

context_behaviour="isolated"

{% fill "my_slot" slot_data="slot_data" %}{{ slot_data.input }}{% endfill %}  <-- Works!

@JuroOravec
Copy link
Collaborator Author

Right, I understand now. That's where I as a component author would prefer to specify the variables passed to slot tag explicitly. Otherwise, if I have a public package that manages e.g. 20+ components, I'd be harder to manage, and I would need to come up with custom workaround like prefixing private vars with underscore, to distinguish between private and public vars.

@EmilStenstrom
Copy link
Owner

@JuroOravec Could you give a concrete example where it's hard to manage? Maybe if you have an existing component that would benefit from this?

I just feel that we're adding so much extra work for people: First you have to specify what variables to expose, then you have to specify the variable they land in. This is for every slot, in every component you have, for everyone that uses the library (isolated is default now). If you do have a library with 20+ components as a component library author (are there django-component libraries?) you only have to care about the public API for all your slots, but this is still a lot of extra work.

@EmilStenstrom
Copy link
Owner

I wonder if we should switch back to "django" as the default context_behavior, and have isolated be a mode the we reserve for everyone that want the kind of detailed scope control you are after? I think that might be the best path here, that both gives you the control you want, but does not complicate things for beginners?

@JuroOravec
Copy link
Collaborator Author

To give you an idea what I'm talking about. So far I have 10 "generic" material design components (below), and I'd like to grow that collection.

None of the components uses "scoped slots" (slot data) as of now, because I haven't implemented that in my "fork". So I also don't feel too concerned about people having to write too much - people can design slots also without the "scoped slots" feature (just differently, and with maybe a bit less flexibility).

But to give you an idea what kind of data could be made available through the slots, I link to corresponding Vuetify components. In the links, slots with never means that they expose no data. The linked Vuetify components also serve as an example of what I'd like to be able to build within django-components.

For each component, I also include how often I feel I had to access the "slot data" when I worked with it in Vue/Vuetify:

  • breadrumbs - rarely
  • button - N/A (no data passed to slots)
  • dialog - always (activator slot must pass on props to be interactive)
  • text field - rarely
  • icon - N/A (no data passed to slots)
  • menu - always (activator slot must pass on props to be interactive)
  • select - sometimes
  • table - often
  • tabs - rarely
  • list - sometimes

Note: You see that it feels really variable.


As per this:

I just feel that we're adding so much extra work for people: First you have to specify what variables to expose, then you have to specify the variable they land in. This is for every slot, in every component you have, for everyone that uses the library (isolated is default now). If you do have a library with 20+ components as a component library author (are there django-component libraries?) you only have to care about the public API for all your slots, but this is still a lot of extra work.

The way I see it is like functions - sure, you don't need to annotate the input / output types, and just use def fn(*args, **kwargs) everywhere, but you'll have hard time maintaining that in 6 months.

With slots it's similar - we don't have to explicitly define the exposed variables, but then someone else might come across the file, and be like "hmm, these variables aren't used anywhere in this file, I might as well delete them".

Consider the example below, where we'd implicitly pass the context to slots. Can you tell which one of "def" or "xyz" is used in the slots, and which one can be modified or removed? You would have to check for all uses of all slots to figure out if something is used or not, and that's difficult to maintain.

@component.register("my_comp")
class MyComp(component.Component):
	template = """
	<div>
		{{ abc }}
		...
		{% slot "slot_123" %} {% endslot %}
		...
		{% slot "slot_456" %} {% endslot %}
	</div>
	"""
	def get_context_data(self):
		return {
			"abc": 1,
			"def": 2,
			"xyz": 3,
		}

And same applies to accessing the data in fills. If I don't explicitly define where my variables are coming from, then I won't be able to tell if, inside a fill tag, I'm using a variable that came from the slot, or from get_context_data.

It's the same kind of issue with Django components before we've added the "isolated" mode. The isolation and explicit interfaces means you can safely work on one component, knowing that you will not accidentally break other parts of the system.


So IMO there's 3 aspects to this:

  1. Explicit vs implicit
  2. Isolation (as in, knowing I won't break anything as long as I keep the interfaces the same)
  3. Verbosity

I believe this feature should be explicit and isolated (implicit and non-isolated is what you get with the vanilla "django" behavior).

Low verbosity is nice, but that's a secondary aspect.

To achieve the explicitness and isolation-ness, there is some minimal amount of information that needs to be provided - So IMO defining the data for each slot (or not if none given), and declaring whether you're accessing slot data from within a fill (or not), that's required.

Where I think there is a leeway is how that info is declared. E.g. what in django-components could be achieved as:

{% fill "my_slot" slot_data="data" %}
	{{ data.message }}
{% endfill %} 

Then in Vue they squeezed the fill name and slot data access into one:

<template #default="{ message }">
  {{ message }}
</template>

@JuroOravec
Copy link
Collaborator Author

I wonder if we should switch back to "django" as the default context_behavior, and have isolated be a mode the we reserve for everyone that want the kind of detailed scope control you are after? I think that might be the best path here, that both gives you the control you want, but does not complicate things for beginners?

Your call

@EmilStenstrom
Copy link
Owner

I see what you mean, great example with having an existing component context dict, and wondering if it's used anywhere.

I think we should go with your suggestion and be very specific when in isolated mode, but at the same time switch back to "django" as the default. I fear that the kinds of issues you bring up are for later in a project lifetime, are great for maintainability, but might hinder some people that are getting started and don't (yet) need strict isolation. They can always add it later on.

Does this make sense?

@JuroOravec
Copy link
Collaborator Author

Yup, makes sense. While I can't relate to newcomer Django user who's new to frontend templating (because I got to Django already being familiar with the concepts), there's plenty of people who are trying to wrap their heads around Vue's scoped slots, so I get what you're saying.

@EmilStenstrom
Copy link
Owner

EmilStenstrom commented May 15, 2024

So, new plan:

context_behaviour="django"

{% slot "my_slot" default %}{{ input }}{% endslot %}
{% fill "my_slot" %}{{ input }}{% endfill %}  <-- Works!

context_behaviour="isolated"

{% slot "my_slot" default %}{{ input }}{% endslot %}  <-- Exposes no variables to fill tags
{% slot "my_slot" default input=input %}{{ input }}{% endslot %}  <-- Exposes input variable to fill tags
{% fill "my_slot" %}{{ input }}{% endfill %}  <-- Error, doesn't expose slot variables to template
{% fill "my_slot" slot_data="slot_data" %}{{ slot_data.input }}{% endfill %}  <-- Works!

Does this match your idea of how this works?

@JuroOravec
Copy link
Collaborator Author

Yup, that's what I had in mind!

@JuroOravec
Copy link
Collaborator Author

@EmilStenstrom I've updated the MR to match the API in your latest comment, so the MR is now ready for review. 🎉

I also made an issue for changing the context_behavior default, so we tackle that separately - #498

@JuroOravec
Copy link
Collaborator Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants