-
Notifications
You must be signed in to change notification settings - Fork 261
fix: Close nested Menus when hovering over other items #4090
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
base: support
Are you sure you want to change the base?
Changes from all commits
34a699d
95b7599
b316321
40b6a72
e027866
38a1e9b
430b1fb
3252a66
4de6bf4
d90ac47
af74382
88a941c
7d2f3bb
4c3d9d4
896507b
32d5f34
79bd8db
671906d
4164a24
3cf9cd8
8c3fac6
d4d1cd0
6ea1ab4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| /** | ||
| * Checks whether an element is disabled, either via the native `disabled` DOM property (set, for | ||
| * example, by `useListItemRegister` when an item's id is included in `state.nonInteractiveIds`) or | ||
| * via the `aria-disabled="true"` attribute (set by consumers directly on an item, e.g. | ||
| * `<Menu.Item aria-disabled>`). | ||
| * | ||
| * Collection items should use this check instead of testing `aria-disabled` alone so that items | ||
| * disabled through either mechanism are consistently blocked from activating (click, Enter/Space, | ||
| * hover-intent, etc.). | ||
| */ | ||
| export const isElementDisabled = (element: Element | null | undefined): boolean => { | ||
| if (!element) { | ||
| return false; | ||
| } | ||
| return element.getAttribute('aria-disabled') === 'true' || element.hasAttribute('disabled'); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import React from 'react'; | |
|
|
||
| import {createElemPropsHook} from '@workday/canvas-kit-react/common'; | ||
|
|
||
| import {isElementDisabled} from './isElementDisabled'; | ||
| import {useListModel} from './useListModel'; | ||
|
|
||
| /** | ||
|
|
@@ -23,12 +24,11 @@ export const useListItemSelect = createElemPropsHook(useListModel)(( | |
| ) => { | ||
| const name = elemProps['data-id'] || ''; | ||
| const onClick = (event: React.MouseEvent<HTMLElement>) => { | ||
| if ( | ||
| !state.nonInteractiveIds.includes(name) && | ||
| event.currentTarget.getAttribute('aria-disabled') !== 'true' | ||
| ) { | ||
| events.select({id: name}); | ||
| if (isElementDisabled(event.currentTarget) || state.nonInteractiveIds.includes(name)) { | ||
| return null; | ||
| } | ||
| events.select({id: name}); | ||
| return undefined; | ||
|
Comment on lines
+27
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you need anything to be returned here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. returning undefined here allows the event to continue or if someone passes an onclick, that it would still be called, the return null explicitly stops the function from being called. |
||
| }; | ||
|
|
||
| return {onClick}; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,15 @@ | ||
| import * as React from 'react'; | ||
|
|
||
| import {Card} from '@workday/canvas-kit-react/card'; | ||
| import {listBoxContainerStencil} from '@workday/canvas-kit-react/collection'; | ||
| import { | ||
| ExtractProps, | ||
| createElemPropsHook, | ||
| createSubcomponent, | ||
| } from '@workday/canvas-kit-react/common'; | ||
| import {mergeStyles} from '@workday/canvas-kit-react/layout'; | ||
| import {getTransformFromPlacement} from '@workday/canvas-kit-react/popup'; | ||
| import {calc, createStencil, px2rem} from '@workday/canvas-kit-styling'; | ||
| import {calc, createStencil, cssVar, px2rem} from '@workday/canvas-kit-styling'; | ||
| import {system} from '@workday/canvas-tokens-web'; | ||
|
|
||
| import {useMenuModel} from './useMenuModel'; | ||
|
|
@@ -42,12 +43,22 @@ export const menuCardStencil = createStencil({ | |
| maxWidth: calc.subtract('100vw', system.legacy.size.sm), | ||
| boxShadow: system.depth[3], | ||
| minWidth, | ||
| maxHeight, | ||
| maxHeight: cssVar(maxHeight, '60vh'), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add a default value here
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting this |
||
| transformOrigin: `${transformOriginVertical} ${transformOriginHorizontal}`, | ||
| // Allow overriding of animation in special cases | ||
| '.wd-no-animation &': { | ||
| animation: 'none', | ||
| }, | ||
| [`&:where(:has(${listBoxContainerStencil.parts.listBoxContainer.selector}))`]: { | ||
| overflow: 'hidden', | ||
| }, | ||
| [`& :where(${listBoxContainerStencil.parts.listBoxContainer.selector})`]: { | ||
| borderRadius: system.legacy.shape.xxl, | ||
| // Card is a flex column container. Without this, a flex child won't shrink below its | ||
| // content size, so `maxHeight` on the Card would be ignored and content would overflow | ||
| // instead of scrolling inside the list-box-container. | ||
| minHeight: 0, | ||
| }, | ||
| }), | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,13 @@ import * as React from 'react'; | |
|
|
||
| import { | ||
| isCursor, | ||
| isElementDisabled, | ||
| useListItemRegister, | ||
| useListItemRovingFocus, | ||
| useListItemSelect, | ||
| } from '@workday/canvas-kit-react/collection'; | ||
| import { | ||
| changeFocus, | ||
| composeHooks, | ||
| createComponent, | ||
| createElemPropsHook, | ||
|
|
@@ -179,7 +181,12 @@ export const useMenuItemArrowReturn = createElemPropsHook(useMenuModel)(model => | |
| onKeyDown(event: React.KeyboardEvent) { | ||
| const styles = getComputedStyle(event.currentTarget); | ||
| if (event.key === 'ArrowLeft' && styles.direction === 'ltr' && model.UNSTABLE_parentModel) { | ||
| event.preventDefault(); | ||
| const target = model.state.targetRef.current as HTMLElement | null; | ||
| model.events.hide(event); | ||
| requestAnimationFrame(() => { | ||
| changeFocus(target); | ||
| }); | ||
| } | ||
| }, | ||
| }; | ||
|
|
@@ -192,6 +199,27 @@ export const useMenuItemFocus = createElemPropsHook(useMenuModel)(( | |
| ) => { | ||
| const {localRef, elementRef} = useLocalRef(ref as React.Ref<HTMLElement>); | ||
| const id = elemProps['data-id']; | ||
|
|
||
| // A menu keeps its cursor between openings, so reopening would restore focus to a disabled item. | ||
| // Items remount whenever the menu opens, which distinguishes reopening from navigating onto a | ||
| // disabled item while the menu is already open. Clearing the cursor lets the roving focus | ||
| // fallback in `useListItemRovingFocus` move focus to the first item. The check waits for the | ||
| // first render where `id` is known, since items without an explicit `data-id` register it later. | ||
| const hasCheckedDisabledCursor = React.useRef(false); | ||
| React.useLayoutEffect(() => { | ||
| if (hasCheckedDisabledCursor.current || !id) { | ||
| return; | ||
| } | ||
| hasCheckedDisabledCursor.current = true; | ||
|
|
||
| const isItemDisabled = isElementDisabled(localRef.current); | ||
|
|
||
| if (model.state.mode === 'single' && isCursor(model.state, id) && isItemDisabled) { | ||
| model.events.goToFirst(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this part of the code highlights that if the cursor was at a disabled item and the menu is closed, the first item is focus upon reopening |
||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [id]); | ||
|
|
||
| // focus on the item with the cursor | ||
| React.useLayoutEffect(() => { | ||
| if (model.state.mode === 'single') { | ||
|
|
@@ -227,11 +255,12 @@ export const useMenuItem = composeHooks( | |
| onClick: | ||
| model.state.mode === 'single' | ||
| ? (event: React.SyntheticEvent) => { | ||
| // only hide if the item isn't disabled | ||
| if (event.currentTarget.getAttribute('aria-disabled') !== 'true') { | ||
| model.events.hide(event); | ||
| hideParent(model); | ||
| if (isElementDisabled(event.currentTarget as Element)) { | ||
| return null; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If element is disabled, we don't fire the on click event, this fixes the bug that previously allowed on click events for disabled items. |
||
| } | ||
| model.events.hide(event); | ||
| hideParent(model); | ||
| return undefined; | ||
| } | ||
| : undefined, | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious. How do we decide which lib functions get tests? I see some in
modules/react/collection/specbut definitely not for all.