@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.
@@ -1,79 +1,166 @@
1
1
  'use client'
2
2
 
3
- import { useMessages } from '../providers/locale-provider'
4
-
5
- // shadcn/ui Base Nova Dialog, using the shared RDX theme and buttons.
6
3
  import * as React from 'react'
7
4
  import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
8
- import { X } from 'lucide-react'
9
- import { mergeStateClassName } from '../lib/utils'
5
+ import { cn, mergeStateClassName } from '../lib/utils'
6
+
10
7
  import { Button } from './button'
8
+ import { XIcon } from 'lucide-react'
9
+ import { useMessages } from '../providers/locale-provider'
11
10
 
12
- function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
13
- return <DialogPrimitive.Root {...props} />
11
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
12
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
14
13
  }
15
- function DialogTrigger(
16
- props: React.ComponentProps<typeof DialogPrimitive.Trigger>
17
- ) {
14
+
15
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
18
16
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19
17
  }
18
+
19
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
20
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
21
+ }
22
+
23
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
24
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
25
+ }
26
+
27
+ function DialogOverlay({
28
+ className,
29
+ ...props
30
+ }: DialogPrimitive.Backdrop.Props) {
31
+ return (
32
+ <DialogPrimitive.Backdrop
33
+ data-slot="dialog-overlay"
34
+ className={mergeStateClassName(
35
+ 'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ )
41
+ }
42
+
20
43
  function DialogContent({
21
44
  className,
22
45
  children,
46
+ showCloseButton = true,
47
+ closeLabel,
48
+ overlayClassName,
23
49
  ...props
24
- }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
50
+ }: DialogPrimitive.Popup.Props & {
51
+ showCloseButton?: boolean
52
+ closeLabel?: string
53
+ overlayClassName?: DialogPrimitive.Backdrop.Props['className']
54
+ }) {
25
55
  const messages = useMessages()
26
56
  return (
27
- <DialogPrimitive.Portal>
28
- <DialogPrimitive.Backdrop
29
- data-slot="dialog-overlay"
30
- className="fixed inset-0 isolate z-50 bg-black/30 backdrop-blur-xs duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none"
31
- />
57
+ <DialogPortal>
58
+ <DialogOverlay className={overlayClassName} />
32
59
  <DialogPrimitive.Popup
33
60
  data-slot="dialog-content"
34
61
  className={mergeStateClassName(
35
- 'fixed top-[20%] left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 gap-4 rounded-xl bg-popover p-1 text-sm text-popover-foreground ring-1 ring-foreground/10 shadow-lg outline-none duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
62
+ 'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
36
63
  className
37
64
  )}
38
65
  {...props}
39
66
  >
40
67
  {children}
41
- <DialogPrimitive.Close
42
- render={
43
- <Button
44
- variant="ghost"
45
- size="icon"
46
- className="absolute top-2 right-2 size-7"
47
- />
48
- }
49
- aria-label={messages.closeSearch}
50
- >
51
- <X className="size-3.5" />
52
- </DialogPrimitive.Close>
68
+ {showCloseButton && (
69
+ <DialogPrimitive.Close
70
+ data-slot="dialog-close"
71
+ render={
72
+ <Button
73
+ variant="ghost"
74
+ className="absolute top-2 right-2"
75
+ size="icon-sm"
76
+ />
77
+ }
78
+ >
79
+ <XIcon />
80
+ <span className="sr-only">{closeLabel ?? messages.close}</span>
81
+ </DialogPrimitive.Close>
82
+ )}
53
83
  </DialogPrimitive.Popup>
54
- </DialogPrimitive.Portal>
84
+ </DialogPortal>
85
+ )
86
+ }
87
+
88
+ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
89
+ return (
90
+ <div
91
+ data-slot="dialog-header"
92
+ className={cn('flex flex-col gap-2', className)}
93
+ {...props}
94
+ />
55
95
  )
56
96
  }
57
- function DialogTitle({
97
+
98
+ function DialogFooter({
58
99
  className,
100
+ showCloseButton = false,
101
+ children,
59
102
  ...props
60
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
103
+ }: React.ComponentProps<'div'> & {
104
+ showCloseButton?: boolean
105
+ }) {
106
+ const messages = useMessages()
107
+ return (
108
+ <div
109
+ data-slot="dialog-footer"
110
+ className={cn(
111
+ '-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
112
+ className
113
+ )}
114
+ {...props}
115
+ >
116
+ {children}
117
+ {showCloseButton && (
118
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
119
+ {messages.close}
120
+ </DialogPrimitive.Close>
121
+ )}
122
+ </div>
123
+ )
124
+ }
125
+
126
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
61
127
  return (
62
128
  <DialogPrimitive.Title
63
- className={mergeStateClassName('font-medium', className)}
129
+ data-slot="dialog-title"
130
+ className={mergeStateClassName(
131
+ 'cn-font-heading text-base leading-none font-medium',
132
+ className
133
+ )}
64
134
  {...props}
65
135
  />
66
136
  )
67
137
  }
138
+
68
139
  function DialogDescription({
69
140
  className,
70
141
  ...props
71
- }: React.ComponentProps<typeof DialogPrimitive.Description>) {
142
+ }: DialogPrimitive.Description.Props) {
72
143
  return (
73
144
  <DialogPrimitive.Description
74
- className={mergeStateClassName('text-muted-foreground', className)}
145
+ data-slot="dialog-description"
146
+ className={mergeStateClassName(
147
+ 'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
148
+ className
149
+ )}
75
150
  {...props}
76
151
  />
77
152
  )
78
153
  }
79
- export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription }
154
+
155
+ export {
156
+ Dialog,
157
+ DialogClose,
158
+ DialogContent,
159
+ DialogDescription,
160
+ DialogFooter,
161
+ DialogHeader,
162
+ DialogOverlay,
163
+ DialogPortal,
164
+ DialogTitle,
165
+ DialogTrigger,
166
+ }
@@ -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
+ }
@@ -99,7 +99,12 @@ export function DocsSearchDialog({
99
99
  }
100
100
  return (
101
101
  <Dialog open={open} onOpenChange={onOpenChange}>
102
- <DialogContent finalFocus={triggerRef}>
102
+ <DialogContent
103
+ finalFocus={triggerRef}
104
+ closeLabel={messages.closeSearch}
105
+ overlayClassName="bg-black/30"
106
+ className="top-[20%] w-[calc(100%-2rem)] max-w-lg translate-y-0 p-1 shadow-lg sm:max-w-lg"
107
+ >
103
108
  <DialogTitle className="sr-only">
104
109
  {messages.searchDocumentation}
105
110
  </DialogTitle>
@@ -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,13 +29,34 @@ 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) => (
@@ -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={`justify-start text-sm ${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
  ))}