<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
// Ввод длительности: два поля — часы и минуты (минуты с шагом 5). Заставлять
// пользователя переводить время в минуты не нужно. Значение компонента —
// минуты (число|null), как в API; часы/минуты раскладываются при вводе и
// собираются обратно при изменении. Оба поля пустые = null («не указано»).
//
// Текст полей — источник истины, пока в них печатают: значение, вернувшееся от
// родителя, применяется к полям, только если не совпадает с уже набранным.
// Без этой сверки очищенное поле мгновенно наполнялось прежним числом (эхо
// «родитель → поля» раскладывало «30 мин» обратно в «0 ч 30 мин»), и переписать
// значение было нельзя.
//
// Границы — те же, что у API (ge=1, le=1440): иначе сервер отвечает 422 уже
// после нажатия «сохранить». Зажимаем их при выходе из полей, а не на каждой
// цифре: зажим на вводе переписывал бы набираемое число.
const props = defineProps<{
modelValue: number | null
/** Общая подпись блока; в инлайн-редакторах подпись даёт строка параметров */
label?: string
/** Минимум для непустого значения, в минутах (не задан — 0) */
min?: number
/** Максимум, в минутах; по умолчанию — сутки, как в API */
max?: number
}>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
const { t } = useI18n()
// Предел полей задачи и в API, и в атрибутах input (шаг спиннера — целые часы)
const MAX_HOURS = 24
const MAX_MINUTES = 59
const DAY = MAX_HOURS * 60
const root = ref<HTMLElement | null>(null)
const hoursText = ref('')
const minutesText = ref('')
function toNumber(text: string): number | null {
const trimmed = text.trim()
if (trimmed === '') return null
const n = Number(trimmed)
return Number.isFinite(n) ? n : null
}
/** Набранное, как его понимает API: оба поля пусты → null («не указано»). */
function typed(): number | null {
const h = toNumber(hoursText.value)
const m = toNumber(minutesText.value)
if (h === null && m === null) return null
return Math.max(0, Math.round(h ?? 0)) * 60 + Math.max(0, Math.round(m ?? 0))
}
/** Значение компонента: набранное, зажатое в границы. */
const value = computed<number | null>(() => {
const total = typed()
if (total === null) return null
return Math.min(Math.max(total, props.min ?? 0), props.max ?? DAY)
})
/** Разложить минуты по полям; null → оба пустые. */
function fill(total: number | null): void {
if (total === null) {
hoursText.value = ''
minutesText.value = ''
return
}
hoursText.value = String(Math.floor(total / 60))
minutesText.value = String(total % 60)
}
watch(
() => props.modelValue,
(v) => {
// Эхо собственного ввода — не трогаем поля; чужое значение (загрузка
// задачи, сброс формы, правка извне) раскладываем
if (v !== value.value) fill(v)
},
{ immediate: true },
)
watch(value, (v) => emit('update:modelValue', v))
/** Привести поля к границам и к нормальному виду: «0 ч 90 мин» → «1 ч 30 мин». */
function onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget as Node | null
// Переход «часы → мин» или к кнопкам ✓/✗ внутри строки — ещё не конец ввода:
// нормализация тут переписывала бы поле, в которое человек собирается печатать
if (next && root.value?.contains(next)) return
fill(value.value)
}
</script>
<template>
<span ref="root" class="duration-input" @focusout="onFocusOut">
<span v-if="label" class="duration-label">{{ label }}</span>
<span class="duration-fields">
<GnInput
v-model="hoursText"
type="number"
inputmode="numeric"
min="0"
:max="MAX_HOURS"
class="hours"
:aria-label="t('duration.hours')"
/>
<span class="unit">{{ t('duration.h') }}</span>
<GnInput
v-model="minutesText"
type="number"
inputmode="numeric"
min="0"
:max="MAX_MINUTES"
step="5"
class="minutes"
:aria-label="t('duration.minutes')"
/>
<span class="unit">{{ t('duration.min') }}</span>
</span>
</span>
</template>
<style scoped>
.duration-input {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
min-width: 0;
}
.duration-label {
font-size: 0.85em;
color: var(--text-muted, #888);
}
.duration-fields {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
/* GnInput рендерит div.form-group > label > input: убираем вертикальные
отступы формы и задаём компактную ширину полей (переопределение размеров,
стили кита остаются). Ширина — с запасом под спиннер: на 4rem он наезжал на
цифру в узком сайдбаре */
.duration-fields :deep(.form-group) {
margin: 0;
width: auto;
}
.duration-fields :deep(input) {
width: 5rem;
padding: 4px 10px;
/* цифры в столбик: «10 ч 5 мин» не дёргается при наборе */
font-variant-numeric: tabular-nums;
}
.unit {
color: var(--text-muted, #888);
font-size: 0.9em;
}
</style>