Newer
Older
gnexus-tasks / frontend / src / components / InlineField.vue
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

// Универсальный враппер «показ ↔ редактор» для инлайн-правки поля на месте.
// В ките инлайн-компонента нет, а GnInput умеет bare (голый input) — слоты
// позволяют подставить любой редактор (input, select, пара дата+период).
// Сохранение — явными кнопками ✓/✗ (не по blur); Esc — отмена.
// save() может кинуть ошибку: тогда редактор остаётся открытым
// (родитель уже показал тост).

const props = defineProps<{
  /** aria-label/tooltip кнопки входа в правку */
  label: string
  /** Родительский хендлер сохранения; promise → busy, throw → редактор открыт */
  save: () => Promise<unknown> | unknown
  /** Редактор показывается под полем (на всю ширину), а не в строке значения */
  below?: boolean
}>()

const emit = defineEmits<{ open: [] }>()

const { t } = useI18n()

const open = ref(false)
const busy = ref(false)
const root = ref<HTMLElement | null>(null)

function edit() {
  emit('open')
  open.value = true
}

function cancel() {
  if (busy.value) return
  open.value = false
}

// Фокус в первое поле редактора: клик по значению означает «начать печатать»,
// иначе после открытия приходилось целиться в поле второй раз, а Esc до этого
// клика не срабатывал вовсе (обработчик ниже ловит событие по фокусу)
watch(open, (isOpen) => {
  if (!isOpen) return
  void nextTick(() => {
    root.value?.querySelector<HTMLElement>('input, select, textarea')?.focus()
  })
})

// Esc — отмена: обработчика на самом компоненте мало, он ловит событие, только
// пока фокус внутри редактора, а к моменту нажатия фокус уже мог уйти (клик по
// соседней строке параметров). Внутри модалок Esc принадлежит им — не мешаем
function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'Escape' || !open.value) return
  const target = event.target
  if (target instanceof Element && target.closest('.modal, .drawer')) return
  cancel()
}

onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))

async function save() {
  if (busy.value) return
  busy.value = true
  try {
    await props.save()
    open.value = false
  } catch {
    // ошибка уже показана родителем — оставляем редактор открытым
  } finally {
    busy.value = false
  }
}
</script>

<template>
  <span ref="root" class="inline-field">
    <span v-if="!open" class="inline-display">
      <slot name="display" :edit="edit" />
    </span>
    <span v-else class="inline-editing" :class="{ below }">
      <slot name="edit" :save="save" :cancel="cancel" :busy="busy" />
      <span class="inline-actions">
        <GnIconButton
          icon="ph-check"
          size="sm"
          :label="t('common.save')"
          :disabled="busy"
          @click="save"
        />
        <GnIconButton
          icon="ph-x"
          size="sm"
          :label="t('common.cancel')"
          :disabled="busy"
          @click="cancel"
        />
      </span>
    </span>
  </span>
</template>

<style scoped>
.inline-field {
  display: inline-flex;
  min-width: 0;
}
/* показ: кликабельно, по hover — подсказка пунктиром */
.inline-display {
  cursor: pointer;
  min-width: 0;
}
.inline-display:hover {
  text-decoration: underline dashed var(--accent, #7aa2f7);
  text-underline-offset: 3px;
}
/* правка: редактор + кнопки; перенос строкой ниже лучше обрезки в узкой колонке */
.inline-editing {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
  min-width: 0;
}
.inline-actions {
  display: inline-flex;
  gap: 0.25rem;
  flex: none;
}
/* режим «под полем»: редактор на всю ширину строки (select/поля + кнопки ✓/✗
   в один ряд), с внешним отступом снизу — узкие строки (сайдбар параметров)
   не обрезают формочку */
.inline-editing.below {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  width: 100%;
  margin-bottom: 1rem;
}
.inline-editing.below > :not(.inline-actions) {
  /* width: auto — иначе базис auto у form-group кита (width:100%) выталкивает
     кнопки ✓/✗ на следующую строку */
  flex: 1 1 auto;
  min-width: 0;
  width: auto;
}
</style>