@lexibase/rdx-ui 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,15 @@
2
2
 
3
3
  ## shadcn/ui
4
4
 
5
- Table and Kbd use the official base-nova registry components. Dialog, Code Block,
5
+ Table, Kbd and Sheet use the official base-nova registry components. Dialog, Code Block,
6
6
  useHighlightedCode, and highlighter are installed in full from that registry,
7
7
  with project imports, icons, localization and clipboard failure feedback wired
8
8
  to RDX. The official shadcn 4.22.1 tailwind.css is included as styles/shadcn.css.
9
9
  https://ui.shadcn.com/docs/components/base/table and
10
10
  https://ui.shadcn.com/docs/components/base/kbd and
11
11
  https://ui.shadcn.com/docs/components/base/code-block and
12
- https://ui.shadcn.com/docs/components/base/dialog.
12
+ https://ui.shadcn.com/docs/components/base/dialog and
13
+ https://ui.shadcn.com/docs/components/base/sheet.
13
14
 
14
15
  MIT License
15
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexibase/rdx-ui",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^22.18.0 || ^24.11.0"
@@ -29,13 +29,15 @@
29
29
  "clsx": "^2.1.1",
30
30
  "cmdk": "^1.1.1",
31
31
  "lucide-react": "^0.577.0",
32
+ "mermaid": "^12.2.0",
33
+ "motion": "^12.43.0",
32
34
  "next-themes": "^0.4.6",
33
35
  "postcss": "^8.5.31",
34
36
  "shiki": "^4.5.0",
35
37
  "tailwind-merge": "^3.7.0",
36
38
  "tailwindcss": "^4.3.3",
37
39
  "tw-animate-css": "^1.4.0",
38
- "@lexibase/rdx-types": "1.1.1"
40
+ "@lexibase/rdx-types": "1.2.0"
39
41
  },
40
42
  "peerDependencies": {
41
43
  "next": "^16.4.2",
@@ -0,0 +1,178 @@
1
+ 'use client'
2
+
3
+ import { useEffect, ViewTransition, type ReactNode } from 'react'
4
+ import { usePathname, useRouter } from 'next/navigation'
5
+
6
+ const directions = {
7
+ 'rdx-doc-forward': 'rdx-doc-forward',
8
+ 'rdx-doc-back': 'rdx-doc-back',
9
+ default: 'none',
10
+ }
11
+
12
+ const interactive = [
13
+ 'a',
14
+ 'button',
15
+ 'input',
16
+ 'textarea',
17
+ 'select',
18
+ 'label',
19
+ 'summary',
20
+ 'dialog',
21
+ '[contenteditable]:not([contenteditable="false"])',
22
+ '[role="button"]',
23
+ '[role="slider"]',
24
+ '[role="textbox"]',
25
+ '[role="combobox"]',
26
+ '[draggable="true"]',
27
+ 'pre',
28
+ 'table',
29
+ 'img',
30
+ 'video',
31
+ 'audio',
32
+ 'canvas',
33
+ 'iframe',
34
+ '[data-rdx-code-block]',
35
+ '[data-slot="table-container"]',
36
+ '[data-slot="tabs"]',
37
+ '[data-rdx-mermaid-preview]',
38
+ ].join(',')
39
+
40
+ /** Let the router retain ownership of navigation, focus and history. */
41
+ export function DocsPageTransition({
42
+ children,
43
+ previousHref,
44
+ nextHref,
45
+ }: {
46
+ children: ReactNode
47
+ previousHref?: string
48
+ nextHref?: string
49
+ }) {
50
+ const router = useRouter()
51
+ const pathname = usePathname()
52
+
53
+ useEffect(() => {
54
+ const scroller = document.querySelector<HTMLElement>(
55
+ '[data-rdx-docs-scroll]'
56
+ )
57
+ if (!scroller) return
58
+ let gesture: {
59
+ id: number
60
+ x: number
61
+ y: number
62
+ top: number
63
+ time: number
64
+ } | null = null
65
+ let navigationAt = -Infinity
66
+ const selected = () => window.getSelection()?.isCollapsed === false
67
+ const cancel = () => {
68
+ gesture = null
69
+ }
70
+ const start = (event: TouchEvent) => {
71
+ cancel()
72
+ if (
73
+ event.defaultPrevented ||
74
+ window.innerWidth >= 768 ||
75
+ event.touches.length !== 1 ||
76
+ selected()
77
+ )
78
+ return
79
+ const target = event.target
80
+ if (!(target instanceof Element) || target.closest(interactive)) return
81
+ // Leave nested horizontal scrollers to their own gestures, including custom MDX.
82
+ for (
83
+ let element: Element | null = target;
84
+ element && element !== scroller;
85
+ element = element.parentElement
86
+ ) {
87
+ if (
88
+ element.scrollWidth > element.clientWidth + 1 &&
89
+ /auto|scroll/.test(getComputedStyle(element).overflowX)
90
+ )
91
+ return
92
+ }
93
+ const touch = event.touches[0]!
94
+ // Keep browser/system edge gestures available.
95
+ if (touch.clientX < 32 || touch.clientX > window.innerWidth - 32) return
96
+ gesture = {
97
+ id: touch.identifier,
98
+ x: touch.clientX,
99
+ y: touch.clientY,
100
+ top: scroller.scrollTop,
101
+ time: performance.now(),
102
+ }
103
+ }
104
+ const move = (event: TouchEvent) => {
105
+ if (event.defaultPrevented) return cancel()
106
+ if (!gesture) return
107
+ const touch = event.touches[0]
108
+ if (
109
+ event.touches.length !== 1 ||
110
+ !touch ||
111
+ touch.identifier !== gesture.id
112
+ )
113
+ return cancel()
114
+ const dx = Math.abs(touch.clientX - gesture.x)
115
+ const dy = Math.abs(touch.clientY - gesture.y)
116
+ if (dy > 12 && dy >= dx) cancel()
117
+ }
118
+ const end = (event: TouchEvent) => {
119
+ const current = gesture
120
+ cancel()
121
+ if (
122
+ !current ||
123
+ event.touches.length ||
124
+ selected() ||
125
+ window.innerWidth >= 768
126
+ )
127
+ return
128
+ const touch = Array.from(event.changedTouches).find(
129
+ (entry) => entry.identifier === current.id
130
+ )
131
+ if (!touch) return
132
+ const dx = touch.clientX - current.x
133
+ const dy = touch.clientY - current.y
134
+ const now = performance.now()
135
+ if (
136
+ now - current.time > 1000 ||
137
+ now - navigationAt < 450 ||
138
+ Math.abs(scroller.scrollTop - current.top) > 8
139
+ )
140
+ return
141
+ if (
142
+ Math.abs(dx) < Math.min(120, Math.max(64, window.innerWidth * 0.18)) ||
143
+ Math.abs(dx) < Math.abs(dy) * 1.5
144
+ )
145
+ return
146
+ const href = dx < 0 ? nextHref : previousHref
147
+ if (!href) return
148
+ navigationAt = now
149
+ router.push(href, {
150
+ transitionTypes: [dx < 0 ? 'rdx-doc-forward' : 'rdx-doc-back'],
151
+ })
152
+ }
153
+ // Passive listeners never take scrolling, zooming or selection away from the browser.
154
+ scroller.addEventListener('touchstart', start, { passive: true })
155
+ scroller.addEventListener('touchmove', move, { passive: true })
156
+ scroller.addEventListener('touchend', end, { passive: true })
157
+ scroller.addEventListener('touchcancel', cancel, { passive: true })
158
+ return () => {
159
+ scroller.removeEventListener('touchstart', start)
160
+ scroller.removeEventListener('touchmove', move)
161
+ scroller.removeEventListener('touchend', end)
162
+ scroller.removeEventListener('touchcancel', cancel)
163
+ }
164
+ }, [pathname, previousHref, nextHref, router])
165
+
166
+ return (
167
+ <ViewTransition
168
+ name="rdx-document"
169
+ default="none"
170
+ enter={directions}
171
+ exit={directions}
172
+ update={directions}
173
+ share={directions}
174
+ >
175
+ {children}
176
+ </ViewTransition>
177
+ )
178
+ }
@@ -4,9 +4,17 @@ import { useMessages } from '../providers/locale-provider'
4
4
 
5
5
  import { useEffect, useRef, useState } from 'react'
6
6
  import { SidebarClose, SidebarOpen } from 'lucide-react'
7
+ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
7
8
 
8
9
  import { Button } from './button'
9
10
  import { Separator } from './separator'
11
+ import {
12
+ Sheet,
13
+ SheetClose,
14
+ SheetContent,
15
+ SheetTitle,
16
+ SheetTrigger,
17
+ } from './sheet'
10
18
 
11
19
  export type SidebarCategory = {
12
20
  title: string
@@ -21,21 +29,42 @@ export function DocsSidebar({
21
29
  pathname: string
22
30
  }) {
23
31
  const messages = useMessages()
24
- const [openPath, setOpenPath] = useState<string | null>(null)
25
- const isOpen = openPath === pathname
26
- const dialog = useRef<HTMLDialogElement>(null)
32
+ const [isOpen, setOpen] = useState(false)
33
+ const open = useRef(false)
34
+ const actions = useRef<{ close: () => void; unmount: () => void } | null>(
35
+ null
36
+ )
37
+ const previousPath = useRef(pathname)
38
+ const reducedMotion = useReducedMotion()
39
+ const pageOrder = new Map(
40
+ categories
41
+ .flatMap((category) => category.links)
42
+ .map((page, index) => [page.href, index])
43
+ )
44
+ const currentIndex = pageOrder.get(pathname) ?? -1
45
+
46
+ // Route changes (including browser history) must use the same closing phase
47
+ // as Escape and the close button, keeping the content mounted until exit ends.
27
48
  useEffect(() => {
28
- if (isOpen && !dialog.current?.open) dialog.current?.showModal()
29
- if (!isOpen && dialog.current?.open) dialog.current?.close()
30
- }, [isOpen])
49
+ if (previousPath.current !== pathname) actions.current?.close()
50
+ previousPath.current = pathname
51
+ }, [pathname])
52
+ useEffect(() => {
53
+ const desktop = window.matchMedia('(min-width: 768px)')
54
+ const closeOnDesktop = () => {
55
+ if (desktop.matches) actions.current?.close()
56
+ }
57
+ desktop.addEventListener('change', closeOnDesktop)
58
+ return () => desktop.removeEventListener('change', closeOnDesktop)
59
+ }, [])
31
60
  const content = (
32
61
  <nav aria-label={messages.documentationPages}>
33
62
  {categories.map((category, index) => (
34
63
  <div
35
64
  key={`${index}:${category.title}`}
36
- className="flex flex-col gap-0.5 mb-3 first:mt-4"
65
+ className="flex min-w-0 flex-col gap-0.5 mb-3 first:mt-4"
37
66
  >
38
- <p className="text-xs text-ring font-semibold uppercase mb-1">
67
+ <p className="text-xs text-ring font-semibold uppercase mb-1 [overflow-wrap:anywhere]">
39
68
  {category.title}
40
69
  </p>
41
70
  {category.links.map((link) => (
@@ -46,9 +75,18 @@ export function DocsSidebar({
46
75
  active={pathname === link.href}
47
76
  aria-label={link.label}
48
77
  href={link.href}
49
- onClick={() => setOpenPath(null)}
78
+ transitionTypes={
79
+ currentIndex < 0 || link.href === pathname
80
+ ? undefined
81
+ : [
82
+ pageOrder.get(link.href)! < currentIndex
83
+ ? 'rdx-doc-back'
84
+ : 'rdx-doc-forward',
85
+ ]
86
+ }
87
+ onClick={() => actions.current?.close()}
50
88
  aria-current={pathname === link.href ? 'page' : undefined}
51
- className="justify-start text-sm text-primary"
89
+ className={`h-auto min-h-8 w-full min-w-0 justify-start whitespace-normal py-1.5 text-left text-sm [overflow-wrap:anywhere] ${pathname === link.href ? 'text-accent-foreground' : 'text-primary'}`}
52
90
  >
53
91
  {link.label}
54
92
  </Button>
@@ -59,15 +97,22 @@ export function DocsSidebar({
59
97
  </nav>
60
98
  )
61
99
  return (
62
- <>
100
+ <Sheet
101
+ open={isOpen}
102
+ actionsRef={actions}
103
+ onOpenChange={(nextOpen, details) => {
104
+ if (!nextOpen) details.preventUnmountOnClose()
105
+ open.current = nextOpen
106
+ setOpen(nextOpen)
107
+ }}
108
+ >
63
109
  <div className="md:hidden absolute top-2 left-4 z-30">
64
- <Button
65
- variant="ghost"
66
- onClick={() => setOpenPath(pathname)}
110
+ <SheetTrigger
111
+ render={<Button variant="ghost" />}
67
112
  aria-label={messages.openSidebar}
68
113
  >
69
114
  <SidebarOpen className="size-4" />
70
- </Button>
115
+ </SheetTrigger>
71
116
  </div>
72
117
  <aside
73
118
  data-rdx-sidebar
@@ -75,23 +120,51 @@ export function DocsSidebar({
75
120
  >
76
121
  {content}
77
122
  </aside>
78
- <dialog
79
- ref={dialog}
80
- aria-label={messages.documentationNavigation}
81
- onClose={() => setOpenPath(null)}
82
- className="fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none bg-background text-foreground border-0 p-4 backdrop:bg-black/40"
83
- >
84
- <div className="flex justify-end mb-4">
85
- <Button
86
- variant="ghost"
87
- onClick={() => setOpenPath(null)}
88
- aria-label={messages.closeSidebar}
123
+ <AnimatePresence>
124
+ {isOpen && (
125
+ <SheetContent
126
+ key="mobile-sidebar"
127
+ side="left"
128
+ showCloseButton={false}
129
+ portalProps={{ keepMounted: true }}
130
+ data-rdx-mobile-sidebar
131
+ className="md:hidden w-full! max-w-none! h-dvh border-0 bg-background text-foreground gap-0 p-4 overflow-y-auto translate-none! opacity-100! transition-none!"
132
+ render={
133
+ <motion.div
134
+ initial="closed"
135
+ animate="open"
136
+ exit="closed"
137
+ variants={{
138
+ closed: { x: reducedMotion ? 0 : '-100%' },
139
+ open: { x: 0 },
140
+ }}
141
+ transition={
142
+ reducedMotion
143
+ ? { duration: 0 }
144
+ : { type: 'spring', stiffness: 300, damping: 30 }
145
+ }
146
+ onAnimationComplete={(phase) => {
147
+ if (phase === 'closed' && !open.current)
148
+ actions.current?.unmount()
149
+ }}
150
+ />
151
+ }
89
152
  >
90
- <SidebarClose className="size-4" />
91
- </Button>
92
- </div>
93
- {isOpen && content}
94
- </dialog>
95
- </>
153
+ <SheetTitle className="sr-only">
154
+ {messages.documentationNavigation}
155
+ </SheetTitle>
156
+ <div className="flex justify-end mb-4">
157
+ <SheetClose
158
+ render={<Button variant="ghost" />}
159
+ aria-label={messages.closeSidebar}
160
+ >
161
+ <SidebarClose className="size-4" />
162
+ </SheetClose>
163
+ </div>
164
+ {content}
165
+ </SheetContent>
166
+ )}
167
+ </AnimatePresence>
168
+ </Sheet>
96
169
  )
97
170
  }
@@ -54,7 +54,7 @@ function HeadingLinks({
54
54
  className={cn(
55
55
  'block rounded-md py-1.5 pr-2 text-xs leading-5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
56
56
  heading.id === activeId &&
57
- 'bg-accent/60 font-medium text-foreground'
57
+ 'bg-accent/60 font-medium text-rdx-selected'
58
58
  )}
59
59
  >
60
60
  {heading.title}
@@ -98,12 +98,12 @@ function PageToc({ contentId }: { contentId: string }) {
98
98
  }
99
99
  const scrollTarget = scrollRoot ?? window
100
100
  // Keep native fragment links/history; smooth the actual document scroller.
101
- // Tailwind's motion-safe variant leaves reduced-motion navigation instant.
101
+ // Match the TOC's xl visibility and leave reduced-motion navigation instant.
102
102
  const scrollElement = scrollRoot ?? document.documentElement
103
103
  const alreadySmooth = scrollElement.classList.contains(
104
- 'motion-safe:scroll-smooth'
104
+ 'xl:motion-safe:scroll-smooth'
105
105
  )
106
- scrollElement.classList.add('motion-safe:scroll-smooth')
106
+ scrollElement.classList.add('xl:motion-safe:scroll-smooth')
107
107
  function updateActive() {
108
108
  frame = 0
109
109
  const top = (scrollRoot?.getBoundingClientRect().top ?? 0) + 96
@@ -172,7 +172,7 @@ function PageToc({ contentId }: { contentId: string }) {
172
172
  cancelAnimationFrame(frame)
173
173
  cancelAnimationFrame(initialFrame)
174
174
  if (!alreadySmooth)
175
- scrollElement.classList.remove('motion-safe:scroll-smooth')
175
+ scrollElement.classList.remove('xl:motion-safe:scroll-smooth')
176
176
  }
177
177
  }, [contentId])
178
178
 
@@ -197,7 +197,7 @@ function PageToc({ contentId }: { contentId: string }) {
197
197
  className={cn(
198
198
  'h-0.5 shrink-0 rounded-full bg-current transition-colors',
199
199
  heading.depth === baseDepth ? 'w-5' : 'w-3',
200
- heading.id === activeId && 'text-foreground'
200
+ heading.id === activeId && 'text-rdx-selected'
201
201
  )}
202
202
  />
203
203
  ))}
@@ -16,10 +16,17 @@ function Kbd({ className, ...props }: ComponentProps<'kbd'>) {
16
16
  )
17
17
  }
18
18
 
19
- function KbdGroup({ className, ...props }: ComponentProps<'div'>) {
19
+ function KbdGroup({ className, role, ...props }: ComponentProps<'div'>) {
20
20
  return (
21
21
  <kbd
22
22
  data-slot="kbd-group"
23
+ role={
24
+ role ??
25
+ (props['aria-label'] !== undefined ||
26
+ props['aria-labelledby'] !== undefined
27
+ ? 'group'
28
+ : undefined)
29
+ }
23
30
  className={cn('inline-flex items-center gap-1', className)}
24
31
  {...props}
25
32
  />
@@ -1,7 +1,16 @@
1
1
  'use client'
2
2
 
3
- import { useState, type ComponentProps, type ReactNode } from 'react'
3
+ import {
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ComponentProps,
9
+ type CSSProperties,
10
+ type ReactNode,
11
+ } from 'react'
4
12
  import { CodeXml, FolderTree, GitCompareArrows, WrapText } from 'lucide-react'
13
+ import { motion } from 'motion/react'
5
14
 
6
15
  import { useMessages } from '../providers/locale-provider'
7
16
  import { Button } from './button'
@@ -12,24 +21,100 @@ import {
12
21
  CodeBlockCopyButton,
13
22
  CodeBlockHeader,
14
23
  CodeBlockTitle,
24
+ CodeBlockFooter,
15
25
  } from './code-block'
16
26
 
27
+ // Keep native pre events separate from Motion's gesture event props.
28
+ const AnimatedCodeBlockContent = motion.create(function PreviewContent({
29
+ preProps,
30
+ ...props
31
+ }: ComponentProps<typeof CodeBlockContent> & {
32
+ preProps: ComponentProps<'pre'>
33
+ }) {
34
+ return <CodeBlockContent {...preProps} {...props} />
35
+ })
36
+
17
37
  /** The wrap action follows shadcn's stateful Code Block composition. */
18
38
  export function MdxCodeBlockView({
19
39
  label,
20
40
  mode,
21
41
  value,
42
+ lineNumbers,
43
+ startLine,
22
44
  preProps,
23
45
  children,
24
46
  }: {
25
47
  label: string
26
48
  mode?: 'folder' | 'diff'
27
49
  value: string
50
+ lineNumbers?: boolean
51
+ startLine?: number
28
52
  preProps: ComponentProps<'pre'>
29
53
  children: ReactNode
30
54
  }) {
31
55
  const [wrap, setWrap] = useState(false)
56
+ const [expanded, setExpanded] = useState(false)
57
+ const [previewHeight, setPreviewHeight] = useState<number | null>(null)
58
+ const [fullHeight, setFullHeight] = useState(0)
59
+ const [animateSize, setAnimateSize] = useState(false)
60
+ const blockRef = useRef<HTMLDivElement>(null)
61
+ const generatedId = useId()
62
+ const contentId = preProps.id ?? generatedId
63
+ const collapsed = previewHeight !== null && !expanded
64
+ const contentStyle: CSSProperties = {
65
+ ...preProps.style,
66
+ ...(previewHeight !== null && {
67
+ height: 'var(--rdx-code-preview-height)',
68
+ }),
69
+ }
32
70
  const messages = useMessages()
71
+
72
+ useEffect(() => {
73
+ const pre = blockRef.current?.querySelector<HTMLElement>(
74
+ '[data-slot="code-block-content"]'
75
+ )
76
+ const code = pre?.querySelector('code')
77
+ if (!pre || !code) return
78
+ const desktop = window.matchMedia('(min-width: 768px)')
79
+ let frame = 0
80
+ let observer: ResizeObserver | undefined
81
+ const measure = () => {
82
+ if (desktop.matches) return
83
+ const style = getComputedStyle(pre)
84
+ const lineHeight = parseFloat(style.lineHeight)
85
+ const padding =
86
+ parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
87
+ if (!Number.isFinite(lineHeight)) return
88
+ const limit = lineHeight * 12 + padding
89
+ const height = code.getBoundingClientRect().height + padding
90
+ setFullHeight(height)
91
+ setPreviewHeight(height > limit + 1 ? limit : null)
92
+ }
93
+ const schedule = () => {
94
+ cancelAnimationFrame(frame)
95
+ frame = requestAnimationFrame(measure)
96
+ }
97
+ const updateViewport = () => {
98
+ setAnimateSize(false)
99
+ observer?.disconnect()
100
+ cancelAnimationFrame(frame)
101
+ if (desktop.matches) {
102
+ setPreviewHeight(null)
103
+ return
104
+ }
105
+ observer = new ResizeObserver(schedule)
106
+ observer.observe(code)
107
+ observer.observe(pre)
108
+ schedule()
109
+ }
110
+ updateViewport()
111
+ desktop.addEventListener('change', updateViewport)
112
+ return () => {
113
+ observer?.disconnect()
114
+ cancelAnimationFrame(frame)
115
+ desktop.removeEventListener('change', updateViewport)
116
+ }
117
+ }, [value])
33
118
  const Icon =
34
119
  mode === 'folder'
35
120
  ? FolderTree
@@ -39,13 +124,15 @@ export function MdxCodeBlockView({
39
124
  return (
40
125
  <CodeBlock
41
126
  size="sm"
127
+ ref={blockRef}
42
128
  data-rdx-code-block
43
129
  data-rdx-code-mode={mode}
44
130
  data-rdx-code-value={value}
131
+ data-rdx-code-collapsed={collapsed || undefined}
45
132
  className="my-4 max-w-full"
46
133
  >
47
134
  <CodeBlockHeader>
48
- <CodeBlockTitle>
135
+ <CodeBlockTitle title={label}>
49
136
  <Icon aria-hidden="true" /> {label}
50
137
  </CodeBlockTitle>
51
138
  <CodeBlockActions>
@@ -56,22 +143,61 @@ export function MdxCodeBlockView({
56
143
  aria-label={messages.wrapCode}
57
144
  title={messages.wrapCode}
58
145
  aria-pressed={wrap}
59
- onClick={() => setWrap(!wrap)}
146
+ onClick={() => {
147
+ setAnimateSize(false)
148
+ setWrap(!wrap)
149
+ }}
60
150
  >
61
151
  <WrapText aria-hidden="true" />
62
152
  </Button>
63
153
  <CodeBlockCopyButton value={value} />
64
154
  </CodeBlockActions>
65
155
  </CodeBlockHeader>
66
- <CodeBlockContent
67
- {...preProps}
156
+ <AnimatedCodeBlockContent
157
+ preProps={preProps}
158
+ initial={false}
159
+ animate={{
160
+ '--rdx-code-preview-height': `${previewHeight === null ? 0 : expanded ? fullHeight : previewHeight}px`,
161
+ }}
162
+ transition={{
163
+ duration: animateSize ? 0.28 : 0,
164
+ ease: [0.22, 1, 0.36, 1],
165
+ }}
166
+ id={contentId}
167
+ style={contentStyle}
68
168
  wrap={wrap}
69
- className={[preProps.className, 'data-wrap:wrap-anywhere']
169
+ lineNumbers={lineNumbers}
170
+ startLine={startLine}
171
+ className={[
172
+ preProps.className,
173
+ 'data-wrap:wrap-anywhere',
174
+ previewHeight !== null && 'overflow-y-hidden',
175
+ collapsed && 'scroll-fade-y',
176
+ ]
70
177
  .filter(Boolean)
71
178
  .join(' ')}
72
179
  >
73
180
  {children}
74
- </CodeBlockContent>
181
+ </AnimatedCodeBlockContent>
182
+ {previewHeight !== null && (
183
+ <CodeBlockFooter className="justify-center md:hidden">
184
+ <Button
185
+ variant="ghost"
186
+ size="sm"
187
+ className="h-6 px-2 text-xs"
188
+ aria-expanded={expanded}
189
+ aria-controls={contentId}
190
+ onClick={() => {
191
+ setAnimateSize(
192
+ !window.matchMedia('(prefers-reduced-motion: reduce)').matches
193
+ )
194
+ setExpanded(!expanded)
195
+ }}
196
+ >
197
+ {expanded ? messages.showLess : messages.showMore}
198
+ </Button>
199
+ </CodeBlockFooter>
200
+ )}
75
201
  </CodeBlock>
76
202
  )
77
203
  }