htmlx-org / HTMLx

One Template to rule them all
588 stars 9 forks source link

RFC: slightly expand "as" expressions #3

Open jamesarosen opened 6 years ago

jamesarosen commented 6 years ago

One feature that Ember's Handlebars offers is the ability for blocks to yield an object:

{{#my-details as |details|}}
  <button onclick={{details.toggle}}>
   {{#if details.isOpen 'Close' 'Open'}}
  <button>

  {{#if details.isOpen}}
    the content that gets toggled
  {{/if}}
{{/my-details}}

The HTMLX feature that comes closest is {#each cats as cat}. If you loosen the syntax just slightly to allow a JavaScript express instead of each cats, but still only allow a single yielded object, the above could be written as

{#new MyDetails() as |details|}
  <button onclick={details.toggle}>
   {details.isOpen ? 'Close' 'Open'}
  <button>

  {#if details.isOpen}
    the content that gets toggled
  {/if}
{/as}
jamesarosen commented 6 years ago

A related feature is the idea of capture. In Ember Handlebars, the above template might more naturally be

{{!-- my-details.hbs --}}
<button onclick={{details.toggle}}>
  {{#if details.isOpen 'Close' 'Open'}}
<button>

{{#if details.isOpen}}
  <div class='details__content'>
    {{yield}}
  </div>
{{/if}}

{{!-- some-other-template.hbs --}}
{{#my-details}}
  the content that gets toggled
{{/my-details}}

That is, we separate the toggling behavior from what is toggled. That requires some way of passing the result of the content that gets toggled to the MyDetails object. For example, HTMLX might specify something like

If the value of the "as" expression is an object that defines a writeable content property, the HTMLX-compatible framework MUST evaluate the inner block, then set the content to the result. The framework MAY invoke set content() after any update to the content.

Rich-Harris commented 6 years ago

as is a possible addition — I haven't personally found a situation where it's necessary, because (in Svelte at least) you can use a computed property at the top level of a template, which basically serves the same purpose. I'd be tempted to make the opening and closing tags more symmetrical though:

{#with new MyDetails() as details}
  <button onclick={details.toggle}>
   {details.isOpen ? 'Close' 'Open'}
  <button>

  {#if details.isOpen}
    the content that gets toggled
  {/if}
{/with}

or

{#alias new MyDetails() as details}
  <button onclick={details.toggle}>
   {details.isOpen ? 'Close' 'Open'}
  <button>

  {#if details.isOpen}
    the content that gets toggled
  {/if}
{/alias}

More broadly, I wonder if control flow should be flexible enough to allow different frameworks to have their own opinions about this (e.g. Svelte has await blocks, which might not be interesting to other frameworks) — i.e. rather than having a canonical set of #if, #each, #alias etc, everyone can pick the constructs that are suitable for their framework. Though that probably undermines the whole point of this endeavour.

As to your second point, most frameworks solve that problem with components:

<!-- MyDetails.html -->
<button on:click="set({ open: !open })">
  {open ? "Close" : "Open"}
<button>

{#if open}
  <div class='details__content'>
    <slot></slot>
  </div>
{/if}

<!-- SomeOtherTemplate.html -->
<MyDetails>
  the content that gets toggled
</MyDetails>
jamesarosen commented 6 years ago

+1 for with. It at least resembles JS syntax and semantics.

I think you're right that we should maintain a component-centric mindset, but I don't think that absolves us of the need for with or some sort of yielding. For example, in Ember Handlebars:

{{#my-list as |list|}}
  {{list.item 'Foo'}}
  {{list.item 'Bar'}}
  {{list.item 'Baz'}}
{{/my-list}}

This allows components to render context-specific, encapsulated APIs for children. The alternative is to make every component global:

<MyList>
  <MyListItem>Foo</MyListItem>
  <MyListItem>Bar</MyListItem>
  <MyListItem>Baz</MyListItem>
</MyList>

Or

<MyModal>
  <!--
    how can we have this button
    (1) do some logic specific to our context, then
    (2) call modal.dismiss?
    If we're transcluded, we have access to the calling context, but no access to
    the modal's methods.
  -->
  <button on:click="hmm()">
</MyModal>

compared with

{#with new MyModal as modal}
  <button onclick={saveMyThing().then(modal.dismiss)}>
{/with}
Rich-Harris commented 6 years ago

Not sure I understand the point about global components? There's no reason a <MyListItem> couldn't be local to the component that invokes it (indeed, that's the only way you can use a component in Svelte.)

I'm afraid I don't have a clue what that Ember syntax means! Can you explain what my-list and list.item 'Foo' etc are?

jamesarosen commented 6 years ago

There's no reason a couldn't be local to the component that invokes it (indeed, that's the only way you can use a component in Svelte.)

Interesting point! I hadn't considered lexical scoping in a markup framework. I guess that makes total sense.

my-list is the name of a component. {{#my-list as |list|}} opens the component's block and passes it (or whatever it chooses to yield) to the block. list.item would require that the yielded object have an item function. It could do anything, but most likely it's a partially-applied new MyListItem() invocation.

Rich-Harris commented 6 years ago

Thanks. Ah, I think I completely misunderstood what your original proposal was getting at. I thought it was simply about aliasing values that are unwieldy to repeat, or that you don't want to keep evaluating:

{#with unnecessarilyLongObjectName.coordinates() as [x, y] }
  <p>coords: {x},{y}</p>
{/with}

...not creating a new my-details component. I don't think a new template language proposal should entertain the idea of creating components without angle brackets.

My own view is that content inside a component's 'light DOM' (to borrow WC terminology) shouldn't have any privileged access to that component — in other words these should differ only in the placement of the button, not in what the button can do:

{#if showModal}
  <MyModal>
    <button on:click="set({ showModal: false })">close</button>
  </MyModal>
{/if}

{#if showModal}
  <MyModal/>
{/if}
<button on:click="set({ showModal: false })">close</button>
jamesarosen commented 6 years ago

The problem with

{#if showModal}
  <MyModal>
    <button on:click="set({ showModal: false })">close</button>
  </MyModal>
{/if}

is that you've made how MyModal closes a responsibility of every caller of MyModal. If your modal has an animation, you want that to return a promise, then remove it from the DOM only after the promise resolves. That's why I like giving the caller access to modal.dismiss.

There is, however, a way for these components to yield their API to the caller without any special syntax:

<MyModal on:inserted={(event) => this.dismissModal = event.target.dismiss}>
</MyModal>

<button on:click={() => this.save().then(this.dismissModal)}>

It's not as pretty, but it doesn't impose new requirements on implementers of the HTMLX spec.

Rich-Harris commented 6 years ago

is that you've made how MyModal closes a responsibility of every caller of MyModal

Not necessarily — it's up to the framework whether an #if block becoming falsy means that its contents should immediately be yanked out of the DOM, or transitioned out gracefully.

See here for an example: https://svelte.technology/repl?version=2.3.0&demo=transitions-fly.

More to the point, without an #if block how do you make the modal appear in the first place? I'd argue that a self-destroying component is an anti-pattern — but we're veering away from a syntax discussion and into something entirely else, so we should probably stop!

jamesarosen commented 6 years ago

but we're veering away from a syntax discussion and into something entirely else, so we should probably stop!

Let me try a different approach then: why is each special? Why is there

{#each foos as foo}

but not

{#with unnecessarilyLongObjectName.coordinates() as [x, y]}

The syntax currently has the concept of yield, but not in any arbitrary or extensible sense. Array is far from the only Functor in the world.

Put that way, though, perhaps each is just HTMLX for map. As long as the foos in {# each foos as foo} implements map it's an HTMLX-compatible Functor. A Nothing would invoke the block zero times. A Maybe would invoke it zero or one times.

In spec language, this might be something like

The argument to each MUST be a Functor (an object that has a readable map method). Implementing frameworks MAY place additional requirements on the argument to each.

Rich-Harris commented 6 years ago

each is special, alongside if, because conditionals and loops are sine quibus non — as long as you have those, you can do basically anything else:

{#each Object.entries(object) as [key, value] }
  <p>{key}: {value}</p>
{/each}

{#each [...iterable] as item}
  <p>{item}</p>
{/each}

Though as I said earlier with could be added — I just haven't seen personally seen much evidence that it's necessary (and that's after implementing it in Ractive many years ago; it turned out to be pretty much wasted surface area).

HTMLx doesn't have an opinion about how expressions are evaluated though, and without that I don't think it's possible to say something like 'the argument to each must be a functor'. In Svelte's case, the expression doesn't have to have a map method, it just has to have a length property, and other frameworks could have their own opinions about that:

https://svelte.technology/repl?version=2.3.0&gist=5e9d63b864d38b82b15af82d0c6925c8

arxpoetica commented 6 years ago

In Svelte, isn't the following currently possible with #each?

{#each MyDetails() as details}...{/each}
Rich-Harris commented 6 years ago

Sure, as long as MyDetails() returns an array-like object

bernardoadc commented 5 years ago

So would it be wrong to do..

{#each [MyDetails()] as details}...{/each}

.. to achieve as behavior?

jmakeig commented 4 years ago

You’d spread the iterable into an Array:

{#each [...MyDetails()] as details}...{/each}

See also, sveltejs/svelte#4289.