diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6a443193906..a6e2d230e7c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,7 +23,7 @@ body: id: reproduce attributes: label: Steps to reproduce - description: 'How do we reproduce the error you described above?' + description: 'How do we reproduce the error you described above? Feel free to use the `@primer/react` template on [CodeSandbox](https://codesandbox.io/s/primer-react-qyyepc) to get started' placeholder: | 1. Go to '...' 2. Click on '....' diff --git a/lib-esm/Checkbox.js b/lib-esm/Checkbox.js index dc2049624cb..5e0d13c564a 100644 --- a/lib-esm/Checkbox.js +++ b/lib-esm/Checkbox.js @@ -1,7 +1,7 @@ import styled from 'styled-components'; import React, { useContext } from 'react'; import sx from './sx.js'; -import useLayoutEffect from './utils/useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from './utils/useIsomorphicLayoutEffect.js'; import { CheckboxGroupContext } from './CheckboxGroupContext.js'; import getGlobalFocusStyles from './_getGlobalFocusStyles.js'; import { useProvidedRefOrCreate } from './hooks/useProvidedRefOrCreate.js'; @@ -34,7 +34,7 @@ const Checkbox = /*#__PURE__*/React.forwardRef(({ onChange && onChange(e); }; - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (checkboxRef.current) { checkboxRef.current.indeterminate = indeterminate || false; } diff --git a/lib-esm/Overlay.js b/lib-esm/Overlay.js index ad6d768c9a9..a5ff9245bf8 100644 --- a/lib-esm/Overlay.js +++ b/lib-esm/Overlay.js @@ -1,6 +1,6 @@ import styled from 'styled-components'; import React, { useRef, useEffect } from 'react'; -import useLayoutEffect from './utils/useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from './utils/useIsomorphicLayoutEffect.js'; import { get } from './constants.js'; import Portal from './Portal/index.js'; import sx from './sx.js'; @@ -119,7 +119,7 @@ const Overlay = /*#__PURE__*/React.forwardRef(({ overlayRef.current.style.height = `${overlayRef.current.clientHeight}px`; } }, [height]); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { var _overlayRef$current2; const { diff --git a/lib-esm/Portal/Portal.js b/lib-esm/Portal/Portal.js index 4f3dd0aad59..c8aa6483f36 100644 --- a/lib-esm/Portal/Portal.js +++ b/lib-esm/Portal/Portal.js @@ -1,6 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; const PRIMER_PORTAL_ROOT_ID = '__primerPortalRoot__'; const DEFAULT_PORTAL_CONTAINER_NAME = '__default__'; @@ -59,7 +59,7 @@ const Portal = ({ hostElement.style.position = 'relative'; hostElement.style.zIndex = '1'; const elementRef = React.useRef(hostElement); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { let containerName = _containerName; if (containerName === undefined) { diff --git a/lib-esm/constants.js b/lib-esm/constants.js index d87c1ad8381..1c7ce314d16 100644 --- a/lib-esm/constants.js +++ b/lib-esm/constants.js @@ -19,6 +19,8 @@ const whiteSpace = system({ }); const TYPOGRAPHY = compose(styledSystem.typography, whiteSpace); // Border props -compose(styledSystem.border, styledSystem.shadow); +const BORDER = compose(styledSystem.border, styledSystem.shadow); +// Layout props +const LAYOUT = styledSystem.layout; -export { COMMON, TYPOGRAPHY, get }; +export { BORDER, COMMON, LAYOUT, TYPOGRAPHY, get }; diff --git a/lib-esm/hooks/index.js b/lib-esm/hooks/index.js new file mode 100644 index 00000000000..d729f19676a --- /dev/null +++ b/lib-esm/hooks/index.js @@ -0,0 +1,12 @@ +export { useOnOutsideClick } from './useOnOutsideClick.js'; +export { useProvidedRefOrCreate } from './useProvidedRefOrCreate.js'; +export { useOnEscapePress } from './useOnEscapePress.js'; +export { useOpenAndCloseFocus } from './useOpenAndCloseFocus.js'; +export { useAnchoredPosition } from './useAnchoredPosition.js'; +export { useOverlay } from './useOverlay.js'; +export { useRenderForcingRef } from './useRenderForcingRef.js'; +export { useProvidedStateOrCreate } from './useProvidedStateOrCreate.js'; +export { useMenuInitialFocus } from './useMenuInitialFocus.js'; +export { useMenuKeyboardNavigation } from './useMenuKeyboardNavigation.js'; +export { useMnemonics } from './useMnemonics.js'; +export { useRefObjectAsForwardedRef } from './useRefObjectAsForwardedRef.js'; diff --git a/lib-esm/hooks/useAnchoredPosition.js b/lib-esm/hooks/useAnchoredPosition.js index 9d670042810..5912177806b 100644 --- a/lib-esm/hooks/useAnchoredPosition.js +++ b/lib-esm/hooks/useAnchoredPosition.js @@ -2,7 +2,7 @@ import React from 'react'; import { getAnchoredPosition } from '@primer/behaviors'; import { useProvidedRefOrCreate } from './useProvidedRefOrCreate.js'; import { useResizeObserver } from './useResizeObserver.js'; -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; /** * Calculates the top and left values for an absolutely-positioned floating element @@ -25,7 +25,7 @@ function useAnchoredPosition(settings, dependencies = []) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps [floatingElementRef, anchorElementRef, ...dependencies]); - useLayoutEffect(updatePosition, [updatePosition]); + useIsomorphicLayoutEffect(updatePosition, [updatePosition]); useResizeObserver(updatePosition); return { floatingElementRef, diff --git a/lib-esm/hooks/useControllableState.js b/lib-esm/hooks/useControllableState.js index 95271a090db..38053024103 100644 --- a/lib-esm/hooks/useControllableState.js +++ b/lib-esm/hooks/useControllableState.js @@ -43,15 +43,11 @@ function useControllableState({ const controlledValue = value !== undefined; // Uncontrolled -> Controlled // If the component prop is uncontrolled, the prop value should be undefined - if (controlled.current === false && controlledValue) { - warn(); - } // Controlled -> Uncontrolled + if (controlled.current === false && controlledValue) ; // Controlled -> Uncontrolled // If the component prop is controlled, the prop value should be defined - if (controlled.current === true && !controlledValue) { - warn(); - } + if (controlled.current === true && !controlledValue) ; }, [name, value]); if (controlled.current === true) { @@ -60,8 +56,5 @@ function useControllableState({ return [state, setState]; } -/** Warn when running in a development environment */ - -const warn = function emptyFunction() {}; export { useControllableState }; diff --git a/lib-esm/hooks/useMedia.js b/lib-esm/hooks/useMedia.js index 39b91eef58d..98beaeeee00 100644 --- a/lib-esm/hooks/useMedia.js +++ b/lib-esm/hooks/useMedia.js @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, createContext } from 'react'; +import React, { useContext, useEffect, createContext, useState } from 'react'; import { canUseDOM } from '../utils/environment.js'; /** @@ -77,5 +77,55 @@ function useMedia(mediaQueryString, defaultState) { // be used for development and demo purposes to emulate specific features if // unavailable through devtools const MatchMediaContext = /*#__PURE__*/createContext({}); +const defaultFeatures = {}; +/** + * Use `MatchMedia` to emulate media conditions by passing in feature + * queries to the `features` prop. If a component uses `useMedia` with the + * feature passed in to `MatchMedia` it will force its value to match what is + * provided to `MatchMedia` + * + * This should be used for development and documentation only in situations + * where devtools cannot emulate this feature + * + * @example + * + * + * + */ + +function MatchMedia({ + children, + features = defaultFeatures +}) { + const value = useShallowObject(features); + return /*#__PURE__*/React.createElement(MatchMediaContext.Provider, { + value: value + }, children); +} +MatchMedia.displayName = "MatchMedia"; + +/** + * Utility hook to provide a stable identity for a "simple" object which + * contains only primitive values. This provides a `useMemo`-esque signature + * without dealing with shallow equality checks in the dependency array. + * + * Note (perf): this hook iterates through keys and values of the object if the + * shallow equality check is false each time the hook is called + */ +function useShallowObject(object) { + const [value, setValue] = useState(object); + + if (value !== object) { + const match = Object.keys(object).every(key => { + return object[key] === value[key]; + }); + + if (!match) { + setValue(object); + } + } + + return value; +} -export { useMedia }; +export { MatchMedia, useMedia }; diff --git a/lib-esm/hooks/useResizeObserver.js b/lib-esm/hooks/useResizeObserver.js index 5892a706333..75f79e3ddd2 100644 --- a/lib-esm/hooks/useResizeObserver.js +++ b/lib-esm/hooks/useResizeObserver.js @@ -1,12 +1,12 @@ import { useRef } from 'react'; -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; function useResizeObserver(callback, target) { const savedCallback = useRef(callback); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { savedCallback.current = callback; }); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { const targetEl = target && 'current' in target ? target.current : document.documentElement; if (!targetEl) { diff --git a/lib-esm/index.js b/lib-esm/index.js index 3c9208d359a..883bd314812 100644 --- a/lib-esm/index.js +++ b/lib-esm/index.js @@ -1,4 +1,3 @@ -export { default as theme } from './theme-preval.js'; export { get as themeGet } from './constants.js'; export { default as BaseStyles } from './BaseStyles.js'; export { default as ThemeProvider, useColorSchemeVar, useTheme } from './ThemeProvider.js'; @@ -63,6 +62,7 @@ export { default as UnderlineNav } from './UnderlineNav.js'; export { default as Checkbox } from './Checkbox.js'; export { default as Textarea } from './Textarea.js'; export { default as sx } from './sx.js'; +export { default as theme } from './theme-preval.js'; export { PageLayout } from './PageLayout/PageLayout.js'; export { AnchoredOverlay } from './AnchoredOverlay/AnchoredOverlay.js'; export { default as Autocomplete } from './Autocomplete/Autocomplete.js'; diff --git a/lib-esm/polyfills/eventListenerSignal.js b/lib-esm/polyfills/eventListenerSignal.js new file mode 100644 index 00000000000..7ec2c8945c8 --- /dev/null +++ b/lib-esm/polyfills/eventListenerSignal.js @@ -0,0 +1,59 @@ +/* + +This file polyfills the following: https://github.com/whatwg/dom/issues/911 +Once all targeted browsers support this DOM feature, this polyfill can be deleted. + +This allows users to pass an AbortSignal to a call to addEventListener as part of the +AddEventListenerOptions object. When the signal is aborted, the event listener is +removed. + +*/ +let signalSupported = false; + +function noop() {} + +try { + const options = Object.create({}, { + signal: { + get() { + signalSupported = true; + } + + } + }); + window.addEventListener('test', noop, options); + window.removeEventListener('test', noop, options); +} catch (e) { + /* */ +} + +function featureSupported() { + return signalSupported; +} + +function monkeyPatch() { + if (typeof window === 'undefined') { + return; + } + + const originalAddEventListener = EventTarget.prototype.addEventListener; + + EventTarget.prototype.addEventListener = function (name, originalCallback, optionsOrCapture) { + if (typeof optionsOrCapture === 'object' && 'signal' in optionsOrCapture && optionsOrCapture.signal instanceof AbortSignal) { + originalAddEventListener.call(optionsOrCapture.signal, 'abort', () => { + this.removeEventListener(name, originalCallback, optionsOrCapture); + }); + } + + return originalAddEventListener.call(this, name, originalCallback, optionsOrCapture); + }; +} + +function polyfill() { + if (!featureSupported()) { + monkeyPatch(); + signalSupported = true; + } +} + +export { polyfill }; diff --git a/lib-esm/utils/create-slots.js b/lib-esm/utils/create-slots.js index e45a33aa740..91594215244 100644 --- a/lib-esm/utils/create-slots.js +++ b/lib-esm/utils/create-slots.js @@ -1,6 +1,6 @@ import React from 'react'; import { useForceUpdate } from './use-force-update.js'; -import useLayoutEffect from './useIsomorphicLayoutEffect.js'; +import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect.js'; /** createSlots is a factory that can create a * typesafe Slots + Slot pair to use in a component definition @@ -33,7 +33,7 @@ const createSlots = slotNames => { const rerenderWithSlots = useForceUpdate(); const [isMounted, setIsMounted] = React.useState(false); // fires after all the effects in children - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { rerenderWithSlots(); setIsMounted(true); }, [rerenderWithSlots]); @@ -72,7 +72,7 @@ const createSlots = slotNames => { unregisterSlot, context } = React.useContext(SlotsContext); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { registerSlot(name, typeof children === 'function' ? children(context) : children); return () => unregisterSlot(name); }, [name, children, registerSlot, unregisterSlot, context]); @@ -85,6 +85,4 @@ const createSlots = slotNames => { }; }; -var createSlots$1 = createSlots; - -export { createSlots$1 as default }; +export { createSlots as default }; diff --git a/lib-esm/utils/deprecate.js b/lib-esm/utils/deprecate.js new file mode 100644 index 00000000000..b6d052b59c6 --- /dev/null +++ b/lib-esm/utils/deprecate.js @@ -0,0 +1,55 @@ +import 'react'; + +const noop = () => {}; // eslint-disable-next-line import/no-mutable-exports + + +let deprecate = noop; + +let useDeprecation = null; + +{ + useDeprecation = () => { + return noop; + }; +} +class Deprecations { + static instance = null; + + static get() { + if (!Deprecations.instance) { + Deprecations.instance = new Deprecations(); + } + + return Deprecations.instance; + } + + constructor() { + this.deprecations = []; + } + + static deprecate({ + name, + message, + version + }) { + const msg = `WARNING! ${name} is deprecated and will be removed in version ${version}. ${message}`; // eslint-disable-next-line no-console + + console.warn(msg); + this.get().deprecations.push({ + name, + message, + version + }); + } + + static getDeprecations() { + return this.get().deprecations; + } + + static clearDeprecations() { + this.get().deprecations.length = 0; + } + +} + +export { Deprecations, deprecate, useDeprecation }; diff --git a/lib-esm/utils/polymorphic.js b/lib-esm/utils/polymorphic.js new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/lib-esm/utils/polymorphic.js @@ -0,0 +1 @@ + diff --git a/lib-esm/utils/ssr.js b/lib-esm/utils/ssr.js new file mode 100644 index 00000000000..ec1a73776e3 --- /dev/null +++ b/lib-esm/utils/ssr.js @@ -0,0 +1 @@ +export { SSRProvider, useSSRSafeId } from '@react-aria/ssr'; diff --git a/lib-esm/utils/story-helpers.js b/lib-esm/utils/story-helpers.js new file mode 100644 index 00000000000..8672a3bc550 --- /dev/null +++ b/lib-esm/utils/story-helpers.js @@ -0,0 +1,278 @@ +import React from 'react'; +import { createGlobalStyle } from 'styled-components'; +import { get } from '../constants.js'; +import theme from '../theme-preval.js'; +import Box from '../Box.js'; +import ThemeProvider from '../ThemeProvider.js'; +import BaseStyles from '../BaseStyles.js'; + +// set global theme styles for each story +const GlobalStyle = createGlobalStyle(["body{background-color:", ";color:", ";}"], get('colors.canvas.default'), get('colors.fg.default')); // only remove padding for multi-theme view grid + +const GlobalStyleMultiTheme = createGlobalStyle(["body{padding:0 !important;}"]); +const withThemeProvider = (Story, context) => { + // used for testing ThemeProvider.stories.tsx + if (context.parameters.disableThemeDecorator) return /*#__PURE__*/React.createElement(Story, context); + const { + colorScheme + } = context.globals; + + if (colorScheme === 'all') { + return /*#__PURE__*/React.createElement(Box, { + sx: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(0, 1fr))', + height: '100vh' + } + }, /*#__PURE__*/React.createElement(GlobalStyleMultiTheme, null), Object.keys(theme.colorSchemes).map(scheme => /*#__PURE__*/React.createElement(ThemeProvider, { + key: scheme, + colorMode: "day", + dayScheme: scheme + }, /*#__PURE__*/React.createElement(BaseStyles, null, /*#__PURE__*/React.createElement(Box, { + sx: { + padding: '1rem', + height: '100%', + backgroundColor: 'canvas.default', + color: 'fg.default' + } + }, /*#__PURE__*/React.createElement("div", { + id: `html-addon-root-${scheme}` + }, /*#__PURE__*/React.createElement(Story, context))))))); + } + + return /*#__PURE__*/React.createElement(ThemeProvider, { + colorMode: "day", + dayScheme: colorScheme + }, /*#__PURE__*/React.createElement(GlobalStyle, null), /*#__PURE__*/React.createElement(BaseStyles, null, /*#__PURE__*/React.createElement("div", { + id: "html-addon-root" + }, /*#__PURE__*/React.createElement(Story, context)))); +}; +withThemeProvider.displayName = "withThemeProvider"; +const toolbarTypes = { + colorScheme: { + name: 'Color scheme', + description: 'Switch color scheme', + defaultValue: 'light', + toolbar: { + icon: 'photo', + items: [...Object.keys(theme.colorSchemes), 'all'], + showName: true + } + } +}; +const inputWrapperArgTypes = { + block: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + contrast: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + placeholder: { + defaultValue: '', + control: { + type: 'text' + } + }, + size: { + name: 'size (input)', + // TODO: remove '(input)' + defaultValue: 'medium', + options: ['small', 'medium', 'large'], + control: { + type: 'radio' + } + }, + validationStatus: { + defaultValue: undefined, + options: ['error', 'success', 'warning', undefined], + control: { + type: 'radio' + } + } +}; +const textInputArgTypesUnsorted = { ...inputWrapperArgTypes, + loading: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + loaderPosition: { + defaultValue: 'auto', + options: ['auto', 'leading', 'trailing'], + control: { + type: 'radio' + } + }, + monospace: { + defaultValue: false, + control: { + type: 'boolean' + } + } +}; // Alphabetize and optionally categorize the props + +const getTextInputArgTypes = category => Object.keys(textInputArgTypesUnsorted).sort().reduce((obj, key) => { + obj[key] = category ? { // have to do weird type casting so we can spread the object + ...textInputArgTypesUnsorted[key], + table: { + category + } + } : textInputArgTypesUnsorted[key]; + return obj; +}, {}); +const textInputExcludedControlKeys = ['as', 'icon', 'leadingVisual', 'sx', 'trailingVisual', 'trailingAction']; +const textInputWithTokensArgTypes = { + hideTokenRemoveButtons: { + defaultValue: false, + type: 'boolean', + table: { + category: 'TextInputWithTokens props' + } + }, + maxHeight: { + type: 'string', + defaultValue: 'none', + description: 'Any valid value for the CSS max-height property', + table: { + category: 'TextInputWithTokens props' + } + }, + preventTokenWrapping: { + defaultValue: false, + type: 'boolean', + table: { + category: 'TextInputWithTokens props' + } + }, + size: { + name: 'size (token size)', + defaultValue: 'xlarge', + options: ['small', 'medium', 'large', 'xlarge'], + control: { + type: 'radio' + }, + table: { + category: 'TextInputWithTokens props' + } + }, + visibleTokenCount: { + defaultValue: 999, + type: 'number', + table: { + category: 'TextInputWithTokens props' + } + } +}; +const formControlArgTypes = { + // FormControl + required: { + defaultValue: false, + control: { + type: 'boolean' + }, + table: { + category: 'FormControl' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + }, + table: { + category: 'FormControl' + } + }, + // FormControl.Label + labelChildren: { + name: 'children', + type: 'string', + defaultValue: 'Label', + table: { + category: 'FormControl.Label' + } + }, + visuallyHidden: { + defaultValue: false, + type: 'boolean', + table: { + category: 'FormControl.Label' + } + }, + // FormControl.Caption + captionChildren: { + name: 'children', + type: 'string', + defaultValue: '', + table: { + category: 'FormControl.Caption' + } + }, + // FormControl.Validation + validationChildren: { + name: 'children', + type: 'string', + defaultValue: '', + table: { + category: 'FormControl.Validation' + } + }, + variant: { + defaultValue: 'error', + control: { + type: 'radio', + options: ['error', 'success', 'warning'] + }, + table: { + category: 'FormControl.Validation' + } + } +}; +const formControlArgTypeKeys = Object.keys(formControlArgTypes); +const formControlArgTypesWithoutValidation = formControlArgTypeKeys.reduce((acc, key) => { + if (formControlArgTypes[key].table.category !== 'FormControl.Validation') { + acc[key] = formControlArgTypes[key]; + } + + return acc; +}, {}); +const getFormControlArgsByChildComponent = ({ + captionChildren, + disabled, + labelChildren, + required, + validationChildren, + variant, + visuallyHidden +}) => ({ + parentArgs: { + disabled, + required + }, + labelArgs: { + visuallyHidden, + children: labelChildren + }, + captionArgs: { + children: captionChildren + }, + validationArgs: { + children: validationChildren, + variant + } +}); + +export { formControlArgTypes, formControlArgTypesWithoutValidation, getFormControlArgsByChildComponent, getTextInputArgTypes, inputWrapperArgTypes, textInputExcludedControlKeys, textInputWithTokensArgTypes, toolbarTypes, withThemeProvider }; diff --git a/lib-esm/utils/test-deprecations.js b/lib-esm/utils/test-deprecations.js new file mode 100644 index 00000000000..67842f74f3a --- /dev/null +++ b/lib-esm/utils/test-deprecations.js @@ -0,0 +1,17 @@ +import semver from 'semver'; +import { Deprecations } from './deprecate.js'; + +const ourVersion = require('../../package.json').version; + +beforeEach(() => { + Deprecations.clearDeprecations(); +}); +afterEach(() => { + const deprecations = Deprecations.getDeprecations(); + + for (const dep of deprecations) { + if (semver.gte(ourVersion, dep.version)) { + throw new Error(`Found a deprecation that should be removed in ${dep.version}`); + } + } +}); diff --git a/lib-esm/utils/test-helpers.js b/lib-esm/utils/test-helpers.js new file mode 100644 index 00000000000..5ec1e3cb75f --- /dev/null +++ b/lib-esm/utils/test-helpers.js @@ -0,0 +1,13 @@ +// JSDOM doesn't mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => { + return { + observe: jest.fn(), + disconnect: jest.fn() + }; +}); +global.CSS = { + escape: jest.fn(), + supports: jest.fn().mockImplementation(() => { + return false; + }) +}; diff --git a/lib-esm/utils/test-matchers.js b/lib-esm/utils/test-matchers.js new file mode 100644 index 00000000000..ae995e56e1e --- /dev/null +++ b/lib-esm/utils/test-matchers.js @@ -0,0 +1,112 @@ +import '@testing-library/jest-dom'; +import 'jest-styled-components'; +import { styleSheetSerializer } from 'jest-styled-components/serializer'; +import React from 'react'; +import { getClasses, render, getComputedStyles } from './testing.js'; + +expect.addSnapshotSerializer(styleSheetSerializer); + +const stringify = d => JSON.stringify(d, null, ' '); + +expect.extend({ + toMatchKeys(obj, values) { + return { + pass: Object.keys(values).every(key => this.equals(obj[key], values[key])), + message: () => `Expected ${stringify(obj)} to have matching keys: ${stringify(values)}` + }; + }, + + toHaveClass(node, klass) { + const classes = getClasses(node); + const pass = classes.includes(klass); + return { + pass, + message: () => `expected ${stringify(classes)} to include: ${stringify(klass)}` + }; + }, + + toHaveClasses(node, klasses, only = false) { + const classes = getClasses(node); + const pass = only ? this.equals(classes.sort(), klasses.sort()) : klasses.every(klass => classes.includes(klass)); + return { + pass, + message: () => `expected ${stringify(classes)} to include: ${stringify(klasses)}` + }; + }, + + toImplementSxBehavior(element) { + const mediaKey = '@media (max-width:123px)'; + const sxPropValue = { + [mediaKey]: { + color: 'red.5' + } + }; + const elem = /*#__PURE__*/React.cloneElement(element, { + sx: sxPropValue + }); + + function checkStylesDeep(rendered) { + const className = rendered.props.className; + const styles = getComputedStyles(className); + const mediaStyles = styles[mediaKey]; + + if (mediaStyles && mediaStyles.color) { + return true; + } else if (rendered.children) { + return rendered.children.some(child => checkStylesDeep(child)); + } else { + return false; + } + } + + return { + pass: checkStylesDeep(render(elem)), + message: () => 'sx prop values did not change styles of component nor of any sub-components' + }; + }, + + toSetExports(mod, expectedExports) { + if (!Object.keys(expectedExports).includes('default')) { + return { + pass: false, + message: () => "You must specify the module's default export" + }; + } + + const seen = new Set(); + + for (const exp of Object.keys(expectedExports)) { + seen.add(exp); + + if (mod[exp] !== expectedExports[exp]) { + if (!mod[exp] && !expectedExports[exp]) { + continue; + } + + return { + pass: false, + message: () => `Module exported a different value from key '${exp}' than expected` + }; + } + } + + for (const exp of Object.keys(mod)) { + if (seen.has(exp)) { + continue; + } + + if (mod[exp] !== expectedExports[exp]) { + return { + pass: false, + message: () => `Module exported an unexpected value from key '${exp}'` + }; + } + } + + return { + pass: true, + message: () => '' + }; + } + +}); diff --git a/lib-esm/utils/testing.js b/lib-esm/utils/testing.js new file mode 100644 index 00000000000..9858cc6f477 --- /dev/null +++ b/lib-esm/utils/testing.js @@ -0,0 +1,250 @@ +import React from 'react'; +import { promisify } from 'util'; +import renderer from 'react-test-renderer'; +import enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { render as render$1 } from '@testing-library/react'; +import { toHaveNoViolations, axe } from 'jest-axe'; +import theme from '../theme-preval.js'; +import ThemeProvider from '../ThemeProvider.js'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const readFile = promisify(require('fs').readFile); +const COMPONENT_DISPLAY_NAME_REGEX = /^[A-Z][A-Za-z]+(\.[A-Z][A-Za-z]+)*$/; +enzyme.configure({ + adapter: new Adapter() +}); +function mount(component) { + return enzyme.mount(component); +} + +/** + * Render the component (a React.createElement() or JSX expression) + * into its intermediate object representation with 'type', + * 'props', and 'children' keys + * + * The returned object can be matched with expect().toEqual(), e.g. + * + * ```js + * expect(render()).toEqual(render(
)) + * ``` + */ +function render(component, theme$1 = theme) { + return renderer.create( /*#__PURE__*/React.createElement(ThemeProvider, { + theme: theme$1 + }, component)).toJSON(); +} +/** + * Render the component (a React.createElement() or JSX expression) + * using react-test-renderer and return the root node + * ``` + */ + +function renderRoot(component) { + return renderer.create(component).root; +} +/** + * Get the HTML class names rendered by the component instance + * as an array. + * + * ```js + * expect(renderClasses(
)) + * .toEqual(['a', 'b']) + * ``` + */ + +function renderClasses(component) { + const { + props: { + className + } + } = render(component); + return className ? className.trim().split(' ') : []; +} +/** + * Returns true if a node renders with a single class. + */ + +function rendersClass(node, klass) { + return renderClasses(node).includes(klass); +} +function px(value) { + return typeof value === 'number' ? `${value}px` : value; +} +function percent(value) { + return typeof value === 'number' ? `${value}%` : value; +} +function renderStyles(node) { + const { + props: { + className + } + } = render(node); + return getComputedStyles(className); +} +function getComputedStyles(className) { + const div = document.createElement('div'); + div.className = className; + const computed = {}; + + for (const sheet of document.styleSheets) { + // CSSRulesLists assumes every rule is a CSSRule, not a CSSStyleRule + for (const rule of sheet.cssRules) { + if (rule instanceof CSSMediaRule) { + readMedia(rule); + } else if (rule instanceof CSSStyleRule) { + readRule(rule, computed); + } else ; + } + } + + return computed; + + function matchesSafe(node, selector) { + if (!selector) { + return false; + } + + try { + return node.matches(selector); + } catch (error) { + return false; + } + } + + function readRule(rule, dest) { + if (matchesSafe(div, rule.selectorText)) { + const { + style + } = rule; + + for (let i = 0; i < style.length; i++) { + const prop = style[i]; + dest[prop] = style.getPropertyValue(prop); + } + } + } + + function readMedia(mediaRule) { + const key = `@media ${mediaRule.media[0]}`; // const dest = computed[key] || (computed[key] = {}) + + const dest = {}; + + for (const rule of mediaRule.cssRules) { + if (rule instanceof CSSStyleRule) { + readRule(rule, dest); + } + } // Don't add media rule to computed styles + // if no styles were actually applied + + + if (Object.keys(dest).length > 0) { + computed[key] = dest; + } + } +} +/** + * This provides a layer of compatibility between the render() function from + * react-test-renderer and Enzyme's mount() + */ + +function getProps(node) { + return typeof node.props === 'function' ? node.props() : node.props; +} +function getClassName(node) { + return getProps(node).className; +} +function getClasses(node) { + const className = getClassName(node); + return className ? className.trim().split(/ +/) : []; +} +async function loadCSS(path) { + const css = await readFile(require.resolve(path), 'utf8'); + const style = document.createElement('style'); + style.setAttribute('data-path', path); + style.textContent = css; + document.head.appendChild(style); + return style; +} +function unloadCSS(path) { + const style = document.querySelector(`style[data-path="${path}"]`); + + if (style) { + style.remove(); + return true; + } +} // If a component requires certain props or other conditions in order +// to render without errors, you can pass a `toRender` function that +// returns an element ready to be rendered. + +function behavesAsComponent({ + Component, + toRender, + options +}) { + options = options || {}; + + const getElement = () => toRender ? toRender() : /*#__PURE__*/React.createElement(Component, null); + + if (!options.skipSx) { + it('implements sx prop behavior', () => { + expect(getElement()).toImplementSxBehavior(); + }); + } + + if (!options.skipAs) { + it('respects the as prop', () => { + const As = /*#__PURE__*/React.forwardRef((_props, ref) => /*#__PURE__*/React.createElement("div", { + className: "as-component", + ref: ref + })); + const elem = /*#__PURE__*/React.cloneElement(getElement(), { + as: As + }); + expect(render(elem)).toEqual(render( /*#__PURE__*/React.createElement(As, null))); + }); + } + + it('sets a valid displayName', () => { + expect(Component.displayName).toMatch(COMPONENT_DISPLAY_NAME_REGEX); + }); + it('renders consistently', () => { + expect(render(getElement())).toMatchSnapshot(); + }); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any + +function checkExports(path, exports) { + it('has declared exports', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(`../${path}`); + + expect(mod).toSetExports(exports); + }); +} +expect.extend(toHaveNoViolations); +function checkStoriesForAxeViolations(name, storyDir) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const stories = require(`${storyDir || '../stories/'}${name}.stories`); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- _meta + + + const { + default: _meta, + ...Stories + } = stories; + Object.values(Stories).map(Story => { + if (typeof Story !== 'function') return; + const { + storyName, + name: StoryFunctionName + } = Story; + it(`story ${storyName || StoryFunctionName} should have no axe violations`, async () => { + const { + container + } = render$1( /*#__PURE__*/React.createElement(Story, null)); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); +} + +export { COMPONENT_DISPLAY_NAME_REGEX, behavesAsComponent, checkExports, checkStoriesForAxeViolations, getClassName, getClasses, getComputedStyles, getProps, loadCSS, mount, percent, px, render, renderClasses, renderRoot, renderStyles, rendersClass, unloadCSS }; diff --git a/lib-esm/utils/theme.js b/lib-esm/utils/theme.js new file mode 100644 index 00000000000..dc6f0a5227d --- /dev/null +++ b/lib-esm/utils/theme.js @@ -0,0 +1 @@ +export { default } from './theme2.js'; diff --git a/lib-esm/utils/theme2.js b/lib-esm/utils/theme2.js new file mode 100644 index 00000000000..cdbfb3b93fe --- /dev/null +++ b/lib-esm/utils/theme2.js @@ -0,0 +1,72 @@ +import require$$0 from 'lodash.isempty'; +import require$$1 from 'lodash.isobject'; +import require$$2 from 'chroma-js'; + +// Utility functions used in theme-preval.js +// This file needs to be a JavaScript file using CommonJS to be compatible with preval +const isEmpty = require$$0; + +const isObject = require$$1; + +const chroma = require$$2; + +function fontStack(fonts) { + return fonts.map(font => font.includes(' ') ? `"${font}"` : font).join(', '); +} // The following functions are a temporary measure for splitting shadow values out from the colors object. +// Eventually, we will push these structural changes upstream to primer/primitives so this data manipulation +// will not be needed. + + +function isShadowValue(value) { + return typeof value === 'string' && /(inset\s|)([0-9.]+(\w*)\s){1,4}(rgb[a]?\(.*\)|\w+)/.test(value); +} + +function isColorValue(value) { + return chroma.valid(value); +} + +function filterObject(obj, predicate) { + if (Array.isArray(obj)) { + return obj.filter(predicate); + } + + return Object.entries(obj).reduce((acc, [key, value]) => { + if (isObject(value)) { + const result = filterObject(value, predicate); // Don't include empty objects or arrays + + if (!isEmpty(result)) { + acc[key] = result; + } + } else if (predicate(value)) { + acc[key] = value; + } + + return acc; + }, {}); +} + +function partitionColors(colors) { + return { + colors: filterObject(colors, value => isColorValue(value)), + shadows: filterObject(colors, value => isShadowValue(value)) + }; +} + +function omitScale(obj) { + const { + scale, + ...rest + } = obj; + return rest; +} + +var theme = { + fontStack, + isShadowValue, + isColorValue, + filterObject, + partitionColors, + omitScale +}; + +export { theme as default }; diff --git a/lib-esm/utils/useIsomorphicLayoutEffect.js b/lib-esm/utils/useIsomorphicLayoutEffect.js index 38d69bc45c7..a7ff9c74b06 100644 --- a/lib-esm/utils/useIsomorphicLayoutEffect.js +++ b/lib-esm/utils/useIsomorphicLayoutEffect.js @@ -1,6 +1,5 @@ -import { useLayoutEffect as useLayoutEffect$1, useEffect } from 'react'; +import { useLayoutEffect, useEffect } from 'react'; -const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? useLayoutEffect$1 : useEffect; -var useLayoutEffect = useIsomorphicLayoutEffect; +const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? useLayoutEffect : useEffect; -export { useLayoutEffect as default }; +export { useIsomorphicLayoutEffect as default }; diff --git a/lib/constants.js b/lib/constants.js index d1a3ef69d82..5923bb6fe54 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -43,8 +43,12 @@ const whiteSpace = system({ }); const TYPOGRAPHY = compose(styledSystem__namespace.typography, whiteSpace); // Border props -compose(styledSystem__namespace.border, styledSystem__namespace.shadow); +const BORDER = compose(styledSystem__namespace.border, styledSystem__namespace.shadow); +// Layout props +const LAYOUT = styledSystem__namespace.layout; +exports.BORDER = BORDER; exports.COMMON = COMMON; +exports.LAYOUT = LAYOUT; exports.TYPOGRAPHY = TYPOGRAPHY; exports.get = get; diff --git a/lib/hooks/index.js b/lib/hooks/index.js new file mode 100644 index 00000000000..a3b1902b973 --- /dev/null +++ b/lib/hooks/index.js @@ -0,0 +1,31 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var useOnOutsideClick = require('./useOnOutsideClick.js'); +var useProvidedRefOrCreate = require('./useProvidedRefOrCreate.js'); +var useOnEscapePress = require('./useOnEscapePress.js'); +var useOpenAndCloseFocus = require('./useOpenAndCloseFocus.js'); +var useAnchoredPosition = require('./useAnchoredPosition.js'); +var useOverlay = require('./useOverlay.js'); +var useRenderForcingRef = require('./useRenderForcingRef.js'); +var useProvidedStateOrCreate = require('./useProvidedStateOrCreate.js'); +var useMenuInitialFocus = require('./useMenuInitialFocus.js'); +var useMenuKeyboardNavigation = require('./useMenuKeyboardNavigation.js'); +var useMnemonics = require('./useMnemonics.js'); +var useRefObjectAsForwardedRef = require('./useRefObjectAsForwardedRef.js'); + + + +exports.useOnOutsideClick = useOnOutsideClick.useOnOutsideClick; +exports.useProvidedRefOrCreate = useProvidedRefOrCreate.useProvidedRefOrCreate; +exports.useOnEscapePress = useOnEscapePress.useOnEscapePress; +exports.useOpenAndCloseFocus = useOpenAndCloseFocus.useOpenAndCloseFocus; +exports.useAnchoredPosition = useAnchoredPosition.useAnchoredPosition; +exports.useOverlay = useOverlay.useOverlay; +exports.useRenderForcingRef = useRenderForcingRef.useRenderForcingRef; +exports.useProvidedStateOrCreate = useProvidedStateOrCreate.useProvidedStateOrCreate; +exports.useMenuInitialFocus = useMenuInitialFocus.useMenuInitialFocus; +exports.useMenuKeyboardNavigation = useMenuKeyboardNavigation.useMenuKeyboardNavigation; +exports.useMnemonics = useMnemonics.useMnemonics; +exports.useRefObjectAsForwardedRef = useRefObjectAsForwardedRef.useRefObjectAsForwardedRef; diff --git a/lib/hooks/useControllableState.js b/lib/hooks/useControllableState.js index b00f45fa2aa..4f93074f52f 100644 --- a/lib/hooks/useControllableState.js +++ b/lib/hooks/useControllableState.js @@ -51,15 +51,11 @@ function useControllableState({ const controlledValue = value !== undefined; // Uncontrolled -> Controlled // If the component prop is uncontrolled, the prop value should be undefined - if (controlled.current === false && controlledValue) { - warn(); - } // Controlled -> Uncontrolled + if (controlled.current === false && controlledValue) ; // Controlled -> Uncontrolled // If the component prop is controlled, the prop value should be defined - if (controlled.current === true && !controlledValue) { - warn(); - } + if (controlled.current === true && !controlledValue) ; }, [name, value]); if (controlled.current === true) { @@ -68,8 +64,5 @@ function useControllableState({ return [state, setState]; } -/** Warn when running in a development environment */ - -const warn = function emptyFunction() {}; exports.useControllableState = useControllableState; diff --git a/lib/hooks/useMedia.js b/lib/hooks/useMedia.js index a8e37ee68c8..01baf635618 100644 --- a/lib/hooks/useMedia.js +++ b/lib/hooks/useMedia.js @@ -85,5 +85,56 @@ function useMedia(mediaQueryString, defaultState) { // be used for development and demo purposes to emulate specific features if // unavailable through devtools const MatchMediaContext = /*#__PURE__*/React.createContext({}); +const defaultFeatures = {}; +/** + * Use `MatchMedia` to emulate media conditions by passing in feature + * queries to the `features` prop. If a component uses `useMedia` with the + * feature passed in to `MatchMedia` it will force its value to match what is + * provided to `MatchMedia` + * + * This should be used for development and documentation only in situations + * where devtools cannot emulate this feature + * + * @example + * + * + * + */ + +function MatchMedia({ + children, + features = defaultFeatures +}) { + const value = useShallowObject(features); + return /*#__PURE__*/React__default["default"].createElement(MatchMediaContext.Provider, { + value: value + }, children); +} +MatchMedia.displayName = "MatchMedia"; + +/** + * Utility hook to provide a stable identity for a "simple" object which + * contains only primitive values. This provides a `useMemo`-esque signature + * without dealing with shallow equality checks in the dependency array. + * + * Note (perf): this hook iterates through keys and values of the object if the + * shallow equality check is false each time the hook is called + */ +function useShallowObject(object) { + const [value, setValue] = React.useState(object); + + if (value !== object) { + const match = Object.keys(object).every(key => { + return object[key] === value[key]; + }); + + if (!match) { + setValue(object); + } + } + + return value; +} +exports.MatchMedia = MatchMedia; exports.useMedia = useMedia; diff --git a/lib/index.js b/lib/index.js index 800b6c98718..2feb44fe836 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,7 +2,6 @@ Object.defineProperty(exports, '__esModule', { value: true }); -var themePreval = require('./theme-preval.js'); var constants = require('./constants.js'); var BaseStyles = require('./BaseStyles.js'); var ThemeProvider = require('./ThemeProvider.js'); @@ -67,6 +66,7 @@ var UnderlineNav = require('./UnderlineNav.js'); var Checkbox = require('./Checkbox.js'); var Textarea = require('./Textarea.js'); var sx = require('./sx.js'); +var themePreval = require('./theme-preval.js'); var PageLayout = require('./PageLayout/PageLayout.js'); var AnchoredOverlay = require('./AnchoredOverlay/AnchoredOverlay.js'); var Autocomplete = require('./Autocomplete/Autocomplete.js'); @@ -89,7 +89,6 @@ var merge__default = /*#__PURE__*/_interopDefaultLegacy(merge); -exports.theme = themePreval; exports.themeGet = constants.get; exports.BaseStyles = BaseStyles; exports.ThemeProvider = ThemeProvider["default"]; @@ -158,6 +157,7 @@ exports.UnderlineNav = UnderlineNav; exports.Checkbox = Checkbox; exports.Textarea = Textarea["default"]; exports.sx = sx["default"]; +exports.theme = themePreval; exports.PageLayout = PageLayout.PageLayout; exports.AnchoredOverlay = AnchoredOverlay.AnchoredOverlay; exports.Autocomplete = Autocomplete; diff --git a/lib/polyfills/eventListenerSignal.js b/lib/polyfills/eventListenerSignal.js new file mode 100644 index 00000000000..deb38beeeda --- /dev/null +++ b/lib/polyfills/eventListenerSignal.js @@ -0,0 +1,63 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +/* + +This file polyfills the following: https://github.com/whatwg/dom/issues/911 +Once all targeted browsers support this DOM feature, this polyfill can be deleted. + +This allows users to pass an AbortSignal to a call to addEventListener as part of the +AddEventListenerOptions object. When the signal is aborted, the event listener is +removed. + +*/ +let signalSupported = false; + +function noop() {} + +try { + const options = Object.create({}, { + signal: { + get() { + signalSupported = true; + } + + } + }); + window.addEventListener('test', noop, options); + window.removeEventListener('test', noop, options); +} catch (e) { + /* */ +} + +function featureSupported() { + return signalSupported; +} + +function monkeyPatch() { + if (typeof window === 'undefined') { + return; + } + + const originalAddEventListener = EventTarget.prototype.addEventListener; + + EventTarget.prototype.addEventListener = function (name, originalCallback, optionsOrCapture) { + if (typeof optionsOrCapture === 'object' && 'signal' in optionsOrCapture && optionsOrCapture.signal instanceof AbortSignal) { + originalAddEventListener.call(optionsOrCapture.signal, 'abort', () => { + this.removeEventListener(name, originalCallback, optionsOrCapture); + }); + } + + return originalAddEventListener.call(this, name, originalCallback, optionsOrCapture); + }; +} + +function polyfill() { + if (!featureSupported()) { + monkeyPatch(); + signalSupported = true; + } +} + +exports.polyfill = polyfill; diff --git a/lib/utils/create-slots.js b/lib/utils/create-slots.js index c268eaa8fc3..4707ad614f9 100644 --- a/lib/utils/create-slots.js +++ b/lib/utils/create-slots.js @@ -91,6 +91,4 @@ const createSlots = slotNames => { }; }; -var createSlots$1 = createSlots; - -module.exports = createSlots$1; +module.exports = createSlots; diff --git a/lib/utils/deprecate.js b/lib/utils/deprecate.js new file mode 100644 index 00000000000..b1593052ddd --- /dev/null +++ b/lib/utils/deprecate.js @@ -0,0 +1,60 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +require('react'); + +const noop = () => {}; // eslint-disable-next-line import/no-mutable-exports + + +let deprecate = noop; + +exports.useDeprecation = null; + +{ + exports.useDeprecation = () => { + return noop; + }; +} +class Deprecations { + static instance = null; + + static get() { + if (!Deprecations.instance) { + Deprecations.instance = new Deprecations(); + } + + return Deprecations.instance; + } + + constructor() { + this.deprecations = []; + } + + static deprecate({ + name, + message, + version + }) { + const msg = `WARNING! ${name} is deprecated and will be removed in version ${version}. ${message}`; // eslint-disable-next-line no-console + + console.warn(msg); + this.get().deprecations.push({ + name, + message, + version + }); + } + + static getDeprecations() { + return this.get().deprecations; + } + + static clearDeprecations() { + this.get().deprecations.length = 0; + } + +} + +exports.Deprecations = Deprecations; +exports.deprecate = deprecate; diff --git a/lib/utils/polymorphic.js b/lib/utils/polymorphic.js new file mode 100644 index 00000000000..eb109abbed0 --- /dev/null +++ b/lib/utils/polymorphic.js @@ -0,0 +1,2 @@ +'use strict'; + diff --git a/lib/utils/ssr.js b/lib/utils/ssr.js new file mode 100644 index 00000000000..ae578645293 --- /dev/null +++ b/lib/utils/ssr.js @@ -0,0 +1,16 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var ssr = require('@react-aria/ssr'); + + + +Object.defineProperty(exports, 'SSRProvider', { + enumerable: true, + get: function () { return ssr.SSRProvider; } +}); +Object.defineProperty(exports, 'useSSRSafeId', { + enumerable: true, + get: function () { return ssr.useSSRSafeId; } +}); diff --git a/lib/utils/story-helpers.js b/lib/utils/story-helpers.js new file mode 100644 index 00000000000..75269015070 --- /dev/null +++ b/lib/utils/story-helpers.js @@ -0,0 +1,294 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var React = require('react'); +var styled = require('styled-components'); +var constants = require('../constants.js'); +var themePreval = require('../theme-preval.js'); +var Box = require('../Box.js'); +var ThemeProvider = require('../ThemeProvider.js'); +var BaseStyles = require('../BaseStyles.js'); + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var React__default = /*#__PURE__*/_interopDefaultLegacy(React); + +// set global theme styles for each story +const GlobalStyle = styled.createGlobalStyle(["body{background-color:", ";color:", ";}"], constants.get('colors.canvas.default'), constants.get('colors.fg.default')); // only remove padding for multi-theme view grid + +const GlobalStyleMultiTheme = styled.createGlobalStyle(["body{padding:0 !important;}"]); +const withThemeProvider = (Story, context) => { + // used for testing ThemeProvider.stories.tsx + if (context.parameters.disableThemeDecorator) return /*#__PURE__*/React__default["default"].createElement(Story, context); + const { + colorScheme + } = context.globals; + + if (colorScheme === 'all') { + return /*#__PURE__*/React__default["default"].createElement(Box, { + sx: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(0, 1fr))', + height: '100vh' + } + }, /*#__PURE__*/React__default["default"].createElement(GlobalStyleMultiTheme, null), Object.keys(themePreval.colorSchemes).map(scheme => /*#__PURE__*/React__default["default"].createElement(ThemeProvider["default"], { + key: scheme, + colorMode: "day", + dayScheme: scheme + }, /*#__PURE__*/React__default["default"].createElement(BaseStyles, null, /*#__PURE__*/React__default["default"].createElement(Box, { + sx: { + padding: '1rem', + height: '100%', + backgroundColor: 'canvas.default', + color: 'fg.default' + } + }, /*#__PURE__*/React__default["default"].createElement("div", { + id: `html-addon-root-${scheme}` + }, /*#__PURE__*/React__default["default"].createElement(Story, context))))))); + } + + return /*#__PURE__*/React__default["default"].createElement(ThemeProvider["default"], { + colorMode: "day", + dayScheme: colorScheme + }, /*#__PURE__*/React__default["default"].createElement(GlobalStyle, null), /*#__PURE__*/React__default["default"].createElement(BaseStyles, null, /*#__PURE__*/React__default["default"].createElement("div", { + id: "html-addon-root" + }, /*#__PURE__*/React__default["default"].createElement(Story, context)))); +}; +withThemeProvider.displayName = "withThemeProvider"; +const toolbarTypes = { + colorScheme: { + name: 'Color scheme', + description: 'Switch color scheme', + defaultValue: 'light', + toolbar: { + icon: 'photo', + items: [...Object.keys(themePreval.colorSchemes), 'all'], + showName: true + } + } +}; +const inputWrapperArgTypes = { + block: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + contrast: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + placeholder: { + defaultValue: '', + control: { + type: 'text' + } + }, + size: { + name: 'size (input)', + // TODO: remove '(input)' + defaultValue: 'medium', + options: ['small', 'medium', 'large'], + control: { + type: 'radio' + } + }, + validationStatus: { + defaultValue: undefined, + options: ['error', 'success', 'warning', undefined], + control: { + type: 'radio' + } + } +}; +const textInputArgTypesUnsorted = { ...inputWrapperArgTypes, + loading: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + loaderPosition: { + defaultValue: 'auto', + options: ['auto', 'leading', 'trailing'], + control: { + type: 'radio' + } + }, + monospace: { + defaultValue: false, + control: { + type: 'boolean' + } + } +}; // Alphabetize and optionally categorize the props + +const getTextInputArgTypes = category => Object.keys(textInputArgTypesUnsorted).sort().reduce((obj, key) => { + obj[key] = category ? { // have to do weird type casting so we can spread the object + ...textInputArgTypesUnsorted[key], + table: { + category + } + } : textInputArgTypesUnsorted[key]; + return obj; +}, {}); +const textInputExcludedControlKeys = ['as', 'icon', 'leadingVisual', 'sx', 'trailingVisual', 'trailingAction']; +const textInputWithTokensArgTypes = { + hideTokenRemoveButtons: { + defaultValue: false, + type: 'boolean', + table: { + category: 'TextInputWithTokens props' + } + }, + maxHeight: { + type: 'string', + defaultValue: 'none', + description: 'Any valid value for the CSS max-height property', + table: { + category: 'TextInputWithTokens props' + } + }, + preventTokenWrapping: { + defaultValue: false, + type: 'boolean', + table: { + category: 'TextInputWithTokens props' + } + }, + size: { + name: 'size (token size)', + defaultValue: 'xlarge', + options: ['small', 'medium', 'large', 'xlarge'], + control: { + type: 'radio' + }, + table: { + category: 'TextInputWithTokens props' + } + }, + visibleTokenCount: { + defaultValue: 999, + type: 'number', + table: { + category: 'TextInputWithTokens props' + } + } +}; +const formControlArgTypes = { + // FormControl + required: { + defaultValue: false, + control: { + type: 'boolean' + }, + table: { + category: 'FormControl' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + }, + table: { + category: 'FormControl' + } + }, + // FormControl.Label + labelChildren: { + name: 'children', + type: 'string', + defaultValue: 'Label', + table: { + category: 'FormControl.Label' + } + }, + visuallyHidden: { + defaultValue: false, + type: 'boolean', + table: { + category: 'FormControl.Label' + } + }, + // FormControl.Caption + captionChildren: { + name: 'children', + type: 'string', + defaultValue: '', + table: { + category: 'FormControl.Caption' + } + }, + // FormControl.Validation + validationChildren: { + name: 'children', + type: 'string', + defaultValue: '', + table: { + category: 'FormControl.Validation' + } + }, + variant: { + defaultValue: 'error', + control: { + type: 'radio', + options: ['error', 'success', 'warning'] + }, + table: { + category: 'FormControl.Validation' + } + } +}; +const formControlArgTypeKeys = Object.keys(formControlArgTypes); +const formControlArgTypesWithoutValidation = formControlArgTypeKeys.reduce((acc, key) => { + if (formControlArgTypes[key].table.category !== 'FormControl.Validation') { + acc[key] = formControlArgTypes[key]; + } + + return acc; +}, {}); +const getFormControlArgsByChildComponent = ({ + captionChildren, + disabled, + labelChildren, + required, + validationChildren, + variant, + visuallyHidden +}) => ({ + parentArgs: { + disabled, + required + }, + labelArgs: { + visuallyHidden, + children: labelChildren + }, + captionArgs: { + children: captionChildren + }, + validationArgs: { + children: validationChildren, + variant + } +}); + +exports.formControlArgTypes = formControlArgTypes; +exports.formControlArgTypesWithoutValidation = formControlArgTypesWithoutValidation; +exports.getFormControlArgsByChildComponent = getFormControlArgsByChildComponent; +exports.getTextInputArgTypes = getTextInputArgTypes; +exports.inputWrapperArgTypes = inputWrapperArgTypes; +exports.textInputExcludedControlKeys = textInputExcludedControlKeys; +exports.textInputWithTokensArgTypes = textInputWithTokensArgTypes; +exports.toolbarTypes = toolbarTypes; +exports.withThemeProvider = withThemeProvider; diff --git a/lib/utils/test-deprecations.js b/lib/utils/test-deprecations.js new file mode 100644 index 00000000000..6d9d0ee8521 --- /dev/null +++ b/lib/utils/test-deprecations.js @@ -0,0 +1,23 @@ +'use strict'; + +var semver = require('semver'); +var deprecate = require('./deprecate.js'); + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var semver__default = /*#__PURE__*/_interopDefaultLegacy(semver); + +const ourVersion = require('../../package.json').version; + +beforeEach(() => { + deprecate.Deprecations.clearDeprecations(); +}); +afterEach(() => { + const deprecations = deprecate.Deprecations.getDeprecations(); + + for (const dep of deprecations) { + if (semver__default["default"].gte(ourVersion, dep.version)) { + throw new Error(`Found a deprecation that should be removed in ${dep.version}`); + } + } +}); diff --git a/lib/utils/test-helpers.js b/lib/utils/test-helpers.js new file mode 100644 index 00000000000..cfe6fb8e0df --- /dev/null +++ b/lib/utils/test-helpers.js @@ -0,0 +1,15 @@ +'use strict'; + +// JSDOM doesn't mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => { + return { + observe: jest.fn(), + disconnect: jest.fn() + }; +}); +global.CSS = { + escape: jest.fn(), + supports: jest.fn().mockImplementation(() => { + return false; + }) +}; diff --git a/lib/utils/test-matchers.js b/lib/utils/test-matchers.js new file mode 100644 index 00000000000..708d6f234b1 --- /dev/null +++ b/lib/utils/test-matchers.js @@ -0,0 +1,118 @@ +'use strict'; + +require('@testing-library/jest-dom'); +require('jest-styled-components'); +var serializer = require('jest-styled-components/serializer'); +var React = require('react'); +var testing = require('./testing.js'); + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var React__default = /*#__PURE__*/_interopDefaultLegacy(React); + +expect.addSnapshotSerializer(serializer.styleSheetSerializer); + +const stringify = d => JSON.stringify(d, null, ' '); + +expect.extend({ + toMatchKeys(obj, values) { + return { + pass: Object.keys(values).every(key => this.equals(obj[key], values[key])), + message: () => `Expected ${stringify(obj)} to have matching keys: ${stringify(values)}` + }; + }, + + toHaveClass(node, klass) { + const classes = testing.getClasses(node); + const pass = classes.includes(klass); + return { + pass, + message: () => `expected ${stringify(classes)} to include: ${stringify(klass)}` + }; + }, + + toHaveClasses(node, klasses, only = false) { + const classes = testing.getClasses(node); + const pass = only ? this.equals(classes.sort(), klasses.sort()) : klasses.every(klass => classes.includes(klass)); + return { + pass, + message: () => `expected ${stringify(classes)} to include: ${stringify(klasses)}` + }; + }, + + toImplementSxBehavior(element) { + const mediaKey = '@media (max-width:123px)'; + const sxPropValue = { + [mediaKey]: { + color: 'red.5' + } + }; + const elem = /*#__PURE__*/React__default["default"].cloneElement(element, { + sx: sxPropValue + }); + + function checkStylesDeep(rendered) { + const className = rendered.props.className; + const styles = testing.getComputedStyles(className); + const mediaStyles = styles[mediaKey]; + + if (mediaStyles && mediaStyles.color) { + return true; + } else if (rendered.children) { + return rendered.children.some(child => checkStylesDeep(child)); + } else { + return false; + } + } + + return { + pass: checkStylesDeep(testing.render(elem)), + message: () => 'sx prop values did not change styles of component nor of any sub-components' + }; + }, + + toSetExports(mod, expectedExports) { + if (!Object.keys(expectedExports).includes('default')) { + return { + pass: false, + message: () => "You must specify the module's default export" + }; + } + + const seen = new Set(); + + for (const exp of Object.keys(expectedExports)) { + seen.add(exp); + + if (mod[exp] !== expectedExports[exp]) { + if (!mod[exp] && !expectedExports[exp]) { + continue; + } + + return { + pass: false, + message: () => `Module exported a different value from key '${exp}' than expected` + }; + } + } + + for (const exp of Object.keys(mod)) { + if (seen.has(exp)) { + continue; + } + + if (mod[exp] !== expectedExports[exp]) { + return { + pass: false, + message: () => `Module exported an unexpected value from key '${exp}'` + }; + } + } + + return { + pass: true, + message: () => '' + }; + } + +}); diff --git a/lib/utils/testing.js b/lib/utils/testing.js new file mode 100644 index 00000000000..7037b588542 --- /dev/null +++ b/lib/utils/testing.js @@ -0,0 +1,278 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var React = require('react'); +var util = require('util'); +var renderer = require('react-test-renderer'); +var enzyme = require('enzyme'); +var Adapter = require('@wojtekmaj/enzyme-adapter-react-17'); +var react = require('@testing-library/react'); +var jestAxe = require('jest-axe'); +var themePreval = require('../theme-preval.js'); +var ThemeProvider = require('../ThemeProvider.js'); + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var React__default = /*#__PURE__*/_interopDefaultLegacy(React); +var renderer__default = /*#__PURE__*/_interopDefaultLegacy(renderer); +var enzyme__default = /*#__PURE__*/_interopDefaultLegacy(enzyme); +var Adapter__default = /*#__PURE__*/_interopDefaultLegacy(Adapter); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const readFile = util.promisify(require('fs').readFile); +const COMPONENT_DISPLAY_NAME_REGEX = /^[A-Z][A-Za-z]+(\.[A-Z][A-Za-z]+)*$/; +enzyme__default["default"].configure({ + adapter: new Adapter__default["default"]() +}); +function mount(component) { + return enzyme__default["default"].mount(component); +} + +/** + * Render the component (a React.createElement() or JSX expression) + * into its intermediate object representation with 'type', + * 'props', and 'children' keys + * + * The returned object can be matched with expect().toEqual(), e.g. + * + * ```js + * expect(render()).toEqual(render(
)) + * ``` + */ +function render(component, theme = themePreval) { + return renderer__default["default"].create( /*#__PURE__*/React__default["default"].createElement(ThemeProvider["default"], { + theme: theme + }, component)).toJSON(); +} +/** + * Render the component (a React.createElement() or JSX expression) + * using react-test-renderer and return the root node + * ``` + */ + +function renderRoot(component) { + return renderer__default["default"].create(component).root; +} +/** + * Get the HTML class names rendered by the component instance + * as an array. + * + * ```js + * expect(renderClasses(
)) + * .toEqual(['a', 'b']) + * ``` + */ + +function renderClasses(component) { + const { + props: { + className + } + } = render(component); + return className ? className.trim().split(' ') : []; +} +/** + * Returns true if a node renders with a single class. + */ + +function rendersClass(node, klass) { + return renderClasses(node).includes(klass); +} +function px(value) { + return typeof value === 'number' ? `${value}px` : value; +} +function percent(value) { + return typeof value === 'number' ? `${value}%` : value; +} +function renderStyles(node) { + const { + props: { + className + } + } = render(node); + return getComputedStyles(className); +} +function getComputedStyles(className) { + const div = document.createElement('div'); + div.className = className; + const computed = {}; + + for (const sheet of document.styleSheets) { + // CSSRulesLists assumes every rule is a CSSRule, not a CSSStyleRule + for (const rule of sheet.cssRules) { + if (rule instanceof CSSMediaRule) { + readMedia(rule); + } else if (rule instanceof CSSStyleRule) { + readRule(rule, computed); + } else ; + } + } + + return computed; + + function matchesSafe(node, selector) { + if (!selector) { + return false; + } + + try { + return node.matches(selector); + } catch (error) { + return false; + } + } + + function readRule(rule, dest) { + if (matchesSafe(div, rule.selectorText)) { + const { + style + } = rule; + + for (let i = 0; i < style.length; i++) { + const prop = style[i]; + dest[prop] = style.getPropertyValue(prop); + } + } + } + + function readMedia(mediaRule) { + const key = `@media ${mediaRule.media[0]}`; // const dest = computed[key] || (computed[key] = {}) + + const dest = {}; + + for (const rule of mediaRule.cssRules) { + if (rule instanceof CSSStyleRule) { + readRule(rule, dest); + } + } // Don't add media rule to computed styles + // if no styles were actually applied + + + if (Object.keys(dest).length > 0) { + computed[key] = dest; + } + } +} +/** + * This provides a layer of compatibility between the render() function from + * react-test-renderer and Enzyme's mount() + */ + +function getProps(node) { + return typeof node.props === 'function' ? node.props() : node.props; +} +function getClassName(node) { + return getProps(node).className; +} +function getClasses(node) { + const className = getClassName(node); + return className ? className.trim().split(/ +/) : []; +} +async function loadCSS(path) { + const css = await readFile(require.resolve(path), 'utf8'); + const style = document.createElement('style'); + style.setAttribute('data-path', path); + style.textContent = css; + document.head.appendChild(style); + return style; +} +function unloadCSS(path) { + const style = document.querySelector(`style[data-path="${path}"]`); + + if (style) { + style.remove(); + return true; + } +} // If a component requires certain props or other conditions in order +// to render without errors, you can pass a `toRender` function that +// returns an element ready to be rendered. + +function behavesAsComponent({ + Component, + toRender, + options +}) { + options = options || {}; + + const getElement = () => toRender ? toRender() : /*#__PURE__*/React__default["default"].createElement(Component, null); + + if (!options.skipSx) { + it('implements sx prop behavior', () => { + expect(getElement()).toImplementSxBehavior(); + }); + } + + if (!options.skipAs) { + it('respects the as prop', () => { + const As = /*#__PURE__*/React__default["default"].forwardRef((_props, ref) => /*#__PURE__*/React__default["default"].createElement("div", { + className: "as-component", + ref: ref + })); + const elem = /*#__PURE__*/React__default["default"].cloneElement(getElement(), { + as: As + }); + expect(render(elem)).toEqual(render( /*#__PURE__*/React__default["default"].createElement(As, null))); + }); + } + + it('sets a valid displayName', () => { + expect(Component.displayName).toMatch(COMPONENT_DISPLAY_NAME_REGEX); + }); + it('renders consistently', () => { + expect(render(getElement())).toMatchSnapshot(); + }); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any + +function checkExports(path, exports) { + it('has declared exports', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(`../${path}`); + + expect(mod).toSetExports(exports); + }); +} +expect.extend(jestAxe.toHaveNoViolations); +function checkStoriesForAxeViolations(name, storyDir) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const stories = require(`${storyDir || '../stories/'}${name}.stories`); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- _meta + + + const { + default: _meta, + ...Stories + } = stories; + Object.values(Stories).map(Story => { + if (typeof Story !== 'function') return; + const { + storyName, + name: StoryFunctionName + } = Story; + it(`story ${storyName || StoryFunctionName} should have no axe violations`, async () => { + const { + container + } = react.render( /*#__PURE__*/React__default["default"].createElement(Story, null)); + const results = await jestAxe.axe(container); + expect(results).toHaveNoViolations(); + }); + }); +} + +exports.COMPONENT_DISPLAY_NAME_REGEX = COMPONENT_DISPLAY_NAME_REGEX; +exports.behavesAsComponent = behavesAsComponent; +exports.checkExports = checkExports; +exports.checkStoriesForAxeViolations = checkStoriesForAxeViolations; +exports.getClassName = getClassName; +exports.getClasses = getClasses; +exports.getComputedStyles = getComputedStyles; +exports.getProps = getProps; +exports.loadCSS = loadCSS; +exports.mount = mount; +exports.percent = percent; +exports.px = px; +exports.render = render; +exports.renderClasses = renderClasses; +exports.renderRoot = renderRoot; +exports.renderStyles = renderStyles; +exports.rendersClass = rendersClass; +exports.unloadCSS = unloadCSS; diff --git a/lib/utils/theme.js b/lib/utils/theme.js new file mode 100644 index 00000000000..18c058557db --- /dev/null +++ b/lib/utils/theme.js @@ -0,0 +1,7 @@ +'use strict'; + +var theme = require('./theme2.js'); + + + +module.exports = theme; diff --git a/lib/utils/theme2.js b/lib/utils/theme2.js new file mode 100644 index 00000000000..7dce30713fe --- /dev/null +++ b/lib/utils/theme2.js @@ -0,0 +1,80 @@ +'use strict'; + +var require$$0 = require('lodash.isempty'); +var require$$1 = require('lodash.isobject'); +var require$$2 = require('chroma-js'); + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var require$$0__default = /*#__PURE__*/_interopDefaultLegacy(require$$0); +var require$$1__default = /*#__PURE__*/_interopDefaultLegacy(require$$1); +var require$$2__default = /*#__PURE__*/_interopDefaultLegacy(require$$2); + +// Utility functions used in theme-preval.js +// This file needs to be a JavaScript file using CommonJS to be compatible with preval +const isEmpty = require$$0__default["default"]; + +const isObject = require$$1__default["default"]; + +const chroma = require$$2__default["default"]; + +function fontStack(fonts) { + return fonts.map(font => font.includes(' ') ? `"${font}"` : font).join(', '); +} // The following functions are a temporary measure for splitting shadow values out from the colors object. +// Eventually, we will push these structural changes upstream to primer/primitives so this data manipulation +// will not be needed. + + +function isShadowValue(value) { + return typeof value === 'string' && /(inset\s|)([0-9.]+(\w*)\s){1,4}(rgb[a]?\(.*\)|\w+)/.test(value); +} + +function isColorValue(value) { + return chroma.valid(value); +} + +function filterObject(obj, predicate) { + if (Array.isArray(obj)) { + return obj.filter(predicate); + } + + return Object.entries(obj).reduce((acc, [key, value]) => { + if (isObject(value)) { + const result = filterObject(value, predicate); // Don't include empty objects or arrays + + if (!isEmpty(result)) { + acc[key] = result; + } + } else if (predicate(value)) { + acc[key] = value; + } + + return acc; + }, {}); +} + +function partitionColors(colors) { + return { + colors: filterObject(colors, value => isColorValue(value)), + shadows: filterObject(colors, value => isShadowValue(value)) + }; +} + +function omitScale(obj) { + const { + scale, + ...rest + } = obj; + return rest; +} + +var theme = { + fontStack, + isShadowValue, + isColorValue, + filterObject, + partitionColors, + omitScale +}; + +module.exports = theme; diff --git a/lib/utils/useIsomorphicLayoutEffect.js b/lib/utils/useIsomorphicLayoutEffect.js index 86585b2feac..8e96cc95fd2 100644 --- a/lib/utils/useIsomorphicLayoutEffect.js +++ b/lib/utils/useIsomorphicLayoutEffect.js @@ -3,6 +3,5 @@ var React = require('react'); const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? React.useLayoutEffect : React.useEffect; -var useLayoutEffect = useIsomorphicLayoutEffect; -module.exports = useLayoutEffect; +module.exports = useIsomorphicLayoutEffect; diff --git a/package.json b/package.json index 10266634e76..09d690a7371 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,7 @@ "./lib/*.js", "./lib/*/index.js" ] - }, - "./lib-esm/utils/test-helpers": "./lib-esm/utils/test-helpers.js", - "./lib/utils/test-helpers": "./lib/utils/test-helpers.js" + } }, "typings": "lib/index.d.ts", "sideEffects": false, diff --git a/rollup.config.js b/rollup.config.js index f65d4e9d1da..5de47528673 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,17 +17,25 @@ const input = new Set([ // "./deprecated" 'src/deprecated/index.ts', - // "./lib-esm/*" - ...glob.sync(['src/*', 'src/*/index.js'], { - cwd: __dirname, - onlyFiles: true, - // Note: ignore theme-preval as it is handled through the theme import and - // specifying it as an entrypoint creates an intermediate file - ignore: ['src/theme-preval.js'] - }), + // Make sure all members are exported + 'src/constants.ts', - // "./lib-esm/utils/test-helpers", "./lib/utils/test-helpers" - 'src/utils/test-helpers.tsx' + ...glob.sync( + [ + // "./lib-esm/hooks/*" + 'src/hooks/*', + + // "./lib-esm/polyfills/*" + 'src/polyfills/*', + + // "./lib-esm/utils/*" + 'src/utils/*' + ], + { + cwd: __dirname, + ignore: ['**/__tests__/**', '*.stories.tsx'] + } + ) ]) const extensions = ['.js', '.jsx', '.ts', '.tsx']