import type { CSSProperties, HTMLAttributes } from 'react' import { useEffect, useLayoutEffect, useRef } from 'react' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect type MenuElementProps = HTMLAttributes type MenuSyntheticEvent = Event & { nativeEvent: Event currentTarget: HTMLDivElement target: EventTarget | null persist: () => void isDefaultPrevented: () => boolean isPropagationStopped: () => boolean } type MenuEventListener = (event: MenuSyntheticEvent) => void type MenuNativeListener = (event: Event) => void type MenuEventListenerOptions = { capture?: boolean } type EventListenerEntry = { eventName: string listener: MenuNativeListener options?: MenuEventListenerOptions } const PLUGIN_MANAGED_STYLE_PROPERTIES = new Set(['left', 'opacity', 'position', 'top', 'visibility', 'width']) const UNITLESS_STYLE_PROPERTIES = new Set([ 'animationIterationCount', 'aspectRatio', 'borderImageOutset', 'borderImageSlice', 'borderImageWidth', 'columnCount', 'columns', 'fillOpacity', 'flex', 'flexGrow', 'flexShrink', 'fontWeight', 'gridArea', 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart', 'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans', 'scale', 'stopOpacity', 'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit', 'strokeOpacity', 'strokeWidth', 'tabSize', 'widows', 'zIndex', 'zoom', ]) const ATTRIBUTE_EXCLUSIONS = new Set(['children', 'className', 'style']) const DIRECT_PROPERTY_KEYS = new Set(['tabIndex']) const FORWARDED_ATTRIBUTE_KEYS = new Set([ 'accessKey', 'autoCapitalize', 'contentEditable', 'contextMenu', 'dir', 'draggable', 'enterKeyHint', 'hidden', 'id', 'lang', 'nonce', 'role', 'slot', 'spellCheck', 'tabIndex', 'title', 'translate', ]) const SPECIAL_EVENT_NAMES: Record = { Blur: 'focusout', DoubleClick: 'dblclick', Focus: 'focusin', MouseEnter: 'mouseenter', MouseLeave: 'mouseleave', } function isEventProp(key: string, value: unknown): value is MenuEventListener { return /^on[A-Z]/.test(key) && typeof value === 'function' } function toAttributeName(key: string) { if (key.startsWith('aria-') || key.startsWith('data-')) { return key } return key } function isForwardedAttributeKey(key: string) { return key.startsWith('aria-') || key.startsWith('data-') || FORWARDED_ATTRIBUTE_KEYS.has(key) } function toStylePropertyName(key: string) { if (key.startsWith('--')) { return key } return key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`) } function toEventConfig(key: string) { const useCapture = key.endsWith('Capture') const baseKey = useCapture ? key.slice(0, -7) : key const reactEventName = baseKey.slice(2) const eventName = SPECIAL_EVENT_NAMES[reactEventName] ?? reactEventName.toLowerCase() return { eventName, options: useCapture ? { capture: true } : undefined, } } function createSyntheticEvent(element: HTMLDivElement, nativeEvent: Event): MenuSyntheticEvent { let defaultPrevented = nativeEvent.defaultPrevented let propagationStopped = false const syntheticEvent = Object.create(nativeEvent) Object.defineProperties(syntheticEvent, { nativeEvent: { value: nativeEvent }, currentTarget: { value: element }, target: { value: nativeEvent.target }, persist: { value: () => undefined }, isDefaultPrevented: { value: () => defaultPrevented }, isPropagationStopped: { value: () => propagationStopped }, preventDefault: { value: () => { defaultPrevented = true nativeEvent.preventDefault() }, }, stopPropagation: { value: () => { propagationStopped = true nativeEvent.stopPropagation() }, }, }) return syntheticEvent as MenuSyntheticEvent } function isDirectPropertyKey(key: string) { return DIRECT_PROPERTY_KEYS.has(key) } function setDirectProperty(element: HTMLDivElement, key: string, value: unknown) { if (key === 'tabIndex') { element.tabIndex = Number(value) return } ;(element as unknown as Record)[key] = value } function clearDirectProperty(element: HTMLDivElement, key: string) { if (key === 'tabIndex') { element.removeAttribute('tabindex') return } const propertyValue = (element as unknown as Record)[key] if (typeof propertyValue === 'boolean') { ;(element as unknown as Record)[key] = false return } if (typeof propertyValue === 'number') { ;(element as unknown as Record)[key] = 0 return } ;(element as unknown as Record)[key] = '' } function toStyleValue(styleName: string, value: string | number) { if ( typeof value !== 'number' || value === 0 || styleName.startsWith('--') || UNITLESS_STYLE_PROPERTIES.has(styleName) ) { return String(value) } return `${value}px` } function removeStyleProperty(element: HTMLDivElement, styleName: string) { if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) { return } element.style.removeProperty(toStylePropertyName(styleName)) } function applyStyleProperty(element: HTMLDivElement, styleName: string, value: string | number) { if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) { return } element.style.setProperty(toStylePropertyName(styleName), toStyleValue(styleName, value)) } function syncAttributes(element: HTMLDivElement, prevProps: MenuElementProps, nextProps: MenuElementProps) { const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(nextProps)]) allKeys.forEach(key => { if ( ATTRIBUTE_EXCLUSIONS.has(key) || !isForwardedAttributeKey(key) || isEventProp(key, prevProps[key as keyof MenuElementProps]) || isEventProp(key, nextProps[key as keyof MenuElementProps]) ) { return } const prevValue = prevProps[key as keyof MenuElementProps] const nextValue = nextProps[key as keyof MenuElementProps] if (prevValue === nextValue) { return } const attributeName = toAttributeName(key) if (nextValue == null || nextValue === false) { if (isDirectPropertyKey(key)) { clearDirectProperty(element, key) } element.removeAttribute(attributeName) return } if (nextValue === true) { if (isDirectPropertyKey(key)) { setDirectProperty(element, key, true) } element.setAttribute(attributeName, '') return } if (isDirectPropertyKey(key)) { setDirectProperty(element, key, nextValue) return } element.setAttribute(attributeName, String(nextValue)) }) } function syncClassName(element: HTMLDivElement, prevClassName?: string, nextClassName?: string) { if (prevClassName === nextClassName) { return } if (nextClassName) { element.className = nextClassName return } element.removeAttribute('class') } function syncStyles( element: HTMLDivElement, prevStyle: CSSProperties | undefined, nextStyle: CSSProperties | undefined, ) { const previousStyle = prevStyle ?? {} const currentStyle = nextStyle ?? {} const allStyleNames = new Set([...Object.keys(previousStyle), ...Object.keys(currentStyle)]) allStyleNames.forEach(styleName => { const prevValue = previousStyle[styleName as keyof CSSProperties] const nextValue = currentStyle[styleName as keyof CSSProperties] if (prevValue === nextValue) { return } if (nextValue == null) { removeStyleProperty(element, styleName) return } applyStyleProperty(element, styleName, nextValue as string | number) }) } function syncEventListeners(element: HTMLDivElement, prevListeners: EventListenerEntry[], nextProps: MenuElementProps) { prevListeners.forEach(({ eventName, listener, options }) => { element.removeEventListener(eventName, listener, options) }) const nextListeners: EventListenerEntry[] = [] Object.entries(nextProps).forEach(([key, value]) => { if (!isEventProp(key, value)) { return } const { eventName, options } = toEventConfig(key) const listener: MenuNativeListener = event => { value(createSyntheticEvent(element, event)) } element.addEventListener(eventName, listener, options) nextListeners.push({ eventName, listener, options }) }) return nextListeners } export function useMenuElementProps(element: HTMLDivElement, props: MenuElementProps) { const previousPropsRef = useRef({}) const listenersRef = useRef([]) useIsomorphicLayoutEffect(() => { const previousProps = previousPropsRef.current syncClassName(element, previousProps.className, props.className) syncStyles(element, previousProps.style, props.style) syncAttributes(element, previousProps, props) listenersRef.current = syncEventListeners(element, listenersRef.current, props) previousPropsRef.current = props return () => { listenersRef.current.forEach(({ eventName, listener, options }) => { element.removeEventListener(eventName, listener, options) }) listenersRef.current = [] } }, [element, props]) }