From 49c1af81121386dcc78a278abbae55cdd5cd9ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 14:31:10 +0200 Subject: [PATCH 1/3] feat(date-range-filter): keyboard navigation between date parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #64. The segmented date-range field now responds to arrow keys: - Left/Right move focus between DD/MM/YYYY parts, crossing the min→max separator; focus clamps at the first/last part (no wrap). - Up/Down increment/decrement the focused part, clamped to its valid range (day 1-31, month 1-12, year 1-9999). An empty part seeds to 01 for day/month and the current year for year on the first press. Arrows with modifiers (Ctrl/Alt/Meta) still fall through to native behavior. Adds e2e coverage for focus walking, boundary clamping, value stepping, hidden-ISO commit, and modifier passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/test_date_range_picker_e2e.py | 134 ++++++++++++++++++++++++++++++ ts/elements/date-range-picker.ts | 47 +++++++++++ 2 files changed, 181 insertions(+) diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py index f3b9ca5..7e5ae9f 100644 --- a/e2e/test_date_range_picker_e2e.py +++ b/e2e/test_date_range_picker_e2e.py @@ -18,6 +18,7 @@ import urllib.parse import pytest from django.http import HttpResponse from django.test import override_settings +from playwright.sync_api import expect from common.components import PurchaseFilterBar from django.urls import path @@ -324,3 +325,136 @@ def test_prefilled_picker_round_trips_unchanged(live_server, page): "value2": "2024-09-20", "modifier": "BETWEEN", } + + +# ── Keyboard navigation ───────────────────────────────────────────────────── + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_right_moves_focus_across_segments(live_server, page): + """ArrowRight walks DD → MM → YYYY and crosses the min→max separator.""" + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "min", "day").click() + page.keyboard.press("ArrowRight") + expect(_segment(page, "min", "month")).to_be_focused() + page.keyboard.press("ArrowRight") + expect(_segment(page, "min", "year")).to_be_focused() + page.keyboard.press("ArrowRight") + expect(_segment(page, "max", "day")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_left_moves_focus_backwards(live_server, page): + """ArrowLeft from the max side's first part lands on the min side's last.""" + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "max", "day").click() + page.keyboard.press("ArrowLeft") + expect(_segment(page, "min", "year")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_left_clamps_at_first_segment(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "min", "day").click() + page.keyboard.press("ArrowLeft") + expect(_segment(page, "min", "day")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_right_clamps_at_last_segment(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "max", "year").click() + page.keyboard.press("ArrowRight") + expect(_segment(page, "max", "year")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_up_on_empty_day_seeds_value(live_server, page): + """First ArrowUp on an empty part lands on its seed (day → 01).""" + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.press("ArrowUp") + assert day_segment.input_value() == "01" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_up_on_empty_year_uses_current_year(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + year_segment = _segment(page, "min", "year") + year_segment.click() + page.keyboard.press("ArrowUp") + assert year_segment.input_value() == str(datetime.date.today().year) + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_up_down_increments_and_clamps_day(live_server, page): + """Up increments and clamps at 31; Down clamps at 01 (no wrap).""" + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + # Typing a full 2-digit part auto-advances, so re-focus before arrowing. + day_segment.click() + page.keyboard.type("28") + day_segment.click() + for _ in range(4): + page.keyboard.press("ArrowUp") + assert day_segment.input_value() == "31" + page.keyboard.press("ArrowDown") + assert day_segment.input_value() == "30" + day_segment.click() + page.keyboard.type("01") + day_segment.click() + page.keyboard.press("ArrowDown") + assert day_segment.input_value() == "01" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_up_down_clamps_month(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + month_segment = _segment(page, "min", "month") + month_segment.click() + page.keyboard.type("12") + month_segment.click() + page.keyboard.press("ArrowUp") + assert month_segment.input_value() == "12" + month_segment.click() + page.keyboard.type("01") + month_segment.click() + page.keyboard.press("ArrowDown") + assert month_segment.input_value() == "01" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_arrow_value_change_commits_complete_date(live_server, page): + """Stepping the last empty part to a valid value commits the hidden ISO.""" + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "min", "month").click() + page.keyboard.type("032024") # month=03 → year=2024, day left empty + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.press("ArrowUp") # day → 01 + assert page.locator(HIDDEN_MIN).input_value() == "2024-03-01" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_ctrl_arrow_does_not_change_value(live_server, page): + """Ctrl+Arrow falls through to native — the segment value is untouched.""" + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.type("15") + day_segment.click() + page.keyboard.down("Control") + page.keyboard.press("ArrowUp") + page.keyboard.up("Control") + assert day_segment.input_value() == "15" diff --git a/ts/elements/date-range-picker.ts b/ts/elements/date-range-picker.ts index 6d64f74..1404271 100644 --- a/ts/elements/date-range-picker.ts +++ b/ts/elements/date-range-picker.ts @@ -137,6 +137,23 @@ function segmentBuffer(segment: HTMLInputElement): string { return segment.dataset.typedDigits || ""; } +// The numeric bounds of a date part plus the value an empty part jumps to on +// the first ArrowUp/ArrowDown (day/month start at 01, year at the current year +// rather than 0001). +interface PartRange { + min: number; + max: number; + empty: number; +} + +function partRange(datePart: string): PartRange { + if (datePart === "month") return { min: 1, max: 12, empty: 1 }; + if (datePart === "year") { + return { min: 1, max: 9999, empty: new Date().getFullYear() }; + } + return { min: 1, max: 31, empty: 1 }; // day +} + function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void { segment.dataset.typedDigits = buffer; if (buffer === "") { @@ -229,6 +246,36 @@ function initField(picker: HTMLElement, calendarState: CalendarState): void { return; } if (event.ctrlKey || event.metaKey || event.altKey) return; + // Arrow keys move between parts (Left/Right) or step the focused part's + // value (Up/Down); handled before the digit-only path below. Out-of-range + // index clamps (no wrap); Up/Down clamp at the part's range ends. + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const step = event.key === "ArrowRight" ? 1 : -1; + const target = segments[segmentIndex + step]; + if (target) { + target.focus(); + target.select(); + } + return; + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + const range = partRange(segment.dataset.datePart ?? ""); + const width = parseInt(segment.getAttribute("maxlength") ?? "", 10); + const buffer = segmentBuffer(segment); + let next: number; + if (buffer === "") { + next = range.empty; + } else { + next = parseInt(buffer, 10) + (event.key === "ArrowUp" ? 1 : -1); + } + if (next < range.min) next = range.min; + if (next > range.max) next = range.max; + setSegmentBuffer(segment, padNumber(next, width)); + syncHiddenFromSegments(picker, segment.dataset.dateSide ?? ""); + return; + } event.preventDefault(); if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10); From c7c649117567bf38f59fe3909c778a6b7dd9e346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 15:01:08 +0200 Subject: [PATCH 2/3] feat(date-range-filter): clamp typed digits per part with smart advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed digits in the segmented date field were unvalidated — you could enter 60 for a day or 30 for a month. Now each digit is clamped to its part's range and auto-advances: - A digit that cannot validly extend the current part commits as a zero-padded value and moves to the next part (month 9 → 09▶, day 6 → 06▶). - An ambiguous digit that could still take a second stays pending (month 1 → 01; then 2 → 12▶, or 9 → 09▶ dropping the overflowed 1). - Day/month show a pending single digit zero-padded; the year part keeps its existing right-fill placeholder display and 4-digit advance. Logic lives in a pure applyDigit() helper; completion is normalized to a full-width buffer so syncHiddenFromSegments commits it. Adds 10 e2e tests covering clamping, auto-advance, overflow-drop, zero-pad display, the single-digit commit invariant, and restart-on-full. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/test_date_range_picker_e2e.py | 133 ++++++++++++++++++++++++++++++ ts/elements/date-range-picker.ts | 53 ++++++++++-- 2 files changed, 179 insertions(+), 7 deletions(-) diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py index 7e5ae9f..95b1caa 100644 --- a/e2e/test_date_range_picker_e2e.py +++ b/e2e/test_date_range_picker_e2e.py @@ -458,3 +458,136 @@ def test_ctrl_arrow_does_not_change_value(live_server, page): page.keyboard.press("ArrowUp") page.keyboard.up("Control") assert day_segment.input_value() == "15" + + +# ── Digit clamping & auto-advance ─────────────────────────────────────────── + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_month_high_digit_autoadvances_zero_padded(live_server, page): + """A digit that cannot lead a valid month commits zero-padded and advances.""" + page.goto(live_server.url + "/test-date-range-picker/") + month_segment = _segment(page, "min", "month") + month_segment.click() + page.keyboard.press("9") + assert month_segment.input_value() == "09" + expect(_segment(page, "min", "year")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_month_one_stays_then_two_completes(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + month_segment = _segment(page, "min", "month") + month_segment.click() + page.keyboard.press("1") + assert month_segment.input_value() == "01" + expect(month_segment).to_be_focused() # ambiguous: could become 10/11/12 + page.keyboard.press("2") + assert month_segment.input_value() == "12" + expect(_segment(page, "min", "year")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_month_one_then_nine_drops_overflow(live_server, page): + """1 then 9 (19 > 12) drops the leading 1 and commits 09.""" + page.goto(live_server.url + "/test-date-range-picker/") + month_segment = _segment(page, "min", "month") + month_segment.click() + page.keyboard.press("1") + page.keyboard.press("9") + assert month_segment.input_value() == "09" + expect(_segment(page, "min", "year")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_day_high_digit_autoadvances(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.press("6") + assert day_segment.input_value() == "06" + expect(_segment(page, "min", "month")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_day_three_then_two_overflows_to_pending_two(live_server, page): + """3 stays (30/31 possible); 2 (32 > 31) drops to a pending 02, still day.""" + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.press("3") + assert day_segment.input_value() == "03" + expect(day_segment).to_be_focused() + page.keyboard.press("2") + assert day_segment.input_value() == "02" + expect(day_segment).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_leading_zero_then_digit_on_day(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.press("0") + assert day_segment.input_value() == "00" + expect(day_segment).to_be_focused() + page.keyboard.press("9") + assert day_segment.input_value() == "09" + expect(_segment(page, "min", "month")).to_be_focused() + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_double_zero_day_does_not_commit(live_server, page): + """Day 00 is a complete-but-invalid part, so the side stays uncommitted.""" + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "min", "day").click() + page.keyboard.type("00032024") # day=00, month=03, year=2024 + assert _segment(page, "min", "day").input_value() == "00" + assert page.locator(HIDDEN_MIN).input_value() == "" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_year_pending_still_right_fills(live_server, page): + """Year keeps the right-fill placeholder display under the new logic.""" + page.goto(live_server.url + "/test-date-range-picker/") + year_segment = _segment(page, "min", "year") + year_segment.click() + page.keyboard.press("1") + assert year_segment.input_value() == "YYY1" + page.keyboard.press("9") + assert year_segment.input_value() == "YY19" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_single_high_digit_commits_when_other_parts_present(live_server, page): + """An auto-advanced single digit is a complete part, so it commits the ISO.""" + page.goto(live_server.url + "/test-date-range-picker/") + _segment(page, "min", "day").click() + page.keyboard.type("15") # day=15 → advances to month + _segment(page, "min", "year").click() + page.keyboard.type("2024") # year=2024 + _segment(page, "min", "month").click() + page.keyboard.press("9") # month=09 (auto-advance) + assert page.locator(HIDDEN_MIN).input_value() == "2024-09-15" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e") +def test_retype_full_part_restarts(live_server, page): + page.goto(live_server.url + "/test-date-range-picker/") + day_segment = _segment(page, "min", "day") + day_segment.click() + page.keyboard.type("15") # advances to month + day_segment.click() + page.keyboard.press("3") + assert day_segment.input_value() == "03" + expect(day_segment).to_be_focused() diff --git a/ts/elements/date-range-picker.ts b/ts/elements/date-range-picker.ts index 1404271..6b0ce95 100644 --- a/ts/elements/date-range-picker.ts +++ b/ts/elements/date-range-picker.ts @@ -154,6 +154,36 @@ function partRange(datePart: string): PartRange { return { min: 1, max: 31, empty: 1 }; // day } +interface DigitEntry { + buffer: string; + complete: boolean; +} + +// Fold a freshly typed digit into a part's buffer, clamping to the part's max +// and deciding whether to auto-advance. A digit that cannot validly extend the +// current value (e.g. 9 into a ≤12 month, or a second digit pushing past the +// max) commits as a zero-padded single digit and completes; an ambiguous digit +// that could still take another (month 1 → 10/11/12) stays pending. +// +// Invariant: complete === true MUST imply buffer.length === width, because +// syncHiddenFromSegments re-derives completeness from buffer length — that is +// why a completing single digit is padded to full width before returning. +function applyDigit( + buffer: string, + digit: string, + width: number, + max: number +): DigitEntry { + if (buffer.length >= width) buffer = ""; // restart an already-full part + let candidate = buffer + digit; + if (parseInt(candidate, 10) > max) candidate = digit; // overflow → fresh ones digit + const value = parseInt(candidate, 10); + // Strict >: value*10 <= max means another digit could still land in range. + const complete = candidate.length === width || value * 10 > max; + if (complete) candidate = padNumber(value, width); + return { buffer: candidate, complete }; +} + function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void { segment.dataset.typedDigits = buffer; if (buffer === "") { @@ -161,8 +191,13 @@ function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void { return; } const placeholder = segment.getAttribute("placeholder") ?? ""; - // Fill the placeholder from the right: typing 19 into YYYY shows YY19. - segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer; + if (segment.dataset.datePart === "year") { + // Fill the placeholder from the right: typing 19 into YYYY shows YY19. + segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer; + } else { + // Day/month show a pending single digit zero-padded: typing 1 shows 01. + segment.value = buffer.padStart(placeholder.length, "0"); + } } function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] { @@ -278,13 +313,17 @@ function initField(picker: HTMLElement, calendarState: CalendarState): void { } event.preventDefault(); if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed - const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10); - let buffer = segmentBuffer(segment); - // Typing into an already-full part starts it over. - buffer = buffer.length >= maximumLength ? event.key : buffer + event.key; + const width = parseInt(segment.getAttribute("maxlength") ?? "", 10); + const max = partRange(segment.dataset.datePart ?? "").max; + const { buffer, complete } = applyDigit( + segmentBuffer(segment), + event.key, + width, + max + ); setSegmentBuffer(segment, buffer); syncHiddenFromSegments(picker, segment.dataset.dateSide ?? ""); - if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) { + if (complete && segmentIndex + 1 < segments.length) { segments[segmentIndex + 1].focus(); } }); From 8ba175adc48b2007e5435519d3e0c62fd884cc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 18:04:46 +0200 Subject: [PATCH 3/3] fix(date-range-filter): drop text selection on arrow navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow Left/Right called target.select(), which painted the segment with the browser's default text-selection color instead of the brand focus background used everywhere else — so a part looked pink when reached by arrow keys but blue when clicked or tabbed into. The select() was redundant (the digit handler already restarts a full part on the next keypress), so removing it makes the focus highlight consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/elements/date-range-picker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ts/elements/date-range-picker.ts b/ts/elements/date-range-picker.ts index 6b0ce95..8f2390d 100644 --- a/ts/elements/date-range-picker.ts +++ b/ts/elements/date-range-picker.ts @@ -290,7 +290,6 @@ function initField(picker: HTMLElement, calendarState: CalendarState): void { const target = segments[segmentIndex + step]; if (target) { target.focus(); - target.select(); } return; }