toc.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. 'use client'
  2. import { View, Pressable } from 'app/design/view'
  3. import { Text } from 'app/design/typography'
  4. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  5. import Svg, { Path } from 'react-native-svg'
  6. import { useCSSVariable } from 'uniwind'
  7. import DropdownPopup from 'app/ui/atoms/dropdown-popup'
  8. import { useScrollValue } from 'app/context/jotai/layout'
  9. import { useWindowHeight } from 'app/context/measure'
  10. import { isWeb } from './helpers'
  11. // The web spy uses its own window scroll listener. Never subscribe the wiki
  12. // layout to the scroll atom on web — it updates every frame during scroll and
  13. // would re-render the entire page tree. Platform is constant per bundle.
  14. const useNativeScrollValue = isWeb ? () => 0 : useScrollValue
  15. const useNativeWindowHeight = isWeb ? () => 0 : useWindowHeight
  16. // Match Fumadocs TOC offsets (packages/base-ui/.../toc/default.tsx).
  17. const LINE_BASE = 8
  18. /** Horizontal offset of the tracking line for h2 / h3. */
  19. function getLineOffset(level) {
  20. if (level <= 2) return LINE_BASE
  21. return LINE_BASE + 8
  22. }
  23. function getItemPaddingStart(level) {
  24. if (level <= 2) return LINE_BASE + 12
  25. return LINE_BASE + 24
  26. }
  27. function arraysEqual(a, b) {
  28. if (a === b) return true
  29. if (!a || !b || a.length !== b.length) return false
  30. for (let i = 0; i < a.length; i++) {
  31. if (a[i] !== b[i]) return false
  32. }
  33. return true
  34. }
  35. function isFiniteNumber(value) {
  36. return typeof value === 'number' && Number.isFinite(value)
  37. }
  38. function findHeadingElement(id, contentRoot) {
  39. if (!id || typeof document === 'undefined') return null
  40. // Prefer the wiki article root so stale nodes from a previous page aren't used.
  41. if (contentRoot?.querySelector) {
  42. try {
  43. const scoped = contentRoot.querySelector(`#${CSS.escape(id)}`)
  44. if (scoped) return scoped
  45. } catch {
  46. // CSS.escape may throw on odd ids — fall through.
  47. }
  48. }
  49. return document.getElementById(id)
  50. }
  51. /**
  52. * Compute which TOC headings' sections intersect the reading viewport.
  53. * A heading's "section" runs from its top to the next heading's top (last
  54. * section extends to infinity). When `includeLast` is set — typically near the
  55. * document bottom — always keep the final heading active so short trailing
  56. * sections still highlight (Fumadocs-style).
  57. */
  58. function activeIdsFromPositions(positions, viewTop, viewBottom, { includeLast = false } = {}) {
  59. if (!positions.length) return []
  60. const active = []
  61. for (let i = 0; i < positions.length; i++) {
  62. const start = positions[i].top
  63. const end = i + 1 < positions.length
  64. ? positions[i + 1].top
  65. : Number.POSITIVE_INFINITY
  66. if (end > viewTop && start < viewBottom) {
  67. active.push(positions[i].id)
  68. }
  69. }
  70. if (includeLast) {
  71. const lastId = positions[positions.length - 1]?.id
  72. if (lastId && !active.includes(lastId)) {
  73. active.push(lastId)
  74. }
  75. }
  76. if (active.length) return active
  77. // Fallback: heading closest to the top reading line.
  78. let bestId = positions[0].id
  79. let bestDist = Number.POSITIVE_INFINITY
  80. for (const pos of positions) {
  81. const dist = Math.abs(pos.top - viewTop)
  82. if (dist < bestDist) {
  83. bestDist = dist
  84. bestId = pos.id
  85. }
  86. }
  87. return bestId ? [bestId] : []
  88. }
  89. /** True when the viewport is within `threshold` px of the document end. */
  90. function isNearDocumentBottom(threshold = 32) {
  91. if (typeof window === 'undefined' || typeof document === 'undefined') return false
  92. const doc = document.documentElement
  93. return window.scrollY + window.innerHeight >= doc.scrollHeight - threshold
  94. }
  95. /**
  96. * Scroll-spy for wiki TOC — multi-active range of headings whose sections
  97. * intersect the viewport (Fumadocs-style). Uses scroll geometry rather than
  98. * IntersectionObserver so in-layout wiki navigations don't leave a stale IO
  99. * map that collapses to a single fallback highlight.
  100. * @see https://github.com/fuma-nama/fumadocs (packages/core/src/toc.tsx)
  101. */
  102. export function useWikiTocSpy(tocItems, {
  103. headerOffset = 0,
  104. pageKey,
  105. contentRootRef,
  106. headingRefs,
  107. scrollRef,
  108. } = {}) {
  109. const [activeIds, setActiveIds] = useState([])
  110. const scrollY = useNativeScrollValue()
  111. const windowHeight = useNativeWindowHeight()
  112. const idsKey = useMemo(
  113. () => (tocItems || []).map((item) => item.id).join('|'),
  114. [tocItems],
  115. )
  116. const setActiveSafe = useCallback((next) => {
  117. setActiveIds((prev) => (arraysEqual(prev, next) ? prev : next))
  118. }, [])
  119. // Web: measure heading boxes on scroll/resize (stable across client navigations).
  120. useEffect(() => {
  121. if (!isWeb) return
  122. if (!tocItems?.length) {
  123. setActiveSafe([])
  124. return
  125. }
  126. const ids = tocItems.map((item) => item.id)
  127. let raf = 0
  128. let mutateTimer = 0
  129. const publish = () => {
  130. const root = contentRootRef?.current || null
  131. const viewTop = Math.max(0, headerOffset)
  132. const nearBottom = isNearDocumentBottom()
  133. // Upper ~65% reading band (Fumadocs-like). Near the document end, expand
  134. // to the full viewport so short trailing headings below that band still
  135. // count — and always keep the last TOC item active as a backstop.
  136. const viewBottom = nearBottom
  137. ? window.innerHeight
  138. : window.innerHeight * 0.65
  139. const positions = []
  140. for (const id of ids) {
  141. const el = findHeadingElement(id, root)
  142. if (!el) continue
  143. const rect = el.getBoundingClientRect()
  144. positions.push({ id, top: rect.top, height: rect.height })
  145. }
  146. setActiveSafe(activeIdsFromPositions(positions, viewTop, viewBottom, {
  147. includeLast: nearBottom,
  148. }))
  149. }
  150. const schedule = () => {
  151. cancelAnimationFrame(raf)
  152. raf = requestAnimationFrame(publish)
  153. }
  154. const scheduleFromMutation = () => {
  155. // Debounce: markdown/WASM swaps fire many mutations; don't thrash.
  156. clearTimeout(mutateTimer)
  157. mutateTimer = setTimeout(schedule, 64)
  158. }
  159. window.addEventListener('scroll', schedule, { passive: true })
  160. window.addEventListener('resize', schedule)
  161. const root = contentRootRef?.current
  162. const mo = root
  163. ? new MutationObserver(scheduleFromMutation)
  164. : null
  165. mo?.observe(root, { childList: true, subtree: true })
  166. schedule()
  167. return () => {
  168. cancelAnimationFrame(raf)
  169. clearTimeout(mutateTimer)
  170. window.removeEventListener('scroll', schedule)
  171. window.removeEventListener('resize', schedule)
  172. mo?.disconnect()
  173. }
  174. }, [contentRootRef, headerOffset, idsKey, pageKey, setActiveSafe, tocItems])
  175. // Native: derive visible range from scroll offset + measured heading Y.
  176. useEffect(() => {
  177. if (isWeb || !tocItems?.length) return
  178. let cancelled = false
  179. const scrollView = scrollRef?.current
  180. const refs = headingRefs?.current
  181. if (!scrollView || !refs) return
  182. const measureOne = (id) => new Promise((resolve) => {
  183. const node = refs.get(id)
  184. if (!node?.measureLayout) {
  185. resolve(null)
  186. return
  187. }
  188. node.measureLayout(
  189. scrollView,
  190. (_x, y, _w, h) => resolve({ id, top: y, height: h || 0 }),
  191. () => resolve(null),
  192. )
  193. })
  194. Promise.all(tocItems.map((item) => measureOne(item.id))).then((rows) => {
  195. if (cancelled) return
  196. const positions = rows.filter(Boolean)
  197. if (!positions.length) return
  198. const vh = Number(windowHeight) || 600
  199. const viewTop = Number(scrollY) + Math.max(0, headerOffset)
  200. const last = positions[positions.length - 1]
  201. // Native has no document scrollHeight here; treat as near-end when the
  202. // viewport extends past the last heading (short trailing sections).
  203. const includeLast = Boolean(
  204. last && Number(scrollY) + vh >= last.top + Math.max(last.height, 1) + 100
  205. )
  206. const viewBottom = Number(scrollY) + (includeLast ? vh : vh * 0.65)
  207. setActiveSafe(activeIdsFromPositions(positions, viewTop, viewBottom, { includeLast }))
  208. })
  209. return () => {
  210. cancelled = true
  211. }
  212. }, [headerOffset, headingRefs, idsKey, pageKey, scrollRef, scrollY, setActiveSafe, tocItems, windowHeight])
  213. return activeIds
  214. }
  215. /**
  216. * Build the indent-following path. Connections use cubic Béziers like Fumadocs:
  217. * `C upperX (top-4) x (upperBottom+4) x top`
  218. */
  219. function buildTrackGeometry(items, layouts) {
  220. let width = LINE_BASE + 8
  221. let height = 0
  222. let d = ''
  223. const positions = []
  224. const positionedItems = []
  225. for (let i = 0; i < items.length; i++) {
  226. const item = items[i]
  227. const layout = layouts[item.id]
  228. if (!layout) continue
  229. const x = getLineOffset(item.level) + 0.5
  230. const top = layout.top
  231. const bottom = layout.bottom
  232. if (!isFiniteNumber(top) || !isFiniteNumber(bottom) || bottom <= top) continue
  233. width = Math.max(width, x + 8)
  234. height = Math.max(height, bottom)
  235. if (!positions.length) {
  236. d += `M${x} ${top} L${x} ${bottom}`
  237. } else {
  238. const [, upperBottom, upperX] = positions[positions.length - 1]
  239. if (upperX === x) {
  240. // Same indent — continue the vertical run.
  241. d += ` L${x} ${bottom}`
  242. } else {
  243. // Smooth stair between indent levels (Fumadocs default TOC).
  244. d += ` C${upperX} ${top - 4} ${x} ${upperBottom + 4} ${x} ${top} L${x} ${bottom}`
  245. }
  246. }
  247. positions.push([top, bottom, x])
  248. positionedItems.push(item)
  249. }
  250. if (!positions.length) return null
  251. return { width, height: Math.ceil(height), d, positions, positionedItems }
  252. }
  253. function WikiTocTrack({ geometry, activeIds }) {
  254. const [primaryToken, borderToken] = useCSSVariable(['--color-primary', '--color-border'])
  255. const primary = isWeb ? 'var(--color-primary)' : (primaryToken || '#f59e0b')
  256. const border = isWeb ? 'var(--color-border)' : (borderToken || 'rgba(128,128,128,0.35)')
  257. const startIdx = geometry
  258. ? geometry.positionedItems.findIndex((item) => activeIds.includes(item.id))
  259. : -1
  260. const endIdx = (() => {
  261. if (!geometry) return -1
  262. for (let i = geometry.positionedItems.length - 1; i >= 0; i--) {
  263. if (activeIds.includes(geometry.positionedItems[i].id)) return i
  264. }
  265. return -1
  266. })()
  267. if (!geometry) return null
  268. if (!isFiniteNumber(geometry.width) || !isFiniteNumber(geometry.height) || geometry.width <= 0 || geometry.height <= 0) {
  269. return null
  270. }
  271. if (!geometry.d || /nan/i.test(geometry.d)) return null
  272. const hasActiveRange = startIdx >= 0 && endIdx >= 0
  273. const trackTop = hasActiveRange ? geometry.positions[startIdx][0] : 0
  274. const trackBottom = hasActiveRange ? geometry.positions[endIdx][1] : 0
  275. const clipHeight = Math.max(0, trackBottom - trackTop)
  276. const canClipActive = hasActiveRange && isFiniteNumber(trackTop) && isFiniteNumber(clipHeight) && clipHeight > 0
  277. return (
  278. <View
  279. pointerEvents="none"
  280. className="absolute top-0 left-0"
  281. style={{ width: geometry.width, height: geometry.height }}
  282. >
  283. <Svg
  284. width={geometry.width}
  285. height={geometry.height}
  286. viewBox={`0 0 ${geometry.width} ${geometry.height}`}
  287. >
  288. <Path
  289. d={geometry.d}
  290. stroke={border}
  291. strokeWidth={1}
  292. fill="none"
  293. strokeLinecap="round"
  294. strokeLinejoin="round"
  295. />
  296. </Svg>
  297. {canClipActive ? (
  298. <View
  299. className="absolute left-0 overflow-hidden"
  300. style={{ top: trackTop, width: geometry.width, height: clipHeight, overflow: 'hidden' }}
  301. >
  302. <Svg
  303. width={geometry.width}
  304. height={geometry.height}
  305. viewBox={`0 0 ${geometry.width} ${geometry.height}`}
  306. // The clip box is a flex column: without flexShrink 0 the SVG
  307. // shrinks to the clip height and the rail draws scaled down.
  308. style={{ marginTop: -trackTop, flexShrink: 0 }}
  309. >
  310. <Path
  311. d={geometry.d}
  312. stroke={primary}
  313. strokeWidth={1}
  314. fill="none"
  315. strokeLinecap="round"
  316. strokeLinejoin="round"
  317. />
  318. </Svg>
  319. </View>
  320. ) : null}
  321. </View>
  322. )
  323. }
  324. function resolveDomNode(ref) {
  325. if (!ref) return null
  326. if (typeof ref.getBoundingClientRect === 'function') return ref
  327. // Some RN-web hosts keep the DOM node on _node / getNode().
  328. if (typeof ref.getNode === 'function') {
  329. const node = ref.getNode()
  330. if (node && typeof node.getBoundingClientRect === 'function') return node
  331. }
  332. if (ref._node && typeof ref._node.getBoundingClientRect === 'function') return ref._node
  333. return null
  334. }
  335. /**
  336. * Measure link content boxes relative to the TOC container (Fumadocs-style:
  337. * top = rowTop + paddingTop, bottom = rowTop + height - paddingBottom).
  338. */
  339. function measureTocLayoutsWeb(containerRef, itemIds, itemNodeRefs) {
  340. const layouts = {}
  341. const container = resolveDomNode(containerRef)
  342. if (!container || !itemIds?.length) return layouts
  343. const containerRect = container.getBoundingClientRect()
  344. const scrollTop = container.scrollTop || 0
  345. for (const id of itemIds) {
  346. const el = resolveDomNode(itemNodeRefs.current.get(id))
  347. if (!el) continue
  348. const rect = el.getBoundingClientRect()
  349. const styles = window.getComputedStyle(el)
  350. const paddingTop = parseFloat(styles.paddingTop) || 0
  351. const paddingBottom = parseFloat(styles.paddingBottom) || 0
  352. const y = rect.top - containerRect.top + scrollTop
  353. layouts[id] = {
  354. top: y + paddingTop,
  355. bottom: y + rect.height - paddingBottom,
  356. }
  357. }
  358. return layouts
  359. }
  360. /**
  361. * @param {object} props
  362. * @param {Array} props.items
  363. * @param {string[]} [props.activeIds] — headings in the viewport reading band
  364. * @param {string|null} [props.focusedId] — last clicked TOC item (bg while in view)
  365. * @param {(id: string) => void} props.onPress
  366. * @param {boolean} [props.showTrack] — Fumadocs-style indent-following rail (sidebar)
  367. */
  368. export function WikiTocList({ items, activeIds = [], focusedId = null, onPress, showTrack = false }) {
  369. const containerRef = useRef(null)
  370. const itemNodeRefs = useRef(new Map())
  371. const [layouts, setLayouts] = useState({})
  372. const activeSet = useMemo(() => new Set(activeIds), [activeIds])
  373. const itemsKey = useMemo(() => (items || []).map((item) => item.id).join('|'), [items])
  374. const itemIds = useMemo(() => (items || []).map((item) => item.id), [items])
  375. const remeasure = useCallback(() => {
  376. if (!showTrack) return
  377. if (isWeb) {
  378. const next = measureTocLayoutsWeb(containerRef.current, itemIds, itemNodeRefs)
  379. setLayouts((prev) => {
  380. const prevKey = JSON.stringify(prev)
  381. const nextKey = JSON.stringify(next)
  382. return prevKey === nextKey ? prev : next
  383. })
  384. return
  385. }
  386. const container = containerRef.current
  387. if (!container) return
  388. const measureItems = () => {
  389. const pending = itemIds.map((id) => new Promise((resolve) => {
  390. const node = itemNodeRefs.current.get(id)
  391. if (!node?.measureLayout) {
  392. resolve(null)
  393. return
  394. }
  395. node.measureLayout(
  396. container,
  397. (_x, y, _w, h) => {
  398. const pad = 6
  399. resolve({ id, top: y + pad, bottom: y + h - pad })
  400. },
  401. () => resolve(null),
  402. )
  403. }))
  404. Promise.all(pending).then((rows) => {
  405. const next = {}
  406. for (const row of rows) {
  407. if (row) next[row.id] = { top: row.top, bottom: row.bottom }
  408. }
  409. setLayouts(next)
  410. })
  411. }
  412. // Display-none Uniwind `hidden` views still exist in the tree. Measuring
  413. // them on Android can abort the process with no JS exception.
  414. if (typeof container.measure === 'function') {
  415. container.measure((_x, _y, width, height) => {
  416. if (!width || !height) return
  417. measureItems()
  418. })
  419. return
  420. }
  421. measureItems()
  422. }, [itemIds, showTrack])
  423. useEffect(() => {
  424. if (!showTrack) return
  425. setLayouts({})
  426. const id = requestAnimationFrame(() => {
  427. requestAnimationFrame(remeasure)
  428. })
  429. return () => cancelAnimationFrame(id)
  430. }, [itemsKey, remeasure, showTrack])
  431. useEffect(() => {
  432. if (!showTrack || !isWeb) return
  433. const container = resolveDomNode(containerRef.current)
  434. if (!container || typeof ResizeObserver === 'undefined') return
  435. const observer = new ResizeObserver(() => remeasure())
  436. observer.observe(container)
  437. remeasure()
  438. return () => observer.disconnect()
  439. }, [remeasure, showTrack, itemsKey])
  440. const geometry = useMemo(
  441. () => (showTrack ? buildTrackGeometry(items, layouts) : null),
  442. [items, layouts, showTrack],
  443. )
  444. if (!items?.length) return null
  445. return (
  446. <View ref={containerRef} className="relative">
  447. {showTrack ? (
  448. <WikiTocTrack
  449. geometry={geometry}
  450. activeIds={activeIds}
  451. />
  452. ) : null}
  453. {items.map((item, index) => {
  454. const isInView = activeSet.has(item.id)
  455. const isFocused = Boolean(focusedId && focusedId === item.id && isInView)
  456. const padStart = showTrack
  457. ? getItemPaddingStart(item.level)
  458. : (item.level === 3 ? 12 : 0)
  459. const isFirst = index === 0
  460. const isLast = index === items.length - 1
  461. return (
  462. <Pressable
  463. key={item.key}
  464. ref={(node) => {
  465. if (node) itemNodeRefs.current.set(item.id, node)
  466. else itemNodeRefs.current.delete(item.id)
  467. }}
  468. href={`#${item.id}`}
  469. onPress={() => onPress(item.id)}
  470. className={`justify-center pe-2 rounded-md ${
  471. showTrack
  472. ? `${isFirst ? 'pt-1.5' : 'pt-1.5'} ${isLast ? 'pb-1.5' : 'pb-1.5'}`
  473. : 'min-h-8 py-1.5'
  474. } ${
  475. isFocused
  476. ? 'bg-accent/60'
  477. : 'web:hover:bg-muted/50'
  478. }`}
  479. style={isWeb ? { paddingInlineStart: padStart } : { paddingStart: padStart }}
  480. accessibilityRole="link"
  481. accessibilityState={{ selected: isFocused || isInView }}
  482. >
  483. <Text
  484. className={`text-sm leading-snug ${
  485. isInView
  486. ? 'text-primary font-medium'
  487. : 'text-muted-foreground web:hover:text-foreground'
  488. }`}
  489. >
  490. {item.text}
  491. </Text>
  492. </Pressable>
  493. )
  494. })}
  495. </View>
  496. )
  497. }
  498. /** Compact TOC for transient dropdowns — no scroll-spy rail (closes on navigate). */
  499. function WikiTocMenuList({ items, activeIds = [], focusedId = null, onPress }) {
  500. const activeSet = useMemo(() => new Set(activeIds), [activeIds])
  501. if (!items?.length) return null
  502. return (
  503. <View className="gap-0.5">
  504. {items.map((item) => {
  505. const isInView = activeSet.has(item.id)
  506. const isFocused = Boolean(focusedId && focusedId === item.id && isInView)
  507. return (
  508. <Pressable
  509. key={item.key}
  510. href={`#${item.id}`}
  511. onPress={() => onPress(item.id)}
  512. className={`min-h-9 justify-center px-3 py-1.5 rounded-lg ${
  513. item.level === 3 ? 'ms-3' : ''
  514. } ${isFocused ? 'bg-accent/60' : 'web:hover:bg-muted/50'}`}
  515. accessibilityRole="link"
  516. accessibilityState={{ selected: isFocused || isInView }}
  517. >
  518. <Text
  519. className={`text-sm leading-snug ${
  520. isInView
  521. ? 'text-primary font-medium'
  522. : 'text-muted-foreground web:hover:text-foreground'
  523. }`}
  524. >
  525. {item.text}
  526. </Text>
  527. </Pressable>
  528. )
  529. })}
  530. </View>
  531. )
  532. }
  533. /**
  534. * @param {object} props
  535. * @param {boolean} [props.compact] — icon-only circle on mobile, label from `sm` up
  536. */
  537. export function WikiTocDropdown({ items, activeIds, focusedId = null, onPress, label, compact = false }) {
  538. if (!items?.length || items.length < 2) return null
  539. const renderDropdown = (iconOnly) => (
  540. <DropdownPopup
  541. minPopupWidth={256}
  542. // Long headings wrap instead of stretching the menu across the page.
  543. maxPopupWidth={320}
  544. contentClasses={isWeb ? 'rounded-xl border border-popover mt-2 bg-popover/60 backdrop-blur shadow-md' : undefined}
  545. buttonProps={{
  546. label: iconOnly ? undefined : label,
  547. accessibilityLabel: label,
  548. style: 'borderless',
  549. controlSize: 'regular',
  550. image: 'TextAlignStart',
  551. borderShape: iconOnly ? 'circle' : 'capsule',
  552. }}
  553. >
  554. <View className="p-1">
  555. <WikiTocMenuList
  556. items={items}
  557. activeIds={activeIds}
  558. focusedId={focusedId}
  559. onPress={onPress}
  560. />
  561. </View>
  562. </DropdownPopup>
  563. )
  564. if (!compact) return renderDropdown(false)
  565. // NeoButton `showTitleFromSize` keeps the capsule padding when the label is
  566. // hidden, so swap two triggers by CSS instead (SSR-safe, no measuring).
  567. return (
  568. <>
  569. <View className="sm:hidden">{renderDropdown(true)}</View>
  570. <View className="hidden sm:flex">{renderDropdown(false)}</View>
  571. </>
  572. )
  573. }