@lexibase/rdx-ui 2.0.0 → 3.1.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.
@@ -0,0 +1,203 @@
1
+ 'use client'
2
+
3
+ import {
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ComponentProps,
9
+ type CSSProperties,
10
+ type ReactNode,
11
+ } from 'react'
12
+ import { CodeXml, FolderTree, GitCompareArrows, WrapText } from 'lucide-react'
13
+ import { motion } from 'motion/react'
14
+
15
+ import { useMessages } from '../providers/locale-provider'
16
+ import { Button } from './button'
17
+ import {
18
+ CodeBlock,
19
+ CodeBlockActions,
20
+ CodeBlockContent,
21
+ CodeBlockCopyButton,
22
+ CodeBlockHeader,
23
+ CodeBlockTitle,
24
+ CodeBlockFooter,
25
+ } from './code-block'
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
+
37
+ /** The wrap action follows shadcn's stateful Code Block composition. */
38
+ export function MdxCodeBlockView({
39
+ label,
40
+ mode,
41
+ value,
42
+ lineNumbers,
43
+ startLine,
44
+ preProps,
45
+ children,
46
+ }: {
47
+ label: string
48
+ mode?: 'folder' | 'diff'
49
+ value: string
50
+ lineNumbers?: boolean
51
+ startLine?: number
52
+ preProps: ComponentProps<'pre'>
53
+ children: ReactNode
54
+ }) {
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
+ }
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])
118
+ const Icon =
119
+ mode === 'folder'
120
+ ? FolderTree
121
+ : mode === 'diff'
122
+ ? GitCompareArrows
123
+ : CodeXml
124
+ return (
125
+ <CodeBlock
126
+ size="sm"
127
+ ref={blockRef}
128
+ data-rdx-code-block
129
+ data-rdx-code-mode={mode}
130
+ data-rdx-code-value={value}
131
+ data-rdx-code-collapsed={collapsed || undefined}
132
+ className="my-4 max-w-full"
133
+ >
134
+ <CodeBlockHeader>
135
+ <CodeBlockTitle title={label}>
136
+ <Icon aria-hidden="true" /> {label}
137
+ </CodeBlockTitle>
138
+ <CodeBlockActions>
139
+ <Button
140
+ variant="ghost"
141
+ size="icon-xs"
142
+ className="md:hidden"
143
+ aria-label={messages.wrapCode}
144
+ title={messages.wrapCode}
145
+ aria-pressed={wrap}
146
+ onClick={() => {
147
+ setAnimateSize(false)
148
+ setWrap(!wrap)
149
+ }}
150
+ >
151
+ <WrapText aria-hidden="true" />
152
+ </Button>
153
+ <CodeBlockCopyButton value={value} />
154
+ </CodeBlockActions>
155
+ </CodeBlockHeader>
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}
168
+ wrap={wrap}
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
+ ]
177
+ .filter(Boolean)
178
+ .join(' ')}
179
+ >
180
+ {children}
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
+ )}
201
+ </CodeBlock>
202
+ )
203
+ }
@@ -0,0 +1,161 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ isValidElement,
5
+ type ComponentProps,
6
+ type ReactNode,
7
+ } from 'react'
8
+
9
+ import { getMessages } from '../lib/i18n'
10
+ import { CodeBlockLine } from './code-block'
11
+ import { MdxCodeBlockView } from './mdx-code-block-view'
12
+ import { MdxFolderTree } from './mdx-folder-tree'
13
+ import { MdxMermaid } from './mdx-mermaid'
14
+
15
+ function textOf(children: ReactNode): string {
16
+ return Children.toArray(children)
17
+ .map((child) =>
18
+ typeof child === 'string' || typeof child === 'number'
19
+ ? String(child)
20
+ : isValidElement<{ children?: ReactNode }>(child)
21
+ ? textOf(child.props.children)
22
+ : ''
23
+ )
24
+ .join('')
25
+ }
26
+
27
+ // Shadcn draws diff markers itself. Strip the original marker from highlighted
28
+ // tokens while keeping the original patch as the clipboard value.
29
+ function withoutMarker(children: ReactNode): ReactNode {
30
+ let remaining = 1
31
+ function strip(nodes: ReactNode): ReactNode {
32
+ return Children.map(nodes, (node) => {
33
+ if (!remaining) return node
34
+ if (typeof node === 'string') {
35
+ const value = node.slice(remaining)
36
+ remaining = Math.max(0, remaining - node.length)
37
+ return value
38
+ }
39
+ return isValidElement<{ children?: ReactNode }>(node)
40
+ ? cloneElement(node, {}, strip(node.props.children))
41
+ : node
42
+ })
43
+ }
44
+ return strip(children)
45
+ }
46
+
47
+ /** Translate Markdown fences to the complete shadcn Code Block composition. */
48
+ export function MdxCodeBlock({
49
+ lang,
50
+ children,
51
+ 'data-rdx-code-mode': mode,
52
+ 'data-rdx-code-title': title,
53
+ 'data-rdx-code-line-numbers': numbered,
54
+ 'data-rdx-code-start-line': firstLine,
55
+ ...props
56
+ }: ComponentProps<'pre'> & {
57
+ 'data-rdx-code-mode'?: 'folder' | 'diff' | 'mermaid'
58
+ 'data-rdx-code-title'?: string
59
+ 'data-rdx-code-line-numbers'?: string
60
+ 'data-rdx-code-start-line'?: string
61
+ }) {
62
+ const messages = getMessages(lang)
63
+ const code = Children.toArray(children).find((child) => isValidElement(child))
64
+ const codeProps = isValidElement<{
65
+ className?: string
66
+ children?: ReactNode
67
+ }>(code)
68
+ ? code.props
69
+ : undefined
70
+ const language = codeProps?.className?.match(/(?:^|\s)language-([^\s]+)/)?.[1]
71
+ const value = textOf(codeProps?.children ?? children)
72
+ if (mode === 'mermaid' || language?.toLowerCase() === 'mermaid') {
73
+ return (
74
+ <MdxMermaid
75
+ value={value}
76
+ title={title}
77
+ lineNumbers={numbered === 'true'}
78
+ startLine={firstLine === undefined ? undefined : Number(firstLine)}
79
+ preProps={props}
80
+ />
81
+ )
82
+ }
83
+ const label =
84
+ title ||
85
+ (mode === 'folder' ? messages.folderTree : (language ?? messages.code))
86
+ const lineNumbers = numbered === 'true'
87
+ const raw = codeProps?.children ?? children
88
+ const nodes = Children.toArray(raw)
89
+ type LineProps = {
90
+ children?: ReactNode
91
+ className?: string
92
+ 'data-rdx-line-variant'?: string
93
+ }
94
+ const lines = nodes.filter(
95
+ (node) =>
96
+ isValidElement<LineProps>(node) &&
97
+ (node.props['data-rdx-line-variant'] ||
98
+ node.props.className?.split(/\s+/).includes('line'))
99
+ )
100
+ const lastLine = lines.at(-1)
101
+ const content =
102
+ mode === 'folder' ? (
103
+ <MdxFolderTree value={value} />
104
+ ) : (mode === 'diff' || lineNumbers) && typeof raw !== 'string' ? (
105
+ nodes.map((node) => {
106
+ if (lineNumbers && node === '\n') return null
107
+ if (!isValidElement<LineProps>(node)) return node
108
+ const variant = node.props['data-rdx-line-variant']
109
+ if (
110
+ !variant &&
111
+ !(lineNumbers && node.props.className?.split(/\s+/).includes('line'))
112
+ )
113
+ return node
114
+ // A final newline terminates the last source line; intentional blank
115
+ // lines before it still receive the official component's own number.
116
+ if (
117
+ lineNumbers &&
118
+ lines.length > 1 &&
119
+ node === lastLine &&
120
+ value.endsWith('\n') &&
121
+ textOf(node.props.children) === ''
122
+ )
123
+ return null
124
+ return (
125
+ <CodeBlockLine
126
+ key={node.key}
127
+ variant={
128
+ variant === 'added' || variant === 'removed'
129
+ ? variant
130
+ : variant === 'hunk'
131
+ ? 'highlighted'
132
+ : 'default'
133
+ }
134
+ >
135
+ {variant === 'added' || variant === 'removed'
136
+ ? withoutMarker(node.props.children)
137
+ : node.props.children}
138
+ </CodeBlockLine>
139
+ )
140
+ })
141
+ ) : (
142
+ raw
143
+ )
144
+ return (
145
+ <MdxCodeBlockView
146
+ label={label}
147
+ mode={mode}
148
+ value={value}
149
+ lineNumbers={lineNumbers}
150
+ startLine={firstLine === undefined ? undefined : Number(firstLine)}
151
+ preProps={{
152
+ ...props,
153
+ className: [props.className, language && `language-${language}`]
154
+ .filter(Boolean)
155
+ .join(' '),
156
+ }}
157
+ >
158
+ {content}
159
+ </MdxCodeBlockView>
160
+ )
161
+ }
@@ -1,7 +1,14 @@
1
1
  import { getMessages } from '../lib/i18n'
2
- import type { ComponentProps } from 'react'
2
+ import {
3
+ Children,
4
+ cloneElement,
5
+ isValidElement,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from 'react'
3
9
 
4
10
  import { cn } from '../lib/utils'
11
+ import { MdxImage } from './mdx-image'
5
12
  import {
6
13
  Accordion,
7
14
  AccordionContent,
@@ -10,7 +17,17 @@ import {
10
17
  } from './accordion'
11
18
  import { Blockquote } from './blockquote'
12
19
  import { Callout } from './callout'
13
- import { CodeBlock } from './code-block'
20
+ import {
21
+ CodeBlock,
22
+ CodeBlockActions,
23
+ CodeBlockContent,
24
+ CodeBlockCopyButton,
25
+ CodeBlockFooter,
26
+ CodeBlockHeader,
27
+ CodeBlockLine,
28
+ CodeBlockTitle,
29
+ } from './code-block'
30
+ import { MdxCodeBlock } from './mdx-code-block'
14
31
  import { Kbd, KbdGroup } from './kbd'
15
32
  import {
16
33
  Table,
@@ -25,6 +42,17 @@ import {
25
42
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
26
43
  import { Separator } from '@lexibase/rdx-ui/components/separator'
27
44
 
45
+ function linkedImages(children: ReactNode): ReactNode {
46
+ return Children.map(children, (child) => {
47
+ if (!isValidElement<{ children?: ReactNode; zoom?: boolean }>(child))
48
+ return child
49
+ if (child.type === MdxImage) return cloneElement(child, { zoom: false })
50
+ return child.props.children
51
+ ? cloneElement(child, {}, linkedImages(child.props.children))
52
+ : child
53
+ })
54
+ }
55
+
28
56
  function MdxTable({ lang, className, ...props }: ComponentProps<'table'>) {
29
57
  return (
30
58
  <Table
@@ -76,14 +104,13 @@ export const mdxComponents = {
76
104
  {...props}
77
105
  />
78
106
  ),
79
- a: ({ className, ...props }: ComponentProps<'a'>) => (
80
- <a
81
- className={cn('underline text-chart-1 text-blue-500', className)}
82
- {...props}
83
- />
107
+ a: ({ className, children, ...props }: ComponentProps<'a'>) => (
108
+ <a className={cn('underline text-rdx-highlighting', className)} {...props}>
109
+ {linkedImages(children)}
110
+ </a>
84
111
  ),
85
112
  blockquote: Blockquote,
86
- pre: CodeBlock,
113
+ pre: MdxCodeBlock,
87
114
  code: ({ className, ...props }: ComponentProps<'code'>) => (
88
115
  <code
89
116
  className={cn(
@@ -106,13 +133,7 @@ export const mdxComponents = {
106
133
  hr: ({ className, ...props }: ComponentProps<'hr'>) => (
107
134
  <hr className={cn('my-8 border-border', className)} {...props} />
108
135
  ),
109
- img: ({ className, alt = '', ...props }: ComponentProps<'img'>) => (
110
- <img
111
- alt={alt}
112
- className={cn('max-w-full h-auto rounded-md my-4', className)}
113
- {...props}
114
- />
115
- ),
136
+ img: MdxImage,
116
137
  table: MdxTable,
117
138
  thead: TableHeader,
118
139
  tbody: TableBody,
@@ -123,6 +144,14 @@ export const mdxComponents = {
123
144
  <TableHead scope={scope} className={className} {...props} />
124
145
  ),
125
146
  td: TableCell,
147
+ CodeBlock,
148
+ CodeBlockActions,
149
+ CodeBlockContent,
150
+ CodeBlockCopyButton,
151
+ CodeBlockFooter,
152
+ CodeBlockHeader,
153
+ CodeBlockLine,
154
+ CodeBlockTitle,
126
155
  Kbd,
127
156
  KbdGroup,
128
157
  Table,
@@ -158,7 +187,9 @@ export const mdxComponents = {
158
187
  export function getMdxComponents(lang?: string) {
159
188
  return {
160
189
  ...mdxComponents,
161
- pre: (props: ComponentProps<'pre'>) => <CodeBlock {...props} lang={lang} />,
190
+ pre: (props: ComponentProps<'pre'>) => (
191
+ <MdxCodeBlock {...props} lang={lang} />
192
+ ),
162
193
  table: (props: ComponentProps<'table'>) => (
163
194
  <MdxTable {...props} lang={lang} />
164
195
  ),
@@ -0,0 +1,58 @@
1
+ import {
2
+ File,
3
+ FileCode2,
4
+ FileImage,
5
+ FileJson,
6
+ FileText,
7
+ Folder,
8
+ } from 'lucide-react'
9
+
10
+ import { CodeBlockLine } from './code-block'
11
+
12
+ function entryIcon(name: string) {
13
+ if (name.endsWith('/')) return { Icon: Folder, kind: 'folder' }
14
+ const extension = name.includes('.')
15
+ ? name.split('.').at(-1)?.toLowerCase()
16
+ : undefined
17
+ if (extension === 'json' || extension === 'jsonc')
18
+ return { Icon: FileJson, kind: 'json' }
19
+ if (extension && /^(?:png|jpe?g|gif|webp|svg|avif|ico|bmp)$/.test(extension))
20
+ return { Icon: FileImage, kind: 'image' }
21
+ if (extension && /^(?:mdx?|txt|log|rst)$/.test(extension))
22
+ return { Icon: FileText, kind: 'text' }
23
+ if (
24
+ extension &&
25
+ /^(?:[cm]?[jt]sx?|html|css|scss|vue|svelte|py|rb|go|rs|java|c|cpp|h|cs|php|sql|sh|bash|ya?ml|toml)$/.test(
26
+ extension
27
+ )
28
+ )
29
+ return { Icon: FileCode2, kind: 'code' }
30
+ return { Icon: File, kind: 'file' }
31
+ }
32
+
33
+ /** Decorate generated text with icons inside the official shadcn line component. */
34
+ export function MdxFolderTree({ value }: { value: string }) {
35
+ const trailingNewline = value.endsWith('\n')
36
+ const lines = value.replace(/\n$/, '').split('\n')
37
+ return lines.map((line, index) => {
38
+ const match = line.match(/^([ │\t]*(?:├──|└──)\s*)(.*)$/)
39
+ const prefix = match?.[1] ?? line.match(/^[ │\t]*/)?.[0] ?? ''
40
+ const name = match?.[2] ?? line.slice(prefix.length)
41
+ const { Icon, kind } = entryIcon(name)
42
+ return (
43
+ <CodeBlockLine key={index} data-rdx-folder-kind={name ? kind : undefined}>
44
+ {prefix && <span aria-hidden="true">{prefix}</span>}
45
+ {name && (
46
+ <>
47
+ <Icon
48
+ aria-hidden="true"
49
+ className="mr-1.5 inline-block size-3.5 align-[-2px] text-muted-foreground"
50
+ />
51
+ {name}
52
+ </>
53
+ )}
54
+ {index < lines.length - 1 || trailingNewline ? '\n' : null}
55
+ </CodeBlockLine>
56
+ )
57
+ })
58
+ }
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import type { ComponentProps } from 'react'
4
+
5
+ import { formatMessage } from '../lib/i18n'
6
+ import { cn } from '../lib/utils'
7
+ import { useMessages } from '../providers/locale-provider'
8
+ import { Button } from './button'
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from './dialog'
16
+
17
+ /** Markdown images keep their normal appearance and use shadcn Dialog to expand. */
18
+ export function MdxImage({
19
+ className,
20
+ alt = '',
21
+ zoom = true,
22
+ ...props
23
+ }: ComponentProps<'img'> & { zoom?: boolean }) {
24
+ const messages = useMessages()
25
+ const image = (
26
+ <img
27
+ {...props}
28
+ alt={alt}
29
+ className={cn('max-w-full h-auto rounded-md my-4', className)}
30
+ />
31
+ )
32
+ // Decorative images and images inside links retain their original semantics.
33
+ if (!zoom || !alt.trim() || !props.src) return image
34
+ return (
35
+ <Dialog>
36
+ <DialogTrigger
37
+ data-rdx-image-trigger
38
+ aria-label={formatMessage(messages.enlargeImage, { alt })}
39
+ title={formatMessage(messages.enlargeImage, { alt })}
40
+ render={
41
+ <Button
42
+ variant="ghost"
43
+ className="h-auto max-w-full cursor-zoom-in p-0 align-middle hover:bg-transparent dark:hover:bg-transparent"
44
+ />
45
+ }
46
+ >
47
+ {image}
48
+ </DialogTrigger>
49
+ <DialogContent
50
+ data-rdx-image-dialog
51
+ closeLabel={messages.closeImage}
52
+ overlayClassName="bg-black/70"
53
+ className="flex max-h-[calc(100dvh-2rem)] w-max max-w-[calc(100vw-2rem)] flex-col gap-0 p-2 sm:max-w-[calc(100vw-4rem)] [&>[data-slot=dialog-close]]:bg-popover/90"
54
+ >
55
+ <DialogTitle className="sr-only">{alt}</DialogTitle>
56
+ <DialogDescription className="sr-only">
57
+ {messages.imagePreviewDescription}
58
+ </DialogDescription>
59
+ <img
60
+ src={props.src}
61
+ srcSet={props.srcSet}
62
+ sizes={props.srcSet ? 'calc(100vw - 4rem)' : undefined}
63
+ crossOrigin={props.crossOrigin}
64
+ referrerPolicy={props.referrerPolicy}
65
+ alt={alt}
66
+ loading="eager"
67
+ decoding="async"
68
+ className="h-auto max-h-[calc(100dvh-4rem)] w-auto max-w-full self-center rounded-md object-contain"
69
+ />
70
+ </DialogContent>
71
+ </Dialog>
72
+ )
73
+ }