Each Koyomi picker is a form field. It posts its value with the other fields of the form, in the form of the browser's own inputs, and the field's Ecto type casts it in the changeset.

The element

<.form for={@form} id="booking-form" phx-change="validate" phx-submit="save">
  <label id="due-label">Due</label>
  <.date_picker field={@form[:due]} label_id="due-label" />
</.form>

A picker renders one element with phx-hook="Koyomi" and phx-update="ignore", its id (booking_due_picker here: the field's id with _picker), and the inputs that the form posts. The hook builds the field and the popover around these inputs, and hides the inputs. The server's later values and options reach the hook through the data-value and data-config attributes of the element, which every render updates.

What each picker posts

PickerInputsExampleCleared
datedue2027-03-15""
date range, one fieldstay[from], stay[to]2027-03-01, 2027-03-07both ""
date range, from and tocheck_in, check_out2027-03-01, 2027-03-07both ""
several daysdays[], an empty first entry, then one per day"", 2027-03-01, 2027-03-05""
monthmonth2027-03""
yearyear2027""
weekweek2027-W11""
timeat14:30:00""
date and time, time_zonestarts_at2027-03-15T14:30:00+01:00, or Z at offset zero""
date and time, no zonestarts_at2027-03-15T14:30:00""
recurrencerepeatFREQ=WEEKLY;BYDAY=MO,WE"" (does not repeat)
calendar with a nameone hidden input2027-03-15, 2027-03-01/2027-03-07, or 2027-03-01,2027-03-05""

The empty first entry of the several days picker makes a form with no day still post the field. Koyomi.Dates drops empty entries, removes repeated days and sorts the rest.

The types cast more than the picker posts: Koyomi.DateRange also casts a Date.Range, a {first, last} tuple and an ISO 8601 interval (2027-03-01/2027-03-07); Koyomi.Month a Date and a map with "year" and "month"; Koyomi.Week a Date; Koyomi.Dates a list of Date and a comma-separated string. A range with one end, or one that ends before it starts, is invalid.

The form events

On each change, the hook writes the value to the inputs and dispatches input and change events on the last input it changed. The form's own phx-change sees them, with _target set to that input's name, and phx-submit posts the inputs. The LiveView needs no other event for a picker.

A picker posts a value when it is whole. A range posts when both ends are chosen. In the date, month and date and time pickers, a date typed in segments posts when every segment has a value, or when every segment is empty; a half-typed date posts nothing.

Errors

The picker marks its field invalid when the form field has errors and Phoenix.Component.used_input?/1 is true for it, so that a field the person has not touched does not show an error. The segments then have aria-invalid="true" and the field the class koyomi-invalid. The several days and recurrence pickers have no segments, so only the class marks them (see Known limits). Once the person has picked a value, the picker marks every one of its inputs as used (phx-no-unused-field), so the other end of a range counts too.

Show the messages next to the picker, and give their id to the picker as aria-describedby:

<.date_picker
  field={@form[:due]}
  label_id="due-label"
  aria-describedby={if errors(@form[:due]) != [], do: "due-error"}
/>
<p :for={msg <- errors(@form[:due])} id="due-error" class="error">{msg}</p>
defp errors(field) do
  if used_input?(field), do: Enum.map(field.errors, &translate_error/1), else: []
end

aria-describedby and aria-invalid given to the picker go to its segments, not to the picker element. aria-invalid={true} marks the field invalid whatever its errors. A change of either in a later render reaches the picker without building it again.

The picker also checks the value with its rules in the browser, as the changeset will, and shows why a value is refused under the field ("Choose a day on or after 1 Mar 2027"), in a polite live region that the segments name in aria-describedby. The changeset still decides: see the Rules guide.

Before the hook mounts, and without JavaScript

Until the hook mounts, the inputs of a picker are the browser's own inputs of its kind, with the same name and value, and the min, max and step of the rules:

PickerBefore the hook
date<input type="date">
date rangetwo <input type="date">, labelled "Start date" and "End date"
several daysone <input type="date"> per day, and an empty one
month<input type="month">
year<input type="number">
week<input type="week">
time<input type="time">, with the rule's :step in seconds
date and time<input type="datetime-local">, the wall clock in the picker's zone
recurrence<input type="text"> with the rule text
calendara hidden input, only with a name

So a form works with JavaScript off. The browser's datetime-local input has no zone: it posts 2027-03-15T14:30, with no offset and no seconds. Cast it with Koyomi.Changeset.cast_datetime/5 (see the Time zones guide). Some browsers show month and week inputs as plain text boxes.

A picker outside a form

For a filter bar, a search page or params in a URL, see Filters and URLs.

A calendar/1 with select, or any picker with on_change, pushes an event on each change:

<.calendar id="plan" select="range" on_change="planned" />
def handle_event("planned", %{"id" => "plan", "value" => value}, socket) do
  # value is %{"from" => "2027-03-01", "to" => "2027-03-07"}
  {:ok, plan} = Koyomi.DateRange.cast(value)
  {:noreply, assign(socket, plan: plan)}
end

The value is the picker's wire form: an ISO string, a map of "from" and "to" for a range, a list of ISO dates for several days, or nil. The event goes to the phx-target of the picker, when it has one. A picker in a form can have on_change too: it then posts with the form and also pushes the event.

A page without LiveView

On a page that a controller renders, the hook does not run. Import mountKoyomi in the page's script and call it after the page loads:

import { mountKoyomi } from "koyomi"

window.addEventListener("DOMContentLoaded", () => mountKoyomi())

mountKoyomi(root) mounts every picker under root (the document by default), skips a picker it already mounted, and returns the pickers. The pickers post with a plain HTML form, as they do in a LiveView. They have no day content and no server events, and on_change pushes nothing. Render them with name and value, or with a field of a form built with Phoenix.Component.to_form/2:

<form method="get" action="/search">
  <label id="stay-label">Stay</label>
  <.date_range_picker name="stay" value={@params["stay"]} label_id="stay-label" />
  <button>Search</button>
</form>

Values from the server

A picker takes the value of each render: the field's value, or value. A changeset that changes the field, for example to clear it, changes the picker.

To set a value that the form does not have yet, push it:

socket = Koyomi.Live.push_value(socket, "booking_stay_picker", Date.range(~D[2027-03-06], ~D[2027-03-07]))

Koyomi.Live.push_value/3 sends koyomi:set to the picker with that id. The picker shows the value and posts it with the form, as if the person had picked it: a phx-change follows. The value is a Date, a Date.Range, a list of Date, a Time, a DateTime, a NaiveDateTime, a Koyomi.Recurrence, an ISO string, or nil to clear it. For a month or a week picker, give the ISO string ("2027-03", "2027-W11"): a Date is sent as a full date, which these pickers do not read.

The other pushes of Koyomi.Live are open/2, close/2 and focus/2 for the popover and the focus, and refresh_days/2 (see Day content). Each carries the picker's id, so only that picker acts on it.

Echoes

A picker posts a value, the LiveView casts it, and the next render brings the value back in data-value. The picker must not take back a value that it posted itself, or a slow render would undo what the person typed since. So:

  • A rendered value equal to the picker's current value means the server is in step: nothing changes.
  • A rendered value equal to one of the last eight values the picker posted is an echo, and the picker ignores it.
  • Any other rendered value is the server's own, and the picker takes it, without posting it.

Values are compared in their wire form; a date and time with an offset compares as an instant, so the server's UTC form of a posted local time is an echo too.

So a render that sets the field back to a value that the person posted a moment ago is ignored. To set such a value, use push_value/3. See Known limits.

A LiveComponent

In a LiveComponent, give phx-target={@myself}. The picker then sends its events (koyomi:days, and the on_change event) to the component, and the component must handle them:

<.date_picker field={@form[:due]} phx-target={@myself} days />

More than one picker

Give each picker its own id, or let each take its field's id. Every event from a picker has its id, and every server push names the picker, so two pickers on a page do not act on the events of the other.