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 +23 -0
- pyweft_ui/alert.weft +48 -0
- pyweft_ui/app_shell.weft +47 -0
- pyweft_ui/badge.weft +25 -0
- pyweft_ui/button.weft +64 -0
- pyweft_ui/card.weft +45 -0
- pyweft_ui/checkbox.weft +26 -0
- pyweft_ui/column.weft +23 -0
- pyweft_ui/dialog.weft +91 -0
- pyweft_ui/grid.weft +25 -0
- pyweft_ui/icon.weft +50 -0
- pyweft_ui/number_field.weft +69 -0
- pyweft_ui/radio_group.weft +60 -0
- pyweft_ui/row.weft +28 -0
- pyweft_ui/select.weft +81 -0
- pyweft_ui/spinner.weft +21 -0
- pyweft_ui/switch.weft +39 -0
- pyweft_ui/table.weft +223 -0
- pyweft_ui/tabs.weft +122 -0
- pyweft_ui/text_area.weft +48 -0
- pyweft_ui/text_field.weft +51 -0
- pyweft_ui/theme.weft +81 -0
- pyweft_ui/toaster.weft +117 -0
- pyweft_ui/toasts.py +73 -0
- pyweft_ui-0.1.0.dist-info/METADATA +184 -0
- pyweft_ui-0.1.0.dist-info/RECORD +28 -0
- pyweft_ui-0.1.0.dist-info/WHEEL +4 -0
- pyweft_ui-0.1.0.dist-info/licenses/LICENSE +21 -0
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>
|
pyweft_ui/app_shell.weft
ADDED
|
@@ -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>
|
pyweft_ui/checkbox.weft
ADDED
|
@@ -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>
|