Merge pull request #79 from KucharczykL/drf-keyboard-nav
feat(date-range-filter): keyboard navigation between date parts
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user