Shadcn Hooks

useClickAway

A hook to click away from an element

Loading...

Installation

npx shadcn@latest add @hooks/use-click-away
pnpm dlx shadcn@latest add @hooks/use-click-away
yarn dlx shadcn@latest add @hooks/use-click-away
bun x shadcn@latest add @hooks/use-click-away

Copy and paste the following code into your project.

use-latest.ts
import { useRef } from 'react'
import { useIsomorphicLayoutEffect } from '@/registry/hooks/use-isomorphic-layout-effect'

export function useLatest<T>(value: T) {
  const ref = useRef(value)

  useIsomorphicLayoutEffect(() => {
    ref.current = value
  })

  return ref
}

create-effect-with-target.ts
import { isBrowser, isEqual, isFunction } from 'es-toolkit'
import { useRef } from 'react'
import { useUnmount } from '@/registry/hooks/use-unmount'
import type {
  DependencyList,
  EffectCallback,
  RefObject,
  useEffect,
  useLayoutEffect,
} from 'react'

type TargetValue<T> = T | undefined | null

type TargetType = HTMLElement | Element | Window | Document

export type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | RefObject<TargetValue<T>>

export function getTargetElement<T extends TargetType>(
  target: BasicTarget<T>,
  defaultElement?: T,
) {
  if (!isBrowser) {
    return undefined
  }

  if (!target) {
    return defaultElement
  }

  let targetElement: TargetValue<T>

  if (isFunction(target)) {
    targetElement = target()
  } else if ('current' in target) {
    targetElement = target.current
  } else {
    targetElement = target
  }

  return targetElement
}

export function createEffectWithTarget(
  useEffectType: typeof useEffect | typeof useLayoutEffect,
) {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[],
  ) => {
    const hasInitRef = useRef(false)

    const lastElementRef = useRef<(Element | null)[]>([])
    const lastDepsRef = useRef<DependencyList>([])

    const unLoadRef = useRef<any>(undefined)

    useEffectType(() => {
      const targets = Array.isArray(target) ? target : [target]
      const els = targets.map((item) => getTargetElement(item))

      // init run
      if (!hasInitRef.current) {
        hasInitRef.current = true
        lastElementRef.current = els
        lastDepsRef.current = deps

        unLoadRef.current = effect()
        return
      }

      if (
        els.length !== lastElementRef.current.length ||
        !isEqual(lastElementRef.current, els) ||
        !isEqual(lastDepsRef.current, deps)
      ) {
        unLoadRef.current?.()

        lastElementRef.current = els
        lastDepsRef.current = deps
        unLoadRef.current = effect()
      }
    })

    useUnmount(() => {
      unLoadRef.current?.()
      // for react-refresh
      hasInitRef.current = false
    })
  }

  return useEffectWithTarget
}

use-effect-with-target.ts
import { useEffect } from 'react'
import { createEffectWithTarget } from '@/registry/lib/create-effect-with-target'

export const useEffectWithTarget = createEffectWithTarget(useEffect)

use-click-away.ts
import { useEffectWithTarget } from '@/registry/hooks/use-effect-with-target'
import { useLatest } from '@/registry/hooks/use-latest'
import { getTargetElement } from '@/registry/lib/create-effect-with-target'
import type { BasicTarget } from '@/registry/lib/create-effect-with-target'

type DocumentEventKey = keyof DocumentEventMap

declare type TargetValue<T> = T | undefined | null

const checkIfAllInShadow = (targets: BasicTarget[]) => {
  return targets.every((item) => {
    const targetElement = getTargetElement(item)
    if (!targetElement) {
      return false
    }
    if (targetElement.getRootNode() instanceof ShadowRoot) {
      return true
    }
    return false
  })
}

const getShadow = (node: TargetValue<Element>) => {
  if (!node) {
    return document
  }
  return node.getRootNode()
}

const getDocumentOrShadow = (
  target: BasicTarget | BasicTarget[],
): Document | Node => {
  if (!target || !document.getRootNode) {
    return document
  }

  const targets = Array.isArray(target) ? target : [target]

  if (checkIfAllInShadow(targets)) {
    return getShadow(getTargetElement(targets[0]))
  }

  return document
}

export function useClickAway<T extends Event = Event>(
  onClickAway: (event: T) => void,
  target: BasicTarget | BasicTarget[],
  eventName: DocumentEventKey | DocumentEventKey[] = 'click',
) {
  const onClickAwayRef = useLatest(onClickAway)

  useEffectWithTarget(
    () => {
      const handler = (event: any) => {
        const targets = Array.isArray(target) ? target : [target]
        if (
          targets.some((item) => {
            const targetElement = getTargetElement(item)
            return !targetElement || targetElement.contains(event.target)
          })
        ) {
          return
        }
        onClickAwayRef.current(event)
      }

      const documentOrShadow = getDocumentOrShadow(target)

      const eventNames = Array.isArray(eventName) ? eventName : [eventName]

      eventNames.forEach((event) =>
        documentOrShadow.addEventListener(event, handler),
      )

      return () => {
        eventNames.forEach((event) =>
          documentOrShadow.removeEventListener(event, handler),
        )
      }
    },
    Array.isArray(eventName) ? eventName : [eventName],
    target,
  )
}

API

type TargetValue<T> = T | undefined | null

type TargetType = HTMLElement | Element | Window | Document

type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | RefObject<TargetValue<T>>

type DocumentEventKey = keyof DocumentEventMap

/**
 * A hook to click away from an element
 * @param onClickAway - The function to call when the user clicks away from the target
 * @param target - The target to click away from
 * @param eventName - The event to listen for
/**
 * A hook to click away from an element
 * @param onClickAway - The function to call when the user clicks away from the target
 * @param target - The target to click away from
 * @param eventName - The event to listen for
 */
 */
export function useClickAway<T extends Event = Event>(
  onClickAway: (event: T) => void,
  target: BasicTarget | BasicTarget[],
  eventName?: DocumentEventKey | DocumentEventKey[],
): void

Credits

Last updated on