Barazo default frontend barazo.forum
at main 178 lines 5.4 kB view raw
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}