|
| 1 | +import PropTypes from 'prop-types'; |
| 2 | +import {Component, createElement} from 'react'; |
| 3 | +import {contextTypes} from './SizeWatcherTypes'; |
| 4 | + |
| 5 | +const defaultBreakpoint = {props: {}, minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity}; |
| 6 | + |
| 7 | +export default class SizeWatcher extends Component { |
| 8 | + static contextTypes = contextTypes; |
| 9 | + |
| 10 | + static propTypes = { |
| 11 | + // Array of breakpoints |
| 12 | + breakpoints: PropTypes.arrayOf(PropTypes.shape({ |
| 13 | + // Dimensions if breakpoint |
| 14 | + minWidth: PropTypes.number, |
| 15 | + maxWidth: PropTypes.number, |
| 16 | + minHeight: PropTypes.number, |
| 17 | + maxHeight: PropTypes.number, |
| 18 | + // Props that are mixed into container props |
| 19 | + props: PropTypes.object, |
| 20 | + // Property that contains any custom data useful in child render function |
| 21 | + data: PropTypes.any, |
| 22 | + })).isRequired, |
| 23 | + |
| 24 | + // Breakpoint match strategy |
| 25 | + // 'order' (default) - match by breakpoints order, like in css @media-queries, when the last match wins |
| 26 | + // 'breakpointArea' - match by breakpoints area, when the smallest area is considered to be more specific |
| 27 | + // 'intersectionArea' - match by area of breakpoint/size intersection, |
| 28 | + // when the biggest intersection area is considered to be more specific |
| 29 | + matchBy: PropTypes.oneOf(['order', 'breakpointArea', 'intersectionArea']), |
| 30 | + |
| 31 | + // By default only container will be rendered on first render() call (because usually it's a block element) |
| 32 | + // to compute its width and decide what breakpoint pass down to render function. |
| 33 | + // But if container is a part of flex, or grow according to its content, or breakpoint depend on height, |
| 34 | + // content should be rendered with visibility:hidden before breakpoint computation. |
| 35 | + renderContentOnInit: PropTypes.bool, // By default - false |
| 36 | + // If renderContentOnInit is true, pass this flag to show content on first render before computation |
| 37 | + showOnInit: PropTypes.bool, // By default - false |
| 38 | + |
| 39 | + // Container can mimic any html element ('div', 'h2' etc) or custom component (constructors like Link, Button etc) |
| 40 | + type: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), |
| 41 | + |
| 42 | + // Render function, that is called on breakpoint change |
| 43 | + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, |
| 44 | + |
| 45 | + // Optional callback on each size change |
| 46 | + onSizeChange: PropTypes.func, |
| 47 | + }; |
| 48 | + |
| 49 | + static defaultProps = { |
| 50 | + renderContentOnInit: false, |
| 51 | + showOnInit: false, |
| 52 | + matchBy: 'order', |
| 53 | + type: 'div', |
| 54 | + }; |
| 55 | + |
| 56 | + constructor(props, context) { |
| 57 | + super(props, context); |
| 58 | + |
| 59 | + this.processBreakpoints(props.breakpoints); |
| 60 | + this.state = { |
| 61 | + breakpoint: props.renderContentOnInit ? this.findBreakpoint() : null, |
| 62 | + }; |
| 63 | + |
| 64 | + this.saveRef = this.saveRef.bind(this); |
| 65 | + } |
| 66 | + |
| 67 | + componentWillReceiveProps(nextProps) { |
| 68 | + const {props} = this; |
| 69 | + |
| 70 | + if (nextProps.breakpoints !== props.breakpoints || nextProps.matchBy !== props.matchBy) { |
| 71 | + this.processBreakpoints(nextProps.breakpoints); |
| 72 | + |
| 73 | + const breakpoint = this.findBreakpoint(this.size); |
| 74 | + |
| 75 | + if (breakpoint !== this.state.breakpoint) { |
| 76 | + this.setState({breakpoint}); |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + componentWillUnmount() { |
| 82 | + this.checkOut(); |
| 83 | + } |
| 84 | + |
| 85 | + saveRef(dom) { |
| 86 | + if (dom) { |
| 87 | + // If type props contains custom react components, ref returns instance of that component |
| 88 | + // Try taking dom by calling method getWrappedInstance on it |
| 89 | + while (typeof dom.getWrappedInstance === 'function') { |
| 90 | + dom = dom.getWrappedInstance(); |
| 91 | + } |
| 92 | + |
| 93 | + this.checkIn(dom); |
| 94 | + } else { |
| 95 | + this.checkOut(); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + checkIn(dom) { |
| 100 | + this.dom = dom; |
| 101 | + this.context.sizeWatcher.checkIn(this, dom); |
| 102 | + } |
| 103 | + |
| 104 | + checkOut() { |
| 105 | + if (this.dom) { |
| 106 | + this.context.sizeWatcher.checkOut(this.dom); |
| 107 | + this.dom = null; |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + updateSize(size) { |
| 112 | + if (this.size === undefined || size.width !== this.size.width || size.height !== this.size.height) { |
| 113 | + const breakpointNeedCheck = |
| 114 | + this.size === undefined || |
| 115 | + this.sensitiveToWidth && size.width !== this.size.width || |
| 116 | + this.sensitiveToHeight && size.height !== this.size.height; |
| 117 | + |
| 118 | + const currentBreakpoint = this.state.breakpoint; |
| 119 | + const newBreakpoint = breakpointNeedCheck ? this.findBreakpoint(size) : currentBreakpoint; |
| 120 | + |
| 121 | + this.size = size; |
| 122 | + |
| 123 | + if (typeof this.props.onSizeChange === 'function') { |
| 124 | + this.props.onSizeChange(size, currentBreakpoint, newBreakpoint); |
| 125 | + } |
| 126 | + |
| 127 | + if (newBreakpoint !== currentBreakpoint) { |
| 128 | + this.setState({breakpoint: newBreakpoint}); |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + processBreakpoints(breakpoints) { |
| 134 | + this.sensitiveToWidth = false; |
| 135 | + this.sensitiveToHeight = false; |
| 136 | + this.breakpoints = breakpoints; |
| 137 | + |
| 138 | + for (const {minWidth, maxWidth, minHeight, maxHeight} of breakpoints) { |
| 139 | + // Validate maxWidth/minWidth in development |
| 140 | + const maxWidthType = typeof maxWidth; |
| 141 | + const minWidthType = typeof minWidth; |
| 142 | + const maxHeightType = typeof maxHeight; |
| 143 | + const minHeightType = typeof minHeight; |
| 144 | + |
| 145 | + if (maxWidthType !== 'undefined' && (maxWidthType !== 'number' || maxWidth < 0) || |
| 146 | + minWidthType !== 'undefined' && (minWidthType !== 'number' || minWidth < 0 || maxWidth && minWidth > maxWidth)) { |
| 147 | + throw Error(`Wrong min ${JSON.stringify(minWidth)} or max ${JSON.stringify(maxWidth)} width in breakpoint`); |
| 148 | + } |
| 149 | + |
| 150 | + if (maxHeightType !== 'undefined' && (maxHeightType !== 'number' || maxHeight < 0) || |
| 151 | + minHeightType !== 'undefined' && (minHeightType !== 'number' || minHeight < 0 || maxHeight && minHeight > maxHeight)) { |
| 152 | + throw Error(`Wrong min ${JSON.stringify(minHeight)} or max ${JSON.stringify(maxHeight)} height in breakpoint`); |
| 153 | + } |
| 154 | + |
| 155 | + if (!this.sensitiveToWidth && (minWidth || maxWidth)) { |
| 156 | + this.sensitiveToWidth = true; |
| 157 | + } |
| 158 | + |
| 159 | + if (!this.sensitiveToHeight && (minHeight || maxHeight)) { |
| 160 | + this.sensitiveToHeight = true; |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + if (this.props.matchBy === 'order') { |
| 165 | + this.findBreakpoint = this.findBreakpointByOrder; |
| 166 | + } else if (this.props.matchBy === 'breakpointArea') { |
| 167 | + this.findBreakpoint = this.findBreakpointByArea; |
| 168 | + } else if (this.props.matchBy === 'intersectionArea') { |
| 169 | + this.findBreakpoint = this.findBreakpointByIntersection; |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + // Find the last breakpoint that matches given size |
| 174 | + findBreakpointByOrder({width = Infinity, height = Infinity} = {}) { |
| 175 | + for (let i = this.breakpoints.length; i--;) { |
| 176 | + const breakpoint = this.breakpoints[i]; |
| 177 | + const {minWidth = 0, maxWidth = Infinity, minHeight = 0, maxHeight = Infinity} = breakpoint; |
| 178 | + |
| 179 | + if (width >= minWidth && width <= maxWidth && height >= minHeight && height <= maxHeight) { |
| 180 | + return breakpoint; |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + return defaultBreakpoint; |
| 185 | + } |
| 186 | + |
| 187 | + // Find breakpoint with the smallest area |
| 188 | + findBreakpointByArea({width = 1e6, height = 1e6} = {}) { |
| 189 | + return this.breakpoints.reduce((result, breakpoint) => { |
| 190 | + const {minWidth = 0, maxWidth = 1e6, minHeight = 0, maxHeight = 1e6} = breakpoint; |
| 191 | + |
| 192 | + if (width >= minWidth && width <= maxWidth && height >= minHeight && height <= maxHeight) { |
| 193 | + const area = (maxWidth - minWidth) * (maxHeight - minHeight); |
| 194 | + |
| 195 | + if (area <= result.area) { |
| 196 | + result.area = area; |
| 197 | + result.breakpoint = breakpoint; |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + return result; |
| 202 | + }, {area: Infinity, breakpoint: defaultBreakpoint}).breakpoint; |
| 203 | + } |
| 204 | + |
| 205 | + // Find breakpoint with the biggest breakpoint/size intersection area |
| 206 | + findBreakpointByIntersection({width = 1e6, height = 1e6} = {}) { |
| 207 | + return this.breakpoints.reduce((result, breakpoint) => { |
| 208 | + const {minWidth = 0, maxWidth = 1e6, minHeight = 0, maxHeight = 1e6} = breakpoint; |
| 209 | + const area = Math.max(0, Math.min(maxWidth, width) - minWidth) * Math.max(0, Math.min(maxHeight, height) - minHeight); |
| 210 | + |
| 211 | + if (area >= result.area) { |
| 212 | + result.area = area; |
| 213 | + result.breakpoint = breakpoint; |
| 214 | + } |
| 215 | + |
| 216 | + return result; |
| 217 | + }, {area: 0, breakpoint: defaultBreakpoint}).breakpoint; |
| 218 | + } |
| 219 | + |
| 220 | + render() { |
| 221 | + const { |
| 222 | + props: { |
| 223 | + type, breakpoints, matchBy, renderContentOnInit, showOnInit, children, onSizeChange, |
| 224 | + ...elementProps |
| 225 | + }, |
| 226 | + state: {breakpoint}, |
| 227 | + } = this; |
| 228 | + |
| 229 | + let renderedChildren; |
| 230 | + |
| 231 | + if (breakpoint !== null) { |
| 232 | + renderedChildren = typeof children === 'function' ? children(breakpoint, this.size) : children; |
| 233 | + |
| 234 | + Object.assign(elementProps, breakpoint.props); |
| 235 | + |
| 236 | + if (this.size === undefined && !showOnInit) { |
| 237 | + elementProps.style = {...elementProps.style, visibility: 'hidden'}; |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + elementProps.ref = this.saveRef; |
| 242 | + |
| 243 | + return createElement(type, elementProps, renderedChildren); |
| 244 | + } |
| 245 | +} |
0 commit comments