Skip to content

Commit

Permalink
Allow nested EuiAccordions (#2136)
Browse files Browse the repository at this point in the history
* transition services

* use transition services

* add doc example

* snapshot updates

* Update src/services/transition/transition.ts

Co-Authored-By: Chandler Prall <chandler.prall@gmail.com>

* clean up

* update regex; distinguish s and ms

* clean up

* CL
  • Loading branch information
thompsongl authored Jul 19, 2019
1 parent 5643b3c commit c5ceab9
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 46 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

- Added `EuiSuggestItem` component ([#2090](https://github.com/elastic/eui/pull/2090))
- Added support for negated or clauses to `EuiSearchBar` ([#2140](https://github.com/elastic/eui/pull/2140))
- Added `transition` utility services to help create timeouts that account for CSS transition durations and delays ([#2136](https://github.com/elastic/eui/pull/2136))

**Bug fixes**

- Fixed `EuiComboBox`'s padding on the right ([#2135](https://github.com/elastic/eui/pull/2135))
- Fixed `EuiAccordion` to correctly account for changing computed height of child elements ([#2136](https://github.com/elastic/eui/pull/2136))

**Breaking changes**

Expand Down
26 changes: 26 additions & 0 deletions src-docs/src/views/accordion/accordion_multiple.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,31 @@ export default () => (
<p>The content inside can be of any height.</p>
</EuiText>
</EuiAccordion>

<EuiSpacer />

<EuiAccordion
id="accordion3"
buttonContent="A third accordion with a nested accordion"
paddingSize="m">
<EuiText>
<p>
This content area will grow to accomodate when the accordion below
opens
</p>
</EuiText>
<EuiSpacer />
<EuiAccordion id="accordion4" buttonContent="A fourth nested accordion">
<EuiText>
<p>The content inside can be of any height.</p>
<p>The content inside can be of any height.</p>
<p>The content inside can be of any height.</p>
<p>The content inside can be of any height.</p>
<p>The content inside can be of any height.</p>
<p>The content inside can be of any height.</p>
</EuiText>
</EuiAccordion>
<EuiSpacer />
</EuiAccordion>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ exports[`EuiAccordion behavior closes when clicked twice 1`] = `
<EuiMutationObserver
observerOptions={
Object {
"attributeFilter": Array [
"style",
],
"childList": true,
"subtree": true,
}
Expand Down Expand Up @@ -187,6 +190,9 @@ exports[`EuiAccordion behavior opens when clicked once 1`] = `
<EuiMutationObserver
observerOptions={
Object {
"attributeFilter": Array [
"style",
],
"childList": true,
"subtree": true,
}
Expand Down
24 changes: 22 additions & 2 deletions src/components/accordion/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { CommonProps, keysOf } from '../common';
import { EuiIcon } from '../icon';
import { EuiFlexGroup, EuiFlexItem } from '../flex';
import { EuiMutationObserver } from '../observer/mutation_observer';
import { getDurationAndPerformOnFrame } from '../../services';

const MUTATION_ATTRIBUTE_FILTER = ['style'];

const paddingSizeToClassNameMap = {
none: null,
Expand Down Expand Up @@ -79,6 +82,19 @@ export class EuiAccordion extends Component<
});
};

onMutation = (records: MutationRecord[]) => {
const isChildStyleMutation = records.find((record: MutationRecord) => {
return record.attributeName
? MUTATION_ATTRIBUTE_FILTER.indexOf(record.attributeName) > -1
: false;
});
if (isChildStyleMutation) {
getDurationAndPerformOnFrame(records, this.setChildContentHeight);
} else {
this.setChildContentHeight();
}
};

componentDidMount() {
this.setChildContentHeight();
}
Expand Down Expand Up @@ -178,8 +194,12 @@ export class EuiAccordion extends Component<
}}
id={id}>
<EuiMutationObserver
observerOptions={{ childList: true, subtree: true }}
onMutation={this.setChildContentHeight}>
observerOptions={{
childList: true,
subtree: true,
attributeFilter: MUTATION_ATTRIBUTE_FILTER,
}}
onMutation={this.onMutation}>
{mutationRef => (
<div
ref={ref => {
Expand Down
52 changes: 8 additions & 44 deletions src/components/popover/popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import tabbable from 'tabbable';

import { cascadingMenuKeyCodes } from '../../services';
import {
cascadingMenuKeyCodes,
getTransitionTimings,
getWaitDuration,
performOnFrame,
} from '../../services';

import { EuiFocusTrap } from '../focus_trap';

Expand Down Expand Up @@ -84,8 +89,6 @@ const DEFAULT_POPOVER_STYLES = {
left: 50,
};

const GROUP_NUMERIC = /^([\d.]+)/;

function getElementFromInitialFocus(initialFocus) {
const initialFocusType = typeof initialFocus;
if (initialFocusType === 'string')
Expand All @@ -94,22 +97,6 @@ function getElementFromInitialFocus(initialFocus) {
return initialFocus;
}

function getTransitionTimings(element) {
const computedStyle = window.getComputedStyle(element);

const computedDuration = computedStyle.getPropertyValue(
'transition-duration'
);
let durationMatch = computedDuration.match(GROUP_NUMERIC);
durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0;

const computedDelay = computedStyle.getPropertyValue('transition-delay');
let delayMatch = computedDelay.match(GROUP_NUMERIC);
delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0;

return { durationMatch, delayMatch };
}

export class EuiPopover extends Component {
static getDerivedStateFromProps(nextProps, prevState) {
if (prevState.prevProps.isOpen && !nextProps.isOpen) {
Expand Down Expand Up @@ -278,33 +265,10 @@ export class EuiPopover extends Component {
}

onMutation = records => {
const waitDuration = records.reduce((waitDuration, record) => {
// only check for CSS transition values for ELEMENT nodes
if (record.target.nodeType === document.ELEMENT_NODE) {
const { durationMatch, delayMatch } = getTransitionTimings(
record.target
);
waitDuration = Math.max(waitDuration, durationMatch + delayMatch);
}

return waitDuration;
}, 0);
const waitDuration = getWaitDuration(records);
this.positionPopoverFixed();

if (waitDuration > 0) {
const startTime = Date.now();
const endTime = startTime + waitDuration;

const onFrame = () => {
this.positionPopoverFixed();

if (endTime > Date.now()) {
requestAnimationFrame(onFrame);
}
};

requestAnimationFrame(onFrame);
}
performOnFrame(waitDuration, this.positionPopoverFixed);
};

positionPopover = allowEnforcePosition => {
Expand Down
7 changes: 7 additions & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,11 @@ export {

export { calculatePopoverPosition, findPopoverPosition } from './popover';

export {
getDurationAndPerformOnFrame,
getTransitionTimings,
getWaitDuration,
performOnFrame,
} from './transition';

export { EuiWindowEvent } from './window_event';
6 changes: 6 additions & 0 deletions src/services/transition/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
getDurationAndPerformOnFrame,
getTransitionTimings,
getWaitDuration,
performOnFrame,
} from './transition';
71 changes: 71 additions & 0 deletions src/services/transition/transition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const GROUP_NUMERIC = /^([\d.]+)(s|ms)/;

function getMilliseconds(value: string, unit: string) {
// Given the regex match and capture groups, we can assume `unit` to be either 's' or 'ms'
const multiplier = unit === 's' ? 1000 : 1;
return parseFloat(value) * multiplier;
}
// Find CSS `transition-duration` and `transition-delay` intervals
// and return the value of each computed property in 'ms'
export const getTransitionTimings = (element: Element) => {
const computedStyle = window.getComputedStyle(element);

const computedDuration = computedStyle.getPropertyValue(
'transition-duration'
);
const durationMatchArray = computedDuration.match(GROUP_NUMERIC);
const durationMatch = durationMatchArray
? getMilliseconds(durationMatchArray[1], durationMatchArray[2])
: 0;

const computedDelay = computedStyle.getPropertyValue('transition-delay');
const delayMatchArray = computedDelay.match(GROUP_NUMERIC);
const delayMatch = delayMatchArray
? getMilliseconds(delayMatchArray[1], delayMatchArray[2])
: 0;

return { durationMatch, delayMatch };
};

function isElementNode(element: Node): element is Element {
return element.nodeType === document.ELEMENT_NODE;
}
// Uses `getTransitionTimings` to find the total transition time for
// all elements targeted by a MutationObserver callback
export const getWaitDuration = (records: MutationRecord[]) => {
return records.reduce((waitDuration, record) => {
// only check for CSS transition values for ELEMENT nodes
if (isElementNode(record.target)) {
const { durationMatch, delayMatch } = getTransitionTimings(record.target);
waitDuration = Math.max(waitDuration, durationMatch + delayMatch);
}

return waitDuration;
}, 0);
};

// Uses `requestAnimationFrame` to perform a given callback after a specified waiting period
export const performOnFrame = (waitDuration: number, toPerform: () => void) => {
if (waitDuration > 0) {
const startTime = Date.now();
const endTime = startTime + waitDuration;

const onFrame = () => {
toPerform();

if (endTime > Date.now()) {
requestAnimationFrame(onFrame);
}
};

requestAnimationFrame(onFrame);
}
};

// Convenience method for combining the result of 'getWaitDuration' directly with 'performOnFrame'
export const getDurationAndPerformOnFrame = (
records: MutationRecord[],
toPerform: () => void
) => {
performOnFrame(getWaitDuration(records), toPerform);
};

0 comments on commit c5ceab9

Please sign in to comment.