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

[Breadcrumbs] Add new component #3106

Merged
merged 22 commits into from
Nov 2, 2018
Merged
Show file tree
Hide file tree
Changes from 12 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
1 change: 1 addition & 0 deletions packages/core/src/components/breadcrumbs/_breadcrumbs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Styleguide breadcrumbs
background: $light-gray1;
cursor: pointer;
padding: 1px ($pt-grid-size / 2);
vertical-align: text-bottom;

&::before {
display: block;
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/components/breadcrumbs/breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export const Breadcrumb: React.SFC<IBreadcrumbProps> = breadcrumbProps => {
},
breadcrumbProps.className,
);
if (breadcrumbProps.href == null && breadcrumbProps.onClick == null) {
return (
<span className={classes}>
{breadcrumbProps.text}
{breadcrumbProps.children}
</span>
);
}
return (
<a
className={classes}
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/components/breadcrumbs/breadcrumbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

Breadcrumbs identify the current resource in an application.

@css breadcrumbs
@reactExample BreadcrumbsExample

@## Props

The component renders an `a.@ns-breadcrumb`. You are responsible for constructing
the `ul.@ns-breadcrumbs` list. [`CollapsibleList`](#core/components/collapsible-list)
works nicely with this component because its props are a subset of `IMenuItemProps`.
@### Breadcrumbs

The component renders an `OverflowList` with all the supplied `Breadcrumb`s inside.
invliD marked this conversation as resolved.
Show resolved Hide resolved

@interface IBreadcrumbsProps

@### Breadcrumb

The component renders an `a.@ns-breadcrumb` (or a `span.@ns-breadcrumb` if there is no `href` or
invliD marked this conversation as resolved.
Show resolved Hide resolved
`onClick` handler).

@interface IBreadcrumbProps

Expand All @@ -27,3 +34,5 @@ user to that resource.
containing breadcrumbs that are collapsed due to layout constraints.
* When adding another element (such as a [tooltip](#core/components/tooltip) or
[popover](#core/components/popover)) to a breadcrumb, wrap it around the contents of the `li`.

@css breadcrumbs
127 changes: 127 additions & 0 deletions packages/core/src/components/breadcrumbs/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2018 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the terms of the LICENSE file distributed with this project.
*/

import classNames from "classnames";
import * as React from "react";

import { Boundary } from "../../common/boundary";
import * as Classes from "../../common/classes";
import { Position } from "../../common/position";
import { IProps } from "../../common/props";
import { Menu } from "../menu/menu";
import { MenuItem } from "../menu/menuItem";
import { IOverflowListProps, OverflowList } from "../overflow-list/overflowList";
import { IPopoverProps, Popover } from "../popover/popover";
import { Breadcrumb, IBreadcrumbProps } from "./breadcrumb";

export interface IBreadcrumbsProps extends IProps {
/**
* Callback invoked to render visible breadcrumbs. If
* `currentBreadcrumbRenderer` is also supplied, that callback will be used
* for the current breadcrumb instead.
*
* If this callback is not supplied, a `Breadcrumb` will be rendered.
invliD marked this conversation as resolved.
Show resolved Hide resolved
*/
breadcrumbRenderer?: (props: IBreadcrumbProps) => JSX.Element;

/**
* Which direction the breadcrumbs should collapse from: start or end.
* @default Boundary.START
*/
collapseFrom?: Boundary;

/**
* Callback invoked to render to current breadcrumb, which is the last
invliD marked this conversation as resolved.
Show resolved Hide resolved
* element in the `items` array.
*
* If this callback is not supplied, the `breadcrumbRenderer` will be
invliD marked this conversation as resolved.
Show resolved Hide resolved
* invoked for the current breadcrumb instead.
*/
currentBreadcrumbRenderer?: (props: IBreadcrumbProps) => JSX.Element;

/**
* All breadcrumbs to display. Breadcrumbs that do not fit in the container
* will be rendered in an overflow menu instead.
*/
items: IBreadcrumbProps[];

/**
* The number of visible breadcrumbs will never be lower than the number
* passed to this prop.
invliD marked this conversation as resolved.
Show resolved Hide resolved
* @default 0
*/
minVisibleItems?: number;

/**
* Props to spread to `OverflowList`. Note that `items`,
* `overflowRenderer`, and `visibleItemRenderer` cannot be changed.
*/
overflowListProps?: Partial<IOverflowListProps<IBreadcrumbProps>>;

/**
* Props to spread to the `Popover` showing the overflow menu.
*/
popoverProps?: IPopoverProps;
}

export class Breadcrumbs extends React.PureComponent<IBreadcrumbsProps> {
public static defaultProps: Partial<IBreadcrumbsProps> = {
collapseFrom: Boundary.START,
};

public render() {
const { className, collapseFrom, items, minVisibleItems, overflowListProps = {} } = this.props;
return (
<OverflowList
collapseFrom={collapseFrom}
minVisibleItems={minVisibleItems}
tagName="ul"
{...overflowListProps}
className={classNames(Classes.BREADCRUMBS, overflowListProps.className, className)}
items={items}
overflowRenderer={this.renderOverflow}
visibleItemRenderer={this.renderBreadcrumbWrapper}
/>
);
}

private renderOverflow = (items: IBreadcrumbProps[]) => {
const { collapseFrom } = this.props;
const position = collapseFrom === Boundary.END ? Position.BOTTOM_RIGHT : Position.BOTTOM_LEFT;
let orderedItems = items;
if (collapseFrom === Boundary.START) {
orderedItems = items.slice().reverse();
invliD marked this conversation as resolved.
Show resolved Hide resolved
}
return (
<li>
<Popover position={position} {...this.props.popoverProps}>
<span className={Classes.BREADCRUMBS_COLLAPSED} />
<Menu>{orderedItems.map(this.renderOverflowBreadcrumb)}</Menu>
</Popover>
</li>
);
};

private renderOverflowBreadcrumb = (props: IBreadcrumbProps, index: number) => {
const isClickable = props.href != null || props.onClick != null;
return <MenuItem disabled={!isClickable} {...props} text={props.text} key={index} />;
};

private renderBreadcrumbWrapper = (props: IBreadcrumbProps, index: number) => {
const isCurrent = this.props.items[this.props.items.length - 1] === props;
return <li key={index}>{this.renderBreadcrumb(props, isCurrent)}</li>;
};

private renderBreadcrumb(props: IBreadcrumbProps, isCurrent: boolean) {
if (isCurrent && this.props.currentBreadcrumbRenderer != null) {
return this.props.currentBreadcrumbRenderer(props);
} else if (this.props.breadcrumbRenderer != null) {
return this.props.breadcrumbRenderer(props);
} else {
return <Breadcrumb {...props} current={isCurrent} />;
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const ContextMenu = contextMenu;

export * from "./alert/alert";
export * from "./breadcrumbs/breadcrumb";
export * from "./breadcrumbs/breadcrumbs";
export * from "./button/buttons";
export * from "./button/buttonGroup";
export * from "./callout/callout";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ to watch for resizing further up in the DOM tree.

[resizeobserver]: https://developers.google.com/web/updates/2016/10/resizeobserver

@reactExample OverflowListExample
@reactExample BreadcrumbsExample

@## Props

Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/components/overflow-list/overflowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface IOverflowListProps<T> extends IProps {
/** CSS properties to apply to the root element. */
style?: React.CSSProperties;

/**
* HTML tag name for the container element.
* @default "div"
*/
tagName?: keyof JSX.IntrinsicElements;

/**
* Callback invoked to render each visible item.
* Remember to set a `key` on the rendered element!
Expand Down Expand Up @@ -164,16 +170,23 @@ export class OverflowList<T> extends React.PureComponent<IOverflowListProps<T>,
}

public render() {
const { className, collapseFrom, observeParents, style, visibleItemRenderer } = this.props;
const {
className,
collapseFrom,
observeParents,
style,
tagName: TagName = "div",
visibleItemRenderer,
} = this.props;
const overflow = this.maybeRenderOverflow();
return (
<ResizeSensor onResize={this.resize} observeParents={observeParents}>
<div className={classNames(Classes.OVERFLOW_LIST, className)} style={style}>
<TagName className={classNames(Classes.OVERFLOW_LIST, className)} style={style}>
{collapseFrom === Boundary.START ? overflow : null}
{this.state.visible.map(visibleItemRenderer)}
{collapseFrom === Boundary.END ? overflow : null}
<div className={Classes.OVERFLOW_LIST_SPACER} ref={ref => (this.spacer = ref)} />
</div>
</TagName>
</ResizeSensor>
);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/core/test/breadcrumbs/breadcrumbTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,14 @@ describe("Breadcrumb", () => {
shallow(<Breadcrumb disabled={true} onClick={onClick} text="Hello" />).simulate("click");
assert.isTrue(onClick.notCalled, "onClick called");
});

it("renders an a tag if it's clickable", () => {
assert.lengthOf(shallow(<Breadcrumb href="test" />).find("a"), 1);
assert.lengthOf(shallow(<Breadcrumb href="test" />).find("span"), 0);
});

it("renders a span tag if it's not clickable", () => {
assert.lengthOf(shallow(<Breadcrumb />).find("a"), 0);
assert.lengthOf(shallow(<Breadcrumb />).find("span"), 1);
});
});
117 changes: 117 additions & 0 deletions packages/core/test/breadcrumbs/breadcrumbsTests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2018 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the terms of the LICENSE file distributed with this project.
*/

import { assert } from "chai";
import { mount } from "enzyme";
import * as React from "react";
import * as sinon from "sinon";

import { Classes } from "../../src/common";
import { Boundary } from "../../src/common/boundary";
import { Breadcrumb, IBreadcrumbProps } from "../../src/components/breadcrumbs/breadcrumb";
import { Breadcrumbs } from "../../src/components/breadcrumbs/breadcrumbs";
import { MenuItem } from "../../src/components/menu/menuItem";
import { IOverflowListProps, OverflowList } from "../../src/components/overflow-list/overflowList";

const ITEMS: IBreadcrumbProps[] = [{ text: "1" }, { text: "2" }, { text: "3" }];

// Note that the `Breadcrumbs` component in these tests is not actually mounted into the document.
// That means the `OverflowList` will always render all items into the overflow (since it detects
// its own size as 0), except for the `minVisibleItems` prop. Due to this detail, we use the
// `minVisibleItems` prop to set the exact number of visible/overflown breadcrumbs.
invliD marked this conversation as resolved.
Show resolved Hide resolved

describe("Breadcrumbs", () => {
it("passes through props to the OverflowList", () => {
const overflowListProps = mount(
<Breadcrumbs
className="breadcrumbs-class"
collapseFrom={Boundary.END}
items={[]}
minVisibleItems={7}
overflowListProps={{ className: "overflow-list-class", tagName: "article" }}
/>,
)
.find<IOverflowListProps<IBreadcrumbProps>>(OverflowList)
.props();
assert.equal(overflowListProps.className, `${Classes.BREADCRUMBS} overflow-list-class breadcrumbs-class`);
assert.equal(overflowListProps.collapseFrom, Boundary.END);
assert.equal(overflowListProps.minVisibleItems, 7);
assert.equal(overflowListProps.tagName, "article");
});

it("makes the last breadcrumb current", () => {
const breadcrumbs = mount(<Breadcrumbs items={ITEMS} minVisibleItems={ITEMS.length} />).find(Breadcrumb);
assert.lengthOf(breadcrumbs, ITEMS.length);
assert.isFalse(breadcrumbs.get(0).props.current);
assert.isTrue(breadcrumbs.get(ITEMS.length - 1).props.current);
});

it("renders overflow indicator", () => {
assert.lengthOf(
mount(<Breadcrumbs items={ITEMS} minVisibleItems={1} />).find(`.${Classes.BREADCRUMBS_COLLAPSED}`),
1,
);
});

it("renders the correct overflow menu items", () => {
const menuItems = mount(
<Breadcrumbs items={ITEMS} minVisibleItems={1} popoverProps={{ isOpen: true, usePortal: false }} />,
).find(MenuItem);
assert.lengthOf(menuItems, ITEMS.length - 1);
assert.equal(menuItems.get(0).props.text, "2");
assert.equal(menuItems.get(1).props.text, "1");
});

it("renders the correct overflow menu items when collapsing from END", () => {
const menuItems = mount(
<Breadcrumbs
collapseFrom={Boundary.END}
items={ITEMS}
minVisibleItems={1}
popoverProps={{ isOpen: true, usePortal: false }}
/>,
).find(MenuItem);
assert.lengthOf(menuItems, ITEMS.length - 1);
assert.equal(menuItems.get(0).props.text, "2");
assert.equal(menuItems.get(1).props.text, "3");
});

it("disables menu item when it is not clickable", () => {
const menuItems = mount(<Breadcrumbs items={ITEMS} popoverProps={{ isOpen: true, usePortal: false }} />).find(
MenuItem,
);
assert.lengthOf(menuItems, ITEMS.length);
assert.isTrue(menuItems.get(0).props.disabled);
});

it("calls currentBreadcrumbRenderer (only) for the current breadcrumb", () => {
const spy = sinon.spy();
mount(<Breadcrumbs currentBreadcrumbRenderer={spy} items={ITEMS} minVisibleItems={ITEMS.length} />);
assert.isTrue(spy.calledOnce);
assert.isTrue(spy.calledWith(ITEMS[ITEMS.length - 1]));
});

it("does not call breadcrumbRenderer for the current breadcrumb when there is a currentBreadcrumbRenderer", () => {
const spy = sinon.spy();
mount(
<Breadcrumbs
breadcrumbRenderer={spy}
// tslint:disable-next-line:jsx-no-lambda
currentBreadcrumbRenderer={() => undefined}
items={ITEMS}
minVisibleItems={ITEMS.length}
/>,
);
assert.equal(spy.callCount, ITEMS.length - 1);
assert.isTrue(spy.neverCalledWith(ITEMS[ITEMS.length - 1]));
});

it("calls breadcrumbRenderer", () => {
const spy = sinon.spy();
mount(<Breadcrumbs breadcrumbRenderer={spy} items={ITEMS} minVisibleItems={ITEMS.length} />);
assert.equal(spy.callCount, ITEMS.length);
});
});
1 change: 1 addition & 0 deletions packages/core/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import "@blueprintjs/test-commons/bootstrap";

import "./alert/alertTests";
import "./breadcrumbs/breadcrumbsTests";
import "./breadcrumbs/breadcrumbTests";
import "./buttons/buttonTests";
import "./callout/calloutTests";
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/isotest.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ describe("Core isomorphic rendering", () => {
Alert: {
props: { isOpen: true, usePortal: false },
},
Breadcrumbs: {
props: { items: [] },
},
Dialog: {
props: { isOpen: true, usePortal: false },
},
Expand Down
Loading