Newer
Older
gnexus-tasks / frontend / src / components / DurationInput.vue
<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>