// Copyright 2016 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// Thanks!
/// https://github.com/marcj/css-element-queries/blob/master/src/ResizeSensor.js
library resize_sensor;
import 'dart:collection';
import 'dart:html';
import 'package:platform_detect/platform_detect.dart';
import 'package:react/react.dart' as react;
import 'package:over_react/over_react.dart';
/// A wrapper component that detects when its parent is resized.
///
/// This component _must_ be put in a relative or absolutely positioned
/// container.
///
/// (ResizeSensor()..onResize = () => print('resized'))(children)
///
/// See: <https://docs.workiva.org/web_skin_dart/latest/components/#resize-sensor>.
@Factory()
UiFactory<ResizeSensorProps> ResizeSensor = ([Map backingProps]) => new _$ResizeSensorPropsImpl(backingProps);
@PropsMixin()
abstract class ResizeSensorPropsMixin { /* GENERATED CONSTANTS */ static const ConsumedProps $consumedProps = const ConsumedProps($props, $propKeys); static const PropDescriptor _$prop__onInitialize = const PropDescriptor(_$key__onInitialize), _$prop__onResize = const PropDescriptor(_$key__onResize), _$prop__isFlexChild = const PropDescriptor(_$key__isFlexChild), _$prop__isFlexContainer = const PropDescriptor(_$key__isFlexContainer), _$prop__shrink = const PropDescriptor(_$key__shrink), _$prop__quickMount = const PropDescriptor(_$key__quickMount); static const List<PropDescriptor> $props = const [_$prop__onInitialize, _$prop__onResize, _$prop__isFlexChild, _$prop__isFlexContainer, _$prop__shrink, _$prop__quickMount]; static const String _$key__onInitialize = 'ResizeSensorPropsMixin.onInitialize', _$key__onResize = 'ResizeSensorPropsMixin.onResize', _$key__isFlexChild = 'ResizeSensorPropsMixin.isFlexChild', _$key__isFlexContainer = 'ResizeSensorPropsMixin.isFlexContainer', _$key__shrink = 'ResizeSensorPropsMixin.shrink', _$key__quickMount = 'ResizeSensorPropsMixin.quickMount'; static const List<String> $propKeys = const [_$key__onInitialize, _$key__onResize, _$key__isFlexChild, _$key__isFlexContainer, _$key__shrink, _$key__quickMount];
static final ResizeSensorPropsMixinMapView defaultProps = new ResizeSensorPropsMixinMapView({})
..isFlexChild = false
..isFlexContainer = false
..shrink = false
..quickMount = false;
Map get props;
/// A function invoked when the resize sensor is initialized.
ResizeSensorHandler onInitializeResizeSensorHandler get onInitialize => props[_$key__onInitialize]; set onInitialize(ResizeSensorHandler value) => props[_$key__onInitialize] = value;;
/// A function invoked when the parent element is resized.
ResizeSensorHandler onResizeResizeSensorHandler get onResize => props[_$key__onResize]; set onResize(ResizeSensorHandler value) => props[_$key__onResize] = value;;
/// Whether the [ResizeSensor] is a child of a flex item. Necessary to apply the correct styling.
///
/// See this issue for details: <https://code.google.com/p/chromium/issues/detail?id=346275>
///
/// Default: false
bool isFlexChildbool get isFlexChild => props[_$key__isFlexChild]; set isFlexChild(bool value) => props[_$key__isFlexChild] = value;;
/// Whether the [ResizeSensor] is a flex container. Necessary to apply the correct styling.
///
/// Default: false
bool isFlexContainerbool get isFlexContainer => props[_$key__isFlexContainer]; set isFlexContainer(bool value) => props[_$key__isFlexContainer] = value;;
/// Whether the [ResizeSensor] should shrink to the size of its child.
///
/// __WARNING:__ If set to true there is a possibility that the [ResizeSensor] will not work due to it being too
/// small.
///
/// Default: false
bool shrinkbool get shrink => props[_$key__shrink]; set shrink(bool value) => props[_$key__shrink] = value;;
/// Whether quick-mount mode is enabled, which minimizes layouts caused by accessing element dimensions
/// during initialization, allowing the component to mount faster.
///
/// When enabled:
///
/// * The initial dimensions will not be retrieved, so the first [onResize]
/// event will contain `0` for the previous dimensions.
///
/// * [onInitialize] will never be called.
///
/// * The sensors will be initialized/reset in the next animation frame after mount, as opposed to synchronously,
/// helping to break up resulting layouts.
///
/// Default: false
bool quickMountbool get quickMount => props[_$key__quickMount]; set quickMount(bool value) => props[_$key__quickMount] = value;;
}
@Props()
abstract class ResizeSensorProps extends UiProps with ResizeSensorPropsMixin { /* GENERATED CONSTANTS */ static const ConsumedProps $consumedProps = const ConsumedProps($props, $propKeys); static const List<PropDescriptor> $props = const []; static const List<String> $propKeys = const []; }
@Component()
class ResizeSensorComponent extends UiComponent<ResizeSensorProps> with _SafeAnimationFrameMixin, _$ResizeSensorComponentImplMixin {
// Refs
Element _expandSensorRef;
Element _collapseSensorRef;
@override
Map getDefaultProps() => (newProps()
..addProps(ResizeSensorPropsMixin.defaultProps)
);
@override
void componentWillUnmount() {
super.componentWillUnmount();
cancelAnimationFrames();
}
@override
void componentDidMount() {
if (props.quickMount) {
assert(props.onInitialize == null || ValidationUtil.warn(
'props.onInitialize will not be called when props.quickMount is true.'
));
// [1] Initialize/reset the sensor in the next animation frame after mount
// so that resulting layouts don't happen synchronously, and are better dispersed.
//
// [2] Ignore the first `2` scroll events triggered by resetting the scroll positions
// of the expand and collapse sensors.
//
// [3] Don't access the dimensions of the sensor to prevent unnecessary layouts.
requestAnimationFrame(() { // [1]
_scrollEventsToIgnore = 2; // [2]
_reset(updateLastDimensions: false); // [3]
});
} else {
_reset();
if (props.onInitialize != null) {
var event = new ResizeSensorEvent(_lastWidth, _lastHeight, 0, 0);
props.onInitialize(event);
}
}
}
@override
render() {
var expandSensor = (Dom.div()
..className = 'resize-sensor-expand'
..onScroll = _handleSensorScroll
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..ref = (ref) { _expandSensorRef = ref; }
)(
(Dom.div()..style = _expandSensorChildStyle)()
);
var collapseSensor = (Dom.div()
..className = 'resize-sensor-collapse'
..onScroll = _handleSensorScroll
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..ref = (ref) { _collapseSensorRef = ref; }
)(
(Dom.div()..style = _collapseSensorChildStyle)()
);
var resizeSensor = (Dom.div()
..className = 'resize-sensor'
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..key = 'resizeSensor'
)(expandSensor, collapseSensor);
Map<String, dynamic> wrapperStyles;
if (props.isFlexChild) {
wrapperStyles = _wrapperStylesFlexChild;
} else if (props.isFlexContainer) {
wrapperStyles = _wrapperStylesFlexContainer;
} else {
wrapperStyles = _wrapperStyles;;
}
return (Dom.div()
..addProps(copyUnconsumedDomProps())
..className = forwardingClassNameBuilder().toClassName()
..style = wrapperStyles
)(
props.children,
resizeSensor
);
}
/// When the expand or collapse sensors are resized, builds a [ResizeSensorEvent] and calls
/// props.onResize with it. Then, calls through to [_reset()].
void _handleSensorScroll(react.SyntheticEvent _) {
if (_scrollEventsToIgnore > 0) {
_scrollEventsToIgnore--;
return;
}
var sensor = findDomNode(this);
var newWidth = sensor.offsetWidth;
var newHeight = sensor.offsetHeight;
if (newWidth != _lastWidth || newHeight != _lastHeight) {
if (props.onResize != null) {
var event = new ResizeSensorEvent(newWidth, newHeight, _lastWidth, _lastHeight);
props.onResize(event);
}
_reset();
}
}
/// Reset the scroll positions on [_expandSensorRef] and [_collapseSensorRef] so that future
/// resizes will trigger scroll events.
///
/// Additionally update the state with the new [_lastWidth] and [_lastHeight] when [updateLastDimensions] is true.
void _reset({bool updateLastDimensions: true}) {
if (updateLastDimensions) {
var sensor = findDomNode(this);
_lastWidth = sensor.offsetWidth;
_lastHeight = sensor.offsetHeight;
}
// Scroll positions are clamped to their maxes; use this behavior to scroll to the end
// as opposed to scrollWidth/scrollHeight, which trigger reflows immediately.
_expandSensorRef
..scrollLeft = _maxSensorSize
..scrollTop = _maxSensorSize;
_collapseSensorRef
..scrollLeft = _maxSensorSize
..scrollTop = _maxSensorSize;
}
/// The number of future scroll events to ignore.
///
/// Resetting the sensors' scroll positions causes sensor scroll events to fire even though a resize didn't occur,
/// so this flag is used to ignore those scroll events on mount for performance reasons in quick-mount mode
/// (since the handler causes a layout by accessing the sensor's dimensions).
///
/// This value is only set for the component's mount and __not__ reinitialized every time [_reset] is called
/// in order to avoid ignoring scroll events fired by actual resizes at the same time that the reset is taking place.
int _scrollEventsToIgnore = 0;
/// The most recently measured value for the height of the sensor.
int _lastHeight = 0;
/// The most recently measured value for the width of the sensor.
int _lastWidth = 0;
}
/// The maximum size, in `px`, the sensor can be: 100,000.
///
/// We want to use absolute values to avoid accessing element dimensions when possible,
/// and relative units like `%` don't work since they don't cause scroll events when sensor size changes.
///
/// We could use `rem` or `vh`/`vw`, but that opens us up to more edge cases.
const int _maxSensorSize = 100 * 1000;
final Map<String, dynamic> _baseStyle = const {
'position': 'absolute',
// Have this element reach "outside" its containing element in such a way to ensure its width/height are always at
// least 2x the scrollbar width (e.g., 32px on Chrome OS X).
'top': '-100px',
'right': '-100px',
'bottom': '-100px',
'left': '-100px',
'overflow': 'scroll',
'zIndex': '-1',
'visibility': 'hidden',
// Set opacity in addition to visibility to work around Safari scrollbar bug.
'opacity': '0',
};
final Map<String, dynamic> _shrinkBaseStyle = const {
'position': 'absolute',
'top': '0',
'right': '0',
'bottom': '0',
'left': '0',
'overflow': 'scroll',
'zIndex': '-1',
'visibility': 'hidden',
// Set opacity in addition to visibility to work around Safari scrollbar bug.
'opacity': '0',
};
final Map<String, dynamic> _expandSensorChildStyle = const {
'position': 'absolute',
'top': '0',
'left': '0',
'visibility': 'hidden',
// Use a width/height that will always be larger than the expandSensor.
// We'd ideally want to do something like calc(100% + 10px), but that doesn't
// trigger scroll events the same way a fixed dimension does.
'width': _maxSensorSize,
'height': _maxSensorSize,
// Set opacity in addition to visibility to work around Safari scrollbar bug.
'opacity': '0',
};
final Map<String, dynamic> _collapseSensorChildStyle = const {
'position': 'absolute',
'top': '0',
'left': '0',
'width': '200%',
'height': '200%',
'visibility': 'hidden',
// Set opacity in addition to visibility to work around Safari scrollbar bug.
'opacity': '0',
};
const Map<String, dynamic> _wrapperStyles = const {
'position': 'relative',
'height': '100%',
'width': '100%',
};
const Map<String, dynamic> _wrapperStylesFlexChild = const {
'position': 'relative',
'flex': '1 1 0%',
'msFlex': '1 1 0%',
'display': 'block',
};
final Map<String, dynamic> _wrapperStylesFlexContainer = {
'position': 'relative',
'flex': '1 1 0%',
'msFlex': '1 1 0%',
'display': _displayFlex,
};
/// The browser-prefixed value for the CSS `display` property that enables flexbox.
final String _displayFlex = (() {
if (browser.isInternetExplorer && browser.version.major <= 10) return '-ms-flexbox';
return 'flex';
})();
/// Used with [ResizeSensorHandler] to provide information about a resize.
class ResizeSensorEvent {
/// The new width, in pixels.
final int newWidth;
/// The new height, in pixels.
final int newHeight;
/// The previous width, in pixels.
final int prevWidth;
/// The previous height, in pixels.
final int prevHeight;
ResizeSensorEvent(this.newWidth, this.newHeight, this.prevWidth, this.prevHeight);
}
/// A MapView with the typed getters/setters for all HitArea display variation props.
class ResizeSensorPropsMixinMapView extends MapView with ResizeSensorPropsMixin {
/// Create a new instance backed by the specified map.
ResizeSensorPropsMixinMapView(Map map) : super(map);
/// The props to be manipulated via the getters/setters.
/// In this case, it's the current MapView object.
@override
Map get props => this;
}
/// A mixin that makes it easier to manage animation frames within a React component lifecycle.
class _SafeAnimationFrameMixin {
/// The ids of the pending animation frames.
final _animationFrameIds = <int>[];
/// Calls [Window.requestAnimationFrame] with the specified [callback], and keeps track of the
/// request ID so that it can be cancelled in [cancelAnimationFrames].
void requestAnimationFrame(callback()) {
int queuedId;
queuedId = window.requestAnimationFrame((_) {
callback();
_animationFrameIds.remove(queuedId);
});
_animationFrameIds.add(queuedId);
}
/// Cancels all pending animation frames requested by [requestAnimationFrame].
///
/// Should be called in [react.Component.componentWillUnmount].
void cancelAnimationFrames() {
_animationFrameIds.forEach(window.cancelAnimationFrame);
}
}
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
//
// GENERATED IMPLEMENTATIONS
//
// React component factory implementation.
//
// Registers component implementation and links type meta to builder factory.
final $ResizeSensorComponentFactory = registerComponent(() => new ResizeSensorComponent(),
builderFactory: ResizeSensor,
componentClass: ResizeSensorComponent,
isWrapper: false,
parentType: null,
displayName: 'ResizeSensor'
);
// Concrete props implementation.
//
// Implements constructor and backing map, and links up to generated component factory.
class _$ResizeSensorPropsImpl extends ResizeSensorProps {
/// The backing props map proxied by this class.
@override
final Map props;
_$ResizeSensorPropsImpl(Map backingMap) : this.props = backingMap ?? ({});
/// Let [UiProps] internals know that this class has been generated.
@override
bool get $isClassGenerated => true;
/// The [ReactComponentFactory] associated with the component built by this class.
@override
Function get componentFactory => $ResizeSensorComponentFactory;
/// The default namespace for the prop getters/setters generated for this class.
@override
String get propKeyNamespace => 'ResizeSensorProps.';
// Work around https://github.com/dart-lang/sdk/issues/16030 by making
// the original props class abstract and redeclaring `call` in the impl class.
@override
call([children, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, c20, c21, c22, c23, c24, c25, c26, c27, c28, c29, c30, c31, c32, c33, c34, c35, c36, c37, c38, c39, c40]);
}
// Concrete component implementation mixin.
//
// Implements typed props/state factories, defaults `consumedPropKeys` to the keys
// generated for the associated props class.
class _$ResizeSensorComponentImplMixin {
/// Let [UiComponent] internals know that this class has been generated.
@override
bool get $isClassGenerated => true;
/// The default consumed props, taken from ResizeSensorProps.
/// Used in [UiProps.consumedProps] if [consumedProps] is not overridden.
@override
final List<ConsumedProps> $defaultConsumedProps = const [ResizeSensorProps.$consumedProps];
@override
ResizeSensorProps typedPropsFactory(Map backingMap) => new _$ResizeSensorPropsImpl(backingMap);
}
//
// END GENERATED IMPLEMENTATIONS
//
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------