Skip to content

Commit

Permalink
Adding MainContainer, ActiveMainContext, and related files (#195)
Browse files Browse the repository at this point in the history
* Introducing SkipToLinks to the ApplicationContainer.

* Adding wdio tests

* Adding CHANGELOG entry

* Adding newlines

* Copying files from integration branch

* Updating implementation to be more straightforward.

* Final API updates. Adding tests.

* Adding node version range

* Working around flaky tests

* Removing unnecessary default

* Adding comment
  • Loading branch information
tbiethman committed Apr 26, 2021
1 parent 016ec34 commit 52332e9
Show file tree
Hide file tree
Showing 87 changed files with 792 additions and 77 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -10,6 +10,7 @@
* Adding SessionContext
* ApplicationBase updates
* Adding SkipToLink
* Adding MainContainer

## Unreleased

Expand Down
3 changes: 1 addition & 2 deletions package.json
Expand Up @@ -7,7 +7,7 @@
"version": "1.35.0",
"description": "A framework to support application development with Terra components",
"engines": {
"node": ">=10"
"node": ">=10 <13"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -104,7 +104,6 @@
"terra-slide-panel-manager": "^5.19.0",
"terra-status-view": "^4.10.0",
"terra-theme-context": "^1.0.0",
"terra-theme-provider": "^4.0.0",
"terra-toolbar": "^1.20.0",
"terra-visually-hidden-text": "^2.30.0",
"uuid": "^3.0.0",
Expand Down
9 changes: 6 additions & 3 deletions src/application-container/ApplicationContainer.jsx
Expand Up @@ -4,6 +4,7 @@ import classNames from 'classnames/bind';

import { NavigationPromptCheckpoint } from '../navigation-prompt';
import WindowManager from '../utils/window-manager';
import ActiveMainProvider from '../main-container/private/ActiveMainProvider';

import ApplicationContainerErrorBoundary from './private/ApplicationContainerErrorBoundary';
import useSkipToLinks from './private/skip-to-links/useSkipToLinks';
Expand Down Expand Up @@ -63,9 +64,11 @@ const ApplicationContainer = ({
<div className={cx('application-container')} data-testid="application-container">
<SkipToLinks />
<SkipToLinksProvider>
<ApplicationContainerErrorBoundary>
{children}
</ApplicationContainerErrorBoundary>
<ActiveMainProvider>
<ApplicationContainerErrorBoundary>
{children}
</ApplicationContainerErrorBoundary>
</ActiveMainProvider>
</SkipToLinksProvider>
</div>
</NavigationPromptCheckpoint>
Expand Down
2 changes: 0 additions & 2 deletions src/application-container/index.js
@@ -1,5 +1,4 @@
import ApplicationContainer from './ApplicationContainer';
import useActiveMainPage from './useActiveMainPage';
import ApplicationContainerContext, {
useApplicationContainer,
contextShape as applicationContainerContextShape,
Expand All @@ -11,7 +10,6 @@ import ApplicationConceptContext, {

export default ApplicationContainer;
export {
useActiveMainPage,
ApplicationContainerContext,
useApplicationContainer,
applicationContainerContextShape,
Expand Down

This file was deleted.

11 changes: 0 additions & 11 deletions src/application-container/useActiveMainPage.jsx

This file was deleted.

28 changes: 28 additions & 0 deletions src/main-container/ActiveMainContext.jsx
@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';

/**
* The ActiveMainContext is used to communicate data related to the current
* active main content to the application.
*/
const ActiveMainContext = React.createContext({});

/**
* Hook to simplify consumption of the ActiveMainContext.
* @returns The ActiveMainContext value found at the consuming render location.
*/
const useActiveMain = () => React.useContext(ActiveMainContext);

const contextShape = {
/**
* The string label describing the active main content to be used for display purposes.
*/
label: PropTypes.string,
/**
* A collection of data related to the active main content.
*/
metaData: PropTypes.object,
};

export default ActiveMainContext;
export { useActiveMain, contextShape };
105 changes: 105 additions & 0 deletions src/main-container/MainContainer.jsx
@@ -0,0 +1,105 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import classNamesBind from 'classnames/bind';

import { ApplicationIntlContext } from '../application-intl';
import SkipToLink from '../application-container/private/skip-to-links/SkipToLink';
import NavigationItemContext from '../navigation-item/NavigationItemContext';

import ActiveMainRegistrationContext from './private/ActiveMainRegistrationContext';

import styles from './MainContainer.module.scss';

const cx = classNamesBind.bind(styles);

const propTypes = {
/**
* The elements to render within the main element.
*/
children: PropTypes.node,
/**
* A string label describing the content within the main element. This value
* will be applied to the element as a user-facing aria-label and should be
* translated, if necessary. It will also be provided to consumers of the
* ActiveMainContext when this element is active.
*/
label: PropTypes.string.isRequired,
/**
* An object containing meta data related to the main element. This data is
* provided to consumers of the ActiveMainContext to provide additional
* information regarding the active main content.
*/
metaData: PropTypes.object,
/**
* A function to be called when a ref has been assigned for the created
* `<main>` element.
*/
refCallback: PropTypes.func,
};

/**
* The MainContainer can be used to create a semantic `<main>` element for the
* application, within which the application's most important and dynamic
* content will reside. A SkipToLink will be registered automatically to ensure
* this content can be accessed quickly.
*/
const MainContainer = ({
children, refCallback, label, metaData, ...otherProps
}) => {
const applicationIntl = React.useContext(ApplicationIntlContext);
const activeMainRegistration = React.useContext(ActiveMainRegistrationContext);
const navigationItem = React.useContext(NavigationItemContext);

const mainElementRef = React.useRef();
const unregisterActiveMainRef = React.useRef();

React.useEffect(() => {
unregisterActiveMainRef.current = activeMainRegistration.register({
label,
metaData,
});
}, [
activeMainRegistration,
label,
metaData,
navigationItem.isActive,
navigationItem.navigationKeys,
]);

React.useEffect(() => () => {
// A separate effect is used to unregister the active main when it is
// unmounted to limit registration thrash on updates to props.
unregisterActiveMainRef.current();
}, []);

return (
<main
aria-label={label}
className={classNames(cx('main-container'), otherProps.className)}
tabIndex="-1"
ref={(mainRef) => {
mainElementRef.current = mainRef;

if (refCallback) {
refCallback(mainRef);
}
}}
{...otherProps}
>
<SkipToLink
description={applicationIntl.formatMessage({
id: 'terraApplication.mainContainer.skipToLabel',
})}
onSelect={() => {
mainElementRef.current.focus();
}}
/>
{children}
</main>
);
};

MainContainer.propTypes = propTypes;

export default MainContainer;
5 changes: 5 additions & 0 deletions src/main-container/MainContainer.module.scss
@@ -0,0 +1,5 @@
:local {
.main-container {
outline: none;
}
}
8 changes: 8 additions & 0 deletions src/main-container/index.js
@@ -0,0 +1,8 @@
import MainContainer from './MainContainer';
import ActiveMainContext, {
useActiveMain,
contextShape as activeMainContextShape,
} from './ActiveMainContext';

export default MainContainer;
export { ActiveMainContext, useActiveMain, activeMainContextShape };
106 changes: 106 additions & 0 deletions src/main-container/private/ActiveMainProvider.jsx
@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import uuidv4 from 'uuid/v4';

import NavigationItemContext from '../../navigation-item/NavigationItemContext';

import ActiveMainContext from '../ActiveMainContext';
import ActiveMainRegistrationContext from './ActiveMainRegistrationContext';

const propTypes = {
children: PropTypes.node,
};

const ActiveMainProvider = ({ children }) => {
const navigationItem = React.useContext(NavigationItemContext);
const activeMainRegistration = React.useContext(ActiveMainRegistrationContext);
const unregisterActiveMainRef = React.useRef();

const [state, dispatch] = React.useReducer((currentState, action) => {
if (action.type === 'register') {
return {
registrationId: action.registrationId,
activeMain: {
label: action.label,
metaData: action.metaData,
},
};
}

if (action.type === 'unregister') {
if (currentState.registrationId === action.registrationId) {
return {
registrationId: undefined,
activeMainPage: undefined,
};
}
}

return currentState;
}, { registrationId: undefined, activeMain: undefined });

React.useEffect(() => {
if (!activeMainRegistration) {
return;
}

// If an ancestor provider exists, we need to forward the active main info
// if the provider exists in the active navigation branch. It is
// otherwise unregistered, if necessary, as we do not want potentially stale
// information living above this provider level.
if (navigationItem.isActive) {
unregisterActiveMainRef.current = activeMainRegistration.register(state.activeMain);
} else if (unregisterActiveMainRef.current) {
unregisterActiveMainRef.current();
unregisterActiveMainRef.current = undefined;
}
}, [state.activeMain, navigationItem.isActive, activeMainRegistration]);

React.useEffect(() => () => {
if (unregisterActiveMainRef.current) {
unregisterActiveMainRef.current();
unregisterActiveMainRef.current = undefined;
}
}, []);

const activeMainRegistrationContextValue = React.useMemo(() => ({
register: (registrationData) => {
if (!registrationData) {
return undefined;
}

const { label, metaData } = registrationData;

// Multiple main sources can be writing to this single provider, and the
// order of their registrations is not guaranteed to be deterministic.
// So we assign an identifier to each registration and check it above
// prior to performing any unregistrations, ensuring that main elements
// can execute their unregistration logic as part of their lifecycle
// without worrying about damaging registration data from other sources.
const registrationId = uuidv4();

dispatch({
type: 'register',
registrationId,
label,
metaData,
});

return () => {
dispatch({ type: 'unregister', registrationId });
};
},
}), []);

return (
<ActiveMainRegistrationContext.Provider value={activeMainRegistrationContextValue}>
<ActiveMainContext.Provider value={state.activeMain}>
{children}
</ActiveMainContext.Provider>
</ActiveMainRegistrationContext.Provider>
);
};

ActiveMainProvider.propTypes = propTypes;

export default ActiveMainProvider;
19 changes: 19 additions & 0 deletions src/main-container/private/ActiveMainRegistrationContext.jsx
@@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';

/**
* A private Context used to enable communication between the
* ActiveMainPageProvider and the MainContainer.
*/
const ActiveMainPageRegistrationContext = React.createContext();

const contextShape = {
/**
* A function used to register page data.
* Returns a function that will undo the registration.
*/
register: PropTypes.func,
};

export default ActiveMainPageRegistrationContext;
export { contextShape };
6 changes: 5 additions & 1 deletion src/navigation-item/NavigationItem.jsx
Expand Up @@ -2,6 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

import ActiveMainProvider from '../main-container/private/ActiveMainProvider';

import NavigationItemContext from './NavigationItemContext';

const propTypes = {
Expand Down Expand Up @@ -77,7 +79,9 @@ const NavigationItem = ({

return ReactDOM.createPortal((
<NavigationItemContext.Provider value={navigationItemContextValue}>
{pageContent}
<ActiveMainProvider>
{pageContent}
</ActiveMainProvider>
</NavigationItemContext.Provider>
), portalElement);
};
Expand Down

0 comments on commit 52332e9

Please sign in to comment.