286 lines
10 KiB
Markdown
286 lines
10 KiB
Markdown
|
# Emerald Counter
|
||
|
|
||
|
I set out to reproduce the effect from "[The CSS Hack You Need To
|
||
|
Know](https://blog.devgenius.io/the-css-hack-you-need-to-know-7a3c2f7a8ce1)", which is all about
|
||
|
using the `attr()` CSS function to extract an attribute's value from the tag being styled so you can
|
||
|
apply it to a `::after` entry that will display the attribute. The idea is that one could then set:
|
||
|
|
||
|
```
|
||
|
<div class="notification-bell-container" current-count=5><svg ...></svg> </div>
|
||
|
```
|
||
|
|
||
|
And get back something that looks like this:
|
||
|
|
||
|
![Image of the Bell Icon](./bell-image.png)
|
||
|
|
||
|
As I was playing with the source code, I realized that it had one major shortcoming: it didn't scale
|
||
|
well. For every different size, if you wanted the notification circle to be even approximately in
|
||
|
the right the right place you would need to hand-code the location of the counter and have
|
||
|
breakpoint-oriented versions for phone, tablet, desktop, extra-wide, and ten-foot displays.
|
||
|
|
||
|
Can we do better than that? With web components we can do anything.
|
||
|
|
||
|
We're going to use a [bell icon from Icon
|
||
|
Finder](https://www.iconfinder.com/icons/211694/bell_icon), and the first thing we're going to do is
|
||
|
encapsulate it into a component that both shows it and scales it:
|
||
|
|
||
|
After running `npm init @open-wc` and creating a new element called "emerald-counter", find the
|
||
|
source file and put this into it:
|
||
|
|
||
|
```
|
||
|
export class EmeraldCounter extends LitElement {
|
||
|
static styles = css`
|
||
|
:host {
|
||
|
display: inline-block;
|
||
|
}
|
||
|
|
||
|
svg {
|
||
|
position: relative;
|
||
|
display: block;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
}
|
||
|
`;
|
||
|
|
||
|
render() {
|
||
|
return html`
|
||
|
<svg
|
||
|
id="Layer_1"
|
||
|
version="1.1"
|
||
|
viewBox="0 0 512 512"
|
||
|
xml:space="preserve"
|
||
|
xmlns="http://www.w3.org/2000/svg"
|
||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
|
>
|
||
|
<g>
|
||
|
<path
|
||
|
part="bell"
|
||
|
d="M381.7,225.9c0-97.6-52.5-130.8-101.6-138.2c0-0.5,0.1-1,0.1-1.6c0-12.3-10.9-22.1-24.2-22.1c-13.3,0-23.8,9.8-23.8,22.1 c0,0.6,0,1.1,0.1,1.6c-49.2,7.5-102,40.8-102,138.4c0,113.8-28.3,126-66.3,158h384C410.2,352,381.7,339.7,381.7,225.9z"
|
||
|
/>
|
||
|
<path part="bell" d="M256.2,448c26.8,0,48.8-19.9,51.7-43H204.5C207.3,428.1,229.4,448,256.2,448z" />
|
||
|
</g>
|
||
|
</svg>
|
||
|
`;
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
The `inline-block` is critical here. By default every web component host is just a `<span>`, and
|
||
|
spans are `inline`, so the SVG will inherit that setting and overflow the container to fill the
|
||
|
entire viewport. That wouldn't have been a problem if I hadn't removed the `width` and `height`
|
||
|
attributes from the `svg` declaration itself, but I had to remove them: the whole point of this
|
||
|
exercises is to scale the web component and have it look good at any scale.
|
||
|
|
||
|
The `viewBox` setting is SVG specifies a _relative space_ in which the element will fit, and the
|
||
|
coordinates in the `<path>` elements fit and fill that space. That whole space will scale relative
|
||
|
to its container and the specified size.
|
||
|
|
||
|
So now, in the `demo/index.html` file that comes with any open-wc component development, we can set:
|
||
|
|
||
|
``` HTML
|
||
|
<style>
|
||
|
emerald-counter: {
|
||
|
width: 4rem;
|
||
|
height: 4rem;
|
||
|
}
|
||
|
</style>
|
||
|
|
||
|
<emerald-counter></emerald-counter>
|
||
|
```
|
||
|
|
||
|
... and we'll see the bell component rendered to fill a space `4rem` on a side. For most people,
|
||
|
that will probably be 64 pixels, since the default base font size shipped with most browsers is 16px
|
||
|
and `rem` is a multiplier of the base font size.
|
||
|
|
||
|
Now to introduce our counter. First, we need a *reference* container that both the SVG and the
|
||
|
counter object will use, and the `:host` container isn't good enough; it needs to be free so that
|
||
|
the user can style it any way she chooses. We also want to introduce our element to display our
|
||
|
counter and the number. As a benefit, we can choose not to show any number when the counter is zero.
|
||
|
So now our `render()` function looks like this:
|
||
|
|
||
|
``` JavaScript
|
||
|
return html`
|
||
|
<div id="main">
|
||
|
<svg> ... </svg>
|
||
|
${ !this.counter || this.counter < 1
|
||
|
? nothing
|
||
|
: html `<div id="counter" part="counter"><span part="number">${this.counter}</span></div>`
|
||
|
}
|
||
|
</div>
|
||
|
`;
|
||
|
```
|
||
|
|
||
|
Obviously, this code includes a new state object that we're tracking: the `counter`. Technically,
|
||
|
this is a property; we're going to read it off the element tag and we're not going to reflect it if
|
||
|
it's changed internally:
|
||
|
|
||
|
``` JavaScript
|
||
|
@property({ type: Number, attribute: "current-count" })
|
||
|
counter = 0;
|
||
|
```
|
||
|
|
||
|
And frankly, at this moment, it looks terrible.
|
||
|
|
||
|
### Stylin'
|
||
|
|
||
|
Everything from this point on is about style. Let's set the stage. First, our host needs *at
|
||
|
least* some sort of block-level definition to keep the SVG from overflowing its borders. Remember
|
||
|
that the `:host` is, by default, treated like a `span`; it gets its size and positions from its
|
||
|
content, rather than defining a container. Also, our icon is square, and we want to keep it that
|
||
|
way:
|
||
|
|
||
|
``` CSS
|
||
|
:host {
|
||
|
display: inline-block;
|
||
|
aspect-ratio: 1 / 1;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
We have wrapped our SVG and our counter in a `div`. That needs to not be a static object, and it
|
||
|
needs to fill its container:
|
||
|
|
||
|
``` CSS
|
||
|
div#main {
|
||
|
position: relative;
|
||
|
display: block;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
The use of `id` inside a WebComponent is strictly limited to *inside* the WebComponent; this `id` is
|
||
|
not visible to the outside world and will not show up on any scans of the top-level HTML. There are
|
||
|
sophisticated libraries for reaching into WebComponents, mostly for testing purposes, and they can
|
||
|
find objects by ID, but only after you've anchored them on the host object itself.
|
||
|
|
||
|
In a way, all WebComponent `:host` objects represent a border between what the container can know
|
||
|
and do to your component, and what the component should know and see about the surrounding context.
|
||
|
Understanding and honoring that distinction will help keep you from writing "React, but in Lit."
|
||
|
|
||
|
Continuing on, our SVG is likewise going to be sized relative to its container:
|
||
|
|
||
|
```CSS
|
||
|
svg {
|
||
|
position: relative;
|
||
|
display: block;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
z-index: 0;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
And this is where things get... fiddly.
|
||
|
|
||
|
It's unfortunate, but the placement of the counter can't really be abstracted into a formula. Where
|
||
|
it goes, what size it's going to be, and so forth, are dependent upon the icon itself, and the
|
||
|
aesthetic judgement needed to say "That looks right" can't be taught to a machine, not even an LLM,
|
||
|
at least not yet.
|
||
|
|
||
|
What we can say is that we want it to be a grid, so that we can perfectly center the number within
|
||
|
it, and we want it to be above the SVG. We can handle that by setting the SVG's z-index below that
|
||
|
of the counter. Remember that the z-index is *relative* to its container and is isolated within the
|
||
|
`:host`, so "0" and "1" are perfect for our purposes. We want the counter's number to be about a
|
||
|
quarter the size of the whole icon, and we need to give it both a some space with respect to the
|
||
|
dot, and to give it a border so it's separate from the icon. Most of the following is reusable, but
|
||
|
the `top`, `left`, and `padding-top` were (and for the foreseeable future, will be) positioned by
|
||
|
eyeball and guesswork.
|
||
|
|
||
|
```
|
||
|
div#counter {
|
||
|
display: grid;
|
||
|
justify-content: center;
|
||
|
align-content: center;
|
||
|
position: absolute;
|
||
|
z-index: 1;
|
||
|
font-family: "Helvetica Neue", "Arial Nova", Helvetica, Arial, sans-serif;
|
||
|
font-size: 1em;
|
||
|
top: 8%;
|
||
|
left: 60%;
|
||
|
width: 30%;
|
||
|
height: 30%;
|
||
|
aspect-ratio: 1 / 1;
|
||
|
border-radius: 50%;
|
||
|
border: 0.2em solid white;
|
||
|
font-weight: bold;
|
||
|
background: red;
|
||
|
color: white;
|
||
|
}
|
||
|
|
||
|
div#counter span {
|
||
|
padding-top: 0.28em;
|
||
|
vertical-align: text-bottom;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
Now, in our `index.html`, we're going to include a new style block, and we're going to define an
|
||
|
auto-scaling [CSS Custom
|
||
|
Property](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). Like so:
|
||
|
|
||
|
```HTML
|
||
|
<style>
|
||
|
:root {
|
||
|
--step-0: clamp(0.75rem, 0.491rem + 1.295vw, 3rem);
|
||
|
}
|
||
|
emerald-counter {
|
||
|
--counter-size: calc(var(--step-0) * 4);
|
||
|
width: var(--counter-size);
|
||
|
font-size: var(--counter-size);
|
||
|
}
|
||
|
</style>
|
||
|
```
|
||
|
|
||
|
It is a shortcoming of CSS that we want our font-size to scale with the width of the object, but we
|
||
|
can't really achieve that. So we have to set both, so that the `em` fields in our code correctly get
|
||
|
the right sizes. Using a CSS Custom Property allows us to do the calculation once; harder to get it
|
||
|
wrong that way, avoids [the last line phenomenon](https://dl.acm.org/doi/10.1007/s10664-016-9489-6).
|
||
|
It may seem odd that we set it to the same multiplier as the width setting, but if you look at the
|
||
|
CSS above you'll see that both the relative size of the counter and its positioning are set in
|
||
|
fractions of an 'em'. This allows all the sub-components of the web component to scale *and
|
||
|
position* together correctly regardless of the font size, creating pixel-perfect positioning within
|
||
|
the component.
|
||
|
|
||
|
But see that `:root` setting? We're creating a new variable, `--step`, and we're setting it to be
|
||
|
somewhere between 12px and 48px, where exactly is a factor of some multiple of the viewport width,
|
||
|
but it's `clamp`ed so that it can't exceed those two settings. You can see, then, that if you change
|
||
|
the viewport width, the notification icon scales correctly and smoothly:
|
||
|
|
||
|
<video controls style="width: 80%">
|
||
|
<source src="./rescaling.webm" type="video/webm" />
|
||
|
</video>
|
||
|
|
||
|
The result is simple enough. You can now just say:
|
||
|
|
||
|
```
|
||
|
<emerald-counter current-count=5></emerald-counter>
|
||
|
```
|
||
|
|
||
|
And you'll see this:
|
||
|
|
||
|
<script type="module" src="./emerald-counter.js"></script>
|
||
|
<style>
|
||
|
:root {
|
||
|
--demo-step-0: clamp(0.75rem, 0.491rem + 1.295vw, 3rem);
|
||
|
}
|
||
|
emerald-counter {
|
||
|
--counter-size: calc(var(--demo-step-0) * 4);
|
||
|
width: var(--counter-size);
|
||
|
font-size: var(--counter-size);
|
||
|
}
|
||
|
.emerald-counter-demo {
|
||
|
width: 100%;
|
||
|
height: 6rem;
|
||
|
display: grid;
|
||
|
justify-content: center;
|
||
|
align-content: center;
|
||
|
}
|
||
|
</style>
|
||
|
<div class="emerald-counter-demo">
|
||
|
<emerald-counter current-count=5></emerald-counter>
|
||
|
</div>
|
||
|
|
||
|
I'm a big fan of dynamic scaling, if you resize the window horizontally you'll see that the fonts,
|
||
|
images, and so forth that I use scale smoothly with the resizing of the viewport. And now there's at
|
||
|
least one notification icon that scales smoothly along with everything else. I count that as a win.
|
||
|
|
||
|
|