DEV Community

loading...

: Adding The Dropdown Feature

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

A dropdown menu is really just a menu inside a non-modal dialog. So
lets create our dialog wrapper (like before select CSS and skip spec
and E2E tests):

npm run generate my-dialog
Enter fullscreen mode Exit fullscreen mode

Lets be wishful and wrap our menu inside it (as if it was ready
already):

// src/components/my-menu/my-menu.tsx

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

  render() {
    return (
      <Host>
        <slot></slot>

        <my-dialog>
          <slot slot="activator" name="label">
            Actions
          </slot>

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

So <my-dialog> should have an activator slot—where we will place
our label for toggling the menu—and a main slot for the dialog
body—where we will place the menu it self.

Event Handling

Lets create the <my-dialog> component:

npm run generate my-dialog
# Select CSS, unselect spec and E2E
Enter fullscreen mode Exit fullscreen mode

And edit src/components/my-dialog/my-dialog.tsx like this:

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

@Component({
  tag: "my-dialog",
  styleUrl: "my-dialog.css",
  shadow: true,
})
export class MyDialog implements ComponentInterface {
  @Prop({ reflect: true, mutable: true }) open: boolean = false;

  render() {
    return (
      <Host>
        {/* Add a button with a click listener */}
        <my-button
          onClick={() => {
            this.open = !this.open;
          }}
        >
          <slot name="activator">Activate</slot>
        </my-button>

        <dialog open={this.open}>
          <slot></slot>
        </dialog>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The <dialog> element has mixed support between browsers so lets add
some styles in src/components/my-dialog/my-dialog.css now to make it
consistent:

:host {
  position: relative;
}

dialog {
  border: 1px solid thistle;
  border-radius: 1ex;
  display: none;
  inline-size: max-content;
  inset-block-start: calc(100% + 5px);
  inset-inline-end: auto;
  inset-inline-start: 0;
  padding: 0;
  position: absolute;
}

dialog[open] {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Notice in the tsx file that the activator button has an onClick
attribute that mutates this.open. This is one way to attach an event
listener. When we click the activator button on our demo page the
function inside the handler will run. Another way is with the
@Listen decorator, lets use that one closes the
dialog when the user hits Esc, and another that closes when
the user clicks outside the menu:

// src/components/my-dialog/my-dialog.tsx

import { Element, Listen /* ... */ } from "@stencil/core";

@Component(/* ... */)
export class MyDialog implements ComponentInterface {
  @Element() el: HTMLElement;

  // ...

  @Listen("keydown", { target: "window" })
  handleKeyDown(event: KeyboardEvent) {
    if (event.key === "Escape") {
      this.open = false;
    }
  }

  @Listen("click", { target: "window" })
  handleWindowClick(event: MouseEvent) {
    // Only close if we click outside the shadow root
    if (!event.composedPath().includes(this.el.shadowRoot)) {
      this.open = false;
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Emitting Events

Say we want to add an icon at the end of the toggle button that points
down when the menu is collapsed, and up when it is expanded. I guess
<my-dialog> will need to tell <my-menu> when it opens or closes.
We can do that with the @Event decorated method
that we’ll fire inside a @Watch decorated method.
Let’s add these to src/components/my-dialog/my-dialog.tsx:

import { Event, EventEmitter, Watch /* ... */ } from "@stencil/core";

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

  @Watch("open")
  openChangedHandler(open: boolean) {
    this.openChanged.emit({ open });
  }

  @Event() openChanged: EventEmitter;

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now listen for this event on the menu in
src/components/my-menu/my-menu.tsx:

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

  @State() open = false;

  private handleToggle(event: CustomEvent) {
    this.open = event.detail.open;
  }

  render() {
    return (
      <Host>
        {/* ... */}

        <my-dialog onOpenChanged={(event) => this.handleToggle(event)}>
          <slot slot="activator" name="label">
            Actions
            <svg
              viewBox="0 0 100 66"
              aria-label={this.open ? "Expanded" : "Collapsed"}
            >
              <polygon
                points={
                  this.open ? "0 66.6, 100 66.6, 50 0" : "0 0, 100 0, 50 66.6"
                }
              />
            </svg>
          </slot>

          {/* ... */}
        </my-dialog>
      </Host>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

And add some styles:

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

/* ... */

slot[name="label"] {
  align-items: center;
  display: flex;
}

slot[name="label"] svg {
  fill: currentcolor;
  block-size: 1em;
  inline-size: 0.666em;
  margin-inline-start: 1ex;
}
Enter fullscreen mode Exit fullscreen mode

And there we have it: A simple dropdown menu component written in
Stencil.

Discussion (0)