GrColorPicker

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда цвет задаёт пользователь.

Когда брать

  • цвет задаёт пользователь — тема бренда, метка проекта, цвет категории;
  • нужен точный код — hex-поле рядом с каналами: цвет чаще приносят из макета, а не подбирают;
  • есть фирменный наборpresets показывает палитру, из которой выбирают в девяти случаях из десяти;
  • нужна прозрачностьalpha добавляет канал и меняет формат значения.

Когда взять другое

НужноБерите
Выбор из нескольких фиксированных цветовGrRadioGroup / GrSegmented
Цвет — тон компонента из палитры темыпроп tone нужного компонента
Значение — произвольная строкаGrInput

Модель — hex-строка

modelValue#RRGGBB, а при alpha#RRGGBBAA. Это та форма, в которой цвет лежит в токенах темы, приезжает с бэкенда и понимается CSS: потребителю не приходится конвертировать ни на входе, ни на выходе.

Невалидное значение не роняет компонент и ничего не эмитит: панель показывает #000000, модель остаётся как есть. Молча переписывать чужие данные компонент не вправе.

Внутри цвет живёт в HSLA и отдельно от модели, потому что hex — проекция с потерями: у чёрного, белого и любого серого нет оттенка. Держи компонент состояние только в hex — бегунок оттенка прыгал бы на 0° каждый раз, когда пользователь уводит насыщенность в ноль.

Почему слайдеры, а не квадрат

Привычная 2D-область saturation/value — отдельный виджет со своими жестами и клавиатурой по двум осям, и доступность в нём приходится собирать с нуля. Каналы здесь — обычные GrSlider, то есть настоящий role="slider" с полной клавиатурой, aria-valuetext и aria-label из локали. Цена — на один жест больше; выигрыш — работающая клавиатура и диктор.

Панель немодальная

Tab из панели уводит фокус дальше по странице, а не запирает в ней: за страницей ничего не блокируется, и запирать пользователя не за что. Esc закрывает панель и возвращает фокус на триггер — это делает общий стек слоёв через GrPopover.

Открытием можно управлять снаружи: v-model:open, положение — placement.

Пресеты

presets — массив hex-строк. Невалидные отсеиваются, у выбранного пресета aria-pressed. Пусто — блока пресетов нет вовсе.

Токены темы в presets не подставляются: --gr-primary живёт CSS-переменной и на сборке в hex не разрешается. Палитру собирает приложение — из своего конфига или из getComputedStyle.

Форма

Контракт форм-контрола целиком: disabled, readonly, invalid, required, ariaLabel, эмиты update:modelValue/change/focus/blur, экспортируемые focus()/blur(). Имя берётся от GrFormField (<label for> указывает на триггер) либо из ariaLabel.

name отдаёт текущее значение в нативную форму скрытым полем — внутри виджета интерактивных элементов быть не должно.

clearable нет намеренно: у цвета не бывает пустого состояния. Нужна «не задано» — это undefined в модели на уровне приложения, а не состояние контрола.

Границы

  • пипетки нетEyeDropper есть не во всех браузерах и требует своего разрешения;
  • истории недавних цветов нет — это состояние приложения;
  • градиентов нет — компонент про один цвет.

Playground 11

Загружается…

Код
<GrColorPicker />

Установка

npm i @feugene/granularity

Импорт

import { GrColorPicker } from '@feugene/granularity/components/GrColorPicker'

API

Props

PropTypeпо умолчаниюОписание
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`).
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: цвет видно, панель открывается, но значение не меняется.
invalidboolean | undefinedfalse
requiredboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefinedИмя для нативной формы: значение уходит скрытым полем.
placement"bottom-start" | "bottom-end" | "top-start" | "top-end" | undefined"bottom-start"Сторона, с которой раскрывается панель.
alphaboolean | undefinedfalseЧетвёртый слайдер и восьмизначная форма hex.
presetsstring[] | undefined[]Палитра быстрого выбора. Пусто — блок не рендерится.
modelValueобязательныйstringЦвет в hex: `#RRGGBB`, а при `alpha` — `#RRGGBBAA`. Мусор не роняет компонент.

Events

EventTypeОписание
update:modelValue[value: string]
change[value: string]
update:open[value: boolean]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeОписание
focus() => void
blur() => void

Примеры 2

Цвета бренда и подложек

Триггер показывает образец и текущее значение, панель — оттенок, насыщенность и светлоту тремя GrSlider, поле hex и палитру. alpha добавляет четвёртый канал и восьмизначную форму #RRGGBBAA; под прозрачным цветом видна шахматка.

Brand color
Overlay color
Preview
#3b82f6
#0f172acc

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrColorPicker } from '@feugene/granularity'

const brand = ref('#3b82f6')
const overlay = ref('#0f172acc')

const presets = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#0ea5e9', '#64748b', '#0f172a']
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
    <div class="grid gap-3">
      <div class="grid gap-1.5">
        <span class="text-sm text-[var(--gr-muted-fg)]">Brand color</span>
        <GrColorPicker v-model="brand" :presets="presets" aria-label="Brand color" />
      </div>

      <div class="grid gap-1.5">
        <span class="text-sm text-[var(--gr-muted-fg)]">Overlay color</span>
        <GrColorPicker v-model="overlay" alpha :presets="presets" aria-label="Overlay color" />
      </div>
    </div>

    <div class="grid content-center gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
      <span class="text-sm text-[var(--gr-muted-fg)]">Preview</span>

      <div class="h-10 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]" :style="{ background: brand }" />
      <code class="text-xs text-[var(--gr-muted-fg)]">{{ brand }}</code>

      <div class="h-10 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]" :style="{ background: overlay }" />
      <code class="text-xs text-[var(--gr-muted-fg)]">{{ overlay }}</code>
    </div>

  </div>
</template>

Каналы сделаны слайдерами, а не двумерным квадратом, намеренно: каждый — настоящий role="slider" с полной клавиатурой и aria-valuetext («217°», «91 %»), тогда как квадрат пришлось бы озвучивать и водить с клавиатуры с нуля.

Внутри поля формы

Пикер — обычный форм-контрол: читает контекст GrFormField (подпись, подсказка, ошибка, disabled/readonly), участвует в правилах GrForm и отдаёт значение в нативную форму скрытым полем по пропу name.

Goes to the --gr-primary token

Обязательное поле

Form
<script setup lang="ts">
import { reactive, ref } from 'vue'

import { GrButton, GrColorPicker, GrForm, GrFormField, GrInput, type GrFormRules } from '@feugene/granularity'

const model = reactive({ name: '', accent: '#22c55e' })

const rules: GrFormRules = {
  name: [{ required: true }],
  accent: [{ required: true, pattern: /^#[0-9a-f]{6}$/i, message: 'Only a six-digit hex is allowed' }],
}

const saved = ref('')
</script>

<template>
  <GrForm :model="model" :rules="rules" class="grid max-w-sm gap-4" @submit="saved = model.accent">
    <GrFormField name="name" label="Theme name">
      <GrInput v-model="model.name" placeholder="Midnight" />
    </GrFormField>

    <GrFormField name="accent" label="Accent" hint="Goes to the --gr-primary token">
      <GrColorPicker v-model="model.accent" name="accent" />
    </GrFormField>

    <GrButton type="submit" class="w-fit">
      Save theme
    </GrButton>

    <p v-if="saved" class="text-sm text-[var(--gr-success-text)]">
      Saved: {{ saved }}
    </p>
  </GrForm>
</template>

Доступность

Паттерн APG
dialog + slider
Клавиши
Enter/Space на триггере — открыть панель, Esc — закрыть и вернуть фокус на триггер, Tab — уйти из панели дальше по странице; внутри каналов — клавиши GrSlider

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты