Modern Web Weekly #16

Modern Web Weekly #16

Staying up to date with the modern web

👋
Hello there! I'm Danny and this is Modern Web Weekly, your weekly update on what the modern web is capable of, Web Components, and Progressive Web Apps (PWA). I test modern web features and write about them in plain English to make sure you stay up to date.

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 invokeactions 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.

💡
Invokers are currently a proposal and are only available in Chrome Canary and Firefox Nightly with experimental web features enabled.

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.

🔗
Got an interesting link for Modern Web Weekly?
Send me a DM on Twitter to let me know!