diff --git a/.gitignore b/.gitignore index e7fa3e1b1..c7fbc225c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ storybook-static/ typedoc.json .env yarn-error.log -.DS_Store/ +.DS_Store .cache/ \ No newline at end of file diff --git a/packages/react/src/components/SplitButton/SplitButton.api.mdx b/packages/react/src/components/SplitButton/SplitButton.api.mdx new file mode 100644 index 000000000..93fa8db78 --- /dev/null +++ b/packages/react/src/components/SplitButton/SplitButton.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'; + + + +# SplitButton API + +```js +import { SplitButton } from '@elonkit/react'; +``` + +## Component name + +The name `ESSplitButton` can be used when providing default props or style overrides in the theme. + +## Props + + + +
+ +## CSS + + + +
+ +## Demos + + diff --git a/packages/react/src/components/SplitButton/SplitButton.classes.ts b/packages/react/src/components/SplitButton/SplitButton.classes.ts new file mode 100644 index 000000000..a473d34d0 --- /dev/null +++ b/packages/react/src/components/SplitButton/SplitButton.classes.ts @@ -0,0 +1,109 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; + +export interface SplitButtonClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the root element if `variant="contained"`. */ + contained: string; + /** Styles applied to the root element if `variant="outlined"`. */ + outlined: string; + /** State class applied to the child elements if `disabled={true}`. */ + disabled: string; + /** Styles applied to the first button in the split button. */ + firstButton: string; + /** Styles applied to the root element if `fullWidth={true}`. */ + fullWidth: string; + /** Styles applied to the children. */ + split: string; + /** Styles applied to the root element if `color="primary"` */ + colorPrimary: string; + /** Styles applied to the root element if `color="secondary"` */ + colorSecondary: string; + /** Styles applied to the root element if `color="tertiary"` */ + colorTertiary: string; + /** Styles applied to the root element if `color="success"` */ + colorSuccess: string; + /** Styles applied to the root element if `color="error"` */ + colorError: string; + /** Styles applied to the root element if `color="monoA"` */ + colorMonoA: string; + /** Styles applied to the root element if `color="monoB"` */ + colorMonoB: string; + /** Styles applied to the root element if `color="black"` */ + colorBlack: string; + /** Styles applied to the root element if `color="white"` */ + colorWhite: string; + /** Styles applied to the divider element.` */ + splitButtonDivider: string; + /** Styles applied to the children if `variant="outlined"`. */ + splitOutlined: string; + /** Styles applied to the children if `variant="outlined"` and `color="primary"`. */ + splitOutlinedPrimary: string; + /** Styles applied to the children if `variant="outlined"` and `color="secondary"`. */ + splitOutlinedSecondary: string; + /** Styles applied to the children if `variant="contained"`. */ + splitContained: string; + /** Styles applied to the children if `variant="contained"` and `color="primary"`. */ + splitContainedPrimary: string; + /** Styles applied to the children if `variant="contained"` and `color="secondary"`. */ + splitContainedSecondary: string; + /** Styles applied to the last button in the split button. */ + lastButton: string; + /** Styles applied to buttons in the middle of the split button. */ + middleButton: string; + /** Styles applied to the root element if `size="20"` */ + size200: string; + /** Styles applied to the root element if `size="24"` */ + size300: string; + /** Styles applied to the root element if `size="32"` */ + size400: string; + /** Styles applied to the root element if `size="40"` */ + size500: string; + /** Styles applied to the root element if `size="48"` */ + size600: string; + /** Styles applied to the root element if `size="56"` */ + size700: string; +} + +export type SplitButtonClassKey = keyof SplitButtonClasses; + +export function getSplitButtonUtilityClass(slot: string): string { + return generateUtilityClass('ESSplitButton', slot); +} + +export const splitButtonClasses: SplitButtonClasses = generateUtilityClasses('ESSplitButton', [ + 'root', + 'contained', + 'outlined', + 'disabled', + 'firstButton', + 'fullWidth', + 'splitButtonDivider', + 'colorPrimary', + 'colorSecondary', + 'colorTertiary', + 'colorSuccess', + 'colorError', + 'colorInfo', + 'colorWarning', + 'colorMonoA', + 'colorMonoB', + 'colorBlack', + 'colorWhite', + 'split', + 'splitOutlined', + 'splitOutlinedPrimary', + 'splitOutlinedSecondary', + 'splitContained', + 'splitContainedPrimary', + 'splitContainedSecondary', + 'lastButton', + 'middleButton', + 'size200', + 'size300', + 'size400', + 'size500', + 'size600', + 'size700', +]); diff --git a/packages/react/src/components/SplitButton/SplitButton.stories.tsx b/packages/react/src/components/SplitButton/SplitButton.stories.tsx new file mode 100644 index 000000000..bcf65a820 --- /dev/null +++ b/packages/react/src/components/SplitButton/SplitButton.stories.tsx @@ -0,0 +1,100 @@ +import { ComponentProps } from 'react'; + +import { Meta, StoryContext, StoryObj } from '@storybook/react'; + +import { Box, useTheme } from '@mui/material'; + +import { SplitButton } from '.'; + +import { IconChevronDownW400 } from '../../icons'; +import { Button } from '../Button/Button'; + +type Args = ComponentProps & { + TouchRipplePropsCenter?: boolean; + TouchRipplePropsPressGrowDuration?: number; + TouchRipplePropsMinimumPressDuration?: number; +}; + +const getText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Button' : 'Кнопка'; +}; + +const meta: Meta = { + tags: ['autodocs'], + component: SplitButton, + parameters: { + references: ['SplitButton'], + }, + argTypes: { + variant: { + control: { type: 'select' }, + }, + color: { + control: { type: 'select' }, + }, + size: { + control: { type: 'select' }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + fullWidth: { + table: { + disable: true, + }, + }, + children: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: (args, context) => { + const text = getText(context); + const icon = + args.size === '200' ? ( + + ) : args.size === '300' ? ( + + ) : ( + + ); + + const theme = useTheme(); + + const backgroundColor = + args.color === 'monoB' || args.color === 'white' ? theme.vars.palette.monoA.A800 : undefined; + + return ( + + + + + + + + + + + + + ); + }, +}; diff --git a/packages/react/src/components/SplitButton/SplitButton.tsx b/packages/react/src/components/SplitButton/SplitButton.tsx new file mode 100644 index 000000000..9e0e97b1c --- /dev/null +++ b/packages/react/src/components/SplitButton/SplitButton.tsx @@ -0,0 +1,360 @@ +import { cloneElement, forwardRef } from 'react'; + +import { SplitButtonProps } from './SplitButton.types'; + +import clsx from 'clsx'; +import { getSplitButtonUtilityClass, splitButtonClasses } from './SplitButton.classes'; + +import { unstable_composeClasses as composeClasses } from '@mui/base'; + +import { styled, useThemeProps } from '@mui/material'; +import { capitalize } from '@mui/material/utils'; +import getValidReactChildren from '@mui/utils/getValidReactChildren'; + +import { buttonBaseClasses } from '../ButtonBase'; +import { Divider, dividerClasses } from '../Divider'; + +type SplitButtonOwnerState = { + classes?: SplitButtonProps['classes']; + color: NonNullable; + disabled: SplitButtonProps['disabled']; + fullWidth: SplitButtonProps['fullWidth']; + variant: NonNullable; + size: SplitButtonProps['size']; +}; + +const useUtilityClasses = (ownerState: SplitButtonOwnerState) => { + const { classes, color, disabled, fullWidth, variant, size } = ownerState; + + const slots = { + root: [ + 'root', + variant, + fullWidth && 'fullWidth', + `color${capitalize(color)}`, + size && `size${capitalize(size)}`, + disabled && 'disabled', + ], + split: [ + 'split', + `split${capitalize(variant)}`, + `split${capitalize(variant)}${capitalize(color)}`, + disabled && 'disabled', + ], + firstButton: ['firstButton'], + lastButton: ['lastButton'], + middleButton: ['middleButton'], + splitButtonDivider: ['splitButtonDivider'], + }; + + return composeClasses(slots, getSplitButtonUtilityClass, classes); +}; + +const SplitButtonRoot = styled('div', { + name: 'ESSplitButton', + slot: 'Root', + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + { [`& .${splitButtonClasses.split}`]: styles.split }, + { [`& .${splitButtonClasses.split}`]: styles[`split${capitalize(ownerState.variant)}`] }, + { + [`& .${splitButtonClasses.split}`]: + styles[`split${capitalize(ownerState.variant)}${capitalize(ownerState.color)}`], + }, + { + [`& .${splitButtonClasses.firstButton}`]: styles.firstButton, + }, + { + [`& .${splitButtonClasses.lastButton}`]: styles.lastButton, + }, + { + [`& .${splitButtonClasses.middleButton}`]: styles.middleButton, + }, + styles.root, + styles[ownerState.variant], + ownerState.fullWidth && styles.fullWidth, + ]; + }, +})(({ theme }) => ({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: 4, + + [`&.${splitButtonClasses.fullWidth}`]: { + width: '100%', + }, + + [`& .${splitButtonClasses.firstButton},& .${splitButtonClasses.middleButton}`]: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + [`& .${splitButtonClasses.lastButton},& .${splitButtonClasses.middleButton}`]: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + + [`&.${splitButtonClasses.outlined}`]: { + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A200}`, + + [`& .${splitButtonClasses.firstButton},& .${splitButtonClasses.middleButton},& .${splitButtonClasses.lastButton}`]: + { + boxShadow: 'none', + + [`&.${buttonBaseClasses.disabled}`]: { + boxShadow: 'none', + }, + }, + + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A200, + }, + + [`&.${splitButtonClasses.colorWhite}`]: { + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.white.A200}`, + + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.white.A200, + }, + }, + [`&.${splitButtonClasses.colorBlack}`]: { + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.black.A200}`, + + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.black.A200, + }, + }, + [`&.${splitButtonClasses.colorMonoB}`]: { + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoB.A200}`, + + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoB.A200, + }, + }, + }, + + [`&.${splitButtonClasses.contained}`]: { + [`&.${splitButtonClasses.colorPrimary}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.primary[300], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoB.A200}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorSecondary}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.secondary[300], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.black.A100}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorError}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.error[300], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoB.A200}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorSuccess}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.success[300], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoB.A200}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorTertiary}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A100, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorWhite}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.white[500], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.black.A100}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.white.A75, + }, + }, + }, + + [`&.${splitButtonClasses.colorBlack}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.black[500], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.white.A200}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.black.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.black.A100}`, + }, + }, + }, + + [`&.${splitButtonClasses.colorMonoB}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoB[500], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoB.A75, + }, + }, + }, + + [`&.${splitButtonClasses.colorMonoA}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoA[500], + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoB.A200}`, + }, + + [`&.${splitButtonClasses.disabled}`]: { + [`& .${splitButtonClasses.splitButtonDivider}`]: { + color: theme.vars.palette.monoB.A75, + boxShadow: `inset 0px 0px 0px 1px ${theme.vars.palette.monoA.A100}`, + }, + }, + }, + }, +})); + +const SplitButtonDivider = styled(Divider, { + name: 'ESSplitButtonDivider', + slot: 'SplitButtonDivider', + overridesResolver: (_props, styles) => styles.splitButtonDivider, +})<{ ownerState: SplitButtonOwnerState }>(() => ({ + variants: [ + { + props: { + variant: 'outlined', + }, + style: { + [`&.${dividerClasses.vertical}.${dividerClasses.flexItem}`]: { + margin: '1px 0 1px 0', + }, + }, + }, + ], +})); + +export const SplitButton = forwardRef(function SplitButton(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'ESSplitButton' }); + const { + children, + className, + color = 'primary', + disabled = false, + disableTouchRipple = false, + fullWidth = false, + size = '500', + variant = 'outlined', + ...other + } = props; + + const ownerState = { + ...props, + color, + disabled, + disableTouchRipple, + fullWidth, + size, + variant, + }; + + const classes = useUtilityClasses(ownerState); + + const validChildren = getValidReactChildren(children); + const childrenCount = validChildren.length; + + const getButtonPositionClassName = (index: number) => { + const isFirstButton = index === 0; + const isLastButton = index === childrenCount - 1; + + if (isFirstButton && isLastButton) { + return ''; + } + + if (isFirstButton) { + return classes.firstButton; + } + + if (isLastButton) { + return classes.lastButton; + } + + return classes.middleButton; + }; + + const shouldRenderDivider = (index: number) => { + return childrenCount > 1 && index < childrenCount - 1 && (childrenCount !== 3 || index < 2); + }; + + return ( + + {validChildren.map((child, index) => ( + <> + {cloneElement(child, { + className: clsx(classes.split, getButtonPositionClassName(index), child.props.className), + color, + disabled, + disableTouchRipple, + fullWidth, + size, + variant, + })} + {shouldRenderDivider(index) && ( + + )} + + ))} + + ); +}); diff --git a/packages/react/src/components/SplitButton/SplitButton.types.ts b/packages/react/src/components/SplitButton/SplitButton.types.ts new file mode 100644 index 000000000..5a2b4c3ad --- /dev/null +++ b/packages/react/src/components/SplitButton/SplitButton.types.ts @@ -0,0 +1,63 @@ +import { ReactNode } from 'react'; + +import { + ButtonPropsColorOverrides, + ButtonPropsSizeOverrides, + ButtonPropsVariantOverrides, +} from '../Button/Button.types'; + +import { SplitButtonClasses } from './SplitButton.classes'; + +import { SxProps, Theme } from '@mui/material'; + +import { OverridableStringUnion } from '@mui/types'; + +export interface SplitButtonProps { + /** + * The content of the component. + */ + children?: ReactNode; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** Class applied to the root element. */ + className?: string; + /** + * The color of the component. + * @default 'primary' + */ + color?: OverridableStringUnion< + 'primary' | 'secondary' | 'tertiary' | 'success' | 'error' | 'monoA' | 'monoB' | 'black' | 'white', + ButtonPropsColorOverrides + >; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If true, the touch ripple effect is disabled. + * @default false + */ + disableTouchRipple?: boolean; + /** + * If `true`, the buttons will take up the full width of its container. + * @default false + */ + fullWidth?: boolean; + /** + * The size of the component. + * @default '500' + */ + size?: OverridableStringUnion<'200' | '300' | '400' | '500' | '600' | '700', ButtonPropsSizeOverrides>; + /** + * The variant to use. + * @default 'outlined' + */ + variant?: OverridableStringUnion<'outlined' | 'contained', ButtonPropsVariantOverrides>; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; +} diff --git a/packages/react/src/components/SplitButton/index.ts b/packages/react/src/components/SplitButton/index.ts new file mode 100644 index 000000000..fc356abd9 --- /dev/null +++ b/packages/react/src/components/SplitButton/index.ts @@ -0,0 +1,3 @@ +export { SplitButton } from './SplitButton'; +export type { SplitButtonClasses, SplitButtonClassKey } from './SplitButton.classes'; +export type { SplitButtonProps } from './SplitButton.types'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 206915895..d79e154c5 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -55,6 +55,7 @@ export * from './Snackbar'; export * from './SortingMenu'; export * from './Spinner'; export * from './Spinner'; +export * from './SplitButton'; export * from './SvgIcon'; export * from './Swiper'; export * from './Swiper'; diff --git a/packages/react/src/overrides.d.ts b/packages/react/src/overrides.d.ts index 7f1d55e63..d316fc28c 100644 --- a/packages/react/src/overrides.d.ts +++ b/packages/react/src/overrides.d.ts @@ -291,6 +291,7 @@ import { TextFieldGroupClassKey, TextFieldGroupProps } from './components/TextFi import { TouchRippleClassKey, TouchRippleProps } from './components/TouchRipple'; import { TooltipClassKey, TooltipProps } from './components/Tooltip'; import { buttonMixin, listItemMixin } from './theming/mixins'; +import { SplitButtonClassKey, SplitButtonProps } from './components/SplitButton'; import { AvatarProps } from './components'; import { BadgeProps, BadgeClassKey } from './components/Badge'; import { BadgePlacementControlProps, BadgePlacementControlClassKey } from './components/BadgePlacementControl'; @@ -374,6 +375,7 @@ declare module '@mui/material/styles/props' { ESBottomSheet: BottomSheetProps; ESButton: ButtonOwnProps; ESButtonBase: ButtonBaseProps; + ESSplitButton: SplitButtonProps; ESCalendar: CalendarProps; ESCalendarButton: CalendarButtonProps; ESCalendarHead: CalendarHeadProps; @@ -523,6 +525,7 @@ declare module '@mui/material/styles/overrides' { ESBottomSheet: BottomSheetClassKey; ESButton: ButtonClassKey; ESButtonBase: ButtonBaseClassKey; + ESSplitButton: SplitButtonClassKey; ESCalendar: CalendarClassKey; ESCalendarButton: CalendarButtonClassKey; ESCalendarHead: CalendarHeadClassKey; @@ -740,6 +743,10 @@ declare module '@mui/material/styles/components' { defaultProps?: ComponentsProps['ESButtonBase']; styleOverrides?: ComponentsOverrides['ESButtonBase']; }; + ESSplitButton?: { + defaultProps?: ComponentsProps['ESSplitButton']; + styleOverrides?: ComponentsOverrides['ESSplitButton']; + }; ESCalendar?: { defaultProps?: ComponentsProps['ESCalendar']; styleOverrides?: ComponentsOverrides['ESCalendar'];