pyweft-ui 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.
pyweft_ui/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """pyweft-ui: ready-made controls for PyWeft.
2
+
3
+ In a component's code block::
4
+
5
+ from pyweft_ui import ui
6
+
7
+ then in its template ``<ui.Button>``, ``<ui.TextField @bind="name" />``,
8
+ ``<ui.Card>`` and the rest. Each control is a ``.weft`` file in this folder,
9
+ loaded the first time it is used. ``toast()`` shows a message in the
10
+ page's ``<ui.Toaster />``.
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from pyweft import ComponentLibrary
16
+
17
+ from .toasts import Toast, clear_toasts, toast
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ ui = ComponentLibrary(Path(__file__).parent, prefix="ui")
22
+
23
+ __all__ = ["ui", "toast", "clear_toasts", "Toast", "__version__"]
pyweft_ui/alert.weft ADDED
@@ -0,0 +1,48 @@
1
+ <template>
2
+ <div :class="'alert alert-' + kind" :role="'alert' if kind in ('danger', 'warning') else 'status'">
3
+ <Icon :name="icon_name" size="1.25em" />
4
+ <div class="content">
5
+ <If cond="title"><strong class="title">{{ title }}</strong></If>
6
+ <div><Slot /></div>
7
+ </div>
8
+ <If cond="dismissible">
9
+ <button class="close" type="button" aria-label="Dismiss" @click="emit('close')"><Icon name="x" /></button>
10
+ </If>
11
+ </div>
12
+ </template>
13
+
14
+ <code>
15
+ # <ui.Alert kind="success" title="Saved">Your changes are live.</ui.Alert>
16
+ # With dismissible="true" it shows an X and emits "close" when clicked.
17
+ kind: str = "info" # info, success, warning, danger
18
+ title: str = ""
19
+ dismissible: bool = False
20
+
21
+ _ICONS = {"info": "info", "success": "check", "warning": "alert", "danger": "alert"}
22
+
23
+ @property
24
+ def icon_name(self):
25
+ return _ICONS.get(self.kind, "info")
26
+ </code>
27
+
28
+ <style>
29
+ .alert {
30
+ --tone: var(--ui-info, #0969da);
31
+ display: flex; align-items: flex-start; gap: 10px;
32
+ padding: 12px 14px;
33
+ border: 1px solid color-mix(in srgb, var(--tone) 40%, transparent);
34
+ border-radius: var(--ui-radius, 8px);
35
+ background: color-mix(in srgb, var(--tone) 10%, var(--ui-surface, #fff));
36
+ }
37
+ .alert > .icon { color: var(--tone); margin-top: 0.1em; }
38
+ .alert-success { --tone: var(--ui-success, #1a7f37); }
39
+ .alert-warning { --tone: var(--ui-warning, #9a6700); }
40
+ .alert-danger { --tone: var(--ui-danger, #d1242f); }
41
+ .content { flex: 1; min-width: 0; }
42
+ .title { display: block; margin-bottom: 2px; }
43
+ .close {
44
+ border: 0; background: transparent; color: inherit; cursor: pointer;
45
+ padding: 2px; border-radius: 4px; line-height: 0; opacity: 0.7;
46
+ }
47
+ .close:hover { opacity: 1; background: color-mix(in srgb, var(--tone) 15%, transparent); }
48
+ </style>
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <div class="shell">
3
+ <header class="top">
4
+ <div class="brand"><Slot name="brand">{{ title }}</Slot></div>
5
+ <div class="actions"><Slot name="actions" /></div>
6
+ </header>
7
+ <div class="frame">
8
+ <If cond="has_slot('nav')">
9
+ <nav class="side"><Slot name="nav" /></nav>
10
+ </If>
11
+ <main class="main" :style="f'max-width: {_WIDTHS.get(width, width)}'"><Slot /></main>
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <code>
17
+ # The page frame: a header bar, an optional sidebar (<Fill name="nav">) and
18
+ # the content. The sidebar moves above the content on narrow screens.
19
+ title: str = ""
20
+ width: str = "normal" # narrow, normal, wide, full, or any CSS width
21
+
22
+ _WIDTHS = {"narrow": "40rem", "normal": "64rem", "wide": "80rem", "full": "none"}
23
+ </code>
24
+
25
+ <style>
26
+ .shell { display: flex; flex-direction: column; min-height: 100vh; }
27
+ .top {
28
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
29
+ padding: 12px 24px;
30
+ border-bottom: 1px solid var(--ui-border, #d1d9e0);
31
+ background: var(--ui-surface, #fff);
32
+ }
33
+ .brand { font-weight: 650; font-size: 1.1rem; }
34
+ .actions { display: flex; align-items: center; gap: 8px; }
35
+ .frame { display: flex; flex: 1; }
36
+ .side {
37
+ width: 220px; flex-shrink: 0; padding: 16px;
38
+ border-right: 1px solid var(--ui-border, #d1d9e0);
39
+ display: flex; flex-direction: column; gap: 4px;
40
+ }
41
+ .main { flex: 1; width: 100%; margin: 0 auto; padding: 24px; box-sizing: border-box; }
42
+ @media (max-width: 720px) {
43
+ .frame { flex-direction: column; }
44
+ .side { width: auto; border-right: 0; border-bottom: 1px solid var(--ui-border, #d1d9e0); flex-direction: row; flex-wrap: wrap; }
45
+ .top, .main { padding-left: 16px; padding-right: 16px; }
46
+ }
47
+ </style>
pyweft_ui/badge.weft ADDED
@@ -0,0 +1,25 @@
1
+ <template>
2
+ <span :class="'badge badge-' + kind"><Slot /></span>
3
+ </template>
4
+
5
+ <code>
6
+ # <ui.Badge kind="success">Active</ui.Badge>
7
+ kind: str = "neutral" # neutral, primary, info, success, warning, danger
8
+ </code>
9
+
10
+ <style>
11
+ .badge {
12
+ --tone: var(--ui-muted, #59636e);
13
+ display: inline-flex; align-items: center; gap: 4px;
14
+ padding: 0.1em 0.6em;
15
+ border-radius: 999px;
16
+ font-size: 0.8rem; font-weight: 600; line-height: 1.5;
17
+ color: var(--tone);
18
+ background: color-mix(in srgb, var(--tone) 14%, transparent);
19
+ }
20
+ .badge-primary { --tone: var(--ui-primary, #4f46e5); }
21
+ .badge-info { --tone: var(--ui-info, #0969da); }
22
+ .badge-success { --tone: var(--ui-success, #1a7f37); }
23
+ .badge-warning { --tone: var(--ui-warning, #9a6700); }
24
+ .badge-danger { --tone: var(--ui-danger, #d1242f); }
25
+ </style>
pyweft_ui/button.weft ADDED
@@ -0,0 +1,64 @@
1
+ <template>
2
+ <button :type="type" :class="classes" :disabled="disabled or loading"
3
+ :aria-busy="'true' if loading else None" @click="emit('click', event)">
4
+ <If cond="loading"><span class="spinner" aria-hidden="true"></span></If>
5
+ <ElseIf cond="icon"><Icon :name="icon" size="1.15em" /></ElseIf>
6
+ <Slot />
7
+ </button>
8
+ </template>
9
+
10
+ <code>
11
+ # <ui.Button variant="primary" icon="send" @click="save">Send</ui.Button>
12
+ variant: str = "secondary" # primary, secondary, ghost, danger
13
+ size: str = "md" # sm, md, lg
14
+ type: str = "button" # submit inside a form that should submit
15
+ icon: str = "" # an icon name, shown before the text
16
+ loading: bool = False # spinner, and not clickable
17
+ disabled: bool = False
18
+ block: bool = False # full width
19
+
20
+ @property
21
+ def classes(self):
22
+ names = ["btn", f"btn-{self.variant}", f"btn-{self.size}"]
23
+ if self.block:
24
+ names.append("btn-block")
25
+ return " ".join(names)
26
+ </code>
27
+
28
+ <style>
29
+ .btn {
30
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.5em;
31
+ border: 1px solid transparent;
32
+ border-radius: var(--ui-radius, 8px);
33
+ font: inherit; font-weight: 550; line-height: 1.2;
34
+ cursor: pointer; white-space: nowrap;
35
+ transition: background-color 0.15s, border-color 0.15s, box-shadow 0.15s, opacity 0.15s;
36
+ }
37
+ .btn:focus-visible {
38
+ outline: none;
39
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-primary, #4f46e5) 35%, transparent);
40
+ }
41
+ .btn:disabled { opacity: 0.55; cursor: not-allowed; }
42
+ .btn-sm { padding: 0.3em 0.75em; font-size: 0.875rem; }
43
+ .btn-md { padding: 0.5em 1em; font-size: 0.95rem; }
44
+ .btn-lg { padding: 0.7em 1.3em; font-size: 1.05rem; }
45
+ .btn-block { display: flex; width: 100%; }
46
+ .btn-primary { background: var(--ui-primary, #4f46e5); color: var(--ui-on-primary, #fff); }
47
+ .btn-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ui-primary, #4f46e5) 88%, black); }
48
+ .btn-secondary {
49
+ background: var(--ui-surface, #fff); color: var(--ui-text, #1f2328);
50
+ border-color: var(--ui-border, #d1d9e0);
51
+ }
52
+ .btn-secondary:hover:not(:disabled) { background: var(--ui-subtle, #f6f8fa); }
53
+ .btn-ghost { background: transparent; color: var(--ui-text, #1f2328); }
54
+ .btn-ghost:hover:not(:disabled) { background: var(--ui-subtle, #f6f8fa); }
55
+ .btn-danger { background: var(--ui-danger, #d1242f); color: #fff; }
56
+ .btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--ui-danger, #d1242f) 88%, black); }
57
+ .spinner {
58
+ width: 1em; height: 1em; border-radius: 50%;
59
+ border: 2px solid currentColor; border-right-color: transparent;
60
+ animation: ui-button-spin 0.7s linear infinite;
61
+ }
62
+ @keyframes ui-button-spin { to { transform: rotate(360deg); } }
63
+ @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.1s; } }
64
+ </style>
pyweft_ui/card.weft ADDED
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <section class="card">
3
+ <If cond="title or subtitle or has_slot('title') or has_slot('actions')">
4
+ <header class="head">
5
+ <div>
6
+ <h3 class="title"><Slot name="title">{{ title }}</Slot></h3>
7
+ <If cond="subtitle"><p class="subtitle">{{ subtitle }}</p></If>
8
+ </div>
9
+ <If cond="has_slot('actions')"><div class="actions"><Slot name="actions" /></div></If>
10
+ </header>
11
+ </If>
12
+ <div class="body"><Slot /></div>
13
+ <If cond="has_slot('footer')"><footer class="foot"><Slot name="footer" /></footer></If>
14
+ </section>
15
+ </template>
16
+
17
+ <code>
18
+ # A panel. Content between the tags is the body; <Fill name="title">,
19
+ # <Fill name="actions"> (top right) and <Fill name="footer"> are optional.
20
+ title: str = ""
21
+ subtitle: str = ""
22
+ </code>
23
+
24
+ <style>
25
+ .card {
26
+ background: var(--ui-surface, #fff);
27
+ border: 1px solid var(--ui-border, #d1d9e0);
28
+ border-radius: calc(var(--ui-radius, 8px) * 1.5);
29
+ overflow: hidden;
30
+ }
31
+ .head {
32
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
33
+ padding: 16px 20px 0;
34
+ }
35
+ .title { margin: 0; font-size: 1.05rem; font-weight: 650; }
36
+ .subtitle { margin: 2px 0 0; color: var(--ui-muted, #59636e); font-size: 0.9rem; }
37
+ .actions { display: flex; gap: 8px; }
38
+ .body { padding: 16px 20px; }
39
+ .foot {
40
+ display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
41
+ padding: 12px 20px;
42
+ border-top: 1px solid var(--ui-border, #d1d9e0);
43
+ background: var(--ui-subtle, #f6f8fa);
44
+ }
45
+ </style>
@@ -0,0 +1,26 @@
1
+ <template>
2
+ <label class="check">
3
+ <input type="checkbox" :checked="value" :name="name or None" :disabled="disabled"
4
+ @change="emit('update', event.checked)" />
5
+ <span><Slot>{{ label }}</Slot></span>
6
+ </label>
7
+ </template>
8
+
9
+ <code>
10
+ # <ui.Checkbox label="Remember me" @bind="remember" />
11
+ label: str = ""
12
+ value: bool = False
13
+ name: str = ""
14
+ disabled: bool = False
15
+ </code>
16
+
17
+ <style>
18
+ .check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
19
+ input {
20
+ width: 1.1em; height: 1.1em; margin: 0;
21
+ accent-color: var(--ui-primary, #4f46e5);
22
+ cursor: inherit;
23
+ }
24
+ input:disabled { cursor: not-allowed; }
25
+ input:disabled + span { opacity: 0.6; }
26
+ </style>
pyweft_ui/column.weft ADDED
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <div class="column" :style="layout"><Slot /></div>
3
+ </template>
4
+
5
+ <code>
6
+ # Children stacked top to bottom.
7
+ gap: str = "md" # none, xs, sm, md, lg, xl, or a CSS length
8
+ align: str = "stretch" # stretch, start, center, end (horizontal)
9
+
10
+ _SPACE = {"none": "0", "xs": "4px", "sm": "8px", "md": "12px", "lg": "20px", "xl": "32px"}
11
+ _FLEX = {"start": "flex-start", "end": "flex-end"}
12
+
13
+ @property
14
+ def layout(self):
15
+ return (
16
+ f"gap: {_SPACE.get(self.gap, self.gap)}; "
17
+ f"align-items: {_FLEX.get(self.align, self.align)}"
18
+ )
19
+ </code>
20
+
21
+ <style>
22
+ .column { display: flex; flex-direction: column; }
23
+ </style>
pyweft_ui/dialog.weft ADDED
@@ -0,0 +1,91 @@
1
+ <template>
2
+ <div class="host">
3
+ <If cond="value">
4
+ <div class="backdrop" @click.self="dismiss" @keydown.esc="dismiss">
5
+ <!-- Tab past either end lands on a guard, which sends focus round. -->
6
+ <span class="guard" tabindex="0" data-focus-guard="" @focus="focus_last('box')"></span>
7
+ <div class="dialog" role="dialog" aria-modal="true" :aria-label="title or None"
8
+ tabindex="-1" ref="box" :style="f'max-width: {width}'">
9
+ <header class="head">
10
+ <h2 class="title">{{ title }}</h2>
11
+ <If cond="dismissible">
12
+ <button class="close" type="button" aria-label="Close" @click="close"><Icon name="x" size="1.2em" /></button>
13
+ </If>
14
+ </header>
15
+ <div class="body"><Slot /></div>
16
+ <If cond="has_slot('footer')"><footer class="foot"><Slot name="footer" /></footer></If>
17
+ </div>
18
+ <span class="guard" tabindex="0" data-focus-guard="" @focus="focus_first('box')"></span>
19
+ </div>
20
+ </If>
21
+ </div>
22
+ </template>
23
+
24
+ <code>
25
+ # <ui.Dialog title="Delete file?" @bind="confirming"> ... </ui.Dialog>
26
+ # Open while the bound field is True. Esc, a click outside, or the X set it
27
+ # back to False (unless dismissible="false") and emit "close". Focus moves
28
+ # in when it opens, Tab stays inside, and focus goes back when it closes.
29
+ from pyweft import effect
30
+
31
+ value: bool = False
32
+ title: str = ""
33
+ width: str = "32rem"
34
+ dismissible: bool = True
35
+
36
+ _open = False
37
+
38
+ @effect
39
+ def follow_focus(self):
40
+ # Runs when value changes, however it changed: the bound field, Esc,
41
+ # the X or the app's own buttons.
42
+ if self.value and not self._open:
43
+ self._open = True
44
+ self.save_focus()
45
+ self.focus("box")
46
+ elif not self.value and self._open:
47
+ self._open = False
48
+ self.restore_focus()
49
+
50
+ def close(self):
51
+ self.emit("update", False)
52
+ self.emit("close")
53
+
54
+ def dismiss(self):
55
+ if self.dismissible:
56
+ self.close()
57
+ </code>
58
+
59
+ <style>
60
+ .host { display: contents; }
61
+ .backdrop {
62
+ position: fixed; inset: 0; z-index: 1000;
63
+ display: grid; place-items: center; padding: 16px;
64
+ background: rgba(15, 23, 42, 0.5);
65
+ animation: ui-dialog-fade 0.12s ease-out;
66
+ }
67
+ .dialog {
68
+ width: 100%; max-height: calc(100vh - 32px); overflow: auto;
69
+ background: var(--ui-surface, #fff); color: var(--ui-text, #1f2328);
70
+ border-radius: calc(var(--ui-radius, 8px) * 1.5);
71
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
72
+ outline: none;
73
+ animation: ui-dialog-rise 0.15s ease-out;
74
+ }
75
+ .head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px 0; }
76
+ .title { margin: 0; font-size: 1.15rem; font-weight: 650; }
77
+ .close {
78
+ border: 0; background: transparent; color: var(--ui-muted, #59636e);
79
+ cursor: pointer; padding: 4px; border-radius: 6px; line-height: 0;
80
+ }
81
+ .close:hover { background: var(--ui-subtle, #f6f8fa); color: var(--ui-text, #1f2328); }
82
+ .body { padding: 12px 20px 20px; }
83
+ .guard { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; outline: none; }
84
+ .foot {
85
+ display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
86
+ padding: 12px 20px; border-top: 1px solid var(--ui-border, #d1d9e0);
87
+ }
88
+ @keyframes ui-dialog-fade { from { opacity: 0; } }
89
+ @keyframes ui-dialog-rise { from { opacity: 0; transform: translateY(8px) scale(0.98); } }
90
+ @media (prefers-reduced-motion: reduce) { .backdrop, .dialog { animation: none; } }
91
+ </style>
pyweft_ui/grid.weft ADDED
@@ -0,0 +1,25 @@
1
+ <template>
2
+ <div class="grid" :style="layout"><Slot /></div>
3
+ </template>
4
+
5
+ <code>
6
+ # Children in a grid. With cols, that many equal columns; without, as many
7
+ # columns of at least min_width as fit, so it adapts to the screen.
8
+ cols: int = 0
9
+ min_width: str = "14rem"
10
+ gap: str = "md" # none, xs, sm, md, lg, xl, or a CSS length
11
+
12
+ _SPACE = {"none": "0", "xs": "4px", "sm": "8px", "md": "12px", "lg": "20px", "xl": "32px"}
13
+
14
+ @property
15
+ def layout(self):
16
+ if self.cols:
17
+ columns = f"repeat({self.cols}, minmax(0, 1fr))"
18
+ else:
19
+ columns = f"repeat(auto-fill, minmax(min({self.min_width}, 100%), 1fr))"
20
+ return f"grid-template-columns: {columns}; gap: {_SPACE.get(self.gap, self.gap)}"
21
+ </code>
22
+
23
+ <style>
24
+ .grid { display: grid; }
25
+ </style>
pyweft_ui/icon.weft ADDED
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <svg class="icon" :width="size" :height="size" viewBox="0 0 24 24" fill="none"
3
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"
4
+ stroke-linejoin="round" aria-hidden="true">
5
+ <path :d="path" />
6
+ </svg>
7
+ </template>
8
+
9
+ <code>
10
+ # A line icon in the current text colour: <ui.Icon name="check" />.
11
+ name: str = ""
12
+ size: str = "1em"
13
+
14
+ _PATHS = {
15
+ "check": "M5 12.5l4.5 4.5L19 7.5",
16
+ "x": "M6 6l12 12M18 6L6 18",
17
+ "plus": "M12 5v14M5 12h14",
18
+ "minus": "M5 12h14",
19
+ "search": "M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-4-4",
20
+ "info": "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18zM12 11v5M12 7.5v.5",
21
+ "alert": "M12 3.5L2.5 20h19L12 3.5zM12 10v4.5M12 17.5v.5",
22
+ "chevron-down": "M6 9l6 6l6-6",
23
+ "chevron-up": "M6 15l6-6l6 6",
24
+ "chevron-right": "M9 6l6 6l-6 6",
25
+ "chevron-left": "M15 6l-6 6l6 6",
26
+ "menu": "M4 6h16M4 12h16M4 18h16",
27
+ "trash": "M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13",
28
+ "edit": "M4 20h4L19 9l-4-4L4 16v4z",
29
+ "send": "M3 11.5L21 4l-7.5 18l-2.5-8z",
30
+ "user": "M12 4a4 4 0 1 0 0 8a4 4 0 1 0 0-8zM4.5 20.5a7.5 7.5 0 0 1 15 0",
31
+ "home": "M3.5 11L12 4l8.5 7M6 9.5V20h12V9.5",
32
+ "star": "M12 3.5l2.6 5.4l5.9.8l-4.3 4.1l1 5.8L12 16.8l-5.2 2.8l1-5.8l-4.3-4.1l5.9-.8z",
33
+ "heart": "M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.3A4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z",
34
+ "download": "M12 4v11M7 10.5l5 5l5-5M5 20h14",
35
+ "upload": "M12 20V9M7 13.5l5-5l5 5M5 4h14",
36
+ "external": "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
37
+ "copy": "M8 8h12v12H8zM4 16V4h12",
38
+ "settings": "M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6zM12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1",
39
+ "sun": "M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8zM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4",
40
+ "moon": "M20 14.5A8 8 0 0 1 9.5 4A8 8 0 1 0 20 14.5z",
41
+ }
42
+
43
+ @property
44
+ def path(self):
45
+ return _PATHS.get(self.name, "")
46
+ </code>
47
+
48
+ <style>
49
+ .icon { flex-shrink: 0; vertical-align: -0.125em; }
50
+ </style>
@@ -0,0 +1,69 @@
1
+ <template>
2
+ <label class="field">
3
+ <If cond="label">
4
+ <span class="label">{{ label }}<If cond="required"><span class="req" aria-hidden="true"> *</span></If></span>
5
+ </If>
6
+ <input class="control" type="number" :value="shown" :min="min" :max="max" :step="step"
7
+ :name="name or None" :placeholder="placeholder or None" :required="required"
8
+ :disabled="disabled" :aria-invalid="'true' if error else None"
9
+ @input="typed(event)" />
10
+ <If cond="error"><span class="error">{{ error }}</span></If>
11
+ <ElseIf cond="hint"><span class="hint">{{ hint }}</span></ElseIf>
12
+ </label>
13
+ </template>
14
+
15
+ <code>
16
+ # <ui.NumberField label="Quantity" min="1" max="99" @bind="quantity" />
17
+ # Emits an int while the bound value is an int (or None), else a float.
18
+ # Text that isn't a number yet (an empty box, "-") emits nothing.
19
+ label: str = ""
20
+ value: float | None = None
21
+ min: float | None = None
22
+ max: float | None = None
23
+ step: str = "any"
24
+ placeholder: str = ""
25
+ hint: str = ""
26
+ error: str = ""
27
+ name: str = ""
28
+ required: bool = False
29
+ disabled: bool = False
30
+
31
+ @property
32
+ def shown(self):
33
+ return "" if self.value is None else str(self.value)
34
+
35
+ def typed(self, event):
36
+ text = event.value.strip()
37
+ if not text:
38
+ return
39
+ wants_int = self.value is None or (isinstance(self.value, int) and not isinstance(self.value, bool))
40
+ try:
41
+ number = float(text)
42
+ except ValueError:
43
+ return
44
+ if wants_int and number.is_integer() and "." not in text and "e" not in text.lower():
45
+ number = int(number)
46
+ self.emit("update", number)
47
+ </code>
48
+
49
+ <style>
50
+ .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
51
+ .label { font-size: 0.9rem; font-weight: 550; }
52
+ .req { color: var(--ui-danger, #d1242f); }
53
+ .control {
54
+ font: inherit; color: var(--ui-text, #1f2328);
55
+ background: var(--ui-surface, #fff);
56
+ border: 1px solid var(--ui-border, #d1d9e0);
57
+ border-radius: var(--ui-radius, 8px);
58
+ padding: 0.5em 0.75em;
59
+ }
60
+ .control:focus {
61
+ outline: none;
62
+ border-color: var(--ui-primary, #4f46e5);
63
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-primary, #4f46e5) 25%, transparent);
64
+ }
65
+ .control:disabled { opacity: 0.6; cursor: not-allowed; }
66
+ .control[aria-invalid="true"] { border-color: var(--ui-danger, #d1242f); }
67
+ .hint { font-size: 0.82rem; color: var(--ui-muted, #59636e); }
68
+ .error { font-size: 0.82rem; color: var(--ui-danger, #d1242f); }
69
+ </style>
@@ -0,0 +1,60 @@
1
+ <template>
2
+ <fieldset class="group" :disabled="disabled">
3
+ <If cond="label"><legend class="label">{{ label }}</legend></If>
4
+ <div :class="'options inline' if inline else 'options'">
5
+ <For each="choice in choices" key="choice[2]">
6
+ <label class="option">
7
+ <input type="radio" :name="_group" :value="choice[2]" :checked="choice[0] == value"
8
+ @change="emit('update', choice[0])" />
9
+ <span>{{ choice[1] }}</span>
10
+ </label>
11
+ </For>
12
+ </div>
13
+ </fieldset>
14
+ </template>
15
+
16
+ <code>
17
+ # <ui.RadioGroup label="Plan" options="Free, Pro, Team" @bind="plan" />
18
+ # options: a comma-separated string, a list of values, a list of
19
+ # (value, label) pairs, or a list of {"value": ..., "label": ...} dicts.
20
+ import itertools
21
+
22
+ label: str = ""
23
+ value = None
24
+ options: list = []
25
+ inline: bool = False # side by side instead of stacked
26
+ disabled: bool = False
27
+
28
+ _ids = itertools.count(1)
29
+
30
+ def on_init(self):
31
+ # Radios in one group share a name; each group needs its own.
32
+ self._group = f"ui-radio-{next(_ids)}"
33
+
34
+ @property
35
+ def choices(self):
36
+ items = self.options
37
+ if isinstance(items, str):
38
+ items = [part.strip() for part in items.split(",") if part.strip()]
39
+ out = []
40
+ for item in items:
41
+ if isinstance(item, dict):
42
+ val = item.get("value")
43
+ text = item.get("label", val)
44
+ elif isinstance(item, (tuple, list)) and len(item) == 2:
45
+ val, text = item
46
+ else:
47
+ val, text = item, item
48
+ out.append((val, str(text), str(val)))
49
+ return out
50
+ </code>
51
+
52
+ <style>
53
+ .group { border: 0; padding: 0; margin: 0; min-width: 0; }
54
+ .label { font-size: 0.9rem; font-weight: 550; padding: 0; margin-bottom: 6px; }
55
+ .options { display: flex; flex-direction: column; gap: 6px; }
56
+ .options.inline { flex-direction: row; flex-wrap: wrap; gap: 16px; }
57
+ .option { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
58
+ input { width: 1.05em; height: 1.05em; margin: 0; accent-color: var(--ui-primary, #4f46e5); cursor: inherit; }
59
+ .group:disabled .option { opacity: 0.6; cursor: not-allowed; }
60
+ </style>
pyweft_ui/row.weft ADDED
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div class="row" :style="layout"><Slot /></div>
3
+ </template>
4
+
5
+ <code>
6
+ # Children side by side.
7
+ gap: str = "md" # none, xs, sm, md, lg, xl, or a CSS length
8
+ align: str = "center" # start, center, end, stretch, baseline (vertical)
9
+ justify: str = "start" # start, center, end, between, around (horizontal)
10
+ wrap: bool = True # move to the next line when out of room
11
+
12
+ _SPACE = {"none": "0", "xs": "4px", "sm": "8px", "md": "12px", "lg": "20px", "xl": "32px"}
13
+ _FLEX = {"start": "flex-start", "end": "flex-end", "between": "space-between",
14
+ "around": "space-around", "evenly": "space-evenly"}
15
+
16
+ @property
17
+ def layout(self):
18
+ return (
19
+ f"gap: {_SPACE.get(self.gap, self.gap)}; "
20
+ f"align-items: {_FLEX.get(self.align, self.align)}; "
21
+ f"justify-content: {_FLEX.get(self.justify, self.justify)}; "
22
+ f"flex-wrap: {'wrap' if self.wrap else 'nowrap'}"
23
+ )
24
+ </code>
25
+
26
+ <style>
27
+ .row { display: flex; flex-direction: row; }
28
+ </style>