view.web.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { forwardRef, useCallback, useEffect, useRef } from 'react'
  2. import { cn } from 'app/lib/util'
  3. const assignRef = (ref, value) => {
  4. if (typeof ref === 'function') {
  5. ref(value)
  6. return
  7. }
  8. if (ref) ref.current = value
  9. }
  10. const createLayoutEvent = (node) => {
  11. const rect = node.getBoundingClientRect()
  12. return {
  13. nativeEvent: {
  14. layout: {
  15. x: rect.left,
  16. y: rect.top,
  17. width: rect.width,
  18. height: rect.height,
  19. },
  20. },
  21. }
  22. }
  23. const useWebLayout = (forwardedRef, onLayout) => {
  24. const nodeRef = useRef(null)
  25. const lastLayoutRef = useRef(null)
  26. const setRef = useCallback((node) => {
  27. nodeRef.current = node
  28. assignRef(forwardedRef, node)
  29. }, [forwardedRef])
  30. useEffect(() => {
  31. const node = nodeRef.current
  32. if (!node || !onLayout) return
  33. const emitLayout = () => {
  34. const event = createLayoutEvent(node)
  35. const nextLayout = event.nativeEvent.layout
  36. const prevLayout = lastLayoutRef.current
  37. if (
  38. prevLayout &&
  39. prevLayout.x === nextLayout.x &&
  40. prevLayout.y === nextLayout.y &&
  41. prevLayout.width === nextLayout.width &&
  42. prevLayout.height === nextLayout.height
  43. ) {
  44. return
  45. }
  46. lastLayoutRef.current = nextLayout
  47. onLayout(event)
  48. }
  49. emitLayout()
  50. const observer = typeof ResizeObserver !== 'undefined'
  51. ? new ResizeObserver(emitLayout)
  52. : null
  53. observer?.observe(node)
  54. window.addEventListener('resize', emitLayout)
  55. return () => {
  56. observer?.disconnect()
  57. window.removeEventListener('resize', emitLayout)
  58. }
  59. }, [onLayout])
  60. return setRef
  61. }
  62. const normalizeWebStyle = (style) => {
  63. if (!Array.isArray(style)) return style || undefined
  64. return Object.assign({}, ...style.filter(Boolean))
  65. }
  66. const isEditableKeyTarget = (target) => {
  67. if (!target || typeof target.closest !== 'function') return false
  68. return !!target.closest(
  69. 'input, textarea, select, [contenteditable="true"], [contenteditable=""]'
  70. )
  71. }
  72. const sanitizeWebProps = (props) => {
  73. const {
  74. accessibilityRole,
  75. accessibilityLabel,
  76. accessibilityHint,
  77. accessibilityElementsHidden,
  78. accessibilityViewIsModal,
  79. accessibilityLiveRegion,
  80. accessibilityState,
  81. accessibilityValue,
  82. accessibilityActions,
  83. accessibilityDisabled,
  84. importantForAccessibility,
  85. accessible,
  86. collapsable,
  87. focusable,
  88. hitSlop,
  89. horizontal,
  90. showsHorizontalScrollIndicator,
  91. showsVerticalScrollIndicator,
  92. scrollEventThrottle,
  93. contentContainerStyle,
  94. endFillColor,
  95. bounces,
  96. alwaysBounceHorizontal,
  97. alwaysBounceVertical,
  98. directionalLockEnabled,
  99. nestedScrollEnabled,
  100. overScrollMode,
  101. pagingEnabled,
  102. snapToAlignment,
  103. snapToEnd,
  104. snapToInterval,
  105. snapToOffsets,
  106. snapToStart,
  107. decelerationRate,
  108. keyboardDismissMode,
  109. keyboardShouldPersistTaps,
  110. scrollEnabled,
  111. extraData,
  112. onScrollBeginDrag,
  113. onScrollEndDrag,
  114. onMomentumScrollBegin,
  115. onMomentumScrollEnd,
  116. nativeID,
  117. onLayout,
  118. onPress,
  119. onKeyDown,
  120. onPressIn,
  121. onPressOut,
  122. onLongPress,
  123. onScroll,
  124. onAccessibilityAction,
  125. onStartShouldSetResponder,
  126. onStartShouldSetResponderCapture,
  127. onMoveShouldSetResponder,
  128. onMoveShouldSetResponderCapture,
  129. onResponderGrant,
  130. onResponderReject,
  131. onResponderMove,
  132. onResponderRelease,
  133. onResponderStart,
  134. onResponderEnd,
  135. onResponderTerminate,
  136. onResponderTerminationRequest,
  137. onShouldBlockNativeResponder,
  138. keyboardAwareBottomOffset,
  139. pointerEvents,
  140. style,
  141. disabled,
  142. ...domProps
  143. } = props || {}
  144. if (accessibilityRole && !domProps.role) domProps.role = accessibilityRole
  145. if (accessibilityLabel && !domProps['aria-label']) domProps['aria-label'] = accessibilityLabel
  146. if (onPress) {
  147. if (!domProps.onClick && !disabled) domProps.onClick = onPress
  148. if (!domProps.role) domProps.role = 'button'
  149. if (domProps.tabIndex == null) domProps.tabIndex = disabled ? -1 : 0
  150. if (disabled && domProps['aria-disabled'] == null) domProps['aria-disabled'] = true
  151. domProps.onKeyDown = (event) => {
  152. onKeyDown?.(event)
  153. if (disabled || event.defaultPrevented) return
  154. if (isEditableKeyTarget(event.target)) return
  155. if (event.key === 'Enter' || event.key === ' ') {
  156. event.preventDefault()
  157. onPress(event)
  158. }
  159. }
  160. } else if (onKeyDown) {
  161. domProps.onKeyDown = onKeyDown
  162. }
  163. if (nativeID && !domProps.id) domProps.id = nativeID
  164. const webStyle = normalizeWebStyle(style)
  165. if (pointerEvents || webStyle) {
  166. domProps.style = {
  167. ...webStyle,
  168. ...(pointerEvents ? { pointerEvents } : null),
  169. }
  170. }
  171. return domProps
  172. }
  173. /** Motion-style props consumed by the web CSS transition shim. */
  174. const MOTION_PROP_KEYS = new Set([
  175. 'animate',
  176. 'animateProps',
  177. 'initial',
  178. 'initialProps',
  179. 'exit',
  180. 'transition',
  181. 'transformOrigin',
  182. 'whileTap',
  183. 'whileHover',
  184. 'onAnimationComplete',
  185. ])
  186. const splitMotionProps = (props) => {
  187. const motion = {}
  188. const rest = {}
  189. for (const key of Object.keys(props)) {
  190. if (MOTION_PROP_KEYS.has(key)) motion[key] = props[key]
  191. else rest[key] = props[key]
  192. }
  193. return [motion, rest]
  194. }
  195. const toCssDuration = (transition) => {
  196. const duration = transition?.duration ?? transition?.default?.duration
  197. if (typeof duration !== 'number') return '120ms'
  198. return `${duration > 10 ? duration : duration * 1000}ms`
  199. }
  200. const getAnimatedWebStyle = (motionProps) => {
  201. const animate = motionProps?.animate || {}
  202. const transform = []
  203. const style = {
  204. transitionProperty: 'transform, opacity',
  205. transitionDuration: toCssDuration(motionProps?.transition),
  206. transitionTimingFunction: 'cubic-bezier(0.2, 0, 0, 1)',
  207. }
  208. if (animate.opacity !== undefined) {
  209. style.opacity = animate.opacity
  210. }
  211. if (animate.scale !== undefined) {
  212. transform.push(`scale(${animate.scale})`)
  213. }
  214. if (animate.x !== undefined) {
  215. transform.push(`translateX(${typeof animate.x === 'number' ? `${animate.x}px` : animate.x})`)
  216. }
  217. if (animate.y !== undefined) {
  218. transform.push(`translateY(${typeof animate.y === 'number' ? `${animate.y}px` : animate.y})`)
  219. }
  220. if (animate.rotate !== undefined) {
  221. transform.push(`rotate(${typeof animate.rotate === 'number' ? `${animate.rotate}deg` : animate.rotate})`)
  222. }
  223. if (transform.length) {
  224. style.transform = transform.join(' ')
  225. }
  226. return style
  227. }
  228. export const interopComponent = (Component, displayName, baseClassName = 'neo-v') => {
  229. /** @type {any} */
  230. const Base = forwardRef(({ className, onLayout, ...props }, ref) => {
  231. const layoutRef = useWebLayout(ref, onLayout)
  232. return (
  233. <Component ref={layoutRef} className={cn(baseClassName, className)} {...sanitizeWebProps(props)} />
  234. )
  235. })
  236. Base.displayName = displayName
  237. return Base
  238. }
  239. export const interopRender = (displayName, render) => {
  240. /** @type {any} */
  241. const Base = forwardRef(render)
  242. Base.displayName = displayName
  243. return Base
  244. }
  245. export const View = interopComponent('div', 'View')
  246. /** Plain left-click only — let cmd/ctrl/shift/middle clicks reach the browser. */
  247. const isPlainLeftClick = (event) =>
  248. event.button === 0 &&
  249. !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
  250. export const Pressable = interopRender(
  251. 'Pressable',
  252. ({ className, onLayout, onPress, href, target, rel, ...props }, ref) => {
  253. const layoutRef = useWebLayout(ref, onLayout)
  254. const hasCursorClass =
  255. typeof className === 'string' && /(?:^|\s)(?:web:)?cursor-/.test(className)
  256. // With href, render a real anchor: status-bar URL preview, open-in-new-tab
  257. // and SEO semantics. onPress still handles plain left-clicks (SPA nav).
  258. if (href) {
  259. const domProps = sanitizeWebProps(props)
  260. const handleClick = (event) => {
  261. if (!onPress || event.defaultPrevented || !isPlainLeftClick(event)) return
  262. event.preventDefault()
  263. onPress(event)
  264. }
  265. return (
  266. <a
  267. ref={layoutRef}
  268. href={href}
  269. target={target}
  270. rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
  271. className={cn('neo-p', className)}
  272. {...domProps}
  273. onClick={handleClick}
  274. />
  275. )
  276. }
  277. return (
  278. <div
  279. ref={layoutRef}
  280. className={cn('neo-p', onPress && !hasCursorClass && 'web:cursor-pointer', className)}
  281. {...sanitizeWebProps({ ...props, onPress })}
  282. />
  283. )
  284. }
  285. )
  286. export const ScrollView = interopRender(
  287. 'ScrollView',
  288. (
  289. {
  290. children,
  291. className,
  292. onLayout,
  293. horizontal,
  294. contentContainerStyle,
  295. contentContainerClassName,
  296. scrollEnabled = true,
  297. onScroll,
  298. ...rest
  299. },
  300. ref
  301. ) => {
  302. const layoutRef = useWebLayout(ref, onLayout)
  303. const domProps = sanitizeWebProps(rest)
  304. const innerStyle = normalizeWebStyle(contentContainerStyle)
  305. const outerClass = cn(
  306. horizontal ? 'neo-sh' : 'neo-sv',
  307. scrollEnabled === false && 'overflow-hidden',
  308. className
  309. )
  310. return (
  311. <div
  312. ref={layoutRef}
  313. className={outerClass}
  314. onScroll={onScroll}
  315. {...domProps}
  316. >
  317. <div
  318. className={cn(horizontal ? 'neo-sh-c' : 'neo-sv-c', contentContainerClassName)}
  319. style={innerStyle}
  320. >
  321. {children}
  322. </div>
  323. </div>
  324. )
  325. }
  326. )
  327. export const MotionView = interopRender(
  328. 'MotionView',
  329. ({ className, onLayout, style, children, ...props }, ref) => {
  330. const layoutRef = useWebLayout(ref, onLayout)
  331. const [motionProps, restProps] = splitMotionProps(props)
  332. const domProps = sanitizeWebProps(restProps)
  333. const motionStyle = normalizeWebStyle(style)
  334. const animatedStyle = getAnimatedWebStyle(motionProps)
  335. return (
  336. <div
  337. ref={layoutRef}
  338. className={cn('neo-v', className)}
  339. {...domProps}
  340. style={{
  341. ...domProps.style,
  342. ...motionStyle,
  343. ...animatedStyle,
  344. }}
  345. >
  346. {children}
  347. </div>
  348. )
  349. }
  350. )
  351. export const Row = interopRender(
  352. 'Row',
  353. ({ children, className, onLayout, ...props }, ref) => {
  354. const layoutRef = useWebLayout(ref, onLayout)
  355. return (
  356. <div
  357. ref={layoutRef}
  358. className={cn('neo-v flex-row', className)}
  359. {...sanitizeWebProps(props)}
  360. >
  361. {children}
  362. </div>
  363. )
  364. }
  365. )