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 useMemo util for htmlIdGenerator + update EuiDataGrid's IDs to not change on every rerender #5133

Merged
merged 16 commits into from
Sep 9, 2021
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [`master`](https://github.com/elastic/eui/tree/master)

- Added `useGeneratedHtmlId` utility, which memoizes the randomly generated ID on mount and prevents regenerated IDs on component rerender ([#5133](https://github.com/elastic/eui/pull/5133))

**Reverts**

- Reverted `EuiScreenReaderOnly` clip property ([#5150](https://github.com/elastic/eui/pull/5150))
Expand Down
9 changes: 6 additions & 3 deletions scripts/jest/setup/mocks.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
jest.mock('./../../../src/components/icon', () => {
const { EuiIcon } = require('./../../../src/components/icon/icon.testenv');
return { EuiIcon }
return { EuiIcon };
});

jest.mock('./../../../src/services/accessibility', () => {
const a11y = jest.requireActual('./../../../src/services/accessibility');
const { htmlIdGenerator } = require('./../../../src/services/accessibility/html_id_generator.testenv');
return { ...a11y, htmlIdGenerator }
const {
htmlIdGenerator,
useGeneratedHtmlId,
} = require('./../../../src/services/accessibility/html_id_generator.testenv');
return { ...a11y, htmlIdGenerator, useGeneratedHtmlId };
});
39 changes: 39 additions & 0 deletions src-docs/src/views/html_id_generator/html_id_generator_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import IdGenerator from './html_id_generator';
import { HtmlIdGeneratorPrefix } from './html_id_generator_prefix';
import { HtmlIdGeneratorSuffix } from './html_id_generator_suffix';
import { PrefixSufix } from './bothPrefixSuffix';
import { UseGeneratedHtmlId } from './use_generated_html_id';

const htmlIdGeneratorSource = require('!!raw-loader!./html_id_generator');
const htmlIdGeneratorHtml = renderToHtml(IdGenerator);
Expand All @@ -26,6 +27,11 @@ const PrefixSufixSource = require('!!raw-loader!./bothPrefixSuffix');
const PrefixSufixHtml = renderToHtml(PrefixSufix);
const prefixSuffixSnippet = " htmlIdGenerator('prefix')('suffix')";

const UseGeneratedHtmlIdSource = require('!!raw-loader!./use_generated_html_id');
const UseGeneratedHtmlIdHtml = renderToHtml(UseGeneratedHtmlId);
const useGeneratedHtmlIdSnippet =
"useGeneratedHtmlId({ prefix: 'Some', suffix: 'id', idFromProps: id })";

export const HtmlIdGeneratorExample = {
title: 'HTML ID generator',
sections: [
Expand Down Expand Up @@ -116,5 +122,38 @@ export const HtmlIdGeneratorExample = {
snippet: prefixSuffixSnippet,
demo: <PrefixSufix />,
},
{
title: 'Memoized hook for component use',
source: [
{
type: GuideSectionTypes.JS,
code: UseGeneratedHtmlIdSource,
},
{
type: GuideSectionTypes.HTML,
code: UseGeneratedHtmlIdHtml,
},
],
text: (
<>
<p>
<EuiCode>useGeneratedHtmlId</EuiCode> is a custom React hook that
automatically memoizes the randomly generated ID, preventing the ID
from regenerating on every component rerender. The ID will only
change if the component fully unmounts/mounts, or if you dynamically
pass in new hook arguments.
</p>
<p>
<EuiCode>useGeneratedHtmlId</EuiCode> optionally takes{' '}
<EuiCode>suffix</EuiCode> and <EuiCode>prefix</EuiCode> parameters
with the same behavior as above, as well as an{' '}
<EuiCode>idFromProps</EuiCode> option for components that allow
end-users to set their own custom IDs.
</p>
</>
),
snippet: useGeneratedHtmlIdSnippet,
demo: <UseGeneratedHtmlId />,
},
],
};
41 changes: 41 additions & 0 deletions src-docs/src/views/html_id_generator/use_generated_html_id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, { useState, Fragment } from 'react';

import {
EuiSwitch,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiCode,
EuiFormRow,
} from '../../../../src/components';

import { useGeneratedHtmlId } from '../../../../src/services';

export const UseGeneratedHtmlId = () => {
const generatedId = useGeneratedHtmlId({ prefix: 'Some', suffix: 'id' });

const [isChecked, setIsChecked] = useState(false);
const onChange = (e) => setIsChecked(e.target.checked);

return (
<Fragment>
<EuiFlexGroup
justifyContent="flexStart"
gutterSize="m"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiFormRow>
<EuiSwitch
label="Clicking me changes component state"
checked={isChecked}
onChange={onChange}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<EuiCode>{generatedId} </EuiCode>
</Fragment>
);
};
7 changes: 5 additions & 2 deletions src/components/collapsible_nav/collapsible_nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import React, {
} from 'react';
import classNames from 'classnames';
import {
htmlIdGenerator,
useGeneratedHtmlId,
isWithinMinBreakpoint,
throttle,
} from '../../services';
Expand Down Expand Up @@ -73,7 +73,10 @@ export const EuiCollapsibleNav: FunctionComponent<EuiCollapsibleNavProps> = ({
paddingSize = 'none',
...rest
}) => {
const [flyoutID] = useState(id || htmlIdGenerator()('euiCollapsibleNav'));
const flyoutID = useGeneratedHtmlId({
idFromProps: id,
suffix: 'euiCollapsibleNav',
});

/**
* Setting the initial state of pushed based on the `type` prop
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,10 @@
* Side Public License, v 1.
*/

import React, {
FunctionComponent,
ReactNode,
useState,
HTMLAttributes,
} from 'react';
import React, { FunctionComponent, ReactNode, HTMLAttributes } from 'react';
import classNames from 'classnames';
import { CommonProps, ExclusiveUnion } from '../../common';
import { htmlIdGenerator } from '../../../services';
import { useGeneratedHtmlId } from '../../../services';

import { EuiAccordion, EuiAccordionProps } from '../../accordion';
import { EuiIcon, IconType, IconSize, EuiIconProps } from '../../icon';
Expand Down Expand Up @@ -116,7 +111,7 @@ export const EuiCollapsibleNavGroup: FunctionComponent<EuiCollapsibleNavGroupPro
iconProps,
...rest
}) => {
const [groupID] = useState(id || htmlIdGenerator()());
const groupID = useGeneratedHtmlId({ idFromProps: id });
const titleID = `${groupID}__title`;

const classes = classNames(
Expand Down
4 changes: 2 additions & 2 deletions src/components/datagrid/body/header/data_grid_header_cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import React, {
} from 'react';
import tabbable from 'tabbable';
import { keys } from '../../../../services';
import { htmlIdGenerator } from '../../../../services/accessibility';
import { useGeneratedHtmlId } from '../../../../services/accessibility';
import { EuiScreenReaderOnly } from '../../../accessibility';
import { useEuiI18n } from '../../../i18n';
import { EuiIcon } from '../../../icon';
Expand Down Expand Up @@ -59,7 +59,7 @@ export const EuiDataGridHeaderCell: FunctionComponent<EuiDataGridHeaderCellProps
'aria-describedby'?: AriaAttributes['aria-describedby'];
} = {};

const screenReaderId = htmlIdGenerator()();
const screenReaderId = useGeneratedHtmlId();
let sortString;
const actionButtonAriaLabel = useEuiI18n(
'euiDataGridHeaderCell.headerActions',
Expand Down
9 changes: 4 additions & 5 deletions src/components/datagrid/data_grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import React, {
useState,
} from 'react';
import tabbable from 'tabbable';
import { htmlIdGenerator, keys } from '../../services';
import { useGeneratedHtmlId, keys } from '../../services';
import { EuiFocusTrap } from '../focus_trap';
import { EuiI18n, useEuiI18n } from '../i18n';
import { useResizeObserver } from '../observer/resize_observer';
Expand Down Expand Up @@ -496,7 +496,7 @@ export const EuiDataGrid: FunctionComponent<EuiDataGridProps> = (props) => {
const [isFullScreen, setIsFullScreen] = useState(false);
const [gridWidth, setGridWidth] = useState(0);

const [interactiveCellId] = useState(htmlIdGenerator()());
const interactiveCellId = useGeneratedHtmlId();
const [headerIsInteractive, setHeaderIsInteractive] = useState(false);

const cellsUpdateFocus = useRef<Map<string, Function>>(new Map());
Expand Down Expand Up @@ -738,9 +738,8 @@ export const EuiDataGrid: FunctionComponent<EuiDataGridProps> = (props) => {
};
}, [setFocusedCell, onFocusUpdate]);

const gridIds = htmlIdGenerator();
const gridId = gridIds();
const ariaLabelledById = gridIds();
const gridId = useGeneratedHtmlId();
const ariaLabelledById = useGeneratedHtmlId();

const ariaLabel = useEuiI18n(
'euiDataGrid.ariaLabel',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
* Side Public License, v 1.
*/

import { htmlIdGenerator } from './html_id_generator';
import React from 'react';
import { shallow } from 'enzyme';

import { htmlIdGenerator, useGeneratedHtmlId } from './html_id_generator';

jest.mock('./html_id_generator', () => {
return jest.requireActual('./html_id_generator');
Expand Down Expand Up @@ -51,3 +54,43 @@ describe('htmlIdGenerator', () => {
expect(generator()).not.toBe(generator());
});
});

describe('useGeneratedHtmlId', () => {
it('does not change when a component updates', () => {
const MockComponent: React.FC = (props) => (
<div id={useGeneratedHtmlId()} {...props} />
);
const component = shallow(<MockComponent />);
const initialId = component.find('div').prop('id');

component.setProps({ className: 'test' });
const rerenderedId = component.find('div').prop('id');

expect(initialId).toEqual(rerenderedId);
});

it('passes prefixes and suffixes to htmlIdGenerator', () => {
const MockComponent: React.FC = () => (
<div id={useGeneratedHtmlId({ prefix: 'hello', suffix: 'world' })} />
);
const component = shallow(<MockComponent />);
const id = component.find('div').prop('id');

expect(id!.startsWith('hello')).toBeTruthy();
expect(id!.endsWith('world')).toBeTruthy();
});

it('allows overriding generated IDs with custom IDs from props', () => {
const MockComponent: React.FC<{ id?: string }> = ({ id, ...props }) => (
<div id={useGeneratedHtmlId({ idFromProps: id })} {...props} />
);
const component = shallow(<MockComponent id="hello" />);
expect(component.find('div').prop('id')).toEqual('hello');

component.setProps({ id: 'world' });
expect(component.find('div').prop('id')).toEqual('world');

component.setProps({ id: undefined });
expect(component.find('div').prop('id')).toBeTruthy(); // Should fall back to a generated ID
});
});
13 changes: 13 additions & 0 deletions src/services/accessibility/html_id_generator.testenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,16 @@ export function htmlIdGenerator(idPrefix: string = '') {
return `${prefix}${staticUuid}${suffix}`;
};
}

export const useGeneratedHtmlId = ({
prefix,
suffix,
idFromProps,
}: {
prefix?: string;
suffix?: string;
idFromProps?: string;
} = {}) => {
// Skip useMemo in test environments - it's not necessary since the uuid is static/mocked
return idFromProps || htmlIdGenerator(prefix)(suffix);
};
20 changes: 20 additions & 0 deletions src/services/accessibility/html_id_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { v1 as uuidv1 } from 'uuid';
import { useMemo } from 'react';

/**
* This function returns a function to generate ids.
Expand All @@ -23,3 +24,22 @@ export function htmlIdGenerator(idPrefix: string = '') {
return `${prefix}${suffix ? staticUuid : uuidv1()}${suffix}`;
};
}

/**
* Generates an ID within a static ref object that remains static
* until the component using it is unmounted. This prevents IDs from
* being re-randomized on every component update.
*/
export const useGeneratedHtmlId = ({
idFromProps,
prefix,
suffix,
}: {
idFromProps?: string;
prefix?: string;
suffix?: string;
} = {}) => {
return useMemo<string>(() => {
return idFromProps || htmlIdGenerator(prefix)(suffix);
}, [idFromProps, prefix, suffix]);
};
2 changes: 1 addition & 1 deletion src/services/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
export { accessibleClickKeys } from './accessible_click_keys';
export { cascadingMenuKeys } from './cascading_menu_keys';
export { comboBoxKeys } from './combo_box_keys';
export { htmlIdGenerator } from './html_id_generator';
export { htmlIdGenerator, useGeneratedHtmlId } from './html_id_generator';
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
cascadingMenuKeys,
comboBoxKeys,
htmlIdGenerator,
useGeneratedHtmlId,
} from './accessibility';

export {
Expand Down