@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.
- package/THIRD_PARTY_NOTICES.md +3 -2
- package/package.json +4 -2
- package/src/components/docs-page-transition.tsx +178 -0
- package/src/components/docs-sidebar.tsx +105 -32
- package/src/components/docs-toc.tsx +6 -6
- package/src/components/kbd.tsx +8 -1
- package/src/components/mdx-code-block-view.tsx +133 -7
- package/src/components/mdx-code-block.tsx +83 -30
- package/src/components/mdx-components.tsx +6 -4
- package/src/components/mdx-folder-tree.tsx +58 -0
- package/src/components/mdx-keyboard-shortcut.tsx +74 -0
- package/src/components/mdx-mermaid.tsx +252 -0
- package/src/components/sheet.tsx +146 -0
- package/src/components/skip-link.tsx +10 -0
- package/src/components/tabs.tsx +6 -1
- package/src/lib/i18n.ts +48 -0
- package/src/lib/mermaid.ts +114 -0
- package/src/styles/globals.css +112 -14
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## shadcn/ui
|
|
4
4
|
|
|
5
|
-
Table and
|
|
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.
|
|
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.
|
|
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 [
|
|
25
|
-
const
|
|
26
|
-
const
|
|
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 (
|
|
29
|
-
|
|
30
|
-
}, [
|
|
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
|
-
|
|
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=
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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-
|
|
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
|
-
//
|
|
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-
|
|
200
|
+
heading.id === activeId && 'text-rdx-selected'
|
|
201
201
|
)}
|
|
202
202
|
/>
|
|
203
203
|
))}
|
package/src/components/kbd.tsx
CHANGED
|
@@ -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 {
|
|
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={() =>
|
|
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
|
-
<
|
|
67
|
-
{
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
}
|