Barazo default frontend
barazo.forum
1/**
2 * MarkdownEditor - Textarea with WAI-ARIA Toolbar for markdown formatting.
3 * Supports bold, italic, link, code, quote, and list formatting.
4 * Implements roving tabindex for toolbar keyboard navigation.
5 * @see specs/prd-web.md Section 4 (Editor Components)
6 */
7
8'use client'
9
10import { useRef, useState, useCallback } from 'react'
11import { cn } from '@/lib/utils'
12import { FormLabel } from '@/components/ui/form-label'
13import { TOOLBAR_ACTIONS } from '@/components/markdown-toolbar-actions'
14import type { ToolbarAction } from '@/components/markdown-toolbar-actions'
15
16interface MarkdownEditorProps {
17 value: string
18 onChange: (value: string) => void
19 id: string
20 label: string
21 required?: boolean
22 optional?: boolean
23 error?: string
24 className?: string
25 placeholder?: string
26}
27
28export function MarkdownEditor({
29 value,
30 onChange,
31 id,
32 label,
33 required,
34 optional,
35 error,
36 className,
37 placeholder,
38}: MarkdownEditorProps) {
39 const textareaRef = useRef<HTMLTextAreaElement>(null)
40 const toolbarRef = useRef<HTMLDivElement>(null)
41 const [focusedIndex, setFocusedIndex] = useState(0)
42
43 const handleAction = useCallback(
44 (action: ToolbarAction) => {
45 const textarea = textareaRef.current
46 if (!textarea) return
47
48 const start = textarea.selectionStart
49 const end = textarea.selectionEnd
50 const { result, cursor } = action.apply(value, start, end)
51
52 onChange(result)
53
54 requestAnimationFrame(() => {
55 textarea.focus()
56 textarea.setSelectionRange(cursor, cursor)
57 })
58 },
59 [value, onChange]
60 )
61
62 const handlePaste = useCallback(
63 (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
64 const textarea = textareaRef.current
65 if (!textarea) return
66
67 const start = textarea.selectionStart
68 const end = textarea.selectionEnd
69 if (start === end) return // No selection — let default paste happen
70
71 const pasted = e.clipboardData.getData('text/plain')
72 if (!/^https?:\/\/\S+$/.test(pasted)) return // Not a URL
73
74 e.preventDefault()
75 const before = value.slice(0, start)
76 const selected = value.slice(start, end)
77 const after = value.slice(end)
78 const link = `[${selected}](${pasted})`
79 onChange(before + link + after)
80
81 const cursorPos = start + link.length
82 requestAnimationFrame(() => {
83 textarea.focus()
84 textarea.setSelectionRange(cursorPos, cursorPos)
85 })
86 },
87 [value, onChange]
88 )
89
90 const handleToolbarKeyDown = useCallback(
91 (e: React.KeyboardEvent<HTMLDivElement>) => {
92 const buttons = toolbarRef.current?.querySelectorAll<HTMLButtonElement>('button')
93 if (!buttons?.length) return
94
95 let newIndex = focusedIndex
96
97 if (e.key === 'ArrowRight') {
98 e.preventDefault()
99 newIndex = (focusedIndex + 1) % buttons.length
100 } else if (e.key === 'ArrowLeft') {
101 e.preventDefault()
102 newIndex = (focusedIndex - 1 + buttons.length) % buttons.length
103 } else if (e.key === 'Home') {
104 e.preventDefault()
105 newIndex = 0
106 } else if (e.key === 'End') {
107 e.preventDefault()
108 newIndex = buttons.length - 1
109 } else {
110 return
111 }
112
113 setFocusedIndex(newIndex)
114 buttons[newIndex]?.focus()
115 },
116 [focusedIndex]
117 )
118
119 const errorId = error ? `${id}-error` : undefined
120
121 return (
122 <div className={cn('space-y-1', className)}>
123 <FormLabel htmlFor={id} required={required} optional={optional}>
124 {label}
125 </FormLabel>
126
127 <div
128 ref={toolbarRef}
129 role="toolbar"
130 aria-label="Formatting"
131 aria-controls={id}
132 onKeyDown={handleToolbarKeyDown}
133 className="flex items-center gap-0.5 rounded-t-md border border-b-0 border-border bg-muted/50 px-1 py-1"
134 >
135 {TOOLBAR_ACTIONS.map((action, index) => {
136 const Icon = action.icon
137 return (
138 <button
139 key={action.label}
140 type="button"
141 aria-label={action.label}
142 tabIndex={index === focusedIndex ? 0 : -1}
143 onClick={() => handleAction(action)}
144 onFocus={() => setFocusedIndex(index)}
145 className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
146 >
147 <Icon className="h-4 w-4" weight="bold" aria-hidden="true" />
148 </button>
149 )
150 })}
151 </div>
152
153 <textarea
154 ref={textareaRef}
155 id={id}
156 value={value}
157 onChange={(e) => onChange(e.target.value)}
158 onPaste={handlePaste}
159 placeholder={placeholder ?? 'Write your content using Markdown...'}
160 required={required}
161 aria-invalid={error ? 'true' : undefined}
162 aria-describedby={errorId}
163 className={cn(
164 'block w-full rounded-b-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground',
165 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
166 'min-h-[200px] resize-y font-mono',
167 error && 'border-destructive'
168 )}
169 />
170
171 {error && (
172 <p id={errorId} className="text-sm text-destructive" role="alert">
173 {error}
174 </p>
175 )}
176 </div>
177 )
178}