Modern Web Weekly #16
Staying up to date with the modern web
Can I compress it? Yes, you can!
If your web app needs to compress and decompress data you no longer need an external library. The Compression Streams API is now available in all browsers and enables your app to (de)compress data using GZIP and DEFLATE.
As you can already tell from the name, the API processes the data to be compressed or decompressed as a stream which makes it very easy to use. The idea is you take a stream of data and pipe it through a CompressionStream
or DecompressionStream
:
// compression
const compressedReadableStream = inputReadableStream.pipeThrough(
new CompressionStream("gzip")
);
// decompression
const decompressedReadableStream = compressedReadableStream.pipeThrough(
new DecompressionStream("gzip")
);
That's basically all there is to this API, short and sweet!
You could use this to let users select a file with <input type="file">
and then let them download the compressed version of it. You can easily convert the selected File
object to a stream using its stream()
method:
const fileInput = document.querySelector('input');
fileInput.addEventListener('change', async () => {
const [file] = fileInput.files;
const fileStream = file.stream();
const compressedFileStream = fileStream.pipeThrough(
new CompressionStream('gzip')
);
...
});
You can also use this when your web app generates large files that your users can download. When you compress these files, downloading will be faster using less bandwidth.
Here's a demo where you can select a file and then download the compressed and uncompressed version. Check that the uncompressed file is identical to the original and unzip the compressed file on your device to see it's identical as well.
Check the demo!
CSS based on how many children an element has? Hold my beer!
The :has()
CSS pseudo-class is an absolute godsend for developers. If you haven't used it yet: it's basically a CSS parent selector as it enables you to target an element when it has certain child elements, represented by a selector.
For example, if you want to give a <div>
a red border when it contains an invalid <input>
you can do this:
div:has(input:invalid) {
border: 1px solid red;
}
You can do pretty amazing stuff with :has()
and when you combine it with the :nth-child
and :nth-last-child
selectors you can even style an element based on how many children it has.
Here's an example of selecting a <div>
that has 3 or more children:
div:has(> :nth-child(3)) {
...
}
Here we select the direct children of the <div>
with >
and :nth-child(3)
selects the third child so this will select the <div>
when it has a third child, in other words at least three child elements.
You can also select the <div>
when it has exactly 3 children by adding :last-child
to the selector:
div:has(> :nth-child(3):last-child) {
...
}
This selects the <div>
when it has a third child that is also the last child, in other words when it has exactly three child elements.
We can now go even one step further and select the <div>
when it has between 3 and 7 children for example:
div:has(> :nth-child(3)):has(> :nth-child(-n+7):last-child) {
...
}
This selects the <div>
when it has a third child and a seventh that is also the last child, in other words when it has between three and seven child elements.
This only scratches the surface of what is possible with :has()
and the family of selectors like :nth-child
, :nth-last-child
, :last-child
etc.
I created a demo where you can add elements to a container that will start of with a green background that will turn orange when it has between 5 and 7 child elements and red when it has 8 child elements or more. Click on a child element to remove it.
Check the demo!
Cleaning up your code with invokers
In Modern Web Weekly #14 I wrote about how invokers can add click behavior to buttons without JavaScript event handlers. By adding an invoketarget
attribute to a button you can execute an action without an event handler. For example, the button in the example below opens a popover when clicked:
<button invoketarget="my-popover">Open Popover</button>
<div id="my-popover" popover="auto">This is a popover</div>
All that's needed is an invoketarget
attribute with a value containing the id
of the element the action should be invoked on, in this case, the <div>
with id="my-popover"
.
The opening of the popover here is called the default behavior. You can also add custom behavior with the invokeaction
attribute, in which case you may need to add an event handler for the invoke
event. In the example below, the button has a file input as its invoketarget
and showPicker
as its invokeaction
. When the button is clicked, the open file dialog is opened without any JavaScript:
<button invoketarget="my-file" invokeaction="showPicker">
Pick a file...
</button>
<input id="my-file" type="file">
This is very cool and the reason why devs are so excited for invokers, but I think there's an even better feature of invokers that is a bit overlooked. When there is no default action or predefined one (like showPicker
in the previous example) you can add an event listener to the element the action is invoked on to handle it.
The next example shows two buttons that open and close a <dialog>
by adding an event listener for the invoke
event to that <dialog>
:
<button invoketarget="my-dialog" invokeaction="show">
This opens a dialog with custom behavior
</button>
<button invoketarget="my-dialog" invokeaction="close">
This closes a dialog with custom behavior
</button>
<dialog id="my-dialog">This is the dialog</dialog>
<script>
const dialog = document.querySelector('#my-dialog');
dialog.addEventListener('invoke', ({action}) => {
if(action === 'show') {
dialog.show();
}
if(action === 'close') {
dialog.close();
}
});
</script>
This example is where invokers truly shine because they enable you to move the event listener from the button to the target. Without invokers, you would need to add an event listener to each button, but with invokers, you only need to add one to the dialog: the target of the invoked actions. Can you imagine how much this would simplify your code when you have many buttons?
If not, let me explain further.
I write a lot of vanilla Web Components and a pattern that keeps emerging is that I have to select an element inside the component's shadow root and attach an event handler to it.
Here's an example that you can find on my Github page, audio-recorder. It's a Web Component that can capture audio through the user's microphone, record it, and then show a waveform or a frequency analyzer when the recorded audio is played. You can toggle between the waveform and frequency analyzer view and there are buttons to save the audio or adjust the volume.
A significant part of the code is selecting the buttons inside the shadow root and attaching event handlers to them to perform all the actions like record, play, save, and toggle the view:
this.playButton = this.shadowRoot.querySelector('#play');
this.pauseButton = this.shadowRoot.querySelector('#pause');
this.captureAudioButton = this.shadowRoot.querySelector('#capture-audio');
this.stopCaptureAudioButton = this.shadowRoot.querySelector('#stop-capture-audio');
this.freqButton = this.shadowRoot.querySelector('#frequencies-button');
this.waveformButton = this.shadowRoot.querySelector('#waveform-button');
...
this.audioContainer.addEventListener('click', this.handleWaveformClick.bind(this));
this.playButton.addEventListener('click', this.playPause.bind(this));
this.pauseButton.addEventListener('click', this.playPause.bind(this));
this.freqButton.addEventListener('click', this.showFrequencyAnalyzer.bind(this));
this.waveformButton.addEventListener('click', this.showWaveform.bind(this));
this.captureAudioButton.addEventListener('click', this.captureAudio.bind(this));
this.stopCaptureAudioButton.addEventListener('click', this.stopCaptureAudio.bind(this));
When using invokers, we can replace all these event handlers with a single one. Let's start by adding invoketarget
and invokeaction
attributes to the buttons (check the source on Github for the original buttons):
<button
id="capture-audio"
invoketarget="container"
invokeaction="capture">
<i class="material-icons">mic</i>
</button>
<button
id="stop-capture-audio"
invoketarget="container"
invokeaction="stop-capture">
<i class="material-icons">mic_off</i>
</button>
<button
id="play"
invoketarget="container"
invokeaction="play">
<i class="material-icons">play_arrow</i>
</button>
<button
id="pause"
invoketarget="container"
invokeaction="pause">
<i class="material-icons">pause</i>
</button>
These are not all the buttons inside the component because I edited it for clarity but I assume you get the idea. Each button has an invoketarget
that is container
. This is the top-level <div>
inside the audio-recorder component that has id="container"
. All actions will be invoked on this <div>
so we can set the single event handler for the invoke
event on this element.
When a button is clicked, we check its invokeaction
inside the invoke
event handler and then call the method of the component that would normally be called inside the click
event handler of that button. For example, when <button id="play">
is clicked, this.playPause()
will be called.
All we then need to do is connect all invokeaction
s to the right method. Note that we will not set the invoke
event handler on <div id="container">
but on this
, which is the audio-recorder component itself. I'll explain why in a minute:
this.addEventListener('invoke', ({action}) => {
const actions = {
'capture': this.captureAudio,
'stop-capture': this.stopCaptureAudio,
'play': this.playPause,
'pause': this.playPause,
'record': this.recordAudio
...
}
if(actions[action]) {
actions[action].call(this);
}
});
Instead of an event handler for each button, we now have one simple event handler that takes care of all actions that the buttons can invoke.
So why is the event handler set on this
and not <div id="container">
which is the invoketarget
of each button?
Imagine we want to allow users to provide their own buttons to control the audio-recorder component. We could do this by requiring users to provide those buttons as slotted content or have users add event handlers to their buttons to invoke the correct methods of the component:
// as slotted content
<audio-recorder>
<button id="play" slot="play-button">Play</button>
<button id="record" slot="record-button">Record</button>
...
</audio-recorder>
// with event handlers
const audioRecorder = document.querySelector('audio-recorder');
const playButton = document.querySelector('#play');
playButton.addEventListener('click', () => audioRecorder.playPause());
But by setting the invoke
event handler on the audio-recorder itself we can now also create buttons outside of it to invoke the same actions the buttons inside of it do. Basically, we can define the same buttons on the outside, we only need to change the invoketarget
to the audio-recorder itself:
<audio-recorder id="recorder"></audio-recorder>
<button id="play" invoketarget="recorder" invokeaction="play">
Play
</button>
<button id="pause" invoketarget="recorder" invokeaction="pause">
Pause
</button>
Your component can communicate which actions are available and the consumers of your component can use their own buttons with simple, declarative HTML and zero JavaScript.
Check the Github repo for the source code. The original version is in the master
branch and the version using invokers is in the feat/invokers
branch.
Send me a DM on Twitter to let me know!