@lexibase/rdx-ui 1.0.0-beta.0 → 1.0.0-beta.2

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,78 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { Check, Copy } from 'lucide-react'
5
+
6
+ import { Button } from './button'
7
+
8
+ export function CopyCodeButton() {
9
+ const [status, setStatus] = useState<'idle' | 'copied' | 'error'>('idle')
10
+ const pending = useRef(false)
11
+ const mounted = useRef(false)
12
+ const reset = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
13
+ useEffect(() => {
14
+ mounted.current = true
15
+ return () => {
16
+ mounted.current = false
17
+ clearTimeout(reset.current)
18
+ }
19
+ }, [])
20
+
21
+ async function copy(button: HTMLButtonElement) {
22
+ if (pending.current) return
23
+ pending.current = true
24
+ clearTimeout(reset.current)
25
+ try {
26
+ const code = button.closest('[data-rdx-code-block]')?.querySelector('pre')
27
+ if (!code || !navigator.clipboard?.writeText)
28
+ throw new Error('Clipboard unavailable')
29
+ await navigator.clipboard.writeText(code.textContent ?? '')
30
+ if (mounted.current) {
31
+ setStatus('copied')
32
+ reset.current = setTimeout(() => setStatus('idle'), 2000)
33
+ }
34
+ } catch {
35
+ if (mounted.current) setStatus('error')
36
+ } finally {
37
+ pending.current = false
38
+ }
39
+ }
40
+
41
+ const Icon = status === 'copied' ? Check : Copy
42
+ return (
43
+ <div className="flex min-w-0 items-center justify-end gap-2">
44
+ <span
45
+ role="status"
46
+ aria-live="polite"
47
+ aria-atomic="true"
48
+ className={
49
+ status === 'error' ? 'text-xs text-muted-foreground' : 'sr-only'
50
+ }
51
+ >
52
+ {status === 'copied'
53
+ ? 'Code copied to clipboard.'
54
+ : status === 'error'
55
+ ? 'Could not copy. Select the code and copy it manually.'
56
+ : ''}
57
+ </span>
58
+ <Button
59
+ type="button"
60
+ variant="outline"
61
+ size="icon"
62
+ data-status={status}
63
+ className="size-7 shrink-0 border-border/70 bg-background/60 p-0 text-muted-foreground shadow-none hover:text-foreground data-[status=copied]:border-emerald-500/30 data-[status=copied]:bg-emerald-500/10 data-[status=copied]:text-emerald-700 dark:data-[status=copied]:text-emerald-400 data-[status=error]:border-amber-500/30 [&>svg]:size-3.5"
64
+ aria-label="Copy code"
65
+ title={
66
+ status === 'copied'
67
+ ? 'Copied!'
68
+ : status === 'error'
69
+ ? 'Could not copy. Try again or copy manually.'
70
+ : 'Copy code'
71
+ }
72
+ onClick={(event) => copy(event.currentTarget)}
73
+ >
74
+ <Icon aria-hidden="true" className="size-4" />
75
+ </Button>
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ // shadcn/ui Base Nova Dialog, using the shared RDX theme and buttons.
4
+ import * as React from 'react'
5
+ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
6
+ import { X } from 'lucide-react'
7
+ import { mergeStateClassName } from '../lib/utils'
8
+ import { Button } from './button'
9
+
10
+ function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
11
+ return <DialogPrimitive.Root {...props} />
12
+ }
13
+ function DialogTrigger(
14
+ props: React.ComponentProps<typeof DialogPrimitive.Trigger>
15
+ ) {
16
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
17
+ }
18
+ function DialogContent({
19
+ className,
20
+ children,
21
+ ...props
22
+ }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
23
+ return (
24
+ <DialogPrimitive.Portal>
25
+ <DialogPrimitive.Backdrop
26
+ data-slot="dialog-overlay"
27
+ 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"
28
+ />
29
+ <DialogPrimitive.Popup
30
+ data-slot="dialog-content"
31
+ className={mergeStateClassName(
32
+ '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',
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ {children}
38
+ <DialogPrimitive.Close
39
+ render={
40
+ <Button
41
+ variant="ghost"
42
+ size="icon"
43
+ className="absolute top-2 right-2 size-7"
44
+ />
45
+ }
46
+ aria-label="Close search"
47
+ >
48
+ <X className="size-3.5" />
49
+ </DialogPrimitive.Close>
50
+ </DialogPrimitive.Popup>
51
+ </DialogPrimitive.Portal>
52
+ )
53
+ }
54
+ function DialogTitle({
55
+ className,
56
+ ...props
57
+ }: React.ComponentProps<typeof DialogPrimitive.Title>) {
58
+ return (
59
+ <DialogPrimitive.Title
60
+ className={mergeStateClassName('font-medium', className)}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+ function DialogDescription({
66
+ className,
67
+ ...props
68
+ }: React.ComponentProps<typeof DialogPrimitive.Description>) {
69
+ return (
70
+ <DialogPrimitive.Description
71
+ className={mergeStateClassName('text-muted-foreground', className)}
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+ export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription }
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import {
4
+ useDeferredValue,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type RefObject,
9
+ } from 'react'
10
+ import { useRouter, usePathname } from 'next/navigation'
11
+ import { FileText, Hash, LoaderCircle } from 'lucide-react'
12
+ import type { RdxSearchIndex } from '@lexibase/rdx-types'
13
+ import {
14
+ prepareSearch,
15
+ searchDocumentation,
16
+ type PreparedSearch,
17
+ } from '../lib/docs-search'
18
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog'
19
+ import {
20
+ Command,
21
+ CommandEmpty,
22
+ CommandInput,
23
+ CommandItem,
24
+ CommandList,
25
+ } from './command'
26
+ import { Button } from './button'
27
+
28
+ const cache = new Map<string, PreparedSearch>()
29
+
30
+ export function DocsSearchDialog({
31
+ indexUrl,
32
+ version,
33
+ open,
34
+ onOpenChange,
35
+ triggerRef,
36
+ }: {
37
+ indexUrl: string
38
+ version: string
39
+ open: boolean
40
+ onOpenChange: (value: boolean) => void
41
+ triggerRef: RefObject<HTMLButtonElement | null>
42
+ }) {
43
+ const router = useRouter()
44
+ const pathname = usePathname()
45
+ const [query, setQuery] = useState('')
46
+ const deferred = useDeferredValue(query)
47
+ const [state, setState] = useState<{
48
+ entries?: PreparedSearch
49
+ error?: boolean
50
+ }>({})
51
+ const [retry, setRetry] = useState(0)
52
+ useEffect(() => {
53
+ if (!open) return
54
+ const controller = new AbortController()
55
+ async function load() {
56
+ try {
57
+ const cached =
58
+ process.env.NODE_ENV === 'production'
59
+ ? cache.get(indexUrl)
60
+ : undefined
61
+ let entries = cached
62
+ if (!entries) {
63
+ const response = await fetch(indexUrl, {
64
+ signal: controller.signal,
65
+ cache: 'no-store',
66
+ })
67
+ if (!response.ok) throw new Error('Search unavailable')
68
+ entries = prepareSearch((await response.json()) as RdxSearchIndex)
69
+ if (process.env.NODE_ENV === 'production') {
70
+ if (cache.size >= 3) cache.delete(cache.keys().next().value!)
71
+ cache.set(indexUrl, entries)
72
+ }
73
+ }
74
+ if (!controller.signal.aborted) setState({ entries })
75
+ } catch {
76
+ if (!controller.signal.aborted) setState({ error: true })
77
+ }
78
+ }
79
+ void load()
80
+ return () => controller.abort()
81
+ }, [open, indexUrl, retry])
82
+ const results = useMemo(
83
+ () => searchDocumentation(state.entries ?? [], deferred),
84
+ [state.entries, deferred]
85
+ )
86
+ const loading = !state.entries && !state.error
87
+ function select(href: string) {
88
+ onOpenChange(false)
89
+ setQuery('')
90
+ // Native fragment navigation also scrolls when already on the target page.
91
+ const url = new URL(href, window.location.origin)
92
+ if (url.pathname === pathname && url.hash) window.location.assign(url.hash)
93
+ else router.push(href)
94
+ }
95
+ return (
96
+ <Dialog open={open} onOpenChange={onOpenChange}>
97
+ <DialogContent finalFocus={triggerRef}>
98
+ <DialogTitle className="sr-only">Search documentation</DialogTitle>
99
+ <DialogDescription className="sr-only">
100
+ Search pages and sections in {version}. Use arrow keys to choose a
101
+ result and Enter to open it.
102
+ </DialogDescription>
103
+ <Command shouldFilter={false} label="Search documentation">
104
+ <CommandInput
105
+ aria-label="Search documentation"
106
+ placeholder="Search documentation…"
107
+ value={query}
108
+ onValueChange={setQuery}
109
+ maxLength={120}
110
+ />
111
+ <div className="px-2 pt-2 pb-1 text-[11px] text-muted-foreground">
112
+ {version}
113
+ </div>
114
+ <CommandList aria-busy={loading}>
115
+ {loading ? (
116
+ <div
117
+ role="status"
118
+ className="flex justify-center gap-2 p-6 text-sm text-muted-foreground"
119
+ >
120
+ <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
121
+ Loading search…
122
+ </div>
123
+ ) : state.error ? (
124
+ <div
125
+ role="status"
126
+ className="p-4 text-center text-sm text-muted-foreground"
127
+ >
128
+ Search is unavailable.{' '}
129
+ <Button
130
+ variant="link"
131
+ size="sm"
132
+ onClick={() => {
133
+ setState({})
134
+ setRetry((value) => value + 1)
135
+ }}
136
+ >
137
+ Try again
138
+ </Button>
139
+ </div>
140
+ ) : !query.trim() ? (
141
+ <div className="px-4 py-6 text-center text-sm text-muted-foreground">
142
+ Type to search this documentation.
143
+ </div>
144
+ ) : (
145
+ <>
146
+ <CommandEmpty>No results found.</CommandEmpty>
147
+ {results.map((entry) => (
148
+ <CommandItem
149
+ key={entry.href}
150
+ value={entry.href}
151
+ onSelect={() => select(entry.href)}
152
+ >
153
+ {entry.section ? (
154
+ <Hash className="size-4 shrink-0 self-start mt-1 text-muted-foreground" />
155
+ ) : (
156
+ <FileText className="size-4 shrink-0 self-start mt-1 text-muted-foreground" />
157
+ )}
158
+ <div className="min-w-0">
159
+ <div className="text-[11px] text-muted-foreground">
160
+ {entry.category}
161
+ {entry.section && ` · ${entry.title}`}
162
+ </div>
163
+ <div className="font-medium leading-5">
164
+ {entry.section || entry.title}
165
+ </div>
166
+ {entry.excerpt && (
167
+ <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
168
+ {entry.excerpt}
169
+ </p>
170
+ )}
171
+ </div>
172
+ </CommandItem>
173
+ ))}
174
+ </>
175
+ )}
176
+ </CommandList>
177
+ <div className="border-t px-2 py-2 text-[10px] text-muted-foreground">
178
+ ↑ ↓ Navigate <span className="mx-2">↵ Open</span> Esc Close
179
+ </div>
180
+ </Command>
181
+ </DialogContent>
182
+ </Dialog>
183
+ )
184
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import { Search } from 'lucide-react'
6
+ import { Button } from './button'
7
+
8
+ const SearchDialog = dynamic(
9
+ () =>
10
+ import('./docs-search-dialog').then((module) => module.DocsSearchDialog),
11
+ { ssr: false }
12
+ )
13
+
14
+ /** Only the small trigger/shortcut is loaded until the first search opens. */
15
+ export function DocsSearch({
16
+ indexUrl,
17
+ version,
18
+ }: {
19
+ indexUrl: string
20
+ version: string
21
+ }) {
22
+ const [open, setOpen] = useState(false)
23
+ const [activated, setActivated] = useState(false)
24
+ const trigger = useRef<HTMLButtonElement | null>(null)
25
+ function show() {
26
+ setActivated(true)
27
+ setOpen(true)
28
+ }
29
+ useEffect(() => {
30
+ function shortcut(event: KeyboardEvent) {
31
+ if (
32
+ event.key.toLowerCase() !== 'k' ||
33
+ (!event.ctrlKey && !event.metaKey) ||
34
+ event.altKey ||
35
+ event.isComposing ||
36
+ event.repeat
37
+ )
38
+ return
39
+ event.preventDefault()
40
+ setActivated(true)
41
+ setOpen((value) => !value)
42
+ }
43
+ document.addEventListener('keydown', shortcut)
44
+ return () => document.removeEventListener('keydown', shortcut)
45
+ }, [])
46
+ return (
47
+ <>
48
+ <Button
49
+ ref={trigger}
50
+ data-rdx-search-trigger
51
+ variant="outline"
52
+ size="sm"
53
+ aria-label="Search documentation"
54
+ aria-haspopup="dialog"
55
+ aria-expanded={open}
56
+ onClick={show}
57
+ className="h-8 gap-2 px-2 text-muted-foreground sm:min-w-40 sm:justify-start"
58
+ >
59
+ <Search className="size-3.5" />
60
+ <span className="hidden sm:inline">Search…</span>
61
+ <kbd
62
+ className="ml-auto hidden rounded border px-1 text-[10px] text-muted-foreground sm:inline"
63
+ aria-hidden="true"
64
+ >
65
+ ⌘ / Ctrl K
66
+ </kbd>
67
+ </Button>
68
+ {activated && (
69
+ <SearchDialog
70
+ key={indexUrl}
71
+ indexUrl={indexUrl}
72
+ version={version}
73
+ open={open}
74
+ onOpenChange={setOpen}
75
+ triggerRef={trigger}
76
+ />
77
+ )}
78
+ </>
79
+ )
80
+ }
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react'
4
4
  import { SidebarClose, SidebarOpen } from 'lucide-react'
5
- import Link from 'next/link'
6
5
 
7
6
  import { Button } from './button'
7
+ import { ButtonLink } from './button-link'
8
8
  import { Separator } from './separator'
9
9
 
10
10
  export type SidebarCategory = {
@@ -19,15 +19,13 @@ export function DocsSidebar({
19
19
  categories: SidebarCategory[]
20
20
  pathname: string
21
21
  }) {
22
- const [isOpen, setIsOpen] = useState(false)
22
+ const [openPath, setOpenPath] = useState<string | null>(null)
23
+ const isOpen = openPath === pathname
23
24
  const dialog = useRef<HTMLDialogElement>(null)
24
25
  useEffect(() => {
25
26
  if (isOpen && !dialog.current?.open) dialog.current?.showModal()
26
27
  if (!isOpen && dialog.current?.open) dialog.current?.close()
27
28
  }, [isOpen])
28
- useEffect(() => {
29
- setIsOpen(false)
30
- }, [pathname])
31
29
  const content = (
32
30
  <nav aria-label="Documentation pages">
33
31
  {categories.map((category, index) => (
@@ -39,24 +37,19 @@ export function DocsSidebar({
39
37
  {category.title}
40
38
  </p>
41
39
  {category.links.map((link) => (
42
- <Button
40
+ <ButtonLink
43
41
  key={link.href}
44
42
  variant="ghost"
45
43
  size="sm"
46
- className="justify-start"
47
44
  active={pathname === link.href}
48
- asChild
49
45
  aria-label={link.label}
46
+ href={link.href}
47
+ onClick={() => setOpenPath(null)}
48
+ aria-current={pathname === link.href ? 'page' : undefined}
49
+ className="justify-start text-sm text-primary"
50
50
  >
51
- <Link
52
- className="text-sm text-primary"
53
- href={link.href}
54
- onClick={() => setIsOpen(false)}
55
- aria-current={pathname === link.href ? 'page' : undefined}
56
- >
57
- {link.label}
58
- </Link>
59
- </Button>
51
+ {link.label}
52
+ </ButtonLink>
60
53
  ))}
61
54
  {index < categories.length - 1 && <Separator className="my-4" />}
62
55
  </div>
@@ -68,7 +61,7 @@ export function DocsSidebar({
68
61
  <div className="md:hidden absolute top-2 left-4 z-30">
69
62
  <Button
70
63
  variant="ghost"
71
- onClick={() => setIsOpen(true)}
64
+ onClick={() => setOpenPath(pathname)}
72
65
  aria-label="Open sidebar"
73
66
  >
74
67
  <SidebarOpen className="size-4" />
@@ -83,13 +76,13 @@ export function DocsSidebar({
83
76
  <dialog
84
77
  ref={dialog}
85
78
  aria-label="Documentation navigation"
86
- onClose={() => setIsOpen(false)}
79
+ onClose={() => setOpenPath(null)}
87
80
  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"
88
81
  >
89
82
  <div className="flex justify-end mb-4">
90
83
  <Button
91
84
  variant="ghost"
92
- onClick={() => setIsOpen(false)}
85
+ onClick={() => setOpenPath(null)}
93
86
  aria-label="Close sidebar"
94
87
  >
95
88
  <SidebarClose className="size-4" />