TimePicker
An hour-and-minute grid in a popup, opened by a trigger you own and walked entirely from the arrow keys.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { TimePicker } from "@glint/components/time-picker.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 420px; height: 400px; background: Tokens.color-background;
in-out property <int> hour: 9; in-out property <int> minute: 40;
// Glint formats no times; two digits is the host's own arithmetic. pure function pad(n: int) -> string { return n < 10 ? "0" + n : "" + n; }
VerticalLayout { padding: 24px; alignment: start;
HorizontalLayout { alignment: start;
picker := TimePicker { hour <=> root.hour; minute <=> root.minute;
Button { variant: ButtonVariant.outline; leading-icon: IconSet.Clock; text: root.pad(root.hour) + ":" + root.pad(root.minute); haspopup: true; // The pointer opens it for free; this is the keyboard path. clicked => { picker.show(); } } } } }}Usage
import { TimePicker } from "@glint/components/time-picker.slint";import { Button } from "@glint/components/button.slint";
export component AppWindow inherits Window { in-out property <int> hour; in-out property <int> minute; in property <string> label;
picker := TimePicker { hour <=> root.hour; minute <=> root.minute; changed(hour, minute) => { /* store it */ }
Button { text: root.label; haspopup: true; clicked => { picker.show(); } } }}Slint has no Time type, so the time is yours. Bind hour (0 … 23) and
minute (0 … 59) to your model and the picker renders the grid, moves the
selection and reports the whole time through changed(hour, minute). It
formats nothing beyond the two-digit cells in the grid itself.
The trigger is yours too — the same contract every Glint overlay keeps. A
pointer click anywhere on the trigger opens the popup for free; wire your
trigger’s keyboard activation to show(), and bind to is-open if the
trigger should reflect that it is up. show() and close() mirror
PopupWindow’s API (ADR-0006).
The selection is live. Every arrow key already writes hour / minute
and fires changed — there is no draft the popup is holding back, the way
RadioGroup and Tabs
move their value on the arrow keys. Enter is therefore
accept-and-close rather than commit, and Esc closes on the value
already stored. Both return the keyboard to your trigger.
changed means changed: landing back on the value already selected — by
re-clicking it, or by wrapping onto it — fires nothing, so a consumer that
persists on the callback does not write for nothing.
Examples
A 12-hour clock
twelve-hour labels the hour cells “12 AM … 11 PM”; hour stays a 0 … 23
value either way, so nothing downstream has to know which clock the user was
shown. The wider labels need a wider popup, which is what content-width
defaults account for — override it if your am-label / pm-label are longer
than the English defaults.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { TimePicker } from "@glint/components/time-picker.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 400px; background: Tokens.color-background;
in-out property <int> hour: 14; in-out property <int> minute: 30;
pure function pad(n: int) -> string { return n < 10 ? "0" + n : "" + n; } pure function clock(h: int) -> string { return (mod(h, 12) == 0 ? 12 : mod(h, 12)) + ":" + root.pad(root.minute) + (h < 12 ? " AM" : " PM"); }
VerticalLayout { padding: 24px; spacing: 12px; alignment: start;
HorizontalLayout { alignment: start;
picker := TimePicker { twelve-hour: true; hour <=> root.hour; minute <=> root.minute;
Button { variant: picker.is-open ? ButtonVariant.secondary : ButtonVariant.outline; leading-icon: IconSet.Clock; text: root.clock(root.hour); haspopup: true; clicked => { picker.show(); } } } }
Text { text: "The model is still 24-hour: hour = " + root.hour + ", minute = " + root.minute + "."; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}Granularity
minute-step is the grid’s granularity in both directions: it decides how
many minute cells exist and how far one arrow press travels. 5 — the default
— gives :00 :05 … :55; 15 gives four cells, which is the shape a booking
form wants.
A minute off the step grid is never rewritten behind your back: it rounds to
the nearest cell for navigation only, so a time restored from storage stays
exactly what it was until the user moves it.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { TimePicker } from "@glint/components/time-picker.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 400px; background: Tokens.color-background;
in-out property <int> hour: 10; in-out property <int> minute: 45; in-out property <string> last: "Nothing changed yet.";
pure function pad(n: int) -> string { return n < 10 ? "0" + n : "" + n; }
VerticalLayout { padding: 24px; spacing: 12px; alignment: start;
HorizontalLayout { alignment: start;
picker := TimePicker { minute-step: 15; hour <=> root.hour; minute <=> root.minute; changed(h, m) => { root.last = "Now " + root.pad(h) + ":" + root.pad(m) + "."; }
Button { variant: ButtonVariant.outline; leading-icon: IconSet.Clock; text: root.pad(root.hour) + ":" + root.pad(root.minute); haspopup: true; clicked => { picker.show(); } } } }
Text { text: root.last; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}Naming the two halves
hour-heading and minute-heading are the section headings inside the popup,
and am-label / pm-label the suffixes a 12-hour clock spells. All four are
properties with English defaults, so translating the picker is setting them —
the component does no other formatting.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { TimePicker } from "@glint/components/time-picker.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 400px; background: Tokens.color-background;
in-out property <int> hour: 8; in-out property <int> minute: 0;
pure function pad(n: int) -> string { return n < 10 ? "0" + n : "" + n; }
VerticalLayout { padding: 24px; alignment: start;
HorizontalLayout { alignment: start;
picker := TimePicker { hour-heading: "Starts at"; minute-heading: "Past the hour"; hour <=> root.hour; minute <=> root.minute;
Button { variant: ButtonVariant.outline; leading-icon: IconSet.Clock; text: "Starts " + root.pad(root.hour) + ":" + root.pad(root.minute); haspopup: true; clicked => { picker.show(); } } } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
hour | in-out int | 0 | Two-way; the selected hour on a 24-hour clock (0 … 23). |
minute | in-out int | 0 | Two-way; the selected minute (0 … 59). |
minute-step | in int | 5 | Granularity of the popup's minute grid; 5 gives :00 :05 … :55. |
twelve-hour | in bool | false | Show hours as 12-hour clock labels; hour stays a 0 … 23 value. |
hour-heading | in string | @tr("Hour") | Section headings and 12-hour suffixes inside the popup; override them to localize. |
minute-heading | in string | @tr("Minute") | |
am-label | in string | @tr("AM") | |
pm-label | in string | @tr("PM") | |
content-width | in length | root.twelve-hour ? 316px : 250px | Pixel width of the popup panel. The default fits six columns of the grid's cells; the 12-hour labels need the wider ones. Override it if your am-label / pm-label are longer than the English defaults. |
is-open | out bool | no default | True while the popup is on screen. The trigger is @children, so reflecting the open state in it is yours to do — bind to this. |
Callbacks
| Callback | Description |
|---|---|
changed(int, int) | Fired with (hour, minute) whenever either changes. |
Functions
| Function | Description |
|---|---|
show() | The overlay API mirrors PopupWindow's, per ADR-0006. Wire your trigger's keyboard activation to show(). |
close() | Hide the popup. Focus returns to whatever held it before. |
Accessibility
- The trigger’s accessibility is yours, because the trigger is. Name it,
and mark it as opening a popup —
haspopup: trueon a Button is that. - The grid takes the keyboard on its own way in. Nothing outside a
PopupWindowcan focus what is inside it, so the grid grabs the keyboard as the popup is built, and the arrows work without a Tab first. - Keyboard. ← / → step one cell along the half you are in, wrapping; ↑ / ↓ step a row through the hour rows and the minute rows below them, so ↓ off the last hour row lands in the minutes. Enter accepts and closes, Esc closes.
- Pointer and keyboard share one cursor. Clicking a cell hands the grid the keyboard focus and the arrows continue from there — no intervening Tab, and no second cursor drifting behind the first.
- Both selections stay readable. The half the arrows are not in keeps its value drawn in a softer surface rather than losing it, so the whole time is visible while either half is being changed.
- Closing returns the focus to whatever held it before — your trigger — so the next Enter opens the picker again.
- Each half is a list, and a time is one of its items. The hours are a
listnamed byhour-headingand the minutes another named byminute-heading, so a cell announces itself as “Hour 09”, says it is the 10th of 24, and says whether it is the selection. Naming the cell after its section is what tells the two “00”s apart. - Taking a cell is its default action, which is what lets a screen reader pick a time without a pointer — the same path Calendar’s day cells offer, and it keeps working inside the popup.
- A blank is nothing at all. The cells filling the tail of the last minute row when the step does not divide the row evenly are holes that keep the columns aligned: they take no click and are in no accessibility tree.