refactor: generalize Checkbox and add Radio primitive component

This commit is contained in:
2026-06-09 19:55:01 +02:00
parent dd2ebe5888
commit af62120c8d
4 changed files with 217 additions and 0 deletions
+4
View File
@@ -36,6 +36,7 @@ from common.components.primitives import (
AddForm,
Button,
ButtonGroup,
Checkbox,
CsrfInput,
Div,
ExternalScript,
@@ -48,6 +49,7 @@ from common.components.primitives import (
Pill,
Popover,
PopoverTruncated,
Radio,
SearchField,
SimpleTable,
Span,
@@ -82,6 +84,7 @@ __all__ = [
"AddForm",
"Button",
"ButtonGroup",
"Checkbox",
"CsrfInput",
"Div",
"ExternalScript",
@@ -93,6 +96,7 @@ __all__ = [
"Pill",
"Popover",
"PopoverTruncated",
"Radio",
"SearchField",
"DEFAULT_PREFETCH",
"FilterSelect",
+62
View File
@@ -401,6 +401,68 @@ def Label(
return Component(tag_name="label", attributes=attributes, children=children)
def Checkbox(
name: str,
label: str,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
(
"class",
"rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[
("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")
],
children=[
Input(type="checkbox", attributes=input_attrs),
label,
],
)
def Radio(
name: str,
label: str,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
(
"class",
"rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[
("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")
],
children=[
Input(type="radio", attributes=input_attrs),
label,
],
)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
+129
View File
@@ -293,27 +293,85 @@
--leading-5: 20px;
--radius-base: 12px;
--color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--color-fg-brand: var(--color-blue-700);
--color-fg-brand-strong: var(--color-blue-900);
--color-fg-success: var(--color-emerald-700);
--color-fg-success-strong: var(--color-emerald-900);
--color-fg-danger: var(--color-rose-700);
--color-fg-danger-strong: var(--color-rose-900);
--color-fg-warning-subtle: var(--color-orange-600);
--color-fg-warning: var(--color-orange-900);
--color-fg-yellow: var(--color-yellow-400);
--color-fg-disabled: var(--color-gray-400);
--color-fg-purple: var(--color-purple-600);
--color-fg-cyan: var(--color-cyan-600);
--color-fg-indigo: var(--color-indigo-600);
--color-fg-pink: var(--color-pink-600);
--color-fg-lime: var(--color-lime-600);
--color-neutral-primary-soft: var(--color-white);
--color-neutral-primary: var(--color-white);
--color-neutral-primary-medium: var(--color-white);
--color-neutral-primary-strong: var(--color-white);
--color-neutral-secondary-soft: var(--color-gray-50);
--color-neutral-secondary: var(--color-gray-50);
--color-neutral-secondary-medium: var(--color-gray-50);
--color-neutral-secondary-strong: var(--color-gray-50);
--color-neutral-secondary-strongest: var(--color-gray-50);
--color-neutral-tertiary-soft: var(--color-gray-100);
--color-neutral-tertiary: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200);
--color-neutral-quaternary-medium: var(--color-gray-200);
--color-gray: var(--color-gray-300);
--color-brand-softer: var(--color-blue-50);
--color-brand-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800);
--color-success-soft: var(--color-emerald-50);
--color-success: var(--color-emerald-700);
--color-success-medium: var(--color-emerald-100);
--color-success-strong: var(--color-emerald-800);
--color-danger-soft: var(--color-rose-50);
--color-danger: var(--color-rose-700);
--color-danger-medium: var(--color-rose-100);
--color-danger-strong: var(--color-rose-800);
--color-warning-soft: var(--color-orange-50);
--color-warning: var(--color-orange-500);
--color-warning-medium: var(--color-orange-100);
--color-warning-strong: var(--color-orange-700);
--color-dark-soft: var(--color-gray-800);
--color-dark: var(--color-gray-800);
--color-dark-strong: var(--color-gray-900);
--color-disabled: var(--color-gray-100);
--color-purple: var(--color-purple-500);
--color-sky: var(--color-sky-500);
--color-teal: var(--color-teal-600);
--color-pink: var(--color-pink-600);
--color-cyan: var(--color-cyan-500);
--color-fuchsia: var(--color-fuchsia-600);
--color-indigo: var(--color-indigo-600);
--color-orange: var(--color-orange-400);
--color-buffer: var(--color-white);
--color-buffer-medium: var(--color-white);
--color-buffer-strong: var(--color-white);
--color-muted: var(--color-gray-50);
--color-light-subtle: var(--color-gray-100);
--color-light: var(--color-gray-100);
--color-light-medium: var(--color-gray-100);
--color-default-subtle: var(--color-gray-200);
--color-default: var(--color-gray-200);
--color-default-medium: var(--color-gray-200);
--color-default-strong: var(--color-gray-200);
--color-success-subtle: var(--color-emerald-200);
--color-danger-subtle: var(--color-rose-200);
--color-warning-subtle: var(--color-orange-200);
--color-brand-subtle: var(--color-blue-200);
--color-brand-light: var(--color-blue-600);
--color-dark-subtle: var(--color-gray-800);
--color-dark-backdrop: var(--color-gray-950);
--color-accent: #7c3aed;
}
@@ -823,12 +881,18 @@
.start-0 {
inset-inline-start: calc(var(--spacing) * 0);
}
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5);
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -850,6 +914,9 @@
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5);
}
@@ -1476,6 +1543,9 @@
.h-8 {
height: calc(var(--spacing) * 8);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
@@ -1559,9 +1629,15 @@
text-align: center;
}
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 {
width: calc(1 / 2 * 100%);
}
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 {
width: calc(var(--spacing) * 2.5);
}
@@ -1679,6 +1755,9 @@
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1695,6 +1774,10 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1779,6 +1862,9 @@
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
@@ -1791,6 +1877,9 @@
.gap-5 {
gap: calc(var(--spacing) * 5);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -2080,12 +2169,18 @@
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-amber-500 {
background-color: var(--color-amber-500);
}
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2110,6 +2205,9 @@
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
.bg-dark-backdrop {
background-color: var(--color-dark-backdrop);
}
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2128,12 +2226,18 @@
.bg-gray-500 {
background-color: var(--color-gray-500);
}
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
}
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.bg-gray-900\/50 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2263,6 +2367,18 @@
fill: white !important;
}
}
.apexcharts-gridline {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-xcrosshairs {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-ycrosshairs {
stroke: var(--color-default) !important;
.dark & {
@@ -2321,6 +2437,9 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -2547,6 +2666,9 @@
.text-balance {
text-wrap: balance;
}
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -2682,6 +2804,9 @@
.line-through {
text-decoration-line: line-through;
}
.no-underline {
text-decoration-line: none;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2748,6 +2873,10 @@
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+22
View File
@@ -821,5 +821,27 @@ class SimpleTableRenderingTest(unittest.TestCase):
self.assertIn("2025-01-01", tbody)
from django.test import SimpleTestCase
from common.components.primitives import Checkbox, Radio
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
self.assertIn('checked="true"', html)
self.assertIn("Accept Terms", html)
def test_radio_primitive(self):
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
self.assertNotIn('checked="true"', html)
self.assertIn("Option A", html)
if __name__ == "__main__":
unittest.main()