There are two primary ways you could do this:
- make the select component more complex by giving it the ability to render colored options, which means you have to pass it colors to the options array, this isn’t the way i would go
- refactor the select component to make it more flexible by making it a “contextual” component
To refactor it we might do something like this:
ORIGINAL VERSION
// app/components/my-select.hbs
<select {{on "change" (pick "target.value" @onChange)}}>
{{#each @options as |theOption|}}
<option value={{theOption}} selected={{eq theOption @value}}>{{theOption}}</option>
{{/each}}
</select>
NEW VERSION
// app/components/my-select-option.hbs
<option
value={{@optionValue}}
selected={{eq @currentOption @optionValue}}
...attributes
>
{{or @optionLabel @optionValue}}
</option>
// app/components/my-select.hbs
<select {{on "change" (pick "target.value" @onChange)}}>
{{#if (has-block)}}
{{yield (component "my-select-option" currentValue=@value)}}
{{else}}
{{#each @options as |theOption|}}
<MySelectOption
@optionValue={{theOption}}
@optionLabel={{theOption}}
/>
{{/each}}
{{/if}}
</select>
Wow there’s a lot going on there. I’ll try to break it all down in case you haven’t looked at “contextual component” patterns yet. Basically what we did is extract the option
into it’s own component and supercharged the select component to give it a lot of flexibility. Let’s look line by line:
Main Select Component
Line 1
<select {{on "change" (pick "target.value" @onChange)}}>
This doesn’t change. We still need to render a select tag and give it a change handler.
Line 2
{{#if (has-block)}}
This one is weird if you haven’t seen it before. has-block is a helper that tells you if this component was rendered with a block. What’s a block? Well there are two ways to render any component: with and without “blocks”. You can also use “named blocks” but we’re using the “default block” here.
A blockless component is rendered with a self-closing tag, like this:
<MySelect ... />
But a “block” component is rendered with an opening and closing tag, and other stuff in the middle:
<MySelect ... >
{{!-- this is the block! --}}
</MySelect>
And a “named block component” might look something like this:
<MyCard>
<:heading>
{{!-- this is where you'd render stuff in the card heading --}}
</:heading>
<:body>
{{!-- this is where you put stuff in the card body --}}
</:body>
</MyCard>
Now, you may notice that we’re putting the block thing in an if
conditional, this means we want this component to support both blockless and block forms. So if you give it a block it does one thing, if you don’t give it a block it does exactly what it did before. We didn’t change the component arguments at all we simply extended its API.
Line 3
{{yield (component "my-select-option" currentValue=@value)}}
Woah another weird one. Yield is how you can tell the component where to render the block(s). So when you render this component in block form:
<MySelect ... >
<div>This is nonsense, just an example.</div>
</MySelect>
That <div>
tag is going to go right where this {{yield}}
helper is.
Ok but we’re also giving the yield yelper something else… a component! We’ve extracted our option component out and now we’re yielding the option component to our block. We could yield other things to, like strings or arrays or numbers or other values, but here we’re yielding a “contextual child component” meaning we’re adding a little context to the option component (the currentOption arg) before we yield it. So now we can use this component like this:
<MySelect@value={{this.selectedOption}} @onChange={{this.optionChanged}} as |MySelectOption|>
<MySelectOption @optionValue="Yes" />
<MySelectOption @optionValue="No" />
</MySelect>
So here you notice we didn’t pass @options
in, because we’re defining our options in the block. Neato!
Lines 4-10
{{else}}
{{#each @options as |theOption|}}
<MySelectOption
@optionValue={{theOption}}
@optionLabel={{theOption}}
/>
{{/each}}
So as I mentioned this is supporting the “blockless” form, so using it how you were before:
<MySelect @value={{this.selectedOption}} @options={{this.options}} @onChange={{this.optionChanged}} />
still works exactly like it did before. The major difference is that now instead of manually rendering the <option>
tags we’ve extracted that into an MySelectOption
component. We’ll walk through that next.
I think the most important thing to note in this template is the if
statement and what happens in each part of it. We’re saying “if we have a block, yield the option component to the block and let the block take care of the options, OTHERWISE we’ll render the options that we’re given automatically”. So the block form gives you extra flexibility over how you want the select to be rendered, and the blockless form gives you fast easy select rendering if you don’t need to customize it (e.g. add colors or extra options or whatever).
Select Option Component
Line 1
<option
We’re just rendering the opening of the option tag, no big deal
Line 2
value={{@optionValue}}
Here we’re just passing the @optionValue
arg to the option
tag value
attribute. Again, standard stuff.
Line 3
selected={{eq @currentOption @optionValue}}
Once again this looks pretty much the same as it did in the parent component before. We know this option is selected if the currentOption === our option value.
Line 4-5
...attributes
>
Here’s where things are getting a little more interesting. This is called “splattributes” and it means any attributes we pass to the option component get “forwarded” to this option tag. Attributes are distinct from args in that they don’t have a @
on them, and usually we assign attributes to an HTML element, not a component, but this lets us forward those through. So you can do this, for example:
<MySelectOption data-test-option="Yes" class="color-green" @optionValue="Yes" />
and it will forward the data-test-option
and class
attributes through to the <option>
tag that is rendered inside the MySelectOption component.
Line 6
{{or @optionLabel @optionValue}}
I just added this to be extra flexible. This allows us to use either the value or an extra “label” arg to render the select label. So you could do something like this:
<MySelectOption @optionLabel="Heck yes!" @optionValue="yes" />
This means the select will use “yes” as the behind-the-scenes value of the option, but it will render “Heck yes!” in the actual dropdown. This is often necessary when you’re dealing with multi-language support or you have a numeric value that has a human-readable string label, you need to separate the option value and option label. Here we’re using the or
helper to let it use the optionLabel if you give it one, but use optionValue as a fallback, so either will work.
Conclusion
Anyway, I hope all that makes sense. Let me know if you have any other questions about any of it. This pattern is called “contextual components” and they are very powerful. In this case what it would allow you to do is use the block form of the select component to customize the options as you see fit. So what does that look like? Let’s put it all together:
<MySelect
@value={{this.selectedOption}}
@onChange={{this.optionChanged}}
as |MySelectOption|
>
<MySelectOption class="color-green" @optionValue="Yes" @optionLabel="Yes, totally" />
<MySelectOption class="color-red" @optionValue="No" @optionLabel="Nah" />
</MySelect>
Here we’re using the “block form” of the component, yielding out the option component as MySelectOption, and using it to render two customized options. Each with their own distinct value/label pairs but more importantly a CSS class! The yes option has “color-green” CSS class applied, and the no option has the “color-red” CSS class applied. You could change these or use them as is, and as long as you have CSS that actually changes the background color correctly, this should work.