Files
timetracker/common/components/date_range_picker.py
T
lukas 19e9fd1419 Convert date_range_picker.js to TypeScript (issue #17)
- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
  dynamically-assigned refreshFromField) and an Anchor union replace the loose
  state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
  onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
  picker now also initializes inside htmx-swapped fragments. Drops the dead
  window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
  it as an ES module in the e2e page (was a deferred classic script)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:52:48 +02:00

355 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DateRangePicker: a segmented date-range input with a calendar popup.
``DateRangePicker`` composes two parts:
- ``DateRangeField`` — the visible widget, styled as a single input. Each
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
plus a calendar icon that opens the popup.
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
last 7 days, …), a month grid rendered client-side, and a
Cancel / Clear / Select footer.
The committed value lives in two hidden ISO-date inputs named
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
as the older ``DateRangeFilter``, so ``filter_bar.ts`` serializes either
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
"""
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
# Wired by ts/date_range_picker.ts (compiled to dist/).
_DATE_RANGE_MEDIA = Media(js=("dist/date_range_picker.js",))
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
)
# The segments must not stand out from the container: transparent background,
# no border, and only a subtle highlight when active (focused).
_SEGMENT_INPUT_CLASS = (
"bg-transparent border-0 p-0 text-center text-sm text-heading "
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
"focus:bg-brand/30 caret-transparent"
)
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
_CALENDAR_ICON_SVG = (
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
'stroke="currentColor" aria-hidden="true">'
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5'
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
"</svg>"
)
_PRESET_OPTIONS: list[tuple[str, str]] = [
("today", "Today"),
("yesterday", "Yesterday"),
("last_7_days", "Last 7 days"),
("last_30_days", "Last 30 days"),
("this_month", "This month"),
("last_month", "Last month"),
("this_year", "This year"),
]
_PRESET_BUTTON_CLASS = (
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
)
_NAV_BUTTON_CLASS = (
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
"rounded-base cursor-pointer"
)
_FOOTER_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-heading bg-neutral-secondary-medium border border-default-medium "
"hover:bg-neutral-tertiary-medium"
)
_FOOTER_SELECT_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-white bg-brand border border-transparent hover:bg-brand-strong"
)
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
Returns an empty mapping for empty/malformed input so a bad stored filter
renders as empty segments instead of crashing."""
if not iso_value:
return {}
pieces = iso_value.split("-")
if len(pieces) != 3:
return {}
year, month, day = pieces
values = {"year": year, "month": month, "day": day}
if any(not values[part.name].isdigit() for part in parts):
return {}
return values
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
side_label = "from" if side == "min" else "to"
return Input(
attributes=[
("inputmode", "numeric"),
("autocomplete", "off"),
("maxlength", str(part.length)),
("placeholder", part.placeholder),
("value", value),
("data-date-part", part.name),
("data-date-side", side),
("aria-label", f"{label} {side_label} {part.name}"),
(
"class",
f"{_SEGMENT_INPUT_CLASS} "
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
),
],
)
def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
parts = date_parts()
initial_values = _iso_part_values(iso_value, parts)
children: list[Node] = []
for index, part in enumerate(parts):
if index > 0:
children.append(
Span(
attributes=[("class", "text-body select-none")],
children=["-"],
)
)
children.append(
_segment_input(
part=part,
side=side,
label=label,
value=initial_values.get(part.name, ""),
)
)
return Span(
attributes=[
("class", "flex items-center gap-0.5"),
("data-date-range-side", side),
],
children=children,
)
def DateRangeField(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""The visible half of the DateRangePicker: a single-input-looking
container holding two segmented dates, a calendar toggle, and the two
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
committed value to ``filter_bar.js``."""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
return Div(
attributes=[
("class", _FIELD_CONTAINER_CLASS),
("data-date-range-field", ""),
],
children=[
Input(
type="hidden",
attributes=[
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("data-date-range-hidden", "min"),
],
),
Input(
type="hidden",
attributes=[
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("data-date-range-hidden", "max"),
],
),
_segment_group(side="min", label=label, iso_value=min_value),
Span(
attributes=[("class", "text-body select-none px-0.5")],
children=[""],
),
_segment_group(side="max", label=label, iso_value=max_value),
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-calendar-toggle", ""),
("aria-label", f"Open {label} calendar"),
(
"class",
"ms-auto p-1 text-body hover:text-heading rounded "
"cursor-pointer shrink-0",
),
],
children=[Safe(_CALENDAR_ICON_SVG)],
),
],
)
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{direction}", ""),
("aria-label", label),
("class", _NAV_BUTTON_CLASS),
],
children=[arrow],
)
def _footer_button(action: str, label: str, button_class: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{action}", ""),
("class", button_class),
],
children=[label],
)
def DateRangeCalendar(*, input_name_prefix: str) -> Node:
"""The popup half of the DateRangePicker: preset column, month grid
(filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-preset", preset_value),
("class", _PRESET_BUTTON_CLASS),
],
children=[preset_label],
)
for preset_value, preset_label in _PRESET_OPTIONS
]
return Div(
attributes=[
(
"class",
"hidden absolute z-20 top-full start-0 mt-1 flex "
"rounded-base border border-default-medium "
"bg-neutral-secondary-medium shadow-lg",
),
("data-date-range-calendar", ""),
("data-input-name-prefix", input_name_prefix),
],
children=[
Div(
attributes=[
(
"class",
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
),
("data-date-range-presets", ""),
],
children=preset_buttons,
),
Div(
attributes=[("class", "p-2")],
children=[
Div(
attributes=[
("class", "flex items-center justify-between gap-2"),
],
children=[
_calendar_nav_button("prev", "", "Previous month"),
Span(
attributes=[
("class", "text-sm font-medium text-heading"),
("data-date-range-month-label", ""),
],
),
_calendar_nav_button("next", "", "Next month"),
],
),
Div(
attributes=[
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
("data-date-range-grid", ""),
],
),
Div(
attributes=[
(
"class",
"flex justify-end gap-2 mt-2 pt-2 border-t "
"border-default-medium",
),
],
children=[
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
_footer_button(
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
),
],
),
],
),
],
)
def DateRangePicker(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
inputs."""
attributes: list[HTMLAttribute] = [
("class", "date-range-picker relative"),
("data-date-range-picker", ""),
("data-input-name-prefix", input_name_prefix),
]
return Div(
attributes=attributes,
children=[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
],
).with_media(_DATE_RANGE_MEDIA)