# 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 `
`. 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. ```hbs {{!-- my-dropdown.hbs --}}
{{yield to="toggle"}} {{#if this.isActive}} {{yield to="content"}} {{/if}}
``` Maybe the invocation is: ```hbs <:toggle> {!-- the user's content --}} <:content> {!-- the user's content --}} <:content> ``` ### Attach an action to be passed to the yielded element Update the template (remember, positional params!): ```hbs {{yield (hash toggleAction=this.toggleAction) to="toggle"}} ``` The invocation: ```hbs <:toggle as |t|> ``` ### 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: - 1.3.1: [Information and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) - 1.4.1: [Use of Color](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html) - 1.4.11: [Non-Text Contrast](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html) - 1.4.13: [Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html) - 2.1.1: [Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html) - 2.1.2: [No Keyboard Trap](https://www.w3.org/WAI/WCAG21/Understanding/no-keyboard-trap) - 2.4.3: [Focus Order](https://www.w3.org/WAI/WCAG21/Understanding/focus-order) - 2.4.7: [Focus Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) ## 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. ```hbs {{!-- my-component.hbs --}}
{{#if this.isActive}}
{{yield to="dropdown-content"}}
{{/if}}
``` ๐Ÿคจ _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 ```js // 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! ```hbs <:toggle-content> {{!-- user's content in the button --}} <:dropdown-content> {{!-- user's content --}} <:dropdown-content> ``` ## 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](https://ember-focus-trap.netlify.app/) 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**. ```hbs {{!-- my-component.hbs --}}
{{#if this.isActive}}
{{yield to="dropdown-content"}}
{{/if}}
``` I think the invocation stays the same: ```hbs <:toggle-content> {{!-- user's content for the button(toggle) --}} <:dropdown-content> {{!-- user's content that contains an interactive element --}} <:dropdown-content> ``` 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)](https://github.com/mansona) to see if we figure out more from the [focus-trap](https://github.com/focus-trap/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) ```js @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](https://github.com/focus-trap/focus-trap) documentation, I should be able to set a container to be the [fallback focusable element](https://github.com/focus-trap/focus-trap#your-trap-should-include-a-tabbable-element-or-a-focusable-container), which would mean I could have a container with non-interactive content. ~~Ended up filing an issue: https://github.com/josemarluedke/ember-focus-trap/issues/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. ```hbs {{!-- my-component.hbs --}}
{{#if this.isActive}}
{{yield to="dropdown-content"}}
{{/if}}
``` 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: ```js // 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: ```hbs <:toggle-content> {{!-- user's content for the button(toggle) --}} <:dropdown-content> {{!-- user's content --}} <:dropdown-content> ``` 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 `
` 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.