diff --git a/CHANGELOG.md b/CHANGELOG.md index 336a3fb016b..59d3f902213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ - Added `onColumnResize` prop to `EuiDataGrid` of type `EuiDataGridOnColumnResizeHandler` that gets called when column changes it's size ([#2963](https://github.com/elastic/eui/pull/2963)) - Added RGB format support to `EuiColorPicker` and `EuiColorStops` ([#2850](https://github.com/elastic/eui/pull/2850)) - Added alpha channel (opacity) support to `EuiColorPicker` and `EuiColorStops` ([#2850](https://github.com/elastic/eui/pull/2850)) +- Added `useResizeObserver` hook ([#2991](https://github.com/elastic/eui/pull/2991)) **Bug Fixes** - Fixed `EuiFieldNumber` so values of type `number` are now allowed ([#3020](https://github.com/elastic/eui/pull/3020)) - Fixed SASS `contrastRatio()` function in dark mode by fixing the `pow()` math function ([#3013], (https://github.com/elastic/eui/pull/3013)) +- Fixed bug preventing `EuiDataGrid` from re-evaluating the default column width on resize ([#2991](https://github.com/elastic/eui/pull/2991)) ## [`21.0.0`](https://github.com/elastic/eui/tree/v21.0.0) diff --git a/src-docs/src/views/resize_observer/resize_observer_example.js b/src-docs/src/views/resize_observer/resize_observer_example.js index 4ffe9a4b894..d0810ba74a7 100644 --- a/src-docs/src/views/resize_observer/resize_observer_example.js +++ b/src-docs/src/views/resize_observer/resize_observer_example.js @@ -4,16 +4,16 @@ import { renderToHtml } from '../../services'; import { GuideSectionTypes } from '../../components'; -import { - EuiCode, - EuiLink, - EuiResizeObserver, -} from '../../../../src/components'; +import { EuiCode, EuiLink } from '../../../../src/components'; import { ResizeObserverExample as ResizeObserver } from './resize_observer'; const resizeObserverSource = require('!!raw-loader!./resize_observer'); const resizeObserverHtml = renderToHtml(ResizeObserver); +import { ResizeObserverHookExample as ResizeObserverHook } from './resize_observer_hook'; +const resizeObserverHookSource = require('!!raw-loader!./resize_observer_hook'); +const resizeObserverHookHtml = renderToHtml(ResizeObserverHook); + export const ResizeObserverExample = { title: 'ResizeObserver', sections: [ @@ -49,16 +49,37 @@ export const ResizeObserverExample = { callback which you must put on the element you wish to observe.

- Due to limited browser support (currently supported in Chrome and - Opera), EuiResizeObserver will fallback to using - the MutationObserver API with a default set of + Due to limited browser support (currently not in Safari and IE11),{' '} + EuiResizeObserver will fallback to using the{' '} + MutationObserver API with a default set of parameters that approximate the results of{' '} MutationObserver.

), - components: { EuiResizeObserver }, demo: , }, + { + title: 'useResizeObserver hook', + source: [ + { + type: GuideSectionTypes.JS, + code: resizeObserverHookSource, + }, + { + type: GuideSectionTypes.HTML, + code: resizeObserverHookHtml, + }, + ], + text: ( + +

+ There is also a React hook, useResizeObserver, + which provides the same observation functionality. +

+
+ ), + demo: , + }, ], }; diff --git a/src-docs/src/views/resize_observer/resize_observer_hook.js b/src-docs/src/views/resize_observer/resize_observer_hook.js new file mode 100644 index 00000000000..da94ad3b4d0 --- /dev/null +++ b/src-docs/src/views/resize_observer/resize_observer_hook.js @@ -0,0 +1,72 @@ +import React, { useRef, useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCode, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, + useResizeObserver, +} from '../../../../src/components'; + +export const ResizeObserverHookExample = () => { + const hasResizeObserver = typeof ResizeObserver !== 'undefined'; + const [paddingSize, setPaddingSize] = useState('s'); + const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']); + + const togglePaddingSize = () => { + setPaddingSize(paddingSize => (paddingSize === 's' ? 'l' : 's')); + }; + + const addItem = () => { + setItems(items => [...items, `Item ${items.length + 1}`]); + }; + + const resizeRef = useRef(); + const dimensions = useResizeObserver(resizeRef.current); + + return ( +
+ + {hasResizeObserver ? ( +

+ Browser + supports ResizeObserver API. +

+ ) : ( +

+ Browser does + not support ResizeObserver API. Using MutationObserver. +

+ )} +

+ {`height: ${dimensions.height}; width: ${ + dimensions.width + }`} +

+
+ + + + + Toggle container padding + + + + +
+ +
    + {items.map(item => ( +
  • {item}
  • + ))} +
+ + add item +
+
+
+ ); +}; diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index cfa59333bb9..3dcf3d117f4 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -56,6 +56,7 @@ import { import { useColumnSorting } from './column_sorting'; import { EuiMutationObserver } from '../observer/mutation_observer'; import { DataGridContext } from './data_grid_context'; +import { useResizeObserver } from '../observer/resize_observer/resize_observer'; // When below this number the grid only shows the full screen button const MINIMUM_WIDTH_FOR_GRID_CONTROLS = 479; @@ -230,40 +231,40 @@ function useDefaultColumnWidth( trailingControlColumns: EuiDataGridProps['leadingControlColumns'] = [], columns: EuiDataGridProps['columns'] ): number | null { + const containerSize = useResizeObserver(container); + const [defaultColumnWidth, setDefaultColumnWidth] = useState( null ); useEffect(() => { - if (container != null) { - const gridWidth = container.clientWidth; - - const controlColumnWidths = [ - ...leadingControlColumns, - ...trailingControlColumns, - ].reduce( - (claimedWidth, controlColumn: EuiDataGridControlColumn) => - claimedWidth + controlColumn.width, - 0 - ); + const gridWidth = containerSize.width; + + const controlColumnWidths = [ + ...leadingControlColumns, + ...trailingControlColumns, + ].reduce( + (claimedWidth, controlColumn: EuiDataGridControlColumn) => + claimedWidth + controlColumn.width, + 0 + ); - const columnsWithWidths = columns.filter< - EuiDataGridColumn & { initialWidth: number } - >(doesColumnHaveAnInitialWidth); + const columnsWithWidths = columns.filter< + EuiDataGridColumn & { initialWidth: number } + >(doesColumnHaveAnInitialWidth); - const definedColumnsWidth = columnsWithWidths.reduce( - (claimedWidth, column) => claimedWidth + column.initialWidth, - 0 - ); + const definedColumnsWidth = columnsWithWidths.reduce( + (claimedWidth, column) => claimedWidth + column.initialWidth, + 0 + ); - const claimedWidth = controlColumnWidths + definedColumnsWidth; + const claimedWidth = controlColumnWidths + definedColumnsWidth; - const widthToFill = gridWidth - claimedWidth; - const unsizedColumnCount = columns.length - columnsWithWidths.length; - const columnWidth = Math.max(widthToFill / unsizedColumnCount, 100); - setDefaultColumnWidth(columnWidth); - } - }, [container, columns, leadingControlColumns, trailingControlColumns]); + const widthToFill = gridWidth - claimedWidth; + const unsizedColumnCount = columns.length - columnsWithWidths.length; + const columnWidth = Math.max(widthToFill / unsizedColumnCount, 100); + setDefaultColumnWidth(columnWidth); + }, [containerSize, columns, leadingControlColumns, trailingControlColumns]); return defaultColumnWidth; } diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx index a36b4e4a4d2..a25db3c3259 100644 --- a/src/components/datagrid/data_grid_cell.tsx +++ b/src/components/datagrid/data_grid_cell.tsx @@ -150,6 +150,7 @@ export class EuiDataGridCell extends Component< if (nextProps.visibleRowIndex !== this.props.visibleRowIndex) return true; if (nextProps.colIndex !== this.props.colIndex) return true; if (nextProps.columnId !== this.props.columnId) return true; + if (nextProps.columnType !== this.props.columnType) return true; if (nextProps.width !== this.props.width) return true; if (nextProps.renderCellValue !== this.props.renderCellValue) return true; if (nextProps.onCellFocus !== this.props.onCellFocus) return true; diff --git a/src/components/index.js b/src/components/index.js index 78214b35586..fab472fa1f1 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -239,7 +239,10 @@ export { EuiProgress } from './progress'; export { EuiTreeView } from './tree_view'; -export { EuiResizeObserver } from './observer/resize_observer'; +export { + EuiResizeObserver, + useResizeObserver, +} from './observer/resize_observer'; export { EuiSearchBar, Query, Ast } from './search_bar'; diff --git a/src/components/observer/observer.ts b/src/components/observer/observer.ts index 07e3caa7784..ae8c9b64e53 100644 --- a/src/components/observer/observer.ts +++ b/src/components/observer/observer.ts @@ -4,7 +4,7 @@ interface BaseProps { children: (ref: any) => ReactNode; } -interface Observer { +export interface Observer { disconnect: () => void; observe: (element: Element, options?: { [key: string]: any }) => void; } diff --git a/src/components/observer/resize_observer/index.ts b/src/components/observer/resize_observer/index.ts index 13179582c9a..fcc6d0459c9 100644 --- a/src/components/observer/resize_observer/index.ts +++ b/src/components/observer/resize_observer/index.ts @@ -1 +1 @@ -export { EuiResizeObserver } from './resize_observer'; +export { EuiResizeObserver, useResizeObserver } from './resize_observer'; diff --git a/src/components/observer/resize_observer/resize_observer.test.tsx b/src/components/observer/resize_observer/resize_observer.test.tsx index 5866b7bd41f..f1c68b5d8ac 100644 --- a/src/components/observer/resize_observer/resize_observer.test.tsx +++ b/src/components/observer/resize_observer/resize_observer.test.tsx @@ -1,7 +1,8 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { mount } from 'enzyme'; -import { EuiResizeObserver } from './resize_observer'; +import { EuiResizeObserver, useResizeObserver } from './resize_observer'; import { sleep } from '../../../test'; +import { act } from 'react-dom/test-utils'; export async function waitforResizeObserver(period = 30) { // `period` defaults to 30 because its the delay used by the ResizeObserver polyfill @@ -10,7 +11,7 @@ export async function waitforResizeObserver(period = 30) { describe('EuiResizeObserver', () => { it('watches for a resize', async () => { - expect.assertions(1); + expect.assertions(2); const onResize = jest.fn(); const Wrapper: FunctionComponent<{}> = ({ children }) => { @@ -25,6 +26,10 @@ describe('EuiResizeObserver', () => { const component = mount(Hello World} />); + // Resize observer is expected to fire once on mount + await waitforResizeObserver(); + expect(onResize).toHaveBeenCalledTimes(1); + component.setProps({ children: (
@@ -40,3 +45,52 @@ describe('EuiResizeObserver', () => { expect(onResize).toHaveBeenCalledTimes(2); }); }); + +type GetBoundingClientRect = typeof HTMLElement['prototype']['getBoundingClientRect']; +describe('useResizeObserver', () => { + let _originalgetBoundingClientRect: undefined | GetBoundingClientRect; + beforeAll(() => { + _originalgetBoundingClientRect = + HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function() { + // use the length of the element's HTML to represent its height + // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion + return { width: 100, height: this.innerHTML.length } as ReturnType< + GetBoundingClientRect + >; + }; + }); + afterAll(() => { + HTMLElement.prototype.getBoundingClientRect = _originalgetBoundingClientRect!; + }); + + it('watches for a resize', async () => { + expect.assertions(2); + + const Wrapper: FunctionComponent<{}> = jest.fn(({ children }) => { + const [ref, setRef] = useState(); + useResizeObserver(ref); + return
{children}
; + }); + + const component = mount(Hello World
} />); + + // Expect the initial render, re-render when the ref is created, and a 3rd for the onresize callback + await act(() => waitforResizeObserver()); + expect(Wrapper).toHaveBeenCalledTimes(3); + + component.setProps({ + children: ( +
+
Hello World
+
Hello Again
+
+ ), + }); + + await waitforResizeObserver(); + + // Expect two more calls because children changed (re-render) & resize observer reacted + expect(Wrapper).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/components/observer/resize_observer/resize_observer.tsx b/src/components/observer/resize_observer/resize_observer.tsx index 73aca10c58a..ddaba07adcc 100644 --- a/src/components/observer/resize_observer/resize_observer.tsx +++ b/src/components/observer/resize_observer/resize_observer.tsx @@ -1,18 +1,26 @@ -import { ReactNode } from 'react'; - -import { EuiObserver } from '../observer'; +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { EuiObserver, Observer } from '../observer'; interface Props { children: (ref: (e: HTMLElement | null) => void) => ReactNode; onResize: (dimensions: { height: number; width: number }) => void; } +// IE11 and Safari don't support the `ResizeObserver` API at the time of writing +const hasResizeObserver = + typeof window !== 'undefined' && typeof window.ResizeObserver !== 'undefined'; + +const mutationObserverOptions = { + // [MutationObserverInit](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit) + attributes: true, // Account for style changes from `className` or `style` + characterData: true, // Account for text content size differences + childList: true, // Account for adding/removing child nodes + subtree: true, // Account for deep child nodes +}; + export class EuiResizeObserver extends EuiObserver { name = 'EuiResizeObserver'; - // Only Chrome and Opera support the `ResizeObserver` API at the time of writing - hasResizeObserver = typeof window.ResizeObserver !== 'undefined'; - onResize = () => { if (this.childNode != null) { // Eventually use `clientRect` on the `entries[]` returned natively @@ -25,23 +33,65 @@ export class EuiResizeObserver extends EuiObserver { }; beginObserve = () => { - let observerOptions; - if (this.hasResizeObserver) { - this.observer = new window.ResizeObserver(this.onResize); - } else { - // MutationObserver fallback - observerOptions = { - // [MutationObserverInit](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit) - attributes: true, // Account for style changes from `className` or `style` - characterData: true, // Account for text content size differences - childList: true, // Account for adding/removing child nodes - subtree: true, // Account for deep child nodes - }; - this.observer = new MutationObserver(this.onResize); - requestAnimationFrame(this.onResize); // Mimic ResizeObserver behavior of triggering a resize event on init - } // The superclass checks that childNode is not null before invoking // beginObserve() - this.observer.observe(this.childNode!, observerOptions); + const childNode = this.childNode!; + this.observer = makeResizeObserver(childNode, this.onResize); }; } + +const makeResizeObserver = (node: Element, callback: () => void) => { + let observer: Observer | undefined; + if (hasResizeObserver) { + observer = new window.ResizeObserver(callback); + observer.observe(node); + } else { + observer = new MutationObserver(callback); + observer.observe(node, mutationObserverOptions); + requestAnimationFrame(callback); // Mimic ResizeObserver behavior of triggering a resize event on init + } + return observer; +}; + +export const useResizeObserver = (container: Element | null) => { + const [size, _setSize] = useState({ width: 0, height: 0 }); + + // _currentDimensions and _setSize are used to only store the + // new state (and trigger a re-render) when the new dimensions actually differ + const _currentDimensions = useRef(size); + const setSize = useCallback(dimensions => { + if ( + _currentDimensions.current.width !== dimensions.width || + _currentDimensions.current.height !== dimensions.height + ) { + _currentDimensions.current = dimensions; + _setSize(dimensions); + } + }, []); + + useEffect(() => { + if (container != null) { + // ResizeObserver's first call to the observation callback is scheduled in the future + // so find the container's initial dimensions now + const boundingRect = container.getBoundingClientRect(); + setSize({ + width: boundingRect.width, + height: boundingRect.height, + }); + + const observer = makeResizeObserver(container, () => { + const boundingRect = container.getBoundingClientRect(); + setSize({ + width: boundingRect.width, + height: boundingRect.height, + }); + }); + + return () => observer.disconnect(); + } else { + setSize({ width: 0, height: 0 }); + } + }, [container, setSize]); + + return size; +};