Coverage for src / lexigram / ui / atoms / editors.py: 18%
51 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui.core.base import Component, el
8class RichEditor(Component):
9 """
10 WYSIWYG editor using Trix.
11 Requires Trix JS/CSS to be loaded in the page.
12 """
14 def __init__(
15 self,
16 name: str,
17 value: str | None = None,
18 label: str | None = None,
19 placeholder: str | None = None,
20 error: str | None = None,
21 disabled: bool = False,
22 required: bool = False,
23 min_height: int = 300,
24 toolbar: str | None = None,
25 **props,
26 ) -> None:
27 super().__init__(
28 name=name,
29 value=value,
30 label=label,
31 placeholder=placeholder,
32 error=error,
33 disabled=disabled,
34 required=required,
35 min_height=min_height,
36 toolbar=toolbar,
37 **props,
38 )
39 self.name = name
40 self.value = value or ""
41 self.label = label
42 self.placeholder = placeholder
43 self.error = error
44 self.disabled = disabled
45 self.required = required
46 self.min_height = min_height
47 self.toolbar = toolbar
49 def render(self) -> Any:
50 # Trix uses a hidden input and a trix-editor element
51 input_el = el(
52 "input",
53 type="hidden",
54 name=self.name,
55 id=f"{self.name}_input",
56 value=self.value,
57 )
59 editor_classes = (
60 "block w-full rounded-lg border-0 py-2 px-3 shadow-sm ring-1 ring-inset "
61 "ring-[var(--input)] bg-background "
62 "text-foreground prose dark:prose-invert max-w-none "
63 "focus:ring-2 focus:ring-ring"
64 )
66 editor_kwargs: dict[str, Any] = {
67 "input": f"{self.name}_input",
68 "placeholder": self.placeholder,
69 "class_": editor_classes,
70 "disabled": "" if self.disabled else None,
71 "style": f"min-height: {self.min_height}px",
72 }
73 if self.toolbar is not None:
74 editor_kwargs["data-toolbar"] = self.toolbar
75 editor_el = el("trix-editor", **editor_kwargs)
77 content = el("div", input_el, editor_el)
79 if self.label:
80 return el(
81 "div",
82 el(
83 "label",
84 self.label,
85 for_=f"{self.name}_input",
86 class_="block text-sm font-medium text-foreground opacity-70 mb-1",
87 ),
88 content,
89 (
90 el(
91 "p",
92 self.error,
93 id=f"{self.name}-error",
94 class_="mt-2 text-sm text-destructive",
95 )
96 if self.error
97 else ""
98 ),
99 class_="mb-4",
100 )
102 return content
105class MarkdownEditor(Component):
106 """
107 Markdown editor with optional preview toggle.
108 Uses Alpine.js for client-side preview mode switching.
109 """
111 def __init__(
112 self,
113 name: str,
114 value: str | None = None,
115 label: str | None = None,
116 placeholder: str | None = None,
117 error: str | None = None,
118 rows: int = 10,
119 disabled: bool = False,
120 preview: bool = True,
121 min_height: int = 300,
122 **props,
123 ) -> None:
124 super().__init__(
125 name=name,
126 value=value,
127 label=label,
128 placeholder=placeholder,
129 error=error,
130 rows=rows,
131 disabled=disabled,
132 preview=preview,
133 min_height=min_height,
134 **props,
135 )
136 self.name = name
137 self.value = value or ""
138 self.label = label
139 self.placeholder = placeholder
140 self.error = error
141 self.rows = rows
142 self.disabled = disabled
143 self.preview = preview
144 self.min_height = min_height
146 def render(self) -> Any:
147 textarea_classes = (
148 "block w-full rounded-lg border-0 py-2 px-3 shadow-sm ring-1 "
149 "ring-inset focus:ring-2 focus:ring-inset sm:text-sm transition-all "
150 "duration-200 bg-background text-foreground "
151 "placeholder:text-muted-foreground font-mono "
152 f"{'ring-red-300 focus:ring-red-500' if self.error else 'ring-[var(--input)] focus:ring-ring'}"
153 f"{' opacity-50' if self.disabled else ''}"
154 )
156 escaped_value = self.value.replace("\\", "\\\\").replace("'", "\\'")
158 x_data = (
159 "{ mode: 'edit', get rendered() { "
160 "const el = document.createElement('div'); "
161 "el.textContent = this.$refs.textarea.value || ''; "
162 "return el.innerHTML.replace(/\\n/g, '<br>'); } }"
163 )
165 textarea_el = el(
166 "textarea",
167 self.value,
168 name=self.name,
169 id=self.name,
170 rows=self.rows,
171 placeholder=self.placeholder,
172 disabled=self.disabled,
173 **{
174 "x-ref": "textarea",
175 "x-model": "mode === 'edit' ? undefined : undefined",
176 },
177 class_=textarea_classes,
178 style=f"min-height: {self.min_height}px",
179 )
181 preview_el = el(
182 "div",
183 **{"x-show": "mode === 'preview'", "x-html": "rendered"},
184 class_=(
185 "prose dark:prose-invert max-w-none p-3 rounded-lg border "
186 "border-border bg-background "
187 f"min-h-[{self.min_height}px]"
188 ),
189 )
191 toggle_btn = el(
192 "button",
193 type="button",
194 **{
195 "@click": "mode = mode === 'edit' ? 'preview' : 'edit'",
196 "x-text": "mode === 'edit' ? 'Preview' : 'Edit'",
197 },
198 class_=(
199 "text-xs font-medium px-2.5 py-1 rounded-md transition-colors duration-200 "
200 "text-muted-foreground "
201 "hover:text-foreground "
202 "hover:bg-accent"
203 ),
204 )
206 editor_wrap = el(
207 "div",
208 textarea_el,
209 preview_el,
210 **{"x-show": "mode === 'edit'", "x-cloak": ""},
211 )
213 header = (
214 el(
215 "div",
216 toggle_btn,
217 class_="flex items-center justify-end mb-1",
218 )
219 if self.preview
220 else ""
221 )
223 content = el(
224 "div",
225 header,
226 editor_wrap,
227 **{"x-data": x_data},
228 )
230 if self.label:
231 return el(
232 "div",
233 el(
234 "label",
235 self.label,
236 for_=self.name,
237 class_="block text-sm font-medium text-foreground opacity-70 mb-1",
238 ),
239 content,
240 (
241 el(
242 "p",
243 self.error,
244 id=f"{self.name}-error",
245 class_="mt-2 text-sm text-destructive",
246 )
247 if self.error
248 else ""
249 ),
250 class_="mb-4",
251 )
253 return content