Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EuiDelayHide component #412

Merged
merged 7 commits into from
Feb 16, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ reports/
tmp/
dist/
lib/
.vscode/
.DS_Store
.eslintcache
.yo-rc.json
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# [`master`](https://github.com/elastic/eui/tree/master)

- Added `EuiDelayHide` component. [#412](https://github.com/elastic/eui/pull/412)
- Decreased overall size of checkbox, radio, and switches as well as better styles for the different states. ([#407](https://github.com/elastic/eui/pull/407))

**Bug fixes**
Expand All @@ -21,7 +22,7 @@
- Added importAction and exportAction icons ([#394](https://github.com/elastic/eui/pull/394))
- Added `EuiCard` for UI patterns that need an icon/image, title and description with some sort of action. ([#380](https://github.com/elastic/eui/pull/380))
- Add TypeScript definitions for the `<EuiHealth>` component. ([#403](https://github.com/elastic/eui/pull/403))
- Added `SearchBar` component - introduces a simple yet rich query language to search for objects + search box and filter controls to construct/manipulate it. ([#379](https://github.com/elastic/eui/pull/379))
- Added `SearchBar` component - introduces a simple yet rich query language to search for objects + search box and filter controls to construct/manipulate it. ([#379](https://github.com/elastic/eui/pull/379))

**Bug fixes**

Expand Down
4 changes: 4 additions & 0 deletions src-docs/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ import { ColorPickerExample }
import { ContextMenuExample }
from './views/context_menu/context_menu_example';

import { DelayHideExample }
from './views/delay_hide/delay_hide_example';

import { DescriptionListExample }
from './views/description_list/description_list_example';

Expand Down Expand Up @@ -226,6 +229,7 @@ const components = [
CodeExample,
ColorPickerExample,
ContextMenuExample,
DelayHideExample,
DescriptionListExample,
ErrorBoundaryExample,
ExpressionExample,
Expand Down
55 changes: 55 additions & 0 deletions src-docs/src/views/delay_hide/delay_hide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { Component, Fragment } from 'react';
import {
EuiDelayHide,
EuiFlexItem,
EuiCheckbox,
EuiFormRow,
EuiFieldNumber,
EuiLoadingSpinner
} from '../../../../src/components';

export default class extends Component {
state = {
minimumDuration: 3000,
hide: false
};

onChangeMinimumDuration = event => {
this.setState({ minimumDuration: parseInt(event.target.value, 10) });
};

onChangeHide = event => {
this.setState({ hide: event.target.checked });
};

render() {
return (
<Fragment>
<EuiFlexItem>
<EuiFormRow>
<EuiCheckbox
id="dummy-id"
checked={this.state.hide}
onChange={this.onChangeHide}
label="Hide child"
/>
</EuiFormRow>
<EuiFormRow label="Minimum duration">
<EuiFieldNumber
value={this.state.minimumDuration}
onChange={this.onChangeMinimumDuration}
/>
</EuiFormRow>

<EuiFormRow label="Child to render">
<EuiDelayHide
hide={this.state.hide}
minimumDuration={this.state.minimumDuration}
render={() => <EuiLoadingSpinner size="m"/>}
/>
</EuiFormRow>
</EuiFlexItem>
</Fragment>
);
}
}
38 changes: 38 additions & 0 deletions src-docs/src/views/delay_hide/delay_hide_example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import DelayHide from './delay_hide';
import { GuideSectionTypes } from '../../components';
import { EuiCode, EuiDelayHide } from '../../../../src/components';
import { renderToHtml } from '../../services';

const delayHideSource = require('!!raw-loader!./delay_hide');
const delayHideHtml = renderToHtml(DelayHide);

export const DelayHideExample = {
title: 'DelayHide',
sections: [
{
title: 'DelayHide',
source: [
{
type: GuideSectionTypes.JS,
code: delayHideSource
},
{
type: GuideSectionTypes.HTML,
code: delayHideHtml
}
],
text: (
<p>
<EuiCode>EuiDelayHide</EuiCode> is a component for conditionally toggling
the visibility of a child component. It will ensure that the child is
visible for at least 1000ms (default). This avoids UI glitches that
are common with loading spinners and other elements that are rendered
conditionally and potentially for a short amount of time.
</p>
),
props: { EuiDelayHide },
demo: <DelayHide />
}
]
};
63 changes: 63 additions & 0 deletions src/components/delay_hide/delay_hide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Component } from 'react';
import PropTypes from 'prop-types';

export class EuiDelayHide extends Component {
static propTypes = {
hide: PropTypes.bool,
minimumDuration: PropTypes.number,
render: PropTypes.func.isRequired
};

static defaultProps = {
hide: false,
minimumDuration: 1000
};

constructor(props) {
super(props);

this.state = {
hide: this.props.hide
};

this.lastRenderedTime = this.props.hide ? 0 : Date.now();
}

getTimeRemaining(minimumDuration) {
const visibleDuration = Date.now() - this.lastRenderedTime;
return minimumDuration - visibleDuration;
}

componentWillReceiveProps(nextProps) {
clearTimeout(this.timeout);
const timeRemaining = this.getTimeRemaining(nextProps.minimumDuration);

if (nextProps.hide && timeRemaining > 0) {
this.setStateDelayed(timeRemaining);
} else {
if (this.state.hide && !nextProps.hide) {
this.lastRenderedTime = Date.now();
}

this.setState({ hide: nextProps.hide });
}
}

setStateDelayed = timeRemaining => {
this.timeout = setTimeout(() => {
this.setState({ hide: true });
}, timeRemaining);
};

componentWillUnmount() {
clearTimeout(this.timeout);
}

render() {
if (this.state.hide) {
return null;
}

return this.props.render();
}
}
109 changes: 109 additions & 0 deletions src/components/delay_hide/delay_hide.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from 'react';
import { mount } from 'enzyme';
import { EuiDelayHide } from './index';

describe('when EuiDelayHide is visible initially', () => {
let wrapper;
beforeEach(() => {
jest.useFakeTimers();
wrapper = mount(
<EuiDelayHide
hide={false}
render={() => <div>Hello World</div>}
/>
);
});

test('it should be visible initially', async () => {
wrapper.setProps({ hide: true });
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should be visible after 900ms', () => {
wrapper.setProps({ hide: true });
jest.advanceTimersByTime(900);
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should be hidden after 1100ms', () => {
wrapper.setProps({ hide: true });
jest.advanceTimersByTime(1100);
expect(wrapper.html()).toEqual(null);
});

test('it should be visible after 1100ms regardless of prop changes in-between', () => {
wrapper.setProps({ hide: true });
wrapper.setProps({ hide: false });
jest.advanceTimersByTime(1100);
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should hide immediately after prop change, if it has been displayed for 1100ms', () => {
const currentTime = Date.now();
jest.advanceTimersByTime(1100);
jest.spyOn(Date, 'now').mockReturnValue(currentTime + 1100);
expect(wrapper.html()).toEqual('<div>Hello World</div>');

wrapper.setProps({ hide: true });
expect(wrapper.html()).toEqual(null);
});
});

describe('when EuiDelayHide is hidden initially', () => {
let wrapper;
beforeEach(() => {
jest.useFakeTimers();
wrapper = mount(
<EuiDelayHide hide={true} render={() => <div>Hello World</div>} />
);
});

test('it should be hidden initially', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be hidden initially? I think it should behave the same as setting hide to true, and should apply the delay. If the consumer really doesn't want it visible, they should just not render it at all.

Copy link
Member Author

@sorenlouv sorenlouv Feb 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should behave the same as setting hide to true, and should apply the delay

I think if the initial value is hide=true there is no reason to display it. The whole point of this component is to avoid "UI glitching". If an element is hidden from the start, it won't need the delay.
I think this is very different from a component changing visibility from visible to hidden (which is where we want the delay).

It would also add an extra burden on the consumer. As it is now I can render the global loading spinner with a prop from redux (isLoading is a reduced value combining all loading states in the entire app):

export default ({ isLoading }) => {
  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

If EuiDelayHide was to always render children on init I would have to keep a flag, to avoid rendering the loading spinner initially:

let isInitialLoad = true;
export default ({ isLoading }) => {
  if (!isLoading && isInitialLoad) {
    return null;
  }
  isInitialLoad = false;

  return (
    <EuiDelayHide
      hide={!isLoading}
      render={<EuiProgress size="xs" position="fixed" />}
    />
  );
};

I think we should wait a little with this but if it becomes a requested feature we can add a flag, eg leading delay (liked lodash.debounce has it).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should wait a little with this but if it becomes a request feature we can add a flag, eg leading delay (liked lodash.debounce has it).

Sounds good!

expect(wrapper.html()).toEqual(null);
});

test('it should become visible immediately after prop change', async () => {
wrapper.setProps({ hide: false });
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should be visible for at least 1100ms before hiding', async () => {
wrapper.setProps({ hide: false });
wrapper.setProps({ hide: true });
jest.advanceTimersByTime(900);

expect(wrapper.html()).toEqual('<div>Hello World</div>');

jest.advanceTimersByTime(200);
expect(wrapper.html()).toEqual(null);
});
});

describe('when EuiDelayHide is visible initially and has a minimumDuration of 2000ms ', () => {
let wrapper;
beforeEach(() => {
jest.useFakeTimers();
wrapper = mount(
<EuiDelayHide
hide={false}
minimumDuration={2000}
render={() => <div>Hello World</div>}
/>
);
wrapper.setProps({ hide: true });
});

test('it should be visible initially', async () => {
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should be visible after 1900ms', () => {
jest.advanceTimersByTime(1900);
expect(wrapper.html()).toEqual('<div>Hello World</div>');
});

test('it should be hidden after 2100ms', () => {
jest.advanceTimersByTime(2100);
expect(wrapper.html()).toEqual(null);
});
});
1 change: 1 addition & 0 deletions src/components/delay_hide/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { EuiDelayHide } from './delay_hide';
4 changes: 4 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export {
EuiContextMenuItem,
} from './context_menu';

export {
EuiDelayHide
} from './delay_hide';

export {
EuiDescriptionList,
EuiDescriptionListTitle,
Expand Down