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

Adding MainContainer, ActiveMainContext, and related files #195

Merged
merged 12 commits into from Apr 26, 2021
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
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.

32 changes: 32 additions & 0 deletions src/main-container/ActiveMainContext.jsx
@@ -0,0 +1,32 @@
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({
id: undefined,
label: undefined,
metaData: undefined,
});

/**
* 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;
Copy link
Contributor

Choose a reason for hiding this comment

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

should main have 100% height?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought about this but erred on the side of caution. If we add a height, I feel like we'd need to make the same call for overflow handling. But that is not always desired/required (for the Page use case, or example), which opens the door to all sorts of props to disable/enable things...

Long story long I'd like to avoid adding more styles here, for the time being at least. Makes me wonder if we should add a MainContainer to the eventual LegacyLayout and provide the default styling we used to provide there while keeping this component more generic.

}
}
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 };
99 changes: 99 additions & 0 deletions src/main-container/private/ActiveMainProvider.jsx
@@ -0,0 +1,99 @@
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.
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

Having nested active main providers should never be a thing right? If that happens maybe we should just blow up?

Copy link
Contributor Author

@tbiethman tbiethman Apr 26, 2021

Choose a reason for hiding this comment

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

It is a thing, as the ActiveMainProviders are rendered both by the ApplicationContainer as well as by each NavigationItem (which will be inside the ApplicationContainer, and potentially nested even further if we have secondary/tertiary navigation concepts).

The nested providers are what allow us to limit cross-talk between different navigation branches of the application.

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;
const registrationId = uuidv4();
tbiethman marked this conversation as resolved.
Show resolved Hide resolved

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

return () => {
dispatch({ type: 'unregister', registrationId });
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick for readability/consistency

Suggested change
dispatch({ type: 'unregister', registrationId });
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