diff --git a/.changeset/khaki-doors-nail.md b/.changeset/khaki-doors-nail.md new file mode 100644 index 000000000..7f5362e0e --- /dev/null +++ b/.changeset/khaki-doors-nail.md @@ -0,0 +1,5 @@ +--- +"@4design/for-ui": patch +--- + +fix(Button): `type="button"` をデフォルトで設定, loading時にdisabledにしないように修正 diff --git a/packages/for-ui/src/button/Button.test.tsx b/packages/for-ui/src/button/Button.test.tsx index ab0d73677..e8b308d23 100644 --- a/packages/for-ui/src/button/Button.test.tsx +++ b/packages/for-ui/src/button/Button.test.tsx @@ -36,15 +36,15 @@ describe('Button', () => { ); expect(screen.queryByRole('button', { name: 'button' })).toBeInTheDocument(); }); - it('with nested text renders single label', () => { + it('with nested text is rendered', () => { render( , ); - expect(screen.queryByRole('button', { name: 'button' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'button test' })).toBeInTheDocument(); }); it('does not fire onClick event when not clicked', async () => { const onClick = vi.fn(); @@ -77,7 +77,19 @@ describe('Button', () => { button , ); - await user.click(screen.getByRole('button', { name: 'button' })); + const element = screen.getByText('button')?.closest('button'); + expect(element).toBeTruthy(); + if (!element) return; // Workaround for that expect does not assert element is not null + await user.click(element); expect(onClick).not.toHaveBeenCalled(); }); + it('works as link when specified as anchor tag by `as`', async () => { + render( + , + ); + const element = await screen.findByRole('link', { name: 'test' }); + expect(element).toBeInTheDocument(); + }); }); diff --git a/packages/for-ui/src/button/Button.tsx b/packages/for-ui/src/button/Button.tsx index 9fc82a08a..9db901df0 100644 --- a/packages/for-ui/src/button/Button.tsx +++ b/packages/for-ui/src/button/Button.tsx @@ -1,15 +1,16 @@ -import { Children, ComponentPropsWithoutRef, ElementType, forwardRef, ReactNode, Ref, useMemo } from 'react'; +import { Children, ElementType, FC, forwardRef, MouseEvent, MouseEventHandler, ReactNode, useMemo } from 'react'; import MuiButton, { ButtonUnstyledProps as MuiButtonProps } from '@mui/base/ButtonUnstyled'; import { LoadingButtonProps } from '@mui/lab/LoadingButton'; import { Loader } from '../loader'; import { fsx } from '../system/fsx'; +import { ComponentProps, Ref } from '../system/polyComponent'; import { walkChildren } from '../system/walkChildren'; // Iterable seems to contain string but cannot be excluded, so added as sum type. type Child = Exclude> | string; -export type ButtonProps = MuiButtonProps & - ComponentPropsWithoutRef & { +export type ButtonProps = ComponentProps< + Omit, 'href' | 'children' | 'onClick'> & { /** * 種類を指定 * @@ -96,8 +97,12 @@ export type ButtonProps = MuiButtonProps */ color?: 'primary' | 'secondary' | 'default'; + onClick?: MouseEventHandler; + className?: string; - }; + }, + As +>; const extractText = (root: ReactNode): string => { let ret = ''; @@ -118,148 +123,155 @@ const structures = ['text', 'icon', 'text-icon', 'icon-text'] as const; type Structure = (typeof structures)[number]; -const _Button = ({ - as, - variant = 'outlined', - intention: passedIntention = 'subtle', - size = 'large', - disabled = false, - loading = false, - startIcon, - endIcon, - color, - children, - _ref, - className, - ...rest -}: ButtonProps & { _ref?: Ref }): JSX.Element => { - const component = as || 'button'; - const childTexts = useMemo(() => Children.map(children, extractText) || [], [children]); - const label = childTexts.join(''); - const structure: Structure = useMemo(() => { - if ((childTexts.at(0) && !childTexts.at(-1)) || (endIcon && children)) { - return 'text-icon'; - } - if ((!childTexts.at(0) && childTexts.at(-1)) || (startIcon && children)) { - return 'icon-text'; - } - if (!childTexts.at(0) || (startIcon && !children)) { - return 'icon'; - } - return 'text'; - }, [startIcon, endIcon, children, childTexts]); +type ButtonComponent = (props: ButtonProps) => ReturnType; - // Legacy support for color props - // If not needed, rename the passedIntention to intention. +export const Button: ButtonComponent = forwardRef( + ( + { + as, + variant = 'outlined', + intention: passedIntention = 'subtle', + size = 'large', + loading = false, + startIcon, + endIcon, + color, + children, + className, + onClick, + ...rest + }: ButtonProps, + ref?: Ref, + ): JSX.Element => { + const component = as || 'button'; + const childTexts = useMemo(() => Children.map(children, extractText) || [], [children]); + const structure: Structure = useMemo(() => { + if ((childTexts.at(0) && !childTexts.at(-1)) || (endIcon && children)) { + return 'text-icon'; + } + if ((!childTexts.at(0) && childTexts.at(-1)) || (startIcon && children)) { + return 'icon-text'; + } + if (!childTexts.at(0) || (startIcon && !children)) { + return 'icon'; + } + return 'text'; + }, [startIcon, endIcon, children, childTexts]); - const intention = color - ? ( - { - primary: 'primary', - secondary: 'secondary', - default: 'primary', - } as const - )[color] - : passedIntention; + // Legacy support for color props + // If not needed, rename the passedIntention to intention. - return ( - - component={component} - ref={_ref} - disabled={disabled || loading} - aria-label={label || rest['aria-label'] || 'button'} - aria-busy={loading} - className={fsx([ - `rounded-1.5 focus-visible:shadow-focused relative flex h-fit w-fit shrink-0 flex-row items-center justify-center font-sans outline-none disabled:cursor-not-allowed [&_svg]:fill-inherit`, - { - text: { - large: `px-4 py-2 gap-1`, - medium: `px-4 py-1 gap-1`, - small: `px-2 py-0.5 gap-0.5`, - }, - icon: { - large: `p-2 gap-1 [&_svg]:w-6 [&_svg]:h-6`, - medium: `p-1 gap-1 [&_svg]:w-6 [&_svg]:h-6`, - small: `p-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, - }, - 'text-icon': { - large: `pl-4 pr-3 py-2 gap-1 [&_svg]:w-4 [&_svg]:h-4`, - medium: `pl-4 pr-2 py-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, - small: `pl-2 pr-1 py-0.5 gap-0.5 [&_svg]:w-3 [&_svg]:h-3`, - }, - 'icon-text': { - large: `pl-3 pr-4 py-2 gap-1 [&_svg]:w-4 [&_svg]:h-4`, - medium: `pl-2 pr-4 py-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, - small: `pl-1 pr-2 py-0.5 gap-0.5 [&_svg]:w-3 [&_svg]:h-3`, - }, - }[structure][size], - { - large: `text-r`, - medium: `text-r`, - small: `text-s`, - }[size], - { - filled: `font-bold disabled:bg-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, - outlined: `font-regular outline outline-1 -outline-offset-1 disabled:bg-shade-dark-disabled disabled:outline-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, - text: `font-bold disabled:bg-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, - }[variant], - { - subtle: { - filled: [ - `bg-shade-light-default hover:bg-shade-light-hover focus-visible:bg-shade-light-hover text-shade-medium-default fill-shade-medium-default`, - structure === 'icon' && `fill-shade-dark-default`, - ], - outlined: [ - `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-shade-medium-default text-shade-dark-default fill-shade-medium-default`, - structure === 'icon' && `fill-shade-dark-default`, - ], - text: [ - `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-shade-medium-default fill-shade-medium-default`, - structure === 'icon' && `fill-shade-dark-default`, - ], - }, - primary: { - filled: `bg-primary-dark-default hover:bg-primary-dark-hover focus-visible:bg-primary-dark-hover text-shade-white-default fill-shade-white-default`, - outlined: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-primary-dark-default text-primary-dark-default fill-primary-dark-default`, - text: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-primary-dark-default fill-primary-dark-default`, - }, - secondary: { - filled: `bg-secondary-dark-default hover:bg-secondary-dark-hover focus-visible:bg-secondary-dark-hover text-shade-white-default fill-shade-white-default`, - outlined: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-secondary-dark-default text-secondary-dark-default fill-secondary-dark-default`, - text: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-secondary-dark-default fill-secondary-dark-default`, - }, - shade: { - filled: `bg-shade-dark-default hover:bg-shade-dark-hover text-shade-white-default fill-shade-white-default`, - outlined: `bg-shade-white-default hover:bg-shade-white-hover outline-shade-dark-default text-shade-dark-default fill-shade-dark-default`, - text: `bg-shade-white-default hover:bg-shade-white-hover text-shade-dark-default fill-shade-dark-default`, - }, - negative: { - filled: `bg-negative-dark-default hover:bg-negative-dark-hover text-shade-white-default fill-shade-white-default`, - outlined: `bg-shade-white-default hover:bg-shade-white-hover outline-negative-dark-default text-negative-dark-default fill-negative-dark-default`, - text: `bg-shade-white-default hover:bg-shade-white-hover text-negative-dark-default fill-negative-dark-default`, - }, - }[intention][variant], - className, - ])} - // FIXME: Avoid unintended type error, maybe MUI's problem? - {...(rest as MuiButtonProps)} - > - {startIcon} - {children} - {endIcon} - {loading && ( -
- -
- )} - - ); -}; + const intention = color + ? ( + { + primary: 'primary', + secondary: 'secondary', + default: 'primary', + } as const + )[color] + : passedIntention; -export const Button = forwardRef((props: ButtonProps, ref: Ref) => ( - <_Button _ref={ref} {...props} /> -)) as (props: ButtonProps) => JSX.Element; + return ( + + component={component} + ref={ref} + aria-disabled={loading} + aria-busy={loading} + type="button" + className={fsx([ + `rounded-1.5 focus-visible:shadow-focused relative flex h-fit w-fit shrink-0 flex-row items-center justify-center font-sans outline-none disabled:cursor-not-allowed [&_svg]:fill-inherit`, + { + text: { + large: `px-4 py-2 gap-1`, + medium: `px-4 py-1 gap-1`, + small: `px-2 py-0.5 gap-0.5`, + }, + icon: { + large: `p-2 gap-1 [&_svg]:w-6 [&_svg]:h-6`, + medium: `p-1 gap-1 [&_svg]:w-6 [&_svg]:h-6`, + small: `p-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, + }, + 'text-icon': { + large: `pl-4 pr-3 py-2 gap-1 [&_svg]:w-4 [&_svg]:h-4`, + medium: `pl-4 pr-2 py-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, + small: `pl-2 pr-1 py-0.5 gap-0.5 [&_svg]:w-3 [&_svg]:h-3`, + }, + 'icon-text': { + large: `pl-3 pr-4 py-2 gap-1 [&_svg]:w-4 [&_svg]:h-4`, + medium: `pl-2 pr-4 py-1 gap-1 [&_svg]:w-4 [&_svg]:h-4`, + small: `pl-1 pr-2 py-0.5 gap-0.5 [&_svg]:w-3 [&_svg]:h-3`, + }, + }[structure][size], + { + large: `text-r`, + medium: `text-r`, + small: `text-s`, + }[size], + { + filled: `font-bold disabled:bg-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, + outlined: `font-regular outline outline-1 -outline-offset-1 disabled:bg-shade-dark-disabled disabled:outline-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, + text: `font-bold disabled:bg-shade-dark-disabled disabled:text-shade-white-disabled disabled:fill-shade-dark-disabled`, + }[variant], + { + subtle: { + filled: [ + `bg-shade-light-default hover:bg-shade-light-hover focus-visible:bg-shade-light-hover text-shade-medium-default fill-shade-medium-default`, + structure === 'icon' && `fill-shade-dark-default`, + ], + outlined: [ + `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-shade-medium-default text-shade-dark-default fill-shade-medium-default`, + structure === 'icon' && `fill-shade-dark-default`, + ], + text: [ + `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-shade-medium-default fill-shade-medium-default`, + structure === 'icon' && `fill-shade-dark-default`, + ], + }, + primary: { + filled: `bg-primary-dark-default hover:bg-primary-dark-hover focus-visible:bg-primary-dark-hover text-shade-white-default fill-shade-white-default`, + outlined: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-primary-dark-default text-primary-dark-default fill-primary-dark-default`, + text: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-primary-dark-default fill-primary-dark-default`, + }, + secondary: { + filled: `bg-secondary-dark-default hover:bg-secondary-dark-hover focus-visible:bg-secondary-dark-hover text-shade-white-default fill-shade-white-default`, + outlined: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover outline-secondary-dark-default text-secondary-dark-default fill-secondary-dark-default`, + text: `bg-shade-white-default hover:bg-shade-white-hover focus-visible:bg-shade-white-hover text-secondary-dark-default fill-secondary-dark-default`, + }, + shade: { + filled: `bg-shade-dark-default hover:bg-shade-dark-hover text-shade-white-default fill-shade-white-default`, + outlined: `bg-shade-white-default hover:bg-shade-white-hover outline-shade-dark-default text-shade-dark-default fill-shade-dark-default`, + text: `bg-shade-white-default hover:bg-shade-white-hover text-shade-dark-default fill-shade-dark-default`, + }, + negative: { + filled: `bg-negative-dark-default hover:bg-negative-dark-hover text-shade-white-default fill-shade-white-default`, + outlined: `bg-shade-white-default hover:bg-shade-white-hover outline-negative-dark-default text-negative-dark-default fill-negative-dark-default`, + text: `bg-shade-white-default hover:bg-shade-white-hover text-negative-dark-default fill-negative-dark-default`, + }, + }[intention][variant], + className, + ])} + // FIXME: Avoid unintended type error, maybe MUI's problem? + {...(rest as MuiButtonProps)} + onClick={(e: MouseEvent) => { + if (loading) { + return; + } + onClick?.(e); + }} + > + {startIcon} + {children} + {endIcon} + {loading && ( +
+ +
+ )} + + ); + }, +);