DEV Community

loading...

: A More Involved Stencil Component

Rúnar Berg Baugsson Sigríðarson
he/him
Originally published at github.com ・5 min read

Next up are the menu components. These are actually two components,
<my-menu> and <my-menu-item>. You will see why we need two
seperate components in a bit. Let’s scaffold them just like we did
with the button (we will only need CSS for my-menu).

npm run generate my-menu-item
# Uncheck CSS, spec, and E2E

npm run generate my-menu
# Keep CSS, but uncheck spec and E2E
Enter fullscreen mode Exit fullscreen mode

We don’t need to touch my-menu-item for now, so lets concentrate on
my-menu for a bit. We know we want to add a <menu> there somewhere
in the render method. But then we hit a snag, how can we express each
child <my-menu-item> as an item of that menu:

import { Component, ComponentInterface, Host, h } from '@stencil/core';

@Component({
  tag: 'my-menu',
  styleUrl: 'my-menu.css',
  shadow: true,
})
export class MyMenu implements ComponentInterface {
  render() {
    return (
      <Host>
        <menu>
          <li>
            <!-- XXX: All children are inside one `<li>` -->
            <slot></slot>
          </li>
        </menu>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We will need to collect all the menu-items into an array so we can map
the contents (now you see why we created that component earlier).
Enter the @State decorator.

Managing Internal State

Stencil has a few lifecycle methods. For now
let’s concern us with the componentWillLoad which fires after the
component is first connected to the DOM. We can use that to collect
the contents of the host. We also need access to component element it
self to find all the child <my-menu-item>s. For that we use the
@Element decorator:

import {
  Component,
  ComponentInterface,
  Element,
  Host,
  State,
  h,
} from "@stencil/core";

@Component({
  tag: "my-menu",
  styleUrl: "my-menu.css",
  shadow: true,
})
export class MyMenu implements ComponentInterface {
  // This will be our host element.
  @Element() el: HTMLElement;

  @State() items: HTMLMyMenuItemElement[] = [];

  // This will fire once after the component is connected.
  componentWillLoad() {
    // Collect all `<my-menu-item>`s into an array.
    this.items = Array.from(this.el.querySelectorAll("my-menu-item"));
  }

  render() {
    return (
      <Host>
        <menu>
          {this.items.map((item) => (
            <li>{item.textContent}</li>
          ))}
        </menu>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This is an improvement, but now we lost our buttons. Notice how we’ve
swapped the <slot> for this.items.map; and how the mapping
function only returns the text content of each element. Thats a
problem. We somehow need to keep a slot for each found menu item and
assign it to that item specifically. Lucky for us, slots can be named,
and if we add a slot attribute with a matching value of
a named slot, it will be added to that slot. For example:

<template>
  <em><slot name="em"></slot></em>
  <strong><slot name="strong"></slot></strong>
</template>
<span slot="strong">
  This will go to the “strong” slot above
</span>
<span slot="em">
  This will go to the “em” slot above
</span>
Enter fullscreen mode Exit fullscreen mode

In our demo we can dynamically add as many slot as we need in our
render function; each with a specific name. We can then manipulate the
slot attribute of each found <my-menu-item> element to match a
specific named slot. So in essence:

import {
  Component,
  ComponentInterface,
  Element,
  Host,
  State,
  h,
} from "@stencil/core";

@Component({
  tag: "my-menu",
  styleUrl: "my-menu.css",
  shadow: true,
})
export class MyMenu implements ComponentInterface {
  @Element() el: HTMLElement;

  @State() items: HTMLMyMenuItemElement[] = [];

  componentWillLoad() {
    this.items = Array.from(this.el.querySelectorAll("my-menu-item"));
    this.items.forEach((item, i) => {
      item.slot = `item-${i}`;
    });
  }

  render() {
    return (
      <Host>
        <menu>
          {this.items.map((_, i) => (
            <li>
              <slot name={`item-${i}`}></slot>
            </li>
          ))}
        </menu>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Bingo! But there is a problem. If the consumer changes the slot (say
adds a menu item) after it connected the custom element to the DOM, we
won’t have a slot for it. Or if it removes an item, we are stuck with
an extra list item. I’ll leave it as an exercise to recreate the
bug. But to fix it we’ll re-intruduce the main <slot> and attach a
[slotchange event] listener, which will fire whenever one of our
slots changes.

import {
  Component,
  ComponentInterface,
  Element,
  Host,
  State,
  h,
} from "@stencil/core";

@Component({
  tag: "my-menu",
  styleUrl: "my-menu.css",
  shadow: true,
})
export class MyMenu implements ComponentInterface {
  @Element() el: HTMLElement;

  @State() items: HTMLMyMenuItemElement[] = [];

  componentWillLoad() {
    this.el.shadowRoot.addEventListener("slotchange", () => {
      this.items = Array.from(this.el.querySelectorAll("my-menu-item"));
      this.items.forEach((item, i) => {
        item.slot = `item-${i}`;
      });
    });
  }

  render() {
    return (
      <Host>
        <slot></slot>
        <menu>
          {this.items.map((_, i) => (
            <li>
              <slot name={`item-${i}`}></slot>
            </li>
          ))}
        </menu>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have our menu we can style it in my-menu.css.

Styling (Part 2)

Buttons inside the menu should look differently then normal
buttons. In particular the borders are visually distructive so we must
get rid of them. Lets try to do that in my-menu.css:

/* src/components/my-menu/my-menu.css */

menu {
  list-style: none;
  padding: 0;
  margin: 0;
}

my-button {
  /* This won’t work */
  border: none;
}
Enter fullscreen mode Exit fullscreen mode

This fixed the menu style, but the borders are still there, why? Turns
out that we put the borders on the child button element inside the
shadow DOM, and styles inside the shadow DOM are isolated from style
rules defined outside of it. So even if we’d select my-button button
it still wouldn’t work. What can we do?

Shadow Parts

We saw when we styled the button previously that CSS custom properties
can penetrate the shadow barrier, so we could define the border in
my-button.css with:

/* src/components/my-button/my-button.css */

:host {
  --border-width: var(--button-border-width, 2px);
}

button {
  border-color: var(--color);
  border-style: solid;
  border-width: var(--border-width);
}
Enter fullscreen mode Exit fullscreen mode

But there is another way. Authors can also mark parts of the structure
as available for styling using the part attribute. In a
stylesheet consumer can then access the part using the ::part
pseudo-element
. So lets try that.

First add the part attribute to our button in my-button.tsx, lets
name it intuitively “button”:

// src/components/my-button/my-button.tsx

@Component(/* ... */)
export class MyButton implements ComponentInterface {
  // ...

  render() {
    // ...

    return (
      <Host>
        <button
          class={classMap}
          type={this.type}
          disabled={this.disabled}
          part="button"
        >
          <slot></slot>
        </button>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now lets try to access it in my-menu.css:

/* src/components/my-menu/my-menu.css */

/* ... */

my-button::part(button) {
  /* This still won’t work */
  border: none;
}
Enter fullscreen mode Exit fullscreen mode

This still won’t work because my-menu isn’t actually the consumer of
the my-button component. We have to go all the way back to
index.html to find the real consumer. So we need to export something
like a global stylesheet that the users of our component library can
import. So let’s to that.

Global Stylesheets

Stencil provides a way to export global styles. First
let’s create the stylesheet in src/global/style.css:

my-menu my-menu-item my-button::part(button) {
  border: none;
}
Enter fullscreen mode Exit fullscreen mode

Then add this file to the globalStyle field in stencil.config.ts.

export const config: Config = {
  // ...
  globalStyle: "src/global/style.css",
};
Enter fullscreen mode Exit fullscreen mode

Finally import it in your src/index.html:

<head>
  <!-- ... -->
  <link rel="stylesheet" href="/build/my-components.css" />
</head>
Enter fullscreen mode Exit fullscreen mode

Now restart the stencil server, refresh the page and see your
borderless menu buttons.

Discussion (0)