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

Styling single element components while retaining element API #6882

Open
philholden opened this issue Oct 26, 2021 · 5 comments
Open

Styling single element components while retaining element API #6882

philholden opened this issue Oct 26, 2021 · 5 comments

Comments

@philholden
Copy link

philholden commented Oct 26, 2021

Describe the problem

I might be missing something but it seems very difficult in Svelte to create components that do nothing more than style basic HTML elements. This makes creating component libraries hard because single element components (buttons, inputs, hr, etc) are the foundation layer of creating a component library. Let's say you want to create a component that does nothing but apply some styles to a button or input. Those styles might also be affected by props. At first it seems trivial:

<script>
  export let primary
</script>

<button class:primary ><slot/></button>

<style>
  .primary {
    background: brown;
    color: white;
  }
</style>

The problem is the button can't be clicked ... easily fixed:

<button class:primary on:click><slot/></button>

Actually there are a bunch of handlers our users might need. I hope I do not miss any and no new ones get added to the HTML spec:

<button
  class:primary
  on:click
  on:mouseup
  on:mousedown
  on:mouseenter
  on:mouseleave
  on:touchdown
  on:touchup
  on:touchmove
  on:pointerdown
  on:pointerup
  on:pointermove
  on:focus
  on:blur
  on:keydown
  on:keyup
  on:transitionstart
  on:transitionend
><slot/></button>

Now we have added these handlers the compiled output to my component is massive. It shows I am creating a bunch of listeners that most of my consumers won't use so this is using up memory for each instance. So Svelte is no longer feeling very svelte:

if (!mounted) {
dispose = [
    listen(button, "click", /*click_handler*/ ctx[4]),
    listen(button, "mouseup", /*mouseup_handler*/ ctx[5]),
    listen(button, "mousedown", /*mousedown_handler*/ ctx[6]),
    listen(button, "mouseenter", /*mouseenter_handler*/ ctx[7]),
    listen(button, "mouseleave", /*mouseleave_handler*/ ctx[8]),
    listen(button, "touchdown", /*touchdown_handler*/ ctx[9]),
    listen(button, "touchup", /*touchup_handler*/ ctx[10]),
    listen(button, "touchmove", /*touchmove_handler*/ ctx[11]),
    listen(button, "pointerdown", /*pointerdown_handler*/ ctx[12]),
    listen(button, "pointerup", /*pointerup_handler*/ ctx[13]),
    listen(button, "pointermove", /*pointermove_handler*/ ctx[14]),
    listen(button, "focus", /*focus_handler*/ ctx[15]),
    listen(button, "blur", /*blur_handler*/ ctx[16]),
    listen(button, "keydown", /*keydown_handler*/ ctx[17]),
    listen(button, "keyup", /*keyup_handler*/ ctx[18]),
    listen(button, "transitionstart", /*transitionstart_handler*/ ctx[19]),
    listen(button, "transitionend", /*transitionend_handler*/ ctx[20])
];

...

Then we need things like ids and data attributes ... but then there is that warning in the docs that spreading props is not ideal and can't be optimized:

<button {...{$$props}} on: ... ...>Click me!</button>

So now I have a deoptimized component and all the cool stuff in Svelte does not work on my Button

<Button use:proximityFetch transition:fade><slot/></Button>
Error:
Transitions can only be applied to DOM elements, not components
Actions can only be applied to DOM elements, not components

For inputs binding does not work if you do a props spread:

<Input bind:{value} />

I just wanted to make the button brown when it was primary and I had to do a ton of boilerplate for every event an element might receive and I lost a lot of functionality (use, transition). It means creating a styled button is for advanced users not beginners. Styled buttons, links, inputs is the bread and butter of building websites and it feels hard to do this well in Svelte.

At the moment because single elements wrapped in components lose Svelte powers I find myself reapplying utility classes to basic elements rather than consolidating style logic into reusable atoms in a component library.

Describe the proposed solution

It would be great to have a special thing that was just for styled elements. I'd be happy if all it could do was modify style and class props and perhaps enforce a type for input. All other props, event handlers, actions, bindings get automatically forwarded to the element. I.e. it allows users to create things that have the same API as Svelte elements not Svelte components. Think of them as middleware or a proxy. They may need a different file extension or compiler options. They might look like this but I am open to other ways of achieving the same thing:

<script>
  export let primary
  export let x
</script>

<svelte:element
  default='button' 
  style={style => `tranform: translate3d(${x}px,0,0);${style};`}
  class:primary
/> 

<style>
  .primary {
    background: brown;
    color: white;
  }
</style>

It can then be used to apply styling logic to an element

import {Button} from "./Button.svelte"
import {foo} from "./foo.js"

<Button on:mouseenter={console.log} use:foo />Click</Button>

Nice to have but not essential

An as prop:

<Button as="a" href="/">Ok</Button>

There might be an allow list and disallow list for element types.

Alternatives considered

In some cases allowing multiple supporting elements would be handy e.g. checkboxes are often wrapped in a label however once we allow multiple elements we do not know which element to send which forwarded which attributes to. Typically you would want values to go to the hidden input and animations and transitions to be forwarded to the container. So it seems best to limit this to single element components only.

Another alternative might be middleware components, they do not render anything but parents can manipulate the props, handlers and styles of their children until finally the props, handlers and styles reach a single element. So the source of an Input elementComponent might look like this:

<focusStyles>
  <inputStyles>
    <eventLogger>
      <forceType type="number">
         <input />
       <forceType>
     <eventLogger>
  </inputStyles>
</focusStyles>

This requires two new kinds of components elementComponents and propMiddleware components.

Importance

would make my life easier

@Prinzhorn
Copy link
Contributor

That's one use-case I want to use declarative actions for sveltejs/rfcs#41 . Because creating wrapper components just for styling feels wrong to me.

styled/button/primary.svelte

<svelte:target class:primary /> 

<style>
  .primary {
    background: brown;
    color: white;
  }
</style>

And then:

<script>
  import primary from 'styled/button/primary.svelte'
</script>

<button use:primary></button>

You can't really do that with imperative actions right now because handling of <style> blocks with consolidation and class name generation is missing.

@dummdidumm
Copy link
Member

To break this down:

  • you don't want to use $$props because of the performance implications. But use cases like this are exactly what this special variable is made for. The performance hit is probably neglectable
  • you want to bubble all events: there's discussion about how to solve this in other issues (on:* attribute to delegate all events #2837) as well as a draft PR which would allow to do this. A more general API would be preferred because it can also be useful in other circumstances
  • you want to be able to apply actions/transitions etc to a component. This was requested in various ways already and the stance on this so far is "no". Reason: components are self-contained and have a strict boundary. Loosening that would make components harder to author as there's now more ways to use that component. It also fails the case where people might want to assign different transitions to multiple different elements of the component. You explicitly ruled that case out but I guarantee it will come up if something like this is implemented. Btw you can do this today using props to pass transitions etc, which I agree does not feel idiomatic. Personally I'm not saying something like this will never happen, but it needs more thought and discussions.

@philholden
Copy link
Author

philholden commented Oct 27, 2021

@dummdidumm

  • I am happy enough using $$prop and in React spreading props is going to pass through everything (e.g. handlers). However spreading props does not give you the ability to bind.
<input {...$$props} />
<script>
  import MyInput from "./MyInput.svelte"
  let value
</script>
<MyInput bind:value />
  • on:* would be helpful for events but would only get you a little further.
  • I am asking for a new kind of component to be introduced the elementComponent it only allows you to create a component with a single element and only allows you to apply style to that element by classes and style prop. By limiting the scope we get some guarantees that make transparently spreading all bindings, handlers, props, transitions and actions safe and predictable. This limited scope means these elementComponent can have the same optimisations that Svelte's built in elements have. Think of them as a way to apply style to the built in elements that ship with Svelte without changing their API (other than adding props that affect styles). Because there is only one element in the component we always know which element should be transitioned, bound, styled etc.

I think most of the need for spreading props, handlers, bindings is on single element components. For compound components spreading is mostly an anti pattern (it makes typing, documentation and testing hard). Compound components should aim to have a narrow interface. But HTML elements start with a massive API surface area this is very hard to recreate by hand and needs a lot of knowledge. Nearly all the time what we want is just something that behaves the same as <button> or <input> but has our custom styling applied. This is something beginners will want. So we are making a trade. Trading the flexibility of being able to have multiple elements in a component in order to get the simplicity keeping the base element API. The compiler can do the wiring.

<script>
  export let primary
</script>

<svelte:element
  default='button' 
  class:primary
/> 

<style>
  .primary {
    background: brown;
    color: white;
  }
</style>

vs

<script>
  export let primary
</script>

<button
  class:primary
  on:*
  {...$$props}
><slot/></button>

<style>
  .primary {
    background: brown;
    color: white;
  }
</style>

In the second example bind:this is broken use:action is broken bind:value does not get wired, transitions and animations don't work, we also lose clientWidth etc. The idea is they would just work out of the box for the first example.

I have written three component libraries in React. Most of this work has been getting the single element components right. I love Svelte because the reactive nature of components removes most of the performance code needed in React. Transitions in Svelte are so much easier too. I am doing all my side projects in Svelte. I'd love to move to Svelte for the next iteration of our companies UI library however the story around wrapping elements and keeping their API makes me hesitate.

@philholden
Copy link
Author

@Rich-Harris Congratulations on new job. Any thoughts on <svelte:element> proposal?

Is it feasible to make this perform as well as native Svelte elements both in terms of terse compiled output and runtime performance?

@AlexRMU
Copy link

AlexRMU commented Jan 3, 2025

See also #2226

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

No branches or pull requests

5 participants