Packages
noora
0.74.0
0.86.0
0.85.0
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
lib/noora/date_picker.ex
defmodule Noora.DatePicker do
@moduledoc """
A date range picker component with preset options and custom selection.
Built on top of Zag.js date picker machine, this component supports:
- Predefined range presets (Last 7 days, Last 30 days, etc.) with DateTime precision
- Custom date range selection via calendar
- Desktop layout (sidebar presets + two-month calendar) and mobile layout (tabs + single-month calendar)
- Light/dark mode theming
## Example
```elixir
<.date_picker
id="date-range"
name="date_range"
on_period_change="date_range_changed"
selected_preset={@selected_preset}
period={@date_range_period}
presets={[
%{id: "1h", label: "Last 1 hour", period: {1, :hour}},
%{id: "24h", label: "Last 24 hours", period: {24, :hour}},
%{id: "7d", label: "Last 7 days", period: {7, :day}},
%{id: "30d", label: "Last 30 days", period: {30, :day}},
%{id: "custom", label: "Custom"}
]}
>
<:actions>
<.button
label="Cancel"
variant="secondary"
phx-click={JS.dispatch("phx:date-picker-cancel", detail: %{id: "date-range"})}
/>
<.button
label="Apply"
phx-click={JS.dispatch("phx:date-picker-apply", detail: %{id: "date-range"})}
/>
</:actions>
</.date_picker>
```
## Handling period changes
```elixir
def handle_event("date_range_changed", %{"value" => %{"start" => start, "end" => end}, "preset" => preset}, socket) do
# start and end are ISO8601 DateTime strings
{:noreply, assign(socket, date_range_period: {start, end})}
end
```
"""
use Phoenix.Component
import Noora.Icon
import Noora.LineDivider
attr :id, :string, required: true, doc: "Unique identifier for the date picker component"
attr :label, :string, default: "Select date range", doc: "Label displayed on the trigger button"
attr :name, :string, default: nil, doc: "The name attribute for the hidden input field"
attr :period, :any,
default: nil,
doc: "The currently selected range as a tuple {start_datetime, end_datetime}. Both values must be DateTime structs."
attr :presets, :list,
required: true,
doc:
"List of preset options. Each preset is a map with :id, :label, and optional :period keys. Period is a tuple of {amount, unit} where unit is :hour, :day, :week, :month, or :year"
attr :selected_preset, :string, default: nil, doc: "The ID of the currently selected preset"
attr :min, :any,
default: nil,
doc: "Minimum selectable date (Date, DateTime, or ISO8601 string)"
attr :max, :any,
default: nil,
doc: "Maximum selectable date (Date, DateTime, or ISO8601 string)"
attr :start_of_week, :integer,
default: 0,
doc: "The day the week starts on (0 = Sunday, 1 = Monday, etc.)"
attr :disabled, :boolean, default: false, doc: "Whether the date picker is disabled"
attr :on_period_change, :string,
default: nil,
doc: "Event handler name for when the selected date range changes"
attr :on_cancel, :string,
default: nil,
doc: "Event handler name for when the cancel button is clicked"
attr :open, :boolean,
default: false,
doc: "Whether the date picker should be open by default (useful for storybook)"
attr :rest, :global, doc: "Additional HTML attributes"
slot :actions,
required: false,
doc:
~s|Action buttons for the footer (e.g., Cancel and Apply). Use JS.dispatch("phx:date-picker-cancel", detail: %{id: id}) and JS.dispatch("phx:date-picker-apply", detail: %{id: id}) to trigger the date picker's cancel and apply actions.|
def date_picker(assigns) do
presets = assigns.presets
{period_start, period_end} = extract_period(assigns[:period])
assigns =
assigns
|> assign(:period_start, period_start)
|> assign(:period_end, period_end)
|> assign_new(:presets_json, fn ->
presets
|> Enum.map(fn preset ->
%{
id: preset.id,
label: preset.label,
period: encode_preset_period(Map.get(preset, :period))
}
end)
|> Jason.encode!()
end)
|> assign_new(:trigger_label, fn ->
selected_preset = assigns[:selected_preset]
cond do
# For custom preset with period values, show the date range
selected_preset == "custom" && period_start && period_end ->
format_date_range(period_start, period_end)
# For other presets, show the preset label
selected_preset ->
preset = Enum.find(presets, &(&1.id == selected_preset))
if preset, do: preset.label, else: assigns[:label] || "Select date range"
# Default label
true ->
assigns[:label] || "Select date range"
end
end)
~H"""
<div
id={@id}
class="noora-date-picker"
phx-hook="NooraDatePicker"
data-name={@name}
data-start-of-week={@start_of_week}
data-min={encode_date(@min)}
data-max={encode_date(@max)}
data-presets={@presets_json}
data-selected-preset={@selected_preset}
data-period-start={encode_datetime(@period_start)}
data-period-end={encode_datetime(@period_end)}
data-on-period-change={@on_period_change}
data-on-cancel={@on_cancel}
data-disabled={@disabled}
{@rest}
>
<div data-part="control">
<button data-part="trigger" type="button" disabled={@disabled}>
<div data-part="trigger-icon">
<.calendar_week />
</div>
<span data-part="trigger-label">{@trigger_label}</span>
</button>
</div>
<div data-part="positioner">
<div data-part="content">
<!-- Desktop: Sidebar presets -->
<div data-part="presets" data-device="desktop">
<button
:for={preset <- @presets}
type="button"
data-part="preset-item"
data-preset-id={preset.id}
data-selected={if @selected_preset == preset.id, do: "true"}
disabled={@disabled}
>
{preset.label}
</button>
</div>
<!-- Mobile: Tab presets -->
<div data-part="presets" data-device="mobile">
<button
:for={preset <- @presets}
type="button"
data-part="preset-item"
data-preset-id={preset.id}
data-selected={if @selected_preset == preset.id, do: "true"}
disabled={@disabled}
>
{preset.label}
</button>
</div>
<!-- Calendar area -->
<div data-part="calendar">
<div data-part="months">
<!-- Month 1 -->
<div data-part="month" data-index="0">
<div data-part="view-control">
<button type="button" data-part="prev-trigger" disabled={@disabled}>
<.chevron_left />
</button>
<span data-part="view-trigger"></span>
<button type="button" data-part="next-trigger" disabled={@disabled}>
<.chevron_right />
</button>
</div>
<table data-part="table">
<thead data-part="table-head">
<tr data-part="table-row">
<th :for={_day <- 1..7} data-part="table-header"></th>
</tr>
</thead>
<tbody data-part="table-body">
<tr :for={_week <- 1..6} data-part="table-row">
<td :for={_day <- 1..7} data-part="day-table-cell">
<button type="button" data-part="table-cell-trigger" disabled={@disabled}>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Month 2 (Desktop only) -->
<div data-part="month" data-index="1" data-desktop-only>
<div data-part="view-control">
<button type="button" data-part="prev-trigger" disabled={@disabled}>
<.chevron_left />
</button>
<span data-part="view-trigger"></span>
<button type="button" data-part="next-trigger" disabled={@disabled}>
<.chevron_right />
</button>
</div>
<table data-part="table">
<thead data-part="table-head">
<tr data-part="table-row">
<th :for={_day <- 1..7} data-part="table-header"></th>
</tr>
</thead>
<tbody data-part="table-body">
<tr :for={_week <- 1..6} data-part="table-row">
<td :for={_day <- 1..7} data-part="day-table-cell">
<button type="button" data-part="table-cell-trigger" disabled={@disabled}>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<.line_divider />
<!-- Footer -->
<div data-part="footer">
<div data-part="range-display">
<.date_input_group type="start" disabled={@disabled} />
<div data-part="arrow">
<.arrow_narrow_right />
</div>
<.date_input_group type="end" disabled={@disabled} />
</div>
<div data-part="actions">
{render_slot(@actions)}
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
defp extract_period(nil), do: {nil, nil}
defp extract_period({start_dt, end_dt}), do: {start_dt, end_dt}
# encode_date is used for min/max which can be Date, DateTime, or string
defp encode_date(nil), do: nil
defp encode_date(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp encode_date(%Date{} = d), do: Date.to_iso8601(d)
defp encode_date(str) when is_binary(str), do: str
# encode_datetime is used for period start/end which must be DateTime
defp encode_datetime(nil), do: nil
defp encode_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp encode_preset_period(nil), do: nil
defp encode_preset_period({amount, unit}), do: %{amount: amount, unit: to_string(unit)}
defp format_date_range(%DateTime{} = start_dt, %DateTime{} = end_dt) do
"#{format_date_for_label(start_dt)} - #{format_date_for_label(end_dt)}"
end
defp format_date_for_label(%DateTime{} = dt) do
Calendar.strftime(dt, "%d.%m.%Y")
end
# Private component for date input fields
attr :type, :string, required: true
attr :disabled, :boolean, default: false
defp date_input_group(assigns) do
~H"""
<div data-part="date-display" data-type={@type} data-format="dmy">
<input
type="text"
data-part="date-input"
data-field="day"
placeholder="DD"
maxlength="2"
disabled={@disabled}
/>
<span data-part="date-separator">•</span>
<input
type="text"
data-part="date-input"
data-field="month"
placeholder="MM"
maxlength="2"
disabled={@disabled}
/>
<span data-part="date-separator">•</span>
<input
type="text"
data-part="date-input"
data-field="year"
placeholder="YYYY"
maxlength="4"
disabled={@disabled}
/>
</div>
"""
end
end