diff --git a/ghdocs/BESTPRACTICES.md b/ghdocs/BESTPRACTICES.md index 4023121785c15..130a90ef9c836 100644 --- a/ghdocs/BESTPRACTICES.md +++ b/ghdocs/BESTPRACTICES.md @@ -1,8 +1,67 @@ +# Component design + +## Build many smaller components and composite them together. + +Often we want to build something complex, like a CommandBar. We see a picture of what we want and we build one giant component that does this. Then we find other scenarios that have overlaps. The CommandBar contains a SearchBox, a set of left side collapsable links and right side links. We may find we have other cases that just want a set of links, without the extra overhead. We may also find cases where we just want a single command bar item with a dropdown menu. + +This is an example where instead of building a single large component, we should build more atomic building blocks that are puzzled together. While it may be more effort to think in smaller blocks, it makes the code easier to reuse and often simpler to maintain. + +## Use a .Props.ts file to extract out the public contracts that should be supported and documented. + +A props file contains all of the interface contracts that the user should know about to use the component. It is the "contract" for the component. When we evaluate semversioning, we look through the changes at Props files to determine if the change is a major, minor, or patch. + +The props files are also auto documented. All JSDoc comments will be extracted and shown on the demo site for documentation. + +When your component exposes public methods/properties, define an interface for the component in the props file and implement the interface. The auto documentation will interpret the I{Component} interface as the class contract and show it in the documentation as the class definition. + +```typescript +interface IButton { + /** + * Sets focus to the button element. + */ + focus(): void; +} +``` + +## Extend from BaseComponent instead of React.Component in most cases. + +In the common folder, there exists a BaseComponent class. For simple components, it may be unnecessary to use. + +If you extend this, you get a few useful utilities: + +_events: An instance of the EventGroup, scoped to the component. It will auto dispose on component unmounting so that you don't forget. + +_async: A collection of utilities for performing async operations, scoped to the component. This includes setTimeout/setInterval helpers as well as utilities for generating throttled/debounced wrappers. Again, anything you use here will be automatically cleaned up on unmounting. + +_disposables: An array of IDisposable instances. If you have things you want disposed, you can push them into this. + +Another interesting thing is that when exceptions occur within React's methods, things tend to silently fail. With the BaseComponent, we +make all methods "safe" meaning that if an exception occurs, we send the exception to a global callback which can be hooked up to a telemetry post. By default however, we forward the exception to console.error with the exact class/method that threw the exception so that there is an obvious hint what went wrong. + +There are some cases where it may be overkill to subclass from this; a simple Button wrapper for example really doesn't need to be more than a simple stateless component and doesn't need extra imports, which would result in making Button's dependency graph heavier. Use your best judgement. + +## Use React eventing, unless you need to use native. + +Be aware that React eventing and DOM eventing are two different systems. They do not play well with each other. DOM event handlers will always fire BEFORE React events, regardless of the DOM structure. This can introduce unexpected bugs in code that mixes both React and native DOM eventing. + +Unfortunately there are plenty of scenarios where we must mix the two systems together; for example, you may need to listen for application-wide clicks that bubble up to window in order to implement a light-dismiss behavior. Or perhaps you need to listen for window resizes. Or maybe you need to observe scroll events so that you can hide/show something. + +We use the EventGroup object for abstracting native eventing. It is simple to use; there is an "on" method and an "off" method that wrap calling addEventListener in modern browsers (or attachEvent in legacy IE.) Again if you're using the BaseComponent, it is already available to you via the _events property. + +## Root elements should have a component class name. + +Every component's root element should have a ms-Component class name. Additinally the user should be able to provide their own className via prop that should be added to the class list of the root element. + +If specific component elements need special classnames injected, add more classNames to props. + +A component's SCSS file should ONLY include files applicable to the component, and should not define styles for any other component. + # Class name guidelines TODO: include our class name guidelines. Example: + ms-Component-area--flags # Style guidelines @@ -35,14 +94,6 @@ Additionaly try to have symetrical paddings rather than using padding-right or l E.g. using ms-font-s classname in a component is forbidden. It makes overriding CSS rules really painful. Instead, use @include ms-font-m; -## Root elements should have a component class name. - -Every component's root element should have a ms-Component class name. Additinally the user should be able to provide their own className via prop that should be added to the class list of the root element. - -If specific component elements need special classnames injected, add more classNames to props. - -A component's SCSS file should ONLY include files applicable to the component, and should not define styles for any other component. - # Example page guidelines Examples should follow a naming convention: Component.Desc.Example.ts diff --git a/index.html b/index.html index 4203ebda5e05f..ed23055b9405d 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/MarqueeSelection.ts b/src/MarqueeSelection.ts new file mode 100644 index 0000000000000..75b9d1b0a56e8 --- /dev/null +++ b/src/MarqueeSelection.ts @@ -0,0 +1,3 @@ +export * from './components/MarqueeSelection/MarqueeSelection'; +export * from './components/MarqueeSelection/MarqueeSelection.Props'; +export * from './utilities/selection/index'; diff --git a/src/common/BaseComponent.ts b/src/common/BaseComponent.ts index 3829a401311b6..565f952f5c4ca 100644 --- a/src/common/BaseComponent.ts +++ b/src/common/BaseComponent.ts @@ -3,6 +3,11 @@ import { Async } from '../utilities/Async/Async'; import { EventGroup } from '../utilities/eventGroup/EventGroup'; import { IDisposable } from './IDisposable'; +// Ensure that the HTML element has a dir specified. This helps to ensure RTL/LTR macros in css for all components will work. +if (document && document.documentElement && !document.documentElement.getAttribute('dir')) { + document.documentElement.setAttribute('dir', 'ltr'); +} + export class BaseComponent extends React.Component { /** * External consumers should override BaseComponent.onError to hook into error messages that occur from diff --git a/src/common/IPoint.ts b/src/common/IPoint.ts new file mode 100644 index 0000000000000..79f682edcb744 --- /dev/null +++ b/src/common/IPoint.ts @@ -0,0 +1,4 @@ +export interface IPoint { + x: number; + y: number; +} diff --git a/src/common/IRectangle.ts b/src/common/IRectangle.ts new file mode 100644 index 0000000000000..b13b19a8b303f --- /dev/null +++ b/src/common/IRectangle.ts @@ -0,0 +1,8 @@ +export interface IRectangle { + left: number; + top: number; + width: number; + height: number; + right?: number; + bottom?: number; +} diff --git a/src/components/DetailsList/DetailsHeader.tsx b/src/components/DetailsList/DetailsHeader.tsx index 891ff78a7cc8e..dda52bb335461 100644 --- a/src/components/DetailsList/DetailsHeader.tsx +++ b/src/components/DetailsList/DetailsHeader.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { BaseComponent } from '../../common/BaseComponent'; import { IColumn, DetailsListLayoutMode, ColumnActionsMode } from './DetailsList.Props'; import { FocusZone, FocusZoneDirection } from '../../FocusZone'; import { Check } from '../Check/Check'; @@ -6,7 +7,6 @@ import { GroupSpacer } from '../GroupedList/GroupSpacer'; import { css } from '../../utilities/css'; import { ISelection, SelectionMode, SELECTION_CHANGE } from '../../utilities/selection/interfaces'; import { getRTL } from '../../utilities/rtl'; -import { EventGroup } from '../../utilities/eventGroup/EventGroup'; import './DetailsHeader.scss'; const MOUSEDOWN_PRIMARY_BUTTON = 0; // for mouse down event we are using ev.button property, 0 means left button @@ -48,23 +48,20 @@ export interface IColumnResizeDetails { columnMinWidth: number; } -export class DetailsHeader extends React.Component { +export class DetailsHeader extends BaseComponent { public static defaultProps = { isSelectAllVisible: true }; public refs: { [key: string]: React.ReactInstance; + root: HTMLElement; focusZone: FocusZone; }; - private _events: EventGroup; - constructor(props: IDetailsHeaderProps) { super(props); - this._events = new EventGroup(this); - this.state = { columnResizeDetails: null, groupNestingDepth: this.props.groupNestingDepth, @@ -79,10 +76,7 @@ export class DetailsHeader extends React.Component + ref='root' + data-automationid='DetailsHeader'> { showSelectAllCheckbox ? (
@@ -180,10 +175,10 @@ export class DetailsHeader extends React.Component { (column.isResizable) ? (
) : (null) } @@ -278,14 +273,16 @@ export class DetailsHeader extends React.Component + ); } @@ -443,7 +443,7 @@ export class DetailsList extends React.Component, IFabricState> { @@ -29,37 +26,30 @@ export class Fabric extends React.Component, IFabricStat }; private _events: EventGroup; - private _scrollTimerId: number; constructor() { super(); this.state = { - isFocusVisible: false, - isStationary: true + isFocusVisible: false }; this._events = new EventGroup(this); - this._onScrollEnd = this._onScrollEnd.bind(this); } public componentDidMount() { this._events.on(document.body, 'mousedown', this._onMouseDown, true); this._events.on(document.body, 'keydown', this._onKeyDown, true); - this._events.on(window, 'scroll', this._onScroll, true); } public componentWillUnmount() { this._events.dispose(); - clearTimeout(this._scrollTimerId); } public render() { - const { isFocusVisible, isStationary } = this.state; + const { isFocusVisible } = this.state; const rootClass = css('ms-Fabric ms-font-m', this.props.className, { - 'is-focusVisible': isFocusVisible, - 'is-stationary': isStationary, - 'is-scrolling': !isStationary + 'is-focusVisible': isFocusVisible }); return ( @@ -82,23 +72,4 @@ export class Fabric extends React.Component, IFabricStat }); } } - - private _onScroll() { - let { isStationary } = this.state; - - clearTimeout(this._scrollTimerId); - if (isStationary) { - this.setState({ - isStationary: false - }); - } - - this._scrollTimerId = setTimeout(this._onScrollEnd, STATIONARY_DETECTION_DELAY); - } - - private _onScrollEnd() { - this.setState({ - isStationary: true - }); - } } diff --git a/src/components/MarqueeSelection/MarqueeSelection.Props.ts b/src/components/MarqueeSelection/MarqueeSelection.Props.ts new file mode 100644 index 0000000000000..37939c2bd6b1c --- /dev/null +++ b/src/components/MarqueeSelection/MarqueeSelection.Props.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { ISelection } from '../../utilities/selection/interfaces'; +import { MarqueeSelection } from './MarqueeSelection'; + +export interface IMarqueeSelectionProps extends React.Props { + /** + * The selection object to interact with when updating selection changes. + */ + selection: ISelection; + + /** + * Optional props to mix into the root DIV element. + */ + rootProps?: React.HTMLProps; + + /** + * Optional callback that is called, when the mouse down event occurs, in order to determine + * if we should start a marquee selection. If true is returned, we will cancel the mousedown + * event to prevent upstream mousedown handlers from executing. + */ + onShouldStartSelection?: (ev: React.MouseEvent) => boolean; + + /** + * Optional flag to control the enabled state of marquee selection. This allows you to render + * it and have events all ready to go, but conditionally disable it. That way transitioning + * between enabled/disabled generate no difference in the DOM. + * @default true + */ + isEnabled?: boolean; + + /** + * Optional flag to restrict the drag rect to the root element, instead of allowing the drag + * rect to start outside of the root element boundaries. + * @default false + */ + isDraggingConstrainedToRoot?: boolean; +} diff --git a/src/components/MarqueeSelection/MarqueeSelection.scss b/src/components/MarqueeSelection/MarqueeSelection.scss new file mode 100644 index 0000000000000..2efac9dae19c2 --- /dev/null +++ b/src/components/MarqueeSelection/MarqueeSelection.scss @@ -0,0 +1,33 @@ +@import '../../common/common'; + +.ms-MarqueeSelection { + position: relative; + cursor: default; +} + +.ms-MarqueeSelection-dragMask { + position: absolute; + background: rgba(255, 0, 0, 0); + left: 0; + top: 0; + right: 0; + bottom: 0; +} + +.ms-MarqueeSelection-box { + position: absolute; + box-sizing: border-box; + border: 1px solid $ms-color-themePrimary; + pointer-events: none; +} + +.ms-MarqueeSelection-boxFill { + position: absolute; + box-sizing: border-box; + background-color: $ms-color-themePrimary; + opacity: .1; + left: 0; + top: 0; + right: 0; + bottom: 0; +} diff --git a/src/components/MarqueeSelection/MarqueeSelection.tsx b/src/components/MarqueeSelection/MarqueeSelection.tsx new file mode 100644 index 0000000000000..27f93a22a56b6 --- /dev/null +++ b/src/components/MarqueeSelection/MarqueeSelection.tsx @@ -0,0 +1,246 @@ +import * as React from 'react'; +import { AutoScroll } from '../../utilities/AutoScroll/AutoScroll'; +import { BaseComponent } from '../../common/BaseComponent'; +import { IMarqueeSelectionProps } from './MarqueeSelection.Props'; +import { IPoint } from '../../common/IPoint'; +import { IRectangle } from '../../common/IRectangle'; +import { css } from '../../utilities/css'; +import { findScrollableParent } from '../../utilities/scrollUtilities'; +import { getDistanceBetweenPoints } from '../../utilities/math'; + +import './MarqueeSelection.scss'; + +export interface IMarqueeSelectionState { + dragOrigin?: IPoint; + dragRect?: IRectangle; +} + +// We want to make the marquee selection start when the user drags a minimum distance. Otherwise we'd start +// the drag even if they just click an item without moving. +const MIN_DRAG_DISTANCE = 5; + +/** + * MarqueeSelection component abstracts managing a draggable rectangle which sets items selected/not selected. + * Elements which have data-selectable-index attributes are queried and measured once to determine if they + * fall within the bounds of the rectangle. The measure is memoized during the drag as a performance optimization + * so if the items change sizes while dragging, that could cause incorrect results. + */ +export class MarqueeSelection extends BaseComponent { + public static defaultProps = { + rootTagName: 'div', + rootProps: {}, + isEnabled: true + }; + + public refs: { + [key: string]: React.ReactInstance; + root: HTMLElement; + }; + + private _dragOrigin: IPoint; + private _rootRect: IRectangle; + private _lastMouseEvent: MouseEvent; + private _autoScroll: AutoScroll; + private _selectedIndicies: { [key: string]: boolean }; + private _itemRectCache: { [key: string]: IRectangle }; + private _scrollableParent: HTMLElement; + private _scrollTop: number; + + constructor(props: IMarqueeSelectionProps) { + super(props); + + this.state = { + dragRect: undefined + }; + + this._onMouseDown = this._onMouseDown.bind(this); + } + + public componentDidMount() { + this._scrollableParent = findScrollableParent(this.refs.root); + + if (!this.props.isDraggingConstrainedToRoot && this._scrollableParent) { + this._events.on(this._scrollableParent, 'mousedown', this._onMouseDown); + } + } + + public componentWillUnmount() { + if (this._autoScroll) { + this._autoScroll.dispose(); + } + } + + public render(): JSX.Element { + let { rootProps, children, isDraggingConstrainedToRoot } = this.props; + let { dragRect } = this.state; + + return ( +
+ { children } + { dragRect && (
) } + { dragRect && ( +
+
+
+ ) } +
+ ); + } + + private _onMouseDown(ev: React.MouseEvent) { + let { isEnabled, onShouldStartSelection } = this.props; + + if (isEnabled && (!onShouldStartSelection || onShouldStartSelection(ev))) { + let scrollableParent = findScrollableParent(this.refs.root); + + if (scrollableParent && ev.button === 0) { + this._selectedIndicies = {}; + this._events.on(window, 'mousemove', this._onMouseMove); + this._events.on(scrollableParent, 'scroll', this._onMouseMove); + this._events.on(window, 'mouseup', this._onMouseUp, true); + this._autoScroll = new AutoScroll(this.refs.root); + this._scrollableParent = scrollableParent; + this._scrollTop = scrollableParent.scrollTop; + this._rootRect = this.refs.root.getBoundingClientRect(); + + this._onMouseMove(ev.nativeEvent as MouseEvent); + } + } + } + + private _getRootRect(): IRectangle { + return { + left: this._rootRect.left, + top: this._rootRect.top + (this._scrollTop - this._scrollableParent.scrollTop), + width: this._rootRect.width, + height: this._rootRect.height + }; + } + + private _onMouseMove(ev: MouseEvent) { + if (ev.clientX !== undefined) { + this._lastMouseEvent = ev; + } + + let rootRect = this._getRootRect(); + let currentPoint = { x: ev.clientX - rootRect.left, y: ev.clientY - rootRect.top }; + + if (!this._dragOrigin) { + this._dragOrigin = currentPoint; + } + + if (ev.buttons !== undefined && ev.buttons === 0) { + this._onMouseUp(ev); + } else { + if (this.state.dragRect || getDistanceBetweenPoints(this._dragOrigin, currentPoint) > MIN_DRAG_DISTANCE) { + // We need to constrain the current point to the rootRect boundaries. + let constrainedPoint = this.props.isDraggingConstrainedToRoot ? { + x: Math.max(0, Math.min(rootRect.width, this._lastMouseEvent.clientX - rootRect.left)), + y: Math.max(0, Math.min(rootRect.height, this._lastMouseEvent.clientY - rootRect.top)) + } : { + x: this._lastMouseEvent.clientX - rootRect.left, + y: this._lastMouseEvent.clientY - rootRect.top + }; + + let dragRect = { + left: Math.min(this._dragOrigin.x, constrainedPoint.x), + top: Math.min(this._dragOrigin.y, constrainedPoint.y), + width: Math.abs(constrainedPoint.x - this._dragOrigin.x), + height: Math.abs(constrainedPoint.y - this._dragOrigin.y) + }; + + this.setState({ dragRect }); + this._evaluateSelection(dragRect); + } + } + + ev.stopPropagation(); + ev.preventDefault(); + + return false; + } + + private _onMouseUp(ev: MouseEvent) { + let scrollableParent = findScrollableParent(this.refs.root); + + this._events.off(window); + this._events.off(scrollableParent, 'scroll'); + + this._autoScroll.dispose(); + this._autoScroll = this._dragOrigin = this._lastMouseEvent = this._selectedIndicies = this._itemRectCache = undefined; + + if (this.state.dragRect) { + this.setState({ + dragRect: undefined + }); + + ev.preventDefault(); + ev.stopPropagation(); + } + } + + private _evaluateSelection(dragRect: IRectangle) { + let { selection } = this.props; + let rootRect = this._getRootRect(); + let allElements = this.refs.root.querySelectorAll('[data-selection-index]'); + + if (!this._itemRectCache) { + this._itemRectCache = {}; + } + + // Stop change events, clear selection to re-populate. + selection.setChangeEvents(false); + selection.setAllSelected(false); + + for (let i = 0; i < allElements.length; i++) { + let element = allElements[i]; + let index = element.getAttribute('data-selection-index'); + + // Pull the memoized rectangle for the item, or the get the rect and memoize. + let itemRect = this._itemRectCache[index]; + + if (!itemRect) { + itemRect = element.getBoundingClientRect(); + + // Normalize the item rect to the dragRect coordinates. + itemRect = { + left: itemRect.left - rootRect.left, + top: itemRect.top - rootRect.top, + width: itemRect.width, + height: itemRect.height, + right: (itemRect.left - rootRect.left) + itemRect.width, + bottom: (itemRect.top - rootRect.top) + itemRect.height + }; + + if (itemRect.width > 0 && itemRect.height > 0) { + this._itemRectCache[index] = itemRect; + } + } + + if ( + itemRect.top < (dragRect.top + dragRect.height) && + itemRect.bottom > dragRect.top && + itemRect.left < (dragRect.left + dragRect.width) && + itemRect.right > dragRect.left + ) { + this._selectedIndicies[index] = true; + } else { + delete this._selectedIndicies[index]; + } + } + + for (let index in this._selectedIndicies) { + if (this._selectedIndicies.hasOwnProperty(index)) { + selection.setIndexSelected(Number(index), true, false); + } + } + + selection.setChangeEvents(true); + } +} + diff --git a/src/demo/components/App/AppState.ts b/src/demo/components/App/AppState.ts index 7b2b1f6798f8c..1beebaa400406 100644 --- a/src/demo/components/App/AppState.ts +++ b/src/demo/components/App/AppState.ts @@ -21,6 +21,7 @@ import { LabelPage } from '../../pages/LabelPage/LabelPage'; import { LayerPage } from '../../pages/LayerPage/LayerPage'; import { LinkPage } from '../../pages/LinkPage/LinkPage'; import { ListPage } from '../../pages/ListPage/ListPage'; +import { MarqueeSelectionPage } from '../../pages/MarqueeSelectionPage/MarqueeSelectionPage'; import { MessageBarPage } from '../../pages/MessageBarPage/MessageBarPage'; import { NavPage } from '../../pages/NavPage/NavPage'; import { OrgChartPage } from '../../pages/OrgChartPage/OrgChartPage'; @@ -86,7 +87,7 @@ export const AppState: IAppState = { component: ChoiceGroupPage, name: 'ChoiceGroup', status: ExampleStatus.beta, - url: '#/examples/ChoiceGroup' + url: '#/examples/choicegroup' }, { component: CommandBarPage, @@ -134,7 +135,7 @@ export const AppState: IAppState = { component: FacepilePage, name: 'Facepile', status: ExampleStatus.started, - url: '#/examples/Facepile' + url: '#/examples/facepile' }, { component: LabelPage, @@ -158,7 +159,7 @@ export const AppState: IAppState = { component: MessageBarPage, name: 'MessageBar', status: ExampleStatus.placeholder, - url: '#/examples/MessageBar' + url: '#/examples/messagebar' }, { component: OrgChartPage, @@ -247,7 +248,7 @@ export const AppState: IAppState = { component: GroupedListPage, name: 'GroupedList', status: ExampleStatus.started, - url: '#examples/GroupedList' + url: '#examples/groupedlist' }, { component: ImagePage, @@ -265,13 +266,13 @@ export const AppState: IAppState = { component: NavPage, name: 'Nav', status: ExampleStatus.started, - url: '#/examples/Nav?mytest=1' + url: '#/examples/nav' }, { component: SliderPage, name: 'Slider', status: ExampleStatus.beta, - url: '#/examples/Slider' + url: '#/examples/slider' } ], name: 'Extended components' @@ -280,21 +281,27 @@ export const AppState: IAppState = { links: [ { component: FocusTrapZonePage, - name: 'Focus Trap zones', + name: 'FocusTrapZone', status: ExampleStatus.beta, url: '#examples/focustrapzone' }, { component: FocusZonePage, - name: 'Focus zones', + name: 'FocusZone', status: ExampleStatus.beta, url: '#examples/focuszone' }, + { + component: MarqueeSelectionPage, + name: 'MarqueeSelection', + status: ExampleStatus.beta, + url: '#examples/marqueeselection' + }, { component: SelectionPage, - name: 'Selection management', + name: 'Selection', status: ExampleStatus.beta, - url: '#examples/selectionManagement' + url: '#examples/selection' }, { component: ThemePage, diff --git a/src/demo/pages/DetailsListPage/DetailsListPage.tsx b/src/demo/pages/DetailsListPage/DetailsListPage.tsx index 890cebf2e90ba..df71e84ca3fbd 100644 --- a/src/demo/pages/DetailsListPage/DetailsListPage.tsx +++ b/src/demo/pages/DetailsListPage/DetailsListPage.tsx @@ -29,7 +29,7 @@ export class DetailsListPage extends React.Component { and provides a sortable, filterable, justified table for rendering large sets of items. This component replaces the Table Component.

Examples

- + diff --git a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx index 79eb9c9800697..757c208c5eda0 100644 --- a/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx +++ b/src/demo/pages/DetailsListPage/examples/DetailsList.Basic.Example.tsx @@ -3,17 +3,23 @@ import * as React from 'react'; /* tslint:enable:no-unused-variable */ import { DetailsList, - TextField + TextField, + Selection, + MarqueeSelection } from '../../../../index'; import { createListItems } from '../../../utilities/data'; let _items: any[]; export class DetailsListBasicExample extends React.Component { + private _selection: Selection; + constructor() { super(); _items = _items || createListItems(500); + this._selection = new Selection(); + this.state = { filterText: '' }; } @@ -27,7 +33,9 @@ export class DetailsListBasicExample extends React.Component { label='Filter by name:' onChanged={ text => this.setState({ filterText: text }) } /> - + + +
); } diff --git a/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx b/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx new file mode 100644 index 0000000000000..5010b49383a38 --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/MarqueeSelectionPage.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { ExampleCard } from '../../components/index'; + +import { MarqueeSelectionBasicExample } from './examples/MarqueeSelection.Basic.Example'; + +const MarqueeSelectionBasicExampleCode = require('./examples/MarqueeSelection.Basic.Example.tsx'); + +export class MarqueeSelectionPage extends React.Component { + + public render() { + return ( +
+

MarqueeSelection

+

+ The MarqueeSelection component provides a service which allows the user to drag a rectangle to be drawn around + items to select them. This works in conjunction with a selection object, which can be used to generically store selection state, separate from a component that consumes the state. +

+

+ MarqueeSelection also works in conjunction with the AutoScroll utility to automatically scroll the container when we drag a rectangle within the vicinity of the edges. +

+

+ When a selection rectangle is dragged, we look for elements with the data-selection-index attribute populated. We get these elements' boundingClientRects and compare them with the root's rect to determine selection state. We update the selection state appropriately. +

+

+ In virtualization cases where items that were once selected are dematerialized, we will keep the item in its + previous state until we know definitively if it's on/off. (In other words, this works with List.) +

+

Examples

+ + + +
+ ); + } + +} \ No newline at end of file diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss new file mode 100644 index 0000000000000..ee4d8288abb59 --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.scss @@ -0,0 +1,34 @@ +@import '../../../../common/common'; + +.ms-MarqueeSelectionBasicExample-photoList { + display: inline-block; + border: 1px solid $ms-color-neutralTertiary; + margin: 0; + padding: 10px; + overflow: hidden; +} + +.ms-MarqueeSelectionBasicExample-photoCell { + position: relative; + display: inline-block; + margin: 2px; + box-sizing: border-box; + background: $ms-color-neutralLighter; + line-height: 100px; + vertical-align: middle; + text-align: center; + + &.is-selected { + background: $ms-color-themeLighter; + + &:after { + content: ''; + position: absolute; + right: 0px; + left: 0px; + top: 0px; + bottom: 0px; + border: 1px solid $ms-color-themePrimary; + } + } +} \ No newline at end of file diff --git a/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx new file mode 100644 index 0000000000000..6d3cd6cdae79e --- /dev/null +++ b/src/demo/pages/MarqueeSelectionPage/examples/MarqueeSelection.Basic.Example.tsx @@ -0,0 +1,79 @@ +/* tslint:disable:no-unused-variable */ +import * as React from 'react'; +/* tslint:enable:no-unused-variable */ + +import { + Toggle, + Selection, + MarqueeSelection, + css +} from '../../../../index'; +import { createArray } from '../../../../utilities/array'; +import './MarqueeSelection.Basic.Example.scss'; + +const PHOTOS = createArray(250, () => { + const randomWidth = 50 + Math.floor(Math.random() * 150); + + return { + url: `http://placekitten.com/${randomWidth}/100`, + width: randomWidth, + height: 100 + }; +}); + +export interface IMarqueeSelectionBasicExampleState { + isMarqueeEnabled: boolean; +} + +export class MarqueeSelectionBasicExample extends React.Component<{}, IMarqueeSelectionBasicExampleState> { + private _selection: Selection; + private _isMounted: boolean; + + constructor() { + super(); + + this.state = { + isMarqueeEnabled: true + }; + + this._selection = new Selection(() => { + if (this._isMounted) { + this.forceUpdate(); + } + }); + + this._selection.setItems(PHOTOS); + } + + public componentDidMount() { + this._isMounted = true; + } + + public render() { + return ( + + this.setState({ isMarqueeEnabled }) } /> +

Drag a rectangle around the items below to select them:

+
    + { PHOTOS.map((photo, index) => ( +
    console.log('clicked') } + style={ { width: photo.width, height: photo.height } }> + { index } +
    + )) } +
+
+ ); + } + +} diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index 1f995cf48cce7..fe7bf9f61a008 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx +++ b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx @@ -7,7 +7,7 @@ import { Selection, SelectionMode, SelectionZone - } from '../../../../utilities/selection/index'; +} from '../../../../utilities/selection/index'; import { createListItems } from '../../../utilities/data'; import './Selection.Example.scss'; @@ -61,15 +61,15 @@ export class SelectionBasicExample extends React.Component { items.map((item, index) => ( - - )) } + + )) }
); @@ -147,14 +147,14 @@ export class SelectionItemExample extends React.Component - { (selectionMode !== SelectionMode.none) && ( - - ) } - - { item.name } - + { (selectionMode !== SelectionMode.none) && ( + + ) } + + { item.name } +
); } diff --git a/src/index.ts b/src/index.ts index 00b5ed7ca280c..4cf9039e496cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export * from './Layer'; export * from './Link'; export * from './List'; export * from './MessageBar'; +export * from './MarqueeSelection'; export * from './Nav'; export * from './OrgChart'; export * from './Overlay'; diff --git a/src/utilities/AutoScroll/AutoScroll.ts b/src/utilities/AutoScroll/AutoScroll.ts new file mode 100644 index 0000000000000..2089b07f31269 --- /dev/null +++ b/src/utilities/AutoScroll/AutoScroll.ts @@ -0,0 +1,79 @@ +import { EventGroup } from '../eventGroup/EventGroup'; +import { findScrollableParent } from '../scrollUtilities'; + +const SCROLL_ITERATION_DELAY = 16; +const SCROLL_GUTTER_HEIGHT = 100; +const MAX_SCROLL_VELOCITY = 15; + +/** + * AutoScroll simply hooks up mouse events given a parent element, and scrolls the container + * up/down depending on how close the mouse is to the top/bottom of the container. + * + * Once you don't want autoscroll any more, just dispose the helper and it will unhook events. + */ +export class AutoScroll { + private _events: EventGroup; + private _scrollableParent: HTMLElement; + private _scrollRect: ClientRect; + private _scrollVelocity: number; + private _timeoutId: number; + + constructor(element: HTMLElement) { + this._events = new EventGroup(this); + this._scrollableParent = findScrollableParent(element); + this._incrementScroll = this._incrementScroll.bind(this); + this._scrollRect = this._scrollableParent.getBoundingClientRect(); + + if (this._scrollableParent) { + this._events.on(window, 'mousemove', this._onMouseMove, true); + } + } + + public dispose() { + this._events.dispose(); + this._stopScroll(); + } + + private _onMouseMove(ev: MouseEvent) { + let scrollRectTop = this._scrollRect.top; + let scrollClientBottom = scrollRectTop + this._scrollRect.height - SCROLL_GUTTER_HEIGHT; + + if (ev.clientY < (scrollRectTop + SCROLL_GUTTER_HEIGHT)) { + this._scrollVelocity = Math.max( + -MAX_SCROLL_VELOCITY, + -MAX_SCROLL_VELOCITY * ((SCROLL_GUTTER_HEIGHT - (ev.clientY - scrollRectTop)) / SCROLL_GUTTER_HEIGHT + )); + } else if (ev.clientY > scrollClientBottom) { + this._scrollVelocity = Math.min( + MAX_SCROLL_VELOCITY, + MAX_SCROLL_VELOCITY * ((ev.clientY - scrollClientBottom) / SCROLL_GUTTER_HEIGHT + )); + } else { + this._scrollVelocity = 0; + } + + if (this._scrollVelocity) { + this._startScroll(); + } else { + this._stopScroll(); + } + } + + private _startScroll() { + if (!this._timeoutId) { + this._incrementScroll(); + } + } + + private _incrementScroll() { + this._scrollableParent.scrollTop += Math.round(this._scrollVelocity); + this._timeoutId = setTimeout(this._incrementScroll, SCROLL_ITERATION_DELAY); + } + + private _stopScroll() { + if (this._timeoutId) { + clearTimeout(this._timeoutId); + delete this._timeoutId; + } + } +} \ No newline at end of file diff --git a/src/utilities/eventGroup/EventGroup.ts b/src/utilities/eventGroup/EventGroup.ts index 38ec3aaf6b901..5de8ed1e44c38 100644 --- a/src/utilities/eventGroup/EventGroup.ts +++ b/src/utilities/eventGroup/EventGroup.ts @@ -179,10 +179,17 @@ export class EventGroup { let result; try { result = callback.apply(parent, args); - if (result === false && args[0] && args[0].preventDefault) { + if (result === false && args[0]) { let e = args[0]; - e.preventDefault(); + if (e.preventDefault) { + e.preventDefault(); + } + + if (e.stopPropagation) { + e.stopPropagation(); + } + e.cancelBubble = true; } } catch (e) { diff --git a/src/utilities/math.ts b/src/utilities/math.ts new file mode 100644 index 0000000000000..b049c0585e286 --- /dev/null +++ b/src/utilities/math.ts @@ -0,0 +1,7 @@ +import { IPoint } from '../common/IPoint'; + +export function getDistanceBetweenPoints(point1: IPoint, point2: IPoint): number { + let distance = Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2)); + + return distance; +} \ No newline at end of file diff --git a/src/utilities/selection/Selection.ts b/src/utilities/selection/Selection.ts index 8db76a7780756..24a0836c37438 100644 --- a/src/utilities/selection/Selection.ts +++ b/src/utilities/selection/Selection.ts @@ -7,6 +7,7 @@ export class Selection implements ISelection { private _changeEventSuppressionCount: number; private _items: IObjectWithKey[]; + private _selectedItems: IObjectWithKey[]; private _isAllSelected: boolean; private _exemptedIndices: { [index: string]: boolean }; private _exemptedCount: number; @@ -95,21 +96,17 @@ export class Selection implements ISelection { } public getSelection(): IObjectWithKey[] { - let selectedItems = []; + if (!this._selectedItems) { + this._selectedItems = []; - for (let i = 0; i < this._items.length; i++) { - let item = this._items[i]; - let isExempt = !!this._exemptedIndices[i]; - let key = item ? this.getKey(item, i) : null; - - if ((!key && this._isAllSelected) || - (key && this._isAllSelected && !isExempt) || - (key && !this._isAllSelected && isExempt)) { - selectedItems.push(item); + for (let i = 0; i < this._items.length; i++) { + if (this.isIndexSelected(i)) { + this._selectedItems.push(this._items[i]); + } } } - return selectedItems; + return this._selectedItems; } public getSelectedCount(): number { @@ -224,6 +221,7 @@ export class Selection implements ISelection { private _change() { if (this._changeEventSuppressionCount === 0) { + this._selectedItems = null; EventGroup.raise(this, SELECTION_CHANGE); diff --git a/src/utilities/selection/SelectionZone.tsx b/src/utilities/selection/SelectionZone.tsx index 85743ef9a403b..8a1da3e172d3d 100644 --- a/src/utilities/selection/SelectionZone.tsx +++ b/src/utilities/selection/SelectionZone.tsx @@ -254,7 +254,9 @@ export class SelectionZone extends React.Component { index = Number(indexString); break; } - element = element.parentElement; + if (element !== this.refs.root) { + element = element.parentElement; + } } while (traverseParents && element !== this.refs.root); return index; diff --git a/src/utilities/selection/index.ts b/src/utilities/selection/index.ts index 38495fe20d46e..c3ca5de5938f9 100644 --- a/src/utilities/selection/index.ts +++ b/src/utilities/selection/index.ts @@ -1,4 +1,4 @@ export * from './interfaces'; export * from './Selection'; export * from './SelectionLayout'; -export * from './SelectionZone'; \ No newline at end of file +export * from './SelectionZone';