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
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.
Maybe the invocation is:
Update the template (remember, positional params!):
The invocation:
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.
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
- 1.4.1: Use of Color
- 1.4.11: Non-Text Contrast
- 1.4.13: Content on Hover or Focus
- 2.1.1: Keyboard
- 2.1.2: No Keyboard Trap
- 2.4.3: Focus Order
- 2.4.7: Focus Visible
So I update my component in three ways:
- 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.
- Wrap the dropdown content in a div with a CSS class for a more consistent styling hook.
- Add a toggle action so the dropdown will open and close.
🤨 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!
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.
I think the invocation stays the same:
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
So here's what I'll try next:
try adding the focus trap to the entire componentthe docs for ember-focus-trap show anactivate
anddeactivate
action- can I have a conditional action on an interactive element?check the original focus-trap library to see if there's some way to exclude the button (toggle) from the focus-trap exclusionthere is: (allowOutsideClick
) but thenclickOutsideDeactivates
won't work.- something else? custom modifier or handle the toggle action differently?
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
andMouseEvent
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?
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. ThefallbackFocus
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 thefallbackFocus
value to bethis.fallbackFocusValue
and put it in our component class.
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:
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.
Noting for myself that there seems to be some different interpretations for what a dropdown should do:
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?)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