Newer
Older
gnexus-tasks / frontend / src / components / ProjectColorPicker.vue
<script setup lang="ts">
// Выбор цвета-метки проекта (0.63). В ките компонента цвета нет (проверено:
// GnChip/GnChipGroup/GnPopover/GnDropdown/GnRadioGroup/GnRange проп цвета не
// имеют), а GnPopover оборачивает слот в <p class="popover-text"> — сетку
// туда не положить. Поэтому панель — свой блок в стиле китового поповера
// (фон/рамка/радиус как у .popover-panel), что разрешено CLAUDE.md.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { PALETTE_GROUPS, isKnownColor } from '../projectColor'

const props = defineProps<{
  modelValue: string | null
  /** Подпись над триггером (в форме правки строка параметров подпись уже имеет) */
  label?: string
  /** Куда раскрывать панель: у правого края строки параметров — влево */
  align?: 'left' | 'right'
  disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()

const { t } = useI18n()

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

const selected = computed(() => props.modelValue?.toLowerCase() ?? null)
// Произвольный цвет (задан агентом через MCP вне палитры) показываем отдельной
// первой клеткой — иначе он был бы «невидим» в сетке и его нельзя было снять
const custom = computed(() => (selected.value && !isKnownColor(selected.value) ? selected.value : null))

function toggle() {
  if (props.disabled) return
  open.value = !open.value
}

function pick(color: string | null) {
  emit('update:modelValue', color)
  open.value = false
}

function onDocumentClick(event: MouseEvent) {
  if (!open.value) return
  if (root.value && !root.value.contains(event.target as Node)) open.value = false
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') open.value = false
}

onMounted(() => {
  document.addEventListener('click', onDocumentClick)
  document.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
  document.removeEventListener('click', onDocumentClick)
  document.removeEventListener('keydown', onKeydown)
})
</script>

<template>
  <span ref="root" class="color-field" :class="{ captioned: !!label, 'align-right': align === 'right' }">
    <span v-if="label" class="color-caption">{{ label }}</span>
    <button
      type="button"
      class="color-trigger"
      :disabled="disabled"
      aria-haspopup="true"
      :aria-expanded="open"
      :aria-label="t('projects.form.color')"
      @click="toggle"
    >
      <span v-if="selected" class="swatch" :style="{ background: selected }" aria-hidden="true" />
      <span v-else class="swatch empty" aria-hidden="true" />
      <span class="color-value">{{ selected ?? t('projects.colorNone') }}</span>
      <i class="ph ph-caret-down" aria-hidden="true" />
    </button>

    <div v-if="open" class="color-panel">
      <div v-for="group in PALETTE_GROUPS" :key="group.base" class="shade-row">
        <button
          v-for="shade in group.shades"
          :key="shade"
          type="button"
          class="swatch"
          :class="{ active: shade === selected }"
          :style="{ background: shade }"
          :aria-label="shade"
          :aria-pressed="shade === selected"
          :title="shade"
          @click="pick(shade)"
        />
      </div>
      <div class="panel-foot">
        <button
          v-if="custom"
          type="button"
          class="foot-btn"
          :aria-label="custom"
          @click="pick(custom)"
        >
          <span class="swatch" :style="{ background: custom }" aria-hidden="true" />
          {{ custom }}
        </button>
        <button type="button" class="foot-btn" @click="pick(null)">
          <span class="swatch empty" aria-hidden="true" />
          {{ t('projects.colorNone') }}
        </button>
      </div>
    </div>
  </span>
</template>

<style scoped>
.color-field {
  position: relative;
  display: inline-flex;
  min-width: 0;
}
/* В форме подпись стоит над триггером — колонкой, как поле кита */
.color-field.captioned {
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}
.color-caption {
  font-size: 0.85em;
  font-weight: 500;
  color: var(--gn-color-text-medium, #a9b1d6);
}
.color-trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 2px 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.color-trigger:disabled {
  cursor: default;
  opacity: 0.6;
}
.color-trigger:hover .color-value {
  color: var(--gn-color-accent, #7aa2f7);
}
.color-value {
  text-transform: none;
  font-size: 0.85em;
  color: var(--gn-color-text-medium, #a9b1d6);
}
.swatch {
  display: inline-block;
  flex: none;
  width: 16px;
  height: 16px;
  padding: 0;
  border: 0;
  border-radius: 4px;
  cursor: pointer;
}
/* Тонкая внутренняя обводка (0.72): тёмные ступени палитры на тёмной панели
   кита без неё сливаются с фоном, и клетку не найти. Цвет почти не меняет */
.swatch:not(.empty) {
  box-shadow: inset 0 0 0 1px rgba(192, 202, 245, 0.16);
}
/* «Без цвета» — пунктирная клетка, а не серый квадрат */
.swatch.empty {
  border: 1px dashed var(--gn-border-color-muted, rgba(192, 202, 245, 0.4));
}
/* Панель — как .popover-panel кита: фон, рамка с акцентной левой полосой, тень */
.color-panel {
  position: absolute;
  z-index: 40;
  top: calc(100% + 8px);
  left: 0;
  padding: 10px;
  background: var(--gn-surface-panel, #1f2335);
  border: 2px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
  border-left-width: 6px;
  border-radius: 6px;
  box-shadow: 0 14px 36px rgba(22, 22, 30, 0.36);
}
.shade-row {
  display: flex;
  gap: 3px;
  padding: 2px;
}
/* Триггер в правом краю строки параметров: панель раскрываем влево, не за экран */
.color-field.align-right .color-panel {
  left: auto;
  right: 0;
}
.shade-row .swatch:hover {
  outline: 2px solid var(--gn-color-text-light, #c0caf5);
}
.swatch.active {
  outline: 2px solid var(--gn-color-accent, #7aa2f7);
  outline-offset: 1px;
}
.panel-foot {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
}
.foot-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 2px 0;
  border: 0;
  background: none;
  color: var(--gn-color-text-medium, #a9b1d6);
  font: inherit;
  font-size: 0.8em;
  cursor: pointer;
}
.foot-btn:hover {
  color: var(--gn-color-accent, #7aa2f7);
}
/* На узких экранах панель не должна вылезать за вьюпорт */
@media (max-width: 420px) {
  .swatch {
    width: 14px;
    height: 14px;
  }
  .shade-row {
    gap: 2px;
  }
}
</style>