-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
Submenu flipping with Popper.js! #2053
Merged
Merged
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
fe8b321
remove MenuItem useSmartPositioning logic, as Popper.js handles it au…
46fe754
Add submenuBoundaryElement, submenuBoundaryPadding props to Menu. cas…
f678135
docs
aef958e
more docs
e23f78c
use prop-types, add dependency
a796819
refactor MenuItem render. no children => no popover
b770995
use Menu and pass context props
2e92f5a
remove Menu props and context stuff
9800ef2
remove unused refs, final refactors
c7bd0ac
revert props-types change
85f2356
remove submenu props docs
8ee3917
Merge branch 'develop' of github.com:palantir/blueprint into gg/submenus
12958ee
props docs
774173e
delete menu test
4b92799
Merge branch 'develop' of github.com:palantir/blueprint into gg/submenus
472361d
inline submenu Popover, fix positioning relative to parent item
c9f43ca
button menu items inherit font
cc9af72
improve Overlay focus tests reliability
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,12 +25,11 @@ export interface IMenuItemProps extends IActionProps, ILinkProps { | |
*/ | ||
label?: string | JSX.Element; | ||
|
||
/** Props to spread to `Popover`. Note that `content` cannot be changed. */ | ||
popoverProps?: Partial<IPopoverProps> & object; | ||
/** Props to spread to `Popover`. Note that `content` and `minimal` cannot be changed. */ | ||
popoverProps?: Partial<IPopoverProps>; | ||
|
||
/** | ||
* Whether an enabled, non-submenu item should automatically close the | ||
* popover it is nested within when clicked. | ||
* Whether an enabled item without a submenu should automatically close its parent popover when clicked. | ||
* @default true | ||
*/ | ||
shouldDismissPopover?: boolean; | ||
|
@@ -40,72 +39,23 @@ export interface IMenuItemProps extends IActionProps, ILinkProps { | |
* An alternative to providing `MenuItem` components as `children`. | ||
*/ | ||
submenu?: IMenuItemProps[]; | ||
|
||
/** | ||
* Width of `margin` from left or right edge of viewport. Submenus will | ||
* flip to the other side if they come within this distance of that edge. | ||
* This has no effect if omitted or if `useSmartPositioning` is set to `false`. | ||
* Note that these values are not CSS properties; they are used in | ||
* internal math to determine when to flip sides. | ||
*/ | ||
submenuViewportMargin?: { left?: number; right?: number }; | ||
|
||
/** | ||
* Whether a submenu popover will try to reposition itself | ||
* if there isn't room for it in its current position. | ||
* The popover opens right by default, but will try to flip | ||
* left if not enough space. | ||
* @default true | ||
*/ | ||
useSmartPositioning?: boolean; | ||
} | ||
|
||
export interface IMenuItemState { | ||
/** Whether a submenu is opened to the left */ | ||
alignLeft?: boolean; | ||
} | ||
|
||
const REACT_CONTEXT_TYPES: React.ValidationMap<IMenuItemState> = { | ||
alignLeft: (obj: IMenuItemState, key: keyof IMenuItemState) => { | ||
if (obj[key] != null && typeof obj[key] !== "boolean") { | ||
return new Error("[Blueprint] MenuItem context alignLeft must be boolean"); | ||
} | ||
return undefined; | ||
}, | ||
}; | ||
|
||
export class MenuItem extends AbstractPureComponent<IMenuItemProps, IMenuItemState> { | ||
export class MenuItem extends AbstractPureComponent<IMenuItemProps> { | ||
public static defaultProps: IMenuItemProps = { | ||
disabled: false, | ||
popoverProps: {}, | ||
shouldDismissPopover: true, | ||
submenuViewportMargin: {}, | ||
text: "", | ||
useSmartPositioning: true, | ||
}; | ||
public static displayName = "Blueprint2.MenuItem"; | ||
|
||
public static contextTypes = REACT_CONTEXT_TYPES; | ||
public static childContextTypes = REACT_CONTEXT_TYPES; | ||
public context: IMenuItemState; | ||
|
||
public state: IMenuItemState = { | ||
alignLeft: false, | ||
}; | ||
|
||
private liElement: HTMLElement; | ||
private popoverElement: HTMLElement; | ||
private refHandlers = { | ||
li: (ref: HTMLElement) => (this.liElement = ref), | ||
popover: (ref: HTMLElement) => (this.popoverElement = ref), | ||
}; | ||
|
||
public render() { | ||
const { children, disabled, label, submenu, popoverProps } = this.props; | ||
const hasSubmenu = children != null || submenu != null; | ||
const liClasses = classNames({ | ||
[Classes.MENU_SUBMENU]: hasSubmenu, | ||
}); | ||
const { disabled, label } = this.props; | ||
const submenuChildren = this.renderSubmenuChildren(); | ||
const hasSubmenu = submenuChildren != null; | ||
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. nit: only used once, can be inlined 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. used twice, or i would have already inlined it 😄 (other usage is hidden in collapsed block, line 66) |
||
|
||
const liClasses = classNames({ [Classes.MENU_SUBMENU]: hasSubmenu }); | ||
const anchorClasses = classNames( | ||
Classes.MENU_ITEM, | ||
Classes.intentClass(this.props.intent), | ||
|
@@ -118,62 +68,20 @@ export class MenuItem extends AbstractPureComponent<IMenuItemProps, IMenuItemSta | |
this.props.className, | ||
); | ||
|
||
let labelElement: JSX.Element; | ||
if (label != null) { | ||
labelElement = <span className="pt-menu-item-label">{label}</span>; | ||
} | ||
|
||
let content = ( | ||
const target = ( | ||
<a | ||
className={anchorClasses} | ||
href={disabled ? undefined : this.props.href} | ||
onClick={disabled ? undefined : this.props.onClick} | ||
tabIndex={disabled ? undefined : 0} | ||
target={this.props.target} | ||
> | ||
{labelElement} | ||
{label && <span className={Classes.MENU_ITEM_LABEL}>{label}</span>} | ||
{this.props.text} | ||
</a> | ||
); | ||
|
||
if (hasSubmenu) { | ||
const submenuContent = <Menu>{this.renderChildren()}</Menu>; | ||
const popoverClasses = classNames(Classes.MENU_SUBMENU, popoverProps.popoverClassName, { | ||
// apply this class to make the popover anchor to the *left* | ||
// side of the menu (setting Position.LEFT_TOP alone would still | ||
// anchor the popover to the right edge of the target). | ||
[Classes.ALIGN_LEFT]: this.state.alignLeft, | ||
}); | ||
|
||
content = ( | ||
<Popover | ||
disabled={disabled} | ||
enforceFocus={false} | ||
hoverCloseDelay={0} | ||
inline={true} | ||
interactionKind={PopoverInteractionKind.HOVER} | ||
position={this.state.alignLeft ? Position.LEFT_TOP : Position.RIGHT_TOP} | ||
{...popoverProps} | ||
content={submenuContent} | ||
minimal={true} | ||
popoverClassName={popoverClasses} | ||
popoverDidOpen={this.handlePopoverDidOpen} | ||
popoverRef={this.refHandlers.popover} | ||
> | ||
{content} | ||
</Popover> | ||
); | ||
} | ||
|
||
return ( | ||
<li className={liClasses} ref={this.refHandlers.li}> | ||
{content} | ||
</li> | ||
); | ||
} | ||
|
||
public getChildContext() { | ||
return { alignLeft: this.state.alignLeft }; | ||
return <li className={liClasses}>{this.maybeRenderPopover(target, submenuChildren)}</li>; | ||
} | ||
|
||
protected validateProps(props: IMenuItemProps & { children?: React.ReactNode }) { | ||
|
@@ -182,97 +90,44 @@ export class MenuItem extends AbstractPureComponent<IMenuItemProps, IMenuItemSta | |
} | ||
} | ||
|
||
private handlePopoverDidOpen = () => { | ||
if (this.props.useSmartPositioning) { | ||
// Popper.js renders the popover in the DOM before relocating its | ||
// position on the next tick. We need to rAF to wait for that to happen. | ||
requestAnimationFrame(() => this.maybeAlignSubmenuLeft()); | ||
} | ||
}; | ||
|
||
private maybeAlignSubmenuLeft() { | ||
if (this.popoverElement == null) { | ||
return; | ||
} | ||
|
||
const submenuRect = this.popoverElement.getBoundingClientRect(); | ||
const parentWidth = this.liElement.parentElement.getBoundingClientRect().width; | ||
const adjustmentWidth = submenuRect.width + parentWidth; | ||
|
||
// this ensures that the left and right measurements represent a submenu opened to the right | ||
let submenuLeft = submenuRect.left; | ||
let submenuRight = submenuRect.right; | ||
if (this.state.alignLeft) { | ||
submenuLeft += adjustmentWidth; | ||
submenuRight += adjustmentWidth; | ||
} | ||
|
||
const { left = 0 } = this.props.submenuViewportMargin; | ||
let { right = 0 } = this.props.submenuViewportMargin; | ||
if ( | ||
typeof document !== "undefined" && | ||
typeof document.documentElement !== "undefined" && | ||
Number(document.documentElement.clientWidth) | ||
) { | ||
// we're in a browser context and the clientWidth is available, | ||
// use it to set calculate 'right' | ||
right = document.documentElement.clientWidth - right; | ||
} | ||
// uses context to prioritize the previous positioning | ||
let alignLeft = this.context.alignLeft || false; | ||
if (alignLeft) { | ||
if (submenuLeft - adjustmentWidth <= left) { | ||
alignLeft = false; | ||
} | ||
} else { | ||
if (submenuRight >= right) { | ||
alignLeft = true; | ||
} | ||
} | ||
|
||
this.setState({ alignLeft }); | ||
private maybeRenderPopover(target: JSX.Element, children?: React.ReactNode) { | ||
const { disabled, popoverProps } = this.props; | ||
return children == null ? ( | ||
target | ||
) : ( | ||
<Popover | ||
disabled={disabled} | ||
enforceFocus={false} | ||
hoverCloseDelay={0} | ||
interactionKind={PopoverInteractionKind.HOVER} | ||
modifiers={SUBMENU_POPOVER_MODIFIERS} | ||
position={Position.RIGHT_TOP} | ||
{...popoverProps} | ||
content={<Menu>{children}</Menu>} | ||
minimal={true} | ||
popoverClassName={classNames(Classes.MENU_SUBMENU, popoverProps.popoverClassName)} | ||
target={target} | ||
/> | ||
); | ||
} | ||
|
||
private renderChildren = () => { | ||
private renderSubmenuChildren(): React.ReactNode { | ||
const { children, submenu } = this.props; | ||
|
||
if (children != null) { | ||
const childProps = this.cascadeProps(); | ||
if (Object.keys(childProps).length === 0) { | ||
return children; | ||
} else { | ||
return React.Children.map(children, (child: JSX.Element) => { | ||
return React.cloneElement(child, childProps); | ||
}); | ||
} | ||
return children; | ||
} else if (submenu != null) { | ||
return submenu.map(this.cascadeProps).map(renderMenuItem); | ||
return submenu.map(renderMenuItem); | ||
} else { | ||
return undefined; | ||
} | ||
}; | ||
|
||
/** | ||
* Evalutes this.props and cascades prop values into new props when: | ||
* - submenuViewportMargin is defined, but is undefined for the supplied input. | ||
* - useSmartPositioning is false, but is undefined for the supplied input. | ||
* @param {IMenuItemProps} newProps If supplied, object will be modified, otherwise, defaults to an empty object. | ||
* @returns An object to be used as child props. | ||
*/ | ||
private cascadeProps = (newProps: IMenuItemProps = ({} as any) as IMenuItemProps) => { | ||
const { submenuViewportMargin, useSmartPositioning } = this.props; | ||
|
||
if (submenuViewportMargin != null && newProps.submenuViewportMargin == null) { | ||
newProps.submenuViewportMargin = submenuViewportMargin; | ||
return null; | ||
} | ||
if (useSmartPositioning === false && newProps.useSmartPositioning == null) { | ||
newProps.useSmartPositioning = useSmartPositioning; | ||
} | ||
|
||
return newProps; | ||
}; | ||
} | ||
} | ||
|
||
export function renderMenuItem(props: IMenuItemProps, key: string | number) { | ||
return <MenuItem key={key} {...props} />; | ||
} | ||
|
||
const SUBMENU_POPOVER_MODIFIERS: Popper.Modifiers = { | ||
flip: { boundariesElement: "viewport", padding: 5 }, | ||
preventOverflow: { boundariesElement: "viewport", padding: 5 }, | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
& object
was there intentionally... IIRC without it, you can assign literally anything non-null to this field.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.
& object
messes up the docs, which i take to be more important. a prop called*Props
is expected to be an object.