Coverage for src / lexigram / admin / ui / molecules / inline_edit_cell.py: 25%
32 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Inline table cell editing component.
3Renders a cell value that becomes an input field when clicked.
4Saves automatically on blur or Enter, cancels on Escape.
5Uses HTMX PATCH to update the record without a full page reload.
7Usage::
9 InlineEditCell(
10 value="Alice",
11 resource_url="/admin/users/42",
12 field_name="name",
13 cell_type="text",
14 )
15"""
17from __future__ import annotations
19from typing import Any
21from lexigram.ui import Component, el
24class InlineEditCell(Component):
25 """A table cell whose value can be edited in place.
27 On click the cell switches to an ``<input>`` (or ``<select>`` / ``<textarea>``).
28 On blur / Enter it fires ``PATCH {resource_url}`` with ``{field_name}=<new_value>``.
29 On Escape it discards the change and reverts.
31 Args:
32 value: Current display value.
33 resource_url: URL to PATCH, e.g. ``"/admin/users/42"``.
34 field_name: Form field name to send in the PATCH body.
35 cell_type: ``"text"``, ``"number"``, ``"select"``, or ``"textarea"``.
36 options: For ``cell_type="select"`` — list of ``{"value": …, "label": …}`` dicts.
37 placeholder: Placeholder text for the input.
38 css_class: Additional Tailwind classes on the outer container.
39 editable: When ``False`` renders a plain non-editable cell.
40 """
42 def __init__(
43 self,
44 value: str,
45 resource_url: str,
46 field_name: str,
47 *,
48 cell_type: str = "text",
49 options: list[dict[str, str]] | None = None,
50 placeholder: str = "",
51 css_class: str = "",
52 editable: bool = True,
53 ) -> None:
54 super().__init__()
55 self.value = value
56 self.resource_url = resource_url
57 self.field_name = field_name
58 self.cell_type = cell_type
59 self.options = options or []
60 self.placeholder = placeholder
61 self.css_class = css_class
62 self.editable = editable
64 # ------------------------------------------------------------------
65 # Helpers
66 # ------------------------------------------------------------------
68 def _input_el(self) -> Any:
69 """Return the el() element for the editable input."""
70 base_cls = "w-full px-2 py-1 text-sm rounded border border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 bg-muted dark:text-foreground"
71 escape_js = "if(event.key==='Escape'){this.closest('[data-inline-cell]').querySelector('[data-display]').classList.remove('hidden');this.closest('[data-inline-cell]').querySelector('[data-edit]').classList.add('hidden');}"
72 common: dict[str, Any] = {
73 "name": self.field_name,
74 "class": base_cls,
75 "hx-patch": self.resource_url,
76 "hx-target": "closest [data-inline-cell]",
77 "hx-swap": "outerHTML",
78 "onkeydown": escape_js,
79 }
81 if self.cell_type == "select":
82 opts = [
83 el(
84 "option",
85 o["label"],
86 value=o["value"],
87 **{"selected": "true"} if o["value"] == self.value else {},
88 )
89 for o in self.options
90 ]
91 return el("select", *opts, **{**common, "hx-trigger": "change"})
93 if self.cell_type == "textarea":
94 return el(
95 "textarea",
96 self.value,
97 rows="2",
98 placeholder=self.placeholder,
99 **{**common, "hx-trigger": "blur"},
100 )
102 input_type = "number" if self.cell_type == "number" else "text"
103 return el(
104 "input",
105 type=input_type,
106 value=self.value,
107 placeholder=self.placeholder,
108 **{**common, "hx-trigger": "blur, keyup[key=='Enter']"},
109 )
111 # ------------------------------------------------------------------
112 # Render
113 # ------------------------------------------------------------------
115 def render(self) -> object:
116 """Render the inline-edit cell wrapper."""
117 if not self.editable:
118 return el(
119 "span",
120 self.value,
121 class_=f"text-sm text-foreground {self.css_class}".strip(),
122 )
124 display_el = el(
125 "span",
126 self.value or "—",
127 **{
128 "data-display": "true",
129 "class": "cursor-pointer text-sm text-foreground hover:text-primary-600 dark:hover:text-primary-400 hover:underline",
130 "onclick": "this.closest('[data-inline-cell]').querySelector('[data-display]').classList.add('hidden');this.closest('[data-inline-cell]').querySelector('[data-edit]').classList.remove('hidden');this.closest('[data-inline-cell]').querySelector('input,select,textarea').focus();",
131 },
132 )
133 edit_el = el(
134 "span",
135 self._input_el(),
136 **{
137 "data-edit": "true",
138 "class": "hidden",
139 },
140 )
141 return el(
142 "span",
143 display_el,
144 edit_el,
145 **{
146 "data-inline-cell": "true",
147 "class": f"inline-flex items-center min-w-0 w-full {self.css_class}".strip(),
148 },
149 )
152__all__ = ["InlineEditCell"]