diff --git a/config/jest/identity-obj-proxy-revised.js b/config/jest/identity-obj-proxy-revised.js new file mode 100644 index 000000000..38ee4e299 --- /dev/null +++ b/config/jest/identity-obj-proxy-revised.js @@ -0,0 +1,16 @@ +// Proxy for jest tests to convert css moodule class names. +// Based on https://medium.com/trabe/testing-css-modules-in-react-components-with-jest-enzyme-and-a-custom-modulenamemapper-8ff86c7d18a2 + +module.exports = new Proxy( + {}, + { + get: function getter(target, key) { + if (key === '__esModule') { + return false; + } + + // Convert camelCase to kebab-case for class selectors in unit tests. + return key.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`); + }, + } +); diff --git a/package.json b/package.json index da9d04fb5..d2dfdfa6c 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,6 @@ "html-webpack-plugin": "5.5.0", "husky": "7.0.4", "icomoon-react": "^3.0.0", - "identity-obj-proxy": "3.0.0", "install-peers": "1.0.3", "install-peers-cli": "2.2.0", "jest": "27.4.3", @@ -209,7 +208,7 @@ "modulePaths": [], "moduleNameMapper": { "^react-native$": "react-native-web", - "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy" + "^.+\\.module\\.(css|sass|scss)$": "/config/jest/identity-obj-proxy-revised.js" }, "moduleFileExtensions": [ "web.js", diff --git a/src/components/Dialog/Dialog.test.tsx b/src/components/Dialog/Dialog.test.tsx index fbc121003..c7ad0d0f5 100644 --- a/src/components/Dialog/Dialog.test.tsx +++ b/src/components/Dialog/Dialog.test.tsx @@ -63,10 +63,10 @@ describe('Dialog', () => { onCancel, onClose, }); - wrapper.find('.buttonPrimary').at(0).simulate('click'); - wrapper.find('.buttonDefault').at(0).simulate('click'); - wrapper.find('.buttonNeutral').at(0).simulate('click'); - wrapper.find('.dialogBackdrop').at(0).simulate('click'); + wrapper.find('.button-primary').at(0).simulate('click'); + wrapper.find('.button-default').at(0).simulate('click'); + wrapper.find('.button-neutral').at(0).simulate('click'); + wrapper.find('.dialog-backdrop').at(0).simulate('click'); expect(onOk).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1); @@ -76,7 +76,7 @@ describe('Dialog', () => { maskClosable: false, }); - wrapper.find('.dialogBackdrop').at(0).simulate('click'); + wrapper.find('.dialog-backdrop').at(0).simulate('click'); expect(onClose).toHaveBeenCalledTimes(2); }); }); diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index f304577d0..15b93ba57 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -53,8 +53,8 @@ describe('Modal', () => { body, onClose, }); - wrapper.find('.buttonNeutral').at(0).simulate('click'); - wrapper.find('.dialogBackdrop').at(0).simulate('click'); + wrapper.find('.button-neutral').at(0).simulate('click'); + wrapper.find('.dialog-backdrop').at(0).simulate('click'); expect(onClose).toHaveBeenCalledTimes(2); @@ -62,7 +62,7 @@ describe('Modal', () => { maskClosable: false, }); - wrapper.find('.dialogBackdrop').at(0).simulate('click'); + wrapper.find('.dialog-backdrop').at(0).simulate('click'); expect(onClose).toHaveBeenCalledTimes(2); }); }); diff --git a/src/components/Panel/Panel.test.tsx b/src/components/Panel/Panel.test.tsx index e80ece51b..9d08f12a5 100644 --- a/src/components/Panel/Panel.test.tsx +++ b/src/components/Panel/Panel.test.tsx @@ -54,15 +54,15 @@ describe('Panel', () => { visible: true, onClose, }); - wrapper.find('.panelBackdrop').at(0).simulate('click'); + wrapper.find('.panel-backdrop').at(0).simulate('click'); - wrapper.find('.buttonNeutral').at(0).simulate('click'); + wrapper.find('.button-neutral').at(0).simulate('click'); expect(onClose).toHaveBeenCalledTimes(2); wrapper.setProps({ maskClosable: false, }); - wrapper.find('.panelBackdrop').at(0).simulate('click'); + wrapper.find('.panel-backdrop').at(0).simulate('click'); expect(onClose).toHaveBeenCalledTimes(2); }); }); diff --git a/src/components/Slider/Slider.stories.tsx b/src/components/Slider/Slider.stories.tsx new file mode 100644 index 000000000..06c3e16d8 --- /dev/null +++ b/src/components/Slider/Slider.stories.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import { Slider } from './'; + +export default { + title: 'Slider', + component: Slider, +} as ComponentMeta; + +const Slider_Story: ComponentStory = (args) => ( + +); + +const sliderArgs: Object = { + ariaLabel: 'Slider', + autoFocus: false, + classNames: 'my-slider', + disabled: false, + id: 'mySliderId', + min: 100, + max: 200, + name: 'mySlider', + onChange: () => { + // handle change. + }, +}; + +export const StandardSlider = Slider_Story.bind({}); +StandardSlider.args = { + ...sliderArgs, + autoFocus: true, + min: 1, + max: 5, + showLabels: false, + value: 2, +}; + +export const RangeSlider = Slider_Story.bind({}); +RangeSlider.args = { + ...sliderArgs, + min: 0, + showLabels: true, + showMarkers: true, + step: 10, + value: [110, 150], +}; diff --git a/src/components/Slider/Slider.test.tsx b/src/components/Slider/Slider.test.tsx new file mode 100644 index 000000000..0a82a47f8 --- /dev/null +++ b/src/components/Slider/Slider.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import Enzyme, { mount, ReactWrapper } from 'enzyme'; +import MatchMediaMock from 'jest-matchmedia-mock'; + +import { Slider } from './'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +describe('Slider', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + afterEach(() => { + matchMedia.clear(); + }); + + test('Should render', () => { + wrapper = mount(); + expect(wrapper.children().length).toEqual(1); + }); + + test('should correctly display markers and indicate when they are active', () => { + let markers; + let activeMarkers; + + wrapper = mount( + + ); + markers = wrapper.find('.rail-marker'); + expect(markers.length).toEqual(19); + activeMarkers = wrapper.find('.rail-marker.active'); + expect(activeMarkers.length).toEqual(10); + + wrapper = mount( + + ); + markers = wrapper.find('.rail-marker'); + expect(markers.length).toEqual(3); + activeMarkers = wrapper.find('.rail-marker.active'); + expect(activeMarkers.length).toEqual(2); + }); + + test('should update values correctly', () => { + let val = 1; + wrapper = mount( + (val = newVal)} + /> + ); + + let thumb = wrapper.find('input[type="range"]').at(0); + expect(thumb.prop('value')).toEqual(1); + thumb.simulate('change', { target: { value: 3 } }); + expect(val).toEqual(3); + + let vals = [0, 10]; + wrapper = mount( + (vals = [...newVal])} + /> + ); + let thumb1 = wrapper.find('input[type="range"]').at(0); + let thumb2 = wrapper.find('input[type="range"]').at(1); + expect(thumb1.prop('value')).toEqual(0); + expect(thumb2.prop('value')).toEqual(10); + thumb1.simulate('change', { target: { value: 3 } }); + thumb2.simulate('change', { target: { value: 7 } }); + expect(vals[0]).toEqual(3); + expect(vals[1]).toEqual(7); + }); +}); diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 000000000..9a1641930 --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,251 @@ +import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { mergeClasses, visuallyHidden } from '../../shared/utilities'; +import { SliderMarker, SliderProps } from './Slider.types'; +import styles from './slider.module.scss'; + +const thumbDiameter: number = +styles.thumbDiameter; +const thumbRadius = thumbDiameter / 2; + +/** + * For use with Array.sort to sort numbers in ascending order. + * @param a The first number for sort comparison. + * @param b The vsecond number for sort comparison. + * @returns The difference between the numbers. + */ +function asc(a: number, b: number): number { + return a - b; +} + +/** + * Provides the percentage representation of the provide value + * based upon where it sits between the minimum and maximum value. + * + * @param value The value to convert to percentage. + * @param min Minimum possible value. + * @param max Maximum possible value. + * @returns The percentage presentation of the provided value. + */ +export function valueToPercent( + value: number, + min: number, + max: number +): number { + return ((value - min) * 100) / (max - min); +} + +export const Slider: FC = ({ + ariaLabel, + autoFocus = false, + classNames, + disabled = false, + id, + min = 0, + max = 100, + name, + onChange, + showLabels = true, + showMarkers = false, + step = 1, + value, +}) => { + const isRange: boolean = Array.isArray(value); + const [values, setValues] = useState( + Array.isArray(value) ? value.sort(asc) : [value] + ); + const railRef = useRef(null); + const lowerLabelRef = useRef(null); + const upperLabelRef = useRef(null); + const trackRef = useRef(null); + let [showMinLabel, setShowMinLabel] = useState(showLabels); + let [showMaxLabel, setShowMaxLabel] = useState(showLabels); + let [markers, setMarkers] = useState([]); + + const getIdentifier = (baseString: string, index: number): string => { + if (!baseString) { + return ''; + } + const idTokens: Array = [baseString]; + if (isRange) { + idTokens.push(index); + } + return idTokens.join('-'); + }; + + const isMarkerActive = (markerValue: number): boolean => { + const markerPct = valueToPercent(markerValue, min, max); + return isRange + ? markerPct >= valueToPercent(values[0], min, max) && + markerPct <= valueToPercent(values[1], min, max) + : markerPct <= valueToPercent(values[0], min, max); + }; + + const getValueOffset = (val: number): number => { + const inputWidth = railRef.current?.offsetWidth || 0; + return ( + ((val - min) / (max - min)) * (inputWidth - thumbDiameter) + + thumbRadius + ); + }; + + const handleChange = (newVal: number, index: number) => { + const newValues = [...values]; + newValues.splice(index, 1, newVal); + newValues.sort(asc); + setValues(newValues); + }; + + useEffect(() => { + onChange?.(isRange ? [...values] : values[0]); + }, [values]); + + // Set width of the range to decrease from the left side + useLayoutEffect(() => { + const inputWidth = railRef.current?.offsetWidth || 0; + + setMarkers( + !showMarkers + ? [] + : [...Array(Math.floor((max - min) / step) + 1)].map( + (_, index) => { + const markVal = min + step * index; + return { + value: markVal, + offset: `${getValueOffset(markVal)}px`, + }; + } + ) + ); + + // Early exit if there is no ref available yet. The DOM has yet to initialize + // and its not possible to calculate positions. + if (!railRef.current) { + return; + } + + const lowerThumbOffset = getValueOffset(values[0]); + const upperThumbOffset = getValueOffset(values[1]); + const rangeWidth = isRange + ? upperThumbOffset - lowerThumbOffset + : lowerThumbOffset; + + // Hide the min/max markers if the value labels would collide. + const maxCollisionLabelRef = isRange ? upperLabelRef : lowerLabelRef; + const maxCollisionThumbOffset = isRange + ? upperThumbOffset + : lowerThumbOffset; + setShowMaxLabel( + showLabels && + inputWidth - maxCollisionThumbOffset > + (maxCollisionLabelRef.current?.offsetWidth || 0) + 15 + ); + setShowMinLabel( + showLabels && + lowerThumbOffset > lowerLabelRef.current.offsetWidth + 15 + ); + + const lowerLabelOffset = lowerLabelRef.current.offsetWidth / 2; + lowerLabelRef.current.style.left = `${ + lowerThumbOffset - lowerLabelOffset + }px`; + + // upper Label/thumb is only used in range mode. + if (isRange) { + const upperLabelOffset = upperLabelRef.current.offsetWidth / 2; + upperLabelRef.current.style.left = `${ + upperThumbOffset - upperLabelOffset + }px`; + } + + trackRef.current.style.left = isRange ? `${lowerThumbOffset}px` : '0'; + trackRef.current.style.width = `${rangeWidth}px`; + }, values); + + return ( + // TODO: implement ResizeObserver to re-render the DOM on resize. +
+
+
+
+ {markers.map((mark, index) => { + // Hiding the first and last marker based on design. + const isFirstOrLast = + index === 0 || index === markers.length - 1; + const style = { left: mark.offset }; + return ( + !isFirstOrLast && ( +
+ ) + ); + })} + {values.map((val, index) => ( + + handleChange(+event.target.value, index) + } + min={min} + max={max} + name={getIdentifier(name, index)} + type="range" + step={step} + value={val} + /> + ))} +
+ <> +
+ {values[0]} +
+ {isRange && ( +
+ {values[1]} +
+ )} + +
+ {min} +
+
+ {max} +
+
+ ); +}; diff --git a/src/components/Slider/Slider.types.ts b/src/components/Slider/Slider.types.ts new file mode 100644 index 000000000..91cf869d8 --- /dev/null +++ b/src/components/Slider/Slider.types.ts @@ -0,0 +1,79 @@ +import { OcBaseProps } from '../OcBase'; + +export interface SliderMarker { + /** + * The step value of the marker. + */ + value: number; + /** + * The left offset position for the marker. ex: "96px" + */ + offset: string; +} + +export interface SliderProps extends SliderInputProps { + /** + * Indicates if the value should be displayed under the slider. + * @default true + */ + showLabels?: boolean; + + /** + * Indicates if steps/markers should be displayed on the slider track. + * NOTE: initial implementation will put a marker at each step. This could + * be extended to provide explicit markers to change the behavior.. + * @default false + */ + showMarkers?: boolean; +} + +export interface SliderInputProps + extends Omit, 'onChange' | 'value'> { + /** + * The input aria label text. + */ + ariaLabel?: string; + /** + * The input autoFocus attribute. + * @default false + */ + autoFocus?: boolean; + /** + * The input disabled state. + * @default false + */ + disabled?: boolean; + /** + * The input id. + * NOTE: For range sliders, each input's id will have an index value added. + */ + id?: string; + /** + * The maximum value of the slider. + * @default 100 + */ + max?: number; + /** + * The maximum value of the slider. + * @default 0 + */ + min?: number; + /** + * The input name. + * NOTE: For range sliders, each input's name will have an index value added. + */ + name?: string; + /** + * The input onChange event handler. + */ + onChange?: (value: number | number[]) => void; + /** + * Selected values must be a multiple of step. + * @default 1 + */ + step?: number; + /** + * The current slider value. Provide an array for range slider. + */ + value: number | number[]; +} diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts new file mode 100644 index 000000000..1bf2619f9 --- /dev/null +++ b/src/components/Slider/index.ts @@ -0,0 +1,2 @@ +export * from './Slider'; +export * from './Slider.types'; diff --git a/src/components/Slider/slider.module.scss b/src/components/Slider/slider.module.scss new file mode 100644 index 000000000..06ca5f3cb --- /dev/null +++ b/src/components/Slider/slider.module.scss @@ -0,0 +1,170 @@ +@function strip-units($number) { + @return $number / ($number * 0 + 1); +} + +// Represents the diameter of the thumb slider +// and the overall height of the slider. +$slider-height: 28px; +$label-height: 20px; +$track-height: 6px; +$vertical-center: $slider-height / 2; +$rail-top: $vertical-center - ($track-height / 2); + +$marker-height: 6px; +$marker-width: 6px; +$marker-top: $vertical-center - ($marker-height / 2); + +// Export these values for typescript consumption. +:export { + thumbDiameter: strip-units($slider-height); +} + +.slider-container { + --slider-default-track-color: var(--primary-color-60); + --slider-default-rail-color: var(--primary-color-10); + --slider-default-rail-border-color: var(--primary-color-60); + --slider-default-value-text-color: var(--text-secondary-color); + --slider-min-max-labels-color: var(--grey-color-60); + --slider-default-thumb-color: var(--primary-color); + --slider-default-marker-background: var(--white-color); + + position: relative; + height: $slider-height + $label-height; + + .slider { + position: relative; + height: $slider-height; + } + + .slider-rail, + .slider-track { + position: absolute; + top: $rail-top; + left: 0; + } + + .slider-rail, + .slider-track { + height: $track-height; + border-radius: 8px; + } + + .slider-rail { + background-color: var(--slider-default-rail-color); + border: 1px solid var(--slider-default-rail-border-color); + width: 100%; + } + + .rail-marker { + height: $marker-height; + width: $marker-width; + position: absolute; + top: $marker-top; + margin-left: calc(($marker-height / 2) * -1); + background-color: var(--slider-default-marker-background); + border: 1px solid var(--slider-default-rail-border-color); + + &.active { + background-color: var(--slider-default-marker-background); + } + } + + .slider-track { + background-color: var(--slider-default-track-color); + } + + .slider-value { + color: var(--slider-default-value-text-color); + font-size: 14px; + font-weight: 600; + position: absolute; + bottom: 0; + opacity: 0; + } + + .extremity-label { + color: var(--slider-min-max-labels-color); + font-size: 14px; + font-weight: normal; + position: absolute; + bottom: 0; + opacity: 0; + + &.min-label { + left: 2px; + } + + &.max-label { + right: 2px; + } + } + + .label-visible { + opacity: 1; + } + + // Removing the default appearance + .thumb, + .thumb::-webkit-slider-thumb { + -webkit-appearance: none; + -webkit-tap-highlight-color: transparent; + } + + .thumb { + display: block; + width: 100%; + pointer-events: none; + position: absolute; + top: $rail-top; + height: 0; + } + + .thumb { + &:focus::-webkit-slider-thumb { + // TODO: move this to a global mixin, update moz implementation below. + outline: 2px solid var(--primary-color-40); + outline-offset: 1px; + } + + // For Chrome browsers + &::-webkit-slider-thumb { + -webkit-appearance: none; + background-color: var(--slider-default-thumb-color); + border: none; + border-radius: 50%; + box-shadow: $shadow-object-s; + cursor: pointer; + height: $slider-height; + width: $slider-height; + margin-top: 4px; + pointer-events: all; + position: relative; + } + + // For Firefox browsers + &::-moz-range-thumb { + background-color: var(--slider-default-thumb-color); + border: none; + border-radius: 50%; + box-shadow: $shadow-object-s; + cursor: pointer; + height: $slider-height; + width: $slider-height; + margin-top: 4px; + pointer-events: all; + position: relative; + } + + &:focus::-moz-range-thumb { + // TODO: move this to a global mixin. + outline: 2px solid var(--primary-color-40); + outline-offset: 1px; + } + } +} + +.slider-disabled { + .slider { + opacity: 0.5; + } +} diff --git a/src/hooks/useEffectOnlyOnUpdate.tsx b/src/hooks/useEffectOnlyOnUpdate.tsx new file mode 100644 index 000000000..194724296 --- /dev/null +++ b/src/hooks/useEffectOnlyOnUpdate.tsx @@ -0,0 +1,21 @@ +import React, { useEffect, useRef } from 'react'; + +/** + * Hook that doesn't run on initial value setting, only on updates. + * @param callback function to be executed on dep update. + * @param dependencies list of deps which will trigger callback on update. + */ +export const useEffectOnlyOnUpdate = ( + callback: React.EffectCallback, + dependencies: React.DependencyList +) => { + const didMount = useRef(false); + + useEffect(() => { + if (didMount.current) { + callback(); + } else { + didMount.current = true; + } + }, [callback, dependencies]); +}; diff --git a/src/hooks/useFocusVisible.test.tsx b/src/hooks/useFocusVisible.test.tsx index 40943a8c8..fc3908041 100644 --- a/src/hooks/useFocusVisible.test.tsx +++ b/src/hooks/useFocusVisible.test.tsx @@ -16,27 +16,21 @@ describe('useFocusVisible', () => { it('returns true', () => { render(); - screen.debug(); fireEvent.keyDown(screen.getByTestId('testDiv'), { KeyboardEvent }); expect(screen.getByTestId('testDiv')).toHaveTextContent('true'); - screen.debug(); }); it('returns false after true', () => { render(); - screen.debug(); fireEvent.keyDown(screen.getByTestId('testDiv'), { KeyboardEvent }); expect(screen.getByTestId('testDiv')).toHaveTextContent('true'); fireEvent.mouseMove(screen.getByTestId('testDiv'), { MouseEvent }); expect(screen.getByTestId('testDiv')).toHaveTextContent('false'); - screen.debug(); }); it('returns false', () => { render(); - screen.debug(); fireEvent.mouseMove(screen.getByTestId('testDiv'), { MouseEvent }); expect(screen.getByTestId('testDiv')).toHaveTextContent('false'); - screen.debug(); }); }); diff --git a/src/hooks/useFocusVisibleClassName.test.tsx b/src/hooks/useFocusVisibleClassName.test.tsx index d996014c9..195ff3af3 100644 --- a/src/hooks/useFocusVisibleClassName.test.tsx +++ b/src/hooks/useFocusVisibleClassName.test.tsx @@ -20,27 +20,21 @@ describe('useFocusVisibleClassName', () => { it('uses className when keyboard is active', () => { render(); - screen.debug(); fireEvent.keyDown(screen.getByTestId('testDiv'), { KeyboardEvent }); expect(screen.getByTestId('testDiv')).toHaveClass('focus-visible'); - screen.debug(); }); it('uses className when keyboard is active, then does not when mouse is active', () => { render(); - screen.debug(); fireEvent.keyDown(screen.getByTestId('testDiv'), { KeyboardEvent }); expect(screen.getByTestId('testDiv')).toHaveClass('focus-visible'); fireEvent.mouseMove(screen.getByTestId('testDiv'), { MouseEvent }); expect(screen.getByTestId('testDiv')).not.toHaveClass('focus-visible'); - screen.debug(); }); it('does not use className when mouse is active', () => { render(); - screen.debug(); fireEvent.mouseMove(screen.getByTestId('testDiv'), { MouseEvent }); expect(screen.getByTestId('testDiv')).not.toHaveClass('focus-visible'); - screen.debug(); }); }); diff --git a/yarn.lock b/yarn.lock index 1245d81bd..bbdce59f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9153,11 +9153,6 @@ hard-rejection@^2.1.0: resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== -harmony-reflect@^1.4.6: - version "1.6.2" - resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710" - integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g== - has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -9639,13 +9634,6 @@ idb@^6.1.4: resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.5.tgz#dbc53e7adf1ac7c59f9b2bf56e00b4ea4fce8c7b" integrity sha512-IJtugpKkiVXQn5Y+LteyBCNk1N8xpGV3wWZk9EVtZWH8DYkjBn0bX1XnGP9RkyZF0sAcywa6unHqSWKe7q4LGw== -identity-obj-proxy@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" - integrity sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ= - dependencies: - harmony-reflect "^1.4.6" - ieee754@^1.1.4: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"