<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>