htmui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmui/__init__.py +1 -0
- htmui/basecoat/__init__.py +0 -0
- htmui/basecoat/accordion.py +76 -0
- htmui/basecoat/alert.py +44 -0
- htmui/basecoat/avatar.py +52 -0
- htmui/basecoat/badge.py +32 -0
- htmui/basecoat/breadcrumb.py +46 -0
- htmui/basecoat/button.py +36 -0
- htmui/basecoat/button_group.py +22 -0
- htmui/basecoat/card.py +32 -0
- htmui/basecoat/cdn.py +14 -0
- htmui/basecoat/chart.py +86 -0
- htmui/basecoat/codeblock.py +109 -0
- htmui/basecoat/combobox.py +190 -0
- htmui/basecoat/command.py +128 -0
- htmui/basecoat/dialog.py +165 -0
- htmui/basecoat/drawer.py +66 -0
- htmui/basecoat/dropdown_menu.py +59 -0
- htmui/basecoat/field.py +91 -0
- htmui/basecoat/input_group.py +49 -0
- htmui/basecoat/item.py +94 -0
- htmui/basecoat/menu.py +131 -0
- htmui/basecoat/pagination.py +60 -0
- htmui/basecoat/popover.py +69 -0
- htmui/basecoat/progress.py +57 -0
- htmui/basecoat/radio_group.py +83 -0
- htmui/basecoat/select.py +190 -0
- htmui/basecoat/separator.py +8 -0
- htmui/basecoat/sidebar.py +270 -0
- htmui/basecoat/slider.py +50 -0
- htmui/basecoat/spinner.py +43 -0
- htmui/basecoat/switch.py +50 -0
- htmui/basecoat/table.py +38 -0
- htmui/basecoat/tabs.py +109 -0
- htmui/basecoat/theme_switcher.py +58 -0
- htmui/basecoat/toast.py +97 -0
- htmui/basecoat/typing.py +21 -0
- htmui/cli.py +257 -0
- htmui-0.1.0.dist-info/METADATA +159 -0
- htmui-0.1.0.dist-info/RECORD +43 -0
- htmui-0.1.0.dist-info/WHEEL +4 -0
- htmui-0.1.0.dist-info/entry_points.txt +5 -0
- htmui-0.1.0.dist-info/licenses/LICENSE +21 -0
htmui/basecoat/tabs.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import Literal, TypeAlias
|
|
2
|
+
|
|
3
|
+
from htmy import ComponentSequence, ComponentType, PropertyValue, SafeStr, html, join_classes
|
|
4
|
+
|
|
5
|
+
__version__ = "0.2.0"
|
|
6
|
+
__framework__ = "BasecoatUI"
|
|
7
|
+
__framework_version__ = "1"
|
|
8
|
+
__framework_url__ = "https://basecoatui.com/components/tabs/"
|
|
9
|
+
|
|
10
|
+
js = SafeStr(
|
|
11
|
+
'<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1/dist/js/tabs.min.js" defer></script>'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Orientation: TypeAlias = Literal["horizontal", "vertical"]
|
|
15
|
+
|
|
16
|
+
TabsVariant: TypeAlias = Literal["line"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tab_button(
|
|
20
|
+
*children: ComponentType,
|
|
21
|
+
id: str,
|
|
22
|
+
panel_id: str,
|
|
23
|
+
selected: bool = False,
|
|
24
|
+
class_: str | None = None,
|
|
25
|
+
**kwargs: PropertyValue,
|
|
26
|
+
) -> ComponentType:
|
|
27
|
+
"""
|
|
28
|
+
Tab button.
|
|
29
|
+
|
|
30
|
+
Arguments:
|
|
31
|
+
*children: Button content.
|
|
32
|
+
id: Button ID.
|
|
33
|
+
panel_id: The ID of the controlled tab panel.
|
|
34
|
+
selected: Whether this tab is active.
|
|
35
|
+
class_: Extra CSS classes for the button.
|
|
36
|
+
**kwargs: Extra attributes for the button.
|
|
37
|
+
"""
|
|
38
|
+
return html.button(
|
|
39
|
+
*children,
|
|
40
|
+
type="button",
|
|
41
|
+
role="tab",
|
|
42
|
+
id=id,
|
|
43
|
+
aria_controls=panel_id,
|
|
44
|
+
aria_selected="true" if selected else "false",
|
|
45
|
+
tabindex="0" if selected else "-1",
|
|
46
|
+
class_=class_,
|
|
47
|
+
**kwargs,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tab_panel(
|
|
52
|
+
*children: ComponentType,
|
|
53
|
+
id: str,
|
|
54
|
+
button_id: str,
|
|
55
|
+
selected: bool = False,
|
|
56
|
+
class_: str | None = None,
|
|
57
|
+
**kwargs: PropertyValue,
|
|
58
|
+
) -> ComponentType:
|
|
59
|
+
"""
|
|
60
|
+
Tab panel.
|
|
61
|
+
|
|
62
|
+
Arguments:
|
|
63
|
+
*children: Panel content.
|
|
64
|
+
id: Panel ID.
|
|
65
|
+
button_id: The ID of the button that controls this tab panel.
|
|
66
|
+
selected: Whether this panel is active.
|
|
67
|
+
class_: Extra CSS classes for the panel.
|
|
68
|
+
**kwargs: Extra attributes for the panel.
|
|
69
|
+
"""
|
|
70
|
+
if not selected:
|
|
71
|
+
kwargs["hidden"] = ""
|
|
72
|
+
|
|
73
|
+
return html.div(
|
|
74
|
+
*children,
|
|
75
|
+
role="tabpanel",
|
|
76
|
+
id=id,
|
|
77
|
+
aria_labelledby=button_id,
|
|
78
|
+
tabindex="-1",
|
|
79
|
+
aria_selected="true" if selected else "false",
|
|
80
|
+
class_=class_,
|
|
81
|
+
**kwargs,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def tabs(
|
|
86
|
+
*panels: ComponentType,
|
|
87
|
+
buttons: ComponentSequence,
|
|
88
|
+
orientation: Orientation = "horizontal",
|
|
89
|
+
variant: TabsVariant | None = None,
|
|
90
|
+
class_: str | None = None,
|
|
91
|
+
**kwargs: PropertyValue,
|
|
92
|
+
) -> ComponentType:
|
|
93
|
+
"""
|
|
94
|
+
Tabs root with a tablist and panels.
|
|
95
|
+
|
|
96
|
+
Arguments:
|
|
97
|
+
*panels: Tab panels.
|
|
98
|
+
buttons: Sequence of tab buttons.
|
|
99
|
+
orientation: Tablist orientation.
|
|
100
|
+
variant: Optional tablist style.
|
|
101
|
+
class_: Extra CSS classes for the root element.
|
|
102
|
+
**kwargs: Extra attributes for the root element.
|
|
103
|
+
"""
|
|
104
|
+
return html.div(
|
|
105
|
+
html.nav(*buttons, role="tablist", aria_orientation=orientation, data_variant=variant),
|
|
106
|
+
*panels,
|
|
107
|
+
class_=join_classes("tabs", class_),
|
|
108
|
+
**kwargs,
|
|
109
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from htmy import ComponentType, SafeStr, html
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
__framework__ = "BasecoatUI"
|
|
5
|
+
__framework_version__ = "1"
|
|
6
|
+
__framework_url__ = "https://basecoatui.com/components/theme-switcher/"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
light_icon = SafeStr(
|
|
10
|
+
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
|
|
11
|
+
'stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" '
|
|
12
|
+
'd="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 '
|
|
13
|
+
"18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 "
|
|
14
|
+
'12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>'
|
|
15
|
+
)
|
|
16
|
+
"""`sun` icon from https://heroicons.com/."""
|
|
17
|
+
|
|
18
|
+
dark_icon = SafeStr(
|
|
19
|
+
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
|
|
20
|
+
'stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" '
|
|
21
|
+
'd="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 '
|
|
22
|
+
"0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 "
|
|
23
|
+
'12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>'
|
|
24
|
+
)
|
|
25
|
+
"""`moon` icon from https://heroicons.com/."""
|
|
26
|
+
|
|
27
|
+
js = SafeStr(
|
|
28
|
+
"""<script>
|
|
29
|
+
(() => {
|
|
30
|
+
try {
|
|
31
|
+
const stored = localStorage.getItem("themeMode");
|
|
32
|
+
if (stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
33
|
+
document.documentElement.classList.add("dark");
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
})();
|
|
37
|
+
</script>"""
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def theme_switcher(
|
|
42
|
+
*,
|
|
43
|
+
class_: str = "btn size-8",
|
|
44
|
+
variant: str = "outline",
|
|
45
|
+
size: str = "icon",
|
|
46
|
+
) -> ComponentType:
|
|
47
|
+
return html.button(
|
|
48
|
+
html.span(light_icon, class_="hidden dark:block"),
|
|
49
|
+
html.span(dark_icon, class_="block dark:hidden"),
|
|
50
|
+
type="button",
|
|
51
|
+
aria_label="Toggle theme",
|
|
52
|
+
class_=class_,
|
|
53
|
+
data_tooltip="Toggle theme",
|
|
54
|
+
data_side="bottom",
|
|
55
|
+
data_size=size,
|
|
56
|
+
data_variant=variant,
|
|
57
|
+
onclick="window.basecoat.theme.toggle()",
|
|
58
|
+
)
|
htmui/basecoat/toast.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from htmy import ComponentType, Fragment, PropertyValue, SafeStr, html, join_classes
|
|
4
|
+
|
|
5
|
+
from .typing import Align
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
__framework__ = "BasecoatUI"
|
|
9
|
+
__framework_version__ = "1"
|
|
10
|
+
__framework_url__ = "https://basecoatui.com/components/toast/"
|
|
11
|
+
|
|
12
|
+
ToastCategory = Literal["success", "info", "warning", "error"]
|
|
13
|
+
|
|
14
|
+
js = SafeStr(
|
|
15
|
+
'<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1/dist/js/toast.min.js" defer></script>'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def toaster(
|
|
20
|
+
*children: ComponentType,
|
|
21
|
+
id: str,
|
|
22
|
+
class_: str | None = None,
|
|
23
|
+
align: Align | None = None,
|
|
24
|
+
**kwargs: PropertyValue,
|
|
25
|
+
) -> ComponentType:
|
|
26
|
+
"""
|
|
27
|
+
Toast container.
|
|
28
|
+
|
|
29
|
+
Arguments:
|
|
30
|
+
*children: Initial toasts rendered inside the container.
|
|
31
|
+
id: The unique identifier of the component.
|
|
32
|
+
class_: Extra CSS classes for the container.
|
|
33
|
+
align: Horizontal alignment of the toaster.
|
|
34
|
+
**kwargs: Extra attributes for the root element.
|
|
35
|
+
"""
|
|
36
|
+
if align is not None:
|
|
37
|
+
kwargs["data-align"] = align
|
|
38
|
+
|
|
39
|
+
return html.div(*children, class_=join_classes("toaster", class_), id=id, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def toast(
|
|
43
|
+
*children: ComponentType,
|
|
44
|
+
category: ToastCategory | None = None,
|
|
45
|
+
class_: str | None = None,
|
|
46
|
+
duration: int | None = None,
|
|
47
|
+
**kwargs: PropertyValue,
|
|
48
|
+
) -> ComponentType:
|
|
49
|
+
"""
|
|
50
|
+
Toast item.
|
|
51
|
+
|
|
52
|
+
Arguments:
|
|
53
|
+
*children: Toast content. Use `toast_content()` for the standard toast layout.
|
|
54
|
+
category: Semantic category that controls styling.
|
|
55
|
+
class_: Optional CSS classes for the toast.
|
|
56
|
+
duration: Timeout in milliseconds. Use `-1` to keep the toast open.
|
|
57
|
+
**kwargs: Extra attributes for the root element.
|
|
58
|
+
"""
|
|
59
|
+
if category is not None:
|
|
60
|
+
kwargs["data-category"] = category
|
|
61
|
+
if duration is not None:
|
|
62
|
+
kwargs["data-duration"] = duration
|
|
63
|
+
|
|
64
|
+
return html.div(
|
|
65
|
+
html.div(*children, class_="toast-content"),
|
|
66
|
+
class_=join_classes("toast", class_),
|
|
67
|
+
role="alert" if category == "error" else "status",
|
|
68
|
+
aria_atomic="true",
|
|
69
|
+
aria_hidden="false",
|
|
70
|
+
**kwargs,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def toast_content(
|
|
75
|
+
description: ComponentType,
|
|
76
|
+
*,
|
|
77
|
+
title: ComponentType | None = None,
|
|
78
|
+
icon: ComponentType | None = None,
|
|
79
|
+
footer: ComponentType | None = None,
|
|
80
|
+
) -> ComponentType:
|
|
81
|
+
"""
|
|
82
|
+
Standard toast content layout.
|
|
83
|
+
|
|
84
|
+
Arguments:
|
|
85
|
+
description: Toast description.
|
|
86
|
+
title: Optional toast title.
|
|
87
|
+
icon: Optional icon.
|
|
88
|
+
footer: Optional footer element, typically an `html.footer(...)`.
|
|
89
|
+
"""
|
|
90
|
+
return Fragment(
|
|
91
|
+
icon,
|
|
92
|
+
html.section(
|
|
93
|
+
None if title is None else html.h2(title),
|
|
94
|
+
html.p(description),
|
|
95
|
+
),
|
|
96
|
+
footer,
|
|
97
|
+
)
|
htmui/basecoat/typing.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Literal, Protocol, TypeAlias
|
|
2
|
+
|
|
3
|
+
from htmy import PropertyValue
|
|
4
|
+
|
|
5
|
+
Align: TypeAlias = Literal["start", "center", "end"]
|
|
6
|
+
|
|
7
|
+
HorizontalSide: TypeAlias = Literal["left", "right"]
|
|
8
|
+
|
|
9
|
+
Side: TypeAlias = HorizontalSide | Literal["top", "bottom"]
|
|
10
|
+
|
|
11
|
+
InlineSide: TypeAlias = Literal["inline-start", "inline-end"]
|
|
12
|
+
|
|
13
|
+
FloatingSide: TypeAlias = Side | InlineSide
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IdToProperty(Protocol):
|
|
17
|
+
"""Callable protocol for building a property value from an HTML element ID."""
|
|
18
|
+
|
|
19
|
+
def __call__(self, id: str, /) -> PropertyValue:
|
|
20
|
+
"""Returns the property value for the given ID."""
|
|
21
|
+
...
|
htmui/cli.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""htmui command-line interface."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
SOURCE_DIR = Path(__file__).parent / "basecoat"
|
|
12
|
+
UTILITIES = {"cdn", "typing"}
|
|
13
|
+
INIT_STEM = "__init__"
|
|
14
|
+
|
|
15
|
+
_Action: TypeAlias = Literal["new", "overwrite", "skip", "prompt"]
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="htmui", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def init(
|
|
22
|
+
src: Annotated[
|
|
23
|
+
bool, typer.Option("--src", help="Install under src/<package>/ instead of <package>/.")
|
|
24
|
+
] = False,
|
|
25
|
+
package: Annotated[
|
|
26
|
+
str, typer.Option("-p", "--package", help="Target package directory name.")
|
|
27
|
+
] = "components",
|
|
28
|
+
components: Annotated[
|
|
29
|
+
list[str] | None, typer.Option("-c", "--component", help="Component to install (repeatable).")
|
|
30
|
+
] = None,
|
|
31
|
+
force: Annotated[
|
|
32
|
+
bool, typer.Option("-f", "--force", help="Overwrite existing files without asking.")
|
|
33
|
+
] = False,
|
|
34
|
+
skip_existing: Annotated[
|
|
35
|
+
bool, typer.Option("--skip-existing", help="Skip existing files without asking.")
|
|
36
|
+
] = False,
|
|
37
|
+
dry_run: Annotated[
|
|
38
|
+
bool, typer.Option("--dry-run", help="Show what would be done; write nothing.")
|
|
39
|
+
] = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Install basecoat components into the current project.
|
|
43
|
+
|
|
44
|
+
Copies the selected components, or the full catalog if none are given, plus
|
|
45
|
+
their dependencies and shared utilities. Existing `__init__.py` is never
|
|
46
|
+
overwritten.
|
|
47
|
+
"""
|
|
48
|
+
if force and skip_existing:
|
|
49
|
+
typer.echo("Error: --force and --skip-existing are mutually exclusive.", err=True)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
catalog = _selectable_catalog()
|
|
53
|
+
catalog_set = set(catalog)
|
|
54
|
+
|
|
55
|
+
requested = list(dict.fromkeys([] if components is None else components))
|
|
56
|
+
unknown = [c for c in requested if c not in catalog_set]
|
|
57
|
+
if len(unknown) > 0:
|
|
58
|
+
typer.echo(f"Error: unknown component(s): {', '.join(unknown)}", err=True)
|
|
59
|
+
typer.echo(f"Available components: {', '.join(catalog)}", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
base = (Path.cwd() / "src" / package) if src else (Path.cwd() / package)
|
|
63
|
+
if base.exists() and base.is_file():
|
|
64
|
+
typer.echo(f"Error: {base} exists and is a file, not a directory.", err=True)
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
selection = requested if len(requested) > 0 else list(catalog)
|
|
68
|
+
resolved, added = _resolve(selection, catalog_set)
|
|
69
|
+
for dep, dependant in added:
|
|
70
|
+
typer.echo(f"+ {dep} (dependency of {dependant})")
|
|
71
|
+
|
|
72
|
+
plans = _plan_install(resolved, base, force=force, skip_existing=skip_existing)
|
|
73
|
+
|
|
74
|
+
if dry_run:
|
|
75
|
+
_print_plan(base, plans)
|
|
76
|
+
raise typer.Exit(0)
|
|
77
|
+
|
|
78
|
+
prompt_count = sum(1 for _s, _d, action in plans if action == "prompt")
|
|
79
|
+
if prompt_count > 0:
|
|
80
|
+
if not sys.stdin.isatty():
|
|
81
|
+
typer.echo(
|
|
82
|
+
f"Error: {prompt_count} existing file(s) in {base}; pass --force or --skip-existing.",
|
|
83
|
+
err=True,
|
|
84
|
+
)
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
choice: _Action = (
|
|
87
|
+
"overwrite"
|
|
88
|
+
if typer.confirm(f"Overwrite {prompt_count} existing file(s) in {base}?")
|
|
89
|
+
else "skip"
|
|
90
|
+
)
|
|
91
|
+
plans = [(source, dest, choice if action == "prompt" else action) for source, dest, action in plans]
|
|
92
|
+
|
|
93
|
+
installed, overwritten, skipped = _write_plans(plans, base)
|
|
94
|
+
typer.echo(f"installed {installed}, overwritten {overwritten}, skipped {skipped} in {base}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def version() -> None:
|
|
99
|
+
"""Print the htmui version and per-component versions."""
|
|
100
|
+
from htmui import __version__ as htmui_version
|
|
101
|
+
|
|
102
|
+
typer.echo(f"htmui {htmui_version}")
|
|
103
|
+
for name, ver, framework, fw_ver in _component_versions():
|
|
104
|
+
line = f" {name} {ver}"
|
|
105
|
+
if len(framework) > 0:
|
|
106
|
+
line += f" [{framework} {fw_ver}]" if len(fw_ver) > 0 else f" [{framework}]"
|
|
107
|
+
typer.echo(line)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _selectable_catalog() -> list[str]:
|
|
111
|
+
"""Sorted installable component names, excluding `__init__` and utilities."""
|
|
112
|
+
return sorted(
|
|
113
|
+
p.stem for p in SOURCE_DIR.glob("*.py") if p.stem != INIT_STEM and p.stem not in UTILITIES
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _sibling_imports(path: Path, catalog: set[str]) -> set[str]:
|
|
118
|
+
"""Relative-import modules from `path` that are in `catalog`."""
|
|
119
|
+
tree = ast.parse(path.read_text(), filename=str(path))
|
|
120
|
+
out: set[str] = set()
|
|
121
|
+
for node in ast.walk(tree):
|
|
122
|
+
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
|
123
|
+
out.add(node.module)
|
|
124
|
+
return out & catalog
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve(selection: list[str], catalog: set[str]) -> tuple[list[str], list[tuple[str, str]]]:
|
|
128
|
+
"""
|
|
129
|
+
`selection` plus transitive relative-import dependencies within `catalog`.
|
|
130
|
+
|
|
131
|
+
The second item is `(dependency, dependant)` for each name not already in
|
|
132
|
+
`selection`.
|
|
133
|
+
"""
|
|
134
|
+
added_log: list[tuple[str, str]] = []
|
|
135
|
+
stack = list(selection)
|
|
136
|
+
seen = set(selection)
|
|
137
|
+
while stack:
|
|
138
|
+
cur = stack.pop()
|
|
139
|
+
for dep in _sibling_imports(SOURCE_DIR / f"{cur}.py", catalog):
|
|
140
|
+
if dep not in seen:
|
|
141
|
+
added_log.append((dep, cur))
|
|
142
|
+
seen.add(dep)
|
|
143
|
+
stack.append(dep)
|
|
144
|
+
return sorted(seen), added_log
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _plan_install(
|
|
148
|
+
resolved: list[str],
|
|
149
|
+
base: Path,
|
|
150
|
+
*,
|
|
151
|
+
force: bool,
|
|
152
|
+
skip_existing: bool,
|
|
153
|
+
) -> list[tuple[Path, Path, _Action]]:
|
|
154
|
+
"""
|
|
155
|
+
Source, destination, and action for installing `resolved` into `base`.
|
|
156
|
+
|
|
157
|
+
Always includes `__init__.py` and the utility modules. An existing
|
|
158
|
+
`__init__.py` is skipped. Other existing files are overwritten if `force`,
|
|
159
|
+
skipped if `skip_existing`, otherwise marked `prompt`.
|
|
160
|
+
"""
|
|
161
|
+
plans: list[tuple[Path, Path, _Action]] = []
|
|
162
|
+
for name in [INIT_STEM, *sorted(UTILITIES), *resolved]:
|
|
163
|
+
source = SOURCE_DIR / f"{name}.py"
|
|
164
|
+
dest = base / f"{name}.py"
|
|
165
|
+
if dest.exists() and name == INIT_STEM:
|
|
166
|
+
action: _Action = "skip"
|
|
167
|
+
elif not dest.exists():
|
|
168
|
+
action = "new"
|
|
169
|
+
elif force:
|
|
170
|
+
action = "overwrite"
|
|
171
|
+
elif skip_existing:
|
|
172
|
+
action = "skip"
|
|
173
|
+
else:
|
|
174
|
+
action = "prompt"
|
|
175
|
+
plans.append((source, dest, action))
|
|
176
|
+
return plans
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _action_line(dest: Path, action: _Action) -> str:
|
|
180
|
+
name = dest.name
|
|
181
|
+
if action == "skip" and dest.stem == INIT_STEM:
|
|
182
|
+
return f" skip {name} (existing __init__.py preserved)"
|
|
183
|
+
if action == "new":
|
|
184
|
+
return f" new {name}"
|
|
185
|
+
if action == "overwrite":
|
|
186
|
+
return f" overwrite {name}"
|
|
187
|
+
if action == "skip":
|
|
188
|
+
return f" skip {name}"
|
|
189
|
+
return f" ask {name}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _print_plan(base: Path, plans: list[tuple[Path, Path, _Action]]) -> None:
|
|
193
|
+
"""Print `plans` and a count summary for `base`."""
|
|
194
|
+
typer.echo(f"Dry-run plan for {base}:")
|
|
195
|
+
would_install = would_overwrite = would_skip = would_ask = 0
|
|
196
|
+
for _source, dest, action in plans:
|
|
197
|
+
typer.echo(_action_line(dest, action))
|
|
198
|
+
if action == "new":
|
|
199
|
+
would_install += 1
|
|
200
|
+
elif action == "overwrite":
|
|
201
|
+
would_overwrite += 1
|
|
202
|
+
elif action == "prompt":
|
|
203
|
+
would_ask += 1
|
|
204
|
+
else:
|
|
205
|
+
would_skip += 1
|
|
206
|
+
typer.echo(
|
|
207
|
+
f"would install {would_install}, overwrite {would_overwrite}, skip {would_skip}, ask {would_ask}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_plans(plans: list[tuple[Path, Path, _Action]], base: Path) -> tuple[int, int, int]:
|
|
212
|
+
"""Write `plans` under `base`. Returns `(installed, overwritten, skipped)`."""
|
|
213
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
installed = 0
|
|
215
|
+
overwritten = 0
|
|
216
|
+
skipped = 0
|
|
217
|
+
for source, dest, action in plans:
|
|
218
|
+
if action in ("new", "overwrite"):
|
|
219
|
+
shutil.copyfile(source, dest)
|
|
220
|
+
if action == "overwrite":
|
|
221
|
+
overwritten += 1
|
|
222
|
+
else:
|
|
223
|
+
installed += 1
|
|
224
|
+
else:
|
|
225
|
+
skipped += 1
|
|
226
|
+
typer.echo(_action_line(dest, action))
|
|
227
|
+
return installed, overwritten, skipped
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _component_versions() -> list[tuple[str, str, str, str]]:
|
|
231
|
+
"""
|
|
232
|
+
`(name, version, framework, framework_version)` for each module with `__version__`.
|
|
233
|
+
|
|
234
|
+
Missing framework attributes are empty strings.
|
|
235
|
+
"""
|
|
236
|
+
import importlib.util
|
|
237
|
+
|
|
238
|
+
names = sorted(p.stem for p in SOURCE_DIR.glob("*.py") if p.stem != INIT_STEM)
|
|
239
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
240
|
+
for name in names:
|
|
241
|
+
path = SOURCE_DIR / f"{name}.py"
|
|
242
|
+
spec = importlib.util.spec_from_file_location(f"htmui.basecoat.{name}", path)
|
|
243
|
+
if spec is None or spec.loader is None:
|
|
244
|
+
continue
|
|
245
|
+
mod = importlib.util.module_from_spec(spec)
|
|
246
|
+
spec.loader.exec_module(mod)
|
|
247
|
+
ver = getattr(mod, "__version__", None)
|
|
248
|
+
if ver is None:
|
|
249
|
+
continue
|
|
250
|
+
framework = getattr(mod, "__framework__", "")
|
|
251
|
+
fw_ver = getattr(mod, "__framework_version__", "")
|
|
252
|
+
rows.append((name, ver, framework, fw_ver))
|
|
253
|
+
return rows
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
app()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: htmui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: htmy component library
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: fastapi[standard]>=0.136.3
|
|
7
|
+
Requires-Dist: holm>=0.10.0
|
|
8
|
+
Requires-Dist: htmy[lxml]>=0.13.1
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.15.0
|
|
10
|
+
Requires-Dist: typer>=0.27.1
|
|
11
|
+
Requires-Dist: uvicorn>=0.52.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
**Source code**: [https://github.com/volfpeter/htmui](https://github.com/volfpeter/htmui)
|
|
17
|
+
|
|
18
|
+
**Live demo**: [https://htmui.vercel.app](https://htmui.vercel.app)
|
|
19
|
+
|
|
20
|
+
# htmui
|
|
21
|
+
|
|
22
|
+
Python UI components for [htmy](https://volfpeter.github.io/htmy/), wrapping [BasecoatUI](https://basecoatui.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
|
23
|
+
|
|
24
|
+
`htmui` is designed with hypermedia applications, and specifically [HTMX](https://htmx.org/), in mind. Components reproduce Basecoat's documented markup so Basecoat's runtime can enhance them.
|
|
25
|
+
|
|
26
|
+
`htmy` and `holm` have built-in Jinja support. If you use another templating engine, converting the components is straightforward, because `htmy` mimics HTML.
|
|
27
|
+
|
|
28
|
+
## Key features
|
|
29
|
+
|
|
30
|
+
- **Vendored**: copy the components you need into your project and adapt them.
|
|
31
|
+
- **`htmui init` CLI** that copies selected components, their dependencies, and shared utilities.
|
|
32
|
+
- **BasecoatUI** components as typed `htmy` functions: buttons, dialogs, sidebars, forms, and more.
|
|
33
|
+
- Extra helpers such as `codeblock` that are not part of Basecoat.
|
|
34
|
+
- Works anywhere `htmy` works, including [holm](https://volfpeter.github.io/holm/).
|
|
35
|
+
|
|
36
|
+
## Prerequisites
|
|
37
|
+
|
|
38
|
+
You need [htmy](https://volfpeter.github.io/htmy/) in your project:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install htmy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For the best developer experience, it is recommended to use [holm](https://volfpeter.github.io/holm/). It brings built-in `htmy` and HTMX support, and is built around FastAPI.
|
|
45
|
+
|
|
46
|
+
The copied components also need [BasecoatUI](https://basecoatui.com/) and [Tailwind CSS](https://tailwindcss.com/) on the page. CDN snippets are included (see [Assets](#assets)).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
`htmui` is designed to be vendored. You do not install it as a runtime dependency. Instead, you copy the components you need into your project and adjust them.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uvx htmui init
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This copies the full catalog into `./components`. Use `-c` to copy only the components you need. Dependencies are included automatically:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uvx htmui init -c dialog -c select
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Useful options:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uvx htmui init --src # install into src/components/
|
|
66
|
+
uvx htmui init -p ui # install into ./ui
|
|
67
|
+
uvx htmui init -c dialog --dry-run # print the plan, write nothing
|
|
68
|
+
uvx htmui init --force # overwrite existing files
|
|
69
|
+
uvx htmui init --skip-existing # keep existing files
|
|
70
|
+
uvx htmui version # package and per-component versions
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
You can also copy files from `htmui/basecoat/` by hand.
|
|
74
|
+
|
|
75
|
+
### Example
|
|
76
|
+
|
|
77
|
+
After `htmui init`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from htmy import ComponentType, html
|
|
81
|
+
|
|
82
|
+
from components.alert import alert
|
|
83
|
+
from components.button import button
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def saved() -> ComponentType:
|
|
87
|
+
return html.div(
|
|
88
|
+
alert("Your changes have been saved.", title="Saved"),
|
|
89
|
+
button("Continue"),
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every component has a live example and its Python source in the sidebar.
|
|
94
|
+
|
|
95
|
+
## Assets
|
|
96
|
+
|
|
97
|
+
You can add the CDN snippets from `cdn` to your document head:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from htmy import ComponentType, html
|
|
101
|
+
|
|
102
|
+
from components import cdn
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def head() -> ComponentType:
|
|
106
|
+
return html.head(
|
|
107
|
+
cdn.css,
|
|
108
|
+
cdn.tailwind_css,
|
|
109
|
+
cdn.js,
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Some Basecoat classes may not work with the Tailwind CDN setup, so a local JS/CSS setup is recommended for production. The [demo application](https://github.com/volfpeter/htmui/tree/main/app) shows one such setup.
|
|
114
|
+
|
|
115
|
+
`codeblock` includes optional [Highlight.js](https://highlightjs.org/) helpers for syntax highlighting.
|
|
116
|
+
|
|
117
|
+
## Related projects
|
|
118
|
+
|
|
119
|
+
- [htmy](https://volfpeter.github.io/htmy/): async Python server-side rendering.
|
|
120
|
+
- [holm](https://volfpeter.github.io/holm/): Next.js-like developer experience on FastAPI, `htmy`, and FastHX.
|
|
121
|
+
- [FastHX](https://volfpeter.github.io/fasthx/): HTMX rendering for FastAPI.
|
|
122
|
+
- [BasecoatUI](https://basecoatui.com/): the design system these components wrap.
|
|
123
|
+
|
|
124
|
+
## Support
|
|
125
|
+
|
|
126
|
+
Consider supporting the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20htmui) so you can get the most out of the library.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
Python:
|
|
131
|
+
|
|
132
|
+
- `uv` for project and dependency management.
|
|
133
|
+
- `poethepoet` for tasks. Run `uv run poe` to see them.
|
|
134
|
+
- `mypy` for static analysis.
|
|
135
|
+
- `ruff` for formatting and linting.
|
|
136
|
+
|
|
137
|
+
JavaScript:
|
|
138
|
+
|
|
139
|
+
- `npm` for the demo app's Tailwind and Basecoat tooling.
|
|
140
|
+
|
|
141
|
+
To get started:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
uv sync
|
|
145
|
+
npm install
|
|
146
|
+
honcho start
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`honcho start` runs the demo app and the Tailwind watcher together. You can also run `uv run poe dev` and `uv run poe build-dev-css --watch` separately.
|
|
150
|
+
|
|
151
|
+
The index page of the demo is generated from this README (`uv run poe build-content`).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
The package is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
156
|
+
|
|
157
|
+
## Credits
|
|
158
|
+
|
|
159
|
+
This project wouldn't exist without the components and documentation of [BasecoatUI](https://basecoatui.com/).
|