sveltejs / svelte

web development for the rest of us
https://svelte.dev
MIT License
79.99k stars 4.25k forks source link

Proposal: Top-Level <:Body> Injections #1133

Open ChrisTalman opened 6 years ago

ChrisTalman commented 6 years ago

Imagine I have the following component, <TopLevelThing>.

<div class="top-level-thing">
    <p>Top level stuff.</p>
</div>

It's intended to be used at the top-level of the <body>.

However, I might want to use it as part of a component which is deep inside the <body>, nowhere near the top-level.

This could be achieved with a special tag like the following.

<:Body>
    <TopLevelThing />
</:Body>

Svelte could inject this into the top-level of the <body> alongside other elements.

The advantage is that it would retain all the functionality of a component: the lifecycle, properties, and being part of the component organisation and structure.

One common use case for this could be modals. They can often relate very specifically to the organisation and structure of a chain of components. However, perhaps because of styling or some other reason it may be more logical for the modal structure to be located at the top-level of the <body>, rather than within the parent component structure.

It's quite possible that an alternative approach, using current Svelte features, is most appropriate to these kinds of use cases. However, this approach came to my mind, and I thought it was worth airing.

As an aside, this feature does raise some additional questions. For instance, if a <:Body> tag sounds good, would it make more sense to have a more generic <:Injection> tag instead, which could be used with more than just the <Body>? Also, might developers find it useful in some cases, of their choosing, for <:Injection> tags to persist even when their parent components are destroyed?

jamesbirtles commented 6 years ago

This would be useful for me too, I've just been getting the ref and appending it to the body manually.

Conduitry commented 6 years ago

I think a special <:Body> tag makes sense. We already have a <:Head> tag. And as you said, with things like modals sometimes it makes sense to insert stuff at the top level in the document.

<:Inject> is interesting. I'm not sure how generally useful it would be. How would we specify where to inject the markup? By passing a CSS selector? By passing an actual DOM reference? My thought is that we should hold on this until people have a specific need, and we can find out what those needs are.

As for injected tags that persist after the injector is destroyed - my feeling is that should be done manually in javascript in oncreate. Again, we can revisit if there's some pressing need, but in the meantime I don't think adding special syntax for that is a great idea.

Conduitry commented 6 years ago

Hmm I was just thinking about the sorts of complications this would cause with SSR. Not sure how to best handle that.

mrkishi commented 6 years ago

What are the implications on SSR that are different than <:Head>'s?

Conduitry commented 6 years ago

When adding <:Head> support to SSR, the compiled render function had to return an additional string (head) besides the existing html one. Supporting this it seems to me would require adding another string that the render function returns (to be put in the injected body). Each compiled component doesn't know whether it is a top-level one, and also only has access to the return values of the compiled child components. So each component would have to now return three separate strings - the main html, the extra injected body html, and the head html. (Plus also the css I guess.) This makes it a bit more unwieldy to use, as the consumer needs to concatenate two strings to get the body.

However, while writing this, it did just occur to me that we could have an extra internal argument or option to the render function that indicates that 'you are not the top-level component' (and so the component knows to return separate 'main body' and 'injected body'), which would not be passed by the consumer to the top-level component (and so that component knows to return the injected body and main body in one string).

It's a couple more moving parts, and I guess I don't have strong opinions about whether these complications are worth it for this new feature.

mrkishi commented 6 years ago

Oh, I just didn't think adding an extra string would complicate things further than they already are due to <:Head>. :P

But that's a nice idea, although we don't even know where the top-level component would end up so the extra separate body markup would still be needed at least as an option.

But, yeah, not particularly a deal-breaker for me either. I wonder if I could use this to include and deduplicate svg icon symbol definitions automatically within Svelte..

ChrisTalman commented 6 years ago

@Conduitry Concerning injections which persist beyond the destruction of their parent, the use case I had in mind for this was 'closure' animations and handling. For instance, imagine that a modal is injected by a parent, and then that parent is destroyed. From a UX perspective, it would be preferable for the modal to gracefully handle its orphanage, either by displaying one or more closure animations, or by changing the state of the orphan to display a notification indicating that it has been orphaned, prompting the user to close the modal themselves.

Of course, if this kind of functionality is desirable at all, it's not limited to only <:Body> injections. It might also be useful for any nested components whose parents might at any time be destroyed and warrant graceful handling of that destruction. For instance, {{each}} items.

In both cases, the core problem is that you want to retain your nested component structure without involving external JavaScript, while at the same time gracefully handling orphanage.

stalkerg commented 6 years ago

I think it's a good idea and it's really needed for my cases but looks like it possible only for Svelte Framework and not for Svelte Render Engine... Currently, we have no Svelte Framework.

btakita commented 5 years ago

To merge in my comment from #1872...

Maybe there can be a placeholder (e.g. in the template %sapper.body%) at the end of body which will fill in the content of the <svelte:body> instances. As the other components are SSR'd, the buffer for %sapper.body% can be filled.

On that note, would it make sense to have arbitrarily named placeholder buffers for the template? This would allow something like:

<body>
  %sapper.html_prefix%
  <div id='sapper'>%sapper.html%</div>
  %sapper.html_postfix%
</body>
andrewagain commented 5 years ago

I had a message here about how React has portals to do this and I need something similar to make modals & popups work in Svelte.

Then I went on Discord and @Rich-Harris reminded me that I can just use position: fixed. That is solving my use-case very well, so I've deleted my other message.

constgen commented 5 years ago

The problem still exists for flay-out menus that should be position absolutely on the page and not the viewport.

fabian-michael commented 4 years ago

Hi, I really need this too. E.g. for a lightbox or a modal. Only to use position fixed/absolute for this is not a nice solution because you can't guarantee that there is no parent node somewhere which is positioned somehow. The ability to inject e.g. my modal at the end of the body would be great.

stalkerg commented 4 years ago

I think syntax like <div :inject="my_block_id"></div> can be useful. In that case, we should just extend mount and unmount methods.

kikonen commented 4 years ago

I think I've need for this. I need to open popup from from svelte widget, which can be placed inside scrollable table. Thus table is wrapped into div with "overflow" settings. This causes that if table is narrow and popup for widget is opened, then it will be cut-off by by container. So far, I know that only solution likely would be to open popup outside of this container (i.e. within closest modal dialog or document.body context).

However, so far I didn't see how to do it with svelte.

Could I allow svelte to render popup, and then manually detach DOM node of popup and attach it into document.body?

EDIT: I think that I figured out possible solution to my problem which should work with existing svelte logic.

thojanssens commented 4 years ago

@kikonen may I know why you can't use position fixed? It is not affected by an overflow parent, contrary to position absolute.

kikonen commented 4 years ago

Yes, actually I went with position fixed, and thus my specific case was seemingly handled

andrewagain commented 4 years ago

For the record, "position: fixed" does not work if any ancestor has the CSS transform attribute. I have run into this a few times, and so in my React projects I use the portals feature. I'm not sure how I would handle it if the same issue came up in a Svelte project.

Regarding position: fixed: "It is positioned relative to the initial containing block established by the viewport, except when one of its ancestors has a transform, perspective, or filter property set to something other than none"

thojanssens commented 4 years ago

Indeed @ahfarmer. This is cancer... You can also use the DOM API to append the node to the <body> element to keep it positioned absolute. But then binding on dimensions don't work (e.g. bind:clientWidth) as I mentioned here https://github.com/sveltejs/svelte/issues/4036#issuecomment-652495759 So I had to use position fixed, and hope that I'll never have the need to use transform for this specific element's ancestors :-/ In other words, we need portals in Svelte!

pretzelhammer commented 4 years ago

Can we rename this ticket from "proposal: top-level <:Body> Injections" to "proposal: Svelte Portals"? There's no reason to treat the document body as a special case, this pattern should be generalizable to any selectable DOM node on the page, so instead of having:

<svelte:body>
  <!-- html content -->
</svelte:body>

we should have:

<svelte:portal inject="#someIdOrCssSelector">
  <!-- html content -->
</svelte:portal>

where the user can specify some ID or CSS selector in an inject attribute to inject the portal's content into the body or any other element on the page. Then it'll finally be possible to build something like React Modal in Svelte, specifically with the same API as React Modal, and not the painful hacky API of Svelte Simple Modal.

arggh commented 4 years ago

where the user can specify some ID or CSS selector

I would much prefer an HTML element to be the expected attribute for inject primarily, though I'm not opposing of supporting also selectors.

afaur commented 4 years ago

~~Repl example that creates a use:portal put together with the help of Jacob Babich, @dimfeld, Kev, and @pretzelhammer. https://svelte.dev/repl/86ec36c27be2471f86590e0c18c7198c?version=3.23.2 (https://github.com/sveltejs/svelte/issues/4036)~~

DaveKin commented 4 years ago

Not extensively tested yet, but this approach seems to have promise: https://svelte.dev/repl/dd6088388b564a70b710e43504f2c193?version=3.24.0

Similar technique to the previous but without using a target node, it just appends the component to to the document body, effectively de-nesting it.

brunnerh commented 4 years ago

In general, parameters that accept something like CSS selectors should also accept HTMLElement instances, so any reference to an element can be used if necessary, e.g.

<svelte:portal inject={document.body}>
  <!-- html content -->
</svelte:portal>

Actually, this functionality should probably come first and the CSS selectors are optional. You can always do a query manually, but you cannot get from a query to every element (e.g. elements currently not attached to the DOM).

<svelte:portal inject={document.querySelector('#selector')}>
  <!-- html content -->
</svelte:portal>
brunnerh commented 4 years ago

@afaur That leaks memory and the state seems completely unnecessary anyway. What is it for?

The simplest version of the action probably looks like this:

export function portal(node, targetNode)
{
    const portalChildren = [...node.children];
    targetNode.append(...portalChildren);

    return {
        destroy()
        {
            for (const portalChild of portalChildren)
                portalChild.remove();
        }
    }
}

(This uses DOM nodes and keeps the original content at the target node as it is. REPL that accepts CSS and nodes)

afaur commented 4 years ago

@brunnerh "That leaks memory... What is it for?" - Looking back at this I can't remember why state was added. - The repl may have other issues that have not been addressed.

I like your example. It seems to work well.

I tried swapping out another project with this alternative version, and it didn't seem to work right. The suggested approach is definitely less complex, but I need some time to verify it.

Update: I was having an issue with a template tag. Made an update that seems to fix it.

mgrisole commented 3 years ago

I needed this for the same reason as @fabian-michael and svelte-portal helped me out until an official solution is available.

D-Nice commented 3 years ago

I had a more complex and dynamic component (paginated modal w/ sub components) for which I was able to build upon this technique, the one as-is did not work.

Not extensively tested yet, but this approach seems to have promise: https://svelte.dev/repl/dd6088388b564a70b710e43504f2c193?version=3.24.0

Similar technique to the previous but without using a target node, it just appends the component to to the document body, effectively de-nesting it.

Utilized the afterUpdate lifecycle hook instead, technically only ran it once, added a conditional that would make sure it ran only once, but reset said conditional whenever the modal may be hidden, as it would need to be re-appended in cases where it was hidden, then re-displayed, or else it would be nested again.

aradalvand commented 3 years ago

Are you considering adding this to Svelte as a native feature or do you believe that a simple action like the ones mentioned above is already good enough?

I've not yet worked with the actions personally but they seem to be a sub-optimal solution. A native feature sounds nicer.