Skip to content

Commit

Permalink
feat: introduce DefaultPropsProvider for setting default props in R…
Browse files Browse the repository at this point in the history
…eact components (#922)

* feat: introduce `DefaultPropsProvider` for setting default props in React components

* docs(react-docs): Use `DefaultPropsProvider` in the main application file

* feat(react/accordion): apply default props

* feat(react/alert): apply default props

* chore: create changeset `tonic-ui-73.md`

* refactor: add input validation for the `useDefaultProps` hook

* feat(react/badge): apply default props

* feat(react/button): apply default props

* feat(react/checkbox): apply default props

* refactor(react-docs): use `useConst` to memoize custom theme in `_app.page.js`

* feat(react/code): apply default props

* feat(react/color-mode): apply default props

* feat(react/color-style): apply default props

* feat(react/date-pickers): apply default props

* feat(react/divider): apply default props

* feat(react/flex): apply default props

* feat(react/grid): apply default props

* feat(react/image): apply default props

* feat(react/link): apply default props

* feat(react/portal): apply default props

* feat(react/icons): apply default props

* feat(react/drawer): apply default props

* feat(react/modal): apply default props

* feat(react/input): apply default props

* feat(react/menu): apply default props

* feat(react/pagination): apply default props

* feat(react/popover): apply default props

* chore: eliminate `DefaultPropsProvider` and `useDefaultProps` from named exports

* feat(react/progress): apply default props

* feat(react/radio): apply default props

* feat(react/resize-handle): apply default props

* feat(react/scrollbar): apply default props

* feat(react/search-input): apply default props

* feat(react/select): apply default props

* feat(react/skeleton): apply default props

* feat(react/space): apply default props

* feat(react/spinner): apply default props

* feat(react/stack): apply default props

* feat(react/switch): apply default props

* feat(react/table): apply default props

* feat(react/tabs): apply default props

* feat(react/tag): apply default props

* feat(react/text): apply default props

* feat(react/textarea): apply default props

* feat(react/toast): apply default props

* feat(react/tooltip): apply default props

* feat(react/transitions): apply default props

* feat(react/tree): apply default props

* feat(react/truncate): apply default props

* feat(react/visually-hidden): apply default props

* refactor(resolveProps): use `Object.entries()` for cleaner object iteration

* feat: add a default value to the context

* test: add `use-default-props.test.js` to check for component name mismatch in the `useDefaultProps` hook

* test: enhance the `use-default-props` test

* feat(react/radio): update `useRadioStyle` in `Radio` component to ensure proper context handling before applying styles
  • Loading branch information
cheton authored Sep 19, 2024
1 parent e42456d commit b2c2f95
Show file tree
Hide file tree
Showing 157 changed files with 1,279 additions and 763 deletions.
5 changes: 5 additions & 0 deletions .changeset/tonic-ui-73.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tonic-ui/react": minor
---

feat: introduce `DefaultPropsProvider` for setting default props in React components
38 changes: 37 additions & 1 deletion packages/react-docs/pages/_app.page.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useTheme,
} from '@tonic-ui/react';
import {
useConst,
useMediaQuery,
useToggle,
} from '@tonic-ui/react-hooks';
Expand All @@ -32,6 +33,19 @@ const NONCE = ensureString(process.env.NONCE);
// Algolia search client
const searchClient = algoliasearch(process.env.ALGOLIA_APPLICATION_ID, process.env.ALGOLIA_SEARCH_API_KEY);

theme.components = {
// Set default props for components here.
//
// Example:
// ```
// 'AccordionToggle': {
// defaultProps: {
// disabled: true,
// },
// }
// ```
};

// Enable CSS variables replacement
theme.config.useCSSVariables = true;

Expand All @@ -52,6 +66,28 @@ const EmotionCacheProvider = ({
};

const App = (props) => {
const customTheme = useConst(() => {
return {
...theme,
components: {
// Set default props for components here.
//
// Example:
// ```
// 'AccordionToggle': {
// defaultProps: {
// disabled: true,
// },
// }
// ```
},
config: {
...theme?.config,
// Enable CSS variables replacement
useCSSVariables: true,
},
};
});
const [initialColorMode, setColorMode] = useState(null);
const router = useRouter();

Expand Down Expand Up @@ -94,7 +130,7 @@ const App = (props) => {
colorStyle={{
defaultValue: defaultColorStyle,
}}
theme={theme}
theme={customTheme}
useCSSBaseline
>
<PortalManager>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

* Explore a variety of pre-defined icons with [React Icons](../icons).
* Discover how to create custom SVG icons using [SVGIcon](../icons/svg-icon).
* Learn about migrating icon components when upgrading from v1 to v2 [here](../migration-guide/migrating-from-v1-to-v2#icons).
* Learn about migrating icon components when upgrading from v1 to v2 [here](../migrations/migrating-from-v1-to-v2#icons).
89 changes: 89 additions & 0 deletions packages/react/__tests__/use-default-props.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
import { globSync } from 'glob';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';

/**
* Example 1: with `forwardRef`
* ```js
* const Accordion = forwardRef((inProps, ref) => {
* const {
* children,
* ...rest
* } = useDefaultProps({ props: inProps, name: 'Accordion' });
* ``
*
* Example 2: without `forwardRef`
* ```
* const Portal = (inProps) => {
* const {
* appendToParentPortal = false,
* children,
* containerRef,
* } = useDefaultProps({ props: inProps, name: 'Portal' });
* ```
*/
test('the `name` property in `useDefaultProps` should match the component name', () => {
const files = globSync([
path.resolve(__dirname, '../src/*/**/*.js'),
], {
'ignore': [
path.resolve(__dirname, '../src/*/**/*.test.js'),
],
}).sort(); // Sort files alphabetically

let matchCount = 0;
let passCount = 0;

for (const file of files) {
const code = fs.readFileSync(file, { encoding: 'utf-8' });

if (code.indexOf(' useDefaultProps(') !== -1) {
++matchCount;
}

const ast = parse(code, {
sourceType: 'module',
plugins: ['jsx'],
});

traverse(ast, {
VariableDeclarator(path) { // eslint-disable-line no-loop-func
if (!path.node.init) {
return;
}

const isForwardRef = (path.node.init.callee && path.node.init.callee.name === 'forwardRef');
const isFunctionOrArrowFunctionExpression = (path.node.init.type === 'ArrowFunctionExpression' || path.node.init.type === 'FunctionExpression');

if (isForwardRef || isFunctionOrArrowFunctionExpression) {
const componentName = path.node.id.name;
path.traverse({
CallExpression(innerPath) {
if (innerPath.node.callee.name === 'useDefaultProps') {
const nameProperty = innerPath.node.arguments[0].properties.find(prop => prop.key.name === 'name');
const namePropertyValue = nameProperty?.value?.value;
if (!namePropertyValue) {
console.error(`Error: No 'name' property found in 'useDefaultProps' in file "${file}"`);
return;
}

try {
expect(namePropertyValue).toEqual(componentName);
} catch (err) {
throw new Error(`Mismatch in file "${file}": Expected component name '${componentName}' but found '${namePropertyValue}'`);
}

passCount++;
}
}
});
}
}
});
}

expect(matchCount).toBeGreaterThan(0);
expect(matchCount).toEqual(passCount);
});
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"eslint-plugin-jsx-a11y": "latest",
"eslint-plugin-react": "latest",
"eslint-plugin-react-hooks": "latest",
"glob": "^11.0.0",
"jest": "^29.0.0",
"jest-axe": "^8.0.0",
"jest-environment-jsdom": "^29.0.0",
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/Accordion.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@ import { runIfFn } from '@tonic-ui/utils';
import memoize from 'micro-memoize';
import React, { forwardRef } from 'react';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import { AccordionContext } from './context';
import { useAccordionStyle } from './styles';

const getMemoizedState = memoize(state => ({ ...state }));

const Accordion = forwardRef((
{
const Accordion = forwardRef((inProps, ref) => {
const {
children,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'Accordion' });
const context = getMemoizedState({
// TODO
});
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/accordion/AccordionBody.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { forwardRef } from 'react';
import { useDefaultProps } from '../default-props';
import AccordionContent from './AccordionContent';
import { useAccordionBodyStyle } from './styles';

const AccordionBody = forwardRef((props, ref) => {
const AccordionBody = forwardRef((inProps, ref) => {
const props = useDefaultProps({ props: inProps, name: 'AccordionBody' });
const styleProps = useAccordionBodyStyle();

return (
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/AccordionContent.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { ariaAttr } from '@tonic-ui/utils';
import React, { forwardRef } from 'react';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import { Collapse } from '../transitions';
import useAccordionItem from './useAccordionItem';

const AccordionContent = forwardRef((
{
const AccordionContent = forwardRef((inProps, ref) => {
const {
TransitionComponent = Collapse,
TransitionProps,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AccordionContent' });
const context = useAccordionItem(); // context might be an undefined value

if (!context) {
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/AccordionHeader.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { ensureBoolean } from 'ensure-type';
import React, { forwardRef } from 'react';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import AccordionToggle from './AccordionToggle';
import AccordionToggleIcon from './AccordionToggleIcon';
import useAccordionItem from './useAccordionItem';
import { useAccordionHeaderStyle } from './styles';

const AccordionHeader = forwardRef((
{
const AccordionHeader = forwardRef((inProps, ref) => {
const {
children,
disabled: disabledProp,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AccordionHeader' });
const context = useAccordionItem(); // context might be an undefined value
const disabled = ensureBoolean(disabledProp ?? context?.disabled);
const styleProps = useAccordionHeaderStyle();
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/AccordionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,23 @@ import { ensureFunction } from 'ensure-type';
import memoize from 'micro-memoize';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import config from '../shared/config';
import useAutoId from '../utils/useAutoId';
import { AccordionItemContext } from './context';
import useAccordion from './useAccordion';

const getMemoizedState = memoize(state => ({ ...state }));

const AccordionItem = forwardRef((
{
const AccordionItem = forwardRef((inProps, ref) => {
const {
children,
disabled,
isExpanded: isExpandedProp,
defaultIsExpanded: defaultIsExpandedProp,
onToggle: onToggleProp,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AccordionItem' });
const accordionContext = useAccordion();
const defaultId = useAutoId();
const accordionToggleId = `${config.name}:AccordionToggle-${defaultId}`;
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/AccordionToggle.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@ import { ariaAttr, callEventHandlers } from '@tonic-ui/utils';
import { ensureBoolean } from 'ensure-type';
import React, { forwardRef } from 'react';
import { ButtonBase } from '../button';
import { useDefaultProps } from '../default-props';
import useAccordionItem from './useAccordionItem';
import { useAccordionToggleStyle } from './styles';

const AccordionToggle = forwardRef((
{
const AccordionToggle = forwardRef((inProps, ref) => {
const {
children,
disabled: disabledProp,
onClick: onClickProp,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AccordionToggle' });
const context = useAccordionItem(); // context might be an undefined value
const disabled = ensureBoolean(disabledProp ?? context?.disabled);
const styleProps = useAccordionToggleStyle();
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/accordion/AccordionToggleIcon.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ensureBoolean } from 'ensure-type';
import React, { forwardRef, useEffect, useRef } from 'react';
import { Transition } from 'react-transition-group';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import {
useAccordionToggleIconStyle,
} from './styles';
Expand Down Expand Up @@ -39,18 +40,16 @@ const defaultTimeout = {
exit: Math.floor(133 * 0.7),
};

const AccordionToggleIcon = forwardRef((
{
const AccordionToggleIcon = forwardRef((inProps, ref) => {
const {
appear = false, // do not perform the enter transition when it first mounts
children,
disabled: disabledProp,
easing = defaultEasing,
style,
timeout = defaultTimeout,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AccordionToggleIcon' });
const context = useAccordionItem(); // context might be an undefined value
const toggleIconStyleProps = useAccordionToggleIconStyle();
const nodeRef = useRef(null);
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/alert/Alert.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { runIfFn } from '@tonic-ui/utils';
import memoize from 'micro-memoize';
import React, { forwardRef } from 'react';
import { Box } from '../box';
import { useDefaultProps } from '../default-props';
import AlertCloseButton from './AlertCloseButton';
import AlertIcon from './AlertIcon';
import AlertMessage from './AlertMessage';
Expand All @@ -16,18 +17,16 @@ import {

const getMemoizedState = memoize(state => ({ ...state }));

const Alert = forwardRef((
{
const Alert = forwardRef((inProps, ref) => {
const {
isClosable = false,
onClose,
severity = defaultSeverity,
variant = defaultVariant,
icon,
children,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'Alert' });
const context = getMemoizedState({
icon,
isClosable,
Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/alert/AlertCloseButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ import { CloseSIcon } from '@tonic-ui/react-icons';
import { callEventHandlers } from '@tonic-ui/utils';
import React, { forwardRef } from 'react';
import { ButtonBase } from '../button';
import { useDefaultProps } from '../default-props';
import {
useAlertCloseButtonStyle,
} from './styles';
import useAlert from './useAlert';

const AlertCloseButton = forwardRef((
{
const AlertCloseButton = forwardRef((inProps, ref) => {
const {
children,
onClick: onClickProp,
...rest
},
ref,
) => {
} = useDefaultProps({ props: inProps, name: 'AlertCloseButton' });
const alertContext = useAlert(); // context might be an undefined value
const {
// The `isClosable` prop determines whether the close button should be displayed and allows for control over its positioning
Expand Down
Loading

0 comments on commit b2c2f95

Please sign in to comment.