From ad394ded5df6e6ba5a2e0234e1febb49ce312966 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Fri, 31 Oct 2025 11:44:01 +0300 Subject: [PATCH] feat(OptionSwatch): add new component --- .../OptionSwatch/OptionSwatch.api.mdx | 37 +++++ .../OptionSwatch/OptionSwatch.classes.ts | 21 +++ .../OptionSwatch/OptionSwatch.stories.tsx | 42 +++++ .../components/OptionSwatch/OptionSwatch.tsx | 154 ++++++++++++++++++ .../OptionSwatch/OptionSwatch.types.ts | 63 +++++++ .../src/components/OptionSwatch/index.ts | 4 + packages/react/src/components/index.ts | 1 + 7 files changed, 322 insertions(+) create mode 100644 packages/react/src/components/OptionSwatch/OptionSwatch.api.mdx create mode 100644 packages/react/src/components/OptionSwatch/OptionSwatch.classes.ts create mode 100644 packages/react/src/components/OptionSwatch/OptionSwatch.stories.tsx create mode 100644 packages/react/src/components/OptionSwatch/OptionSwatch.tsx create mode 100644 packages/react/src/components/OptionSwatch/OptionSwatch.types.ts create mode 100644 packages/react/src/components/OptionSwatch/index.ts diff --git a/packages/react/src/components/OptionSwatch/OptionSwatch.api.mdx b/packages/react/src/components/OptionSwatch/OptionSwatch.api.mdx new file mode 100644 index 000000000..bbedebd58 --- /dev/null +++ b/packages/react/src/components/OptionSwatch/OptionSwatch.api.mdx @@ -0,0 +1,37 @@ +import { Meta } from '@storybook/addon-docs'; +import LinkTo from '@storybook/addon-links/react'; +import { TableInterface } from '~storybook/components/TableInterface'; + + + +# OptionSwatch API + +```js +import { OptionSwatch } from '@esfront/react'; +``` + +## Component name + +The name `ESOptionSwatch` can be used when providing default props or style overrides in the theme. + +## Props + + + +
+ +## CSS + + + +
+ +## Demos + +
    +
  • + + OptionSwatch + +
  • +
diff --git a/packages/react/src/components/OptionSwatch/OptionSwatch.classes.ts b/packages/react/src/components/OptionSwatch/OptionSwatch.classes.ts new file mode 100644 index 000000000..a627e7740 --- /dev/null +++ b/packages/react/src/components/OptionSwatch/OptionSwatch.classes.ts @@ -0,0 +1,21 @@ +import { generateUtilityClass, generateUtilityClasses } from '@mui/material'; + +export type OptionSwatchClasses = { + /** Class name applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; +}; +export type OptionSwatchClassKey = keyof OptionSwatchClasses; + +export function getOptionSwatchUtilityClass(slot: string): string { + return generateUtilityClass('ESOptionSwatch', slot); +} + +export const optionSwatchClasses: OptionSwatchClasses = generateUtilityClasses('ESOptionSwatch', [ + 'root', + 'checked', + 'disabled', +]); diff --git a/packages/react/src/components/OptionSwatch/OptionSwatch.stories.tsx b/packages/react/src/components/OptionSwatch/OptionSwatch.stories.tsx new file mode 100644 index 000000000..c1ca12c7b --- /dev/null +++ b/packages/react/src/components/OptionSwatch/OptionSwatch.stories.tsx @@ -0,0 +1,42 @@ +import { ComponentProps } from 'react'; + +import { Meta, StoryObj } from '@storybook/react'; + +import { OptionSwatch } from './OptionSwatch'; + +type Args = ComponentProps; + +const meta: Meta = { + tags: ['autodocs'], + component: OptionSwatch, + parameters: { + references: ['OptionSwatch'], + }, + argTypes: { + inputProps: { + table: { + disable: true, + }, + }, + inputRef: { + table: { + disable: true, + }, + }, + value: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: (args) => { + return ; + }, +}; diff --git a/packages/react/src/components/OptionSwatch/OptionSwatch.tsx b/packages/react/src/components/OptionSwatch/OptionSwatch.tsx new file mode 100644 index 000000000..22c8ca455 --- /dev/null +++ b/packages/react/src/components/OptionSwatch/OptionSwatch.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; + +import { OptionSwatchProps } from './OptionSwatch.types'; + +import clsx from 'clsx'; +import { getOptionSwatchUtilityClass, optionSwatchClasses } from './OptionSwatch.classes'; + +import { styled } from '@mui/material/styles'; +import { useDefaultProps } from '@mui/system/DefaultPropsProvider'; +import { createChainedFunction } from '@mui/material'; +import composeClasses from '@mui/utils/composeClasses'; + +import { ButtonBase } from '../ButtonBase'; +import { useRadioGroup } from '../RadioGroup'; + +type OptionSwatchOwnerState = { + classes?: OptionSwatchProps['classes']; + checked?: OptionSwatchProps['checked']; + disabled?: OptionSwatchProps['disabled']; +}; + +const useUtilityClasses = (ownerState: OptionSwatchOwnerState) => { + const { classes, checked, disabled } = ownerState; + + const slots = { + root: ['root', checked && 'checked', disabled && 'disabled'], + }; + + const composedClasses = composeClasses(slots, getOptionSwatchUtilityClass, classes); + + return { + ...classes, + ...composedClasses, + }; +}; + +const OptionSwatchRoot = styled(ButtonBase, { + name: 'ESOptionSwatch', + slot: 'Root', + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [styles.root, ownerState.checked && styles.checked, ownerState.disabled && styles.disabled]; + }, +})<{ ownerState: OptionSwatchOwnerState }>(({ theme }) => ({ + background: 'transparent', + color: theme.vars.palette.monoA.A600, + '--hovered': theme.vars.palette.monoA.A50, + '--pressed': theme.vars.palette.monoA.A150, + + [`&.${optionSwatchClasses.disabled}`]: { + cursor: 'not-allowed', + pointerEvents: 'auto', + }, + + variants: [ + { + props: { + checked: true, + }, + style: { + // + }, + }, + { + props: { + disabled: true, + }, + style: { + // + }, + }, + ], +})); + +function areEqualValues(a: any, b: any) { + if (typeof b === 'object' && b !== null) { + return a === b; + } + + // The value could be a number, the DOM will stringify it anyway. + return String(a) === String(b); +} + +export const OptionSwatch = React.forwardRef(function OptionSwatch(inProps, ref) { + const props = useDefaultProps({ props: inProps, name: 'ESOptionSwatch' }); + const { + className, + classes: classesProp, + sx, + + checked: checkedProp, + defaultChecked, + disabled, + + id, + name: nameProp, + value, + + inputProps, + inputRef, + + onChange: onChangeProp, + } = props; + + const radioGroup = useRadioGroup(); + + let checked = checkedProp; + const onChange = createChainedFunction(onChangeProp as never, radioGroup?.onChange as never); + let name = nameProp; + + if (radioGroup) { + if (typeof checked === 'undefined') { + checked = areEqualValues(radioGroup.value, props.value); + } + + if (typeof name === 'undefined') { + name = radioGroup.name; + } + } + + const ownerState = { + classes: classesProp, + checked, + disabled, + }; + + const classes = useUtilityClasses(ownerState); + + return ( + + + + ); +}); diff --git a/packages/react/src/components/OptionSwatch/OptionSwatch.types.ts b/packages/react/src/components/OptionSwatch/OptionSwatch.types.ts new file mode 100644 index 000000000..39ed90ad0 --- /dev/null +++ b/packages/react/src/components/OptionSwatch/OptionSwatch.types.ts @@ -0,0 +1,63 @@ +import { OptionSwatchClasses } from './OptionSwatch.classes'; + +import { SxProps, Theme } from '@mui/material'; + +export interface OptionSwatchProps { + /** Class applied to the root element. */ + className?: string; + /** Override or extend the styles applied to the component. */ + classes?: Partial; + /** The system prop that allows defining system overrides as well as additional CSS styles. */ + sx?: SxProps; + + /** + * If `true`, the component is checked. + */ + checked?: boolean; + + /** + * The default checked state. Use when the component is not controlled. + */ + defaultChecked?: boolean; + + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + + /** + * The id of the `input` element. + */ + id?: string; + + /** + * The name used to reference the value of the control. + * If you don't provide this prop, it falls back to a randomly generated name. + */ + name?: string; + + /** + * [Attributes] applied to the `input` element. + */ + inputProps?: React.InputHTMLAttributes; + + /** + * Pass a ref to the `input` element. + */ + inputRef?: React.Ref; + + /** + * The value of the component. The DOM API casts this to a string. + * The browser uses "on" as the default value. + */ + value?: string | number | readonly string[]; + + /** + * Callback fired when the state is changed. + * + * @param {React.ChangeEvent} event The event source of the callback. + * You can pull out the new checked state by accessing `event.target.checked` (boolean). + */ + onChange?: (event: React.ChangeEvent) => void; +} diff --git a/packages/react/src/components/OptionSwatch/index.ts b/packages/react/src/components/OptionSwatch/index.ts new file mode 100644 index 000000000..193b99aed --- /dev/null +++ b/packages/react/src/components/OptionSwatch/index.ts @@ -0,0 +1,4 @@ +export { OptionSwatch } from './OptionSwatch'; +export type { OptionSwatchClasses, OptionSwatchClassKey } from './OptionSwatch.classes'; +export { optionSwatchClasses } from './OptionSwatch.classes'; +export type { OptionSwatchProps } from './OptionSwatch.types'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index a66e136a6..2c244b07c 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -43,6 +43,7 @@ export * from './locale'; export * from './MadeBy'; export * from './MenuGroup'; export * from './MenuItem'; +export * from './OptionSwatch'; export * from './PageHGroup'; export * from './Pagination'; export * from './PasswordField';