diff --git a/packages/flowtip-core/src/Rect.js b/packages/flowtip-core/src/Rect.js index fbb9bab..8acece8 100644 --- a/packages/flowtip-core/src/Rect.js +++ b/packages/flowtip-core/src/Rect.js @@ -27,8 +27,8 @@ class Rect { * Convert a rect-like object to a Rect instance. This is useful for * converting non-serializable ClientRect instances to more standard objects. * - * @param {object} rect A rect-like object. - * @returns {object} A Rect instance. + * @param {Object} rect A rect-like object. + * @returns {Object} A Rect instance. */ static from(rect: RectLike): Rect { if (rect instanceof Rect) return rect; @@ -44,9 +44,9 @@ class Rect { * If the rects do not intersect in either axis, the returned dimension for * that axis is negative and represents the distance between the rects. * - * @param {object} a A rect-like object. - * @param {object} b A rect-like object. - * @returns {object} A Rect instance. + * @param {Object} a A rect-like object. + * @param {Object} b A rect-like object. + * @returns {Object} A Rect instance. */ static intersect(a: RectLike, b: RectLike): Rect { const rectA = Rect.from(a); @@ -65,9 +65,9 @@ class Rect { /** * Expand (or shrink) the boundaries of a rect. * - * @param {object} rect A rect-like object. + * @param {Object} rect A rect-like object. * @param {number} amount Offset to apply to each boundary edge. - * @returns {object} A Rect instance. + * @returns {Object} A Rect instance. */ static grow(rect: RectLike, amount: number): Rect { return new Rect( @@ -81,8 +81,8 @@ class Rect { /** * Determine if two rect-like objects are equal. * - * @param {object} [a] A rect-like object. - * @param {object} [b] A rect-like object. + * @param {Object} [a] A rect-like object. + * @param {Object} [b] A rect-like object. * @returns {boolean} True if rects are equal. */ static areEqual(a: ?RectLike, b: ?RectLike): boolean { @@ -104,6 +104,16 @@ class Rect { ); } + /** + * Determine if a rect-like object has valid positive area. + * + * @param {Object} [rect] A rect-like object. + * @returns {boolean} True if the rect has a positive area. + */ + static isValid(rect: RectLike): boolean { + return rect.width >= 0 && rect.height >= 0; + } + constructor(left: number, top: number, width: number, height: number): void { this.left = left; this.top = top; diff --git a/packages/flowtip-core/src/flowtip.js b/packages/flowtip-core/src/flowtip.js index 113dd0f..93f7913 100644 --- a/packages/flowtip-core/src/flowtip.js +++ b/packages/flowtip-core/src/flowtip.js @@ -14,6 +14,8 @@ type _Regions = { }; export type Regions = $Shape<_Regions>; export type Result = { + bounds: Rect, + target: Rect, region: Region, reason: Reason, rect: Rect, @@ -69,7 +71,7 @@ export const END: Align = 'end'; * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @returns {object} A rect object. + * @returns {Object} A rect object. */ function getRect(config: _Config, region: Region): Rect { const {target, content, align, offset} = config; @@ -113,7 +115,7 @@ function getRect(config: _Config, region: Region): Rect { * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @returns {object} A rect object + * @returns {Object} A rect object */ function getOffsetBounds(config: _Config, region: Region): Rect { const {bounds, edgeOffset, offset} = config; @@ -133,8 +135,8 @@ function getOffsetBounds(config: _Config, region: Region): Rect { * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} offsetBounds A final bounds rect for the current region. - * @param {object} rect A content rect object. + * @param {Object} offsetBounds A final bounds rect for the current region. + * @param {Object} rect A content rect object. * @returns {number} A new left position for the content rect. */ function constrainLeft( @@ -170,8 +172,8 @@ function constrainLeft( * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} offsetBounds A final bounds rect for the current region. - * @param {object} rect A content rect object. + * @param {Object} offsetBounds A final bounds rect for the current region. + * @param {Object} rect A content rect object. * @returns {number} A new top position for the content rect. */ function constrainTop( @@ -699,8 +701,8 @@ function getRegion(config: _Config, valid: _Regions): [Region, Reason] { * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} rect A content rect object. - * @returns {object} A repositioned content rect. + * @param {Object} rect A content rect object. + * @returns {Object} A repositioned content rect. */ function constrainRect(config: _Config, region: Region, rect: Rect): Rect { const offsetBounds = getOffsetBounds(config, region); @@ -720,7 +722,7 @@ function constrainRect(config: _Config, region: Region, rect: Rect): Rect { * * @param {Object} config FlowTip layout config object. * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} rect A content rect object. + * @param {Object} rect A content rect object. * @returns {number} Distance between target and content. */ function getOffset(config: _Config, region: Region, rect: Rect): number { @@ -742,7 +744,7 @@ function getOffset(config: _Config, region: Region, rect: Rect): number { * Get the current linear overlap between the content rect and the target rect. * * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} intersect The intersection rect of the target and content. + * @param {Object} intersect The intersection rect of the target and content. * @returns {number} Overlap between target and content. */ function getOverlap(region: Region, intersect: Rect): number { @@ -791,8 +793,8 @@ function getOverlap(region: Region, intersect: Rect): number { * * @param {string} region A region (`top`, `right`, `bottom`, or `left`). - * @param {object} rect A content rect object. - * @param {object} intersect The intersection rect of the target and content. + * @param {Object} rect A content rect object. + * @param {Object} intersect The intersection rect of the target and content. * @returns {number} Distance to overlap center. */ function getCenter(region: Region, rect: Rect, intersect: Rect): number { @@ -851,8 +853,8 @@ function defaults(config: Config): _Config { * Calculate a FlowTip layout result. * * @param {Object} config FlowTip layout config object. - * @param {object} config.target A rect representing the target element. - * @param {object} config.content A rect representing the content element. + * @param {Object} config.target A rect representing the target element. + * @param {Object} config.content A rect representing the content element. * @param {string} [config.region] The default region * (`top`, `right`, `bottom`, or `left`). * @param {string} config.disabled Disabled regions @@ -884,6 +886,8 @@ function flowtip(config: Config): Result { const overlapCenter = getCenter(region, rect, intersect); return { + bounds: finalConfig.bounds, + target: finalConfig.target, region, reason, rect, diff --git a/packages/flowtip-react-dom/__test__/FlowTip.test.js b/packages/flowtip-react-dom/__test__/FlowTip.test.js index f05b0ae..f95e7e5 100644 --- a/packages/flowtip-react-dom/__test__/FlowTip.test.js +++ b/packages/flowtip-react-dom/__test__/FlowTip.test.js @@ -50,7 +50,7 @@ describe('FlowTip', () => { let spy; beforeEach(() => { - spy = jest.spyOn(FlowTip.prototype, '_handleScroll'); + spy = jest.spyOn(FlowTip.prototype, '_updateState'); }); afterEach(() => { @@ -71,11 +71,12 @@ describe('FlowTip', () => { attachTo: document.getElementById('root'), }); - expect(spy).toHaveBeenCalledTimes(0); + const count = spy.mock.calls.length; window.dispatchEvent(new UIEvent('scroll')); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(count + 1); + wrapper.unmount(); }); }); diff --git a/packages/flowtip-react-dom/src/FlowTip.js b/packages/flowtip-react-dom/src/FlowTip.js index 4142d78..b5332e4 100644 --- a/packages/flowtip-react-dom/src/FlowTip.js +++ b/packages/flowtip-react-dom/src/FlowTip.js @@ -1,26 +1,25 @@ // @flow + import * as React from 'react'; -import ResizeObserver from 'react-resize-observer'; - -import flowtip, { - RIGHT, - LEFT, - CENTER, - Rect, - areEqualDimensions, - getClampedTailPosition, -} from 'flowtip-core'; - -import type {RectLike, Region, Align, Dimensions, Result} from 'flowtip-core'; - -import getContainingBlock from './util/getContainingBlock'; -import getClippingBlock from './util/getClippingBlock'; -import getContentRect from './util/getContentRect'; +import flowtip, {CENTER, Rect, areEqualDimensions} from 'flowtip-core'; +import type {RectLike, Region, Dimensions, Result} from 'flowtip-core'; + +import type {Props, State} from './types'; import findDOMNode from './util/findDOMNode'; +import { + getBorders, + getContainingBlock, + getClippingBlock, + getContentRect, +} from './util/dom'; +import {getRegion, getOverlap, getOffset} from './util/state'; +import defaultRender from './defaultRender'; // Static `flowtip` layout calculation result mock for use during initial client // side render or on server render where DOM feedback is not possible. const STATIC_RESULT: Result = { + bounds: Rect.zero, + target: Rect.zero, region: 'bottom', reason: 'default', rect: Rect.zero, @@ -31,95 +30,6 @@ const STATIC_RESULT: Result = { _static: true, }; -export type State = { - containingBlock: Rect, - bounds: Rect | null, - content: Dimensions | null, - tail: Dimensions | null, - result: Result | null, -}; - -type Style = {[string]: string | number}; - -export type Props = { - /** DOMRect (or similar shaped object) of target position. */ - target: RectLike | null, - /** - DOMRect (or similar shaped object) of content boundary. - */ - bounds: RectLike | null, - /** Default region the content should unless otherwise constrained. */ - region: Region | void, - /** Retain the previous rendered region unless otherwise constrained. */ - sticky: boolean, - /** Offset between target rect and tail. */ - targetOffset: number, - /** Minimum distance between content react and boundary edge. */ - edgeOffset: number, - /** - * Prevent the tail from getting within this distance of the corner of - * the content. - */ - tailOffset: number, - /** Relative alignment of content rect and target rect. */ - align: Align, - /** Disable the top region. */ - topDisabled: boolean, - /** Disable the right region. */ - rightDisabled: boolean, - /** Disable the bottom region. */ - bottomDisabled: boolean, - /** Disable the left region. */ - leftDisabled: boolean, - /** Constrain the content at the top boundary. */ - constrainTop: boolean, - /** Constrain the content at the top boundary. */ - constrainRight: boolean, - /** Constrain the content at the right boundary. */ - constrainBottom: boolean, - /** Constrain the content at the bottom boundary. */ - constrainLeft: boolean, - content: - | React.ComponentType<{ - style: Style, - result: Result, - children?: React.Node, - }> - | string, - tail?: React.ComponentType<{ - style: Style, - result: Result, - children?: React.Node, - }>, - children?: React.Node, -}; - -const omitFlowtipProps = (props: Props) => { - const { - target: _target, - bounds: _bounds, - region: _region, - sticky: _sticky, - targetOffset: _targetOffset, - edgeOffset: _edgeOffset, - tailOffset: _tailOffset, - align: _align, - topDisabled: _topDisabled, - rightDisabled: _rightDisabled, - bottomDisabled: _bottomDisabled, - leftDisabled: _leftDisabled, - constrainTop: _constrainTop, - constrainRight: _constrainRight, - constrainBottom: _constrainBottom, - constrainLeft: _constrainLeft, - content: _content, - tail: _tail, - ...rest - } = props; - - return rest; -}; - class FlowTip extends React.Component { static defaultProps = { bounds: null, @@ -137,7 +47,7 @@ class FlowTip extends React.Component { constrainRight: true, constrainBottom: true, constrainLeft: true, - content: 'div', + render: defaultRender, }; _nextContent: Dimensions | null = null; @@ -149,15 +59,17 @@ class FlowTip extends React.Component { _containingBlockNode: HTMLElement | null = null; _clippingBlockNode: HTMLElement | null = null; _node: HTMLElement | null = null; - state = this._getState(this.props); + state = { + containingBlock: Rect.zero, + bounds: null, + content: null, + contentBorders: null, + tail: null, + result: STATIC_RESULT, + }; - _handleContentSize = this._handleContentSize.bind(this); - _handleTailSize = this._handleTailSize.bind(this); - _handleScroll = this._handleScroll.bind(this); + // Lifecycle Methods ========================================================= - // =========================================================================== - // Lifecycle Methods - // =========================================================================== componentDidMount(): void { this._isMounted = true; @@ -193,112 +105,7 @@ class FlowTip extends React.Component { window.removeEventListener('resize', this._handleScroll); } - // =========================================================================== - // State Management - // =========================================================================== - - _getLastRegion(nextProps: Props): Region | void { - return this._lastRegion || nextProps.region; - } - - _getRegion(nextProps: Props): Region | void { - // Feed the current region in as the default if `sticky` is true. - // This makes the component stay in its region until it meets a - // boundary edge and must change. - return nextProps.sticky ? this._getLastRegion(nextProps) : nextProps.region; - } - - /** - * Get the dimension of the tail perpendicular to the attached edge of the - * content rect. - * - * Note: `props` are passed in as an argument to allow using this method from - * within `componentWillReceiveProps`. - * - * @param {Object} nextProps - FlowTip props. - * @returns {number} Tail length. - */ - _getTailLength(nextProps: Props): number { - const lastRegion = this._getLastRegion(nextProps); - - if (this._nextTail) { - // Swap the width and height into "base" and "length" to create - // measurements that are agnostic to tail orientation. - if (lastRegion === LEFT || lastRegion === RIGHT) { - return this._nextTail.width; - } - // Either lastRegion is top or bottom - or it is undefined, which means - // the tail was rendered using the static dummy result that uses the - // bottom region. - return this._nextTail.height; - } - - return 0; - } - - /** - * Get the offset between the target and the content rect. - * - * The flowtip layout calculation does not factor the dimensions of the tail. - * This method encodes the tail dimension into the `offset` parameter. - * - * Note: `props` are passed in as an argument to allow using this method from - * within `componentWillReceiveProps`. - * - * @param {Object} nextProps - FlowTip props. - * @returns {number} Tail length. - */ - _getOffset(nextProps: Props) { - // Ensure that the there is `targetOffset` amount of space between the - // tail and the target rect. - return nextProps.targetOffset + this._getTailLength(nextProps); - } - - /** - * Get the dimension of the tail parallel to the attached edge of the content - * rect. - * - * Note: `props` are passed in as an argument to allow using this method from - * within `componentWillReceiveProps`. - * - * @param {Object} nextProps - FlowTip props. - * @returns {number} Tail base size. - */ - _getTailBase(nextProps: Props): number { - const lastRegion = this._getLastRegion(nextProps); - - if (this._nextTail) { - // Swap the width and height into "base" and "length" to create - // measurements that are agnostic to tail orientation. - if (lastRegion === LEFT || lastRegion === RIGHT) { - return this._nextTail.height; - } - // Either lastRegion is top or bottom - or it is undefined, which means - // the tail was rendered using the static dummy result that uses the - // bottom region - return this._nextTail.width; - } - - return 0; - } - - /** - * Get current minimum linear overlap value. - * - * Overlap ensures that there is always enough room to render a tail that - * points to the target rect. This will force the content to enter a - * different region if there is not enough room. The `tailOffset` value - * is the minumun distance between the tail and the content corner. - * - * Note: `props` are passed in as an argument to allow using this method from - * within `componentWillReceiveProps`. - * - * @param {Object} nextProps - FlowTip props. - * @returns {number} Minimum linear overlap. - */ - _getOverlap(nextProps: Props): number { - return nextProps.tailOffset + this._getTailBase(nextProps) / 2; - } + // State Management ========================================================== /** * Get the next state. @@ -321,7 +128,7 @@ class FlowTip extends React.Component { const tail = this._nextTail; const target = nextProps.target; - let result = null; + let result = STATIC_RESULT; if ( bounds && @@ -329,12 +136,25 @@ class FlowTip extends React.Component { content && (typeof nextProps.Tail !== 'function' || tail) ) { + const intermediateState = { + ...this.state, + bounds, + containingBlock, + tail, + content, + }; + + const offset = getOffset(nextProps, intermediateState); + const overlap = getOverlap(nextProps, intermediateState); + const region = getRegion(nextProps, intermediateState); + const {edgeOffset = offset, align} = nextProps; + const config = { - offset: this._getOffset(nextProps), - edgeOffset: nextProps.edgeOffset, - overlap: this._getOverlap(nextProps), - align: nextProps.align, - region: this._getRegion(nextProps), + offset, + edgeOffset, + overlap, + align, + region, bounds, target, content, @@ -353,16 +173,16 @@ class FlowTip extends React.Component { }; result = flowtip(config); - - this._lastRegion = result.region; } + const contentBorders = this._node ? getBorders(this._node) : null; + return { containingBlock, bounds, content, + contentBorders, tail, - result, }; } @@ -407,9 +227,7 @@ class FlowTip extends React.Component { } } - // =========================================================================== - // DOM Measurement Methods - // =========================================================================== + // DOM Measurement Methods =================================================== _getBoundsRect(nextProps: Props): Rect | null { const viewportRect = new Rect(0, 0, window.innerWidth, window.innerHeight); @@ -417,13 +235,7 @@ class FlowTip extends React.Component { const processBounds = (boundsRect: RectLike) => { const visibleBounds = Rect.intersect(viewportRect, boundsRect); - // A rect with negative dimensions doesn't make sense here. - // Returning null will disable rendering content. - if (visibleBounds.width < 0 || visibleBounds.height < 0) { - return Rect.zero; - } - - return visibleBounds; + return Rect.isValid(visibleBounds) ? visibleBounds : null; }; if (nextProps.bounds) { @@ -468,9 +280,7 @@ class FlowTip extends React.Component { return getContentRect(this._containingBlockNode); } - // =========================================================================== - // DOM Element Accessors - // =========================================================================== + // DOM Element Accessors ===================================================== _updateDOMNodes(): void { const node = findDOMNode(this); @@ -478,17 +288,13 @@ class FlowTip extends React.Component { if (node instanceof HTMLElement) { this._node = node; - this._containingBlockNode = - getContainingBlock(node.parentNode) || document.documentElement; + this._containingBlockNode = getContainingBlock(node.parentNode); - this._clippingBlockNode = - getClippingBlock(node.parentNode) || document.documentElement; + this._clippingBlockNode = getClippingBlock(node.parentNode); } } - // =========================================================================== - // Event Handlers - // =========================================================================== + // Event Handlers ============================================================ /** * Content `ResizeObserver` handler. @@ -496,13 +302,13 @@ class FlowTip extends React.Component { * Responds to changes in the dimensions of the rendered content and updates * the cached `_nextContent` rect and triggers a state update. * - * @param {Object} rect - DOMRect instance. + * @param {Object} rect - Object with `width` and `height` properties. * @returns {void} */ - _handleContentSize(rect: ClientRect): void { + _handleContentSize = (rect: Dimensions) => { this._nextContent = {width: rect.width, height: rect.height}; this._updateState(this.props); - } + }; /** * @@ -511,13 +317,13 @@ class FlowTip extends React.Component { * Responds to changes in the dimensions of the rendered tail element and * updates the cached `_nextContent` rect and triggers a state update. * - * @param {Object} rect - DOMRect instance. + * @param {Object} rect - Object with `width` and `height` properties. * @returns {void} */ - _handleTailSize(rect: ClientRect): void { + _handleTailSize = (rect: Dimensions) => { this._nextTail = {width: rect.width, height: rect.height}; this._updateState(this.props); - } + }; /** * Window scroll handler. @@ -527,142 +333,19 @@ class FlowTip extends React.Component { * * @returns {void} */ - _handleScroll(): void { + _handleScroll = () => { this._nextContainingBlock = this._getContainingBlockRect(); this._nextBounds = this._getBoundsRect(this.props); this._updateState(this.props); - } - - // =========================================================================== - // Render Methods - // =========================================================================== - - /** - * Get the content element position style based on the current layout result - * in the state. - * - * @param {Object} result - A `flowtip` layout result. - * @returns {Object} Content position style. - */ - _getContentStyle(result: Result): Style { - const {containingBlock} = this.state; - - // Hide the result with css clip - preserving its ability to be measured - - // when working with a static layout result mock. - if (!result || result._static === true) { - return { - position: 'absolute', - clip: 'rect(0 0 0 0)', - }; - } - - return { - position: 'absolute', - top: Math.round(result.rect.top - containingBlock.top), - left: Math.round(result.rect.left - containingBlock.left), - }; - } - - /** - * Get the tail element position style based on the current layout result in - * the state. - * - * @param {Object} result - A `flowtip` layout result. - * @returns {Object} Tail position style. - */ - _getTailStyle(result: Result): Style { - const {tailOffset} = this.props; - const {tail} = this.state; - - if (!result) return {position: 'absolute'}; - - const {region} = result; - - const tailAttached = result.offset >= this._getOffset(this.props); - - const style: Style = { - position: 'absolute', - visibility: tailAttached ? 'visible' : 'hidden', - }; - - if (tail) { - const position = getClampedTailPosition(result, tail, tailOffset); - - // Position the tail at the opposite edge of the region. i.e. if region is - // `right` the style will be `right: 100%`, which will place the tail - // at left edge. - style[region] = '100%'; - - if (region === RIGHT || region === LEFT) { - style.top = position; - } else { - style.left = position; - } - } - - return style; - } - - /** - * Render the tail element. A `ResizeObserver` is inserted to allow measuring - * the dimensions of the rendered content. - * - * @param {Object} result - A `flowtip` layout result. - * @returns {Object|null} Rendered element. - */ - renderTail(result: Result): React.Node { - if (!this.props.tail) return null; - - const {tail: Tail} = this.props; - const tailStyle = this._getTailStyle(result); - - return ( - - - - ); - } - - /** - * Render the content element. A `ResizeObserver` is inserted before the other - * children to allow measuring the dimensions of the rendered content. - * - * @param {Object} result - A `flowtip` layout result. - * @returns {Object|null} Rendered element. - */ - renderContent(result: Result): React.Node { - if (!this.props.content) return null; - - const {children, content: Content} = this.props; - - const contentProps = { - ...omitFlowtipProps(this.props), - style: this._getContentStyle(result), - }; - - if (typeof Content === 'function') { - contentProps.result = result; - } - - return ( - - - {children} - {this.renderTail(result)} - - ); - } + }; render(): React.Node { - if (this.state.result) { - return this.renderContent(this.state.result); - } - - if (this.props.content) { - return this.renderContent(STATIC_RESULT); - } - - return null; + return this.props.render({ + onTailSize: this._handleTailSize, + onContentSize: this._handleContentSize, + state: this.state, + props: this.props, + }); } } diff --git a/packages/flowtip-react-dom/src/defaultRender.js b/packages/flowtip-react-dom/src/defaultRender.js new file mode 100644 index 0000000..5b74f7e --- /dev/null +++ b/packages/flowtip-react-dom/src/defaultRender.js @@ -0,0 +1,63 @@ +// @flow + +import * as React from 'react'; +import ResizeObserver from 'react-resize-observer'; + +import type {Props, RenderProps} from './types'; +import {getTailStyle, getContentStyle} from './util/render'; + +const omitFlowtipProps = (props: Props) => { + const { + target: _target, + bounds: _bounds, + region: _region, + sticky: _sticky, + targetOffset: _targetOffset, + edgeOffset: _edgeOffset, + tailOffset: _tailOffset, + align: _align, + topDisabled: _topDisabled, + rightDisabled: _rightDisabled, + bottomDisabled: _bottomDisabled, + leftDisabled: _leftDisabled, + constrainTop: _constrainTop, + constrainRight: _constrainRight, + constrainBottom: _constrainBottom, + constrainLeft: _constrainLeft, + render: _render, + content: _content, + tail: _tail, + ...rest + } = props; + + return rest; +}; + +const isComponent = (component): %checks => { + return typeof component === 'string' || typeof component === 'function'; +}; + +const defaultRender = (renderProps: RenderProps): React.Node => { + const {props, state, onTailSize, onContentSize} = renderProps; + const {content: ContentComponent = 'div', tail: TailComponent} = props; + + return ( + + + {props.children} + {isComponent(TailComponent) && ( + + + + )} + + ); +}; + +export default defaultRender; diff --git a/packages/flowtip-react-dom/src/index.js b/packages/flowtip-react-dom/src/index.js index f3ac5e5..97cab0f 100644 --- a/packages/flowtip-react-dom/src/index.js +++ b/packages/flowtip-react-dom/src/index.js @@ -1,3 +1,11 @@ // @flow export {default} from './FlowTip'; -export type {State, Props} from './FlowTip'; +export type { + Style, + ContentProps, + TailProps, + RenderProps, + Render, + Props, + State, +} from './types'; diff --git a/packages/flowtip-react-dom/src/types.js b/packages/flowtip-react-dom/src/types.js new file mode 100644 index 0000000..7cc79c5 --- /dev/null +++ b/packages/flowtip-react-dom/src/types.js @@ -0,0 +1,100 @@ +// @flow + +import * as React from 'react'; +import type { + Rect, + RectLike, + Region, + Align, + Dimensions, + Result, +} from 'flowtip-core'; + +export type Style = {[string]: string | number}; + +export type Borders = { + top: number, + left: number, + right: number, + bottom: number, +}; + +export type ContentProps = { + style: Style, + result: Result, + children: React.Node, +}; + +export type TailProps = { + style: Style, + result: Result, + children: React.Node, +}; + +export type RenderProps = { + onContentSize(Dimensions): mixed, + onTailSize(Dimensions): mixed, + // eslint-disable-next-line no-use-before-define + state: State, + // eslint-disable-next-line no-use-before-define + props: Props, +}; + +export type Render = (RenderProps) => React.Node; + +export type Props = { + /** DOMRect (or similar shaped object) of target position. */ + target: RectLike | null, + /** + DOMRect (or similar shaped object) of content boundary. + */ + bounds: RectLike | null, + /** Default region the content should unless otherwise constrained. */ + region: Region | void, + /** Retain the previous rendered region unless otherwise constrained. */ + sticky: boolean, + /** Offset between target rect and tail. */ + targetOffset: number, + /** Minimum distance between content react and boundary edge. */ + edgeOffset?: number, + /** + * Prevent the tail from getting within this distance of the corner of + * the content. + */ + tailOffset: number, + /** Relative alignment of content rect and target rect. */ + align: Align, + /** Disable the top region. */ + topDisabled: boolean, + /** Disable the right region. */ + rightDisabled: boolean, + /** Disable the bottom region. */ + bottomDisabled: boolean, + /** Disable the left region. */ + leftDisabled: boolean, + /** Constrain the content at the top boundary. */ + constrainTop: boolean, + /** Constrain the content at the top boundary. */ + constrainRight: boolean, + /** Constrain the content at the right boundary. */ + constrainBottom: boolean, + /** Constrain the content at the bottom boundary. */ + constrainLeft: boolean, + children: React.Node, + + render: Render, + + children: React.Node, + + content?: React.ComponentType | string, + tail?: ?React.ComponentType, +}; + +export type State = { + containingBlock: Rect, + bounds: Rect | null, + content: Dimensions | null, + contentBorders: Borders | null, + tail: Dimensions | null, + result: Result, +}; diff --git a/packages/flowtip-react-dom/src/util/dom.js b/packages/flowtip-react-dom/src/util/dom.js new file mode 100644 index 0000000..b7f1888 --- /dev/null +++ b/packages/flowtip-react-dom/src/util/dom.js @@ -0,0 +1,74 @@ +// @flow + +import {Rect} from 'flowtip-core'; +import type {Borders} from '../types'; + +export function findAncestor( + callback: (node: HTMLElement) => boolean, + node: ?Node, +): HTMLElement | null { + let current = node; + + while (current instanceof HTMLElement) { + if (callback(current)) { + return current; + } + + current = current.parentNode; + } + + return null; +} + +export function getBorders(node: HTMLElement): Borders { + const style = getComputedStyle(node); + + const top = parseInt(style.borderTopWidth, 10); + const right = parseInt(style.borderRightWidth, 10); + const bottom = parseInt(style.borderBottomWidth, 10); + const left = parseInt(style.borderLeftWidth, 10); + + return {top, right, bottom, left}; +} + +export function getClippingBlock(node: ?Node): HTMLElement { + const result = findAncestor((node) => { + if (node === document.documentElement) return true; + + const style = getComputedStyle(node); + + return style.overflow && style.overflow !== 'visible'; + }, node); + + if (result) return result; + if (document.documentElement !== null) return document.documentElement; + + throw new Error('document.documentElement is null'); +} + +export function getContainingBlock(node: ?Node): HTMLElement { + const result = findAncestor((node) => { + if (node === document.documentElement) return true; + + const style = getComputedStyle(node); + + return style.position && style.position !== 'static'; + }, node); + + if (result) return result; + if (document.documentElement !== null) return document.documentElement; + + throw new Error('document.documentElement is null'); +} + +export function getContentRect(node: HTMLElement): Rect { + const rect = node.getBoundingClientRect(); + const border = getBorders(node); + + return new Rect( + rect.left + border.left || 0, + rect.top + border.top || 0, + Math.min(rect.width - border.left - border.right, node.clientWidth) || 0, + Math.min(rect.height - border.top - border.bottom, node.clientHeight) || 0, + ); +} diff --git a/packages/flowtip-react-dom/src/util/findAncestor.js b/packages/flowtip-react-dom/src/util/findAncestor.js deleted file mode 100644 index 08c8b9e..0000000 --- a/packages/flowtip-react-dom/src/util/findAncestor.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow - -const findAncestor = ( - callback: (node: HTMLElement) => boolean, - node: ?Node, -): HTMLElement | null => { - let current = node; - - while (current instanceof HTMLElement) { - if (callback(current)) { - return current; - } - - current = current.parentNode; - } - - return null; -}; - -export default findAncestor; diff --git a/packages/flowtip-react-dom/src/util/getClippingBlock.js b/packages/flowtip-react-dom/src/util/getClippingBlock.js deleted file mode 100644 index bc8af48..0000000 --- a/packages/flowtip-react-dom/src/util/getClippingBlock.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -import findAncestor from './findAncestor'; - -const getClippingBlock = (node: ?Node): HTMLElement | null => { - const result = findAncestor((node) => { - if (node === document.documentElement) return true; - - const style = getComputedStyle(node); - - return style.overflow && style.overflow !== 'visible'; - }, node); - - return result; -}; - -export default getClippingBlock; diff --git a/packages/flowtip-react-dom/src/util/getContainingBlock.js b/packages/flowtip-react-dom/src/util/getContainingBlock.js deleted file mode 100644 index 561af8b..0000000 --- a/packages/flowtip-react-dom/src/util/getContainingBlock.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -import findAncestor from './findAncestor'; - -const getContainingBlock = (node: ?Node): HTMLElement | null => { - const result = findAncestor((node) => { - if (node === document.documentElement) return true; - - const style = getComputedStyle(node); - - return style.position && style.position !== 'static'; - }, node); - - return result; -}; - -export default getContainingBlock; diff --git a/packages/flowtip-react-dom/src/util/getContentRect.js b/packages/flowtip-react-dom/src/util/getContentRect.js deleted file mode 100644 index 0805fb3..0000000 --- a/packages/flowtip-react-dom/src/util/getContentRect.js +++ /dev/null @@ -1,22 +0,0 @@ -// @flow - -import {Rect} from 'flowtip-core'; - -const getContentRect = (node: HTMLElement): Rect => { - const rect = node.getBoundingClientRect(); - const style = getComputedStyle(node); - - const topBorder = parseInt(style.borderTopWidth, 10); - const rightBorder = parseInt(style.borderRightWidth, 10); - const bottomBorder = parseInt(style.borderBottomWidth, 10); - const leftBorder = parseInt(style.borderLeftWidth, 10); - - return new Rect( - rect.left + leftBorder || 0, - rect.top + topBorder || 0, - Math.min(rect.width - leftBorder - rightBorder, node.clientWidth) || 0, - Math.min(rect.height - topBorder - bottomBorder, node.clientHeight) || 0, - ); -}; - -export default getContentRect; diff --git a/packages/flowtip-react-dom/src/util/render.js b/packages/flowtip-react-dom/src/util/render.js new file mode 100644 index 0000000..ac85811 --- /dev/null +++ b/packages/flowtip-react-dom/src/util/render.js @@ -0,0 +1,75 @@ +// @flow + +import {getClampedTailPosition, RIGHT, LEFT} from 'flowtip-core'; + +import type {Style, Props, State} from '../types'; +import {getOffset} from '../util/state'; + +/** + * Get the content element position style based on the current layout result + * in the state. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip props. + * @returns {Object} Content position style. + */ +export const getContentStyle = (props: Props, state: State) => { + // Hide the result with css clip - preserving its ability to be measured - + // when working with a static layout result mock. + if (state.result._static === true) { + return { + position: 'absolute', + clip: 'rect(0 0 0 0)', + }; + } + + return { + position: 'absolute', + top: Math.round(state.result.rect.top - state.containingBlock.top), + left: Math.round(state.result.rect.left - state.containingBlock.left), + }; +}; + +/** + * Get the tail element position style based on the current layout result in + * the state. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip props. + * @returns {Object} Tail position style. + */ +export const getTailStyle = (props: Props, state: State) => { + if (!state.result) return {position: 'absolute'}; + + const tailAttached = state.result.offset >= getOffset(props, state); + + const style: Style = { + position: 'absolute', + visibility: tailAttached ? 'visible' : 'hidden', + }; + + if (state.tail && state.contentBorders) { + const borders = state.contentBorders; + + const position = getClampedTailPosition( + state.result, + state.tail, + props.tailOffset, + ); + + // Position the tail at the opposite edge of the region. i.e. if region is + // `right` the style will be `right: 100%`, which will place the tail + // at left edge. + style[state.result.region] = `calc(100% + ${ + borders[state.result.region] + }px)`; + + if (state.result.region === RIGHT || state.result.region === LEFT) { + style.top = position - borders.top; + } else { + style.left = position - borders.left; + } + } + + return style; +}; diff --git a/packages/flowtip-react-dom/src/util/state.js b/packages/flowtip-react-dom/src/util/state.js new file mode 100644 index 0000000..4b3b3c8 --- /dev/null +++ b/packages/flowtip-react-dom/src/util/state.js @@ -0,0 +1,127 @@ +// @flow + +import {RIGHT, LEFT} from 'flowtip-core'; +import type {Region} from 'flowtip-core'; +import type {Props, State} from '../types'; + +/** + * Get the last rendered region (`top`, `right`, `bottom`, or `left`). + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip state. + * @returns {string} Region. + */ +export function getLastRegion(props: Props, state: State): Region | void { + return state.result._static === true ? props.region : state.result.region; +} + +/** + * Get the current region (`top`, `right`, `bottom`, or `left`) that the FlowTip + * layout algorithm should prioritize in it's result. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip state. + * @returns {string} Region. + */ +export function getRegion(props: Props, state: State): Region | void { + // Feed the current region in as the default if `sticky` is true. + // This makes the component stay in its region until it meets a + // boundary edge and must change. + return props.sticky ? getLastRegion(props, state) : props.region; +} + +/** + * Get the dimension of the tail perpendicular to the attached edge of the + * content rect. + * + * Note: `props` are passed in as an argument to allow using this method from + * within `componentWillReceiveProps`. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip state. + * @returns {number} Tail length. + */ +export function getTailLength(props: Props, state: State): number { + const lastRegion = getLastRegion(props, state); + + if (state.tail) { + // Swap the width and height into "base" and "length" to create + // measurements that are agnostic to tail orientation. + if (lastRegion === LEFT || lastRegion === RIGHT) { + return state.tail.width; + } + // Either lastRegion is top or bottom - or it is undefined, which means + // the tail was rendered using the static dummy result that uses the + // bottom region. + return state.tail.height; + } + + return 0; +} + +/** + * Get the dimension of the tail parallel to the attached edge of the content + * rect. + * + * Note: `props` are passed in as an argument to allow using this method from + * within `componentWillReceiveProps`. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip state. + * @returns {number} Tail base size. + */ +export function getTailBase(props: Props, state: State): number { + const lastRegion = getLastRegion(props, state); + + if (state.tail) { + // Swap the width and height into "base" and "length" to create + // measurements that are agnostic to tail orientation. + if (lastRegion === LEFT || lastRegion === RIGHT) { + return state.tail.height; + } + // Either lastRegion is top or bottom - or it is undefined, which means + // the tail was rendered using the static dummy result that uses the + // bottom region + return state.tail.width; + } + + return 0; +} + +/** + * Get current minimum linear overlap value. + * + * Overlap ensures that there is always enough room to render a tail that + * points to the target rect. This will force the content to enter a + * different region if there is not enough room. The `tailOffset` value + * is the minimum distance between the tail and the content corner. + * + * Note: `props` are passed in as an argument to allow using this method from + * within `componentWillReceiveProps`. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip props. + * @returns {number} Minimum linear overlap. + */ +export function getOverlap(props: Props, state: State): number { + return props.tailOffset + getTailBase(props, state) / 2; +} + +/** + * Get the offset between the target and the content rect. + * + * The flowtip layout calculation does not factor the dimensions of the tail. + * This method encodes the tail dimension into the `offset` parameter. + * + * Note: `props` are passed in as an argument to allow using this method from + * within `componentWillReceiveProps`. + * + * @param {Object} props - FlowTip props. + * @param {Object} state - FlowTip state. + * @returns {number} Tail length. + */ +export function getOffset(props: Props, state: State): number { + // Ensure that the there is `targetOffset` amount of space between the + // tail and the target rect. + return props.targetOffset + getTailLength(props, state); +}