ee8797c142
Co-authored-by: Copilot <copilot@github.com>
237 lines
7.6 KiB
Markdown
237 lines
7.6 KiB
Markdown
# TimePicker
|
||
|
||
A styled time picker. The user selects hours, minutes, and optionally AM/PM. The component always writes the selected time as `HH:MM` (24-hour) to the hidden input, regardless of whether 12-hour display mode is used. Optionally renders a visible label and description.
|
||
|
||
---
|
||
|
||
## HTML structure
|
||
|
||
```
|
||
div.flex.flex-col.gap-1.5
|
||
label.text-sm.font-medium ← omitted when empty
|
||
{label}
|
||
div.flex.items-center.gap-1.rounded-md.border.border-input.bg-background.px-3.py-2
|
||
select.timepicker-h[name={name}-h] ← hour select (1–12 or 0–23)
|
||
span.text-muted-foreground :
|
||
select.timepicker-m[name={name}-m] ← minute select (00–59)
|
||
select.timepicker-ampm[name={name}-ampm] ← AM/PM (12h mode only)
|
||
input.sr-only[type=hidden, name={name}] ← hidden input holding HH:MM
|
||
p.text-sm.text-muted-foreground ← omitted when empty
|
||
{description}
|
||
```
|
||
|
||
---
|
||
|
||
## CSS mechanics
|
||
|
||
| Class | Effect |
|
||
|---|---|
|
||
| `rounded-md border border-input bg-background` | Consistent styling with other form fields |
|
||
| `sr-only` on hidden input | Hidden visually but included in form submission |
|
||
| `appearance-none` on `<select>` elements | Removes native browser dropdown arrow for uniform style |
|
||
| `focus:outline-none` on selects | Focus ring deferred to the wrapper `div` |
|
||
|
||
---
|
||
|
||
## JavaScript (`syncTime` in `components.js`)
|
||
|
||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||
|
||
### `syncTime(wrapper)`
|
||
|
||
1. Finds `.timepicker-h`, `.timepicker-m`, `.timepicker-ampm`, and the hidden `input`
|
||
2. On any `change` event across the three visible selects:
|
||
- Reads hour, minute, and AM/PM values
|
||
- Converts 12h → 24h if AM/PM select is present
|
||
- Writes `HH:MM` to the hidden input
|
||
|
||
---
|
||
|
||
## Constructor signature
|
||
|
||
```csharp
|
||
public TimePicker(
|
||
string name,
|
||
string? selected = null,
|
||
string label = "",
|
||
string description = "",
|
||
bool use12h = false)
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `name` | Form field name; hidden input gets this name, visible selects get `{name}-h`, `{name}-m`, `{name}-ampm` |
|
||
| `selected` | Pre-selected time as `"HH:MM"` (24h format); defaults to current time |
|
||
| `label` | Optional visible label |
|
||
| `description` | Optional helper text |
|
||
| `use12h` | If `true`, shows AM/PM select and hour range 1–12 |
|
||
|
||
---
|
||
|
||
## Usage examples
|
||
|
||
### Basic time picker (24h)
|
||
|
||
```csharp
|
||
new TimePicker(name: "startTime", label: "Start time")
|
||
```
|
||
|
||
### 12-hour mode
|
||
|
||
```csharp
|
||
new TimePicker(
|
||
name: "meetingTime",
|
||
label: "Meeting time",
|
||
use12h: true)
|
||
```
|
||
|
||
### Pre-selected time
|
||
|
||
```csharp
|
||
new TimePicker(
|
||
name: "alarmTime",
|
||
selected: "07:30",
|
||
label: "Alarm",
|
||
use12h: true)
|
||
```
|
||
|
||
### Inside a form
|
||
|
||
```html
|
||
<!-- ScheduleForm.htmx -->
|
||
<form method="post" action="/schedule">
|
||
$$AntiforgeryToken$$
|
||
$$StartTime$$
|
||
$$EndTime$$
|
||
<button type="submit">Save</button>
|
||
</form>
|
||
```
|
||
|
||
```csharp
|
||
public ScheduleForm()
|
||
{
|
||
StartTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
||
EndTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
||
}
|
||
```
|
||
|
||
**Reading the submitted values:**
|
||
|
||
```csharp
|
||
public record Command(
|
||
[property: FromForm] string StartTime, // "09:00"
|
||
[property: FromForm] string EndTime // "17:00"
|
||
);
|
||
|
||
var start = TimeOnly.ParseExact(command.StartTime, "HH:mm");
|
||
var end = TimeOnly.ParseExact(command.EndTime, "HH:mm");
|
||
```
|
||
|
||
---
|
||
|
||
## Tips and tricks
|
||
|
||
- The hidden input always stores 24h `HH:MM` regardless of `use12h` — parse with `"HH:mm"` format on the server.
|
||
- `selected` defaults to the current server time if not specified — pass `"00:00"` if you want the picker to start at midnight.
|
||
- The visible hour/minute selects are independent form fields (`{name}-h`, `{name}-m`) — only the hidden input with `name` is needed in your command record. Ignore the `-h`, `-m`, and `-ampm` fields server-side.
|
||
- For a date+time combination, pair `Calendar` (for date) with `TimePicker` (for time) and combine their values in the handler.
|
||
- For a date+time combination, pair `Calendar` (for date) with `TimePicker` (for time) and combine their values in the handler.
|
||
|
||
---
|
||
|
||
## Complete page example
|
||
|
||
**`Templates/ScheduleMeetingPage.htmx`**
|
||
```html
|
||
<div class="max-w-lg mx-auto py-10">
|
||
<h1 class="text-2xl font-bold mb-2">Schedule a meeting</h1>
|
||
<p class="text-sm text-muted-foreground mb-8">Pick a date and time that works for you.</p>
|
||
<form method="post" action="/meetings/new">
|
||
$$AntiforgeryToken$$
|
||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2">Date</label>
|
||
$$DatePicker$$
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2">Time</label>
|
||
$$TimePicker$$
|
||
</div>
|
||
</div>
|
||
$$TitleInput$$
|
||
<div class="mt-6">
|
||
$$SubmitBtn$$
|
||
</div>
|
||
</form>
|
||
$$SuccessAlert$$
|
||
</div>
|
||
```
|
||
|
||
**`Templates/ScheduleMeetingPage.htmx.cs`**
|
||
```csharp
|
||
namespace Htmx.ApiDemo.Templates;
|
||
|
||
public sealed class ScheduleMeetingPage : ScheduleMeetingPageBase
|
||
{
|
||
private readonly IHtmxComponent _calendar;
|
||
private readonly IHtmxComponent _timePicker;
|
||
private readonly IHtmxComponent _title;
|
||
private readonly IHtmxComponent _submit;
|
||
private readonly IHtmxComponent _success;
|
||
private readonly byte[] _afToken;
|
||
|
||
public ScheduleMeetingPage(
|
||
IAntiforgery af,
|
||
HttpContext ctx,
|
||
DateOnly? selectedDate = null,
|
||
string selectedTime = "",
|
||
bool booked = false)
|
||
{
|
||
var tokens = af.GetAndStoreTokens(ctx);
|
||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||
|
||
_calendar = new Components.Calendar(name: "date", selectedDate: selectedDate);
|
||
_timePicker = new Components.TimePicker(name: "time", value: selectedTime, placeholder: "09:00");
|
||
_title = new Components.Input(id: "title", name: "title", label: "Meeting title", placeholder: "Sync call");
|
||
_submit = new Components.Button("Book meeting", type: "submit");
|
||
_success = booked
|
||
? new Components.Alert(title: "Meeting booked!", description: $"Scheduled for {selectedDate:d} at {selectedTime}.")
|
||
: HtmxEmpty.Instance;
|
||
}
|
||
|
||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||
protected override void RenderDatePicker(HtmxRenderContext ctx) => _calendar.Render(ctx.Next());
|
||
protected override void RenderTimePicker(HtmxRenderContext ctx) => _timePicker.Render(ctx.Next());
|
||
protected override void RenderTitleInput(HtmxRenderContext ctx) => _title.Render(ctx.Next());
|
||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||
}
|
||
```
|
||
|
||
**POST handler**
|
||
```csharp
|
||
[Handler]
|
||
[MapPost("/meetings/new")]
|
||
public static partial class PostScheduleMeetingHandler
|
||
{
|
||
public record Command(
|
||
[property: FromForm] DateOnly Date,
|
||
[property: FromForm] string Time,
|
||
[property: FromForm] string Title);
|
||
|
||
private static Task<IResult> HandleAsync(
|
||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||
{
|
||
// Persist meeting…
|
||
return ctx.WriteHtmxPage(
|
||
new ScheduleMeetingPage(af, ctx, cmd.Date, cmd.Time, booked: true),
|
||
title: "Schedule meeting");
|
||
}
|
||
}
|
||
```
|
||
|
||
**`AppJsonSerializerContext.cs`**
|
||
```csharp
|
||
[JsonSerializable(typeof(PostScheduleMeetingHandler.Command), TypeInfoPropertyName = "ScheduleMeetingCommand")]
|
||
```
|