Using pyweft-ui¶
This guide shows how to build screens with the ready-made controls: forms, autocomplete, chips, sliders and validation, dates and calendars, layouts, menus, dialogs, tables and themes, with complete examples you can paste into an app. The README has the compact reference of every control's fields; this guide is the how-to.
1. Setup¶
Install PyWeft and pyweft-ui (see the README), add
"pyweft-ui" to the dependencies in your app's pyproject.toml, and import
the toolkit in the code block of any component that uses it:
<template>
<ui.Theme>
<ui.Card title="Hello">
<ui.Button variant="primary" @click="count += 1">Clicked {{ count }} times</ui.Button>
</ui.Card>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
count: int = 0
</code>
Every control is written <ui.Name>. ui is simply the name you imported:
from pyweft_ui import ui as kit would make them <kit.Button>.
Wrap your app in <ui.Theme> once, at the top. It sets the colours, font and
corner style for everything inside, and gives the page a matching background.
Controls also work without it, with the default look.
2. How controls work¶
Controls are ordinary PyWeft components, so everything you already know applies. Five things are worth knowing up front.
Attributes set a control's fields, converted to the right type. Write
values as plain text: numbers become numbers and true/false become
True/False.
<ui.Grid cols="3">...</ui.Grid> <!-- cols is the number 3 -->
<ui.Button disabled="true">Save</ui.Button> <!-- disabled is True -->
To pass a Python value from your component, put a colon in front:
<ui.Button :disabled="not form_ok" :loading="saving">Save</ui.Button>
<ui.Select :options="countries" @bind="country" />
@bind connects a control to one of your fields, both ways: the control
shows the field's value, and when the user changes it, your field changes.
Controls emit events you handle with @, like page elements:
@click on Button, @close on Alert and Dialog, @enter on
TextField. A handler that takes a parameter receives what the control
sent: the browser event for a click, the new value for @update.
<Fill name="..."> puts content into a named place in a control, such as
a card's footer or a dialog's buttons. Content outside any <Fill> goes in
the main area.
<ui.Card title="Invoice">
<p>Main content.</p>
<Fill name="footer"><ui.Button>Pay</ui.Button></Fill>
</ui.Card>
class, style, id and aria-* go onto the control's outer element,
so you can position it or label it without knowing what's inside:
3. Page layout¶
The app frame: AppShell¶
AppShell gives you a header bar, an optional sidebar and a centred content
area. On narrow screens the sidebar moves above the content.
<template>
<ui.Theme>
<ui.AppShell title="Acme Admin" width="wide">
<Fill name="actions">
<ui.Badge kind="success">online</ui.Badge>
<ui.Button size="sm" icon="user">Account</ui.Button>
</Fill>
<Fill name="nav">
<For each="page in pages" key="page">
<ui.Button variant="ghost" @click="current = page">{{ page }}</ui.Button>
</For>
</Fill>
<h1>{{ current }}</h1>
<p>The content of the {{ current }} page.</p>
</ui.AppShell>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
pages: list = ["Dashboard", "Orders", "Customers"]
current: str = "Dashboard"
</code>
titleis the text at the top left.<Fill name="brand">replaces it with anything, such as a logo and a name.<Fill name="actions">sits at the top right.<Fill name="nav">is the sidebar. Leave it out for no sidebar. Its contents stay in view while the page scrolls, scrolling on their own when they're taller than the window;sticky_nav="false"lets them scroll away with the page. (On narrow screens, above the content, they scroll with the page.)widthis how wide the content may grow:narrow(40rem),normal(64rem, the default),wide(80rem),full, or any CSS width.
Arranging things: Row, Column, Grid¶
These three replace almost all layout CSS.
<ui.Column gap="lg"> <!-- stacked, with space between -->
<ui.Row justify="between"> <!-- side by side, pushed apart -->
<h2>Orders</h2>
<ui.Button variant="primary" icon="plus">New order</ui.Button>
</ui.Row>
<ui.Grid min_width="16rem"> <!-- as many columns as fit -->
<ui.Card title="Today">12</ui.Card>
<ui.Card title="This week">87</ui.Card>
<ui.Card title="This month">340</ui.Card>
</ui.Grid>
</ui.Column>
| Control | Fields |
|---|---|
Row |
gap; align (vertical: start, center, end, stretch, baseline); justify (horizontal: start, center, end, between, around); wrap (true by default: move to a new line when out of room) |
Column |
gap; align (horizontal: stretch, the default, makes children full width; start, center, end) |
Grid |
cols (a fixed number of equal columns), or min_width (as many columns of at least this width as fit, 14rem by default); gap |
gap takes none, xs (4px), sm (8px), md (12px, the default), lg
(20px), xl (32px), or any CSS length such as 1.5rem.
Grouping: Card¶
<ui.Card title="Payment method" subtitle="Charged on the 1st of each month">
<p>Visa ending in 4242</p>
<Fill name="actions"><ui.Button size="sm" variant="ghost" icon="edit">Change</ui.Button></Fill>
<Fill name="footer"><ui.Button variant="danger">Remove card</ui.Button></Fill>
</ui.Card>
A card shows a header only when it has a title, subtitle, or a
title/actions fill, and a footer only when it has a footer fill.
Separating things: Divider¶
<ui.Divider />
<ui.Divider inset="true" spacing="none" /> <!-- between list rows with avatars -->
<ui.Divider label="or" />
<ui.Row align="center">
<ui.Button variant="ghost" icon="bold" aria-label="Bold"></ui.Button>
<ui.Divider vertical="true" />
<ui.Button variant="ghost" icon="trash" aria-label="Delete"></ui.Button>
</ui.Row>
- A thin line in the theme's border colour.
spacingis the room around it:none,sm,md(the default) orlg. insetindents it from the left so it lines up with text next to an avatar or icon:"true"for the usual indent, or a length ("3.5rem").labelputs text on the line ("or", "Today");align="start"or"end"moves it to one side. A labelled divider is read as its text, a plain one as a separator.vertical="true"draws a vertical line as tall as the row, between items in aRowor a toolbar.
Slides: Carousel¶
<ui.Carousel label="Places" :items="photos" @bind="current" loop="true">
<Fill name="slide">
<img :src="slide['src']" :alt="slide['alt']" />
</Fill>
</ui.Carousel>
<ui.Carousel label="Products" :items="products" per_view="3" autoplay="5">
<Fill name="slide"><ProductCard :product="slide" /></Fill>
</ui.Carousel>
itemsis any list;<Fill name="slide">lays out each one. It getsslide(the item),index, andactive(visible now).- People move it with the arrows on its sides, the dots under it,
a swipe (or a mouse drag, decided when it's let go), or the arrow
keys on its buttons.
@bindholds the index of the first slide shown; it emitschangetoo.loop="true"wraps at the ends. per_viewshows several slides side by side, moving one at a time, withgapbetween them. It applies at every width, so choose a number that suits the narrowest screen the app is for.autoplay="5"advances every 5 seconds. It pauses while the pointer is over it or focus is inside (a slide then gets its full time again), and stops for good once someone moves it themselves. A play/stop button is always shown with it: moving content must be stoppable.arrows="false"andindicators="none"hide the controls;go_next(),go_prev()andgo_to(i)move it from code.- Screen readers hear a carousel named by
label, with each slide as "3 of 8"; slides out of view can't be reached, and changes are announced except while it plays by itself.
Panels that open and close: Expansion and Accordion¶
Expansion is a panel whose content opens and closes under its header,
for details people only sometimes need:
<ui.Expansion title="Delivery" description="Address and speed" icon="send" @bind="delivery_open">
<ui.TextField label="Street" @bind="street" />
<Fill name="actions">
<ui.Button variant="ghost" @click="delivery_open = False">Cancel</ui.Button>
<ui.Button @click="save">Save</ui.Button>
</Fill>
</ui.Expansion>
@binda bool to open and close it from code, or leave it unbound (open="true"starts it open). It emitsopenandclosetoo.- The header shows
title,descriptionand an optionalicon;<Fill name="header">replaces them with your own. The arrow sits on the right;toggle="before"moves it left,toggle="none"hides it. <Fill name="actions">adds a row of buttons under the content.- Closed content stays rendered, so what was typed in it survives, but it
can't be reached with Tab or by screen readers.
lazy="true"renders it only once the panel is first opened, for content that is slow to build. disabled="true"stops it opening;levelsets the heading level of the header (3 by default).
Accordion stacks panels and keeps one open at a time:
optionstakeSelect's forms; a dict can adddescription,iconanddisabled. The content between the tags is each panel's body, andpanelinside it is that panel's value, likeTabs.@bindholds the open panel's value (Nonewhen all are closed).multiple="true"lets several stay open and binds a list. Unbound, it keeps its own state.toggle,level,lazyanddisabledapply to every panel.- Keyboard: Up and Down move between the headers (skipping disabled ones), Home and End jump to the first and last.
4. Forms¶
The fields¶
All fields take a label, a hint (small grey text underneath), an error
(shown instead of the hint, in red), required (adds a red *), disabled
and name (the field's name inside a <form>), and all work with @bind.
TextField, TextArea and NumberField also take readonly="true": the
text can be selected and copied but not changed (a link to share, say).
<ui.TextField label="Full name" @bind="name" placeholder="Ada Lovelace" />
<ui.TextField label="Email" type="email" @bind="email" autocomplete="email" />
<ui.TextField label="Password" type="password" @bind="password" />
<ui.TextField label="Birthday" type="date" @bind="birthday" />
<ui.TextArea label="Message" rows="5" @bind="message" />
<ui.NumberField label="Guests" min="1" max="12" @bind="guests" />
<ui.Select label="Room" placeholder="Choose a room" options="Single, Double, Suite" @bind="room" />
<ui.RadioGroup label="Breakfast" options="None, Continental, Full" inline="true" @bind="breakfast" />
<ui.Checkbox label="Airport pickup" @bind="pickup" />
<ui.Switch label="Email me offers" @bind="offers" />
<ui.DatePicker label="Arrival" @bind="arrival" />
<ui.TextField label="Phone" mask="(999) 999-9999" @bind="phone" />
<ui.Autocomplete label="Country" :options="countries" @bind="country" />
What each one gives your field:
| Control | Your field gets |
|---|---|
TextField, TextArea |
a str. TextField's type can be text, email, password, search, url, tel, date, time; dates and times arrive as text such as "2026-10-29" (for a real date, use DatePicker) |
NumberField |
an int while your field is an int (or None), otherwise a float. While the box is empty or holds something that isn't a number yet, your field keeps its last value |
Select, RadioGroup |
the chosen option's value |
Checkbox, Switch |
a bool |
DatePicker |
a datetime.date, or None until one is chosen |
Autocomplete |
the chosen option's value (with free="true", the typed text) |
ButtonToggle |
the chosen value; with multiple="true", a list |
CascadeSelect |
the chosen option's value (a leaf, or any level with any_level="true") |
Chips |
the chosen chip's value; with multiple="true", a list |
ChipInput |
a list: the typed texts, or the chosen options' values |
Slider |
a number; with range="true", a (low, high) tuple |
Shaped input: mask¶
TextField's mask shapes what's typed, keeping the cursor where the user
is typing: 9 is a digit, a a letter, * either, and anything else is
shown as it is.
<ui.TextField label="Phone" mask="(999) 999-9999" @bind="phone" />
<ui.TextField label="Card number" mask="9999 9999 9999 9999" @bind="card" />
<ui.TextField label="Plate" mask="aa-9999" placeholder="AB-1234" @bind="plate" />
Your field gets the shaped text, "(555) 123-4567", and a starting value
such as "5551234567" is shown in the shape. Without a placeholder the
field shows the mask's shape, (___) ___-____, and a mask of digits only
brings up the number pad on phones. To check it's all there,
rules.masked("(999) 999-9999"); for just the digits,
pyweft.masks.typed(mask, phone).
Options for Select and RadioGroup¶
options accepts whatever is handiest:
<ui.Select options="Small, Medium, Large" @bind="size" />
<ui.Select :options="['Small', 'Medium', 'Large']" @bind="size" />
<ui.Select :options="[(1, 'One star'), (5, 'Five stars')]" @bind="rating" />
<ui.Select :options="[{'value': 'nl', 'label': 'Netherlands'}, {'value': 'in', 'label': 'India'}]" @bind="country" />
Your field receives the value, not the label, and keeps its type: with
the pairs above, rating becomes the int 5. A Select with a
placeholder shows it, greyed out, until something is chosen; your field
stays None until then.
Suggestions as you type: Autocomplete¶
A text field with a list of suggestions that narrows as the user types:
It takes options like Select (text, (value, label) pairs, or dicts),
and your field gets the chosen option's value; the field shows its
label. The typed text is matched anywhere in a label (match="starts" for
the start only), the match is shown in bold, and up to max_results (8)
suggestions show. With free="true" your field gets whatever is typed,
so a suggestion is a shortcut rather than a must.
Your own suggestions. <Fill name="option"> lays each one out; it gets
option (with label, value, and data, the dict you passed, so any
key of yours is there), active (the one Enter would pick) and query:
<ui.Autocomplete label="Assign to" :options="people" @bind="assignee">
<Fill name="option">
<span class="avatar">{{ option['data']['initials'] }}</span>
<span>{{ option['label'] }} <small>{{ option['data']['email'] }}</small></span>
<ui.Badge :kind="'primary' if active else 'neutral'">{{ option['data']['role'] }}</ui.Badge>
</Fill>
<Fill name="empty">Nobody matches "{{ query }}"</Fill>
</ui.Autocomplete>
people = [
{"value": "ada", "label": "Ada Lovelace", "email": "ada@engine.org", "initials": "AL", "role": "Owner"},
{"value": "grace", "label": "Grace Hopper", "email": "grace@cobol.net", "initials": "GH", "role": "Admin"},
]
Suggestions from a server. @search gets the text on every keystroke;
fetch the matches into options, set match="none" so they're shown as
they come, and loading while you wait ("Searching…"):
<ui.Autocomplete label="City" :options="cities" match="none" :loading="searching"
@search="find_cities(event)" @bind="city" />
from pyweft import http
cities: list = []
searching: bool = False
_searches: int = 0
async def find_cities(self, query):
self._searches += 1
mine = self._searches
self.searching = True
found = await http.get_json("https://api.example.com/cities", params={"q": query})
if mine == self._searches: # typing on started a newer search: drop this one
self.cities = [(c["id"], c["name"]) for c in found]
self.searching = False
- Keyboard: Down and Up move through the suggestions (Down also opens
them), Enter picks, Esc closes them and, pressed again, puts back the
chosen option's label. Leaving the field closes them too. While the list
is closed, Enter emits
enter, for submitting. - Also:
@selectgets the whole option you passed (the dict, not just its value);min_charswaits for that many characters;placeholder,hint,error,required,disabledand@blurwork as on the other fields, soFormchecks it like any other.
A row of choices: ButtonToggle¶
A button toggle is a joined row of buttons for a small, fixed set of choices: a view switch, text styles, alignment.
<ui.ButtonToggle label="Range" options="Day, Week, Month" @bind="view" />
<ui.ButtonToggle aria_label="Text style" multiple="true" @bind="styles" :options="[
{'value': 'bold', 'label': 'Bold', 'icon': 'bold', 'icon_only': True},
{'value': 'italic', 'label': 'Italic', 'icon': 'italic', 'icon_only': True},
]" />
<ui.ButtonToggle label="Plan" options="Monthly, Yearly" full_width="true" @bind="plan" />
- One choice binds the chosen value;
multiple="true"binds a list, in the options' order. Clicking the chosen button again keeps it chosen, unlessallow_none="true". optionstakeSelect's forms; a dict can addicon,icon_only(just the icon: the label becomes its name for screen readers and its tooltip) anddisabled. A chosen button shows a check mark;check="false"hides it.sizeissm,mdorlg;full_width="true"stretches the buttons evenly across the row;orientation="vertical"stacks them.label,hint,error,required,disabledandnamework as on the other fields. Without alabel, name the group witharia_label.- Keyboard: the row is one Tab stop and the arrow keys move along it.
With one choice it is a radio group, so moving chooses; with
multiple, Space or Enter toggles the button with focus.
Compared with Chips, which also choose one or several: chips are
separate pills that wrap onto more lines and can be removed, for longer or
changing lists; a button toggle is one compact bar for a few fixed choices.
Nested choices: CascadeSelect¶
For a choice from a hierarchy (country, then state, then city; a product
category and its subcategories), CascadeSelect opens one level at a time:
<ui.CascadeSelect label="City" :options="places" @bind="city"
show_path="true" clearable="true" @load="load_cities(event)" />
places = [
{"value": "au", "label": "Australia", "children": [
{"value": "nsw", "label": "New South Wales", "children": [
{"value": "syd", "label": "Sydney"}, {"value": "new", "label": "Newcastle"}]},
]},
{"value": "ca", "label": "Canada", "children": None}, # loaded when it opens
]
async def load_cities(self, country):
states = await fetch_states(country) # your own code
self.places = [dict(p, children=states) if p["value"] == country else p
for p in self.places]
optionsnest the wayTreenodes do:value,label,children, plusiconanddisabled. Values must be unique.- Choosing a leaf binds its value and closes the panel;
@selectalso gets the path of values (["au", "nsw", "syd"]). Withany_level="true"a group can be chosen too, and its arrow opens it. "children": Nonemeans "not loaded yet": the first time that group opens it emitsloadwith its value and shows a spinner until you fill its children in (anasynchandler is fine).- The field shows the chosen label, or the whole path with
show_path="true"(separatorsets the " / " between).clearable="true"adds a × that clears it. <Fill name="option">lays out options (it getsoptionandchosen).- Keyboard: Enter, Space or Down opens it; Up and Down move, Right opens a group, Left goes back, Enter chooses, Esc closes. On narrow screens each level opens over the last, with a "‹ Back" row at its top.
label,hint,error,required,disabled,placeholderandnamework as on the other fields, and it emitsblur, so it works withForm.
Chips: Chips and ChipInput¶
Chips is a row of chips to choose from, a compact alternative to radio
buttons and checkboxes:
<ui.Chips label="Size" options="Small, Medium, Large" @bind="size" />
<ui.Chips label="Interests" :options="interests" multiple="true" @bind="chosen" />
<ui.Chips label="Filters" :options="filters" removable="true" selectable="false"
@remove="drop_filter(event)" />
- One choice binds the chosen value (click it again to un-choose, unless
required="true");multiple="true"binds a list, in the options' order. optionstakeSelect's forms; a dict can addiconanddisabled. A chosen chip shows a check mark.removable="true"adds an × that emitsremovewith the chip's value: remove it fromoptionsyourself. Withselectable="false"the chips are only labels, like a list of applied filters.<Fill name="option">lays out a chip's text (it getsoptionandselected).- Keyboard: the row is one Tab stop; Left and Right move, Home and End jump, Space or Enter chooses, Delete or Backspace removes.
ChipInput turns what's typed into chips, for tags, recipients or
keywords. It binds a list:
<ui.ChipInput label="Tags" @bind="tags" />
<ui.ChipInput label="Invite" :options="people" free="false" @bind="invitees">
<Fill name="chip">{{ chip['data']['initials'] }} {{ chip['label'] }}</Fill>
</ui.ChipInput>
- Enter or a comma adds what's typed (
separators=",;"for more), and a pasted"a, b, c"adds three. × removes a chip, and so does Backspace in the empty box. Leaving the field adds what's left in it (add_on_blur="false"to drop it instead). - With
optionsit suggests as you type, leaving out the ones already added; the list holds the options' values, and text typed in full ("grace hopper") counts as that option.free="false"allows only the options. - Duplicates are skipped (ignoring case for text);
maxlimits how many chips.<Fill name="chip">lays out a chip (it getschip:label,value,data),<Fill name="option">a suggestion, as inAutocomplete. It emitsaddandremovewith the value, and works withForm:rules.required()asks for at least one,rules.min_length(2)for two.
Sliders¶
Slider picks a number by dragging a thumb along a track, or with
range="true" a pair of numbers with two thumbs:
<ui.Slider label="Volume" @bind="volume" format="{}%" />
<ui.Slider label="Price" :min="0" :max="500" :step="10" range="true"
@bind="price" format="${}" value_label="side" />
<ui.Slider label="Rating" :min="1" :max="5" ticks="true" marks="1, 3, 5" @bind="rating" />
min(0),max(100) andstep(1) set the scale. The value is anintwhile all three are whole numbers, else afloat, and always lands on a step. A range binds a(low, high)tuple; with no value it spans the whole scale.- The thumbs of a range can pass each other: the value is always the two positions in order.
value_labelshows the value:drag(default) in a bubble above the thumb while it's dragged, hovered or focused,always,side(next to the label) ornone.formatshapes it ("{}%","${}","{:.2f}"), and screen readers hear the same text.ticks="true"draws a dot at every step (up to 100 steps);markslabels points under the track:"0, 50, 100", or(value, label)pairs.- The bound field follows the thumb as it moves;
lazy="true"updates it only when the thumb is let go. It emitschangeon release andblur. - Keyboard: arrows move one step, Page Up and Page Down move further, Home and End jump to the ends.
label,hint,error,disabledandnamework as on the other fields (a range sendsname_minandname_max), so it works withForm.
Checkbox and Switch labels with links¶
Put the label between the tags when it needs more than text:
Dates: DatePicker¶
A field that opens a calendar, and gives your field a datetime.date:
<ui.DatePicker label="Check-in" @bind="check_in" :min="today" />
<ui.DatePicker label="Check-out" @bind="check_out" :min="check_in or today" required="true" />
<ui.DatePicker label="Birthday" @bind="birthday" max="2010-12-31" format="%d/%m/%Y" week_start="sunday" />
import datetime
check_in = None # a datetime.date once chosen
check_out = None
@property
def today(self):
return datetime.date.today()
minandmaxtake adateor text such as"2026-12-31". Days outside them are crossed out and can't be chosen, and the month arrows stop at them.- The field shows the date as "3 Nov 2026", or
placeholder("Choose a date") before one is chosen;formattakes astrftimepattern instead. week_start="sunday"starts the weeks on Sunday (Monday by default).- Today and Clear buttons sit under the calendar.
required="true"hides Clear, so a chosen date can only be changed, not removed. namesends the date asYYYY-MM-DDinside a<form>.- With a form, it works like any field:
:error="form.error('check_in')" @blur="form.touch('check_in')". It emitsblurwhen the calendar closes. - Jumping to another month or year: click the month's name in the
calendar's header to choose from the year's months, or the year to choose
from twelve years at a time (the arrows then move a year, or twelve).
Choosing a year shows its months, and choosing a month shows its days.
Months and years outside
minandmaxare crossed out. - Keyboard: Enter, Space or the down arrow opens the calendar on the chosen day (or today). The arrows move by day and week, Page Up and Page Down by month, Home and End to the ends of the week, Enter picks, and Esc closes it. In the months and years, the arrows move between them, and Esc goes back a step.
Date ranges: DateRangePicker¶
For a stay, a booking or a report period: two months side by side, the first day clicked and then the last, with the range shown as a band while the pointer (or the keyboard) moves.
stay = None # (start, end) once both are chosen
def nights(self):
return (self.stay[1] - self.stay[0]).days if self.stay else 0
- Your field gets a
(start, end)pair ofdatetime.dates, start first whichever was clicked first, orNoneuntil both are chosen. Nothing changes until the last day is clicked. min_daysandmax_dayslimit the length, counted from the first day to the last (Saturday to Sunday is 1): once the first day is chosen, days that would make it too short or too long are crossed out.- The field shows "3 – 10 Nov 2026" (or "28 Dec 2026 – 3 Jan 2027"), and
the calendar's footer the number of days.
months="1"shows one month; on a narrow screen two months stack. - Esc while choosing drops the first day; again, it closes.
namesends<name>_startand<name>_endin a<form>. The rest (min,max,format,week_start,align,required,error,@blur) work as forDatePicker.
A calendar on the page: Calendar¶
DatePicker and DateRangePicker open a Calendar; it can also sit on the
page itself, always open:
It takes the same min, max, week_start, min_days and max_days,
binds a date (or with range="true" a (start, end) pair), and has the
same keyboard and the month and year views.
A complete form: validation and submitting¶
Form checks a component's fields against rules and says what needs
fixing, next to each field. This sign-up form shows nothing until a field
is left or the form is submitted; from then on each message follows what
the user types, so it disappears the moment the value is right.
<template>
<ui.Theme>
<ui.Card title="Create your account">
<form novalidate="" @submit.prevent="submit">
<ui.Column>
<ui.TextField ref="email" label="Email" type="email" required="true" autocomplete="email"
@bind="email" :error="form.error('email')" @blur="form.touch('email')" />
<ui.TextField ref="password" label="Password" type="password" required="true"
hint="At least 8 characters" @bind="password"
:error="form.error('password')" @blur="form.touch('password')" />
<ui.Select ref="country" label="Country" placeholder="Choose your country"
options="India, Netherlands, United States" @bind="country"
:error="form.error('country')" @blur="form.touch('country')" />
<ui.Checkbox ref="agreed" @bind="agreed" :error="form.error('agreed')">I agree to the terms</ui.Checkbox>
<If cond="done">
<ui.Alert kind="success" title="Welcome!">Your account for {{ email }} is ready.</ui.Alert>
</If>
<ui.Button type="submit" variant="primary" block="true" :loading="saving">Create account</ui.Button>
</ui.Column>
</form>
</ui.Card>
</ui.Theme>
</template>
<code>
import asyncio
from pyweft_ui import Form, rules, ui
email: str = ""
password: str = ""
country = None
agreed: bool = False
saving: bool = False
done: bool = False
form = Form(
email=[rules.required("Enter your email"), rules.email()],
password=[rules.required("Choose a password"), rules.min_length(8)],
country=rules.required("Choose a country"),
agreed=rules.accepted("Agree to the terms to continue"),
)
async def submit(self):
if not self.form.check(): # shows every message, focuses the first field to fix
return
self.saving = True
await asyncio.sleep(1) # send it to your server here
self.saving = False
self.done = True
</code>
How it fits together:
form = Form(field=rules, ...)names each field of the component and the rules it must pass: one rule, or a list checked in order (the first that fails gives the message).:error="form.error('email')"shows the field's message, or nothing while there's nothing to say.@blur="form.touch('email')"says the user has left the field, which is when its message starts to show.TextField,TextArea,NumberFieldandSelectreport leaving; every field,Checkbox,SwitchandRadioGroupincluded, shows anerror.self.form.check()is for submitting: it shows every message, moves the cursor to the first field to fix (the one whoserefhas the same name) and returnsTrueonly when every field passes.<form @submit.prevent="submit">runssubmitwhen the user clicks thetype="submit"button or presses Enter in a field, and.preventstops the browser from reloading the page.novalidate=""turns off the browser's own pop-up checks, so your messages are the only ones.:loading="saving"shows a spinner on the button and makes it unclickable while theasynchandler waits, so nobody submits twice.
Rules¶
| Rule | Passes when |
|---|---|
required(message) |
there is a value: not None, blank text or an empty list |
accepted(message) |
a Checkbox or Switch is on |
email(message), url(message) |
the text looks like an email address, or an http(s):// address |
min_length(n), max_length(n) |
the text has at least / at most n characters (a list, n items) |
min_value(n), max_value(n) |
the number is at least / at most n |
pattern(regex, message) |
the whole text matches regex |
one_of(choices) |
the value is one of choices |
same_as("field", message) |
the value equals another field's, as when a password is typed twice |
masked(mask, message) |
the text fills every slot of an input mask |
check(test, message) |
test(value) is true: check(lambda v: v != "admin", "That name is taken") |
Every rule takes the message to show, and has a sensible one if you leave
it out. Only required and accepted complain about an empty field; the
others let it through, so put required() first when a field must be
filled in.
Your own rule is any function of the value that returns a message, or
None when the value is fine. Give it a second parameter to read the
other fields:
def no_spaces(value):
return "Leave out the spaces" if " " in value else None
def not_before_start(value, form):
if value and form.value("start") and value < form.value("start"):
return "The end can't be before the start"
form = Form(
username=[rules.required(), no_spaces],
end=not_before_start,
)
What the form can tell you¶
| Call | Gives |
|---|---|
form.error("email") |
the message to show under the field: "" until it's left or submitted, and while it passes |
form.touch("email") |
(nothing) the field was left: show its message from now on |
form.check() |
shows every message, focuses the first field to fix (focus=False skips that), returns whether all pass |
form.valid |
whether every field passes right now, messages showing or not, for :disabled="not form.valid" |
form.errors |
{field: message} for every message showing |
form.problem("email") |
the field's message whether it's showing or not |
form.reset() |
hides every message again: after saving, when the fields start over empty |
Rules are checked again on every update, against the fields' current
values, so there is nothing to keep in step. A name in Form must be a
field of the component; a ref of the same name is optional, and is what
check() focuses.
5. Buttons¶
<ui.Row>
<ui.Button variant="primary">Save</ui.Button> <!-- the main action -->
<ui.Button>Cancel</ui.Button> <!-- secondary, the default -->
<ui.Button variant="ghost">Skip</ui.Button> <!-- low emphasis -->
<ui.Button variant="danger" icon="trash">Delete</ui.Button>
</ui.Row>
<ui.Row>
<ui.Button size="sm">Small</ui.Button>
<ui.Button size="lg">Large</ui.Button>
<ui.Button icon="download">With an icon</ui.Button>
<ui.Button block="true">Full width</ui.Button>
</ui.Row>
@clickruns your handler. It can beasync; pair it with:loadingfor long actions (see the form above).type="submit"inside a<form>submits the form. The default,button, never does, so buttons in forms don't submit by accident.loadingshows a spinner instead of the icon and disables the button.disabledgreys it out.- An icon-only button needs a text alternative for screen readers:
<ui.Button icon="x" aria-label="Close" />.
A menu of actions: Menu¶
A button that opens a list of actions, and tells you which was chosen:
<ui.Menu label="Actions" @select="act(event)" :items="[
{'value': 'edit', 'label': 'Edit', 'icon': 'edit'},
{'value': 'copy', 'label': 'Duplicate', 'icon': 'copy'},
{'value': 'archive', 'label': 'Archive', 'disabled': True},
'-',
{'value': 'delete', 'label': 'Delete', 'icon': 'trash', 'danger': True},
]" />
<ui.Menu label="More actions" icon="more" icon_only="true" variant="ghost" align="end"
:items="['Rename', 'Move']" @select="act(event)" />
itemsare text ("Rename", which is also its value),(value, label)pairs, or dicts withvalueandlabeland optionallyicon,danger(red) anddisabled."-"draws a divider.@selectgets the chosen item's value asevent.- The button takes
label,icon,variant(primary,secondary,ghost) anddisabled. Withicon_only="true"it shows just the icon, andlabelbecomes its name for screen readers.align="end"lines the list up with the button's right edge, for a button at the end of a row. - Keyboard: Enter, Space or the down arrow opens it on the first item, the up arrow on the last. The arrows, Home and End move (skipping disabled items), Enter picks, Esc closes it and returns to the button, and Tab closes it and moves on. A click anywhere else closes it too.
Submenus, headings and richer items¶
<ui.Menu label="File" @select="act(event)" :items="[
{'heading': 'Document'},
{'value': 'new', 'label': 'New', 'icon': 'plus', 'hint': 'Ctrl+N'},
{'label': 'Share', 'icon': 'send', 'items': [
'Email',
{'label': 'Export as', 'items': ['PDF', 'Word']},
]},
'-',
{'value': 'trash', 'label': 'Move to trash', 'danger': True,
'description': 'Kept for 30 days'},
]" />
- An item with
itemsopens a submenu beside it, as deep as you like. It opens on hover, on a click, or with the right arrow; the left arrow goes back.@selectgets the value chosen at any depth. hintshows text at the right of an item (a keyboard shortcut),descriptiona second line under its label, and{"heading": "..."}a small heading over a group of items.position="above"opens the list above the button, for a button at the bottom of the page. Submenus open to the right, or to the left withalign="end", and switch sides when there's no room.
Your own trigger and items¶
<ui.Menu label="Account" align="end" :items="account" @select="act(event)">
<Fill name="trigger"><img class="avatar" src="assets/me.png" alt="" /></Fill>
<Fill name="item">
<b>{{ item['label'] }}</b>
<If cond="item['data'].get('email')"><small>{{ item['data']['email'] }}</small></If>
</Fill>
</ui.Menu>
<Fill name="trigger">replaces the button's look with your own content: an avatar, a picture, some text. It is still the menu's button (focusable, opened with the keyboard,labelas its name), so put content in it, not another button.<Fill name="item">lays out every item, submenus included. It getsitem, withlabel,value,icon,description,hint,itemsanddata(the item as you wrote it, for your own keys).
Right-click menus: ContextMenu¶
<ui.ContextMenu :items="['Cut', 'Copy', 'Paste', '-', {'label': 'Sort by', 'items': ['Name', 'Date']}]"
@select="act(event)">
<div class="files">...</div>
</ui.ContextMenu>
- Right-clicking inside the content (a long press on most touch screens) opens the menu at the pointer instead of the browser's own. From the keyboard, the Menu key or Shift+F10 opens it while focus is inside.
items, submenus and<Fill name="item">work as onMenu;@selectgets the value and@openfires as it opens, for remembering what was right-clicked. Esc or choosing an item gives focus back to where it was.- It stays inside the window, near the bottom or right edge too.
disabled="true"leaves the browser's own menu alone.
6. Messages, badges and loading¶
Alert¶
<ui.Alert kind="info">Your trial ends in 3 days.</ui.Alert>
<ui.Alert kind="success" title="Saved">Your changes are live.</ui.Alert>
<ui.Alert kind="warning" title="Heads up">This affects every user.</ui.Alert>
<ui.Alert kind="danger" title="Payment failed">Check your card details and try again.</ui.Alert>
To let the user close it, add dismissible="true" and handle @close. The
alert doesn't hide itself; you decide, typically with a field:
<If cond="show_tip">
<ui.Alert kind="info" dismissible="true" @close="show_tip = False">
Tip: press Enter to search.
</ui.Alert>
</If>
Badge¶
Small labels for status and counts:
<ui.Badge>draft</ui.Badge>
<ui.Badge kind="success">paid</ui.Badge>
<ui.Badge kind="warning">due soon</ui.Badge>
<ui.Badge kind="danger">overdue</ui.Badge>
<ui.Badge kind="primary">{{ unread }} new</ui.Badge>
Spinner and loading states¶
<If cond="loading">
<ui.Row><ui.Spinner /><span>Loading orders…</span></ui.Row>
</If>
<Else>
<p>{{ len(orders) }} orders</p>
</Else>
A pattern for loading data when a component appears:
<template>
<ui.Card title="Orders">
<If cond="orders is None"><ui.Spinner /></If>
<Else>
<For each="order in orders" key="order['id']">
<ui.Row justify="between">
<span>#{{ order['id'] }}</span>
<ui.Badge :kind="'success' if order['paid'] else 'warning'">
{{ 'paid' if order['paid'] else 'unpaid' }}
</ui.Badge>
</ui.Row>
</For>
</Else>
</ui.Card>
</template>
<code>
import asyncio
from pyweft_ui import ui
orders = None
async def on_init(self):
await asyncio.sleep(1) # fetch from your server here
self.orders = [{"id": 101, "paid": True}, {"id": 102, "paid": False}]
</code>
How far along: Progress¶
<ui.Progress label="Uploading" :value="sent" :max="total" format="{} of {} files" show_value="true" />
<ui.Progress label="Waiting for the server" />
<ui.Progress shape="circle" :value="72" kind="warning" size="lg" />
valueout ofmax(100by default;0.4withmax="1"works too) fills the bar, sliding as the value changes. Without a value it is indeterminate: a bar moving back and forth, or a turning ring, for work whose length you don't know.show_value="true"puts the value beside the label, as a percentage;formatshapes it from the value instead ("{} of 12 files","{:.1f} MB"). Screen readers hear that same text.bufferdraws a lighter fill ahead of the value (buffered video).kindisprimary,success,warningordanger;sizeissm,mdorlg(a bar's thickness, a ring's diameter);striped="true"adds moving stripes.shape="circle"draws a ring with the value in the middle (not atsize="sm"), withlabelbeside it.- Without a
label, name it for screen readers witharia_label.
To show progress from an async handler, change the value and call
self.state_has_changed() after each step, so the page updates before
the handler ends:
async def upload(self):
for self.sent in range(1, self.total + 1):
await send_file(self.sent)
self.state_has_changed()
7. Dialogs¶
A dialog is open while the field you bind it to is True.
Asking for confirmation¶
<template>
<ui.Theme>
<ui.Row>
<ui.Button variant="danger" icon="trash" @click="confirming = True">Delete project</ui.Button>
<span>{{ status }}</span>
</ui.Row>
<ui.Dialog title="Delete this project?" @bind="confirming">
<p>“{{ project }}” and its files will be removed. This can't be undone.</p>
<Fill name="footer">
<ui.Button @click="confirming = False">Cancel</ui.Button>
<ui.Button variant="danger" @click="delete_project">Delete</ui.Button>
</Fill>
</ui.Dialog>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
project: str = "Website redesign"
confirming: bool = False
status: str = ""
def delete_project(self):
self.status = f"Deleted {self.project}."
self.confirming = False
</code>
- Opening and closing are just your field. Set it to
Trueto open andFalseto close. - The user can close it too, with Esc, a click on the dark area around
it, or the X. The dialog sets your field to
Falseand emits@close. For a question the user must answer, adddismissible="false": then only your own buttons can close it. widthsets its maximum width (32remby default).- Forms work inside dialogs: put a
<form @submit.prevent>with fields in it, and close the dialog from the submit handler when the input is valid.
Running code whenever it closes¶
@close runs however the dialog was dismissed, which is handy for resetting
what was typed into it:
<ui.Dialog title="Rename" @bind="renaming" @close="new_name = ''">
<ui.TextField label="New name" @bind="new_name" />
</ui.Dialog>
@close only fires when the dialog closes itself (Esc, the dark area, the
X); when your own code sets the field to False, you're already there to do
whatever's needed.
Sheets from the bottom: BottomSheet¶
A bottom sheet slides up from the bottom of the window, over a dimmed page. It suits phones, where it sits under the thumb: a list of actions, or a few settings such as filters.
<ui.Button @click="sharing = True">Share</ui.Button>
<ui.BottomSheet title="Share" @bind="sharing" @select="share(event)" :items="[
{'value': 'link', 'label': 'Copy link', 'icon': 'copy'},
{'value': 'email', 'label': 'Email', 'icon': 'send', 'description': 'Opens your mail app'},
]" />
<ui.BottomSheet title="Filters" @bind="filtering">
<ui.Chips label="Status" options="Open, Done" multiple="true" @bind="status" />
<Fill name="actions">
<ui.Button variant="primary" @click="filtering = False">Show results</ui.Button>
</Fill>
</ui.BottomSheet>
@binda bool, as withDialog: open while it isTrue.items(the formsMenutakes; dicts may addicon,description,danger,disabled) make it a list of actions: choosing one emitsselectwith its value and closes the sheet. Anything else goes between the tags, and<Fill name="actions">is a row of buttons pinned to its bottom.- Closing: Esc, a tap on the dimmed page, or a swipe down on the top of
the sheet (drag the handle down and let go).
dismissible="false"leaves only your own buttons.@closesays how it closed:"esc","backdrop","swipe"or"select"; setting the bound field yourself emits nothing. - It is full width on a phone and centred on wider screens, at most
max_widthwide (40rem). Tall content scrolls inside it (it takes up to 85% of the window's height). Without atitle, name it for screen readers witharia_label. - Once opened, it stays on the page while closed (hidden and out of reach) so it can slide away; what was typed in it is kept until you clear it.
Small panels beside a button: Popover¶
A popover is a button that opens a small panel of your own content beside it: a share link, a quick setting, a short explanation, a preview.
<ui.Popover label="Share" icon="send" title="Share this page" @bind="sharing">
<ui.TextField label="Link" :value="link" readonly="true" />
<ui.Button variant="primary" @click="copy">Copy link</ui.Button>
</ui.Popover>
<ui.Popover open_on="hover" variant="link" label="Ada Lovelace">
<Fill name="trigger">@ada</Fill>
Ada Lovelace · Owner · 12 projects
</ui.Popover>
- The button takes
label,icon,icon_only,variant(primary,secondary,ghostorlink) andcaret, likeMenu's.<Fill name="trigger">puts your own content in it, such as an avatar or a name; the popover's own button wraps it, so don't put another button there (labelthen names it for screen readers). - Click mode (the default):
@binda bool to open and close it from code, or leave it unbound. A click outside or Esc closes it, and Esc gives focus back to the button. As it opens, focus moves to its first control. It emitsopenandclose. open_on="hover"makes a preview card that shows after a short delay on hover or keyboard focus, and stays while the pointer is on the card, so links in it can be clicked. Esc hides it. It is shown by CSS alone, so@bindand the events don't apply.- Placement:
positionisbelow(the default),above,startorend;alignlines it up with the button'sstart,centerorend. A small arrow points at the button (arrow="false"hides it).widthdefaults to20remand never exceeds the window;titleadds a heading.
Labels on hover: Tooltip¶
<ui.Tooltip text="Bold (Ctrl+B)">
<ui.Button variant="ghost" icon="bold" aria-label="Bold"></ui.Button>
</ui.Tooltip>
<ui.Tooltip text="Delete forever: there is no undo" tip_id="delete-tip">
<ui.Button icon="trash" aria-label="Delete" aria-describedby="delete-tip"></ui.Button>
</ui.Tooltip>
- A short text label over whatever it wraps, shown on hover (after a
short delay) and at once on keyboard focus. Esc hides it until the
pointer or focus moves away.
position:above(the default),below,start,end. - It is shown by CSS, with no wait for Python. Text only: anything
clickable belongs in a
Popover. - An icon-only button still needs its own
aria-label; the tooltip is for sighted users. For screen readers to also read the tooltip as a description, give the tooltip atip_idand the controlaria-describedbywith the same id.
8. Tabs¶
Tabs shows a row of tabs. Bind it to a field, and put what each tab shows
between its tags:
<template>
<ui.Theme>
<ui.Tabs label="Account" options="Profile, Security, Billing" @bind="tab">
<If cond="tab == 'Profile'">
<ui.TextField label="Display name" @bind="display_name" />
</If>
<ElseIf cond="tab == 'Security'">
<ui.Switch label="Two-step sign-in" @bind="two_step" />
</ElseIf>
<Else>
<p>You're on the free plan.</p>
</Else>
</ui.Tabs>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
tab: str = "Profile"
display_name: str = "Ada"
two_step: bool = False
</code>
optionstakes the same forms asSelect's: a comma-separated string, a list,(value, label)pairs or dicts. Your field gets the value.- Start your field at one of the values, as
tab = "Profile"does above. Until it is, the first tab is shown. tabinside the tags is always the chosen tab, even without@bind, for tabs nothing else needs to know about:
<ui.Tabs options="Day, Week, Month" variant="pills">
<p>Sales this {{ tab.lower() }}: {{ sales[tab] }}</p>
</ui.Tabs>
variantisline(underlined, the default) orpills.labelnames the tab row for screen readers.- Leave the tags empty (
<ui.Tabs ... />) to use the tab row on its own, for example to switch what a whole page shows.
Only the chosen tab's content exists on the page, so what's typed into a field on another tab lives in your fields, not in the hidden tab.
A task in steps: Stepper¶
A stepper splits a long task (a checkout, a sign-up) into steps shown one at a time, with a numbered header and Back / Next under each step:
<ui.Stepper label="Checkout" :steps="steps" @bind="step" linear="true"
@next="check(event)" @finish="place_order" finish_label="Place order">
<If cond="step == 'account'"> ... </If>
<ElseIf cond="step == 'delivery'"> ... </ElseIf>
<ElseIf cond="step == 'gift'"> ... </ElseIf>
<Else> ... a summary ... </Else>
</ui.Stepper>
steps = [("account", "Account"), ("delivery", "Delivery"),
{"value": "gift", "label": "Gift message", "optional": True},
("review", "Review")]
account = Form(email=[rules.required(), rules.email()])
delivery = Form(street=rules.required(), city=rules.required())
def check(self, step): # False keeps the stepper on this step
form = {"account": self.account, "delivery": self.delivery}.get(step)
return form.check() if form is not None else True
stepstakeSelect's forms; a dict can adddescription(a line under the label),optional,error(a message: the step turns red),doneanddisabled. The content between the tags is the current step's body;step(its value) andindexare available in it.@nextruns when Next is pressed, before moving on; if it returnsFalsethe stepper stays. With aFormper step,form.check()shows that step's messages and focuses the first field to fix. On the last step the button says Finish (finish_label) and emitsfinish.- A step left with Next counts as done and shows a check mark (a step's
donekey overrides that). Withlinear="true"a step can't be opened from its header until the steps before it are done; optional ones don't hold it up. Without it, any header opens its step. orientation="vertical"puts each step's content under its header, which suits narrow pages;label_position="bottom"puts the labels under the numbers. On a narrow screen the header row scrolls sideways.<Fill name="actions">replaces the Back / Next row. It getsstep,index,first,last, andgo_next()/go_back()to call:
<Fill name="actions">
<ui.Button variant="ghost" @click="go_back()">Previous</ui.Button>
<ui.Button variant="primary" @click="go_next()">{{ 'Pay now' if last else 'Continue' }}</ui.Button>
</Fill>
back_label,next_label,finish_labelandoptional_labelchange the words.
Where you are: Breadcrumbs¶
<ui.Breadcrumbs :items="[('/', 'Home'), ('/orders', 'Orders'), 'Order #1041']" />
<ui.Breadcrumbs :items="trail" max="4" separator="slash" />
itemsare(to, label)pairs, dicts withto,label,iconanddisabled, or plain text for a level with no link. The last item is the current page: shown as text and marked as the current page for screen readers.- A
tostarting with/goes through the router, like<Link>; any other is an ordinary link. If you listen to@select, the items are buttons that emit theirtoinstead, for apps without the router. separator:chevron(the default),slash, or any text.max="4"folds the levels in between into a "…" menu once the trail is longer. Long labels are cut short with "…" (the full text shows on hover), and the trail wraps on narrow screens.<Fill name="item">lays out an item; it getsitemandcurrent.
In an app with pages (router()), auto="true" builds the trail from
the current URL and keeps it up to date:
# pages/orders.weft # pages/order.weft
crumb = "Orders" route = "/orders/{id}"
crumb = "Order #{id}"
Each level is labelled by its page's crumb (route parameters fill in),
else its URL segment made readable; home (default "Home") names /, and
a level with no page of its own is shown as text.
9. Tables, trees and charts¶
Table shows a list of dicts (or objects) as rows. Name the fields to show
as columns:
Leave out columns and every field of the first row becomes a column.
Headings come from the field names (order_date becomes "Order date"), and
columns of numbers line up on the right by themselves.
A complete example: headings to click for sorting, a formatted amount, a badge in the status column, and a row the user can pick:
<template>
<ui.Theme>
<ui.Table :rows="orders" :columns="COLUMNS" row_key="id"
sortable="true" sort="-total" @select="open_order">
<Fill name="cell">
<If cond="column == 'status'">
<ui.Badge :kind="'success' if value == 'paid' else 'warning'">{{ text }}</ui.Badge>
</If>
<Else>{{ text }}</Else>
</Fill>
<Fill name="empty">No orders yet.</Fill>
</ui.Table>
<p>{{ message }}</p>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
COLUMNS = [
("id", "Order"),
"customer",
"status",
{"key": "total", "label": "Amount", "format": ",.2f"},
]
orders: list = [
{"id": 1041, "customer": "Ada Lovelace", "status": "paid", "total": 1250.0},
{"id": 1042, "customer": "Grace Hopper", "status": "pending", "total": 89.9},
]
message: str = "Click an order."
def open_order(self, order):
self.message = f"Order {order['id']} for {order['customer']}"
</code>
Columns can be written in four ways, mixed freely: a field name,
(field, heading), or a dict with key and any of:
| Key | Meaning |
|---|---|
label |
the heading |
format |
a format such as ",.2f" (1,250.00), ".0%" or "%d %b %Y" for dates, or a function from the value to text |
align |
start, center or end |
sortable |
False to stop one column from sorting |
Sorting: sortable="true" lets the user click a heading to sort, and
again to reverse. sort="total" starts sorted by total, sort="-total"
largest first. Text sorts ignoring case, and empty cells always go last.
Picking a row: @select makes rows clickable (and reachable with Tab,
then Enter) and gives your handler the row. Without it, rows are plain.
Your own cell content: <Fill name="cell"> replaces every cell. Inside
it, row is the row, column the column's field name, value the raw
value and text the formatted text, so <Else>{{ text }}</Else> keeps the
other columns as they were. A button in a cell also counts as a click on
its row; write @click.stop on it to keep the two apart.
Rows that change: give row_key a field that identifies each row, such
as "id". Rows then keep their identity when the list is re-ordered or
filtered. Without it, rows are matched by their position in your list.
Other fields: striped="true" shades every other row, dense="true"
tightens the spacing, caption adds a title above the table, and empty
is the text shown when there are no rows ("Nothing to show"; the empty
fill replaces it with anything). On a narrow screen the table scrolls
sideways inside its frame.
Trees: Tree¶
A tree shows nested items that open and close: folders and files, categories, an org chart, permissions.
<ui.TextField label="Files" placeholder="Search" @bind="query" />
<ui.Tree label="Files" :nodes="files" @bind="selected" :filter="query"
:expanded="['src']" @load="load_folder(event)" @activate="open_file(event)" />
files = [
{"value": "src", "label": "src", "icon": "folder", "children": [
{"value": "app", "label": "app.weft", "icon": "file"},
]},
{"value": "docs", "label": "docs", "icon": "folder", "children": None}, # loads on open
]
async def load_folder(self, value):
children = await fetch_folder(value) # your own code
for node in self.files:
if node["value"] == value:
node["children"] = children
nodesare dicts withvalueandlabeland optionallyicon,children,disabledand keys of your own (node['data']in a fill); text and(value, label)or(value, label, children)tuples work too. Values must be unique across the tree. Afoldericon shows asfolder-openwhile its node is open.- Opening: the arrow opens and closes a node;
expandedlists the values open at the start andexpand_all="true"opens everything.@togglegets{"value": ..., "open": True/False}. - Choosing: a click (or Space) selects a node and
@bindgets its value; Enter or a double-click on a leaf emitsactivate(on a parent, they open or close it). - Loading on demand:
"children": Nonemeans "has children, not loaded yet". The first time such a node opens, the tree emitsloadwith its value and shows a spinner until you put its children in. Anasynchandler is fine. filtershows only the nodes whose label contains the text, with the parents leading to them opened and the match marked. Nodes not loaded yet aren't searched.<Fill name="node">lays out a node (it getsnode,level,expandedandselected);lines="true"draws guide lines;emptyandno_matcheschange the text shown when there's nothing.
Checkboxes¶
With checkable="true" every node has a checkbox and @bind is the list
of checked leaves. Checking a parent checks everything under it (and
unchecks it all when it was all checked); a parent with only some of its
leaves checked shows a dash. Disabled nodes keep their state. Values in
the list that aren't in the tree (yet) are kept.
Keyboard: the tree is one Tab stop. Up and Down move, Right opens a
node or moves to its first child, Left closes it or moves to its parent,
Home and End jump to the ends, * opens all the siblings, and typing a
letter jumps to the next node starting with it.
Charts¶
Five charts cover the everyday cases: BarChart, LineChart (with areas),
PieChart (and doughnuts), PolarAreaChart and RadarChart. They are
drawn in Python as SVG, so they work the same in the browser, in the
desktop app and in tests, follow the Theme (dark mode included) and lay
themselves out for the width they really have, a phone's too.
<ui.BarChart :data="sales" x="month" y="online, store" title="Sales"
format="${:,.0f}" @select="open_month(event)" />
<ui.LineChart :data="visits" x="day" y="desktop, mobile" curve="smooth" />
<ui.PieChart :data="{'Chrome': 64.7, 'Safari': 18.6, 'Other': 16.7}" />
sales = [
{"month": "Jan", "online": 4200, "store": 3100},
{"month": "Feb", "online": 5100, "store": 2900},
]
def open_month(self, picked):
print(picked["label"], picked["series"], picked["value"]) # Feb store 2900
Data can be a list of dicts or objects, a dict of columns
({"month": [...], "online": [...]}), a pandas or polars DataFrame, or, for
pies, a plain {label: value} dict. x names the field for the categories
and y the fields to plot ("online, store" or a list); left out, x is
the first text field and y every number field. Data in Chart.js's shape
({"labels": [...], "datasets": [{"label": ..., "data": [...]}]}) works
as it is, dataset settings such as type, fill, tension, stepped,
borderDash and yAxisID included.
Bars. Series sit side by side; stacked="true" piles them up and
stacked="percent" shows each as a share of 100%. horizontal="true"
turns the bars sideways (good for long names), and labels="true" writes
each value on its bar.
Lines and areas. curve is linear, smooth (it never overshoots
the data: no dips below zero that aren't there), step, step-before or
step-after. fill="true" makes an area chart; with stacked="true" the
areas pile up. A missing value (None) breaks the line, unless
connect_gaps="true". Dots mark each value when there are 40 or fewer;
points turns them on or off.
Per series, series takes dicts with a key and any of label,
color (a CSS colour, or primary, success, warning, danger,
info), type (bar, line or area: mix them for a combo chart),
axis="right" (a second scale, named with y2_label), stack (bars
with the same stack name pile up), dash (True or a pattern like
"2 4"), curve, points and width:
<ui.BarChart :data="weather" x="month" y="rain" y_label="Rain (mm)"
y2_label="Temperature (°C)" :series="[
{'key': 'temp', 'type': 'line', 'axis': 'right', 'curve': 'smooth', 'color': 'warning'}]" />
Pies. label and value name the fields when the data is rows (a
color field colours a slice). donut="true" leaves a hole (hole sets
its size, 0.6 of the radius) and center writes in it, "total" for the
sum, above center_label. half="true" draws a half circle. labels
on the slices are percent, value, label or none.
Polar area and radar. A PolarAreaChart takes the same data as a pie
but gives every slice the same angle and lets its length show the value.
A RadarChart takes the same data as a bar chart and draws one outlined
shape per series, with a spoke per category (three or more).
Everywhere: title and subtitle; height in pixels (the width is
the space the chart gets); legend is auto (when there's more than one
series), top, bottom, right or none, and clicking a legend entry
hides or shows that series; format shapes the values in tooltips and
labels (a format spec like ",.1f", a template like "${:,.0f}" or
"{} kg", or a function) and tick_format the axis labels, which are
short by default (12k, 1.5M); x_label, y_label, y_min, y_max,
grid="false"; colors replaces the palette ("#0d9488, #f59e0b" or a
list); empty is the text shown with no data.
Clicking: with @select, the chart reports the bar, point or slice
clicked: {"index", "label", "series", "value", "values", "row"} for bar,
line and radar charts (values has every series at that category, row
is your row), {"index", "label", "value", "percent", "row"} for pies.
Pressing Enter picks the category under the keyboard, with series and
value set to None.
Dashboards: Sparkline, Gauge, ScatterChart, FunnelChart, WaterfallChart¶
<ui.Card>
<span class="name">Revenue</span> <b>$48.2k</b>
<ui.Sparkline :data="[31, 33, 32, 36, 38, 41]" kind="area" name="Revenue" />
</ui.Card>
<ui.Gauge :value="cpu" label="CPU" format="{:.0f}%" bands="60:success, 85:warning, 100:danger" />
<ui.ScatterChart :data="people" x="height" y="weight" group="group" trend="true" />
<ui.ScatterChart :data="countries" x="income" y="life" size="population" label="name" />
<ui.FunnelChart :data="{'Visited': 12400, 'Signed up': 4100, 'Paid': 310}" />
<ui.WaterfallChart :data="bridge" label="item" value="amount" totals="Revenue, Profit" />
Sparklineis a word-sized chart for a stat card or a table cell: a list of numbers (or rows withy=),kindline, area or bar (negative bars in red), a dot on thelastvalue (orminmax,all,none). It takes the width it's given; screen readers hear "Revenue over 6 values: from 31 to 41, low 31, high 41" (namestarts the sentence).Gaugeshows one value againstminandmaxon a 270° dial, or a half circle withhalf="true".bandscolour ranges ("60:success, 85:warning, 100:danger", or(upto, colour)pairs); the arc takes the colour of the band the value is in.needle="true"adds a pointer. It animates when the value changes and is read as a meter.ScatterChartplaces points by two numbers:xandyfields (severalys are several series), orgroup=to split rows into series by a field.size=makes a bubble chart (a bubble's area shows the value),label=names points in tooltips andtrend="true"draws a least-squares line with its R². Both axes take_min/_max, labels and formats (x_format,x_tick_format).FunnelChartshows how many make it through each stage, with each stage's share of the first; the tooltip adds the share of the previous stage and how many were lost.shape="funnel"draws one tapering shape.WaterfallChartshows how a total is made up: each bar starts where the last ended, increases in green, decreases in red. Rows whosetotalfield is true, or whose label is intotals, stand on zero; a total with no value shows the running total.
All of them report clicks with @select like the other charts.
Data science: distributions, matrices, finance, hierarchies, flows¶
<ui.Histogram :data="people" y="height" group="sex" curve="true" show_mean="true" />
<ui.BoxPlot :data="{'Search': search_ms, 'Login': login_ms}" violin="true" />
<ui.Heatmap :data="visits" x="hour" y="day" value="visits" />
<ui.CorrelationMatrix :data="cars" triangle="lower" />
<ui.CandlestickChart :data="prices" averages="20, 50" />
<ui.Treemap :data="sales" path="region, country" value="sales" />
<ui.SankeyChart :data="[('Salary', 'Budget', 5200), ('Budget', 'Rent', 1900)]" />
<ui.CalendarHeatmap :data="commits" date="day" value="count" />
The statistics are computed in plain Python (no numpy needed), and the data can be a list of numbers, rows, or a DataFrame as with the other charts.
Histogram: counts in equal bins on round numbers.binsis about how many (the width stays round),"width:5"or a list of edges; left out, it is chosen from the data.group=or severalyfields overlap histograms;curve="true"adds a smoothed density curve (a kernel density estimate),show_mean="true"a line at each mean,densityandcumulativechange what the bars count.BoxPlot: quartiles, median, whiskers to the furthest values within 1.5 box-lengths, outliers as dots, the mean as a ring.violin="true"draws each group's smoothed distribution;points="all"shows every value;horizontal="true"lays them sideways. Tooltips give n, min, Q1, median, Q3, max and mean.Heatmap: a grid coloured by value from rows with x, y and value (repeats add up), a list of lists, or a{row: {column: value}}dict;diverging="true"colours below and abovecenterdifferently.CorrelationMatrix: Pearson (ormethod="spearman") correlations of a table's number fields, from -1 to 1, with the values written in.CandlestickChart: open, high, low and close per period (field names in any case), volume underneath, moving averages withaverages;styleis candle, hollow or ohlc.Treemap: areas for values, nested bypath=fields (or a nested dict, or dicts with children); each group gets a colour and a header.SankeyChart: amounts flowing between nodes, from source, target and value; hovering a node highlights its flows.align="justify"puts every end in the last column.CalendarHeatmap: a year of days, a column per week, shaded in four steps by quartile; on a phone it shows the latest weeks that fit.
Uncertainty and trends on bar and line charts. A series can carry a
range: lower and upper fields draw a shaded band around a line (a
forecast interval, a confidence band), and on bars, error (a field, or
one number for plus or minus) draws error bars. trend: True adds a
least-squares line. Tooltips show the range beside the value.
<ui.LineChart :data="forecast" x="month" :series="[
{'key': 'forecast', 'lower': 'low', 'upper': 'high', 'dash': True},
{'key': 'actual', 'trend': True}]" />
<ui.BarChart :data="trials" x="dose" y="effect" :series="[{'key': 'effect', 'error': 'se'}]" />
10. Calendars of events¶
EventCalendar shows events in a month, a week, or a list by day, the way
a team or booking calendar does:
import datetime
events = [
{"title": "Standup", "start": datetime.datetime(2026, 11, 3, 9, 30),
"end": datetime.datetime(2026, 11, 3, 9, 45), "color": "blue"},
{"title": "Release", "start": datetime.date(2026, 11, 7), "color": "red"},
{"title": "Trip", "start": datetime.date(2026, 11, 11), "end": datetime.date(2026, 11, 13)},
]
def open(self, event):
self.editing = event # the very dict (or object) you passed in
def add(self, when):
self.events = self.events + [{"title": "New event", "start": when}]
- Events are dicts or objects with
titleandstart, and optionallyend,colorandid. Adatestart (or text like"2026-11-07") is all day, andendis then the last day; adatetimestart (or"2026-11-03T09:30") is timed, andenddefaults to an hour later.colorisblue,green,amber,red,purple,teal,pink,gray, or any CSS colour. - Month view: all-day and multi-day events are bars across their days,
continuing into the next week; timed ones show their time. A day with
more than
max_per_day(3) rows shows "+2 more", which opens up the week.months="3"shows three months, one under another. - Week view: the hours from
day_starttoday_end(8 to 20), with timed events placed by their times and side by side where they overlap; all-day events in a row above. - Agenda: the events by day, a good choice on phones.
@selectgets the event you passed in.@daygets the date of an empty day, or in the week view thedatetimeof the hour, so you can add one there; days are only clickable when you listen to@day.@navigategets the(first, last)dates now showing, whenever the user moves or switches view: load just those events from your server withpyweft.http.- Settings:
view(month,week,agenda),date(where it starts; today),week_start,time_format("%I:%M %p"for 9:30 AM),switcher="false"to hide the view buttons, andlabelto name it for screen readers. Every event is a button that reads out its title, day and time.
11. Toasts¶
Toasts are short messages that appear in a corner of the page and go away
by themselves: "Saved", "Copied", "Message sent". Put one <ui.Toaster /> on
the page, inside your Theme, then call toast() from any handler:
<template>
<ui.Theme>
<ui.Row>
<ui.Button variant="primary" @click="save">Save</ui.Button>
<ui.Button variant="danger" @click="archive">Archive</ui.Button>
</ui.Row>
<p>{{ len(notes) }} notes</p>
<ui.Toaster />
</ui.Theme>
</template>
<code>
from pyweft_ui import toast, ui
notes: list = ["Groceries", "Ideas", "Books"]
def save(self):
toast("Your changes are saved.", "success")
def archive(self):
gone = self.notes.pop()
toast(f"Archived “{gone}”.", action="Undo", on_action=lambda: self.notes.append(gone))
</code>
toast(message, kind, ...) takes:
| Argument | Meaning |
|---|---|
kind |
info (the default), success, warning or danger |
title |
bold text above the message |
duration |
seconds before it closes, 4 by default; None keeps it until the user closes it |
action, on_action |
a button such as "Undo", and the function it calls (which may be async) |
Good to know:
- It works from anywhere: a handler, an
asynctask after anawait, or a plain Python module of yours. The Toaster shows every toast, whichever component raised it. - Timers pause while the pointer is over the toasts or focus is in them, so nobody loses a message they're reading.
toast()returns the toast; call.close()on it to take it away early, for example a "Uploading…" toast withduration=Nonethat you close when the upload finishes.clear_toasts()closes them all.<ui.Toaster>fields:position(bottom-right, the default;bottom-left,bottom-center,top-left,top-center,top-right),limit(at most 5 at once by default; older ones wait their turn),dismissible(the × on each toast) andlook(see below).- Toasts raised before the Toaster is on the page appear once it is.
As a snackbar¶
look="snackbar" shows the same toast() messages as a compact dark bar
at the bottom centre: one at a time, a new message replacing the one
showing, with no icon, the action as a text button, and no × (a message
with duration=None still gets one, or it could never be closed).
position, limit and dismissible change those defaults. The rest is
unchanged, so switching an app between the two looks is one attribute.
12. Icons¶
Icons take the text colour around them and are 1em by default, so they
match the text size. Buttons and alerts use them for you (icon="send").
Available names: check, x, plus, minus, search, info, alert,
chevron-up, chevron-down, chevron-left, chevron-right, menu,
trash, edit, send, user, home, star, heart, download,
upload, external, copy, settings, sun, moon, calendar,
more, bold, italic, underline, align-left, align-center,
align-right, list, grid, folder, folder-open, file.
13. Themes¶
| Field | Effect |
|---|---|
mode |
light (the default), dark, or auto to follow the visitor's system setting |
primary |
the accent colour: primary buttons, focus rings, switches, checkboxes, spinners |
radius |
corner rounding: 0 for square, 12px for round |
font |
a CSS font family; load web fonts in head.html |
Letting users switch between light and dark¶
Bind the theme's mode to a field and give the user a control for it:
<template>
<ui.Theme :mode="mode">
<ui.AppShell title="My app">
<Fill name="actions">
<ui.Select :options="[('light', 'Light'), ('dark', 'Dark'), ('auto', 'System')]"
@bind="mode" aria-label="Colour mode" />
</Fill>
<p>Pick a colour mode at the top right.</p>
</ui.AppShell>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
mode: str = "auto"
</code>
Fine-tuning colours¶
Every colour the controls use is a CSS variable. Set any of them on the
Theme with style:
| Variable | Used for |
|---|---|
--ui-primary, --ui-on-primary |
the accent colour, and text on it |
--ui-text, --ui-muted |
main text, secondary text |
--ui-bg |
the page background |
--ui-surface |
cards, fields, dialogs |
--ui-subtle |
hover backgrounds, card footers |
--ui-border |
borders and dividers |
--ui-info, --ui-success, --ui-warning, --ui-danger |
alerts, badges, errors |
--ui-chart-1 ... --ui-chart-10 |
chart series, in order (the first is --ui-primary) |
--ui-radius |
corner rounding |
--ui-font |
the font family |
A Theme inside another restyles just that part of the page, for example a
dark sidebar in a light app.
14. Changing how a control looks¶
From the lightest touch to the heaviest:
- Use the control's own fields first:
variant,size,kind,block, and so on. - Change theme variables (above) to restyle every control at once.
- Add a
classorstyleto position or size one control. They go on its outer element, so your component's own<style>block can target them:
- Wrap it in your own component (next section) when you want a reusable variant with its own look.
What happens inside a control, such as the exact padding of a text field, is styled by the toolkit. Prefer the theme variables over overriding those details, so your app keeps working as the toolkit improves.
15. Building your own components from controls¶
Your own components can use controls in their templates and pass content through to them.
A reusable stat card (stat_card.weft, next to your app.weft):
<!-- stat_card.weft -->
<template>
<ui.Card :title="label">
<p class="figure">{{ value }}</p>
<If cond="change">
<ui.Badge :kind="'success' if change > 0 else 'danger'">{{ change }}%</ui.Badge>
</If>
</ui.Card>
</template>
<code>
from pyweft_ui import ui
label: str = ""
value: str = ""
change: int = 0
</code>
<style>
.figure { margin: 0 0 4px; font-size: 2rem; font-weight: 700; }
</style>
Using it:
<template>
<ui.Theme>
<ui.Grid min_width="12rem">
<StatCard label="Revenue" value="$48,200" change="12" />
<StatCard label="Orders" value="1,204" change="-3" />
<StatCard label="Customers" value="932" />
</ui.Grid>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
</code>
A field of your own that works with @bind, built on TextField
(email_field.weft). It receives your field as value and reports changes
with emit("update", ...):
<!-- email_field.weft -->
<template>
<ui.TextField :label="label" type="email" autocomplete="email"
:value="value" :error="problem" @update="emit('update', event)" />
</template>
<code>
from pyweft_ui import ui
label: str = "Email"
value: str = ""
@property
def problem(self):
if self.value and "@" not in self.value:
return "That doesn't look like an email address"
return ""
</code>
<template>
<ui.Theme>
<EmailField @bind="email" />
<p>You typed: {{ email }}</p>
</ui.Theme>
</template>
<code>
from pyweft_ui import ui
email: str = ""
</code>
16. Accessibility and keyboard¶
The controls are built on standard HTML elements, so keyboards, screen readers and mobile devices work the way people expect:
- Fields are labelled: every field's
labelis connected to its input, so clicking the label focuses the field and screen readers announce it.errormarks the field as invalid for screen readers as well as in red. - Everything is reachable with Tab, shows a visible focus ring, and works with Space and Enter. Radio buttons move with the arrow keys.
- Alerts are announced:
warninganddangeralerts interrupt,infoandsuccesswait politely. - Popovers say they open a dialog and whether it is open; focus moves into one as it opens and Esc brings it back. Tooltips appear on keyboard focus as well as hover, and Esc hides them.
- Bottom sheets behave like dialogs for the keyboard and screen readers: focus moves in, Tab stays inside, Esc closes, and focus goes back to where it was.
- Dialogs are announced as dialogs, move focus into themselves when they open (so Esc closes them straight away), keep Tab and Shift+Tab inside while open, close with Esc, and give focus back to whatever had it before, such as the button that opened them.
- Breadcrumbs are a navigation landmark named "Breadcrumb" holding a list, with separators hidden and the last item marked as the current page.
- Tabs are announced as tabs, with the chosen one marked, and their content as the chosen tab's panel. The tab row is one Tab stop: the arrow keys move between tabs, and Home and End jump to the first and last.
- Carousels are announced as carousels, each slide as "3 of 8"; their autoplay pauses on hover and focus, and has a stop button.
- Expansion panels have a real button in a heading, saying whether
it's open; closed content is out of reach. In an
Accordion, Up and Down move between the headers. - Steppers read each header as "Step 2 of 4: Delivery", with done, optional and error states; the headers are one Tab stop moved with the arrow keys, and Next or Back moves focus to the new step's content.
- Trees read each node's level, its place among its siblings, and whether it is open, selected or checked (or partly checked); a folder that is loading says it is busy.
- Tables use real table headings, and tell screen readers which column is sorted and which way.
- Gauges are read as meters, with their value and range.
- Charts are one Tab stop, read as a picture with a summary ("Bar chart of Online, Store over 6 categories"), and carry their data as a table for screen readers. The arrow keys move from category to category (Home and End to the ends), and each one's values are read out; Enter picks it. Legend entries are buttons saying whether their series is shown.
- Menus are announced as menus, with the button saying whether it's open, and items with a submenu saying so too. Focus moves into the list when it opens, into a submenu with the right arrow and back with the left, and back to the button when an item is chosen or Esc is pressed. A right-click menu gives focus back to where it was.
- The date pickers' calendar is a grid of days, each read out in full ("Tuesday 3 November 2026"), with today and the chosen days marked. It is one Tab stop, moved with the arrow keys.
- Menus and calendars stay on screen: opened next to something near the window's edge, they shift sideways to fit.
- Progress bars and rings are announced as progress bars with their value and its text ("4 of 12 files"); one without a value says it is busy.
- Toasts and snackbars are read out politely when they appear (
dangerones interrupt), and pause while someone is reading them. - Motion is reduced for visitors who ask their system for it: spinners slow down, and dialogs and toasts appear without animating.
What you should add yourself:
- A text alternative for buttons with only an icon:
aria-label="Close". - An
aria-labelon fields with no visiblelabel, like the colour-modeSelectin section 13, and alabelonTabs. - Don't put the only copy of something important in a toast: it goes away. Show errors that need fixing next to what needs fixing.
- After a failed submit, move focus to the first field with a problem, so
keyboard and screen reader users land on it.
form.check()does this for you when each field has arefnamed like the field (see the form); without aForm, give the control arefand callfocus()(see moving focus):