Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Tooltipの内部ロジックをリファクタリングする #5245

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, {
} from 'react'

import { type DecoratorsType, useDecorators } from '../../../hooks/useDecorators'
import { type ResponseMessageType, useResponseMessage } from '../../../hooks/useResponseMessage'
import { Button } from '../../Button'
import { Cluster, Stack } from '../../Layout'
import { ResponseMessage } from '../../ResponseMessage'
Expand All @@ -19,8 +20,6 @@ import { dialogContentInner } from '../dialogInnerStyle'

import { StepFormDialogContext, StepItem } from './StepFormDialogProvider'

import type { ResponseMessageType } from '../../../types'

export type BaseProps = PropsWithChildren<
DialogHeaderProps &
DialogBodyProps & {
Expand Down Expand Up @@ -122,8 +121,6 @@ export const StepFormDialogContentInner: FC<StepFormDialogContentInnerProps> = (
setCurrentStep(stepQueue.current.pop() ?? firstStep)
}, [firstStep, stepQueue, onClickBack, setCurrentStep])

const isRequestProcessing = responseMessage && responseMessage.status === 'processing'

const classNames = useMemo(() => {
const { wrapper, actionArea, buttonArea, message } = dialogContentInner()

Expand All @@ -138,6 +135,8 @@ export const StepFormDialogContentInner: FC<StepFormDialogContentInnerProps> = (
const decorated = useDecorators<DecoratorKeyTypes>(DECORATOR_DEFAULT_TEXTS, decorators)
const actionText = activeStep === stepLength ? submitLabel : decorated.nextButtonLabel

const calcedResponseStatus = useResponseMessage(responseMessage)

Comment on lines +138 to +139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calcedはcalculatedってことですかね?ぱっと見読み取れなかった

return (
// eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content
<Section>
Expand All @@ -156,7 +155,7 @@ export const StepFormDialogContentInner: FC<StepFormDialogContentInnerProps> = (
{activeStep > 1 && (
<Button
onClick={handleBackAction}
disabled={isRequestProcessing}
disabled={calcedResponseStatus.isProcessing}
className="smarthr-ui-Dialog-backButton"
>
{decorated.backButtonLabel}
Expand All @@ -165,7 +164,7 @@ export const StepFormDialogContentInner: FC<StepFormDialogContentInnerProps> = (
<Cluster gap={BUTTON_COLUMN_GAP} className={classNames.buttonArea}>
<Button
onClick={handleCloseAction}
disabled={closeDisabled || isRequestProcessing}
disabled={closeDisabled || calcedResponseStatus.isProcessing}
className="smarthr-ui-Dialog-closeButton"
>
{decorated.closeButtonLabel}
Expand All @@ -174,17 +173,17 @@ export const StepFormDialogContentInner: FC<StepFormDialogContentInnerProps> = (
type="submit"
variant={actionTheme}
disabled={actionDisabled}
loading={isRequestProcessing}
loading={calcedResponseStatus.isProcessing}
className="smarthr-ui-Dialog-actionButton"
>
{actionText}
</Button>
</Cluster>
</Cluster>
{(responseMessage?.status === 'success' || responseMessage?.status === 'error') && (
{calcedResponseStatus.message && (
<div className={classNames.message}>
<ResponseMessage type={responseMessage.status} role="alert">
{responseMessage.text}
<ResponseMessage type={calcedResponseStatus.status} role="alert">
{calcedResponseStatus.message}
</ResponseMessage>
</div>
)}
Expand Down
162 changes: 111 additions & 51 deletions packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, {
PropsWithChildren,
ReactElement,
ReactNode,
memo,
useCallback,
useId,
useMemo,
Expand All @@ -25,6 +26,7 @@ import { TooltipPortal } from './TooltipPortal'

const subscribeFullscreenChange = (callback: () => void) => {
window.addEventListener('fullscreenchange', callback)

return () => {
window.removeEventListener('fullscreenchange', callback)
}
Expand Down Expand Up @@ -52,7 +54,7 @@ type Props = PropsWithChildren<{
}>
type ElementProps = Omit<ComponentProps<'span'>, keyof Props | 'aria-describedby'>

const tooltip = tv({
const classNameGenerator = tv({
base: [
'smarthr-ui-Tooltip',
'shr-inline-block shr-max-w-full shr-align-bottom',
Expand All @@ -70,7 +72,7 @@ export const Tooltip: FC<Props & ElementProps> = ({
children,
triggerType,
multiLine,
ellipsisOnly = false,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

比較処理しかしておらず、booleanが型として必要な箇所もなかったため、undefinedのままでも問題になる可能性はありませんでした

ellipsisOnly,
horizontal = 'left',
vertical = 'bottom',
tabIndex = 0,
Expand Down Expand Up @@ -99,53 +101,99 @@ export const Tooltip: FC<Props & ElementProps> = ({
setPortalRoot(fullscreenElement ?? document.body)
}, [fullscreenElement])

const getHandlerToShow = useCallback(
<T,>(handler?: (e: T) => void) =>
(e: T) => {
Comment on lines -102 to -104
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

もともとの方法では 関数を返す関数 をmemo化していました。
共通化以上の意味が薄く、関数も毎回生成されるので以下のように修正しました

  • 共通ロジックを切り出して ellipsisOnly が変更されない限り再生成されないように修正
  • 上記共通ロジックを使ったonXxxx毎の関数をmemo化

これにより適切にメモ化されるようになり、基本的な使い方をしている限り、関数が再生成されないようになっています

if (handler) {
handler(e)
}

if (!ref.current) {
const toShowAction = useCallback(
(e: React.BaseSyntheticEvent) => {
// Tooltipのtriggerの他の要素(Dropwdown menu buttonで開いたmenu contentとか)に移動されたらtooltipを表示しない
if (!ref.current?.contains(e.target)) {
return
}

if (ellipsisOnly) {
const outerWidth = parseInt(
window
.getComputedStyle(ref.current.parentNode! as HTMLElement, null)
.width.match(/\d+/)![0],
10,
)

if (outerWidth < 0 || outerWidth > ref.current.clientWidth) {
return
}
}

if (ellipsisOnly) {
const outerWidth = parseInt(
window
.getComputedStyle(ref.current.parentNode! as HTMLElement, null)
.width.match(/\d+/)![0],
10,
)
const wrapperWidth = ref.current.clientWidth
const existsEllipsis = outerWidth >= 0 && outerWidth <= wrapperWidth

if (!existsEllipsis) {
return
}
}

setRect(ref.current.getBoundingClientRect())
setIsVisible(true)
},
setRect(ref.current.getBoundingClientRect())
setIsVisible(true)
},
[ellipsisOnly],
)
const actualOnPointerEnter = useMemo(
() =>
onPointerEnter
? (e: React.PointerEvent<HTMLSpanElement>) => {
onPointerEnter(e)
toShowAction(e)
}
: toShowAction,
[onPointerEnter, toShowAction],
)
const actualOnTouchStart = useMemo(
() =>
onTouchStart
? (e: React.TouchEvent<HTMLSpanElement>) => {
onTouchStart(e)
toShowAction(e)
}
: toShowAction,
[onTouchStart, toShowAction],
)
const actualOnFocus = useMemo(
() =>
onFocus
? (e: React.FocusEvent<HTMLSpanElement>) => {
onFocus(e)
toShowAction(e)
}
: toShowAction,
[onFocus, toShowAction],
)

const getHandlerToHide = useCallback(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こちらも関数を返す関数になっていたため、onXxxx毎にmemo化する方法に変更しています

<T,>(handler?: (e: T) => void) =>
(e: T) => {
if (handler) {
handler(e)
}

setIsVisible(false)
},
[setIsVisible],
const toCloseAction = useCallback(() => setIsVisible(false), [])
const actualOnPointerLeave = useMemo(
() =>
onPointerLeave
? (e: React.PointerEvent<HTMLSpanElement>) => {
onPointerLeave(e)
toCloseAction()
}
: toCloseAction,
[onPointerLeave, toCloseAction],
)
const actualOnTouchEnd = useMemo(
() =>
onTouchEnd
? (e: React.TouchEvent<HTMLSpanElement>) => {
onTouchEnd(e)
toCloseAction()
}
: toCloseAction,
[onTouchEnd, toCloseAction],
)
const actualOnBlur = useMemo(
() =>
onBlur
? (e: React.FocusEvent<HTMLSpanElement>) => {
onBlur(e)
toCloseAction()
}
: toCloseAction,
[onBlur, toCloseAction],
)

const hiddenText = useMemo(() => innerText(message), [message])
const isIcon = triggerType === 'icon'
const styles = tooltip({ isIcon, className })
const actualClassName = useMemo(
() => classNameGenerator({ isIcon, className }),
[isIcon, className],
)
const isInnerTarget = ariaDescribedbyTarget === 'inner'
const childrenWithProps = useMemo(
() =>
Expand All @@ -159,16 +207,16 @@ export const Tooltip: FC<Props & ElementProps> = ({
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,smarthr/a11y-delegate-element-has-role-presentation
<span
{...props}
aria-describedby={isInnerTarget ? undefined : messageId}
ref={ref}
onPointerEnter={getHandlerToShow(onPointerEnter)}
onTouchStart={getHandlerToShow(onTouchStart)}
onFocus={getHandlerToShow(onFocus)}
onPointerLeave={getHandlerToHide(onPointerLeave)}
onTouchEnd={getHandlerToHide(onTouchEnd)}
onBlur={getHandlerToHide(onBlur)}
tabIndex={tabIndex}
className={styles}
aria-describedby={isInnerTarget ? undefined : messageId}
onPointerEnter={actualOnPointerEnter}
onTouchStart={actualOnTouchStart}
onFocus={actualOnFocus}
onPointerLeave={actualOnPointerLeave}
onTouchEnd={actualOnTouchEnd}
onBlur={actualOnBlur}
className={actualClassName}
>
{portalRoot &&
createPortal(
Expand All @@ -185,9 +233,21 @@ export const Tooltip: FC<Props & ElementProps> = ({
portalRoot,
)}
{childrenWithProps}
<VisuallyHiddenText id={messageId} aria-hidden={!isVisible}>
{hiddenText}
</VisuallyHiddenText>
<MemoizedVisuallyHiddenText id={messageId} visible={isVisible}>
{message}
</MemoizedVisuallyHiddenText>
</span>
)
}

const MemoizedVisuallyHiddenText = memo<PropsWithChildren<{ id: string; visible: boolean }>>(
({ id, visible, children }) => {
const hiddenText = useMemo(() => innerText(children), [children])

return (
<VisuallyHiddenText id={id} aria-hidden={!visible}>
{hiddenText}
</VisuallyHiddenText>
)
},
)