dify/web/app/components/step-by-step-tour/dropdown-menu.ts
Jingyi cb64446fa3
feat(web): add step-by-step tour shell (#38785)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-20 09:23:25 +00:00

189 lines
6.1 KiB
TypeScript

import type { DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import type { ComponentProps, SyntheticEvent } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useState } from 'react'
const STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR = 'data-step-by-step-tour-highlight-part'
const STEP_BY_STEP_TOUR_MENU_POPUP_NO_MOTION_CLASS_NAME =
'transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100'
const STEP_BY_STEP_TOUR_MENU_PRESENTATION_CLASS_NAME = 'pointer-events-none cursor-default'
type DropdownMenuContentProps = ComponentProps<typeof DropdownMenuContent>
type DropdownMenuPositionerProps = DropdownMenuContentProps['positionerProps']
type DropdownMenuPopupProps = DropdownMenuContentProps['popupProps']
type StepByStepTourDropdownMenuContentProps = {
highlightPart?: string
disableMotion?: boolean
interactionMode?: 'interactive' | 'presentation'
popupClassName?: string
popupProps?: DropdownMenuPopupProps
positionerProps?: DropdownMenuPositionerProps
}
const blockPresentationMenuEvent = (event: SyntheticEvent) => {
event.preventDefault()
event.stopPropagation()
}
const stopMenuEventPropagation = (event: SyntheticEvent) => {
event.stopPropagation()
}
const composeStepByStepTourEventHandlers = <Event extends SyntheticEvent>(
handler: ((event: Event) => void) | undefined,
stepByStepTourHandler: (event: Event) => void,
) => {
return (event: Event) => {
handler?.(event)
stepByStepTourHandler(event)
}
}
export const getStepByStepTourDropdownMenuContentProps = ({
highlightPart,
disableMotion = false,
interactionMode = 'interactive',
popupClassName,
popupProps,
positionerProps,
}: StepByStepTourDropdownMenuContentProps): Pick<
DropdownMenuContentProps,
'popupClassName' | 'popupProps' | 'positionerProps'
> => {
const isPresentation = interactionMode === 'presentation'
const nextPositionerProps =
highlightPart || isPresentation
? ({
...positionerProps,
...(highlightPart
? { [STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR]: highlightPart }
: undefined),
...(isPresentation
? {
onClickCapture: composeStepByStepTourEventHandlers(
positionerProps?.onClickCapture,
blockPresentationMenuEvent,
),
onMouseDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onMouseDownCapture,
blockPresentationMenuEvent,
),
onPointerDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onPointerDownCapture,
blockPresentationMenuEvent,
),
onKeyDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onKeyDownCapture,
blockPresentationMenuEvent,
),
}
: undefined),
} as DropdownMenuPositionerProps)
: positionerProps
const nextPopupProps = isPresentation
? ({
...popupProps,
'aria-hidden': true,
onClickCapture: composeStepByStepTourEventHandlers(
popupProps?.onClickCapture,
blockPresentationMenuEvent,
),
onMouseDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onMouseDownCapture,
blockPresentationMenuEvent,
),
onPointerDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onPointerDownCapture,
blockPresentationMenuEvent,
),
onKeyDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onKeyDownCapture,
blockPresentationMenuEvent,
),
} as DropdownMenuPopupProps)
: ({
...popupProps,
onClick: composeStepByStepTourEventHandlers(popupProps?.onClick, stopMenuEventPropagation),
} as DropdownMenuPopupProps)
return {
popupClassName: cn(
popupClassName,
disableMotion && STEP_BY_STEP_TOUR_MENU_POPUP_NO_MOTION_CLASS_NAME,
isPresentation && STEP_BY_STEP_TOUR_MENU_PRESENTATION_CLASS_NAME,
),
popupProps: nextPopupProps,
positionerProps: nextPositionerProps,
}
}
type StepByStepTourControlledDropdownOptions = {
allowTriggerCloseWhileControlled?: boolean
controlledOpen?: boolean
}
type StepByStepTourControlledDropdownState = {
controlledOpen?: boolean
open: boolean
}
const getNextDropdownStateForControlledOpen = (
state: StepByStepTourControlledDropdownState,
controlledOpen: boolean | undefined,
): StepByStepTourControlledDropdownState => {
if (state.controlledOpen === controlledOpen) return state
return {
controlledOpen,
open:
controlledOpen === true
? true
: controlledOpen === false || state.controlledOpen === true
? false
: state.open,
}
}
export const useStepByStepTourControlledDropdown = ({
allowTriggerCloseWhileControlled = true,
controlledOpen,
}: StepByStepTourControlledDropdownOptions = {}) => {
const [state, setState] = useState<StepByStepTourControlledDropdownState>(() => ({
controlledOpen,
open: controlledOpen === true,
}))
const normalizedState = getNextDropdownStateForControlledOpen(state, controlledOpen)
if (normalizedState !== state) setState(normalizedState)
const onOpenChange = useCallback(
(nextOpen: boolean) => {
setState((currentState) => {
const nextState = getNextDropdownStateForControlledOpen(currentState, controlledOpen)
if (nextState.controlledOpen && !allowTriggerCloseWhileControlled && !nextOpen)
return nextState
return {
...nextState,
open: nextOpen,
}
})
},
[allowTriggerCloseWhileControlled, controlledOpen],
)
const close = useCallback(() => {
setState((currentState) => ({
...currentState,
open: false,
}))
}, [])
return {
close,
controlled: Boolean(normalizedState.controlledOpen && normalizedState.open),
open: normalizedState.open,
onOpenChange,
}
}