pcp-tgu-ops-ui: маска ввода времени — невозможные значения недопустимы

Time24Field принимал любой текст (99:99 и т.п.), валидным он был только при
коммите/блюре. Добавлена маска maskTime: на каждый ввод оставляет цифры,
расставляет двоеточия и зажимает сегменты (часы 0-23, мин/сек 0-59) с авто-
дополнением первого разряда, поэтому набрать невозможное время нельзя.
Покрыто юнит-тестами (timeFields.test.ts).
This commit is contained in:
Дмитрий Соловьев
2026-06-03 09:12:16 +03:00
parent 51fd8a446b
commit b61be0afb8
2 changed files with 69 additions and 1 deletions
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { maskTime } from "./timeFields";
describe("maskTime", () => {
it("не пропускает невозможные значения", () => {
expect(maskTime("99:99", false)).toBe("09:09"); // 9>2 → 09; 9>5 → 09
expect(maskTime("3070", false)).toBe("03:07"); // 3>2 → 03; 7>5 → 07
expect(maskTime("2599", false)).toBe("23:09"); // 25→23 (зажим), 9>5 → 09
});
it("расставляет двоеточия и ограничивает длину", () => {
expect(maskTime("1234", false)).toBe("12:34");
expect(maskTime("12", false)).toBe("12");
expect(maskTime("123456789", false)).toBe("12:34");
});
it("поддерживает секунды", () => {
expect(maskTime("123456", true)).toBe("12:34:56");
expect(maskTime("235959", true)).toBe("23:59:59");
expect(maskTime("990000", true)).toBe("09:09:00");
});
it("игнорирует мусор и лишние двоеточия", () => {
expect(maskTime("1a2:b3", false)).toBe("12:3");
expect(maskTime("", false)).toBe("");
});
});
@@ -23,6 +23,46 @@ type Time24FieldProps = {
const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/;
const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
/**
* Забирает из потока цифр один сегмент времени `00..max` (часы → 23, мин/сек → 59).
* Если первая цифра заведомо больше старшего разряда (напр. `9` для часов), сразу
* дополняет нулём (`09`) и переходит к следующему сегменту; двузначное значение
* сверх максимума зажимается. Возвращает [сегмент, остаток цифр].
*/
function takeSegment(digits: string, max: number): [string, string] {
if (digits.length === 0) return ["", ""];
const firstMax = Math.floor(max / 10);
if (Number(digits[0]) > firstMax) {
return [`0${digits[0]}`, digits.slice(1)];
}
let segment = digits.slice(0, 2);
if (segment.length === 2 && Number(segment) > max) {
segment = String(max);
}
return [segment, digits.slice(2)];
}
/**
* Маскирует произвольный ввод в `ЧЧ:ММ[:СС]`: оставляет только цифры, расставляет
* двоеточия и зажимает каждый сегмент в допустимый диапазон, поэтому невозможные
* значения (`99:99`, `30:70`) набрать нельзя — они приводятся к корректным.
*/
export function maskTime(input: string, withSeconds: boolean): string {
const digits = input.replace(/\D/g, "").slice(0, withSeconds ? 6 : 4);
const segments: string[] = [];
let [segment, rest] = takeSegment(digits, 23);
segments.push(segment);
if (rest.length > 0) {
[segment, rest] = takeSegment(rest, 59);
segments.push(segment);
}
if (withSeconds && rest.length > 0) {
[segment, rest] = takeSegment(rest, 59);
segments.push(segment);
}
return segments.join(":");
}
export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) { export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) {
const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM; const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM;
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
@@ -38,13 +78,14 @@ export function Time24Field({ value, withSeconds = false, onChange, className, s
{...rest} {...rest}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
maxLength={withSeconds ? 8 : 5}
className={className} className={className}
style={style} style={style}
placeholder={withSeconds ? "чч:мм:сс" : "чч:мм"} placeholder={withSeconds ? "чч:мм:сс" : "чч:мм"}
value={draft} value={draft}
onFocus={() => setEditing(true)} onFocus={() => setEditing(true)}
onChange={(event) => { onChange={(event) => {
const next = event.target.value; const next = maskTime(event.target.value, withSeconds);
setDraft(next); setDraft(next);
if (re.test(next)) onChange(next); if (re.test(next)) onChange(next);
}} }}