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

Submenu flipping with Popper.js! #2053

Merged
merged 18 commits into from
Jan 31, 2018
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/common/abstractPureComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isNodeEnv } from "./utils";
* An abstract component that Blueprint components can extend
* in order to add some common functionality like runtime props validation.
*/
export abstract class AbstractPureComponent<P, S> extends React.PureComponent<P, S> {
export abstract class AbstractPureComponent<P, S = {}> extends React.PureComponent<P, S> {
/** Component displayName should be `public static`. This property exists to prevent incorrect usage. */
protected displayName: never;

Expand Down
30 changes: 17 additions & 13 deletions packages/core/src/components/menu/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,28 @@ The submenu opens to the right of its parent by default, but will adjust and fli
there is not enough room to the right.

```jsx
<MenuItem text="Submenu">
<MenuItem text="Child one" />
<MenuItem text="Child two" />
<MenuItem text="Child three" />
</MenuItem>
<Menu>
<MenuItem text="Submenu">
<MenuItem text="Child one" />
<MenuItem text="Child two" />
<MenuItem text="Child three" />
</MenuItem>
</Menu>
```

Alternatively, you can pass an array of `IMenuItemProps` to the `submenu` prop:

```jsx
React.createElement(MenuItem, {
submenu: [
{ text: "Child one" },
{ text: "Child two" },
{ text: "Child three" },
],
text: "parent",
});
React.createElement(Menu, {},
React.createElement(MenuItem, {
submenu: [
{ text: "Child one" },
{ text: "Child two" },
{ text: "Child three" },
],
text: "parent",
}),
);
```

<div class="pt-callout pt-intent-warning pt-icon-warning-sign">
Expand Down
227 changes: 41 additions & 186 deletions packages/core/src/components/menu/menuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Copy link
Contributor

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.

Copy link
Contributor Author

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.
screen shot 2018-01-31 at 3 06 45 pm


/**
* 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;
Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: only used once, can be inlined

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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),
Expand All @@ -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 }) {
Expand All @@ -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 },
};
Loading