2024-10-18 07:50:00
jakearchibald.com
We’re finally getting a way to fully style & customise elements! But there’s a detail I’d like everyone’s opinion on.
If you want to hear about it in depth, I talked about it on OTMT, and there’s a great post by Una Kravets. But here’s a whirlwind tour:
style>
select,
::picker(select) {
appearance: base-select;
}
style>
select>
button>
…
selectedoption>selectedoption>
button>
option>…option>
option>…option>
option>…option>
select>
And that’s as much background as you need for the rest of this post.
automatically displays the currently selected within the
that opens the popover menu.
It’s entirely optional, so if you wanted to manually update the content of the when the selected
changes, you can, and you get a lot more control that way. But
is much easier, and works without JavaScript.
When the selected changes, it clears the contents of the
, takes a clone of the contents of the newly selected , and inserts it into the
.
This is kinda new and weird behaviour. I recently wrote that I didn’t like elements that modify themselves, as I think the light DOM should have a single owner. But, I can’t think of a better way to do this, given:
- The content needs to render in two places at once – in the button and in the menu.
- The selected option needs to be able to be styled differently to the equivalent content in the
.
- The solution must work without JavaScript.
But there are limitations
This is a clone in the el.cloneNode(true)
sense. That means it’s a copy of the tree, including attributes, but not including properties, event listeners, or any other internal state. So, if the selected option contains a , the clone will be a blank canvas. An
will reload using the
src
attribute. CSS animations will appear to start over, since they’re newly constructed elements. Custom elements will be constructed afresh, and may have different internal state to the cloned element.
As far as I can tell, in most cases this will be fine, as the kinds of elements you’d typically use in an are fully configurable via attributes.
This is the bit I want your opinion on. I’m going to present a series of options, and point out potential limitations/gotchas/issues with each. Not all of these issues are equal, and you may even feel some of them are features rather than bugs (I certainly do).
Editing the content of elements isn’t super common, but it’s possible, so we need to define what happens. It might happen when:
- Enhancing options with additional data. For example, you might discover asynchronously that an option is “almost sold out”, and want to display that information in the
.
- Dynamically modifying styles in response to interaction. Most animation libraries modify
element.style
, which also updates thestyle
attribute. - Going from a ‘loading’ state to a ‘loaded’ state.
For example, imagine a React app like this:
function CustomSelect({ options }) {
return (
select>
button>
selectedoption>selectedoption>
button>
{options.map((option) => (
option value={option.value}>
{option.icon && img src={option.icon} alt={option.iconAlt} />}
{option.text}
option>
))}
select>
);
}
function App() {
const [options, setOptions] = useState([
{
text: 'Loading…',
},
]);
useEffect(() => {
}, []);
return CustomSelect options={options} />;
}
Because the above example doesn’t give each a meaningful
key
, React will modify the first ‘loading’ to become the first real
when the data loads. It’s better to use
key
in this situation, which would cause React to create a new element, but not everyone does this.
You might see a similar pattern to above when updating depending on a choice made earlier in a form.
So what should happen?
Option: Nothing by default, but provide a way to trigger an update
When an becomes selected, the content of
is replaced with a clone of the selected ‘s content. If the content of the selected
is later modified, it would become out of sync with the
element.
A method like selectedOption.resetContent()
would cause the content to be replaced with a fresh clone of the selected ‘s content. Developers would have to call this if they’ve updated the
‘s content in a way that they want mirrored in the
.
Any manual modifications to the contents of
will be overwritten the next time the selected option changes to another , or when
selectedOption.resetContent()
is called.
Option: Automatically reset the content when anything in the selected
changes
Whenever the tree in the selected changes, as in a node is added, removed, or attributes change in any way, the content of the
is replaced with a fresh clone of the selected ‘s content.
This would be a full clone of the ‘s content. So even if you deliberately only changed one attribute on one element within the selected
, every element in the
would be replaced with a fresh clone. This would cause state to be reset in those elements, and things like CSS animations within
would appear to restart.
Since the cloning is performed synchronously, it will probably happen more than you expect. In the above React example where is changed to an option with an icon, that’s three changes within the selected
:
- The
is inserted (it’s already been given the
alt
attribute). - The text is updated.
- The
src
of theis updated.
So that’s three times the content of the
is replaced with a fresh clone of the selected ‘s content, and this is a really basic example.
What about this:
const selectedOption = select.selectedOptions[0];
selectedOption.append(selectedOption.firstChild);
Well, that’s two clones of the selected ‘s content, because an element ‘move’ is actually two tree modifications: a remove followed by an insert.
If you change 10 styles on an element within the selected via
element.style
, each change updates the style attribute, so that’s 10 times the content of the
is replaced with a fresh clone of the selected ‘s content.
If you’re using an animation library to do something fancy within one of the options, they tend to modify element.style
per frame. So that means the content of
is being entirely rebuilt every frame, or more likely, many times per frame.
There may be cases where you don’t want a change in the to be reflected in the
. Since they’re independent elements, you can give each independent :hover
states via CSS. But, if you want to do something much fancier involving JavaScript, which modifies element.style
on mouseenter
, that will appear to be mirrored from the selected to the
, which may not be your intent, because only the is being hovered over.
This could actually become more of an issue in future. Right now, when you click on a
– it modifies its own attributes. If you had one of those in a selected
and the user clicked on it, the one in the
would appear to open too (via cloning since the attribute changed). Now, having a
in an
doesn’t really make sense, but since this pattern is becoming more popular on the web platform, it may appear on an element that you would use in an
.
There isn’t a way to prevent changes from mirroring to
. The only way around it is to avoid using
and doing things manually.
Also, this automatic ‘mirroring’ is one-way. If you manually alter content in the
, it won’t cause the content in the selected to be updated. Your manual changes in the
will be overwritten the next time the cloning operation occurs.
Option: Automatically reset the content when anything in the selected
changes… debounced
As above, but when the content of the selected changes, the content of the
is replaced with a fresh clone after a microtask. This would always be before the next render.
This reduces the amount of cloning significantly. All the examples I gave above would only trigger a single clone of the selected ‘s content.
However, it means that:
const selectedOption = select.selectedOptions[0];
const selectedOptionMirror = select.querySelector('selectedoption');
selectedOption.textContent = 'New text';
console.log(selectedOption.textContent === selectedOptionMirror.textContent);
Also, you still have the behaviours where:
- Changing one inner element of the selected
causes all elements in the
to be replaced. - Changes are mirrored even if you don’t want them to be.
- Mirroring is one-way.
Option: Perform targeted DOM changes when something in the selected
changes
When the becomes selected, the content of the
is replaced with a clone of the selected ‘s content. However, the browser maintains a link between each of the elements and their respective clones. If you modify an attribute on an original element, the same attribute on its clone is updated, and only that attribute. This means that changes on one element in the selected
won’t cause everything in the
to ‘reset’, such as CSS animations.
If a new element is introduced into the selected , it will be cloned and inserted into the equivalent position in the
.
Changes will be performed synchronously, but the changes are just a repeat of your specific action. Your changes run twice, once in the selected , and once in the
.
However, when the selected changes to another
, the content of the
is fully replaced with a fresh clone of the newly selected ‘s content.
You still have the behaviours where:
- Changes are mirrored even if you don’t want them to be.
- Mirroring is one-way.
The one-way mirroring behaviour is different to the other options. In the ‘clone’ options, any change to the selected will cause the content in the
to ‘reset’, as the content is completely replaced with a fresh clone. Whereas in this option, since the DOM changes are targeted, if you manually modify the
content, it’s more like a fork.
For example, inserts will be done using an internal version of element.insertBefore
, as in “insert node
into element
before referenceNode
“. If the content of
has been manually altered, it’s possible this change will fail because referenceNode
is no longer in element
, in which case the node
won’t be inserted.
This is being actively discussed in the HTML spec, but I want a wider set of developers to have their opinions heard on this.
What should happen? Which options do you like? Which do you hate? Let me know what you think in the comments, social networks, Hacker News, or wherever else you can get my attention. I’ll present this at the next OpenUI meeting.
Support Techcratic
If you find value in Techcratic’s insights and articles, consider supporting us with Bitcoin. Your support helps me, as a solo operator, continue delivering high-quality content while managing all the technical aspects, from server maintenance to blog writing, future updates, and improvements. Support Innovation! Thank you.
Bitcoin Address:
bc1qlszw7elx2qahjwvaryh0tkgg8y68enw30gpvge
Please verify this address before sending funds.
Bitcoin QR Code
Simply scan the QR code below to support Techcratic.
Please read the Privacy and Security Disclaimer on how Techcratic handles your support.
Disclaimer: As an Amazon Associate, Techcratic may earn from qualifying purchases.