@lexibase/rdx-ui 3.4.0 → 3.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexibase/rdx-ui",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^22.18.0 || ^24.11.0"
@@ -39,7 +39,7 @@
39
39
  "tailwind-merge": "^3.7.0",
40
40
  "tailwindcss": "^4.3.3",
41
41
  "tw-animate-css": "^1.4.0",
42
- "@lexibase/rdx-types": "2.2.0"
42
+ "@lexibase/rdx-types": "2.3.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "next": "^16.4.2",
@@ -0,0 +1,87 @@
1
+ import { mergeProps } from '@base-ui/react/merge-props'
2
+ import { useRender } from '@base-ui/react/use-render'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '@lexibase/rdx-ui/lib/utils'
5
+
6
+ import { Separator } from './separator'
7
+
8
+ const buttonGroupVariants = cva(
9
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
10
+ {
11
+ variants: {
12
+ orientation: {
13
+ horizontal:
14
+ '*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
15
+ vertical:
16
+ 'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ orientation: 'horizontal',
21
+ },
22
+ }
23
+ )
24
+
25
+ function ButtonGroup({
26
+ className,
27
+ orientation,
28
+ ...props
29
+ }: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
30
+ return (
31
+ <div
32
+ role="group"
33
+ data-slot="button-group"
34
+ data-orientation={orientation}
35
+ className={cn(buttonGroupVariants({ orientation }), className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function ButtonGroupText({
42
+ className,
43
+ render,
44
+ ...props
45
+ }: useRender.ComponentProps<'div'>) {
46
+ return useRender({
47
+ defaultTagName: 'div',
48
+ props: mergeProps<'div'>(
49
+ {
50
+ className: cn(
51
+ "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
52
+ className
53
+ ),
54
+ },
55
+ props
56
+ ),
57
+ render,
58
+ state: {
59
+ slot: 'button-group-text',
60
+ },
61
+ })
62
+ }
63
+
64
+ function ButtonGroupSeparator({
65
+ className,
66
+ orientation = 'vertical',
67
+ ...props
68
+ }: React.ComponentProps<typeof Separator>) {
69
+ return (
70
+ <Separator
71
+ data-slot="button-group-separator"
72
+ orientation={orientation}
73
+ className={cn(
74
+ 'relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ export {
83
+ ButtonGroup,
84
+ ButtonGroupSeparator,
85
+ ButtonGroupText,
86
+ buttonGroupVariants,
87
+ }
@@ -0,0 +1,102 @@
1
+ import * as React from 'react'
2
+ import { cn } from '@lexibase/rdx-ui/lib/utils'
3
+
4
+ function Card({
5
+ className,
6
+ size = 'default',
7
+ ...props
8
+ }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
9
+ return (
10
+ <div
11
+ data-slot="card"
12
+ data-size={size}
13
+ className={cn(
14
+ 'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
15
+ className
16
+ )}
17
+ {...props}
18
+ />
19
+ )
20
+ }
21
+
22
+ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
23
+ return (
24
+ <div
25
+ data-slot="card-header"
26
+ className={cn(
27
+ 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
28
+ className
29
+ )}
30
+ {...props}
31
+ />
32
+ )
33
+ }
34
+
35
+ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
36
+ return (
37
+ <div
38
+ data-slot="card-title"
39
+ className={cn(
40
+ 'cn-font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
41
+ className
42
+ )}
43
+ {...props}
44
+ />
45
+ )
46
+ }
47
+
48
+ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
49
+ return (
50
+ <div
51
+ data-slot="card-description"
52
+ className={cn('text-sm text-muted-foreground', className)}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
59
+ return (
60
+ <div
61
+ data-slot="card-action"
62
+ className={cn(
63
+ 'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ )
69
+ }
70
+
71
+ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
72
+ return (
73
+ <div
74
+ data-slot="card-content"
75
+ className={cn('px-(--card-spacing)', className)}
76
+ {...props}
77
+ />
78
+ )
79
+ }
80
+
81
+ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
82
+ return (
83
+ <div
84
+ data-slot="card-footer"
85
+ className={cn(
86
+ 'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
87
+ className
88
+ )}
89
+ {...props}
90
+ />
91
+ )
92
+ }
93
+
94
+ export {
95
+ Card,
96
+ CardHeader,
97
+ CardFooter,
98
+ CardTitle,
99
+ CardAction,
100
+ CardDescription,
101
+ CardContent,
102
+ }
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import { Check, ChevronDown, CircleAlert, Copy, FileText } from 'lucide-react'
4
+ import { useEffect, useRef, useState } from 'react'
5
+
6
+ import { useMessages } from '../providers/locale-provider'
7
+ import { Button } from './button'
8
+ import { ButtonGroup } from './button-group'
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuLinkItem,
13
+ DropdownMenuTrigger,
14
+ } from './dropdown-menu'
15
+
16
+ export function CopyPage({ href }: { href: string }) {
17
+ const messages = useMessages()
18
+ const [status, setStatus] = useState<'idle' | 'copying' | 'copied' | 'error'>(
19
+ 'idle'
20
+ )
21
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
22
+ const request = useRef<AbortController | undefined>(undefined)
23
+
24
+ useEffect(
25
+ () => () => {
26
+ request.current?.abort()
27
+ clearTimeout(timer.current)
28
+ },
29
+ []
30
+ )
31
+
32
+ async function copyPage() {
33
+ if (request.current) return
34
+ const controller = new AbortController()
35
+ request.current = controller
36
+ clearTimeout(timer.current)
37
+ setStatus('copying')
38
+
39
+ try {
40
+ if (!navigator.clipboard) throw new Error('Clipboard unavailable')
41
+ const markdown = fetch(href, { signal: controller.signal }).then(
42
+ (response) => {
43
+ if (
44
+ !response.ok ||
45
+ !response.headers.get('content-type')?.startsWith('text/markdown')
46
+ )
47
+ throw new Error('Markdown unavailable')
48
+ return response.text()
49
+ }
50
+ )
51
+ if (navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
52
+ // Initiate the write inside the click's activation, even while fetching.
53
+ const blob = markdown.then(
54
+ (text) => new Blob([text], { type: 'text/plain' })
55
+ )
56
+ // ClipboardItem construction can fail before the browser consumes the blob.
57
+ void blob.catch(() => {})
58
+ await Promise.all([
59
+ blob,
60
+ navigator.clipboard.write([
61
+ new ClipboardItem({ 'text/plain': blob }),
62
+ ]),
63
+ ])
64
+ } else {
65
+ await navigator.clipboard.writeText(await markdown)
66
+ }
67
+ if (controller.signal.aborted) return
68
+ setStatus('copied')
69
+ timer.current = setTimeout(() => setStatus('idle'), 1800)
70
+ } catch {
71
+ if (!controller.signal.aborted) setStatus('error')
72
+ controller.abort()
73
+ } finally {
74
+ request.current = undefined
75
+ }
76
+ }
77
+
78
+ const Icon =
79
+ status === 'copied' ? Check : status === 'error' ? CircleAlert : Copy
80
+
81
+ return (
82
+ <ButtonGroup
83
+ aria-label={messages.copyPageOptions}
84
+ className="ms-auto shrink-0"
85
+ >
86
+ <Button
87
+ type="button"
88
+ variant="outline"
89
+ size="sm"
90
+ onClick={copyPage}
91
+ aria-busy={status === 'copying'}
92
+ title={status === 'error' ? messages.copyPageError : undefined}
93
+ >
94
+ <Icon aria-hidden="true" />
95
+ {messages.copyThisPage}
96
+ </Button>
97
+ <DropdownMenu>
98
+ <DropdownMenuTrigger
99
+ aria-label={messages.copyPageOptions}
100
+ render={<Button type="button" variant="outline" size="icon-sm" />}
101
+ >
102
+ <ChevronDown aria-hidden="true" />
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent align="end">
105
+ <DropdownMenuLinkItem
106
+ render={<a href={href} target="_blank" rel="noopener noreferrer" />}
107
+ closeOnClick
108
+ >
109
+ <FileText aria-hidden="true" />
110
+ {messages.viewMarkdown}
111
+ <span className="sr-only"> ({messages.opensInNewTab})</span>
112
+ </DropdownMenuLinkItem>
113
+ </DropdownMenuContent>
114
+ </DropdownMenu>
115
+ <span role="status" className="sr-only">
116
+ {status === 'copied'
117
+ ? messages.pageCopied
118
+ : status === 'error'
119
+ ? messages.copyPageError
120
+ : ''}
121
+ </span>
122
+ </ButtonGroup>
123
+ )
124
+ }
@@ -5,6 +5,7 @@ import { useMessages } from '../providers/locale-provider'
5
5
  import { useEffect, useRef, useState } from 'react'
6
6
  import { SidebarClose, SidebarOpen } from 'lucide-react'
7
7
  import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
8
+ import type { RdxPageHighlight } from '@lexibase/rdx-types'
8
9
 
9
10
  import { Button } from './button'
10
11
  import { Separator } from './separator'
@@ -18,7 +19,7 @@ import {
18
19
 
19
20
  export type SidebarCategory = {
20
21
  title: string
21
- links: { href: string; label: string }[]
22
+ links: { href: string; label: string; highlighting?: RdxPageHighlight }[]
22
23
  }
23
24
 
24
25
  export function DocsSidebar({
@@ -73,7 +74,11 @@ export function DocsSidebar({
73
74
  variant="ghost"
74
75
  size="sm"
75
76
  active={pathname === link.href}
76
- aria-label={link.label}
77
+ aria-label={
78
+ link.highlighting
79
+ ? `${link.label} (${link.highlighting === 'important' ? messages.sidebarImportant : messages.sidebarNew})`
80
+ : link.label
81
+ }
77
82
  href={link.href}
78
83
  transitionTypes={
79
84
  currentIndex < 0 || link.href === pathname
@@ -89,6 +94,15 @@ export function DocsSidebar({
89
94
  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'}`}
90
95
  >
91
96
  {link.label}
97
+ {link.highlighting && (
98
+ <span
99
+ aria-hidden="true"
100
+ data-rdx-sidebar-highlight={link.highlighting}
101
+ className={`relative ms-auto size-2 shrink-0 rounded-full bg-current ${link.highlighting === 'important' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'}`}
102
+ >
103
+ <span className="absolute inset-0 rounded-full bg-current opacity-0 motion-safe:animate-[rdx-sidebar-highlight_2.8s_ease-out_infinite]" />
104
+ </span>
105
+ )}
92
106
  </Button>
93
107
  ))}
94
108
  {index < categories.length - 1 && <Separator className="my-4" />}
@@ -116,7 +130,7 @@ export function DocsSidebar({
116
130
  </div>
117
131
  <aside
118
132
  data-rdx-sidebar
119
- className="hidden md:block h-full w-40 lg:w-56 shrink-0 border-r border-border overflow-y-auto p-4"
133
+ className="hidden md:block h-full w-40 lg:w-56 shrink-0 border-r border-border overflow-y-auto scrollbar-none p-4"
120
134
  >
121
135
  {content}
122
136
  </aside>
@@ -128,7 +142,7 @@ export function DocsSidebar({
128
142
  showCloseButton={false}
129
143
  portalProps={{ keepMounted: true }}
130
144
  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!"
145
+ className="md:hidden w-full! max-w-none! h-dvh border-0 bg-background text-foreground gap-0 p-4 overflow-y-auto scrollbar-none translate-none! opacity-100! transition-none!"
132
146
  render={
133
147
  <motion.div
134
148
  initial="closed"
@@ -138,13 +138,17 @@ function PageToc({ contentId }: { contentId: string }) {
138
138
  element.getClientRects().length > 0 &&
139
139
  !!element.id &&
140
140
  !!element.textContent?.trim() &&
141
+ !element.closest('[data-slot="step-panel"][inert]') &&
141
142
  getComputedStyle(element).visibility !== 'hidden'
142
143
  )
143
144
  setHeadings(
144
145
  elements
145
146
  .map((element) => ({
146
147
  id: element.id,
147
- title: element.textContent?.trim() ?? '',
148
+ title:
149
+ (
150
+ element.querySelector('[data-rdx-heading-text]') ?? element
151
+ ).textContent?.trim() ?? '',
148
152
  depth: Number(element.tagName.slice(1)),
149
153
  }))
150
154
  .filter((heading) => heading.title)
@@ -158,7 +162,7 @@ function PageToc({ contentId }: { contentId: string }) {
158
162
  subtree: true,
159
163
  characterData: true,
160
164
  attributes: true,
161
- attributeFilter: ['hidden', 'style', 'class', 'id'],
165
+ attributeFilter: ['hidden', 'style', 'class', 'id', 'inert'],
162
166
  })
163
167
  scrollTarget.addEventListener('scroll', schedule, { passive: true })
164
168
  window.addEventListener('resize', collect)
@@ -0,0 +1,22 @@
1
+ import { Pencil } from 'lucide-react'
2
+
3
+ import { getMessages } from '../lib/i18n'
4
+ import { Button } from './button'
5
+
6
+ export function EditPageLink({ href, lang }: { href: string; lang?: string }) {
7
+ const messages = getMessages(lang)
8
+ return (
9
+ <Button
10
+ variant="link"
11
+ size="link"
12
+ href={href}
13
+ target="_blank"
14
+ rel="noopener noreferrer"
15
+ className="mb-4 self-start text-sm text-muted-foreground hover:text-foreground"
16
+ >
17
+ <Pencil aria-hidden="true" className="me-1.5 inline size-3.5" />
18
+ {messages.editThisPage}
19
+ <span className="sr-only"> ({messages.opensInNewTab})</span>
20
+ </Button>
21
+ )
22
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { Check, Hash } from 'lucide-react'
4
+ import {
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ } from 'react'
11
+
12
+ import { useMessages } from '../providers/locale-provider'
13
+
14
+ export function HeadingPermalink({
15
+ id,
16
+ children,
17
+ }: {
18
+ id: string
19
+ children: ReactNode
20
+ }) {
21
+ const messages = useMessages()
22
+ const [copied, setCopied] = useState(false)
23
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
24
+ useEffect(() => () => clearTimeout(timer.current), [])
25
+
26
+ const href = `#${encodeURIComponent(id)}`
27
+
28
+ async function copy(event: MouseEvent<HTMLAnchorElement>) {
29
+ if (
30
+ event.defaultPrevented ||
31
+ event.metaKey ||
32
+ event.ctrlKey ||
33
+ event.altKey ||
34
+ event.shiftKey ||
35
+ !navigator.clipboard?.writeText
36
+ )
37
+ return
38
+
39
+ try {
40
+ await navigator.clipboard.writeText(event.currentTarget.href)
41
+ } catch {
42
+ // The native link still takes the reader to the section.
43
+ return
44
+ }
45
+
46
+ setCopied(true)
47
+ clearTimeout(timer.current)
48
+ timer.current = setTimeout(() => setCopied(false), 1800)
49
+ }
50
+
51
+ const Icon = copied ? Check : Hash
52
+
53
+ return (
54
+ <>
55
+ <a
56
+ href={href}
57
+ onClick={copy}
58
+ title={messages.sectionPermalink}
59
+ className="rounded-sm text-inherit no-underline underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:outline-ring"
60
+ >
61
+ {children}
62
+ </a>
63
+ <span
64
+ aria-hidden="true"
65
+ className="pointer-events-none ms-1 inline-flex size-6 items-center justify-center align-middle text-muted-foreground pointer-fine:opacity-0 pointer-fine:group-hover/mdx-heading:opacity-100 pointer-fine:group-focus-within/mdx-heading:opacity-100"
66
+ >
67
+ <Icon className="size-4" />
68
+ </span>
69
+ <span role="status" className="sr-only">
70
+ {copied ? messages.sectionLinkCopied : ''}
71
+ </span>
72
+ </>
73
+ )
74
+ }
@@ -106,6 +106,7 @@ export function MdxCodeBlock({
106
106
  if (lineNumbers && node === '\n') return null
107
107
  if (!isValidElement<LineProps>(node)) return node
108
108
  const variant = node.props['data-rdx-line-variant']
109
+ const hasDiffMarker = variant === 'added' || variant === 'removed'
109
110
  if (
110
111
  !variant &&
111
112
  !(lineNumbers && node.props.className?.split(/\s+/).includes('line'))
@@ -124,15 +125,20 @@ export function MdxCodeBlock({
124
125
  return (
125
126
  <CodeBlockLine
126
127
  key={node.key}
128
+ className={
129
+ hasDiffMarker
130
+ ? '[&>[data-slot=code-block-line-content]]:ps-[1ch]'
131
+ : undefined
132
+ }
127
133
  variant={
128
- variant === 'added' || variant === 'removed'
134
+ hasDiffMarker
129
135
  ? variant
130
136
  : variant === 'hunk'
131
137
  ? 'highlighted'
132
138
  : 'default'
133
139
  }
134
140
  >
135
- {variant === 'added' || variant === 'removed'
141
+ {hasDiffMarker
136
142
  ? withoutMarker(node.props.children)
137
143
  : node.props.children}
138
144
  </CodeBlockLine>
@@ -29,6 +29,7 @@ import {
29
29
  } from './code-block'
30
30
  import { MdxCodeBlock } from './mdx-code-block'
31
31
  import { Kbd, KbdGroup } from './kbd'
32
+ import { HeadingPermalink } from './heading-permalink'
32
33
  import { MdxKeyboardShortcut } from './mdx-keyboard-shortcut'
33
34
  import {
34
35
  Table,
@@ -41,6 +42,7 @@ import {
41
42
  TableRow,
42
43
  } from './table'
43
44
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
45
+ import { Steps, Step, StepTitle } from './steps'
44
46
  import { Separator } from '@lexibase/rdx-ui/components/separator'
45
47
 
46
48
  function linkedImages(children: ReactNode): ReactNode {
@@ -71,16 +73,46 @@ function MdxTable({ lang, className, ...props }: ComponentProps<'table'>) {
71
73
  )
72
74
  }
73
75
 
76
+ function containsLink(children: ReactNode): boolean {
77
+ return Children.toArray(children).some(
78
+ (child) =>
79
+ isValidElement<{ href?: string; children?: ReactNode }>(child) &&
80
+ (child.type === 'a' ||
81
+ child.props.href !== undefined ||
82
+ containsLink(child.props.children))
83
+ )
84
+ }
85
+
86
+ function heading(Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', styles: string) {
87
+ return function MdxHeading({
88
+ className,
89
+ children,
90
+ id,
91
+ ...props
92
+ }: ComponentProps<'h2'>) {
93
+ const title = <span data-rdx-heading-text>{children}</span>
94
+ return (
95
+ <Tag
96
+ id={id}
97
+ className={cn('group/mdx-heading', styles, className)}
98
+ {...props}
99
+ >
100
+ {id && !containsLink(children) ? (
101
+ <HeadingPermalink key={id} id={id}>
102
+ {title}
103
+ </HeadingPermalink>
104
+ ) : (
105
+ title
106
+ )}
107
+ </Tag>
108
+ )
109
+ }
110
+ }
111
+
74
112
  export const mdxComponents = {
75
- h1: ({ className, ...props }: ComponentProps<'h1'>) => (
76
- <h1 className={cn('text-4xl font-bold my-6', className)} {...props} />
77
- ),
78
- h2: ({ className, ...props }: ComponentProps<'h2'>) => (
79
- <h2 className={cn('text-2xl font-bold my-6', className)} {...props} />
80
- ),
81
- h3: ({ className, ...props }: ComponentProps<'h3'>) => (
82
- <h3 className={cn('text-xl font-bold my-6', className)} {...props} />
83
- ),
113
+ h1: heading('h1', 'text-4xl font-bold my-6'),
114
+ h2: heading('h2', 'text-2xl font-bold my-6'),
115
+ h3: heading('h3', 'text-xl font-bold my-6'),
84
116
  p: ({ className, ...props }: ComponentProps<'p'>) => (
85
117
  <p className={cn('my-4', className)} {...props} />
86
118
  ),
@@ -122,15 +154,9 @@ export const mdxComponents = {
122
154
  />
123
155
  ),
124
156
 
125
- h4: ({ className, ...props }: ComponentProps<'h4'>) => (
126
- <h4 className={cn('text-lg font-semibold my-4', className)} {...props} />
127
- ),
128
- h5: ({ className, ...props }: ComponentProps<'h5'>) => (
129
- <h5 className={cn('text-base font-semibold my-4', className)} {...props} />
130
- ),
131
- h6: ({ className, ...props }: ComponentProps<'h6'>) => (
132
- <h6 className={cn('text-sm font-semibold my-4', className)} {...props} />
133
- ),
157
+ h4: heading('h4', 'text-lg font-semibold my-4'),
158
+ h5: heading('h5', 'text-base font-semibold my-4'),
159
+ h6: heading('h6', 'text-sm font-semibold my-4'),
134
160
  hr: ({ className, ...props }: ComponentProps<'hr'>) => (
135
161
  <hr className={cn('my-8 border-border', className)} {...props} />
136
162
  ),
@@ -169,6 +195,9 @@ export const mdxComponents = {
169
195
  AccordionItem,
170
196
  AccordionTrigger,
171
197
  AccordionContent,
198
+ Steps,
199
+ Step,
200
+ StepTitle,
172
201
  Tabs,
173
202
  TabsList,
174
203
  TabsTrigger,
@@ -0,0 +1,141 @@
1
+ 'use client'
2
+
3
+ import { motion, useReducedMotion } from 'motion/react'
4
+ import { Children, useEffect, useRef, useState, type ReactNode } from 'react'
5
+
6
+ import { cn } from '../lib/utils'
7
+ import { formatMessage } from '../lib/i18n'
8
+ import { useMessages } from '../providers/locale-provider'
9
+ import { Button } from './button'
10
+ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './card'
11
+
12
+ function StepPanel({
13
+ children,
14
+ active,
15
+ }: {
16
+ children: ReactNode
17
+ active: boolean
18
+ }) {
19
+ const panel = useRef<HTMLDivElement>(null)
20
+ const wasActive = useRef(active)
21
+
22
+ useEffect(() => {
23
+ if (active && !wasActive.current)
24
+ panel.current?.focus({ preventScroll: true })
25
+ wasActive.current = active
26
+ }, [active])
27
+
28
+ return (
29
+ <div
30
+ ref={panel}
31
+ data-slot="step-panel"
32
+ tabIndex={-1}
33
+ inert={!active}
34
+ aria-hidden={!active || undefined}
35
+ className="min-w-0 shrink-0 basis-full focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
36
+ >
37
+ {children}
38
+ </div>
39
+ )
40
+ }
41
+
42
+ export function Steps({ children }: { children: ReactNode }) {
43
+ const messages = useMessages()
44
+ const reduceMotion = useReducedMotion()
45
+ const steps = Children.toArray(children)
46
+ const [page, setPage] = useState(0)
47
+ const index = Math.min(page, steps.length - 1)
48
+ if (index < 0) return null
49
+
50
+ function navigate(direction: number) {
51
+ setPage((current) =>
52
+ Math.max(
53
+ 0,
54
+ Math.min(
55
+ Math.min(current, steps.length - 1) + direction,
56
+ steps.length - 1
57
+ )
58
+ )
59
+ )
60
+ }
61
+
62
+ return (
63
+ <Card
64
+ className="mx-auto my-6 w-full min-w-0 max-w-xl text-base"
65
+ data-slot="steps"
66
+ >
67
+ <CardHeader>
68
+ <div aria-hidden="true" className="flex gap-1.5">
69
+ {steps.map((_, position) => (
70
+ <span
71
+ key={position}
72
+ className={cn(
73
+ 'h-1.5 min-w-0 rounded-full motion-safe:transition-[flex-basis,background-color] motion-safe:duration-200',
74
+ position === index
75
+ ? 'basis-10 bg-rdx-selected'
76
+ : 'basis-2.5 bg-muted-foreground/25'
77
+ )}
78
+ />
79
+ ))}
80
+ </div>
81
+ <p
82
+ role="status"
83
+ className="m-0 mt-2 text-sm font-medium text-muted-foreground"
84
+ >
85
+ {formatMessage(messages.stepProgress, {
86
+ current: String(index + 1),
87
+ total: String(steps.length),
88
+ })}
89
+ </p>
90
+ </CardHeader>
91
+ <CardContent className="min-w-0">
92
+ <div className="min-h-32 overflow-clip">
93
+ <motion.div
94
+ className="flex items-start"
95
+ initial={false}
96
+ animate={{ x: `${-index * 100}%` }}
97
+ transition={{
98
+ duration: reduceMotion ? 0 : 0.24,
99
+ ease: 'easeInOut',
100
+ }}
101
+ >
102
+ {steps.map((step, position) => (
103
+ <StepPanel key={position} active={position === index}>
104
+ {step}
105
+ </StepPanel>
106
+ ))}
107
+ </motion.div>
108
+ </div>
109
+ </CardContent>
110
+ {steps.length > 1 && (
111
+ <CardFooter className="justify-between gap-2">
112
+ <Button
113
+ type="button"
114
+ variant="outline"
115
+ size="sm"
116
+ disabled={index === 0}
117
+ onClick={() => navigate(-1)}
118
+ >
119
+ {messages.previous}
120
+ </Button>
121
+ <Button
122
+ type="button"
123
+ size="sm"
124
+ disabled={index === steps.length - 1}
125
+ onClick={() => navigate(1)}
126
+ >
127
+ {messages.next}
128
+ </Button>
129
+ </CardFooter>
130
+ )}
131
+ </Card>
132
+ )
133
+ }
134
+
135
+ export function Step({ children }: { children: ReactNode }) {
136
+ return <div className="min-w-0 [&>:last-child]:mb-0">{children}</div>
137
+ }
138
+
139
+ export function StepTitle({ children }: { children: ReactNode }) {
140
+ return <CardTitle className="text-lg font-semibold">{children}</CardTitle>
141
+ }
package/src/lib/i18n.ts CHANGED
@@ -31,8 +31,12 @@ const en = {
31
31
  closeSidebar: 'Close sidebar',
32
32
  previous: 'Previous',
33
33
  next: 'Next',
34
+ stepProgress: 'Step {current} of {total}',
34
35
  previousPage: 'Previous page',
35
36
  nextPage: 'Next page',
37
+ sidebarImportant: 'Important',
38
+ sidebarNew: 'New',
39
+ editThisPage: 'Edit this page',
36
40
  onThisPage: 'On this page',
37
41
  search: 'Search…',
38
42
  searchDocumentation: 'Search documentation',
@@ -60,6 +64,14 @@ const en = {
60
64
  copyCodeError: 'Could not copy. Select the code and copy it manually.',
61
65
  copyRetry: 'Could not copy. Try again or copy manually.',
62
66
  copied: 'Copied!',
67
+ copyThisPage: 'Copy this page',
68
+ copyPageOptions: 'Page copy options',
69
+ viewMarkdown: 'View Markdown',
70
+ pageCopied: 'Page Markdown copied to clipboard.',
71
+ copyPageError:
72
+ 'Could not copy the page. Open View Markdown to copy it manually.',
73
+ sectionPermalink: 'Copy link to this section',
74
+ sectionLinkCopied: 'Section link copied to clipboard.',
63
75
  copyTab: 'Copy active tab',
64
76
  documentationTabs: 'Documentation tabs',
65
77
  tabCopied: 'Tab content copied to clipboard.',
@@ -132,8 +144,12 @@ const ptBR = {
132
144
  closeSidebar: 'Fechar menu lateral',
133
145
  previous: 'Anterior',
134
146
  next: 'Próxima',
147
+ stepProgress: 'Etapa {current} de {total}',
135
148
  previousPage: 'Página anterior',
136
149
  nextPage: 'Próxima página',
150
+ sidebarImportant: 'Importante',
151
+ sidebarNew: 'Novo',
152
+ editThisPage: 'Editar esta página',
137
153
  onThisPage: 'Nesta página',
138
154
  search: 'Buscar…',
139
155
  searchDocumentation: 'Buscar na documentação',
@@ -162,6 +178,14 @@ const ptBR = {
162
178
  'Não foi possível copiar. Selecione o código e copie manualmente.',
163
179
  copyRetry: 'Não foi possível copiar. Tente novamente ou copie manualmente.',
164
180
  copied: 'Copiado!',
181
+ copyThisPage: 'Copiar esta página',
182
+ copyPageOptions: 'Opções de cópia da página',
183
+ viewMarkdown: 'Ver Markdown',
184
+ pageCopied: 'Markdown da página copiado para a área de transferência.',
185
+ copyPageError:
186
+ 'Não foi possível copiar a página. Abra Ver Markdown para copiar manualmente.',
187
+ sectionPermalink: 'Copiar link para esta seção',
188
+ sectionLinkCopied: 'Link da seção copiado para a área de transferência.',
165
189
  copyTab: 'Copiar aba ativa',
166
190
  documentationTabs: 'Abas da documentação',
167
191
  tabCopied: 'Conteúdo da aba copiado para a área de transferência.',
@@ -236,6 +236,18 @@
236
236
  }
237
237
  }
238
238
 
239
+ @keyframes rdx-sidebar-highlight {
240
+ 0% {
241
+ opacity: 0.35;
242
+ transform: scale(1);
243
+ }
244
+ 65%,
245
+ 100% {
246
+ opacity: 0;
247
+ transform: scale(2.5);
248
+ }
249
+ }
250
+
239
251
  @keyframes rdx-page-enter {
240
252
  from {
241
253
  opacity: 0;