Skip to content

Instantly share code, notes, and snippets.

@MelSumner
Last active May 11, 2022 15:48
Show Gist options
  • Save MelSumner/1e724431bcb3ec666408240a85fa94db to your computer and use it in GitHub Desktop.
Save MelSumner/1e724431bcb3ec666408240a85fa94db to your computer and use it in GitHub Desktop.

Let's Make A Dropdown

Here, we intend a component that can be used for something like a user menu in a nav. Maybe you've heard it called "popover" or something like that.

It is not meant to be a select element, or used in a <form>.

Goals:

  • make a component that other developers can consume as an addon
  • it should be accessible
  • maybe: control what is yielded to some places
  • maybe: multiple yields

Part One: The General Idea

The simplest form of a dropdown component, from a template perspective, would be a div with the component's name as a CSS class, wrapping a yield for a toggle and a yield for a dropdown container. The dropdown container would be wrapped in an if statement that conditionally displayed the content.

{{!-- my-dropdown.hbs --}}
<div class="my-dropdown">
   {{yield to="toggle"}}
  
  {{#if this.isActive}}
    {{yield to="content"}}
  {{/if}}
</div>

Maybe the invocation is:

<MyDropdown>
  <:toggle>
    {!-- the user's content --}}
  </:toggle>
  <:content>
    {!-- the user's content --}}
   <:content>
</MyDropdown>

Attach an action to be passed to the yielded element

Update the template (remember, positional params!):

{{yield (hash toggleAction=this.toggleAction) to="toggle"}}

The invocation:

<:toggle as |t|>
  <button type="button" {{on "click" t.toggleAction}}>
   Some text
  </button>
</:toggle>

Accessibility

Also, we should understand what kind of accessibility criteria we are thinking about here. Even if we're just aware of some criteria that design will take care of, we should know that it exists and is relevant to what we're creating.

Relevant WCAG

While some of these might not be relevant to what you do with a dropdown component, these are the success criteria that are probably related to dropdowns and the things that go inside of them:

Part Two: Updating Semantics

So I update my component in three ways:

  1. Add a button element. The button is the only eligible element for this action, and we want to guide users to the well-lit path. The user can still put custom content in the button when they invoke the component.
  2. Wrap the dropdown content in a div with a CSS class for a more consistent styling hook.
  3. Add a toggle action so the dropdown will open and close.
{{!-- my-component.hbs --}}
<div class="my-dropdown">
  <button type="button" {{on "click" this.toggleAction}}>
    {{yield to="toggle-content"}}
  </button>
  
  {{#if this.isActive}}
    <div class="my-dropdown__container">
      {{yield to="dropdown-content"}}
    </div>
  {{/if}}
</div>

🤨 Do I need this backing class? Is there a template-only way to do it?

Answer(s):

  • having the backing class is idiomatic Ember right now
  • we could do a {{#let}} situation if we really want to move things to template-only
  • we will need the backing class later for other things, so let's just leave it be for now
// my-component.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class MyDropdownComponent extends Component {
  @tracked isActive = false;

  @action 
  toggleAction() {
    this.isActive = !this.isActive;
  }
}

As a result, the invocation changes a little bit:

🤨 Is dasherized okay? Answer: seems fine!

<MyDropdown>
  <:toggle-content>
    {{!-- user's content in the button --}}
  </:toggle-content>
  <:dropdown-content>
   {{!-- user's content --}}
   <:dropdown-content>
</MyDropdown>

Part Three: Adding Focus-Trap

Ok so if I can do this, I'd like to add a focus trap to the <:dropdown-content> container, that way we're making sure it's accessible.

I should be able to:

  • keyboard: open the dropdown with the ENTER or SPACE key when focused on the toggle (button)
  • keyboard: press the ESC key when the dropdown is open to close it
  • mouse: open the dropdown with a click on the toggle (button)
  • mouse: click outside the dropdown to close it
  • mouse: close the dropdown with a click on the toggle (button)
  • focus: when I press ESC and close the dropdown, focus should return to the toggle button
  • focus: when the dropdown is open, I should only be able to TAB to the interactive elements inside of the dropdown

Let's try adding the Ember Focus Trap addon, noting that the user has to add an interactive element inside of the element that has the focus-trap, or an error will be thrown.

{{!-- my-component.hbs --}}
<div class="my-dropdown">
  <button type="button" {{on "click" this.toggleAction}}>
    {{yield to="toggle-content"}}
  </button>
  
  {{#if this.isActive}}
    <div
      class="my-dropdown__container"
      {{focus-trap
        focusTrapOptions=(hash
          onDeactivate=this.deactivate
          clickOutsideDeactivates=true
        )
      }}
    >
      {{yield to="dropdown-content"}}
    </div>
  {{/if}}
</div>

I think the invocation stays the same:

<MyDropdown>
  <:toggle-content>
    {{!-- user's content for the button(toggle) --}}
  </:toggle-content>
  <:dropdown-content>
    {{!-- user's content that contains an interactive element --}}
   <:dropdown-content>
</MyDropdown>

Requirements check:

  • ✅ keyboard: open the dropdown with the ENTER or SPACE key when focused on the toggle (button)
  • ✅ keyboard: press the ESC key when the dropdown is open to close it
  • ✅ mouse: open the dropdown with a click on the toggle (button)
  • ✅ mouse: click outside the dropdown to close it
  • ❌ mouse: close the dropdown with a click on the toggle (button)
  • ✅ focus: when I press ESC and close the dropdown, focus should return to the toggle button
  • ✅ focus: when the dropdown is open, I should only be able to TAB to the interactive elements inside of the dropdown

Potential solutions

So here's what I'll try next:

  1. try adding the focus trap to the entire component
  2. the docs for ember-focus-trap show an activate and deactivate action- can I have a conditional action on an interactive element?
  3. check the original focus-trap library to see if there's some way to exclude the button (toggle) from the focus-trap exclusion there is: (allowOutsideClick) but then clickOutsideDeactivates won't work.
  4. something else? custom modifier or handle the toggle action differently?

Update (Feb 28, 2022)

Still working on the component because we want to be able to click outside of the dropdown to close it AND ALSO click on the toggle button to close it again. According to the focus-trap docs, you get one or the other, but not both. But it's just javascript, so maybe?

Dug into the events to see if I could figure out what was going on, and paired with Chris Manson (mansona) to see if we figure out more from the focus-trap documentation itself.

Discovered that the PointerEvent and MouseEvent are being handled separately.

Added:

  • clickOutside action
  • console logging to figure out what events are really going on
  • in the toggleAction, added some console log for math
  • added a weird hack to sort of make the PointerEvent and MouseEvent chill out (but this is super brittle)
  @action
  clickedOutside(event) {
    this.clickedOutsideEvent = event;
    console.log('clickedOutside action', event);
    return true;
  }

  @action
  deactivate() {
    if (this.isActive) {
      this.isActive = false;
      console.log('deactivate action');
    }
  }

  @action
  toggleAction(event) {
    console.log(`toggle action: ${event.timeStamp} - ${this.clickedOutsideEvent?.timeStamp}`);
    // ewwwwww this is a super hack and temporary
    if (this.clickedOutsideEvent && event.timeStamp - this.clickedOutsideEvent.timeStamp < 300) {
      return;
    }
    this.isActive = !this.isActive;
  }
}

Then in the component template file, changed the clickOutsideDeactivates from true to this.clickedOutside.

This sort of works. If I don't click too fast, or don't leave the menu open and then go like, make a sandwich, and then come back and try to click to close it, then it's fine. I guess I shouldn't say it works, because it just feels...awful.

🤔 How on earth do I do this correctly? Might need to dig into browser events more? Or is the last line of event to file an issue with the focus-trap maintaners and/or see about re-writing this to better fit our needs?

Part Four: (Bonus) Support containers with non-interactive content

As per the focus-trap documentation, I should be able to set a container to be the fallback focusable element, which would mean I could have a container with non-interactive content.

Ended up filing an issue: josemarluedke/ember-focus-trap#56

Ok, figured out what the issue was. Let's continue. Since the original library supports non-interactive content through the use of a fallback element that can receive focus, we can go ahead and add support that for greater flexibility. To do this, we'll need to adjust the component's template and backing class. The invocation still will not change (so convenient!).

Adjust the component template in three ways:

  • add a negative tabindex to the dropdown container (this tells browsers that the element is eligible to receive focus)
  • add a unique id to the dropdown container
  • set that value to the fallbackFocus option...oh wait. The fallbackFocus value expects the # at the beginning. I don't know how to do this as an argument passed inside of a hash to a modifier (if you know, feel free to tell me). Let's make the fallbackFocus value to be this.fallbackFocusValue and put it in our component class.
{{!-- my-component.hbs --}}
<div class="my-dropdown">
  <button 
    type="button"
    {{on "click" this.toggleAction}}
  >
    {{yield to="toggle-content"}}
  </button>
  
  {{#if this.isActive}}
    <div
      class="my-dropdown__container {{if isActive 'is-active'}}"
      id={{this.contentId}}
      tabindex="-1"
      {{focus-trap
        isActive=this.isActive
        focusTrapOptions=(hash
          onDeactivate=this.deactivate
          clickOutsideDeactivates=true
          fallbackFocus=this.fallbackFocusValue
        )
      }}
    >
      {{yield to="dropdown-content"}}
    </div>
  {{/if}}
</div>

Ok so then I can make it work (again, is this the best way to do it? IDK, so feel free to tell me if I can improve it) in my component .js file.

At the same time, I'm going to add some of the things that ember-focus-trap indicates should be there. and see if that improves my other issue.

So my file ends up looking something like this:

// my-component.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';

export default class MyDropdownComponent extends Component {
  containerId = 'dropdown-container-' + guidFor(this);

  // is there a better way to do this? feels incorrect but also it works.
  get fallBackFocusValue() {
    let fallBackFocusValue = '#' + this.containerId;
    return fallBackFocusValue;
  }

  @tracked isActive = false;

  @action
  activate() {
    this.isActive = true;
  }

  @action
  deactivate() {
    if (this.isActive) {
      this.isActive = false;
    }
  }

  @action
  toggleAction() {
    this.isActive = !this.isActive;
  }
}

Again, the invocation stays the same:

<MyDropdown>
  <:toggle-content>
    {{!-- user's content for the button(toggle) --}}
  </:toggle-content>
  <:dropdown-content>
    {{!-- user's content --}}
   <:dropdown-content>
</MyDropdown>

What will happen is that if there are no interactive elements inside of the <:dropdown-content>, focus will instead go to the container itself. I think this is pretty nifty because while it might be more semantically correct in a single instance to use a <details> element, here we will have more flexibility with a dropdown component that supports both interactive and non-interactive content, while staying accessible the whole time.

So cool, this part works too. Now we only have left the single challenge in part 3.

@MelSumner
Copy link
Author

Current status: it all works except when using the mouse to click on the toggle button to close.

Update notes: https://gist.github.com/MelSumner/1e724431bcb3ec666408240a85fa94db#update-feb-28-2022

@MelSumner
Copy link
Author

MelSumner commented Mar 1, 2022

Noting for myself that there seems to be some different interpretations for what a dropdown should do:

  1. you don't need to focus trap if the dropdown content is full-width and pushes down the content below it (in that case, it's more like an accordion with links in it) - see gov.uk for an example of this.
  2. it's possible that you could close the dropdown when you tab out of the last item, and that could satisfy the UX. See bootstrap (sorta, it has its own a11y issue )
  3. I have viewed this onBlur (ish) action as distinctly form-like, and forms have their own special set of expected actions/interactions. (but now curious: should I re-think?)
  4. When inert lands in the browser, this should be easier. Idk what the polyfill would be like to implement here. But that will be useful in the long run.

If curious, see this Twitter thread

@MelSumner
Copy link
Author

MelSumner commented Mar 2, 2022

From the internet, here's some acceptable patterns:

  • In some cases, a large pop-up/drop-down section is effectively a modal dialog. It needs to be closed to move on. Trap focus.
  • If it's more like an accordion section, don't trap.
  • If it's more like a menu, autoclose when you tab out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment