feat(date-range-filter): keyboard navigation between date parts
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) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import urllib.parse
|
|||||||
import pytest
|
import pytest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
from common.components import PurchaseFilterBar
|
from common.components import PurchaseFilterBar
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@@ -324,3 +325,136 @@ def test_prefilled_picker_round_trips_unchanged(live_server, page):
|
|||||||
"value2": "2024-09-20",
|
"value2": "2024-09-20",
|
||||||
"modifier": "BETWEEN",
|
"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"
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ function segmentBuffer(segment: HTMLInputElement): string {
|
|||||||
return segment.dataset.typedDigits || "";
|
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 {
|
function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
||||||
segment.dataset.typedDigits = buffer;
|
segment.dataset.typedDigits = buffer;
|
||||||
if (buffer === "") {
|
if (buffer === "") {
|
||||||
@@ -229,6 +246,36 @@ function initField(picker: HTMLElement, calendarState: CalendarState): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) 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();
|
event.preventDefault();
|
||||||
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
||||||
const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
||||||
|
|||||||
Reference in New Issue
Block a user