This was on my plans for a long time but 1.13 compatibility was holding me back from implementing it.
Ember 2.0 was release the 13th of August of 2015, and it's time to move forward and take advantage of the new features introduced since then. This proposed new API makes usage of some of those new features like contextual components.
Rationale: A critic of the current API
First of all, let's audit the current API of this component and highlight what I think are it's mayor flaws.
{{#basic-dropdown verticalPosition="above" ariaLabel="Terms of use" renderInPlace=true as |dropdown|}}
<p>Lorem ipsum dolor sit amet ...</p>
<img src="gumpy-cat.png"/>
{{else}}
<button>Click me</button>
{{/basic-dropdown}}
On the surface, this API is not very complicated, but there is a few limitations
The yielded public API is only available in one of the blocks
This is a limitation in ember itself. You can't yield arguments to the inverse block, so the end user cannot make use of the public API yielded in the first block, that corresponds to the content of the dropdown when opened.
{{#basic-dropdown verticalPosition="above" ariaLabel="Terms of use" renderInPlace=true as |dropdown|}}
...
{{else}}
<!-- What if I want to use the current API here? I can't -->
{{/basic-dropdown}}
Unexpected order of blocks
It is, at the very least, surprising that the order of the blocks. The reason for that derives the the previous point. Since I the yielded public API is only available to one of the blocks, I had to choose which one was more likely to make use of it. I decided that the content had more chances of need access to it, and I made it the first block, despite of being unexpected.
LMAO component (large monolith with an army of options)
I coined this term in my talk in last Embercamp London about building composable component. I warned about the danger of this and yet, I found myself unable to prevent this from happening to me. There was just too much behaviour that this component had to support to be flexible to not end up being a LMAO.
Luckily, most of them are optional, but if you decided to override all defaults, you would end up with this:
{{#basic-dropdown
animationEnabled=...
disabled=...
renderInPlace=...
role=...
destination=...
initiallyOpened=...
hasFocusInside=...
verticalPosition=...
horizontalPosition=...
dir=...
class=...
triggerClass=...
dropdownClass=...
onOpen=...
onClose=...
onFocus=...
onMouseEnter=...
onMouseLeave=...
ariaDescribedBy=...
ariaInvalid=...
ariaLabel=...
ariaLabelledBy=...
ariaRequired=...
}}
I counted 23 options and maybe there is even some more, I don't know. The fact that I can't even be sure is the scary part.
What can I say? I'm sorry.
Don't get me wrong, every one of this options fulfills a role and is necessary, but the fact that this component has to be aware of all of them is an antipattern.
As I see it, there is a clear culprit for this antipattern. Since the public API is composed of a single component, that component is the entry point of every single option. That is something I intend to fix with the proposal.
Constrained HTML markup
This is better explained with an example.
<div id="ember317" class="ember-view ember-basic-dropdown ember-basic-dropdown--below ember-basic-dropdown--left">
<div aria-haspopup="true" class="ember-basic-dropdown-trigger " aria-controls="ember-basic-dropdown-content-ember317" aria-disabled="false" aria-expanded="true" aria-pressed="true" role="button" tabindex="0">
<button>Click me</button>
</div>
<div id="ember338" class="ember-view"><!-- The content is rendered in the root of the body --></div>
</div>
Let's analyze the problem with this:
- Unnecessary wrapper div. The top-level div doesn't really fulfill any mission. Those cases are there but could have been in the trigger/content just as well.
- The
.ember-basic-dropdown-trigger
element wraps the user-provided html. This is a problem when the user wants the content to be opened by an element like button, an input. Currently the user's button is contained inside the trigger instead of being the trigger. Also, how does the trigger open and close the component if that button is not bound do any action? Magic? I know the answer, but you probably don't.
- The user can't introduce content before/after/between the trigger and the content parts. This prevent the user to leverage this component to create trigger where only part of the markup acts as trigger. Think by example in a datepicker that is opened when the use click in the calendar icon attached to a text input.
## New proposed API
The challenge of this task is to make the component more flexible and at the same time make the API easier to use and more natural. Sounds hard but I think that contextual components can to achieve this goal.
In summary, the new proposed API in it's simplest form is this one:
{{#basic-dropdown as |dropdown|}}
{{#dropdown.trigger}}Click me{{/dropdown.trigger}}
{{#dropdown.content}}This is the content{{/dropdown.content}}
{{/basic-dropdown}}
This is it. Now let me analyze point by point why I think this is a huge improvement
The yielded public API is only available in one of the blocks
While this is still technically true, now both the trigger and the content are independent components
renderes inside the main (and only) block, so the yielded attributes are available in both.
Unexpected order of blocks
Being free of the previous limitation also means that the order of the components is decided by the user.
Choose whatever you want.
LMAO component (large monolith with an army of options)
While the component will continue to be customizable in the same ways, now the internal components
are exposed to the end user, and therefore there is no longer a single point of entry for all
the configuration of the addon.
If we think about the options, some existes just because the internal components weren't public. The best
examples are triggerClass
and dropdownClass
. Why do we need this options at all if we have direct
access to trigger and content components?
{{#basic-dropdown as |dropdown|}}
{{#dropdown.trigger class="my-trigger"}}Click me{{/dropdown.trigger}}
{{#dropdown.content class="my-content"}}This is the content{{/dropdown.content}}
{{/basic-dropdown}}
About the rest of the options, now some of them belong to the parent component, some to the trigger
and some to the content. This is the worst posible scenario where you override every single option of the component.
{{#basic-dropdown
initiallyOpened=true
onOpen=...
onClose=...
as |dropdown|}}
{{#dropdown.trigger
disabled=true
role=...
onFocus=...
onMouseEnter=...
onMouseLeave=...
ariaDescribedBy=...
ariaInvalid=...
ariaLabel=...
ariaLabelledBy=...
ariaRequired=...
dir=...
class="my-trigger"}} Click me {{/dropdown.trigger}}
{{#dropdown.content
animationEnabled=true
renderInPlace=true
destination="#placeholder"
verticalPosition="above"
horizontalPosition="right"
onMouseEnter=...
onMouseLeave=...
class="my-content"}} This is the content {{/dropdown.content}}
{{/basic-dropdown}}
In this example, the same 23 options are now passed only to the components that actually cares about them.
This even allow more granularity, because I might care about mouseEnter
/mouseLeave
events over
the dropdown.content
component but not on the trigger. Or I might want to invoke a different action
on each.
The bottom line of this approach is that the LMAO (the M stands for monolith) is no longer a monolith,
and although the number of configuration options is not any smaller, each component cares just about a subset of them.
Constrained HTML markup
Let's go back to the datepicker component I mentioned before, that was impossible to build.
The desired markup can now be achieved this way:
{{#basic-dropdown as |dropdown|}}
<div class="input-and-button">
<input type="text">
{{#dropdown.trigger tagName="button" class="input-affix"}}<i class="icon-calendar"></i>{{/dropdown.trigger}}
</div>
{{#dropdown.content}}This is the content{{/dropdown.content}}
{/basic-dropdown}}
Unnecessary wrapper div. The top-level component is now tag less. No extra wrapper div required.
The .ember-basic-dropdown-trigger
element wraps the user-provided html. Not anymore.
The user can now customize that component passing things like tagName
, class
and all the usual jazz.
If the users wants to provide it's own triggerComponent
there is nothing preventing them to do so. They
could even opt out to using the trigger component at all:
{{#basic-dropdown as |dropdown|}}
<button type="button" onclick={{dropdown.actions.toggle}}>Click me</button>
{{#dropdown.content}}This is the content{{/dropdown.content}}
{/basic-dropdown}}
This might not be recommended because there is many things that this component does for the user for
free (accesibility, aria-* attributes, keyboard handling, ect...) but at the end is good to just let
the users choose their own path.
The user can't introduce content before/after/between the trigger and the content parts. Also solved :)
Drawbacks
The obvious drawbacks are:
- Totally breaking change. There is no upgrade path other than other than manually update all usages
of the component. The transformation will be relatively easy because no functionality will be removed,
and I want to maintain the same names for the options, but the transformation will require some unavoidable work.
- It will require contextual component, which effectively means 2.3+
Extra
Although this component is not nearly as popular as other components built on top of it (Ember Power Select mainly), there is a substantial amount of people using it, so I believe that after this refactor, I will deserve a domain and a web page with documentation, live examples and cookbooks showing how to take full advantage of the available options to build other component on top of it.