+ );
+};
+
+export default LoadingSpinner;
diff --git a/libs/core-components/src/lib/LoadingSpinner/index.ts b/libs/core-components/src/lib/LoadingSpinner/index.ts
new file mode 100644
index 000000000..72aa1b3c3
--- /dev/null
+++ b/libs/core-components/src/lib/LoadingSpinner/index.ts
@@ -0,0 +1,3 @@
+import LoadingSpinner from './LoadingSpinner';
+
+export default LoadingSpinner;
diff --git a/libs/core-components/src/lib/Message/Message.jsx b/libs/core-components/src/lib/Message/Message.jsx
new file mode 100644
index 000000000..33ff24fa9
--- /dev/null
+++ b/libs/core-components/src/lib/Message/Message.jsx
@@ -0,0 +1,203 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Fade } from 'reactstrap';
+import Icon from '../Icon';
+
+import styles from './Message.module.scss';
+
+export const ERROR_TEXT = {
+ mismatchCanDismissScope:
+ 'For a <(Section)Message> to use `canDismiss`, `scope` must equal `section`.',
+ deprecatedType:
+ 'In a <(Section|Inline)Message> `type="warn"` is deprecated. Use `type="warning"` instead.',
+ missingScope:
+ 'A without a `scope` should become an . (If must be used, then explicitely set `scope="inline"`.)',
+};
+
+export const TYPE_MAP = {
+ info: {
+ iconName: 'conversation',
+ className: 'is-info',
+ iconText: 'Notice',
+ },
+ success: {
+ iconName: 'approved-reverse',
+ className: 'is-success',
+ iconText: 'Notice',
+ },
+ warning: {
+ iconName: 'alert',
+ className: 'is-warn',
+ iconText: 'Warning',
+ },
+ error: {
+ iconName: 'alert',
+ className: 'is-error',
+ iconText: 'Error',
+ },
+};
+TYPE_MAP.warn = TYPE_MAP.warning; // FAQ: Deprecated support for `type="warn"`
+export const TYPES = Object.keys(TYPE_MAP);
+
+export const SCOPE_MAP = {
+ inline: {
+ className: 'is-scope-inline',
+ role: 'status',
+ tagName: 'span',
+ },
+ section: {
+ className: 'is-scope-section',
+ role: 'status',
+ tagName: 'p',
+ },
+ // app: { … } // FAQ: Do not use; instead, use a
+};
+export const SCOPES = ['', ...Object.keys(SCOPE_MAP)];
+export const DEFAULT_SCOPE = 'inline'; // FAQ: Historical support for default
+
+/**
+ * Show an event-based message to the user
+ * @example
+ * // basic usage
+ * Invalid content.
+ * @example
+ * // manage dismissal and visibility
+ * const [isVisible, setIsVisible] = useState(...);
+ *
+ * const onDismiss = useCallback(() => {
+ * setIsVisible(!isVisible);
+ * }, [isVisible]);
+ *
+ * return (
+ *
+ * Uh oh.
+ *
+ * );
+ * ...
+ */
+const Message = ({
+ ariaLabel,
+ children,
+ className,
+ dataTestid,
+ onDismiss,
+ canDismiss,
+ isVisible,
+ scope,
+ type,
+}) => {
+ const typeMap = TYPE_MAP[type];
+ const scopeMap = SCOPE_MAP[scope || DEFAULT_SCOPE];
+ const { iconName, iconText, className: typeClassName } = typeMap;
+ const { role, tagName, className: scopeClassName } = scopeMap;
+
+ const hasDismissSupport = scope === 'section';
+
+ // Manage prop warnings
+ /* eslint-disable no-console */
+ if (canDismiss && !hasDismissSupport) {
+ // Component will work, except `canDismiss` is ineffectual
+ console.error(ERROR_TEXT.mismatchCanDismissScope);
+ }
+ if (type === 'warn') {
+ // Component will work, but `warn` is deprecated value
+ console.info(ERROR_TEXT.deprecatedType);
+ }
+ if (!scope) {
+ // Component will work, but `scope` should be defined
+ console.info(ERROR_TEXT.missingScope);
+ }
+ /* eslint-enable no-console */
+
+ // Manage class names
+ const modifierClassNames = [];
+ modifierClassNames.push(typeClassName);
+ modifierClassNames.push(scopeClassName);
+ const containerStyleNames = ['container', ...modifierClassNames]
+ .map((s) => styles[s])
+ .join(' ');
+
+ // Manage disappearance
+ // FAQ: Design does not want fade, but we still use to manage dismissal
+ // TODO: Consider replacing with a replication of `unmountOnExit: true`
+ const shouldFade = false;
+ const fadeProps = {
+ ...Fade.defaultProps,
+ unmountOnExit: true,
+ baseClass: shouldFade ? Fade.defaultProps.baseClass : '',
+ timeout: shouldFade ? Fade.defaultProps.timeout : 0,
+ };
+
+ return (
+ 's default props
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...fadeProps}
+ tag={tagName}
+ className={`${className} ${containerStyleNames}`}
+ role={role}
+ in={isVisible}
+ aria-label={ariaLabel}
+ data-testid={dataTestid}
+ >
+
+ {iconText}
+
+
+ {children}
+
+ {canDismiss && hasDismissSupport ? (
+
+
+
+ ) : null}
+
+ );
+};
+Message.propTypes = {
+ /** How to label this message for accessibility (via `aria-label`) */
+ ariaLabel: PropTypes.string,
+ /** Whether an action can be dismissed (requires scope equals `section`) */
+ canDismiss: PropTypes.bool,
+ /** Message text (as child node) */
+ /* FAQ: We can support any values, even a component */
+ children: PropTypes.node.isRequired, // This checks for any render-able value
+ /** Additional className for the root element */
+ className: PropTypes.string,
+ /** ID for test case element selection */
+ dataTestid: PropTypes.string,
+ /** Whether message is visible (pair with `onDismiss`) */
+ isVisible: PropTypes.bool,
+ /** Action on message dismissal (pair with `isVisible`) */
+ onDismiss: PropTypes.func,
+ /** How to place the message within the layout */
+ scope: PropTypes.oneOf(SCOPES), // RFE: Require scope; change all instances
+ /** Message type or severity */
+ type: PropTypes.oneOf(TYPES).isRequired,
+};
+Message.defaultProps = {
+ ariaLabel: 'message',
+ className: '',
+ canDismiss: false,
+ dataTestid: '',
+ isVisible: true,
+ onDismiss: () => null,
+ scope: '', // RFE: Require scope; remove this line
+};
+
+export default Message;
diff --git a/libs/core-components/src/lib/Message/Message.module.scss b/libs/core-components/src/lib/Message/Message.module.scss
new file mode 100644
index 000000000..0f2454d8e
--- /dev/null
+++ b/libs/core-components/src/lib/Message/Message.module.scss
@@ -0,0 +1,148 @@
+/* WARNING: No official design */
+/* FAQ: Styles are a mix of static design and dev design */
+/* SEE: https://confluence.tacc.utexas.edu/x/gYCeBw */
+
+/* Root */
+
+.container {
+ /* SEE: "Modifiers" */
+ /* --buffer-vert: 0; */
+ /* --buffer-horz: 0; */
+
+ /* Vertically center child elements */
+ flex-flow: row;
+ align-items: start; /* FAQ: Effect visible only if text wraps */
+
+ padding: var(--buffer-vert) var(--buffer-horz);
+}
+.is-scope-inline {
+ --buffer-vert: 0;
+ --buffer-horz: 0;
+
+ display: inline-flex;
+}
+.is-scope-section {
+ --buffer-vert: 0.5em;
+ --buffer-horz: 1em;
+
+ display: flex;
+}
+/* HELP: FP-1227: Why is this unset? */
+p.is-scope-section {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+/* Children */
+
+.text a {
+ white-space: nowrap;
+}
+.type-icon {
+ margin-right: 0.25em; /* ~4px */
+ margin-top: 0.125em; /* HACK: Align better with 14px–17px sibling font */
+}
+.close-button {
+ margin-left: auto;
+ /* FAQ: Ignore padding by moving over it */
+ transform: translateX(var(--buffer-horz));
+
+ border: none;
+ background: transparent;
+
+ appearance: none;
+ color: #222222;
+}
+.close-icon {
+ /* … */
+}
+
+/* Modifiers */
+
+/* Modifiers: Type */
+
+/* Design decided icon is not necessary for informational messages */
+.is-info .icon:not(.close-icon) {
+ display: none;
+}
+
+/* Modifiers: Scope */
+
+.is-scope-inline {
+ &.is-info .icon {
+ color: var(--global-color-info--dark);
+ }
+ &.is-warn .icon {
+ color: var(--global-color-warning--normal);
+ }
+ &.is-error,
+ &.is-error .icon {
+ color: var(--global-color-danger--normal);
+ }
+ &.is-success .icon {
+ color: var(--global-color-success--normal);
+ }
+}
+
+.is-scope-section {
+ border-width: var(--global-border-width--normal);
+ border-style: solid;
+
+ /* Children */
+ & .type-icon {
+ margin-right: 1rem;
+ }
+
+ /* Modifiers */
+ &.is-info {
+ color: var(--global-color-info--dark);
+ border-color: var(--global-color-info--normal);
+ background-color: var(--global-color-info--light);
+ & .type-icon {
+ color: var(--global-color-info--dark);
+ }
+ }
+ &.is-warn {
+ border-color: var(--global-color-warning--normal);
+ background-color: var(--global-color-warning--weak);
+ & .type-icon {
+ color: var(--global-color-warning--normal);
+ }
+ }
+ &.is-error {
+ border-color: var(--global-color-danger--normal);
+ background-color: var(--global-color-danger--weak);
+ & .type-icon {
+ color: var(--global-color-danger--normal);
+ }
+ }
+ &.is-success {
+ border-color: var(--global-color-success--normal);
+ background-color: var(--global-color-success--weak);
+ & .type-icon {
+ color: var(--global-color-success--normal);
+ }
+ }
+}
+
+/* Modifiers: Complex */
+
+.is-scope-inline {
+ /* WARNING: This accessibility solution is only because of lack of design */
+ /* FAQ: Can not explicitely declare `.wb-link`, because its a global class */
+ /* Avoid clash of red text and purple `.wb-link` */
+ .is-error a {
+ color: var(
+ --global-color-danger--normal
+ ) !important /* override overly-specific `.workbench-content .wb-link` */;
+ }
+ /* Distinguish text and `.wb-link`, and link states default and hover */
+ .is-warn a,
+ .is-error a {
+ text-decoration-line: underline;
+ }
+ .is-warn a:hover,
+ .is-error a:hover {
+ text-decoration-style: double;
+ }
+}
diff --git a/libs/core-components/src/lib/Message/Message.test.js b/libs/core-components/src/lib/Message/Message.test.js
new file mode 100644
index 000000000..793e293d1
--- /dev/null
+++ b/libs/core-components/src/lib/Message/Message.test.js
@@ -0,0 +1,146 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+
+import Message, * as MSG from './Message';
+
+const TEST_CONTENT = '…';
+const TEST_TYPE = 'info';
+const TEST_SCOPE = 'inline';
+
+function testClassnamesByType(type, getByRole, getByTestId) {
+ const root = getByRole('status');
+ const icon = getByRole('img'); // WARNING: Relies on `Icon`
+ const text = getByTestId('text');
+ const iconName = MSG.TYPE_MAP[type].iconName;
+ const modifierClassName = MSG.TYPE_MAP[type].className;
+ expect(root.className).toMatch('container');
+ expect(root.className).toMatch(new RegExp(modifierClassName));
+ expect(icon.className).toMatch(iconName);
+ expect(text.className).toMatch('text');
+}
+
+describe('Message', () => {
+ it.each(MSG.TYPES)('has correct text for type %s', (type) => {
+ if (type === 'warn') console.warn = jest.fn(); // mute deprecation warning
+ const { getByTestId } = render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(getByTestId('text').textContent).toEqual(TEST_CONTENT);
+ });
+
+ describe('elements', () => {
+ test.each(MSG.TYPES)('include icon when type is %s', (type) => {
+ if (type === 'warn') console.warn = jest.fn(); // mute deprecation warning
+ const { getByRole } = render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(getByRole('img')).toBeDefined(); // WARNING: Relies on `Icon`
+ });
+ test.each(MSG.TYPES)('include text when type is %s', (type) => {
+ if (type === 'warn') console.warn = jest.fn(); // mute deprecation warning
+ const { getByTestId } = render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(getByTestId('text')).toBeDefined();
+ });
+ test('include button when message is dismissible', () => {
+ const { getByRole } = render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(getByRole('button')).not.toEqual(null);
+ });
+ });
+
+ describe('visibility', () => {
+ test('invisible when `isVisible` is `false`', () => {
+ const { queryByRole } = render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(queryByRole('button')).not.toBeInTheDocument();
+ });
+ test.todo('visible when `isVisible` changes from `false` to `true`');
+ // FAQ: Feature works (manually tested), but unit test is difficult
+ // it('appears when isVisible changes from true to false', async () => {
+ // let isVisible = false;
+ // const { findByRole, queryByRole } = render(
+ //
+ // {TEST_CONTENT}
+ //
+ // );
+ // expect(queryByRole('button')).toBeNull();
+ // const button = await findByRole('button');
+ // isVisible = true;
+ // expect(button).toBeDefined();
+ // });
+ });
+
+ describe('className', () => {
+ it.each(MSG.TYPES)('is accurate when type is %s', (type) => {
+ const { getByRole, getByTestId } = render(
+
+ {TEST_CONTENT}
+
+ );
+
+ testClassnamesByType(type, getByRole, getByTestId);
+ });
+ it.each(MSG.SCOPES)(
+ 'has accurate className when scope is "%s"',
+ (scope) => {
+ const { getByRole, getByTestId } = render(
+
+ {TEST_CONTENT}
+
+ );
+ const root = getByRole('status');
+ const modifierClassName = MSG.SCOPE_MAP[scope || MSG.DEFAULT_SCOPE];
+
+ testClassnamesByType(TEST_TYPE, getByRole, getByTestId);
+ expect(root.className).toMatch(new RegExp(modifierClassName));
+ }
+ );
+ });
+
+ describe('property limitation', () => {
+ test('is announced for `canDismiss` and `scope`', () => {
+ console.error = jest.fn();
+ render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(console.error).toHaveBeenCalledWith(
+ MSG.ERROR_TEXT.mismatchCanDismissScope
+ );
+ });
+ test('is announced for `type="warn"`', () => {
+ console.info = jest.fn();
+ render(
+
+ {TEST_CONTENT}
+
+ );
+ expect(console.info).toHaveBeenCalledWith(MSG.ERROR_TEXT.deprecatedType);
+ });
+ test('is announced for missing `scope` value', () => {
+ console.info = jest.fn();
+ render({TEST_CONTENT});
+ expect(console.info).toHaveBeenCalledWith(MSG.ERROR_TEXT.missingScope);
+ });
+ });
+});
diff --git a/libs/core-components/src/lib/Message/index.js b/libs/core-components/src/lib/Message/index.js
new file mode 100644
index 000000000..7b7affb0d
--- /dev/null
+++ b/libs/core-components/src/lib/Message/index.js
@@ -0,0 +1,3 @@
+import Message from './Message';
+
+export default Message;
diff --git a/libs/core-components/src/lib/Paginator/Paginator.jsx b/libs/core-components/src/lib/Paginator/Paginator.jsx
new file mode 100644
index 000000000..75b0f85e5
--- /dev/null
+++ b/libs/core-components/src/lib/Paginator/Paginator.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import Button from '../Button';
+import PropTypes from 'prop-types';
+import styles from './Paginator.module.scss';
+
+const PaginatorEtc = () => {
+ return ...;
+};
+
+const PaginatorPage = ({ number, callback, current }) => {
+ return (
+ callback(number)}
+ >
+ {number}
+
+ );
+};
+
+PaginatorPage.propTypes = {
+ number: PropTypes.number.isRequired,
+ callback: PropTypes.func.isRequired,
+ current: PropTypes.number.isRequired,
+};
+
+const Paginator = ({ pages, current, callback, spread }) => {
+ let start, end;
+ if (pages === 1 || pages === 2) {
+ end = 0;
+ start = pages;
+ } else if (pages > 2 && pages <= spread) {
+ start = 2;
+ end = pages - 1;
+ } else if (pages > spread && current <= 4) {
+ start = 2;
+ end = spread - 1;
+ } else if (pages > spread && current > pages - (spread - 2)) {
+ start = pages - (spread - 2);
+ end = pages - 1;
+ } else {
+ const delta = Math.floor((spread - 2) / 2);
+ start = current - delta;
+ end = current + delta;
+ }
+ const middle = end - start + 1;
+ const middlePages =
+ middle > 0
+ ? Array(middle)
+ .fill()
+ .map((_, index) => start + index)
+ : [];
+ return (
+
+ );
+};
+
+Paginator.propTypes = {
+ pages: PropTypes.number.isRequired,
+ current: PropTypes.number.isRequired,
+ callback: PropTypes.func.isRequired,
+ spread: PropTypes.number, // Number of page buttons to show
+};
+
+Paginator.defaultProps = {
+ spread: 11,
+};
+
+export default Paginator;
diff --git a/libs/core-components/src/lib/Paginator/Paginator.module.css b/libs/core-components/src/lib/Paginator/Paginator.module.css
new file mode 100644
index 000000000..e0494d652
--- /dev/null
+++ b/libs/core-components/src/lib/Paginator/Paginator.module.css
@@ -0,0 +1,30 @@
+.root {
+ composes: c-page-list from '@tacc/core-styles/dist/components/c-page.css';
+}
+.endcap {
+ composes: c-page-end from '@tacc/core-styles/dist/components/c-page.css';
+}
+.endcap:global(.btn) {
+ padding-left: 12px;
+ font-size: inherit;
+}
+
+.etcetera {
+ composes: c-page-item--etcetera from '@tacc/core-styles/dist/components/c-page.css';
+}
+
+.page-root {
+ composes: c-page-item from '@tacc/core-styles/dist/components/c-page.css';
+}
+
+.page {
+ composes: c-page-link from '@tacc/core-styles/dist/components/c-page.css';
+ composes: c-page-link--always-click from '@tacc/core-styles/dist/components/c-page.css';
+
+ /* To show `c-page-link--always-click` pseudo elements */
+ overflow: visible; /* overwrite 's `c-button` */
+}
+.page:global(.btn) {
+ border-radius: 0;
+ font-size: inherit;
+}
diff --git a/libs/core-components/src/lib/Paginator/Paginator.test.js b/libs/core-components/src/lib/Paginator/Paginator.test.js
new file mode 100644
index 000000000..87c9c6024
--- /dev/null
+++ b/libs/core-components/src/lib/Paginator/Paginator.test.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import Paginator from './Paginator';
+
+describe('Paginator', () => {
+ it('renders pages', () => {
+ const { getAllByText } = render();
+ expect(getAllByText('1').length).toEqual(1);
+ expect(getAllByText('9').length).toEqual(1);
+ expect(getAllByText('10').length).toEqual(1);
+ expect(getAllByText('11').length).toEqual(1);
+ expect(getAllByText('12').length).toEqual(1);
+ expect(getAllByText('13').length).toEqual(1);
+ expect(getAllByText('...').length).toEqual(2);
+ expect(getAllByText('20').length).toEqual(1);
+ });
+
+ it('renders 2 pages only', () => {
+ const { getAllByText, queryByText } = render(
+
+ );
+ expect(getAllByText('1').length).toEqual(1);
+ expect(getAllByText('2').length).toEqual(1);
+ expect(queryByText('0')).toBeFalsy();
+ });
+});
diff --git a/libs/core-components/src/lib/Paginator/index.js b/libs/core-components/src/lib/Paginator/index.js
new file mode 100644
index 000000000..203b9570d
--- /dev/null
+++ b/libs/core-components/src/lib/Paginator/index.js
@@ -0,0 +1,3 @@
+import Paginator from './Paginator';
+
+export default Paginator;
diff --git a/libs/core-components/src/lib/Pill/Pill.jsx b/libs/core-components/src/lib/Pill/Pill.jsx
new file mode 100644
index 000000000..241abfdb2
--- /dev/null
+++ b/libs/core-components/src/lib/Pill/Pill.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import styles from './Pill.module.scss';
+
+function Pill({ children, type, className, shouldTruncate }) {
+ let pillStyleName = `${styles['root']} ${styles[`is-${type}`]}`;
+
+ if (shouldTruncate) pillStyleName += ` ${styles['should-truncate']}`;
+
+ return (
+
+ {children}
+
+ );
+}
+
+Pill.propTypes = {
+ children: PropTypes.string.isRequired,
+ type: PropTypes.string,
+ className: PropTypes.string,
+ shouldTruncate: PropTypes.bool,
+};
+
+Pill.defaultProps = {
+ type: 'normal',
+ className: '',
+ shouldTruncate: true,
+};
+
+export default Pill;
diff --git a/libs/core-components/src/lib/Pill/Pill.module.css b/libs/core-components/src/lib/Pill/Pill.module.css
new file mode 100644
index 000000000..b60d9a5e9
--- /dev/null
+++ b/libs/core-components/src/lib/Pill/Pill.module.css
@@ -0,0 +1,40 @@
+.root {
+ font-size: 0.875rem; /* 14px (approved deviation from design) */
+ font-weight: 500;
+ padding: 0.2em 0.5em; /* aim for ~23px height (19px design * 1.2 design-to-app ratio) */
+ display: inline-block; /* FAQ: Supports `min/max-width` */
+ /* min-width: ____; */ /* FAQ: Set only as desired (not always desired) */
+ text-align: center;
+ border-radius: 3px;
+ color: var(--global-color-primary--xx-light);
+}
+
+/* CAVEAT: This alone may not trigger truncation */
+/* SEE: https://confluence.tacc.utexas.edu/x/sAoFDg */
+.should-truncate {
+ max-width: 100%;
+
+ overflow: hidden;
+ text-overflow: ellipsis;
+ /* white-space: nowrap; */ /* FAQ: Set only as desired (not always desired) */
+
+ /* Keep alignment that is changed by `overflow: hidden;` */
+ /* SEE: https://stackoverflow.com/q/25818199 */
+ vertical-align: bottom;
+}
+
+.is-danger {
+ background-color: var(--global-color-danger--normal);
+}
+
+.is-success {
+ background-color: var(--global-color-success--normal);
+}
+
+.is-warning {
+ background-color: var(--global-color-warning--normal);
+}
+
+.is-normal {
+ background-color: var(--global-color-accent--normal);
+}
diff --git a/libs/core-components/src/lib/Pill/Pill.test.js b/libs/core-components/src/lib/Pill/Pill.test.js
new file mode 100644
index 000000000..eace82392
--- /dev/null
+++ b/libs/core-components/src/lib/Pill/Pill.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import Pill from './Pill';
+
+describe('Pill component', () => {
+ it('should render a pill', () => {
+ const { getByText } = render(Pill Text);
+ expect(getByText(/Pill Text/)).toBeDefined();
+ });
+});
diff --git a/libs/core-components/src/lib/Pill/index.js b/libs/core-components/src/lib/Pill/index.js
new file mode 100644
index 000000000..6a2c521ef
--- /dev/null
+++ b/libs/core-components/src/lib/Pill/index.js
@@ -0,0 +1,3 @@
+import Pill from './Pill';
+
+export default Pill;
diff --git a/libs/core-components/src/lib/Section/Section.jsx b/libs/core-components/src/lib/Section/Section.jsx
new file mode 100644
index 000000000..db50f0ab3
--- /dev/null
+++ b/libs/core-components/src/lib/Section/Section.jsx
@@ -0,0 +1,220 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+import { SectionHeader, SectionContent } from '../';
+import { LAYOUTS, DEFAULT_LAYOUT } from '../SectionContent';
+
+import styles from './Section.module.css';
+
+/**
+ * A section layout structure that supports:
+ *
+ * - messages (automatically loads intro message)
+ * - header (with actions, e.g. links, buttons, form)
+ * - content (that will be arranged in the layout you choose)
+ * - manual or automatic sub-components (i.e. header, content)
+ *
+ * @example
+ * // manually build messages, automatically build intro message (by name)
+ * …>}
+ * />
+ * @example
+ * // overwrite text of an automatic intro message, no additional messages
+ *
+ * @example
+ * // define text for a manual intro message, no additional messages
+ *
+ * @example
+ * // add class to , automatically build sub-components
+ * // FAQ: class on + `Bob.global.css` + `body.global-bob-class`
+ * // = unlimited, explicit, isolated CSS side effects
+ *
+ * @example
+ * // automatically build sub-components, with some customization
+ *
+ * @example
+ * // alternate syntax to automatically build content
+ *
+ * {…}
+ *
+ * @example
+ * // manually build sub-components
+ * // WARNING: This component's styles are NOT applied to manual sub-components
+ * // FAQ: The offers auto-built header's layout styles
+ * // FAQ: The offers auto-built content's layout styles
+ *
+ * }
+ * manualContent={
+ *
+ * }
+ * />
+ * @example
+ * // manually build content (alternate method)
+ * // WARNING: This component's styles are NOT applied to manual sub-components
+ * // FAQ: The offers auto-built content's layout options
+ *
+ *
+ * />
+ */
+function Section({
+ bodyClassName,
+ children,
+ className,
+ content,
+ contentClassName,
+ contentLayoutName,
+ contentShouldScroll,
+ header,
+ headerActions,
+ headerClassName,
+ manualContent,
+ manualHeader,
+ // manualSidebar,
+ // sidebar,
+ // sidebarClassName,
+ messages,
+}) {
+ const shouldBuildHeader = header || headerClassName || headerActions;
+
+ // Allowing ineffectual prop combinations would lead to confusion
+ if (
+ manualContent &&
+ (content || contentClassName || contentLayoutName || contentShouldScroll)
+ ) {
+ throw new Error(
+ 'When passing `manualContent`, the following props are ineffectual: `content`, `contentClassName`, `contentLayoutName`, `contentShouldScroll`'
+ );
+ }
+ if (manualHeader && (header || headerClassName || headerActions)) {
+ throw new Error(
+ 'When passing `manualHeader`, the following props are ineffectual: `header`, `headerClassName`, `headerActions`'
+ );
+ }
+ // if (manualSidebar && (sidebar || sidebarClassName)) {
+ // throw new Error(
+ // 'When passing `manualSidebar`, the following props are ineffectual: `sidebar`, `sidebarClassName`'
+ // );
+ // }
+
+ useEffect(() => {
+ if (bodyClassName) document.body.classList.add(bodyClassName);
+
+ return function cleanup() {
+ if (bodyClassName) document.body.classList.remove(bodyClassName);
+ };
+ }, [bodyClassName]);
+
+ return (
+
+ {messages}
+ {/* {manualSidebar ? (
+ <>{manualSidebar}>
+ ) : (
+
+ {sidebar}
+
+ )} */}
+ {manualHeader ??
+ (shouldBuildHeader && (
+
+ {header}
+
+ ))}
+ {manualContent ? (
+ <>
+ {manualContent}
+ {children}
+ >
+ ) : (
+
+ {content}
+ {children}
+
+ )}
+
+ );
+}
+Section.propTypes = {
+ /** Name of class to append to body when section is active */
+ bodyClassName: PropTypes.string,
+ /** Alternate way to pass `manualContent` and `content` */
+ children: PropTypes.node,
+ /** Any additional className(s) for the root element */
+ className: PropTypes.string,
+ /** The section content children (content element built automatically) */
+ content: PropTypes.node,
+ /** Any additional className(s) for the content element */
+ contentClassName: PropTypes.string,
+ /** The name of the layout by which to arrange the content children */
+ contentLayoutName: PropTypes.oneOf(LAYOUTS),
+ /** Whether to allow content to scroll */
+ contentShouldScroll: PropTypes.bool,
+ /** The section header text (header element built automatically) */
+ header: PropTypes.node,
+ /** Any section actions for the header element */
+ headerActions: PropTypes.node,
+ /** Any additional className(s) for the header element */
+ headerClassName: PropTypes.string,
+ /** The section content (built by user) flag or element */
+ /* RFE: Ideally, limit these to one relevant `Section[…]` component */
+ /* SEE: https://github.com/facebook/react/issues/2979 */
+ manualContent: PropTypes.oneOfType([PropTypes.bool, PropTypes.element]),
+ /** The section header (built by user) element */
+ manualHeader: PropTypes.element,
+ // /** The page-specific sidebar */
+ // sidebar: PropTypes.node,
+ // /** Additional className for the sidebar element */
+ // sidebarClassName: PropTypes.string,
+ /** Any message(s) (e.g. ) (but NOT a intro message) */
+ messages: PropTypes.node,
+};
+Section.defaultProps = {
+ bodyClassName: '',
+ children: undefined,
+ className: '',
+ content: '',
+ contentClassName: '',
+ contentLayoutName: DEFAULT_LAYOUT,
+ contentShouldScroll: false,
+ header: '',
+ headerActions: '',
+ headerClassName: '',
+ manualContent: undefined,
+ manualHeader: undefined,
+ messages: '',
+ introMessageName: '',
+ // sidebarClassName: '',
+ introMessageText: '',
+};
+
+export default Section;
diff --git a/libs/core-components/src/lib/Section/Section.module.css b/libs/core-components/src/lib/Section/Section.module.css
new file mode 100644
index 000000000..061c81ff7
--- /dev/null
+++ b/libs/core-components/src/lib/Section/Section.module.css
@@ -0,0 +1,36 @@
+/* Block */
+
+.root {
+ display: flex;
+ flex-direction: column;
+
+ margin-top: var(--global-space--section-top);
+ margin-bottom: var(--global-space--section-bottom);
+}
+
+/* Elements */
+
+.messages,
+/* CAVEAT: These are only applied to automatically-built sub-components */
+.header {
+ /* Border (of header) must not extend all the way to the left */
+ margin-left: var(--global-space--section-left);
+ /* Content (messages, actions) must not extend all the way to the right */
+ margin-right: var(--global-space--section-right);
+}
+.content {
+ /* Content (sidebar links) must extend all the way to the left */
+ padding-left: var(--global-space--section-left);
+ /* Scrollbar must not be positioned all the way to the right */
+ margin-right: var(--global-space--section-right);
+}
+
+/* Elements: Sub-Components */
+
+/* CAVEAT: These are only applied to automatically-built sub-components */
+.header {
+ flex-shrink: 0;
+}
+.content {
+ flex-grow: 1;
+}
diff --git a/libs/core-components/src/lib/Section/Section.test.tsx b/libs/core-components/src/lib/Section/Section.test.tsx
new file mode 100644
index 000000000..5e3b85b25
--- /dev/null
+++ b/libs/core-components/src/lib/Section/Section.test.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import Section from './Section';
+
+describe('Section', () => {
+ describe('elements and classes', () => {
+ it.skip('renders elements with appropriate roles', () => {
+ const { getByRole } = render(
+ Content
} />
+ );
+ // WARNING: Only one `main` is allowed per page
+ expect(getByRole('main').textContent).toEqual('Content');
+ // NOTE: Technically (https://www.w3.org/TR/html-aria/#el-header), the `header` should not have a role, but `aria-query` recognizes it as a banner (https://github.com/A11yance/aria-query/pull/59)
+ expect(getByRole('banner').textContent).toEqual('Header');
+ expect(getByRole('heading').textContent).toEqual('Header');
+ });
+ });
+
+ describe('content and classes', () => {
+ it.skip('renders all passed content and classes', () => {
+ const { container, getByText } = render(
+ Header Actions}
+ headerClassName="header-test"
+ content={
Content
}
+ contentClassName="content-test"
+ // sidebar={}
+ // sidebarClassName="sidebar-test"
+ messages={
+ <>
+ Message
+ List
+ >
+ }
+ />
+ );
+ expect(container.getElementsByClassName('root-test').length).toEqual(1);
+ expect(getByText('Header')).not.toEqual(null);
+ expect(getByText('Header Actions')).not.toEqual(null);
+ expect(container.getElementsByClassName('header-test').length).toEqual(1);
+ expect(getByText('Content')).not.toEqual(null);
+ expect(container.getElementsByClassName('content-test').length).toEqual(
+ 1
+ );
+ // expect(getByText('Sidebar')).not.toEqual(null);
+ // expect(container.getElementsByClassName('sidebar-test').length).toEqual(1);
+ expect(container.querySelector(`[class*="messages"]`)).not.toEqual(null);
+ expect(container.getElementsByClassName('messages-test').length).toEqual(
+ 1
+ );
+ });
+ });
+});
diff --git a/libs/core-components/src/lib/Section/index.js b/libs/core-components/src/lib/Section/index.js
new file mode 100644
index 000000000..9bd61c332
--- /dev/null
+++ b/libs/core-components/src/lib/Section/index.js
@@ -0,0 +1 @@
+export { default } from './Section';
diff --git a/libs/core-components/src/lib/SectionContent/SectionContent.jsx b/libs/core-components/src/lib/SectionContent/SectionContent.jsx
new file mode 100644
index 000000000..543bbd777
--- /dev/null
+++ b/libs/core-components/src/lib/SectionContent/SectionContent.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import styles from './SectionContent.module.css';
+import layoutStyles from './SectionContent.layouts.module.css';
+
+/**
+ * Map of layout names to CSS classes
+ * @enum {number}
+ */
+export const LAYOUT_CLASS_MAP = {
+ /**
+ * Each child element is a full-height column with a flexible width
+ *
+ * CAVEAT: No sidebar styles provided (until a exists)
+ */
+ hasSidebar: layoutStyles['has-sidebar'],
+ /**
+ * Each child element is a flexible block inside one full-height column
+ */
+ oneColumn: layoutStyles['one-column'],
+ /**
+ * Each child element is a panel stacked into two full-height columns
+ * (on narrow screens, there is only one column)
+ * @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Columns
+ */
+ twoColumn: layoutStyles['two-column'],
+ /**
+ * Each child element is a panel stacked into two or more full-height columns
+ * (on short wide screens, there are three equal-width columns)
+ * (on tall wide screens, there are two equal-width columns)
+ * (on narrow screens, there is only one column)
+ * @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Columns
+ */
+ multiColumn: layoutStyles['multi-column'],
+};
+export const DEFAULT_LAYOUT = 'hasSidebar';
+export const LAYOUTS = [...Object.keys(LAYOUT_CLASS_MAP)];
+
+/**
+ * A content panel wrapper that supports:
+ *
+ * - lay out panels (based on layout name and panel position)
+ * - change element tag (like `section` instead of `div`)
+ * - scroll root element (overflow of panel content is not managed)
+ * - debug layout (via color-coded panels)
+ *
+ * @example
+ * // features: lay out panels, change tag, allow content scroll, color-coded
+ *
+ *
Thing 1
+ *
Thing 2
+ *
Thing 3
+ *
+ */
+function SectionContent({
+ className,
+ children,
+ layoutName,
+ shouldScroll,
+ tagName,
+}) {
+ let styleName = '';
+ const styleNameList = [styles['root'], layoutStyles['root']];
+ const layoutClass = LAYOUT_CLASS_MAP[layoutName];
+ const TagName = tagName;
+
+ if (shouldScroll) styleNameList.push(styles['should-scroll']);
+ if (layoutClass) styleNameList.push(layoutClass);
+
+ // Do not join inside JSX (otherwise arcane styleName error occurs)
+ styleName = styleNameList.join(' ');
+
+ return {children};
+}
+SectionContent.propTypes = {
+ /** Any additional className(s) for the root element */
+ className: PropTypes.string,
+ /** Content nodes where each node is a block to be laid out */
+ children: PropTypes.node.isRequired,
+ /** The name of the layout by which to arrange the nodes */
+ layoutName: PropTypes.oneOf(LAYOUTS).isRequired,
+ /** Whether to allow root element to scroll */
+ shouldScroll: PropTypes.bool,
+ /** Override tag of the root element */
+ tagName: PropTypes.string,
+};
+SectionContent.defaultProps = {
+ className: '',
+ shouldScroll: false,
+ tagName: 'div',
+};
+
+export default SectionContent;
diff --git a/libs/core-components/src/lib/SectionContent/SectionContent.layouts.module.css b/libs/core-components/src/lib/SectionContent/SectionContent.layouts.module.css
new file mode 100644
index 000000000..453118a3b
--- /dev/null
+++ b/libs/core-components/src/lib/SectionContent/SectionContent.layouts.module.css
@@ -0,0 +1,93 @@
+@import url('@tacc/core-styles/src/lib/_imports/tools/media-queries.css');
+
+/* Base */
+
+.root {
+ /* FAQ: No styles necessary, but defining class to avoid build error */
+}
+
+/* Debug */
+/* FAQ: To color-code panels, ucncomment the code in this section */
+
+/* Color-code panels to easily track movement of multiple panels */
+/*
+.root::before { background-color: dimgray; }
+.root > *:nth-child(1) { background-color: deeppink; }
+.root > *:nth-child(2) { background-color: deepskyblue; }
+.root > *:nth-child(3) { background-color: gold; }
+.root > *:nth-child(4) { background-color: springgreen; }
+.root::after { background-color: lavender; }
+*/
+
+/* Has Sidebar */
+
+/* CAVEAT: No sidebar styles provided (until a exists) */
+.has-sidebar {
+ display: flex;
+ flex-flow: row nowrap;
+}
+
+/* 1 Column */
+
+.one-column {
+ display: flex;
+ flex-flow: column nowrap;
+}
+
+/* 2 Columns */
+
+/* Always */
+.two-column,
+.multi-column {
+ --vertical-buffer: 2.5rem; /* 40px (~32px design * 1.2 design-to-app ratio) (rounded) */
+ --column-gap: calc(var(--global-space--section-left) * 2);
+}
+.two-column > *,
+.multi-column > * {
+ break-inside: avoid;
+}
+
+/* Narrow */
+@media screen and (--medium-and-below) {
+ .two-column > *,
+ .multi-column > * {
+ margin-bottom: var(--vertical-buffer);
+ }
+}
+
+/* Wide */
+@media screen and (--medium-and-above) {
+ .two-column,
+ .multi-column {
+ column-gap: var(--column-gap);
+ column-rule: 1px solid rgb(112 112 112 / 25%);
+ column-fill: auto;
+ }
+ .two-column > *:not(:last-child),
+ .multi-column > *:not(:last-child) {
+ margin-bottom: var(--vertical-buffer);
+ }
+}
+
+/* Tall & Wide */
+@media screen and (--short-and-above) and (--medium-and-above) {
+ .two-column,
+ .multi-column {
+ column-count: 2;
+ }
+}
+
+/* Short & Wide */
+@media screen and (--short-and-below) and (--medium-to-wide) {
+ .two-column {
+ column-count: 2;
+ }
+}
+@media screen and (--short-and-below) and (--wide-and-above) {
+ .two-column {
+ column-count: 2;
+ }
+ .multi-column {
+ column-count: 3;
+ }
+}
diff --git a/libs/core-components/src/lib/SectionContent/SectionContent.module.css b/libs/core-components/src/lib/SectionContent/SectionContent.module.css
new file mode 100644
index 000000000..f636e8447
--- /dev/null
+++ b/libs/core-components/src/lib/SectionContent/SectionContent.module.css
@@ -0,0 +1,19 @@
+/* Block */
+
+.root {
+ /* … */
+}
+
+/* Modifiers */
+
+/* NOTE: Similar on: SectionContent, SectionTableWrapper */
+.should-scroll {
+ /* We want to permit vertical scrolling, without forcing it… can we? */
+ /* FAQ: Did not set `overflow: auto`, because that would certainly hide negative-margined sidebar links */
+ /* CAVEAT: Setting `overflow-y` still hides the negative-margined sidebar links because `overflow-x: visible` (default) is re-intepreted as `auto` */
+ /* SEE: https://stackoverflow.com/a/6433475/11817077 */
+ overflow-y: auto;
+}
+.root:not(.should-scroll) {
+ overflow: hidden;
+}
diff --git a/libs/core-components/src/lib/SectionContent/SectionContent.test.js b/libs/core-components/src/lib/SectionContent/SectionContent.test.js
new file mode 100644
index 000000000..59d089b81
--- /dev/null
+++ b/libs/core-components/src/lib/SectionContent/SectionContent.test.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import SectionContent, { LAYOUT_CLASS_MAP } from './SectionContent';
+
+// Create our own `LAYOUTS`, because component one may include an empty string
+const LAYOUTS = [...Object.keys(LAYOUT_CLASS_MAP)];
+
+export const PARAMETER_CLASS_MAP = {
+ shouldScroll: 'should-scroll',
+};
+export const PARAMETERS = [...Object.keys(PARAMETER_CLASS_MAP)];
+
+describe('SectionContent', () => {
+ describe('elements', () => {
+ it('renders all passed children', () => {
+ const { container } = render(
+
+