diff --git a/README.md b/README.md index 0b2d46dd7..243ea05a8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ - - # Tacc This project was generated using [Nx](https://nx.dev). Things to try: + - `npx nx serve tup-ui` to run the app - `npx nx build core-components` to create a distributable library for the core components. - `npx nx build core-styles` to build the style library. @@ -84,8 +83,6 @@ Run `nx graph` to see a diagram of the dependencies of your projects. Visit the [Nx Documentation](https://nx.dev) to learn more. - - ## ☁ Nx Cloud ### Distributed Computation Caching & Distributed Task Execution diff --git a/apps/tup-ui/project.json b/apps/tup-ui/project.json index 147d9debf..49c3834bf 100644 --- a/apps/tup-ui/project.json +++ b/apps/tup-ui/project.json @@ -19,7 +19,7 @@ "options": { "commands": [ { - "command": "npx vite build" + "command": "npx vite build --emptyOutDir" } ], "cwd": "apps/tup-ui" diff --git a/apps/tup-ui/src/App.tsx b/apps/tup-ui/src/App.tsx index 213c17409..82a71d390 100644 --- a/apps/tup-ui/src/App.tsx +++ b/apps/tup-ui/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Message } from '@tacc/core-components'; +import { Button, Message } from '@tacc/core-components'; function App() { const [count, setCount] = useState(0); @@ -10,9 +10,13 @@ function App() {

Hello Vite + React!

- +

Edit App.tsx and save to test HMR updates. diff --git a/apps/tup-ui/src/styles/README.md b/apps/tup-ui/src/styles/README.md index e12b65d79..2b698e9a0 100644 --- a/apps/tup-ui/src/styles/README.md +++ b/apps/tup-ui/src/styles/README.md @@ -14,7 +14,7 @@ Global stylesheets may `@import` project stylesheets, e.g.: **`index.css`** ``` -@import url('styles/.../settings/color.css'); +@import url('@tacc/core-styles/.../settings/color.css'); ``` ### Import from Component Stylesheets @@ -24,7 +24,7 @@ Component stylesheets may `@import` project stylesheets, e.g.: **`components/(.../)SomeProjectComponent.module.css`** ``` -@import url('styles/tools/media-queries.css'); +@import url('@tacc/core-styles/.../tools/media-queries.css'); @media screen and (--short-and-above) and (--medium-and-above) { selector { diff --git a/libs/core-components/.babelrc b/libs/core-components/.babelrc index ccae900be..61641ec8a 100644 --- a/libs/core-components/.babelrc +++ b/libs/core-components/.babelrc @@ -3,8 +3,7 @@ [ "@nrwl/react/babel", { - "runtime": "automatic", - "useBuiltIns": "usage" + "runtime": "automatic" } ] ], diff --git a/libs/core-components/.browserslistrc b/libs/core-components/.browserslistrc new file mode 100644 index 000000000..f1d12df4f --- /dev/null +++ b/libs/core-components/.browserslistrc @@ -0,0 +1,16 @@ +# This file is used by: +# 1. autoprefixer to adjust CSS to support the below specified browsers +# 2. babel preset-env to adjust included polyfills +# +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries +# +# If you need to support different browsers in production, you may tweak the list below. + +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major version +last 2 iOS major versions +Firefox ESR +not IE 9-11 # For IE 9-11 support, remove 'not'. \ No newline at end of file diff --git a/libs/core-components/README.md b/libs/core-components/README.md deleted file mode 100644 index ef3e92a70..000000000 --- a/libs/core-components/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# core-components - -This library was generated with [Nx](https://nx.dev). - -## Running unit tests - -Run `nx test core-components` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/core-components/jest.config.ts b/libs/core-components/jest.config.ts index 639e64515..75f45c88d 100644 --- a/libs/core-components/jest.config.ts +++ b/libs/core-components/jest.config.ts @@ -3,8 +3,9 @@ export default { displayName: 'core-components', preset: '../../jest.preset.js', transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', '^.+\\.[tj]sx?$': 'babel-jest', }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '../../coverage/libs/core-components', + coverageDirectory: '../../coverage/apps/tup-ui', }; diff --git a/libs/core-components/package.json b/libs/core-components/package.json index bf6eb6170..5efcb26c5 100644 --- a/libs/core-components/package.json +++ b/libs/core-components/package.json @@ -1,4 +1,14 @@ { "name": "@tacc/core-components", - "version": "0.0.1" + "files": [ + "dist" + ], + "main": "./dist/core-components.umd.js", + "module": "./dist/core-components.es.js", + "exports": { + ".": { + "import": "./dist/core-components.umd.js", + "require": "./dist/core-components.umd.js" + } + } } diff --git a/libs/core-components/project.json b/libs/core-components/project.json index 8469c5cdf..ec10f9d76 100644 --- a/libs/core-components/project.json +++ b/libs/core-components/project.json @@ -2,26 +2,27 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/core-components/src", "projectType": "library", - "tags": [], "targets": { + "serve": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "npx vite" + } + ], + "cwd": "libs/core-components" + } + }, "build": { - "executor": "@nrwl/web:rollup", - "outputs": ["{options.outputPath}"], + "executor": "nx:run-commands", "options": { - "outputPath": "dist/libs/core-components", - "tsConfig": "libs/core-components/tsconfig.lib.json", - "project": "libs/core-components/package.json", - "entryFile": "libs/core-components/src/index.ts", - "external": ["react/jsx-runtime"], - "rollupConfig": "@nrwl/react/plugins/bundle-rollup", - "compiler": "babel", - "assets": [ + "commands": [ { - "glob": "libs/core-components/README.md", - "input": ".", - "output": "." + "command": "npx vite build --emptyOutDir" } - ] + ], + "cwd": "libs/core-components" } }, "lint": { @@ -39,5 +40,6 @@ "passWithNoTests": true } } - } + }, + "tags": [] } diff --git a/libs/core-components/src/index.ts b/libs/core-components/src/index.ts index ae5624ac9..f033da2a2 100644 --- a/libs/core-components/src/index.ts +++ b/libs/core-components/src/index.ts @@ -1 +1,4 @@ +export { default as Button } from './lib/Button'; +export { default as Icon } from './lib/Icon'; +export { default as LoadingSpinner } from './lib/LoadingSpinner'; export { default as Message } from './lib/core-components'; diff --git a/libs/core-components/src/lib/Button/Button.module.css b/libs/core-components/src/lib/Button/Button.module.css new file mode 100644 index 000000000..00a2f7797 --- /dev/null +++ b/libs/core-components/src/lib/Button/Button.module.css @@ -0,0 +1,57 @@ +.root { + composes: c-button from '@tacc/core-styles/dist/components/c-button.css'; +} + +.primary { + composes: c-button--primary from '@tacc/core-styles/dist/components/c-button.css'; +} +.secondary { + composes: c-button--secondary from '@tacc/core-styles/dist/components/c-button.css'; +} +.tertiary { + composes: c-button--tertiary from '@tacc/core-styles/dist/components/c-button.css'; +} +.active { + composes: c-button--is-active from '@tacc/core-styles/dist/components/c-button.css'; +} + +.size-small { + composes: c-button--size-small from '@tacc/core-styles/dist/components/c-button.css'; +} +.width-short { + composes: c-button--width-short from '@tacc/core-styles/dist/components/c-button.css'; +} +.width-medium { + composes: c-button--width-medium from '@tacc/core-styles/dist/components/c-button.css'; +} +.width-long { + composes: c-button--width-long from '@tacc/core-styles/dist/components/c-button.css'; +} + +.as-link { + composes: c-button--as-link from '@tacc/core-styles/dist/components/c-button.css'; +} + +.icon--before { + composes: c-button__icon--before from '@tacc/core-styles/dist/components/c-button.css'; +} +.icon--after { + composes: c-button__icon--after from '@tacc/core-styles/dist/components/c-button.css'; +} + +.loading { + composes: c-button--is-busy from '@tacc/core-styles/dist/components/c-button.css'; +} +.root { + position: relative; +} +.root .loading-over-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.text { + composes: c-button__text from '@tacc/core-styles/dist/components/c-button.css'; +} diff --git a/libs/core-components/src/lib/Button/Button.test.js b/libs/core-components/src/lib/Button/Button.test.js new file mode 100644 index 000000000..bf5a7dd8d --- /dev/null +++ b/libs/core-components/src/lib/Button/Button.test.js @@ -0,0 +1,152 @@ +// WARNING: Relies on `Icon` because of `getByRole('img')` +import React from 'react'; +import { render } from '@testing-library/react'; +import Button, * as BTN from './Button'; + +import '@testing-library/jest-dom/extend-expect'; + +const TEST_TEXT = '…'; +const TEST_TYPE = 'primary'; +const TEST_SIZE = 'medium'; + +function testClassnamesByType(type, size, getByRole, getByTestId) { + const root = getByRole('button'); + const text = getByTestId('text'); + const typeClassName = BTN.TYPE_MAP[type]; + const sizeClassName = BTN.SIZE_MAP[size]; + expect(root.className).toMatch('root'); + expect(root.className).toMatch(new RegExp(typeClassName)); + expect(root.className).toMatch(new RegExp(sizeClassName)); + expect(text.className).toMatch('text'); +} + +function muteTypeNotLinkNoSizeLog(type, size) { + if (type !== 'link' && !size) console.debug = jest.fn(); +} + +function isPropertyLimitation(type, size) { + let isLimited = false; + + if ( + (type === 'primary' && size === 'small') || + (type !== 'link' && !size) || + (type === 'link' && size) + ) + isLimited = true; + + return isLimited; +} + +describe('Button', () => { + it('uses given text', () => { + muteTypeNotLinkNoSizeLog(); + const { getByTestId } = render(); + expect(getByTestId('text').textContent).toEqual(TEST_TEXT); + }); + + describe('icons exist as expected when', () => { + test('only `iconNameBefore` is given', () => { + muteTypeNotLinkNoSizeLog(); + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).toBeInTheDocument(); + expect(queryByTestId('icon-after')).not.toBeInTheDocument(); + }); + test('only `iconNameAfter` is given', () => { + muteTypeNotLinkNoSizeLog(); + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).not.toBeInTheDocument(); + expect(queryByTestId('icon-after')).toBeInTheDocument(); + }); + test('both `iconNameAfter` and `iconNameBefore` are given', () => { + muteTypeNotLinkNoSizeLog(); + const { queryByTestId } = render( + + ); + expect(queryByTestId('icon-before')).toBeInTheDocument(); + expect(queryByTestId('icon-after')).toBeInTheDocument(); + }); + }); + + describe('all type & size combinations render accurately', () => { + it.each(BTN.TYPES)('type is "%s"', (type) => { + muteTypeNotLinkNoSizeLog(); + if (isPropertyLimitation(type, TEST_SIZE)) { + return Promise.resolve(); + } + const { getByRole, getByTestId } = render( + + ); + + testClassnamesByType(type, TEST_SIZE, getByRole, getByTestId); + }); + it.each(BTN.SIZES)('size is "%s"', (size) => { + muteTypeNotLinkNoSizeLog(); + if (isPropertyLimitation(TEST_TYPE, size)) { + return Promise.resolve(); + } + const { getByRole, getByTestId } = render( + + ); + + testClassnamesByType(TEST_TYPE, size, getByRole, getByTestId); + }); + }); + + describe('loading', () => { + it('does not render button without text', () => { + muteTypeNotLinkNoSizeLog(); + const { queryByTestId } = render( + + ); + const el = queryByTestId('no button here'); + expect(el).toBeNull(); + }); + it('disables button when in loading state', () => { + muteTypeNotLinkNoSizeLog(); + const { queryByText } = render( + + ); + const el = queryByText('Loading Button').closest('button'); + expect(el).toBeDisabled(); + }); + }); + + describe('property limitation', () => { + test('type is "link" & ANY size`', () => { + console.warn = jest.fn(); + const { getByRole, getByTestId } = render( + + ); + const expectedType = 'link'; + const expectedSize = ''; + + testClassnamesByType(expectedType, expectedSize, getByRole, getByTestId); + expect(console.warn).toHaveBeenCalled(); + }); + test('type is "primary" & size is "small"', () => { + console.error = jest.fn(); + const { getByRole, getByTestId } = render( + + ); + const expectedType = 'secondary'; + const expectedSize = 'small'; + + testClassnamesByType(expectedType, expectedSize, getByRole, getByTestId); + expect(console.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/core-components/src/lib/Button/Button.tsx b/libs/core-components/src/lib/Button/Button.tsx new file mode 100644 index 000000000..91f677dd9 --- /dev/null +++ b/libs/core-components/src/lib/Button/Button.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import Icon from '../Icon'; +import LoadingSpinner from '../LoadingSpinner'; +import styles from './Button.module.css'; + +export const TYPE_MAP = { + primary: 'primary', + secondary: 'secondary', + tertiary: 'tertiary', + active: 'is-active', + link: 'as-link', +}; + +export const SIZE_MAP = { + short: 'width-short', + medium: 'width-medium', + long: 'width-long', + small: 'size-small', +}; + +export const TYPES = [''].concat(Object.keys(TYPE_MAP)); + +export const SIZES = [''].concat(Object.keys(SIZE_MAP)); + +export const ATTRIBUTES = ['button', 'submit', 'reset']; + +type ButtonProps = React.PropsWithChildren<{ + className?: string; + iconNameBefore?: string; + iconNameAfter?: string; + type?: 'primary' | 'secondary' | 'tertiary' | 'active' | 'link'; + size?: 'short' | 'medium' | 'long' | 'small'; + dataTestid?: string; + disabled?: boolean; + onClick?: (e: React.MouseEvent) => void; + attr?: 'button' | 'submit' | 'reset'; + isLoading?: boolean; +}>; + +const Button: React.FC = ({ + children, + className, + iconNameBefore, + iconNameAfter, + type = 'secondary', + size = 'short', + dataTestid, + disabled, + onClick, + attr = 'button', + isLoading = false, +}) => { + function onclick(e: React.MouseEvent) { + if (disabled) { + e.preventDefault(); + return; + } + if (onClick) { + return onClick(e); + } + } + + // Manage prop warnings + /* eslint-disable no-console */ + if (type === 'link' && size) { + // DISABLING: empty string is not a valid value for size + // size = ''; + // Component will work, except `size` is ineffectual + console.warn('A + ); +}; + +export default Button; diff --git a/libs/core-components/src/lib/Button/index.ts b/libs/core-components/src/lib/Button/index.ts new file mode 100644 index 000000000..803f51fbb --- /dev/null +++ b/libs/core-components/src/lib/Button/index.ts @@ -0,0 +1,3 @@ +import Button from './Button'; + +export default Button; diff --git a/libs/core-components/src/lib/Checkbox/Checkbox.jsx b/libs/core-components/src/lib/Checkbox/Checkbox.jsx new file mode 100644 index 000000000..6773a66dd --- /dev/null +++ b/libs/core-components/src/lib/Checkbox/Checkbox.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Icon from '../Icon'; + +import styles from './Checkbox.module.css'; + +// RFE: Use (and style) an actual checkbox… `` +// and still support `DataFilesListingCells`'s button usage (how?) +// (this would also resolve the aria/lint complications noted below) +const Checkbox = ({ className, isChecked, tabIndex, role, ...props }) => { + const rootStyleNames = [ + styles['root'], + isChecked ? styles['is-checked'] : '', + ].join(' '); + + return ( + + + + + ); +}; +Checkbox.propTypes = { + /** Additional className for the root element */ + className: PropTypes.string, + /** Whether box should be checked */ + isChecked: PropTypes.bool, + /** Standard HTML attribute [tabindex] */ + tabIndex: PropTypes.number, + /** Standard HTML attribute [role] */ + role: PropTypes.string, +}; +Checkbox.defaultProps = { + className: '', + isChecked: false, + tabIndex: 0, + role: 'checkbox', +}; + +export default Checkbox; diff --git a/libs/core-components/src/lib/Checkbox/Checkbox.module.css b/libs/core-components/src/lib/Checkbox/Checkbox.module.css new file mode 100644 index 000000000..779b1cd20 --- /dev/null +++ b/libs/core-components/src/lib/Checkbox/Checkbox.module.css @@ -0,0 +1,21 @@ +/* HACK: Only necessary because icon sizes are not managed by yet */ +.root, +.root > * { + font-size: 1rem !important; /* override `.icon, .icon-set` */ +} + +/* Children */ + +.check { + background-color: var(--global-color-accent--normal); + color: var(--global-color-primary--xx-light); + + clip-path: inset(0.075em); /* to hide internal padding around box shape */ +} +.root:not(.is-checked) .check { + display: none; +} + +.box { + color: var(--global-color-primary--x-dark); +} diff --git a/libs/core-components/src/lib/Checkbox/Checkbox.test.js b/libs/core-components/src/lib/Checkbox/Checkbox.test.js new file mode 100644 index 000000000..6ff12cc96 --- /dev/null +++ b/libs/core-components/src/lib/Checkbox/Checkbox.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import Checkbox from './Checkbox'; + +describe('Icon', () => { + it('has correct `className` (when not passed `isChecked`)', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el.className).not.toMatch(`is-checked`); + }); + it('has correct `className` (when passed `isChecked`)`', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el.className).toMatch(`is-checked`); + }); + it('has correct `role` (when not passed `role`)', () => { + const { getByRole } = render(); + const el = getByRole('checkbox'); + expect(el).not.toEqual(null); + }); + it('has correct `role` (when passed `role`)', () => { + const { getByRole } = render(); + const el = getByRole('button'); + expect(el).not.toEqual(null); + }); +}); diff --git a/libs/core-components/src/lib/Checkbox/index.js b/libs/core-components/src/lib/Checkbox/index.js new file mode 100644 index 000000000..36fa16d80 --- /dev/null +++ b/libs/core-components/src/lib/Checkbox/index.js @@ -0,0 +1,3 @@ +import Checkbox from './Checkbox'; + +export default Checkbox; diff --git a/libs/core-components/src/lib/DescriptionList/DescriptionList.jsx b/libs/core-components/src/lib/DescriptionList/DescriptionList.jsx new file mode 100644 index 000000000..86dc7a039 --- /dev/null +++ b/libs/core-components/src/lib/DescriptionList/DescriptionList.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { v4 as uuidv4 } from 'uuid'; + +import styles from './DescriptionList.module.scss'; + +export const DIRECTION_CLASS_MAP = { + vertical: 'is-vert', + horizontal: 'is-horz', +}; +export const DEFAULT_DIRECTION = 'vertical'; +export const DIRECTIONS = ['', ...Object.keys(DIRECTION_CLASS_MAP)]; + +export const DENSITY_CLASS_MAP = { + compact: 'is-narrow', + default: 'is-wide', +}; +export const DEFAULT_DENSITY = 'default'; +export const DENSITIES = ['', ...Object.keys(DENSITY_CLASS_MAP)]; + +const DescriptionList = ({ className, data, density, direction }) => { + const modifierClasses = []; + modifierClasses.push(DENSITY_CLASS_MAP[density || DEFAULT_DENSITY]); + modifierClasses.push(DIRECTION_CLASS_MAP[direction || DEFAULT_DIRECTION]); + const containerStyleNames = ['container', ...modifierClasses] + .map((s) => styles[s]) + .join(' '); + + const shouldTruncateValues = + (direction === 'vertical' && density === 'compact') || + (direction === 'horizontal' && density === 'default'); + const valueClassName = `${styles.value} ${ + shouldTruncateValues ? 'value-truncated' : '' + }`; + + return ( +

+ {Object.entries(data).map(([key, value]) => ( + +
+ {key} +
+ {Array.isArray(value) ? ( + value.map((val) => ( +
+ {val} +
+ )) + ) : ( +
+ {value} +
+ )} +
+ ))} +
+ ); +}; +DescriptionList.propTypes = { + /** Additional className for the root element */ + className: PropTypes.string, + /** Selector type */ + /* FAQ: We can support any values, even a component */ + // eslint-disable-next-line react/forbid-prop-types + data: PropTypes.object.isRequired, + /** Layout density */ + density: PropTypes.oneOf(DENSITIES), + /** Layout direction */ + direction: PropTypes.oneOf(DIRECTIONS), +}; +DescriptionList.defaultProps = { + className: '', + density: DEFAULT_DENSITY, + direction: DEFAULT_DIRECTION, +}; + +export default DescriptionList; diff --git a/libs/core-components/src/lib/DescriptionList/DescriptionList.module.scss b/libs/core-components/src/lib/DescriptionList/DescriptionList.module.scss new file mode 100644 index 000000000..49ad93c9f --- /dev/null +++ b/libs/core-components/src/lib/DescriptionList/DescriptionList.module.scss @@ -0,0 +1,56 @@ +.container.is-horz { + margin-bottom: 0; /* overwrite Bootstrap's `_reboot.scss` */ + & dd { + margin-bottom: 0; /* overwrite Bootstrap's `_reboot.scss` */ + } +} + +/* Children */ + +.key { + composes: x-truncate--one-line from '@tacc/core-styles/dist/tools/x-truncate.css'; +} +.key::after { + content: ':'; + display: inline; + padding-right: 0.25em; +} +.is-horz > .value { + white-space: nowrap; +} + +/* Types */ + +.is-horz { + display: flex; + flex-direction: row; +} +.is-horz > .key ~ .key::before { + content: '|'; + display: inline-block; +} + +.is-horz.is-narrow > .key ~ .key::before { + padding-left: 0.5em; + padding-right: 0.5em; +} +.is-horz.is-wide > .key ~ .key::before { + padding-left: 1em; + padding-right: 1em; +} + +/* Overwrite Bootstrap `_reboot.scss` */ +.is-vert > .value { + margin-left: 0; +} +.is-vert.is-narrow > .value { + padding-left: 0; +} +.is-vert.is-wide > .value { + padding-left: 2.5rem; +} /* 40px Firefox default margin */ + +/* Truncate specific edge cases */ +.value-truncated { + composes: x-truncate--one-line from '@tacc/core-styles/dist/tools/x-truncate.css'; +} diff --git a/libs/core-components/src/lib/DescriptionList/DescriptionList.test.js b/libs/core-components/src/lib/DescriptionList/DescriptionList.test.js new file mode 100644 index 000000000..e8735ea8f --- /dev/null +++ b/libs/core-components/src/lib/DescriptionList/DescriptionList.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import DescriptionList, * as DL from './DescriptionList'; + +const DATA = { + Username: 'bobward500', + Prefix: 'Mr.', + Name: 'Bob Ward', + Suffix: 'The 5th', +}; + +describe('Description List', () => { + it('has accurate tags', async () => { + const { getByTestId, findAllByTestId } = render( + + ); + const list = getByTestId('list'); + const keys = await findAllByTestId('key'); + const values = await findAllByTestId('value'); + expect(list).toBeDefined(); + expect(list.tagName).toEqual('DL'); + keys.forEach((key) => { + expect(key.tagName).toEqual('DT'); + }); + values.forEach((value) => { + expect(value.tagName).toEqual('DD'); + }); + }); + it.each(DL.DIRECTIONS)( + 'has accurate className when direction is "%s"', + (direction) => { + const { getByTestId } = render( + + ); + const list = getByTestId('list'); + const className = + DL.DIRECTION_CLASS_MAP[direction || DL.DEFAULT_DIRECTION]; + expect(list).toBeDefined(); + expect(list.className).toMatch(className); + } + ); + it.each(DL.DENSITIES)( + 'has accurate className when density is "%s"', + (density) => { + const { getByTestId } = render( + + ); + const list = getByTestId('list'); + const className = DL.DENSITY_CLASS_MAP[density || DL.DEFAULT_DENSITY]; + expect(list).toBeDefined(); + expect(list.className).toMatch(className); + } + ); + + it('renders multiple
terms when value is an Array', async () => { + const dataWithArray = { + Hobbits: [ + 'Frodo Baggins', + 'Samwise Gamgee', + 'Meriadoc Brandybuck', + 'Peregrin Took', + ], + }; + const { findAllByTestId } = render( + + ); + const keys = await findAllByTestId('key'); + const values = await findAllByTestId('value'); + expect(keys.length).toEqual(1); + expect(values.length).toEqual(4); + }); +}); diff --git a/libs/core-components/src/lib/DescriptionList/index.js b/libs/core-components/src/lib/DescriptionList/index.js new file mode 100644 index 000000000..24ce8f7a0 --- /dev/null +++ b/libs/core-components/src/lib/DescriptionList/index.js @@ -0,0 +1,3 @@ +import DescriptionList from './DescriptionList'; + +export default DescriptionList; diff --git a/libs/core-components/src/lib/Icon/Icon.module.css b/libs/core-components/src/lib/Icon/Icon.module.css new file mode 100644 index 000000000..ef65b0bbe --- /dev/null +++ b/libs/core-components/src/lib/Icon/Icon.module.css @@ -0,0 +1 @@ +/* FP-228: Migrate `src/styles/trumps/icon.css` to here and end global icon class names */ diff --git a/libs/core-components/src/lib/Icon/Icon.test.js b/libs/core-components/src/lib/Icon/Icon.test.js new file mode 100644 index 000000000..0b104cb68 --- /dev/null +++ b/libs/core-components/src/lib/Icon/Icon.test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Icon from './Icon'; + +const NAME = 'test-icon-name'; +const CLASS = 'test-class-name'; +const TEXT = 'test-icon-text'; +const LABEL = 'test-icon-label'; + +describe('Icon', () => { + it('has correct `className (when not passed a `className`)`', () => { + const { getByRole } = render(); + const icon = getByRole('img'); + expect(icon.className).toMatch(`icon-${NAME}`); + }); + it('has correct `className` (when passed a `className`)', () => { + const { getByRole } = render(); + const icon = getByRole('img'); + expect(icon.className).toMatch(`icon-${NAME}`); + expect(icon.className).toMatch(CLASS); + }); + it('has correct `tagName`', () => { + const { getByRole } = render(); + const icon = getByRole('img'); + expect(icon.tagName).toEqual('I'); + }); + it('has a label', () => { + const { getByLabelText } = render(); + const label = getByLabelText(LABEL); + expect(label).toBeDefined(); + }); + it('has child text nodes', () => { + const { getAllByText } = render({TEXT}); + expect(getAllByText(TEXT).length).toEqual(1); + }); +}); diff --git a/libs/core-components/src/lib/Icon/Icon.tsx b/libs/core-components/src/lib/Icon/Icon.tsx new file mode 100644 index 000000000..af6aa024d --- /dev/null +++ b/libs/core-components/src/lib/Icon/Icon.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import './Icon.module.css'; + +type IconProps = React.PropsWithChildren<{ + className?: string; + dataTestid?: string; + label?: string; + name: string; +}>; + +const Icon: React.FC = ({ + children, + className, + dataTestid, + label, + name, +}) => { + const iconClassName = `icon icon-${name}`; + // FAQ: The conditional avoids an extra space in class attribute value + const fullClassName = className + ? [className, iconClassName].join(' ') + : iconClassName; + + return ( + + {children} + + ); +}; + +export default Icon; diff --git a/libs/core-components/src/lib/Icon/index.ts b/libs/core-components/src/lib/Icon/index.ts new file mode 100644 index 000000000..311b1a239 --- /dev/null +++ b/libs/core-components/src/lib/Icon/index.ts @@ -0,0 +1,3 @@ +import Icon from './Icon'; + +export default Icon; diff --git a/libs/core-components/src/lib/InlineMessage/InlineMessage.jsx b/libs/core-components/src/lib/InlineMessage/InlineMessage.jsx new file mode 100644 index 000000000..9e1565672 --- /dev/null +++ b/libs/core-components/src/lib/InlineMessage/InlineMessage.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import Message from '../Message'; + +/** + * Show a component-specific event-based message to the user + * @example + * // basic usage + * Task complete. + * @see ../Message + */ +const InlineMessage = (props) => { + // Override default props + const messageProps = { + ...Message.defaultProps, + ...props, + canDismiss: false, + scope: 'inline', + }; + + // Avoid manually syncing 's props + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; +InlineMessage.propTypes = Message.propTypes; +InlineMessage.defaultProps = Message.defaultProps; + +export default InlineMessage; diff --git a/libs/core-components/src/lib/InlineMessage/index.js b/libs/core-components/src/lib/InlineMessage/index.js new file mode 100644 index 000000000..5ac271712 --- /dev/null +++ b/libs/core-components/src/lib/InlineMessage/index.js @@ -0,0 +1,3 @@ +import InlineMessage from './InlineMessage'; + +export default InlineMessage; diff --git a/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.scss b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.scss new file mode 100644 index 000000000..63ffd0ad2 --- /dev/null +++ b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.scss @@ -0,0 +1,20 @@ +.loading-icon { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + .inline { + width: 1.5rem; + height: 1.5rem; + } + .section { + width: 4rem; + height: 4rem; + } +} +button .loading-icon { + display: inline-flex; + vertical-align: middle; + width: auto; +} diff --git a/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.test.js b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.test.js new file mode 100644 index 000000000..52560df16 --- /dev/null +++ b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.test.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import LoadingSpinner from './LoadingSpinner'; + +describe('Loading Spinner component', () => { + it('should render a spinner', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId(/loading-spinner/)).toBeDefined(); + expect(getByText(/Loading.../)).toBeDefined(); + }); +}); diff --git a/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.tsx b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.tsx new file mode 100644 index 000000000..44cca660c --- /dev/null +++ b/libs/core-components/src/lib/LoadingSpinner/LoadingSpinner.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; +import './LoadingSpinner.scss'; + +type LoadingSpinnerProps = { + placement?: 'inline' | 'section'; + className?: string; +}; + +const LoadingSpinner: React.FC = ({ + placement = 'section', + className, +}) => { + return ( +
+ +
+ ); +}; + +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 ( + + ); +}; + +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 } + 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( + +
Thing 1
+
Thing 2
+
Thing 3
+
+ ); + const root = container.children[0]; + + expect(root.children.length).toEqual(3); + }); + it('renders custom tag', () => { + const { container } = render( + +
Thing
+
+ ); + const root = container.children[0]; + + expect(root.tagName.toLowerCase()).toEqual('main'); + }); + }); + + describe('parameter class names', () => { + it.each(LAYOUTS)( + 'renders accurate class for layout name "%s"', + (layoutName) => { + const { container } = render( + Thing + ); + const classNameString = LAYOUT_CLASS_MAP[layoutName]; + const classNameList = classNameString.split(' '); + + classNameList.forEach((className) => { + expect( + container.querySelector(`[class*="${className}"]`) + ).not.toEqual(null); + }); + } + ); + + it.each(PARAMETERS)( + 'renders accurate class for boolean parameter "%s"', + (parameter) => { + const parameterObj = { [parameter]: true }; + const { container } = render( + +
Thing
+
+ ); + const className = PARAMETER_CLASS_MAP[parameter]; + + expect(container.querySelector(`[class*="${className}"]`)).not.toEqual( + null + ); + } + ); + }); +}); diff --git a/libs/core-components/src/lib/SectionContent/index.js b/libs/core-components/src/lib/SectionContent/index.js new file mode 100644 index 000000000..0cece963d --- /dev/null +++ b/libs/core-components/src/lib/SectionContent/index.js @@ -0,0 +1,6 @@ +export { + default, + LAYOUTS, + DEFAULT_LAYOUT, + LAYOUT_CLASS_MAP, +} from './SectionContent'; diff --git a/libs/core-components/src/lib/SectionHeader/SectionHeader.jsx b/libs/core-components/src/lib/SectionHeader/SectionHeader.jsx new file mode 100644 index 000000000..18ebed180 --- /dev/null +++ b/libs/core-components/src/lib/SectionHeader/SectionHeader.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import styles from './SectionHeader.module.css'; + +/** + * A header for a `Section[…]` component + * + * - heading text + * - actions (e.g. links, buttons, form) + * - automatic styles or markup for given context (ex: within a form or a table) + * + * @example + * // a section header with heading text (which happens to be also be a link) + * + * Hyperlinked Name of Section + * + * @example + * // a form header with actions and heading text + * Reset} + * isForForm + * > + * Name of Form + * + * @example + * // a table header with actions and heading text + * } + * isForTable + * > + * Name of Table + * + * @example + * // a list header (a list can be like a table with no column headers) + * Name of List + */ +function SectionHeader({ + actions, + children, + className, + isForForm, + isForTable, + isForList, +}) { + let styleName = ''; + const styleNameList = [styles['root']]; + const HeadingTagName = isForForm || isForTable || isForList ? 'h3' : 'h2'; + + if (isForForm) styleNameList.push(styles['for-form']); + if (isForTable) styleNameList.push(styles['for-table']); + if (isForList) styleNameList.push(styles['for-list']); + + // Do not join inside JSX (otherwise arcane styleName error occurs) + styleName = styleNameList.join(' '); + + return ( +
+ {children && ( + + {children} + + )} + {actions} +
+ ); +} +SectionHeader.propTypes = { + /** Any actions (buttons, links, forms, etc) */ + actions: PropTypes.node, + /** The text a.k.a. title */ + children: PropTypes.node, + /** Any additional className(s) for the root element */ + className: PropTypes.string, + /** Whether this header is for a form */ + isForForm: PropTypes.bool, + /** Whether this header is for a table */ + isForTable: PropTypes.bool, + /** Whether this header is for a list */ + isForList: PropTypes.bool, +}; +SectionHeader.defaultProps = { + actions: '', + className: '', + children: undefined, + isForForm: false, + isForTable: false, + isForList: false, +}; + +export default SectionHeader; diff --git a/libs/core-components/src/lib/SectionHeader/SectionHeader.module.css b/libs/core-components/src/lib/SectionHeader/SectionHeader.module.css new file mode 100644 index 000000000..beb5caa4e --- /dev/null +++ b/libs/core-components/src/lib/SectionHeader/SectionHeader.module.css @@ -0,0 +1,49 @@ +/* Block */ + +.root { + display: flex; + justify-content: space-between; + align-items: flex-end; + flex-wrap: wrap; + + align-content: flex-end; /* preserve alignment of items when wrapped */ + + min-height: 2rem; + box-sizing: content-box; + + padding-bottom: 0.75rem; /* 12px (~10px design * 1.2 design-to-app ratio) */ + border-bottom: var(--global-border-width--normal) solid + var(--global-color-primary--dark); +} + +/* Elements */ + +/* Add vertical space between children that, without wrapping, is invisible */ +/* FAQ: Take away space added, in direction that does not disturb other CSS */ +/* WARNING: `DataFilesToolbar.scss` needs `margin-bottom` */ +.root { + margin-top: -5px; +} +.root > * { + margin-top: 5px; +} + +/* Do not allow external margin styles */ +.heading { + margin-bottom: 0; /* Overwrite Bootstrap `h1`–`h6` styles */ +} +/* Header actions are always after heading text */ +.heading ~ * { + max-height: 100%; /* (gently) force oversized elements to fit */ +} + +/* Modifiers */ + +.for-form, +.for-list { + /* FAQ: No styles necessary, but defining class to avoid build error */ +} + +.for-table { + border-bottom: none; +} diff --git a/libs/core-components/src/lib/SectionHeader/SectionHeader.test.js b/libs/core-components/src/lib/SectionHeader/SectionHeader.test.js new file mode 100644 index 000000000..fd981e02b --- /dev/null +++ b/libs/core-components/src/lib/SectionHeader/SectionHeader.test.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import SectionHeader from './SectionHeader'; + +export const PARAMETER_CLASS_MAP = { + isForForm: 'for-form', + isForTable: 'for-table', +}; +export const PARAMETERS = [...Object.keys(PARAMETER_CLASS_MAP)]; + +describe('SectionHeader', () => { + describe('elements', () => { + it('renders elements with appropriate roles', () => { + const { getByRole } = render( + Button}> + Heading + + ); + // NOTE: Technically (https://www.w3.org/TR/html-aria/#el-header), within a `
` (from `
`), 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('HeadingButton'); + expect(getByRole('heading').textContent).toEqual('Heading'); + }); + }); + + describe('content and classes', () => { + it('renders all passed content and classes', () => { + const { container, getByText } = render( + Button}> + Heading + + ); + expect(getByText('Heading')).not.toEqual(null); + expect(getByText('Button')).not.toEqual(null); + expect(container.getElementsByClassName('root-test').length).toEqual(1); + }); + it('renders JSX header text', () => { + const { getByText } = render( + + Heading + + ); + expect(getByText('Heading')).not.toEqual(null); + }); + }); + + describe('parameter class names', () => { + it.each(PARAMETERS)( + 'renders accurate class and tag for boolean parameter "%s"', + (parameter) => { + const parameterObj = { [parameter]: true }; + const { container, getByText } = render( + Heading + ); + const className = PARAMETER_CLASS_MAP[parameter]; + + expect(container.querySelector(`[class*="${className}"]`)).not.toEqual( + null + ); + expect(getByText('Heading').tagName.toLowerCase()).toEqual('h3'); + } + ); + }); +}); diff --git a/libs/core-components/src/lib/SectionHeader/index.js b/libs/core-components/src/lib/SectionHeader/index.js new file mode 100644 index 000000000..96dda554f --- /dev/null +++ b/libs/core-components/src/lib/SectionHeader/index.js @@ -0,0 +1 @@ +export { default } from './SectionHeader'; diff --git a/libs/core-components/src/lib/SectionMessage/SectionMessage.jsx b/libs/core-components/src/lib/SectionMessage/SectionMessage.jsx new file mode 100644 index 000000000..6e02eca71 --- /dev/null +++ b/libs/core-components/src/lib/SectionMessage/SectionMessage.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; + +import Message from '../Message'; + +/** + * Show a section/page-specific event-based message to the user + * @example + * // basic usage + * Uh oh. + * @see _common/Message + */ +const SectionMessage = (props) => { + const [isVisible, setIsVisible] = useState(true); + const autoManageVisible = props.canDismiss && props.isVisible === undefined; + const autoManageDismiss = props.canDismiss && props.onDismiss === undefined; + + function onDismiss() { + if (autoManageVisible) { + setIsVisible(!isVisible); + } + if (!autoManageDismiss) { + props.onDismiss(); + } + } + + // Override default props + const messageProps = { + ...Message.defaultProps, + ...props, + scope: 'section', + }; + if (autoManageVisible) { + messageProps.isVisible = isVisible; + } + if (autoManageDismiss) { + messageProps.onDismiss = onDismiss; + } + + // Avoid manually syncing 's props + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; +SectionMessage.propTypes = Message.propTypes; +SectionMessage.defaultProps = { + ...Message.defaultProps, + isVisible: undefined, + onDismiss: undefined, +}; + +export default SectionMessage; diff --git a/libs/core-components/src/lib/SectionMessage/SectionMessage.test.js b/libs/core-components/src/lib/SectionMessage/SectionMessage.test.js new file mode 100644 index 000000000..f60669c25 --- /dev/null +++ b/libs/core-components/src/lib/SectionMessage/SectionMessage.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { + render, + fireEvent, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import SectionMessage from './SectionMessage'; + +const TEST_CONTENT = '…'; +const TEST_TYPE = 'info'; + +describe('SectionMessage', () => { + describe('visibility', () => { + test('removed when dismissed', async () => { + const { getByRole, queryByRole } = render( + + {TEST_CONTENT} + + ); + fireEvent.click(getByRole('button')); + await waitForElementToBeRemoved(() => queryByRole('button')); + }); + }); +}); diff --git a/libs/core-components/src/lib/SectionMessage/index.js b/libs/core-components/src/lib/SectionMessage/index.js new file mode 100644 index 000000000..9217d6949 --- /dev/null +++ b/libs/core-components/src/lib/SectionMessage/index.js @@ -0,0 +1,3 @@ +import SectionMessage from './SectionMessage'; + +export default SectionMessage; diff --git a/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.jsx b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.jsx new file mode 100644 index 000000000..54f60f4d1 --- /dev/null +++ b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.jsx @@ -0,0 +1,199 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import SectionHeader from '../SectionHeader'; + +import styles from './SectionTableWrapper.module.css'; + +/** + * A wrapper required for any table within a flex box. + * (All `Section[…]` components are flex boxes.) + * + * It supports: + * + * - header (with actions, e.g. links, buttons, form) + * - changing the element tag (like `section` instead of `article`) + * - manual or automatic sub-components (i.e. header) + * + * If your table is within a `Section[…]` component, and does not employ this wrapper, you must manually resolve any layout issues. + * + * @see https://stackoverflow.com/q/41421512/11817077 + * @example + * // wrap a table (no header) (that is a flex item) + * + * + * + * @example + * // wrap a table, prepend a header, apply a className + * Heading} + * > + * + * + * @example + * // automatically build sub-components, with some customization + * + * + * + * @example + * // alternate syntax to automatically build content + * + * } + * + * @example + * // manually build sub-components + * // WARNING: Manually built sub-components styles must be manually styled + * offers auto-built header's layout styles + * + * Dashboard + * + * } + * // The "o-flex-item-table-wrap" (if available) mimics `isFlexItem` + * // CAVEAT: Manually load `.o-flex-item-table-wrap` from TACC/Core-Styles + * manualContent={ + *
+ * + *
+ * } + * /> + * @example + * // manually build content (alternate method) + * // WARNING: Manually built sub-components styles must be manually styled + * + * // The "o-flex-item-table-wrap" (if available) mimics `isFlexItem` + * // CAVEAT: Manually load `.o-flex-item-table-wrap` from TACC/Core-Styles + *
+ * + *
+ *
+ */ +function SectionTableWrapper({ + className, + children, + content, + contentClassName, + contentShouldScroll, + header, + headerActions, + headerClassName, + manualContent, + manualHeader, + tagName, + isFlexItem, +}) { + let styleName = ''; + const styleNameList = [styles['root']]; + const TagName = tagName; + const shouldBuildHeader = header || headerClassName || headerActions; + + if (contentShouldScroll) { + styleNameList.push(styles['should-scroll']); + } + if (!manualContent && isFlexItem) { + styleNameList.push(styles['has-wrap']); + } + + // Do not join inside JSX (otherwise arcane styleName error occurs) + styleName = styleNameList.join(' '); + + // Allowing ineffectual prop combinations would lead to confusion + // (unlike
, prop `contentShouldScroll` IS allowed here) + if (manualContent && (content || contentClassName)) { + throw new Error( + 'When passing `manualContent`, the following props are ineffectual: `content`, `contentClassName`' + ); + } + if (manualHeader && (header || headerClassName || headerActions)) { + throw new Error( + 'When passing `manualHeader`, the following props are ineffectual: `header`, `headerClassName`, `headerActions`' + ); + } + + return ( + + {manualHeader ?? + (shouldBuildHeader && ( + + {header} + + ))} + {manualContent ? ( + <> + {manualContent} + {children} + + ) : ( + // This wrapper is the keystone of this component + // WARNING: When using `manualContent`, user must implement this feature + // FAQ: A table can NOT be a flex item;
wrap is safest solution + // SEE: https://stackoverflow.com/q/41421512/11817077 +
+ {content} + {children} +
+ )} + + ); +} +SectionTableWrapper.propTypes = { + /** Any additional className(s) for the root element */ + className: PropTypes.string, + /** Alternate way to pass `manualContent` and `content` */ + children: PropTypes.node, + /** The table content itself (content wrapper built automatically) */ + /* RFE: Ideally, limit this to one `InfiniteScrollTable` or `OtherTable` */ + /* SEE: https://github.com/facebook/react/issues/2979 */ + content: PropTypes.node, + /** Any additional className(s) for the content element */ + contentClassName: PropTypes.string, + /** Whether to allow content to scroll */ + contentShouldScroll: PropTypes.bool, + /** The table header text (header element built automatically) */ + header: PropTypes.node, + /** Any table actions for the header element */ + headerActions: PropTypes.node, + /** Any additional className(s) for the header element */ + headerClassName: PropTypes.string, + /** The table 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, + /** Override tag of the root element */ + tagName: PropTypes.string, + isFlexItem: PropTypes.bool, +}; +SectionTableWrapper.defaultProps = { + children: undefined, + className: '', + content: '', + contentClassName: '', + contentShouldScroll: false, + header: '', + headerActions: '', + headerClassName: '', + manualHeader: undefined, + manualContent: undefined, + tagName: 'article', + isFlexItem: false, +}; + +export default SectionTableWrapper; diff --git a/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.module.css b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.module.css new file mode 100644 index 000000000..1fc5f9f73 --- /dev/null +++ b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.module.css @@ -0,0 +1,33 @@ +/* Block */ + +.root { + display: flex; + flex-direction: column; +} + +/* Elements */ + +/* CAVEAT: This is only applied to automatically-built sub-components */ +.header { + flex-shrink: 0; +} + +/* Modifiers */ + +/* Ensure table has height so `.table-wrap` can stretch to fill that height */ +.has-wrap { + flex-grow: 1; +} + +/* NOTE: Similar on: SectionContent, SectionTableWrapper */ +.should-scroll .wrap { + /* We want to permit vertical scrolling, without forcing it */ + /* 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) .wrap { + /* We want to disable vertical and horizontal scrolling */ + overflow: hidden; +} diff --git a/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.test.js b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.test.js new file mode 100644 index 000000000..91e5ab7d1 --- /dev/null +++ b/libs/core-components/src/lib/SectionTableWrapper/SectionTableWrapper.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import SectionTableWrapper from './SectionTableWrapper'; + +const TABLE_MARKUP = ( + + + + + + +
Table Cell
+); + +export const PARAMETER_CLASS_MAP = { + contentShouldScroll: 'should-scroll', +}; +export const PARAMETERS = [...Object.keys(PARAMETER_CLASS_MAP)]; + +describe('SectionTableWrapper', () => { + describe('elements', () => { + it('renders passed children and header', () => { + const { getByRole } = render( + + {TABLE_MARKUP} + + ); + expect(getByRole('table').textContent).toEqual('Table Cell'); + // 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 class names', () => { + it('renders all passed content and class names', () => { + const { container, getByText } = render( + Header Actions} + headerClassName="header-test" + > + {TABLE_MARKUP} + + ); + 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); + }); + it('renders conditional class names', () => { + const { container } = render( + {TABLE_MARKUP} + ); + expect(container.querySelector('[class*="has-wrap"]')).not.toEqual(null); + }); + }); + + describe('parameter class names', () => { + it.each(PARAMETERS)( + 'renders accurate class for boolean parameter "%s"', + (parameter) => { + const parameterObj = { [parameter]: true }; + const { container } = render( + + {TABLE_MARKUP} + + ); + const className = PARAMETER_CLASS_MAP[parameter]; + + expect(container.querySelector(`[class*="${className}"]`)).not.toEqual( + null + ); + } + ); + }); +}); diff --git a/libs/core-components/src/lib/SectionTableWrapper/index.js b/libs/core-components/src/lib/SectionTableWrapper/index.js new file mode 100644 index 000000000..2a0d97d09 --- /dev/null +++ b/libs/core-components/src/lib/SectionTableWrapper/index.js @@ -0,0 +1 @@ +export { default } from './SectionTableWrapper'; diff --git a/libs/core-components/src/lib/ShowMore/ShowMore.jsx b/libs/core-components/src/lib/ShowMore/ShowMore.jsx new file mode 100644 index 000000000..8652f8f42 --- /dev/null +++ b/libs/core-components/src/lib/ShowMore/ShowMore.jsx @@ -0,0 +1,51 @@ +import React, { useState, useCallback } from 'react'; +import { useResizeDetector } from 'react-resize-detector'; +import PropTypes from 'prop-types'; + +import Button from '../Button'; + +import styles from './ShowMore.module.scss'; + +const ShowMore = ({ className, children }) => { + const [expanded, setExpanded] = useState(false); + + const toggleCallback = useCallback(() => { + setExpanded(!expanded); + }, [expanded, setExpanded]); + + const { height, ref } = useResizeDetector(); + + const hasOverflow = + ref && ref.current ? ref.current.scrollHeight > height : false; + + return ( + <> + { +
+ {children} +
+ } + {(hasOverflow || expanded) && ( + + )} + + ); +}; + +ShowMore.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +ShowMore.defaultProps = { + className: '', +}; + +export default ShowMore; diff --git a/libs/core-components/src/lib/ShowMore/ShowMore.module.css b/libs/core-components/src/lib/ShowMore/ShowMore.module.css new file mode 100644 index 000000000..52e42d7a7 --- /dev/null +++ b/libs/core-components/src/lib/ShowMore/ShowMore.module.css @@ -0,0 +1,9 @@ +.clamped { + --lines: 4; + + composes: x-truncate--many-lines from '@tacc/core-styles/dist/tools/x-truncate.css'; +} + +.expanded { + composes: x-untruncate--many-lines from '@tacc/core-styles/dist/tools/x-truncate.css'; +} diff --git a/libs/core-components/src/lib/ShowMore/index.js b/libs/core-components/src/lib/ShowMore/index.js new file mode 100644 index 000000000..1a73fa00e --- /dev/null +++ b/libs/core-components/src/lib/ShowMore/index.js @@ -0,0 +1,3 @@ +import ShowMore from './ShowMore'; + +export default ShowMore; diff --git a/libs/core-components/src/lib/TextCopyField/TextCopyField.jsx b/libs/core-components/src/lib/TextCopyField/TextCopyField.jsx new file mode 100644 index 000000000..6022c5198 --- /dev/null +++ b/libs/core-components/src/lib/TextCopyField/TextCopyField.jsx @@ -0,0 +1,91 @@ +import React, { useCallback, useState } from 'react'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import PropTypes from 'prop-types'; + +import Button from '../Button'; + +import styles from './TextCopyField.module.css'; + +const TextCopyField = ({ value, placeholder, buttonWrapper }) => { + const transitionDuration = 0.15; // second(s) + const stateDuration = 1; // second(s) + const stateTimeout = transitionDuration + stateDuration; // second(s) + + const [isCopied, setIsCopied] = useState(false); + + const onCopy = useCallback(() => { + setIsCopied(true); + + const timeout = setTimeout(() => { + setIsCopied(false); + clearTimeout(timeout); + }, stateTimeout * 1000); + }, [setIsCopied, stateTimeout]); + const isEmpty = !value || value.length === 0; + const onChange = (event) => { + // Swallow keyboard events on the Input control, but + // still allow selecting the text. readOnly property of + // Input is not adequate for this purpose because it + // prevents text selection + event.preventDefault(); + }; + + const ButtonWrapper = buttonWrapper; + const CopyButton = ( + + + + ); + const CopyField = ( + + ); + + return ( + <> + {ButtonWrapper ? ( + + + + ) : ( + + )} + + + ); +}; + +TextCopyField.propTypes = { + buttonWrapper: PropTypes.node, + placeholder: PropTypes.string, + value: PropTypes.string, +}; + +TextCopyField.defaultProps = { + buttonWrapper: undefined, + placeholder: '', + value: '', +}; + +export default TextCopyField; diff --git a/libs/core-components/src/lib/TextCopyField/TextCopyField.module.css b/libs/core-components/src/lib/TextCopyField/TextCopyField.module.css new file mode 100644 index 000000000..5cf53afac --- /dev/null +++ b/libs/core-components/src/lib/TextCopyField/TextCopyField.module.css @@ -0,0 +1,22 @@ +.input { + /* composes: input from '@tacc/core-styles/dist/components/...form.css'; */ +} + +.copy-button { + /* So JavaScript can set this (JavaScript also needs the value) */ + --transition-duration: 0; + + transition: color var(--transition-duration), + background-color var(--transition-duration); + + composes: c-button--secondary from '@tacc/core-styles/dist/components/c-button.css'; +} + +.copy-button.is-copied, +/* FAQ: The pseudo-classes override Bootstrap */ +.copy-button.is-copied:hover, +.copy-button.is-copied:focus, +.copy-button.is-copied:active { + background-color: var(--global-color-success--normal); + color: var(--global-color-primary--xx-light); +} diff --git a/libs/core-components/src/lib/TextCopyField/index.js b/libs/core-components/src/lib/TextCopyField/index.js new file mode 100644 index 000000000..f41d5ff41 --- /dev/null +++ b/libs/core-components/src/lib/TextCopyField/index.js @@ -0,0 +1,3 @@ +import TextCopyField from './TextCopyField'; + +export default TextCopyField; diff --git a/libs/core-components/tsconfig.lib.json b/libs/core-components/tsconfig.app.json similarity index 100% rename from libs/core-components/tsconfig.lib.json rename to libs/core-components/tsconfig.app.json diff --git a/libs/core-components/tsconfig.json b/libs/core-components/tsconfig.json index 4c089585e..99aff8fc8 100644 --- a/libs/core-components/tsconfig.json +++ b/libs/core-components/tsconfig.json @@ -16,10 +16,13 @@ "include": [], "references": [ { - "path": "./tsconfig.lib.json" + "path": "./tsconfig.app.json" }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.node.json" } ] } diff --git a/libs/core-components/tsconfig.node.json b/libs/core-components/tsconfig.node.json new file mode 100644 index 000000000..8292c9be2 --- /dev/null +++ b/libs/core-components/tsconfig.node.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["vite.config.ts"] +} diff --git a/libs/core-components/tsconfig.spec.json b/libs/core-components/tsconfig.spec.json index ff08addd6..b8a950754 100644 --- a/libs/core-components/tsconfig.spec.json +++ b/libs/core-components/tsconfig.spec.json @@ -16,5 +16,9 @@ "**/*.test.jsx", "**/*.spec.jsx", "**/*.d.ts" + ], + "files": [ + "../../node_modules/@nrwl/react/typings/cssmodule.d.ts", + "../../node_modules/@nrwl/react/typings/image.d.ts" ] } diff --git a/libs/core-components/vite.config.ts b/libs/core-components/vite.config.ts new file mode 100644 index 000000000..19acb5e0f --- /dev/null +++ b/libs/core-components/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@tacc/core-styles': path.resolve(__dirname, '../../libs/core-styles/'), + }, + }, + build: { + lib: { + entry: path.resolve(__dirname, './src/index.ts'), + name: '@tacc/core-components', + fileName: (format) => `core-components.${format}.js`, + }, + rollupOptions: { + // Externalized dependencies, that will not be included during build + external: ['react', 'reactstrap'], + output: { + globals: { + react: 'react', + reactstrap: 'reactstrap', + }, + }, + }, + outDir: path.resolve(__dirname, '../../dist/libs/core-components'), + }, +}); diff --git a/libs/core-styles/src/lib/_imports/components/bootstrap.container.css b/libs/core-styles/src/lib/_imports/components/bootstrap.container.css index 97a3a3bcb..f32c8024d 100644 --- a/libs/core-styles/src/lib/_imports/components/bootstrap.container.css +++ b/libs/core-styles/src/lib/_imports/components/bootstrap.container.css @@ -7,7 +7,7 @@ Add to Bootstrap styles. See: Styleguide Components.Bootstrap.Grid */ -@import url("_imports/tools/media-queries.css"); +@import url("../tools/media-queries.css"); @media (--x-wide-and-above) { .container { max-width: var(--global-max-width--x-wide); } diff --git a/libs/core-styles/src/lib/_imports/components/bootstrap.figure.css b/libs/core-styles/src/lib/_imports/components/bootstrap.figure.css index dd1df420c..aa4c7cf36 100644 --- a/libs/core-styles/src/lib/_imports/components/bootstrap.figure.css +++ b/libs/core-styles/src/lib/_imports/components/bootstrap.figure.css @@ -7,7 +7,7 @@ Add to Bootstrap styles. See: Styleguide Components.Bootstrap.Figure */ -@import url("_imports/elements/figure.css"); +@import url("../elements/figure.css"); .figure { @extend figure; diff --git a/libs/core-styles/src/lib/_imports/components/bootstrap.pagination.css b/libs/core-styles/src/lib/_imports/components/bootstrap.pagination.css index 788cbb635..235d5955c 100644 --- a/libs/core-styles/src/lib/_imports/components/bootstrap.pagination.css +++ b/libs/core-styles/src/lib/_imports/components/bootstrap.pagination.css @@ -7,7 +7,7 @@ Style Bootstrap pagination. See: Styleguide Components.Bootstrap.Pagination */ -@import url("_imports/components/c-page.css"); +@import url("../components/c-page.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-button.css b/libs/core-styles/src/lib/_imports/components/c-button.css index 555110361..c035c63e0 100644 --- a/libs/core-styles/src/lib/_imports/components/c-button.css +++ b/libs/core-styles/src/lib/_imports/components/c-button.css @@ -14,7 +14,7 @@ Markup: c-button.html Styleguide Components.Button */ -@import url("_imports/tools/x-truncate.css"); +@import url("../tools/x-truncate.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-callout.css b/libs/core-styles/src/lib/_imports/components/c-callout.css index a8654ae0e..fd5f3245f 100644 --- a/libs/core-styles/src/lib/_imports/components/c-callout.css +++ b/libs/core-styles/src/lib/_imports/components/c-callout.css @@ -7,8 +7,8 @@ Markup: c-callout.html Styleguide Components.Callout */ -@import url("_imports/tools/media-queries.css"); -@import url("_imports/tools/x-article-link.css"); +@import url("../tools/media-queries.css"); +@import url("../tools/x-article-link.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-card.css b/libs/core-styles/src/lib/_imports/components/c-card.css index 6b6d4228c..a0e64f951 100644 --- a/libs/core-styles/src/lib/_imports/components/c-card.css +++ b/libs/core-styles/src/lib/_imports/components/c-card.css @@ -11,7 +11,7 @@ Markup: c-card.html Styleguide Components.Card */ -@import url("_imports/tools/x-article-link.css"); +@import url("../tools/x-article-link.css"); /* Modifiers */ diff --git a/libs/core-styles/src/lib/_imports/components/c-data-list.css b/libs/core-styles/src/lib/_imports/components/c-data-list.css index 9a197d2d2..9e2c34055 100644 --- a/libs/core-styles/src/lib/_imports/components/c-data-list.css +++ b/libs/core-styles/src/lib/_imports/components/c-data-list.css @@ -29,7 +29,7 @@ Markup: c-data-list.html Styleguide Components.DataList */ -@import url("_imports/tools/x-truncate.css"); +@import url("../tools/x-truncate.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-nav.css b/libs/core-styles/src/lib/_imports/components/c-nav.css index 5e25ff030..f8a057157 100644 --- a/libs/core-styles/src/lib/_imports/components/c-nav.css +++ b/libs/core-styles/src/lib/_imports/components/c-nav.css @@ -17,7 +17,7 @@ Markup: c-nav.html Styleguide Components.Nav */ -@import url("_imports/tools/media-queries.css"); +@import url("../tools/media-queries.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-see-all-link.css b/libs/core-styles/src/lib/_imports/components/c-see-all-link.css index f6cec9d72..7710d0f7d 100644 --- a/libs/core-styles/src/lib/_imports/components/c-see-all-link.css +++ b/libs/core-styles/src/lib/_imports/components/c-see-all-link.css @@ -11,7 +11,7 @@ Markup: Styleguide Components.SeeAllLink */ -@import url("_imports/tools/x-truncate.css"); +@import url("../tools/x-truncate.css"); diff --git a/libs/core-styles/src/lib/_imports/components/c-show-more.css b/libs/core-styles/src/lib/_imports/components/c-show-more.css index 09a47df41..c28c1cb43 100644 --- a/libs/core-styles/src/lib/_imports/components/c-show-more.css +++ b/libs/core-styles/src/lib/_imports/components/c-show-more.css @@ -11,7 +11,7 @@ A CSS-only way to support a "Show More…" feature. It requires a container and Styleguide: Components.ShowMore */ -@import url("_imports/tools/x-truncate.css"); +@import url("../tools/x-truncate.css"); /* Truncation */ diff --git a/libs/core-styles/src/lib/_imports/objects/o-grid.css b/libs/core-styles/src/lib/_imports/objects/o-grid.css index e2139f904..be1629ec7 100644 --- a/libs/core-styles/src/lib/_imports/objects/o-grid.css +++ b/libs/core-styles/src/lib/_imports/objects/o-grid.css @@ -15,8 +15,8 @@ Markup: o-grid.html Styleguide Objects.Grid */ -@import url("_imports/tools/media-queries.css"); -@import url("_imports/tools/x-grid.css"); +@import url("../tools/media-queries.css"); +@import url("../tools/x-grid.css"); diff --git a/libs/core-styles/src/lib/_imports/objects/o-offset-content.css b/libs/core-styles/src/lib/_imports/objects/o-offset-content.css index f9ce110de..5efbe36d6 100644 --- a/libs/core-styles/src/lib/_imports/objects/o-offset-content.css +++ b/libs/core-styles/src/lib/_imports/objects/o-offset-content.css @@ -5,7 +5,7 @@ Content that should be offset from the flow of text within which it is placed. Styleguide Objects.OffsetContent */ -@import url("_imports/tools/media-queries.css"); +@import url("../tools/media-queries.css"); diff --git a/libs/core-styles/src/lib/_imports/objects/o-section.css b/libs/core-styles/src/lib/_imports/objects/o-section.css index d85150d10..22181f380 100644 --- a/libs/core-styles/src/lib/_imports/objects/o-section.css +++ b/libs/core-styles/src/lib/_imports/objects/o-section.css @@ -32,9 +32,9 @@ Markup: o-section.html Styleguide Objects.Section */ -@import url("_imports/tools/media-queries.css"); -@import url("_imports/tools/x-layout.css"); -@import url("_imports/tools/x-fake-border.css"); +@import url("../tools/media-queries.css"); +@import url("../tools/x-layout.css"); +@import url("../tools/x-fake-border.css"); diff --git a/libs/core-styles/src/lib/_imports/tools/x-layout.css b/libs/core-styles/src/lib/_imports/tools/x-layout.css index 58018f1ef..e6945cb4d 100644 --- a/libs/core-styles/src/lib/_imports/tools/x-layout.css +++ b/libs/core-styles/src/lib/_imports/tools/x-layout.css @@ -11,7 +11,7 @@ Styles that allow re-usable layouts. Styleguide Tools.ExtendsAndMixins.Layout */ -@import url("_imports/tools/media-queries.css"); +@import url("../tools/media-queries.css"); diff --git a/libs/core-styles/src/lib/_imports/trumps/s-article-list.css b/libs/core-styles/src/lib/_imports/trumps/s-article-list.css index b1237bc2e..b7140c3eb 100644 --- a/libs/core-styles/src/lib/_imports/trumps/s-article-list.css +++ b/libs/core-styles/src/lib/_imports/trumps/s-article-list.css @@ -7,9 +7,9 @@ Markup: s-article-list.html Styleguide Trumps.Scopes.ArticleList */ -@import url("_imports/tools/x-truncate.css"); -@import url("_imports/tools/x-layout.css"); -@import url("_imports/tools/x-article-link.css"); +@import url("../tools/x-truncate.css"); +@import url("../tools/x-layout.css"); +@import url("../tools/x-article-link.css"); diff --git a/libs/core-styles/src/lib/_imports/trumps/s-article-preview.css b/libs/core-styles/src/lib/_imports/trumps/s-article-preview.css index cbe36e415..544fe436c 100644 --- a/libs/core-styles/src/lib/_imports/trumps/s-article-preview.css +++ b/libs/core-styles/src/lib/_imports/trumps/s-article-preview.css @@ -7,8 +7,8 @@ Markup: s-article-preview.html Styleguide Trumps.Scopes.ArticlePreview */ -@import url("_imports/tools/x-truncate.css"); -@import url("_imports/tools/x-article-link.css"); +@import url("../tools/x-truncate.css"); +@import url("../tools/x-article-link.css"); diff --git a/libs/core-styles/src/lib/_imports/trumps/s-breadcrumbs.css b/libs/core-styles/src/lib/_imports/trumps/s-breadcrumbs.css index a85f0ac69..b388cd8c2 100644 --- a/libs/core-styles/src/lib/_imports/trumps/s-breadcrumbs.css +++ b/libs/core-styles/src/lib/_imports/trumps/s-breadcrumbs.css @@ -25,7 +25,7 @@ Markup: Styleguide Trumps.Scopes.Breadcrumbs */ -@import url("_imports/tools/x-truncate.css"); +@import url("../tools/x-truncate.css"); diff --git a/libs/core-styles/src/lib/_imports/trumps/s-system-specs.css b/libs/core-styles/src/lib/_imports/trumps/s-system-specs.css index 15b25cf1f..6d2a044ca 100644 --- a/libs/core-styles/src/lib/_imports/trumps/s-system-specs.css +++ b/libs/core-styles/src/lib/_imports/trumps/s-system-specs.css @@ -8,7 +8,7 @@ Styles for System Specifications content which assumes external code: Styleguide Trumps.Scopes.SystemSpecs */ -@import url("_imports/tools/media-queries.css"); +@import url("../tools/media-queries.css"); diff --git a/package-lock.json b/package-lock.json index f3d347a83..b402b15f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "core-js": "^3.6.5", "react": "18.1.0", "react-dom": "18.1.0", + "reactstrap": "^9.1.1", "regenerator-runtime": "0.13.7", "tslib": "^2.3.0" }, @@ -25,6 +26,7 @@ "@nrwl/react": "14.1.7", "@nrwl/web": "14.1.7", "@nrwl/workspace": "14.1.7", + "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "13.1.1", "@types/jest": "27.4.1", "@types/node": "16.11.7", @@ -34,6 +36,7 @@ "@typescript-eslint/parser": "~5.18.0", "@vitejs/plugin-react": "^1.3.2", "babel-jest": "27.5.1", + "babel-plugin-postcss": "^1.1.0", "cypress": "^9.1.0", "eslint": "~8.12.0", "eslint-config-prettier": "8.1.0", @@ -1966,7 +1969,6 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.0.tgz", "integrity": "sha512-YMQvx/6nKEaucl0MY56mwIG483xk8SDNdlUwb2Ts6FUpr7fm85DxEmsY18LXBNhcTz6tO6JwZV8w1W06v8UKeg==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.4" }, @@ -3261,6 +3263,15 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3985,6 +3996,50 @@ "node": ">=6.0" } }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/react": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.1.1.tgz", @@ -4391,6 +4446,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -5605,6 +5669,114 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/babel-plugin-postcss": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-postcss/-/babel-plugin-postcss-1.1.0.tgz", + "integrity": "sha512-cv2xIZg/yzjIhT4rFFCxiuMK+sFoXe469bcp2B38Wsnixvx5JSuCy3flJSpNquNoQO95hH7erLBDcwAWcz32Wg==", + "dev": true, + "dependencies": { + "postcss-load-config": "^2.1.0", + "sync-rpc": "^1.3.6" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@babel/core": "^7.11.6", + "@babel/types": "^7.11.5" + } + }, + "node_modules/babel-plugin-postcss/node_modules/cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "dependencies": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-plugin-postcss/node_modules/import-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", + "integrity": "sha512-Ew5AZzJQFqrOV5BTW3EIoHAnoie1LojZLXKcCQ/yTRyVZosBhK1x1ViYjHGf5pAFOq8ZyChZp6m/fSN7pJyZtg==", + "dev": true, + "dependencies": { + "import-from": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-plugin-postcss/node_modules/import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", + "dev": true, + "dependencies": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-plugin-postcss/node_modules/import-from": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz", + "integrity": "sha512-0vdnLL2wSGnhlRmzHJAg5JHjt1l2vYhzJ7tNLGbeVg0fse56tpGaH0uzH+r9Slej+BSXXEHvBKDEnVSLLE9/+w==", + "dev": true, + "dependencies": { + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-plugin-postcss/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-plugin-postcss/node_modules/postcss-load-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.2.tgz", + "integrity": "sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw==", + "dev": true, + "dependencies": { + "cosmiconfig": "^5.0.0", + "import-cwd": "^2.0.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/babel-plugin-postcss/node_modules/resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-transform-async-to-promises": { "version": "0.8.18", "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.18.tgz", @@ -5996,6 +6168,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==", + "dev": true, + "dependencies": { + "callsites": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/caller-callsite/node_modules/callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==", + "dev": true, + "dependencies": { + "caller-callsite": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6168,6 +6373,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -7480,6 +7690,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -7543,8 +7759,7 @@ "node_modules/csstype": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true + "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "node_modules/cypress": { "version": "9.6.1", @@ -7975,6 +8190,15 @@ "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -10153,6 +10377,15 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -11055,6 +11288,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -12246,6 +12488,12 @@ "node": ">=4" } }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13473,7 +13721,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -14383,7 +14630,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -14393,8 +14639,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -14611,12 +14856,31 @@ "react": "^18.1.0" } }, + "node_modules/react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz", @@ -14659,6 +14923,38 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reactstrap": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-9.1.1.tgz", + "integrity": "sha512-XlQI5qKHQ4QMpye4GxLgoj8rv+qsypvzMcs2KA11DeYjT82LcS48ttfNqOodDYyeCYv8t89gd9THXkjGhoYp5A==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@popperjs/core": "^2.6.0", + "classnames": "^2.2.3", + "prop-types": "^15.5.8", + "react-popper": "^2.2.4", + "react-transition-group": "^4.4.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -16984,6 +17280,15 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/sync-rpc": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/sync-rpc/-/sync-rpc-1.3.6.tgz", + "integrity": "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw==", + "dev": true, + "dependencies": { + "get-port": "^3.1.0" + } + }, "node_modules/table": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", @@ -17999,6 +18304,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", @@ -20010,7 +20323,6 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.0.tgz", "integrity": "sha512-YMQvx/6nKEaucl0MY56mwIG483xk8SDNdlUwb2Ts6FUpr7fm85DxEmsY18LXBNhcTz6tO6JwZV8w1W06v8UKeg==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -21054,6 +21366,11 @@ } } }, + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==" + }, "@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -21474,6 +21791,41 @@ } } }, + "@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "@testing-library/react": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.1.1.tgz", @@ -21867,6 +22219,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -22772,6 +23133,84 @@ "@babel/helper-define-polyfill-provider": "^0.3.1" } }, + "babel-plugin-postcss": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-postcss/-/babel-plugin-postcss-1.1.0.tgz", + "integrity": "sha512-cv2xIZg/yzjIhT4rFFCxiuMK+sFoXe469bcp2B38Wsnixvx5JSuCy3flJSpNquNoQO95hH7erLBDcwAWcz32Wg==", + "dev": true, + "requires": { + "postcss-load-config": "^2.1.0", + "sync-rpc": "^1.3.6" + }, + "dependencies": { + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "import-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", + "integrity": "sha512-Ew5AZzJQFqrOV5BTW3EIoHAnoie1LojZLXKcCQ/yTRyVZosBhK1x1ViYjHGf5pAFOq8ZyChZp6m/fSN7pJyZtg==", + "dev": true, + "requires": { + "import-from": "^2.1.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "import-from": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz", + "integrity": "sha512-0vdnLL2wSGnhlRmzHJAg5JHjt1l2vYhzJ7tNLGbeVg0fse56tpGaH0uzH+r9Slej+BSXXEHvBKDEnVSLLE9/+w==", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "postcss-load-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.2.tgz", + "integrity": "sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "import-cwd": "^2.0.0" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", + "dev": true + } + } + }, "babel-plugin-transform-async-to-promises": { "version": "0.8.18", "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.18.tgz", @@ -23071,6 +23510,32 @@ "get-intrinsic": "^1.0.2" } }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==", + "dev": true, + "requires": { + "callsites": "^2.0.0" + }, + "dependencies": { + "callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==", + "dev": true + } + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==", + "dev": true, + "requires": { + "caller-callsite": "^2.0.0" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -23188,6 +23653,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -24115,6 +24585,12 @@ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", "dev": true }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -24163,8 +24639,7 @@ "csstype": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true + "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "cypress": { "version": "9.6.1", @@ -24496,6 +24971,15 @@ "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -26065,6 +26549,12 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==", + "dev": true + }, "get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -26728,6 +27218,12 @@ "has-tostringtag": "^1.0.0" } }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==", + "dev": true + }, "is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -27631,6 +28127,12 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -28579,8 +29081,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { "version": "1.12.0", @@ -29229,7 +29730,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -29239,8 +29739,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -29402,12 +29901,26 @@ "scheduler": "^0.22.0" } }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } + }, "react-refresh": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz", @@ -29443,6 +29956,30 @@ } } }, + "react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "reactstrap": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-9.1.1.tgz", + "integrity": "sha512-XlQI5qKHQ4QMpye4GxLgoj8rv+qsypvzMcs2KA11DeYjT82LcS48ttfNqOodDYyeCYv8t89gd9THXkjGhoYp5A==", + "requires": { + "@babel/runtime": "^7.12.5", + "@popperjs/core": "^2.6.0", + "classnames": "^2.2.3", + "prop-types": "^15.5.8", + "react-popper": "^2.2.4", + "react-transition-group": "^4.4.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -31202,6 +31739,15 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "sync-rpc": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/sync-rpc/-/sync-rpc-1.3.6.tgz", + "integrity": "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw==", + "dev": true, + "requires": { + "get-port": "^3.1.0" + } + }, "table": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", @@ -31956,6 +32502,14 @@ "makeerror": "1.0.12" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", diff --git a/package.json b/package.json index 5fe900312..a2352dc05 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "core-js": "^3.6.5", "react": "18.1.0", "react-dom": "18.1.0", + "reactstrap": "^9.1.1", "regenerator-runtime": "0.13.7", "tslib": "^2.3.0" }, @@ -25,6 +26,7 @@ "@nrwl/react": "14.1.7", "@nrwl/web": "14.1.7", "@nrwl/workspace": "14.1.7", + "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "13.1.1", "@types/jest": "27.4.1", "@types/node": "16.11.7", @@ -34,6 +36,7 @@ "@typescript-eslint/parser": "~5.18.0", "@vitejs/plugin-react": "^1.3.2", "babel-jest": "27.5.1", + "babel-plugin-postcss": "^1.1.0", "cypress": "^9.1.0", "eslint": "~8.12.0", "eslint-config-prettier": "8.1.0",