diff --git a/common/changes/office-ui-fabric-react/edwl-fixScrollablePaneBehavior_2018-05-04-00-03.json b/common/changes/office-ui-fabric-react/edwl-fixScrollablePaneBehavior_2018-05-04-00-03.json new file mode 100644 index 00000000000000..90b27657cf1bc3 --- /dev/null +++ b/common/changes/office-ui-fabric-react/edwl-fixScrollablePaneBehavior_2018-05-04-00-03.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "ScrollablePane: Optimizations on how component functions. Change positioning from inheriting height/maxHeight of parent element to use position: absolute", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "edwl@microsoft.com" +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.base.tsx b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.base.tsx index 2c1fcbcc60e9f8..3289ebc2a7a77e 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.base.tsx +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.base.tsx @@ -20,10 +20,15 @@ export interface IScrollablePaneContext { scrollablePane: PropTypes.Requireable; } +export interface IScrollablePaneState { + stickyTopHeight: number; + stickyBottomHeight: number; +} + const getClassNames = classNamesFunction(); @customizable('ScrollablePane', ['theme']) -export class ScrollablePaneBase extends BaseComponent implements IScrollablePane { +export class ScrollablePaneBase extends BaseComponent implements IScrollablePane { public static childContextTypes: React.ValidationMap = { scrollablePane: PropTypes.object }; @@ -31,15 +36,20 @@ export class ScrollablePaneBase extends BaseComponent private _root = createRef(); private _stickyAboveRef = createRef(); private _stickyBelowRef = createRef(); + private _contentContainer = createRef(); private _subscribers: Set; - private _stickyAbove: Set; - private _stickyBelow: Set; + private _stickies: Set; + private _mutationObserver: MutationObserver; constructor(props: IScrollablePaneProps) { super(props); this._subscribers = new Set(); - this._stickyAbove = new Set(); - this._stickyBelow = new Set(); + this._stickies = new Set(); + + this.state = { + stickyTopHeight: 0, + stickyBottomHeight: 0 + }; } public get root(): HTMLDivElement | null { @@ -54,39 +64,108 @@ export class ScrollablePaneBase extends BaseComponent return this._stickyBelowRef.current; } + public get contentContainer(): HTMLDivElement | null { + return this._contentContainer.current; + } + public getChildContext() { return { scrollablePane: { subscribe: this.subscribe, unsubscribe: this.unsubscribe, - addStickyHeader: this.addStickyHeader, - removeStickyHeader: this.removeStickyHeader, - addStickyFooter: this.addStickyFooter, - removeStickyFooter: this.removeStickyFooter, + addSticky: this.addSticky, + removeSticky: this.removeSticky, + updateStickyRefHeights: this.updateStickyRefHeights, + sortSticky: this.sortSticky, notifySubscribers: this.notifySubscribers } }; } - public componentDidMount(): void { - this._events.on(this._root.current, 'scroll', this.notifySubscribers); + public componentDidMount() { + const { initialScrollPosition } = this.props; + this._events.on(this.contentContainer, 'scroll', this._async.throttle(this.notifySubscribers, 50)); this._events.on(window, 'resize', this._onWindowResize); + if (this.contentContainer && initialScrollPosition) { + this.contentContainer.scrollTop = initialScrollPosition; + } + + // Set sticky distances from top property, then sort in correct order and notify subscribers + this.setStickiesDistanceFromTop(); + this._stickies.forEach((sticky) => { + this.sortSticky(sticky); + }); + this.notifySubscribers(); + + if ('MutationObserver' in window) { + this._mutationObserver = new MutationObserver(mutation => { + // Function to check if mutation is occuring in stickyAbove or stickyBelow + function checkIfMutationIsSticky(mutationRecord: MutationRecord): boolean { + if (this.stickyAbove !== null && this.stickyBelow !== null) { + return this.stickyAbove.contains(mutationRecord.target) || this.stickyBelow.contains(mutationRecord.target); + } + return false; + } + + // If mutation occurs in sticky header or footer, then update sticky top/bottom heights + if (mutation.some(checkIfMutationIsSticky.bind(this))) { + this.updateStickyRefHeights(); + } else { + // Else if mutation occurs in scrollable region, then find sticky it belongs to and force update + const stickyList = Array.from(this._stickies).filter((sticky) => { + if (this.root) { + return this.root.contains(mutation[0].target); + } + }); + if (stickyList.length) { + stickyList.forEach((sticky) => { + sticky.forceUpdate(); + }); + } + } + }); + + if (this.root) { + this._mutationObserver.observe(this.root, { + childList: true, + attributes: true, + subtree: true, + characterData: true + }); + } + } } public componentWillUnmount() { - this._events.off(this._root.current); + this._events.off(this.contentContainer); this._events.off(window); + this._mutationObserver.disconnect(); + } + + // Only updates if props/state change, just to prevent excessive setState with updateStickyRefHeights + public shouldComponentUpdate(nextProps: IScrollablePaneProps, nextState: IScrollablePaneState): boolean { + return this.props.children !== nextProps.children || + this.props.initialScrollPosition !== nextProps.initialScrollPosition || + this.props.className !== nextProps.className || + this.state.stickyTopHeight !== nextState.stickyTopHeight || + this.state.stickyBottomHeight !== nextState.stickyBottomHeight; } - public componentDidUpdate(prevProps: IScrollablePaneProps) { + public componentDidUpdate(prevProps: IScrollablePaneProps, prevState: IScrollablePaneState) { const initialScrollPosition = this.props.initialScrollPosition; - if (this._root.current && initialScrollPosition && prevProps.initialScrollPosition !== initialScrollPosition) { - this._root.current.scrollTop = initialScrollPosition; + if (this.contentContainer && initialScrollPosition && prevProps.initialScrollPosition !== initialScrollPosition) { + this.contentContainer.scrollTop = initialScrollPosition; + } + + // Update subscribers when stickyTopHeight/stickyBottomHeight changes + if (prevState.stickyTopHeight !== this.state.stickyTopHeight || prevState.stickyBottomHeight !== this.state.stickyBottomHeight) { + this.notifySubscribers(); } } public render(): JSX.Element { const { className, theme, getStyles } = this.props; + const { stickyTopHeight, stickyBottomHeight } = this.state; const classNames = getClassNames(getStyles!, { theme: theme!, @@ -99,152 +178,207 @@ export class ScrollablePaneBase extends BaseComponent { ...getNativeProps(this.props, divProperties) } ref={ this._root } className={ classNames.root } - data-is-scrollable={ true } > -
-
- { this.props.children } +
+ { this.props.children } +
+
+
+
+
); } + public setStickiesDistanceFromTop(): void { + if (this.contentContainer) { + this._stickies.forEach((sticky) => { + sticky.setDistanceFromTop(this.contentContainer as HTMLDivElement); + }); + } + } + public forceLayoutUpdate() { this._onWindowResize(); } - public subscribe = (handler: (headerBound: ClientRect, footerBound: ClientRect) => void): void => { + public subscribe = (handler: Function): void => { this._subscribers.add(handler); } - public unsubscribe = (handler: (headerBound: ClientRect, footerBound: ClientRect) => void): void => { + public unsubscribe = (handler: Function): void => { this._subscribers.delete(handler); } - public addStickyHeader = (sticky: Sticky): void => { - this._addSticky(sticky, this._stickyAbove, () => { - if (this._stickyAboveRef.current) { - this._stickyAboveRef.current.appendChild(sticky.content); - } - }); - } + public addSticky = (sticky: Sticky): void => { + this._stickies.add(sticky); - public addStickyFooter = (sticky: Sticky): void => { - this._addSticky(sticky, this._stickyBelow, () => { - if (this._stickyBelowRef.current) { - this._stickyBelowRef.current.insertBefore(sticky.content, this._stickyBelowRef.current.firstChild); - } - }); + // If ScrollablePane is mounted, then sort sticky in correct place + if (this.contentContainer) { + sticky.setDistanceFromTop(this.contentContainer); + this.sortSticky(sticky); + } + this.notifySubscribers(); } - public removeStickyHeader = (sticky: Sticky): void => { - this._removeSticky(sticky, this._stickyAbove, this._stickyAboveRef.current); + public removeSticky = (sticky: Sticky): void => { + this._stickies.delete(sticky); + this._removeStickyFromContainers(sticky); + this.notifySubscribers(); } - public removeStickyFooter = (sticky: Sticky): void => { - this._removeSticky(sticky, this._stickyBelow, this._stickyBelowRef.current); + public sortSticky = (sticky: Sticky): void => { + if (this.stickyAbove && this.stickyBelow) { + if (sticky.canStickyTop && sticky.stickyContentTop) { + this._addToStickyContainer(sticky, this.stickyAbove, sticky.stickyContentTop); + } + + if (sticky.canStickyBottom && sticky.stickyContentBottom) { + this._addToStickyContainer(sticky, this.stickyBelow, sticky.stickyContentBottom); + } + } } - public notifySubscribers = (sort?: boolean): void => { - this._subscribers.forEach((handle) => { - if (this._stickyAboveRef.current && this._stickyBelowRef.current) { - handle(this._stickyAboveRef.current.getBoundingClientRect(), this._stickyBelowRef.current.getBoundingClientRect()); + public updateStickyRefHeights = (): void => { + const stickyItems = this._stickies; + + let stickyTopHeight = 0; + let stickyBottomHeight = 0; + + stickyItems.forEach((sticky: Sticky) => { + const { isStickyTop, isStickyBottom } = sticky.state; + if (sticky.nonStickyContent) { + if (isStickyTop) { + stickyTopHeight += sticky.nonStickyContent.offsetHeight; + } + if (isStickyBottom) { + stickyBottomHeight += sticky.nonStickyContent.offsetHeight; + } + this._checkStickyStatus(sticky); } }); - if (this._stickyAbove.size > 1) { - this._sortStickies(this._stickyAbove, this._stickyAboveRef.current); - } - if (this._stickyBelow.size > 1) { - this._sortStickies(this._stickyBelow, this._stickyBelowRef.current); + + this.setState({ + stickyTopHeight: stickyTopHeight, + stickyBottomHeight: stickyBottomHeight + }); + } + + public notifySubscribers = (): void => { + if (this.contentContainer) { + this._subscribers.forEach((handle) => { + // this.stickyBelow is passed in for calculating distance to determine Sticky status + handle(this.contentContainer, this.stickyBelow); + }); } } public getScrollPosition = (): number => { - if (this._root.current) { - return this._root.current.scrollTop; + if (this.contentContainer) { + return this.contentContainer.scrollTop; } return 0; } - private _addSticky(sticky: Sticky, stickyList: Set, addStickyToContainer: () => void): void { - if (!stickyList.has(sticky)) { - stickyList.add(sticky); - addStickyToContainer(); - sticky.content.addEventListener('transitionend', - this._setPlaceholderHeights.bind(null, stickyList), - false); - if (sticky.props.stickyClassName) { - this._async.setTimeout(() => { - if (sticky.props.stickyClassName) { - sticky.content.children[0].classList.add(sticky.props.stickyClassName); - } - }, 1); - } - this._setPlaceholderHeights(stickyList); - } - } + private _checkStickyStatus(sticky: Sticky): void { + if (this.stickyAbove && this.stickyBelow && this.contentContainer && sticky.nonStickyContent) { + // If sticky is sticky, then append content to appropriate container + if (sticky.state.isStickyTop || sticky.state.isStickyBottom) { + if (sticky.state.isStickyTop && !this.stickyAbove.contains(sticky.nonStickyContent) && sticky.stickyContentTop) { + sticky.addSticky(sticky.stickyContentTop); + } - private _removeSticky(sticky: Sticky, stickyList: Set, container: HTMLElement | null): void { - if (container && stickyList.has(sticky)) { - sticky.content.removeEventListener('transitionend', - this._setPlaceholderHeights.bind(null, stickyList, container)); - stickyList.delete(sticky); + if (sticky.state.isStickyBottom && !this.stickyBelow.contains(sticky.nonStickyContent) && sticky.stickyContentBottom) { + sticky.addSticky(sticky.stickyContentBottom); + } + } else if (!this.contentContainer.contains(sticky.nonStickyContent)) { + // Reset sticky if it's not sticky and not in the contentContainer element + sticky.resetSticky(); + } } } - private _onWindowResize(): void { - this._async.setTimeout(() => { - this.notifySubscribers(); - this._setPlaceholderHeights(this._stickyAbove); - this._setPlaceholderHeights(this._stickyBelow); - }, 5); - } + private _addToStickyContainer = (sticky: Sticky, stickyContainer: HTMLDivElement, stickyContentToAdd: HTMLDivElement): void => { + // If there's no children, append child to list, otherwise, sort though array and append at correct position + if (!stickyContainer.children.length) { + stickyContainer.appendChild(stickyContentToAdd); + } else { + // If stickyContentToAdd isn't a child element of target container, then append + if (!stickyContainer.contains(stickyContentToAdd)) { + const stickyChildrenElements = Array.from(stickyContainer.children); - private _setPlaceholderHeights = (stickies: Set): void => { - stickies.forEach((sticky, idx) => { - sticky.setPlaceholderHeight(sticky.content.clientHeight); - }); - } + // Get stickies. Filter by canStickyTop/Bottom, then sort by distance from top, and then + // filter by elements that are in the stickyContainer already. + const stickyListSorted = Array.from(this._stickies).filter((item) => { + if (stickyContainer === this.stickyAbove) { + return item.canStickyTop; + } else { + return item.canStickyBottom; + } + }).sort((a, b) => { + return a.distanceFromTop - b.distanceFromTop; + }).filter((item) => { + const stickyContent = (stickyContainer === this.stickyAbove) ? item.stickyContentTop : item.stickyContentBottom; + if (stickyContent) { + return stickyChildrenElements.indexOf(stickyContent) > -1; + } + }); - private _sortStickies(stickyList: Set, container: HTMLElement | null): void { - // No sorting needed if there is no container - if (!container) { - return; - } + // Get first element that has a distance from top that is further than our sticky that is being added + let targetStickyToAppendBefore: Sticky | undefined = undefined; + for (const i in stickyListSorted) { + if (stickyListSorted[i].distanceFromTop >= sticky.distanceFromTop) { + targetStickyToAppendBefore = stickyListSorted[i]; + break; + } + } - let stickyArr = Array.from(stickyList); - stickyArr = stickyArr.sort((a, b) => { - const aOffset = this._calculateOffsetParent(a.root.current); - const bOffset = this._calculateOffsetParent(b.root.current); - return aOffset - bOffset; - }); - // Get number of elements that is already in order. - let elementsInOrder = 0; - while (elementsInOrder < container.children.length && elementsInOrder < stickyArr.length) { - if (container.children[elementsInOrder] === stickyArr[elementsInOrder].content) { - ++elementsInOrder; - } else { - break; + // If target element to append before is known, then grab respective stickyContentTop/Bottom element and insert before + let targetContainer: HTMLDivElement | null = null; + if (targetStickyToAppendBefore) { + targetContainer = stickyContainer === this.stickyAbove ? + targetStickyToAppendBefore.stickyContentTop : + targetStickyToAppendBefore.stickyContentBottom; + } + stickyContainer.insertBefore(stickyContentToAdd, targetContainer); } } - // Remove elements that is not in order if exist. - for (let i = container.children.length - 1; i >= elementsInOrder; --i) { - container.removeChild(container.children[i]); + } + + private _removeStickyFromContainers = (sticky: Sticky): void => { + if (this.stickyAbove && sticky.stickyContentTop) { + this.stickyAbove.removeChild(sticky.stickyContentTop); } - // Append further elements if needed. - for (let i = elementsInOrder; i < stickyArr.length; ++i) { - container.appendChild(stickyArr[i].content); + if (this.stickyBelow && sticky.stickyContentBottom) { + this.stickyBelow.removeChild(sticky.stickyContentBottom); } } - private _calculateOffsetParent(ele: HTMLElement | null): number { - let offset = 0; - while (ele && this._root.current && ele.offsetParent !== this._root.current.offsetParent) { - offset += ele.offsetTop; - if (ele.parentElement) { - ele = ele.parentElement; - } - } - return offset; + private _onWindowResize = (): void => { + this._async.setTimeout(() => { + this.notifySubscribers(); + }, 5); } -} + + private _getStickyContainerStyle = (height: number): React.CSSProperties => { + return { + height: height, + width: this.contentContainer ? this.contentContainer.clientWidth : '100%' + }; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.styles.ts b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.styles.ts index fa00b9508a9981..b4145a17d7ad3b 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.styles.ts +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.styles.ts @@ -8,6 +8,7 @@ import { const GlobalClassNames = { root: 'ms-ScrollablePane', + contentContainer: 'ms-ScrollablePane--contentContainer' }; export const getStyles = ( @@ -24,19 +25,33 @@ export const getStyles = ( position: 'absolute', pointerEvents: 'auto', width: '100%', - zIndex: ZIndexes.ScrollablePane + zIndex: ZIndexes.ScrollablePane, + overflowY: 'hidden', + overflowX: 'auto' + }; + + const positioningStyle: IStyle = { + zIndex: ZIndexes.ScrollablePane, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + WebkitOverflowScrolling: 'touch' }; return ({ root: [ classNames.root, + positioningStyle, + className + ], + contentContainer: [ + classNames.contentContainer, { - overflowY: 'auto', - maxHeight: 'inherit', - height: 'inherit', - WebkitOverflowScrolling: 'touch' + overflowY: 'auto' }, - className + positioningStyle ], stickyAbove: [ { @@ -59,6 +74,12 @@ export const getStyles = ( } }, AboveAndBelowStyles + ], + stickyBelowItems: [ + { + bottom: 0 + }, + AboveAndBelowStyles ] }); }; diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.types.ts b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.types.ts index 41d1eb7a20d76f..46cc05f00b502e 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.types.ts +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/ScrollablePane.types.ts @@ -68,4 +68,12 @@ export interface IScrollablePaneStyles { * Style set for the stickyAbove element. */ stickyBelow: IStyle; + /** + * Style set for the stickyBelowItems element. + */ + stickyBelowItems: IStyle; + /** + * Style set for the contentContainer element. + */ + contentContainer: IStyle; } diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDonts.md b/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDonts.md index 84f8a79e0cc17a..fa4363d3517c12 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDonts.md +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDonts.md @@ -1 +1 @@ -- Don't use Sticky on elements with margin top or bottom \ No newline at end of file +- Don't use Sticky on elements with `margin-top` or `margin-bottom` \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDos.md b/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDos.md index 4c729aeea7ccad..6ed1153a059689 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDos.md +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/docs/ScrollablePaneDos.md @@ -1,3 +1,4 @@ -- Ensure that a parent component has a CSS height, or max-height attribute set (and any intermediary containers have an inherit, or explicit height/max-height set). +- ScrollablePane uses `position: absolute`. Ensure that the parent element has an explicit `height` and `position: relative`, or has space already allocated for ScrollablePane (e.g: flexbox). - Use Sticky component on block level elements -- Sticky component are ideally section headers and/or footers \ No newline at end of file +- Sticky component are ideally section headers and/or footers +- Ensure that the total height of Sticky components do not exceed the height of the ScrollablePane \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Default.Example.tsx b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Default.Example.tsx index 0705d5ebb49905..9c00b173450d0c 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Default.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Default.Example.tsx @@ -4,41 +4,54 @@ import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { lorem } from '@uifabric/example-app-base'; import './ScrollablePane.Example.scss'; -export class ScrollablePaneDefaultExample extends React.Component { +const colors = [ + '#eaeaea', + '#dadada', + '#d0d0d0', + '#c8c8c8', + '#a6a6a6', + '#c7e0f4', + '#71afe5', + '#eff6fc', + '#deecf9' +]; - public render(): JSX.Element { +export class ScrollablePaneDefaultExample extends React.Component { + public render() { const contentAreas: JSX.Element[] = []; - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { contentAreas.push(this._createContentArea(i)); } return ( - - { contentAreas.map((ele) => { - return ele; - }) } - +
+ + { contentAreas.map((ele) => { + return ele; + }) } + +
); } - private _getRandomColor(): string { - const letters = 'BCDEF'.split(''); - let color = '#'; - for (let i = 0; i < 6; i++) { - color += letters[Math.floor(Math.random() * letters.length)]; - } - return color; - } - - private _createContentArea(index: number): JSX.Element { - const style = this._getRandomColor(); + private _createContentArea(index: number) { + const color = colors.splice(Math.floor(Math.random() * colors.length), 1)[0]; return ( -
+
Sticky Component #{ index + 1 } diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx index e2bf1c61a542fe..a6b7f86e54bddd 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx @@ -18,7 +18,8 @@ import { ScrollablePane } from 'office-ui-fabric-react/lib/ScrollablePane'; import { - Sticky + Sticky, + StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { MarqueeSelection } from 'office-ui-fabric-react/lib/MarqueeSelection'; @@ -83,41 +84,49 @@ export class ScrollablePaneDetailsListExample extends React.Component<{}, { const { items, selectionDetails } = this.state; return ( - - { selectionDetails } - this.setState({ items: text ? _items.filter(i => i.name.toLowerCase().indexOf(text) > -1) : _items }) } - /> - -

Item List

-
- - ) => ( - - { defaultRender({ - ...detailsHeaderProps, - onRenderColumnHeaderTooltip: (tooltipHostProps: ITooltipHostProps) => - }) } - - ) } - selection={ this._selection } - selectionPreservedOnEmptyClick={ true } - ariaLabelForSelectionColumn='Toggle selection' - ariaLabelForSelectAllCheckbox='Toggle selection for all items' +
+ + { selectionDetails } + alert(`Item invoked: ${item.name}`) } + onChanged={ text => this.setState({ items: text ? _items.filter(i => i.name.toLowerCase().indexOf(text) > -1) : _items }) } /> - - + +

Item List

+
+ + ) => ( + + { defaultRender({ + ...detailsHeaderProps, + onRenderColumnHeaderTooltip: (tooltipHostProps: ITooltipHostProps) => + }) } + + ) } + selection={ this._selection } + selectionPreservedOnEmptyClick={ true } + ariaLabelForSelectionColumn='Toggle selection' + ariaLabelForSelectAllCheckbox='Toggle selection for all items' + // tslint:disable-next-line:jsx-no-lambda + onItemInvoked={ (item) => alert(`Item invoked: ${item.name}`) } + /> + + +
); } diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Example.scss b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Example.scss index 5a9603237c42a4..f4f831c52db394 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Example.scss +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.Example.scss @@ -3,31 +3,15 @@ :global { .scrollablePaneDefaultExample { max-width: 400px; - max-height: inherit; border: 1px solid $ms-color-neutralLight; } .sticky { @include ms-fontColor-neutralDark; - padding: 10px 20px; - font-size: 16px; - -webkit-transition: font-size ease 0.3s; - -moz-transition: font-size ease 0.3s; - -o-transition: font-size ease 0.3s; - -ms-transition: font-size ease 0.3s; - -webkit-transform: translateZ(0); - -moz-transform: translateZ(0); - -ms-transform: translateZ(0); - -o-transform: translateZ(0); - transform: translateZ(0); - will-change: font-size; - box-shadow: 0 0 5px -1px $ms-color-neutralDark; - } - - .largeFont { - .sticky { - font-size: 20px; - } + padding: 5px 20px 5px 10px; + font-size: 13px; + border-top: 1px solid $ms-color-black; + border-bottom: 1px solid $ms-color-black; } .textContent { diff --git a/packages/office-ui-fabric-react/src/components/Sticky/Sticky.tsx b/packages/office-ui-fabric-react/src/components/Sticky/Sticky.tsx index 325fb0904e4c9d..2cb40724df775c 100644 --- a/packages/office-ui-fabric-react/src/components/Sticky/Sticky.tsx +++ b/packages/office-ui-fabric-react/src/components/Sticky/Sticky.tsx @@ -1,8 +1,4 @@ -/* tslint:disable:no-unused-variable */ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -/* tslint:enable:no-unused-variable */ - import * as PropTypes from 'prop-types'; import { BaseComponent, createRef } from '../../Utilities'; import { IStickyProps, StickyPositionType } from './Sticky.types'; @@ -10,7 +6,6 @@ import { IStickyProps, StickyPositionType } from './Sticky.types'; export interface IStickyState { isStickyTop: boolean; isStickyBottom: boolean; - placeholderHeight?: number; } export interface IStickyContext { @@ -30,16 +25,19 @@ export class Sticky extends BaseComponent { scrollablePane: { subscribe: (handler: Function) => void; unsubscribe: (handler: Function) => void; - addStickyHeader: (sticky: Sticky) => void; - removeStickyHeader: (sticky: Sticky) => void; - addStickyFooter: (sticky: Sticky) => void; - removeStickyFooter: (sticky: Sticky) => void; + addSticky: (sticky: Sticky) => void; + removeSticky: (sticky: Sticky) => void; + updateStickyRefHeights: () => void; + sortSticky: (sticky: Sticky) => void; notifySubscribers: (sort?: boolean) => void; } }; - public content: HTMLElement; - public root = createRef(); + public distanceFromTop: number; + private _root = createRef(); + private _stickyContentTop = createRef(); + private _stickyContentBottom = createRef(); + private _nonStickyContent = createRef(); constructor(props: IStickyProps) { super(props); @@ -47,6 +45,31 @@ export class Sticky extends BaseComponent { isStickyTop: false, isStickyBottom: false }; + this.distanceFromTop = 0; + } + + public get root(): HTMLDivElement | null { + return this._root.current; + } + + public get stickyContentTop(): HTMLDivElement | null { + return this._stickyContentTop.current; + } + + public get stickyContentBottom(): HTMLDivElement | null { + return this._stickyContentBottom.current; + } + + public get nonStickyContent(): HTMLDivElement | null { + return this._nonStickyContent.current; + } + + public get canStickyTop(): boolean { + return this.props.stickyPosition === StickyPositionType.Both || this.props.stickyPosition === StickyPositionType.Header; + } + + public get canStickyBottom(): boolean { + return this.props.stickyPosition === StickyPositionType.Both || this.props.stickyPosition === StickyPositionType.Footer; } public componentDidMount(): void { @@ -55,135 +78,183 @@ export class Sticky extends BaseComponent { } const { scrollablePane } = this.context; scrollablePane.subscribe(this._onScrollEvent); - this.content = document.createElement('div'); - this.content.style.background = this.props.stickyBackgroundColor || this._getBackground(); - ReactDOM.render(
{ this.props.children }
, this.content); - if (this.root.current) { - this.root.current.appendChild(this.content); - } - this.context.scrollablePane.notifySubscribers(true); + scrollablePane.addSticky(this); } public componentWillUnmount(): void { - const { isStickyTop, isStickyBottom } = this.state; const { scrollablePane } = this.context; - if (isStickyTop) { - this._resetSticky(() => { - scrollablePane.removeStickyHeader(this); - }); - } - if (isStickyBottom) { - this._resetSticky(() => { - scrollablePane.removeStickyFooter(this); - }); - } scrollablePane.unsubscribe(this._onScrollEvent); + scrollablePane.removeSticky(this); } public componentDidUpdate(prevProps: IStickyProps, prevState: IStickyState): void { - const { isStickyTop, isStickyBottom } = this.state; const { scrollablePane } = this.context; - - if (this.props.children !== prevProps.children) { - ReactDOM.render(
{ this.props.children }
, this.content); - } - - if (isStickyTop && !prevState.isStickyTop) { - this._setSticky(() => { - scrollablePane.addStickyHeader(this); - }); - } else if (!isStickyTop && prevState.isStickyTop) { - this._resetSticky(() => { - scrollablePane.removeStickyHeader(this); - }); - } - - if (isStickyBottom && !prevState.isStickyBottom) { - this._setSticky(() => { - scrollablePane.addStickyFooter(this); - }); - } else if (!isStickyBottom && prevState.isStickyBottom) { - this._resetSticky(() => { - scrollablePane.removeStickyFooter(this); - }); + if (prevState.isStickyTop !== this.state.isStickyTop || prevState.isStickyBottom !== this.state.isStickyBottom) { + scrollablePane.updateStickyRefHeights(); } } public shouldComponentUpdate(nextProps: IStickyProps, nextState: IStickyState): boolean { - const { isStickyTop, isStickyBottom, placeholderHeight } = this.state; + const { isStickyTop, isStickyBottom } = this.state; return isStickyTop !== nextState.isStickyTop || isStickyBottom !== nextState.isStickyBottom || - placeholderHeight !== nextState.placeholderHeight || + this.props.stickyPosition !== nextProps.stickyPosition || this.props.children !== nextProps.children; } - public setPlaceholderHeight(height: number): void { - this.setState({ - placeholderHeight: height - }); - } - public render(): JSX.Element { - const { isStickyTop, isStickyBottom, placeholderHeight } = this.state; - const isSticky = isStickyTop || isStickyBottom; + const { isStickyTop, isStickyBottom } = this.state; return ( -
-
+
+ { + this.canStickyTop && +
+
+
+ } + { + this.canStickyBottom && +
+
+
+ } +
+
+ { this.props.children } +
); } - private _onScrollEvent = (headerBound: ClientRect, footerBound: ClientRect): void => { - if (!this.root.current) { - return; + public addSticky(stickyContent: HTMLDivElement): void { + if (this.nonStickyContent) { + stickyContent.appendChild(this.nonStickyContent); } + } - const { top, bottom } = this.root.current.getBoundingClientRect(); - const { isStickyTop, isStickyBottom } = this.state; - const { stickyPosition } = this.props; - const canStickyHeader = stickyPosition === StickyPositionType.Both || stickyPosition === StickyPositionType.Header; - const canStickyFooter = stickyPosition === StickyPositionType.Both || stickyPosition === StickyPositionType.Footer; + public resetSticky(): void { + if (this.nonStickyContent && this.root) { + this.root.appendChild(this.nonStickyContent); + } + } + + public setDistanceFromTop(container: HTMLDivElement): void { + this.distanceFromTop = this._getNonStickyDistanceFromTop(container); + } + + private _getContentStyles(isSticky: boolean): React.CSSProperties { + return { + backgroundColor: this.props.stickyBackgroundColor || this._getBackground() + }; + } - this.setState({ - isStickyTop: canStickyHeader && ((top <= headerBound.bottom) || (isStickyTop && bottom < headerBound.bottom)), - isStickyBottom: canStickyFooter && ((bottom >= footerBound.top) || (isStickyBottom && top > footerBound.top)) - }); + private _getStickyPlaceholderHeight(isSticky: boolean): React.CSSProperties { + const height = this.nonStickyContent ? this.nonStickyContent.offsetHeight : 0; + + return { + visibility: isSticky ? 'hidden' : 'visible', + height: isSticky ? 0 : height + }; } - private _setSticky(callback: () => void): void { - if (this.content.parentElement) { - this.content.parentElement.removeChild(this.content); + private _getNonStickyPlaceholderHeight(): React.CSSProperties { + const { isStickyTop, isStickyBottom, } = this.state; + if (isStickyTop || isStickyBottom) { + const height = this.nonStickyContent ? this.nonStickyContent.offsetHeight : 0; + return { + height: height + }; + } else { + return { + position: 'absolute' + }; } - callback(); } - private _resetSticky(callback: () => void): void { - if (this.root.current) { - this.root.current.appendChild(this.content); + private _onScrollEvent = (container: HTMLElement, footerStickyContainer: HTMLElement): void => { + if (this.root && this.nonStickyContent) { + this.distanceFromTop = this._getNonStickyDistanceFromTop(container); + let isStickyTop = false; + let isStickyBottom = false; + + if (this.canStickyTop) { + const distanceToStickTop = this.distanceFromTop - this._getStickyDistanceFromTop(); + isStickyTop = distanceToStickTop < container.scrollTop; + } + + // Can sticky bottom if the scrollablePane - total sticky footer height is smaller than the sticky's distance from the top of the pane + if (this.canStickyBottom && container.clientHeight - footerStickyContainer.offsetHeight <= this.distanceFromTop) { + isStickyBottom = this.distanceFromTop - container.scrollTop > this._getStickyDistanceFromTopForFooter(container, footerStickyContainer); + } + + this.setState({ + isStickyTop: this.canStickyTop && isStickyTop, + isStickyBottom: isStickyBottom + }); + } + } + + private _getStickyDistanceFromTop = (): number => { + let distance = 0; + if (this.stickyContentTop) { + distance = this.stickyContentTop.offsetTop; } - setTimeout((): void => { - if (this.props.stickyClassName) { - this.content.children[0].classList.remove(this.props.stickyClassName); + return distance; + } + + private _getStickyDistanceFromTopForFooter = (container: HTMLElement, footerStickyVisibleContainer: HTMLElement): number => { + let distance = 0; + if (this.stickyContentBottom) { + distance = container.clientHeight - footerStickyVisibleContainer.offsetHeight + this.stickyContentBottom.offsetTop; + } + + return distance; + } + + private _getNonStickyDistanceFromTop = (container: HTMLElement): number => { + let distance = 0; + let currElem = this.root; + + if (currElem) { + while (currElem.offsetParent !== container) { + distance += currElem.offsetTop; + currElem = currElem.offsetParent as HTMLDivElement; } - }, 1); - callback(); + + if (currElem.offsetParent === container) { + distance += currElem.offsetTop; + } + } + return distance; } // Gets background of nearest parent element that has a declared background-color attribute - private _getBackground(): string | null { - if (!this.root.current) { - return null; + private _getBackground(): string | undefined { + if (!this.root) { + return undefined; } - let curr: HTMLElement = this.root.current; + let curr: HTMLElement = this.root; while (window.getComputedStyle(curr).getPropertyValue('background-color') === 'rgba(0, 0, 0, 0)' || window.getComputedStyle(curr).getPropertyValue('background-color') === 'transparent') { if (curr.tagName === 'HTML') { // Fallback color if no element has a declared background-color attribute - return null; + return undefined; } if (curr.parentElement) { curr = curr.parentElement;