Coverage for src / lexigram / ui / atoms / button.py: 100%
39 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, Literal
5from lexigram.ui.core.base import Component, el
7ButtonVariant = Literal[
8 "default",
9 "secondary",
10 "destructive",
11 "outline",
12 "ghost",
13 "link",
14]
16ButtonSize = Literal["default", "xs", "sm", "lg", "xl", "icon"]
19_VARIANT_CLASSES: dict[str, str] = {
20 "default": (
21 "bg-primary text-primary-foreground hover:bg-primary/90 "
22 "shadow-sm"
23 ),
24 "secondary": (
25 "bg-secondary text-secondary-foreground hover:bg-secondary/80 "
26 "shadow-sm"
27 ),
28 "destructive": (
29 "bg-destructive text-destructive-foreground hover:bg-destructive/90 "
30 "shadow-sm"
31 ),
32 "outline": (
33 "border border-input bg-background hover:bg-accent hover:text-accent-foreground "
34 "shadow-sm"
35 ),
36 "ghost": (
37 "hover:bg-accent hover:text-accent-foreground"
38 ),
39 "link": (
40 "text-primary underline-offset-4 hover:underline"
41 ),
42}
44_SIZE_CLASSES: dict[str, str] = {
45 "default": "h-9 px-4 py-2",
46 "xs": "h-7 rounded-md px-2 text-xs",
47 "sm": "h-8 rounded-md px-3 text-xs",
48 "lg": "h-10 rounded-md px-8",
49 "xl": "h-12 rounded-md px-10 text-base",
50 "icon": "h-9 w-9",
51}
53_BASE_CLASSES = (
54 "inline-flex items-center justify-center whitespace-nowrap "
55 "rounded-md text-sm font-medium "
56 "transition-colors "
57 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 "
58 "disabled:pointer-events-none disabled:opacity-50 "
59)
62class Button(Component):
63 """Button with ShadCN-compatible variants and sizes.
65 Args:
66 label: Button text.
67 variant: Visual style variant.
68 size: Size variant.
69 as_child: If True, renders as first child element.
70 **props: Additional HTML attributes.
71 """
73 def __init__(
74 self,
75 label: str = "",
76 *,
77 variant: ButtonVariant = "default",
78 size: ButtonSize = "default",
79 as_child: bool = False,
80 **props: Any,
81 ) -> None:
82 super().__init__(as_child=as_child, **props)
83 self.label = label
84 self.variant = variant
85 self.size = size
87 def render(self) -> Any:
88 variant_cls = _VARIANT_CLASSES.get(self.variant, _VARIANT_CLASSES["default"])
89 size_cls = _SIZE_CLASSES.get(self.size, _SIZE_CLASSES["default"])
91 cls = " ".join(filter(None, [_BASE_CLASSES, variant_cls, size_cls]))
93 custom_cls = self.props.get("class_") or self.props.get("class")
94 if custom_cls:
95 cls = f"{cls} {custom_cls}"
97 extra_attrs = {
98 k: v
99 for k, v in self.props.items()
100 if k not in ("class_", "class", "children")
101 }
102 extra_attrs.pop("label", None)
103 extra_attrs.pop("type", None)
105 return el("button", self.label, class_=cls, type="button", **extra_attrs)
108class SubmitButton(Component):
109 """Submit button with automatic loading state.
111 Args:
112 label: Button text.
113 variant: Same as Button.
114 size: Same as Button.
115 disabled: Whether button is disabled.
116 **props: Additional attributes.
117 """
119 def __init__(
120 self,
121 label: str = "Submit",
122 *,
123 loading_label: str = "Submitting...",
124 variant: ButtonVariant = "default",
125 size: ButtonSize = "default",
126 disabled: bool = False,
127 **props: Any,
128 ) -> None:
129 super().__init__(**props)
130 self.label = label
131 self.loading_label = loading_label
132 self.variant = variant
133 self.size = size
134 self.disabled = disabled
136 def render(self) -> Any:
137 from lexigram.ui.atoms.spinner import Spinner
139 variant_cls = _VARIANT_CLASSES.get(self.variant, _VARIANT_CLASSES["default"])
140 size_cls = _SIZE_CLASSES.get(self.size, _SIZE_CLASSES["default"])
141 cls = " ".join(filter(None, [_BASE_CLASSES, variant_cls, size_cls]))
143 return el(
144 "button",
145 el(
146 "span",
147 self.label,
148 x_show="!loading",
149 class_="inline-flex items-center",
150 ),
151 el(
152 "span",
153 el(
154 "span",
155 Spinner(size="sm"),
156 class_="inline-block mr-2",
157 ),
158 self.loading_label,
159 x_show="loading",
160 x_cloak=True,
161 class_="inline-flex items-center",
162 ),
163 type="submit",
164 class_=cls,
165 disabled=self.disabled,
166 x_data="{ loading: false }",
167 x_on_click="loading = true",
168 x_on_htmx_after_request="loading = false",
169 )