# TimePicker

An hour-and-minute grid in a popup, opened by a trigger you own and walked entirely from the arrow keys.

```slint
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

```slint
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](/docs/components/radio-group) and [Tabs](/docs/components/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.

```slint
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.

```slint
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.

```slint
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: true` on a [Button](/docs/components/button) is that.
- **The grid takes the keyboard on its own way in.** Nothing outside a `PopupWindow` can 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 `list` named by `hour-heading` and the minutes another named by `minute-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](/docs/components/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.
