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

Allow indicating component "root" element so actions may be used on the component tag #5218

Open
samclaus opened this issue Jul 30, 2020 · 15 comments
Labels
awaiting submitter needs a reproduction, or clarification temp-stale

Comments

@samclaus
Copy link

Is your feature request related to a problem? Please describe.

I cannot augment third-party components with actions because components in Svelte do not have an implicit host/root element at runtime. The most simple example I can think of is a button component from a library that does not support tooltips, but I would like to add a tooltip to the button.

<script>
    import MatButton from "svelte-material";
    import myTooltip from "../actions/tooltip";
</script>

<!-- Obviously, tooltips are not as relevant for text buttons but I am simplifying the example -->
<MatButton use:myTooltip={"Click me!"}>
    Login
</MatButton>

The above code does not compile because there is no host element at runtime for the MatButton so there is no target for the action. Thus, the MatButton component must support a tooltip property or I'm out of luck.

Describe the solution you'd like

I would like components to be able to optionally designate an element in their markup as the root/host element so that actions used on instances of the component get forwarded to that element. In my example, MatButton.svelte might look something like this:

<script>
    // (omitted)
</script>

<!-- Notice this button is marked as the host -->
<button svelte:host>
    <slot/>
    <div class="ripple"></div>
</button>

Because the MatButton component gets replaced by a single button element, I think it is very intuitive for users of the library to think of the MatButton and the button element it gets replaced by as one and the same.

At first, I thought the smartest solution would be to automatically allow actions on a component provided its markup has exactly one root element. However, I realized there might be situations where a component has multiple root elements but only one is visible at runtime and is the 'primary' element while the others are there for intercepting focus or some other shenanigans, etc. Besides, it's probably better for component authors to be explicit about whether or not their component maps to a specific runtime element. Thus, I arrived at the svelte:host marker attribute.

Describe alternatives you've considered

  1. Implicitly creating a runtime host element for every component at runtime and injecting components' markup into their host elements. Just kidding! I think Svelte's approach where it replaces component instances with the component markup is vastly superior to Angular and the other frameworks. It gives the developer more control over what the DOM structure looks like at runtime—which means better performance and fewer CSS headaches, and also allows the developer to create very powerful recursive components. Fun Fact: Angular ended up having to work around this madness via attribute components (like <button mat-button> instead of <mat-button>) so that the resulting DOM could be less convoluted and more semantic.

  2. For my simple tooltip example, I could create a TooltipHitbox component with a <slot/> inside a <div use:myTooltip={tooltipProp}> and then wrap MatButton instances with that component. This would create unnecessary wrapper elements at runtime, potentially causing issues with styling, and is also needlessly verbose and obnoxious.

How important is this feature to you?

This feature is not a dealbreaker for me as I feel it is the only bad tradeoff to Svelte's replace-with-markup approach for components. That said, it does make third-party components less extensible because you cannot use actions on them and you cannot forward stuff to their internal markup within your own templates. This means you either have to use a jank workaround (see my first alternative solution) or you end up writing your own version of a library component just because you need to apply an action to the rendered element.

@antony
Copy link
Member

antony commented Aug 3, 2020

If I understand this correctly - if you control the markup of the child component, you can easily add an action to it which is passed as a prop, so this doesn't really change anything other than adding API surface for something which, should the author of the child/third-party component wish, they could expose an API for anyway.

@antony antony added the awaiting submitter needs a reproduction, or clarification label Aug 3, 2020
@samclaus
Copy link
Author

samclaus commented Aug 3, 2020

@antony Yes, I understand that. The point of the feature is to not rely on the third-party author of the child component to add a prop for every action under the sun. Rather, they could just mark a recipient for actions on the component (assuming there is a viable target element), and then consumers of the library could extend the component using whatever actions they desire.

Relying on the component author to implement a prop for every desired action is not decentralized at all as it means they must bake every feature directly into the library. This has already forced me to forgo Svelte Material because I would like to add some actions to their components but I cannot and it does not make sense for them to cater to my specific use-case by baking random stuff into the library used by everyone.

@Conduitry
Copy link
Member

They don't need to add a prop for every action. The action itself can be passed in as a prop.

<script>
  export let action;
</script>

<div use:action>whatever</div>

The argument for the action can be another prop or can be part of the same prop.

@samclaus
Copy link
Author

samclaus commented Aug 3, 2020

Okay, that is interesting. It makes sense since an action is just a function that could be passed as a value anywhere. So let's say I want to apply multiple actions. I could (presumably) write the following:

multi-action.action.ts

type ActionFn = (target: HTMLElement, opts?: any) => undefined | ActionCallbacks;

interface ActionCallbacks {
    update?: (opts?: any) => void;
    destroy?: () => void;
}

/**
 * Simple action which runs multiple actions on an element.
 */
export default function multiAction(actions: [ActionFn, any][]): ActionFn {
    return function(target: HTMLElement): ActionCallbacks {
        const handles = actions.map(([fn, opts]) => fn(target, opts));

        return {
            destroy(): void {
                for (const handle of handles) {
                    handle?.destroy?.()
                }
            }
        };
    }
}

MatButton.svelte (from third-party library)

<script>
    export let action;
</script>

<button use:action>
    <slot/>
</button>

MyComponent.svelte

<script>
    import MatButton from "@smui/button";
    import multiAction from "../actions/multi-action.action.ts";
    import myTooltip from "../actions/tooltip.action.ts";
    import readOnHover from "../actions/read-on-hover.action.ts";
</script>

<!-- Where readOnHoverOpts is some options object which might change reactively (not shown here) -->
<MatButton action={multiAction([[myTooltip, "Click me!"], [readOnHover, readOnHoverOpts]])}>
    I'm a cool looking button
</MatButton>

I'm assuming the above would be less than ideal performance-wise. If my intuition is correct, whenever readOnHoverOpts changes in MyComponent.svelte, Svelte will re-evaluate the multiAction(...) call and pass the new result to the action prop of MatButton.svelte. This will cause the previous multiAction instance to be destroyed and the new one to be executed with the <button> element.

I have read through the API docs and nowhere did I see a mention of a way to apply an array of actions to an element. Thus, MatButton.svelte could provide action1, action2, etc. props but that is not really a solution. So with MatButton.svelte providing only an action prop, I am forced to use my multiAction function which does not support updating action parameters without destroying the existing set of actions and recreating them completely (once again, assuming my intuition is correct).

I also don't like that this solution is not really visible to the Svelte compiler whatsoever and maybe misses out on some optimizations? Like if the Svelte compiler knew you were applying 3 particular actions it could generate code to apply and update each of those actions individually vs. just having a runtime loop over an array of actions and having to check if any of the actions in the array were changed to a different function entirely.

The more I think about it the more I recognize this is a hard problem. It just seems like a proper method to apply actions to a component's internal markup from another component's markup would be much more optimizable and hygienic from an app-developer standpoint. That said, I don't know much about the Svelte compiler implementation and my tingly feelings tell me this would be a lot of work to implement. I am very grateful for Svelte as it is now so I am really just looking for people's thoughts on this particular problem. Maybe forwarding an action prop (in combination with something like multiAction if multiple are needed) is by far the sanest approach. Please let me know your opinion given my concrete examples.

@TylerRick
Copy link

TylerRick commented Sep 25, 2020

I don't think we necessary need to be able to specify a root element(s). I'm not necessarily opposed to the idea; just wanted to point out the possibility that it could still be useful to have actions that work for components even if the action function only had a reference to the component and not to any root element(s) (see use case below).

(The main reason I would want the ability to specify root element(s) would be for easily applying CSS classes to the root element(s) of child components (this would address #2888 (comment), #2888 (comment), etc.) )

  1. If we did add that ability, I'd want to be able to specify multiple "root elements" and have the specified actions automatically applied to each of them.

  2. Don't forget that there could also be 0 root elements (like if the child component's template had nothing but a <slot />).

  3. I really do wish there was a way to make actions that could apply to components.

I realize that the current definition of an action is in terms of an element, but I think it could be changed to work for components too.

This would let you do things like:

  • attach event handlers (on:whatever) to the component (from the action function), and have it work exactly the same as if you had passed on:whatever={handler} directly to the component that is the subject of this action/trait/enhancer/smoosher.
  • define other things to happen on component lifecycle events (onMount, onUpdate) (I don't have any examples of what I'd use this for; just brainstorming)

In particular, what I want to do is something like this (a use:attachEvents={eventProps} action) — but for components. I just don't know if there's a programmatic way to attach event handlers to (there must be one internally, but probably not one that's exposed currently). In other words, if all we had a reference to is the component instance, how would you translate/adapt this:

node.addEventListener(e, f)

to programmatically (with JS rather than via the template syntax) add an event listener to a component?


They don't need to add a prop for every action. The action itself can be passed in as a prop.

That's awesome that you can do that (I didn't realize that), but the main problem with that approach is that it only works if you own the component that you want to add your behavior/action to. How do you add an action to a component if you don't own the component that you want to add a behavior/action to (that is, if you import it from a library)? (Attaching event handlers is the main use case I have for this.)

There needs to be a way to affect child components without their cooperation (as @syntheticore aply put it). Well, you already can attach those event handlers to a child component without their cooperation if you explicitly list them out every time (so it's not like I'm proposing some new way to break encapsulation and give you more control over something inside your child component). This is more about bundling some behavior together into a reusable function, letting you create reusable behaviors/hooks/actions and avoid duplicating the code that provides that behavior/pattern (by explicitly listing out the same list of event handlers every time you want to reuse this pattern).

This is an area where React really has an abundant supply of features/abstractions to allow reusability (HOCs, hooks, spreading props that may include event handlers (since they are simply props (onChange) like any other prop)) and Svelte feels like it is lacking... I want React hooks — in Svelte. (To clarify: React doesn't actually provide a way to apply hooks to child components (you use hooks in your own component), so that's kind of a bad example/analogy, but they do provide a nice way to bundle reusable behavior into a function, kind of like Svelte actions, so they're still the closest analogue I can think of to Svelte actions in React. My point is I do somehow feel like that powerful feeling that you can extract literally just about anything from your specific component into some generic reusable construct — be it a component (which is the main solution to reusability that Svelte provides), or a hook (this seems missing in Svelte), or a HOC (which might eventually be possible in Svelte through inline components, though probably not since they will probably only be possible from within a .svelte template and not from within a function in any old .js file) — is somehow missing, coming to Svelte from React...)

Sorry, I should probably start a new issue/proposal for this (since the OP's issue/proposal is specifically about applying actions to a component's root element(s))...

@intelcentre
Copy link

I could see that access to an elements attributes/properties etc could be considered a corollary to the notion of a slot.
Where a slot allows IOC for element contents, similar IOC systems could make sense for the elements attributes/properties/actions etc

Similar to slots, it would make sense to have both named and unnamed "element" slots.

Some imagined use cases for this is in complex elements such as input's where there are myriad properties and implementing logic for all of them in a component would have a large overhead but little benefit.
If instead the components input control had an "element" slot, the component user could easily extend the functionality in a meaningful and relatively safe way.

@samclaus
Copy link
Author

@intelcentre I like the way you're putting it.

@TylerRick I half-agree with what you're saying but I would also like the Svelte team to feature-gate very strictly because an influx of features is a surefire way to end up with another React or Angular and I am NOT saying that with a positive connotation.

@Conduitry I have been working on a large Angular frontend for years now, and the more I work with component libraries like Angular Material and watch them try to add every feature under the sun to accommodate the myriad use cases developers, the less I like the library because I don't need any of that junk and it just adds overhead both in terms of bundle size and documentation. Thus, I have come to the conclusion that because user interfaces are so diverse, the best answer is often to just reinvent the wheel and make your own components from scratch which tailor to your own use case--and I believe Rich Harris mentioned the same belief in one of his talks. That said, rewriting components which are 90% similar all the time is a very pessimistic, conservative approach. Does the Svelte team have any ideas about how to make components more extensible without bloating Svelte?

The only way I could think of is a built-in method to expose the root elements of a component to library users, but I now realize I could already do this by binding internal component elements to exported variables and then component users can bind the component instance and access those variables.

Please offer your thoughts and close this issue if it is a dead-end. 😃

@stale
Copy link

stale bot commented Jun 26, 2021

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the stale-bot label Jun 26, 2021
@stale stale bot removed the stale-bot label Jun 26, 2021
@stale stale bot removed the stale-bot label Jun 27, 2021
@probablykasper
Copy link

I'd love to have this so I could add a class to a component, to for instance add some margin

@wickning1
Copy link

I've built a fair number of library components at this point and the svelte:host proposal would make things so much cleaner.

For instance, say I want to make a Form component that has a specific way of managing state and communicating with the server. I'd like it to go ahead and create the <form> element so that I can hook into its on:submit event and bind:this to attach a MutationObserver. I can reorganize this so that my user creates the <form>, but then they have to send me an onSubmit and an HTMLElement and on and on as I add features.

So if I'm a user of the Form component and I need to add an aria attribute to the <form>, or observe its size, or attach an action, I'm out of luck. I need to go back to the Form component and add support for all those possibilities. I try to make my components very re-usable, so I find myself passing through dozens of HTML attributes (including class) and actions and forwarding standard HTML events. Somebody might need one of those things. I avoid $$restProps because of the warning about performance optimization, but even if I used that it doesn't cover events, actions, bind:this and bind:width.

Adding a svelte:host could allow the svelte compiler to automatically export all of the svelte:host's props and forward its events. All the possible exports would be known at compile time, so it should not suffer from the optimization caveat that $$restProps comes with.

For actions and bind:this, bind:width, etc, the compiler could automatically add bind:this to the svelte:host element and export it as __svelteHostElement or something. Then when compiling a parent component, the compiler would see use: or bind:this or bind:width on a component (NOT an HTML element) and check to see if that component provides a __svelteHostElement, then attach the action to that instead.

@samclaus
Copy link
Author

@wickning1 Yes! That's what I'm saying! I believe the Svelte tutorial even suggests patterns like always adding a "klass" field exported as "class" to support adding extra classes to a component, which seems like a very frequent symptom of a more general problem. Some components "are" a single element semantically--it's just been enhance and been given children. The fact that Svelte supports multiple root elements for components and you explicitly see all the root elements in the markup to avoid things like weird ":host" CSS selectors (such as in Angular) and you can get away with more powerful DOM is critical imo, but they are missing out on all the cases where Angular's design decisions actually worked out. I feel like we could easily have the best of both worlds.

@bertybot
Copy link
Contributor

This is just a philosophy thing for me but, I think good Components should be indistinguishable from the default browser components. Frameworks, just give us the ability to extend the default components with our own.

I agree with the sentiment in this issue. The fact that Svelte components have inherently different rules than default html components makes Svelte hard to scale. Since right now basically we have Svelte Components for everything and it makes actions completely unusable for us. I get that there is the workaround with the action prop but, I think that introduces a lot of frustrating cognitive load to the language since you need to now learn how to use actions on Svelte Components in a hacky non documented way. Whereas, if they just behaved consistently across the board it would be way easier to work with.

Also with this it would be nice if we could also use it for the bind:this directive. Since, right now I need to export an element prop for every component in my library. I think adding this would solve a lot of problems.

@SeanRoberts
Copy link

This is a pattern that I've become really reliant on in React and I was surprised to see there's no clear analogue in Svelte:

type MyButtonProps = React.ComponentProps<'button'> & {
  theme: 'primary' | 'secondary';
  size: 'sm' | 'md' | 'lg';
}

export default function MyButton(props: MyButtonProps) {
  const { theme, size, children, ...buttonProps } = props;
  // Not pictured: using theme and size to create classNames for the button
  return <button {...buttonProps}>{children}</button>;
}

This gives the user a type-safe way to apply any event or attribute to the underlying button they want, and basically work with the MyButton component as if it were a button. I start most of my projects by setting up these types of components so I agree having a similar pattern in Svelte would be very useful, especially for developers coming from a React background.

@TheCymaera
Copy link
Contributor

If this is added, we could use style-directives on components.

<!-- App.svelte -->
<Card style:padding=".5em">
    Hello World!
</Card>
<!-- Card.svelte -->
<div svelte:host>
    <slot />
</div>

@samclaus
Copy link
Author

samclaus commented Feb 5, 2023

If this is added, we could use style-directives on components.

Yes, and any of the other directives you can apply to normal elements, including actions.

That does make me wonder if the feature would cause optimization headaches because Svelte might currently make a lot of assumptions about knowing all directives applied to an element just from the template it lives in. Whereas now it would need to expose API for parent components to dynamically add in more style/class/action/event/etc. directives.

I do not know anything about Svelte internals and that may have been a reason the Svelte core contributors largely brushed off this feature request when I first made it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting submitter needs a reproduction, or clarification temp-stale
Projects
None yet
Development

No branches or pull requests