feat(date-range-filter): clamp typed digits per part with smart advance
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) <noreply@anthropic.com>
This commit is contained in:
@@ -458,3 +458,136 @@ def test_ctrl_arrow_does_not_change_value(live_server, page):
|
|||||||
page.keyboard.press("ArrowUp")
|
page.keyboard.press("ArrowUp")
|
||||||
page.keyboard.up("Control")
|
page.keyboard.up("Control")
|
||||||
assert day_segment.input_value() == "15"
|
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()
|
||||||
|
|||||||
@@ -154,6 +154,36 @@ function partRange(datePart: string): PartRange {
|
|||||||
return { min: 1, max: 31, empty: 1 }; // day
|
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 {
|
function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
||||||
segment.dataset.typedDigits = buffer;
|
segment.dataset.typedDigits = buffer;
|
||||||
if (buffer === "") {
|
if (buffer === "") {
|
||||||
@@ -161,8 +191,13 @@ function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const placeholder = segment.getAttribute("placeholder") ?? "";
|
const placeholder = segment.getAttribute("placeholder") ?? "";
|
||||||
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
if (segment.dataset.datePart === "year") {
|
||||||
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
// 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[] {
|
function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] {
|
||||||
@@ -278,13 +313,17 @@ function initField(picker: HTMLElement, calendarState: CalendarState): void {
|
|||||||
}
|
}
|
||||||
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 width = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
||||||
let buffer = segmentBuffer(segment);
|
const max = partRange(segment.dataset.datePart ?? "").max;
|
||||||
// Typing into an already-full part starts it over.
|
const { buffer, complete } = applyDigit(
|
||||||
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
segmentBuffer(segment),
|
||||||
|
event.key,
|
||||||
|
width,
|
||||||
|
max
|
||||||
|
);
|
||||||
setSegmentBuffer(segment, buffer);
|
setSegmentBuffer(segment, buffer);
|
||||||
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
||||||
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
if (complete && segmentIndex + 1 < segments.length) {
|
||||||
segments[segmentIndex + 1].focus();
|
segments[segmentIndex + 1].focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user