diff --git a/packages/smarthr-ui/src/components/Browser/Browser.tsx b/packages/smarthr-ui/src/components/Browser/Browser.tsx index 3f6596696e..b8828a8c64 100644 --- a/packages/smarthr-ui/src/components/Browser/Browser.tsx +++ b/packages/smarthr-ui/src/components/Browser/Browser.tsx @@ -1,7 +1,7 @@ import React, { FC, KeyboardEventHandler, useCallback, useMemo } from 'react' import { tv } from 'tailwind-variants' -import { DecoratorsType } from '../../types' +import { type DecoratorsType } from '../../hooks/useDecorators' import { Text } from '../Text' import { BrowserColumn } from './BrowserColumn' diff --git a/packages/smarthr-ui/src/components/Button/AnchorButton.tsx b/packages/smarthr-ui/src/components/Button/AnchorButton.tsx index ae08ac1d7f..29c75c689a 100644 --- a/packages/smarthr-ui/src/components/Button/AnchorButton.tsx +++ b/packages/smarthr-ui/src/components/Button/AnchorButton.tsx @@ -12,7 +12,6 @@ import { tv } from 'tailwind-variants' import { ElementRef, ElementRefProps } from '../../types' -import { ButtonInner } from './ButtonInner' import { ButtonWrapper } from './ButtonWrapper' import { DisabledDetail } from './DisabledDetail' import { BaseProps } from './types' @@ -50,11 +49,7 @@ const AnchorButton = forwardRef( }: PropsWithoutRef> & ElementProps, ref: Ref>, ): ReactElement => { - const styles = useMemo(() => anchorButton({ className }), [className]) - const actualRel = useMemo( - () => (rel === undefined && target === '_blank' ? 'noopener noreferrer' : rel), - [rel, target], - ) + const style = useMemo(() => anchorButton({ className }), [className]) const button = ( - - {children} - + {children} ) diff --git a/packages/smarthr-ui/src/components/Button/Button.tsx b/packages/smarthr-ui/src/components/Button/Button.tsx index 13f3c718e9..83cd07c97b 100644 --- a/packages/smarthr-ui/src/components/Button/Button.tsx +++ b/packages/smarthr-ui/src/components/Button/Button.tsx @@ -1,14 +1,13 @@ 'use client' -import React, { ButtonHTMLAttributes, forwardRef, useMemo } from 'react' +import React, { ButtonHTMLAttributes, PropsWithChildren, forwardRef, useMemo } from 'react' import { tv } from 'tailwind-variants' +import { type DecoratorsType, useDecorators } from '../../hooks/useDecorators' import { usePortal } from '../../hooks/usePortal' -import { DecoratorsType } from '../../types' import { Loader } from '../Loader' import { VisuallyHiddenText } from '../VisuallyHiddenText' -import { ButtonInner } from './ButtonInner' import { ButtonWrapper } from './ButtonWrapper' import { DisabledDetail } from './DisabledDetail' import { BaseProps } from './types' @@ -39,10 +38,13 @@ const buttonStyle = tv({ }) export type Props = { - decorators?: DecoratorsType<'loading'> + decorators?: DecoratorsType } -const LOADING_TEXT = '処理中' +const DECORATOR_DEFAULT_TEXTS = { + loading: '処理中', +} as const +type DecoratorKeyTypes = keyof typeof DECORATOR_DEFAULT_TEXTS export const Button = forwardRef( ( @@ -64,24 +66,34 @@ export const Button = forwardRef { - const { wrapper, loader: loaderSlot } = buttonStyle() - const wrapperStyle = useMemo(() => wrapper({ className }), [className, wrapper]) - const loaderStyle = useMemo( - () => loaderSlot({ isSecondary: variant === 'secondary' }), - [loaderSlot, variant], - ) - const { createPortal } = usePortal() + const styles = useMemo(() => { + const { wrapper, loader } = buttonStyle() + + return { + wrapper: wrapper({ className }), + loader: loader({ isSecondary: variant === 'secondary' }), + } + }, [variant, className]) + + let actualPrefix = prefix + let actualSuffix = suffix + let disabledOnLoading = disabled + let actualChildren = children - const loader = - const actualPrefix = !loading && prefix - const actualSuffix = loading && !square ? loader : suffix - const disabledOnLoading = loading || disabled - const actualChildren = loading && square ? loader : children + if (loading) { + actualPrefix = undefined + disabledOnLoading = true + + const loader = + + if (square) { + actualChildren = loader + } else { + actualSuffix = loader + } + } - const statusText = useMemo(() => { - const loadingText = decorators?.loading?.(LOADING_TEXT) ?? LOADING_TEXT - return loading ? loadingText : '' - }, [decorators, loading]) + const decorated = useDecorators(DECORATOR_DEFAULT_TEXTS, decorators) const button = ( - { - // `button` 要素内で live region を使うことはできないので、`role="status"` を持つ要素を外側に配置している。 https://github.com/kufu/smarthr-ui/pull/4558 - createPortal({statusText}) - } - - {actualChildren} - + {decorated.loading} + {actualChildren} ) @@ -115,3 +124,14 @@ export const Button = forwardRef>( + ({ loading, children }) => { + const { createPortal } = usePortal() + + // `button` 要素内で live region を使うことはできないので、`role="status"` を持つ要素を外側に配置している。 https://github.com/kufu/smarthr-ui/pull/4558 + return createPortal( + {loading && children}, + ) + }, +) diff --git a/packages/smarthr-ui/src/components/Button/ButtonInner.tsx b/packages/smarthr-ui/src/components/Button/ButtonInner.tsx deleted file mode 100644 index d217ff42b0..0000000000 --- a/packages/smarthr-ui/src/components/Button/ButtonInner.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { FC, PropsWithChildren, useMemo } from 'react' -import { tv } from 'tailwind-variants' - -export type Props = PropsWithChildren<{ - prefix?: React.ReactNode - suffix?: React.ReactNode - size: 'default' | 's' -}> - -const buttonInner = tv({ - base: [ - /* LineClamp を併用する場合に、幅を計算してもらうために指定 */ - 'shr-min-w-0', - ], - variants: { - size: { - default: '', - s: [ - /* SVG とテキストコンテンツの縦位置を揃えるために指定 */ - 'shr-leading-[0]', - ], - }, - }, -}) - -export const ButtonInner: FC = ({ prefix, suffix, size, ...props }) => { - const styles = useMemo(() => buttonInner({ size }), [size]) - return ( - <> - {prefix} - - {suffix} - - ) -} diff --git a/packages/smarthr-ui/src/components/Button/ButtonWrapper.tsx b/packages/smarthr-ui/src/components/Button/ButtonWrapper.tsx index d4d83f99ef..9643246dba 100644 --- a/packages/smarthr-ui/src/components/Button/ButtonWrapper.tsx +++ b/packages/smarthr-ui/src/components/Button/ButtonWrapper.tsx @@ -3,6 +3,7 @@ import React, { ButtonHTMLAttributes, ElementType, ForwardedRef, + PropsWithChildren, ReactNode, useMemo, } from 'react' @@ -10,16 +11,17 @@ import { tv } from 'tailwind-variants' import { Variant } from './types' -type BaseProps = { +type BaseProps = PropsWithChildren<{ size: 'default' | 's' square: boolean wide: boolean variant: Variant $loading?: boolean className: string - children: ReactNode elementAs?: ElementType -} + prefix?: ReactNode + suffix?: ReactNode +}> type ButtonProps = BaseProps & { isAnchor?: never @@ -33,17 +35,25 @@ type Props = | (ButtonProps & Omit, keyof ButtonProps>) | (AnchorProps & Omit, keyof AnchorProps>) +const EVENT_CANCELLER = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() +} + export function ButtonWrapper({ variant, size, square, wide = false, $loading, + prefix, + suffix, + children, className, - ...props + ...rest }: Props) { - const { buttonStyle, anchorStyle } = useMemo(() => { - const { default: defaultButton, anchor } = button({ + const wrapperStyle = useMemo(() => { + const generate = button({ variant, size, square, @@ -51,42 +61,48 @@ export function ButtonWrapper({ wide, }) - return { - buttonStyle: defaultButton({ className }), - anchorStyle: anchor({ className }), - } - }, [$loading, className, size, square, variant, wide]) + const wrapper = rest.isAnchor ? generate.anchor : generate.button + + return wrapper({ className }) + }, [$loading, size, square, variant, wide, className, rest.isAnchor]) + const innerStyle = useMemo(() => buttonInner({ size }), [size]) - if (props.isAnchor) { - const { anchorRef, elementAs, isAnchor: _, ...others } = props + // HINT: 型の関係でisAnchorをrestから展開してしまうとa要素であることを + // 自動型づけできなくなってしまう + if (rest.isAnchor) { + const { anchorRef, elementAs, isAnchor: _, ...others } = rest const Component = elementAs || 'a' - return + return ( + + {prefix} + {children} + {suffix} + + ) } else { - const { buttonRef, disabled, onClick, ...others } = props + const { buttonRef, disabled, onClick, ...others } = rest + return ( // eslint-disable-next-line smarthr/best-practice-for-button-element ) } } const button = tv({ slots: { - default: [ + button: [ 'aria-disabled:shr-cursor-not-allowed', /* alpha color を使用しているので、背景色と干渉させない */ 'aria-disabled:shr-bg-clip-padding', @@ -127,7 +143,7 @@ const button = tv({ }, compoundSlots: [ { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], className: [ 'shr-box-border', 'shr-cursor-pointer', @@ -154,7 +170,7 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], size: 's', className: [ 'shr-p-0.5', @@ -164,34 +180,34 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], size: 'default', className: ['shr-text-base'], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], size: 'default', square: false, className: 'shr-px-1 shr-py-0.75', }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], size: 'default', square: true, className: 'shr-p-0.75', }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], loading: true, className: 'shr-flex-row-reverse', }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], wide: true, className: 'shr-w-full', }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], variant: 'primary', className: [ 'shr-border-main', @@ -204,7 +220,7 @@ const button = tv({ ], }, { - slots: ['default'], + slots: ['button'], variant: 'primary', className: [ 'aria-disabled:shr-border-main/50', @@ -222,7 +238,7 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], variant: 'secondary', className: [ 'shr-border-default', @@ -237,7 +253,7 @@ const button = tv({ ], }, { - slots: ['default'], + slots: ['button'], variant: 'secondary', className: [ 'aria-disabled:shr-border-disabled', @@ -255,7 +271,7 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], variant: 'danger', className: [ 'shr-border-danger', @@ -268,7 +284,7 @@ const button = tv({ ], }, { - slots: ['default'], + slots: ['button'], variant: 'danger', className: [ 'aria-disabled:shr-border-danger/50', @@ -286,7 +302,7 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], variant: 'skeleton', className: [ 'shr-border-white', @@ -301,7 +317,7 @@ const button = tv({ ], }, { - slots: ['default'], + slots: ['button'], variant: 'skeleton', className: [ 'aria-disabled:shr-border-white/50', @@ -319,7 +335,7 @@ const button = tv({ ], }, { - slots: ['default', 'anchor'], + slots: ['button', 'anchor'], variant: 'text', className: [ 'shr-border-transparent', @@ -330,7 +346,7 @@ const button = tv({ ], }, { - slots: ['default'], + slots: ['button'], variant: 'text', className: [ 'aria-disabled:shr-border-transparent', @@ -349,3 +365,19 @@ const button = tv({ }, ], }) + +const buttonInner = tv({ + base: [ + /* LineClamp を併用する場合に、幅を計算してもらうために指定 */ + 'shr-min-w-0', + ], + variants: { + size: { + default: '', + s: [ + /* SVG とテキストコンテンツの縦位置を揃えるために指定 */ + 'shr-leading-[0]', + ], + }, + }, +}) diff --git a/packages/smarthr-ui/src/components/Button/DisabledDetail.tsx b/packages/smarthr-ui/src/components/Button/DisabledDetail.tsx index e06fbb279a..b0051e2685 100644 --- a/packages/smarthr-ui/src/components/Button/DisabledDetail.tsx +++ b/packages/smarthr-ui/src/components/Button/DisabledDetail.tsx @@ -1,4 +1,4 @@ -import React, { type FC } from 'react' +import React, { type FC, useMemo } from 'react' import { tv } from 'tailwind-variants' import { FaCircleInfoIcon } from '../Icon' @@ -31,21 +31,43 @@ const disabledDetailStyle = tv({ }) export const DisabledDetail: FC = ({ button, disabledDetail }) => { - const { disabledWrapper, disabledTooltip } = disabledDetailStyle() - const DisabledDetailIcon = disabledDetail.icon ?? FaCircleInfoIcon + const styles = useMemo(() => { + const { disabledWrapper, disabledTooltip } = disabledDetailStyle() + + return { + disabledWrapper: disabledWrapper(), + disabledTooltip: disabledTooltip(), + } + }, []) return ( -
+
{button} - - - + className={styles.disabledTooltip} + />
) } + +const TooltipIcon = React.memo<{ + icon?: React.FunctionComponent + message: React.ReactNode + className: string +}>(({ icon, message, className }) => { + const DisabledDetailIcon = icon ?? FaCircleInfoIcon + + return ( + + + + ) +}) diff --git a/packages/smarthr-ui/src/components/Button/UnstyledButton.tsx b/packages/smarthr-ui/src/components/Button/UnstyledButton.tsx index c7f39f51a3..4f8b1ea595 100644 --- a/packages/smarthr-ui/src/components/Button/UnstyledButton.tsx +++ b/packages/smarthr-ui/src/components/Button/UnstyledButton.tsx @@ -13,5 +13,6 @@ export const UnstyledButton = forwardRef< PropsWithChildren> >(({ className, type = 'button', ...props }, ref) => { const styles = useMemo(() => unstyledButton({ className }), [className]) + return