Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request for comments / Proof-of-concept towards submenu support #1410

Closed
wants to merge 11 commits into from

Conversation

ecyrb
Copy link
Contributor

@ecyrb ecyrb commented Jun 28, 2023

Apologies in advance.

tl;dr: I'm looking for directional guidance on implementing submenus. Is this the right direction? If not, what can I do to be headed in the right direction? First Github PR, and first use of TypeScript in anger, so apologies for the goofs.

Includes:

  • SubmenuController to manage event listeners on menu-item.
  • Example usage in menu-item documentation.
  • Trivial tests to check rendering.

Outstanding questions include:

  • Accessibility concerns. E.g. where to handle 'ArrowRight', 'ArrowLeft'?
  • Should selection of menu-item denoting submenu be possible or customizable?
  • How to parameterize contained popup?
  • Implementation concerns:
    • Use of ref / id
    • delegation of some rendering to the controller
    • What to test

Related to #620.

This is a Request For Comments to seek directional guidance towards
implementing the submenu slot of menu-item.

Includes:
- SubmenuController to manage event listeners on menu-item.
- Example usage in menu-item documentation.
- Trivial tests to check rendering.

Outstanding questions include:
- Accessibility concerns. E.g. where to handle 'ArrowRight',
  'ArrowLeft'?
- Should selection of menu-item denoting submenu be possible or
  customizable?
- How to parameterize contained popup?
- Implementation concerns:
  - Use of ref / id
  - delegation of some rendering to the controller
  - What to test

Related to [shoelace-style#620](shoelace-style#620).
@vercel
Copy link

vercel bot commented Jun 28, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated (UTC)
shoelace ✅ Ready (Inspect) Visit Preview Jul 14, 2023 8:31pm

Removed extraneous `console.log()`.
src/components/menu-item/menu-item.ts Outdated Show resolved Hide resolved
src/components/menu-item/submenu-controller.ts Outdated Show resolved Hide resolved
}
</style>
<sl-popup
${ref(this.popupRef)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a nit, but can we use @query instead? This is the only thing importing ref, but many things import query so that will be one less module we have to worry about bringing in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this location, this is a ReactiveController, and the query directive wants this to be a ReactiveElement (or at least supporting .querySelector()). I don't believe the @query directive supports changing the renderRoot / querySelector root, but I'm happy to be shown otherwise.

Access to this element could be wrapped in a call to .querySelector() (which is what @query does anyway). ?

I'm slightly surprised there's no non-decorator equivalent I could use, i.e. in the manner this uses createRef(); maybe I just don't know where it is.

I'm happy to change it.

@query source here

(Intuitively, refs should have better performance, right?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I think the ref / queries may be unnecessary. If it were me, I'd probably just write the value of isActive into the template.

  private handleMouseOver = () => {
    clearTimeout(this.mouseOutTimer);
    if (this.hasSlotController.test('submenu') && this.isActive === false) {
      this.isActive = true;
      this.host.requestUpdate()
    }
  }
  
   render () {
      return html`<sl-popup
        ?active=${this.isActive}
        placement=${isLtr ? 'right-start' : 'left-start'}
        anchor="anchor"
        flip
        strategy="fixed"
      >
        <slot name="submenu"></slot>
      </sl-popup>`
   }

The requestUpdate() is important because itll force the re-render since ReactiveControllers dont have reactive properties.

@claviska
Copy link
Member

Thanks for submitting this! I've wanted to add submenus for awhile, but I do have one reservation.

By introducing submenus, we inevitably allow (or will be asked to allow) sub-submenus and sub-sub-submenus, which are usually pretty awful. I'd be OK to scope the feature to one level of submenus, but that might confuse users. If it's technically possible to allow nesting more than one level without collisions, we should at least call it out as a bad practice in the docs.

On mobile, the submenu opens as shown below. The sub-submenu is correctly visible, but the first one goes out of the viewport. Ideally, it would always how within the viewport, even if that means overlapping (which we don't normally want when space is available). There are a few properties of that might be helpful here.

image

I've left some feedback in a review. The approach seems fine, but I really want to make sure we get positioning right as well as:

  • Add keyboard support
  • Research accessibility requirements for submenus and test out our implementation out in screen readers
  • Consider moving the submenu examples to the <sl-menu> docs. We can still talk about them and even show an example in <sl-menu-item> if it makes sense, but people will probably be looking for it on the main menu page.

Thanks again!

@ecyrb
Copy link
Contributor Author

ecyrb commented Jun 29, 2023

Thoughts on following the ARIA Authoring Practices Guide (APG) here, specifically their Menu and Menubar Pattern > Keyboard Interaction guidance?

@claviska
Copy link
Member

claviska commented Jun 29, 2023

That sounds about right!

Any key that corresponds to a printable character (Optional): Move focus to the next item in the current menu whose label begins with that printable character.

There’s some type to select logic in <sl-select> you can explore. Feel free to grab what you need. If it’s useful for this, I’m happy to look into splitting it into a utility. (Totally fine to copy it for this PR if you want.)

@ecyrb
Copy link
Contributor Author

ecyrb commented Jun 29, 2023

There’s some type to select logic in you can explore.

?

@claviska
Copy link
Member

Forgot the backticks, so GitHub stripped the tag. I've updated my previous comment.

(Didn't mean to publish this.)

This reverts commit be04e9a.
@ecyrb
Copy link
Contributor Author

ecyrb commented Jun 30, 2023

I didn't realize pushing to the branch I created the PR from would update the PR. Please ignore for now.

@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 4, 2023

Screenshot 2023-07-04 at 2 13 21 AM

Thoughts on mouseover behavior when another element has focus? (In the image, the focus is on an item in the lower submenu, and the mouse is hovering over the first submenu.)

@mitchray
Copy link
Contributor

mitchray commented Jul 4, 2023

I see the submenus are a little lower than the parent item, is that intentional?

I would suggest aligning them for visual balance and a more predictable pathway for mouse users

250780968-31680efa-2ace-4320-99d6-81217193a040

@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 5, 2023

@mitchray: That is intentional. Even just pulling down menus in my chrome session, the alignment of the first item in the submenu with the "head" of the submenu is consistent (see new attached screenshots). Apologies if you meant something else that I have missed.

My question specifically is about handling mouse-hover highlighting and focus together. In my screenshot, you can see two submenus open because focus and mouseover are acting independently, however, in Chrome application menus, there is not a separate mouse-hover highlight and focus: the most recent action takes focus / highlight. Maybe that is the way forward? But is that correct from an accessibility standpoint?

Screenshot 2023-07-04 at 9 32 22 PM Screenshot 2023-07-04 at 9 27 28 PM

@mitchray
Copy link
Contributor

mitchray commented Jul 5, 2023

Apologies didn't mean to divert the conversation, just wanted to query the alignment situation

To clarify, this is the alignment of your original image (in green below)

his

Is the image with red lines a new screenshot of live code or an edit of the mockup image I shared?

@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 5, 2023

Your initial "screenshot" now makes more sense — which I based mine off of. I agree with your view that the submenus should be in alignment as in your mockup. Thanks for pointing it out!

- Submenus now close on change-of-focus, not a timeout.
- Keyboard navigation support added.
- Skidding fix for better alignment.
- Submenu documentation moved to Menu page.
- Tests for accessibility, right and left arrow keys.
@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 7, 2023

A comment is probably in order about the behaviour change:

From Content on Hover or Focus (Level AA) (W3C's Web Content Accessibility Guidelines), I figured that the most accessible thing to do would be to persist the submenu until the user takes an action that would cause it to be dismissed. This is what my OS does for application menus, but, I have noticed that at least one other web-component library does not do this, and immediately dismisses the submenu if the mouse is moved away from it. To implement this persisting behaviour, I used the focusout event to detect when to dismiss, which meant that I had to set the .focus() on the relevant menu-item.

Also, the popover skidding alignment adjustment (affecting the submenu's popover location) doesn't work with Firefox due to a lack of .computedStyleMap(). Suggestions welcome.

@claviska
Copy link
Member

This is what my OS does for application menus, but, I have noticed that at least one other web-component library does not do this

I think following the OS behavior makes the most sense. I suspect a big reason nested dropdowns are considered bad usability is because submenus tend to be too eager to close.

I like how macOS menus works:

  • Hovering over an item with a submenu shows the submenu
  • The submenu remains open until either:
    • A submenu item is selected
    • Another submenu is hovered
    • An item from the parent menu is hovered
    • Something outside of the menu/submenu is clicked (entire menu closes)
    • Escape is pressed (entire menu closes)

Also, the popover skidding alignment adjustment (affecting the submenu's popover location) doesn't work with Firefox due to a lack of .computedStyleMap(). Suggestions welcome.

If there's no reasonable polyfill, is there maybe a better way to position them? If necessary, I'm even open to using Floating UI directly instead of <sl-popup>. But I'm not sure I fully understand the problem…

@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 12, 2023

But I'm not sure I fully understand the problem…

I don't know if you mean that it looks fine either-way, or something else? There's a mix of screen-shots above, so I'll try to clarify. @mitchray pointed it out best in green above, so I'll just highlight it in their image:

wonky_menu

Floating UI's right-top placement is used, which lines up the top of the submenu with the top of the parent menu-item. .computedStyleMap() is then used to get the style properties of the menu (padding-top, border-top-width, margin-top) to calculate that little red bar, and then shift the menu up by that amount to line up the menu-items across the two menus. It wouldn't surprise me if there's a better way to do this, but it's also probably not the end of the world if Firefox users have to live with it until a better solution is put in place. I do think it looks better when the menu-items are lined-up, but /shrug.

1. Close submenu on click explicitly, so this occurs even if the menu is
   not inside of an sl-dropdown.

2. In menu, ignore clicks that do not explicitly target a menu-item.
   Clicks that were on (e.g. a menu-border) were emitting select events.
Menu's handleKeyDown calls item.click (to emit the selection).
Propagating the keyboard event on Enter / space would the cause re-entry
into a submenu, so prevent the needless propagation.
@claviska
Copy link
Member

Wow, this is looking great! Looking strictly at the example, I'm only seeing a couple visual things that we should figure out.

Submenu hover state is lost when mousing out

When mousing out of the submenu, the hover states are lost (because of nesting). We might need to add some kind of pseudo state for menu items to prevent it. Mimicking macOS, it should probably match the light gray hover color, not the blue focus color.

CleanShot.2023-07-13.at.13.29.13.mp4

I wish we had custom states but, in lieu of that, perhaps we simulate them with data attributes like we're doing with form control validation. This would prevent us from introducing a new pattern and give us a path for backwards compatibility when custom states become available in the future.

Add an opening delay

Submenus appear immediately on hover, which feels especially weird when you have more than one submenu at the same level.

CleanShot.2023-07-13.at.13.58.09.mp4

Contrast this to OS menus, which have a slight delay before opening. It should be quite subtle, perhaps 100–200ms. I don't think it needs to be user configurable for this PR.

Move submenus into a separate example

I'm happy to do this, just leaving it here as a TODO. I'm torn between showing the example on the Menu page, the Menu Item page, or maybe both. Either way, it probably shouldn't live in the first example.

Cosmetic things

The chevrons could use more spacing on the start. This is more obvious when menu items have long labels.

CleanShot 2023-07-13 at 13 53 36@2x

We need to add shadows when menus are used in dropdowns.

CleanShot 2023-07-13 at 13 52 47@2x

Let me know what you're willing to tackle and what you want us to do. I really appreciate your effort to bring this into the library! Thanks again!

@claviska
Copy link
Member

Another visual thing — should we have submenus slightly overlap like this?

CleanShot 2023-07-13 at 14 04 40@2x

And circling back to the topic of delaying menus on hover, I wonder if adding a subtle animation would be useful here. We could potentially delay it using keyframes and this would also make it customizable. If you want to go this route, let me know and I'll be happy to explain my thoughts further.

- 100 ms delay when opening submenus on mouseover
- Shadows added
- Distance added to popup to have submenus overlap menu slightly.
@ecyrb
Copy link
Contributor Author

ecyrb commented Jul 14, 2023

Just pushed a change for a few of these.

Add an opening delay

Hmmm. I used 100 ms for now — for mouseover openings. Should we delay opening on keyboard-based activation? (Enabling the submenu could then return a promise so that focus could .then() be set to the first menu-item?) (I think it's fine with no delay for keyboard users.)

Cosmetic things

Added shadows and overlap. Overlap should probably be dynamic (convert from rem to pixels for Floating UI) or customizable, but I just set it to -4.

Submenu hover state is lost when mousing out

Yeah, there are some remaining issues:

  • Keyboard navigation doesn't leave a visible trail in the same way that mousing over the (menu) tree leaves a "hover trail", so you can see the active parent. I feel like the keyboard navigation could be at least as good as the mouse navigation here.
  • Mouseover events don't currently alter the tabIndex, so if you're hovering over the 5th element of a list, and you press ArrowDown, the focus changes to the 2nd menu-item in the list. Thinking about this some more, I think mouseover should alter tabIndex.
  • Chrome doesn't seem to use :focus-visble until a keyboard event, unlike Safari which always uses it? I think one way to get the browsers to exhibit the same behaviors might mean leveraging :focus and essentially ignoring :hover. Thoughts?

@claviska
Copy link
Member

Can we control the "submenu open" state directly instead of messing with hover/focus? Again, there's a pattern we use for form control validation that might make sense here. I like this pattern because, in the future (pending browser support), we can use ElementInternals.states as a more proper solution while maintaining backwards compatibility with the data attribute.

For example, what if menu items received a data attribute when the submenu is open?

<sl-menu-item data-submenu-open>
  ...
</sl-menu-item>

And that state mapped to the same hover style in CSS:

/* menu-item.styles.ts */

:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item,
:host([data-submenu-open]) {
  background-color: var(--sl-color-neutral-100);
  color: var(--sl-color-neutral-1000);
}

I think that will finish off this PR.

@KonnorRogers
Copy link
Collaborator

Just adding my $0.02, this looks really solid. Mostly code nit-picky stuff, but overall love the look + feel of the submenu seems really nice. Ideally as @claviska stated I'd like to see the submenu appear underneath instead of to the left on smaller screens. Other than that, I really don't have too much to add. 👍

@claviska
Copy link
Member

@ecyrb Sorry, I just noticed you requested a review on this. My comment above sums up what I think is remaining.

Let me know if you'd like help with this or if you'd like to finish it off on your own :)

Thanks again!

@@ -49,19 +48,21 @@ export default class SlMenu extends ShoelaceElement {
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
event.stopPropagation();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm kind of curious why this stopPropagation() was adding? Were the events bubbling causing the parent menus to catch the event?

const item = target.closest('sl-menu-item');

if (!item || item.disabled || item.inert) {
if (!(event.target instanceof SlMenuItem)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know previously we used target.closest('sl-menu-item') so this isn't a huge deal, but we should think of defining common interfaces for cases like this so we don't need to check instance types.

This is purely a comment for the future, and shouldn't affect this PR.

const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;

if (items.length > 0) {
event.preventDefault();
event.stopPropagation();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im assuming the same thing here around parents catching the event.

@claviska claviska mentioned this pull request Aug 18, 2023
2 tasks
@claviska
Copy link
Member

We decided to pick this up and finish it off. Thank you so much for adding this! Really excited to finally ship submenus. Follow #1527 for updates.

@claviska claviska closed this Aug 18, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants