Merge pull request #79 from KucharczykL/drf-keyboard-nav

feat(date-range-filter): keyboard navigation between date parts
This commit is contained in:
2026-06-21 18:07:51 +02:00
committed by GitHub
2 changed files with 359 additions and 7 deletions
+267
View File
@@ -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,269 @@ 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"
# ── 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()
+92 -7
View File
@@ -137,6 +137,53 @@ 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
}
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 === "") {
@@ -144,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[] {
@@ -229,15 +281,48 @@ 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();
}
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);
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();
}
});