GrConfigProvider

Пакет: @feugene/granularityядроГруппа: Утилиты

Берут, когда размеры контролов едины на приложение.

Когда брать

  • размеры контролов едины на приложениеsize один раз вместо пропа на каждом поле;
  • у компонента свои дефолты в этом проектеcomponentDefaults меняет их, не трогая места употребления;
  • подключается перевод — адаптер t и locale доходят до всех компонентов пакета;
  • поддерево живёт в своей теме — тёмная панель внутри светлого приложения без глобального переключения;
  • оверлеи должны лечь выше чужихzIndexBase сдвигает всю шкалу слоёв пакета.

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

НужноБерите
Значение нужно одному компонентупроп на самом компоненте
Меняется палитра, а не дефолтыpackages/granularity/docs/theming.md
Нужны переводы, а не их подключениеpackages/granularity/docs/localization.md

Рендерится прозрачно (display: contents) и работает через provide/inject, поэтому раскладку не меняет и может стоять где угодно — в том числе несколько раз вложенно.

Порядок разрешения

Локальный проп компонента → componentDefaults[Component] → глобальный size → собственный дефолт компонента. Отсюда правило для авторов компонентов: проп, настраиваемый через провайдер, объявляется с дефолтом undefined, иначе Vue подставит своё значение раньше, чем компонент заглянет в конфиг.

Прочитать эффективный конфиг из приложения можно useGrConfig() — он публичный.

Две шкалы размеров

size у провайдера — про контролы (xs | sm | md | lg). У оверлеев шкала своя (sm | md | lg | xl | full), и глобальный size их не трогает: xs для модального окна не значит ничего. Размер окна задаётся точечно:

<GrConfigProvider :component-defaults="{ GrModal: { size: 'lg' } }">

Так настраиваются GrModal, GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette и GrDrawer.

i18n

Адаптер отдаётся вниз всегда и через фасад, а не значением: адаптер, созданный асинхронно (обычная загрузка локали), доедет до детей, когда появится, а подмена адаптера при смене языка перерисует строки. Если проп не задан, фасад делегирует адаптеру, найденному выше по дереву, — установка через app.use() продолжает работать.

locale — просьба к адаптеру переключиться (syncLocale). Источником истины остаётся сам адаптер: провайдер не хранит локаль и не подменяет её.

Тема поддерева

theme кладёт значение в data-theme на обёртку. Темы объявлены атрибутным селектором ([data-theme='dark']), поэтому «тёмный остров» внутри светлой страницы работает без дополнительных стилей.

Панели оверлеев телепортируются в body, то есть в DOM живут вне обёртки, — но в дереве компонентов остаются внутри, поэтому inject до них доходит, и тему они ставят себе сами. Так покрыты модалка, дровер, дропдаун, поповер, тултип, селекты, тостер и просмотрщик изображений.

Тема документа — работа useTheme/initThemeEarly. Проп провайдера именно про остров; двух механизмов на одно и то же в пакете нет.

Шкала слоёв

zIndexBase пересчитывает --gr-z-* от базы (dropdown +0, tooltip +50, modal +100, toast +200) и ставит их на <html>, возвращая прежние значения при размонтировании.

На :root, а не на обёртку, ровно потому же, почему тема ставится панелями самостоятельно: панель уезжает в body и переменных поддерева не видит. «Слои поддеревом» были бы ложным обещанием, поэтому шкала одна на документ — второй провайдер с другой базой в dev-сборке предупреждает о конфликте.

Тот же результат достигается четырьмя строками CSS; проп нужен там, где база приходит из рантайма (микрофронтенд внутри чужого приложения).

Точка монтирования оверлеев

portalTarget называет контейнер, куда уезжают оверлеи поддерева: модалки, панели селектов, тосты, императивные диалоги. По умолчанию это общий #gr-portal в body, который пакет создаёт сам при первом открытии.

<GrConfigProvider portal-target="#my-app-portal">
  <App />
</GrConfigProvider>

Нужно там, где приложение живёт в своём контейнере: микрофронтенд внутри чужой страницы, shadow DOM, CSS-скоупинг под конкретным корнем. Значение наследуется вложенными провайдерами, а отдельный компонент может переопределить его пропом teleportTo.

Провайдер только называет цель — DOM он не создаёт: контейнер должен существовать к моменту открытия оверлея. Требование к нему одно, зато жёсткое: никаких transform, filter, contain, perspective и will-change — они создают containing block для position: fixed, и floating-панели начнут считать позицию от контейнера, а не от вьюпорта. Подробности — ../z-index.md.

Playground 5

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

Код
<GrConfigProvider />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
size"xs" | "sm" | "md" | "lg" | undefinedundefinedДефолтный размер контролов для вложенных компонентов.
componentDefaultsGrComponentDefaults | undefinedundefinedДефолтные пропсы по компонентам: `{ GrButton: { variant: 'secondary' } }`.
i18nGranularityI18nAdapter | null | undefinedundefinedАдаптер переводов (fint-i18n-совместимый). Прокидывается вложенным компонентам.
localestring | undefinedundefinedПросьба к адаптеру переключить язык (`syncLocale`). Источником истины остаётся сам адаптер — провайдер лишь передаёт ему намерение.
themestring | undefinedundefinedТема поддерева: значение уезжает в `data-theme`. Тема **документа** — работа `useTheme`/`initThemeEarly`, здесь именно остров.
portalTargetstring | HTMLElement | undefinedundefinedКуда монтировать оверлеи поддерева. По умолчанию — общий `#gr-portal` в `body`. Своё значение нужно там, где приложение живёт в контейнере: микрофронтенд, shadow DOM, CSS-скоупинг под конкретным корнем.
zIndexBasenumber | undefinedundefinedБаза шкалы слоёв. Переменные `--gr-z-*` пересчитываются от неё и ставятся на `<html>`: панели телепортируются в `body`, и переменные поддерева до них не доходят.
tagstring | undefined"div"Тег обёртки. По умолчанию прозрачный `<div style="display:contents">`.

Slots

SlotTypeОписание
defaultanyПоддерево, которому адресованы настройки.

Примеры 6

Размер по умолчанию для вложенных контролов

GrConfigProvider задаёт дефолтный size для всех вложенных контролов, которые его поддерживают (сейчас — GrButton и GrInput). У самих контролов проп size не указан — он приходит из провайдера. Локальный size на компоненте всегда побеждает.

Активный размер: md. Проп size на контролах не задан.

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

import { GrButton, GrConfigProvider, GrInput, type GrComponentSize } from '@feugene/granularity'

const size = ref<GrComponentSize>('md')
const value = ref('Config-driven size')

const sizes: GrComponentSize[] = ['xs', 'sm', 'md', 'lg']
</script>

<template>
  <div class="grid gap-4">
    <!-- Переключатель размера — сами кнопки вне провайдера (фиксированный sm). -->
    <div class="flex gap-2">
      <GrButton
        v-for="s in sizes"
        :key="s"
        size="sm"
        :variant="size === s ? 'primary' : 'outline'"
        @click="size = s"
      >
        {{ s }}
      </GrButton>
    </div>

    <!-- Ни у одного контрола ниже нет пропа `size` — он приходит из провайдера. -->
    <GrConfigProvider :size="size">
      <div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
        <GrInput v-model="value" class="max-w-[16rem]" aria-label="Config-driven input" />
        <GrButton>Save</GrButton>
        <GrButton variant="outline">Cancel</GrButton>
      </div>
    </GrConfigProvider>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Активный размер: <code>{{ size }}</code>. Проп <code>size</code> на контролах не задан.
    </p>
  </div>
</template>

Провайдер рендерится прозрачно (display: contents) и не влияет на layout. Поддержку конфига компонент включает через useGrComponentSize() / useGrConfig().

Вложенные провайдеры складываются

Провайдеры можно вкладывать: дочерний мержится поверх родительского. Здесь внешний задаёт size="lg", а внутренний переопределяет его на sm только для своего поддерева — остальные значения (componentDefaults, i18n) наследуются.

Outer provider — size="lg"
Inner provider — size="sm"

Nested
<script setup lang="ts">
import { GrButton, GrConfigProvider, GrInput } from '@feugene/granularity'
</script>

<template>
  <!-- Внешний провайдер: size = lg. -->
  <GrConfigProvider size="lg">
    <div class="grid gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Outer provider — <code>size="lg"</code>
      </div>
      <div class="flex flex-wrap items-center gap-3">
        <GrInput model-value="Large" class="max-w-[14rem]" aria-label="Large input" />
        <GrButton>Large</GrButton>
      </div>

      <!-- Вложенный провайдер переопределяет только size; остальное наследуется. -->
      <GrConfigProvider size="sm">
        <div class="grid gap-3 rounded-lg border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-3">
          <div class="text-sm font-semibold text-[var(--gr-fg)]">
            Inner provider — <code>size="sm"</code>
          </div>
          <div class="flex flex-wrap items-center gap-3">
            <GrInput model-value="Small" class="max-w-[14rem]" aria-label="Small input" />
            <GrButton>Small</GrButton>
          </div>
        </div>
      </GrConfigProvider>
    </div>
  </GrConfigProvider>
</template>

Свои значения пропов по компонентам

componentDefaults задаёт дефолтные пропсы по имени компонента: оформление всего поддерева описывается одним объектом, а у самих компонентов пропы не указаны. Локальный проп всегда побеждает конфиг. Набор настраиваемых пропов закрытый (GrButtonvariant/tone/size/square, GrInputsize/clearable, GrBadgetone/size/radius): через конфиг настраивается оформление, но не modelValue и не обработчики.

Pro

Локальный проп всегда сильнее конфига — у кнопки-переключателя выше явно задан variant="ghost", и она не меняется.

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

import {
  GrBadge,
  GrButton,
  GrConfigProvider,
  GrInput,
  type GrComponentDefaults,
} from '@feugene/granularity'

const value = ref('Igor Petrov')

// Оформление всего поддерева задаётся одним объектом: у самих компонентов
// ни `variant`, ни `tone`, ни `clearable` не указаны.
const brandDefaults: GrComponentDefaults = {
  GrButton: { variant: 'outline', tone: 'azure' },
  GrInput: { clearable: true },
  GrBadge: { tone: 'azure', radius: 'semi' },
}

const enabled = ref(true)
</script>

<template>
  <div class="grid gap-4">
    <GrButton size="sm" variant="ghost" @click="enabled = !enabled">
      {{ enabled ? 'Turn defaults off' : 'Turn defaults on' }}
    </GrButton>

    <GrConfigProvider :component-defaults="enabled ? brandDefaults : undefined">
      <div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
        <GrInput v-model="value" class="max-w-[16rem]" aria-label="Full name" />
        <GrButton>Invite</GrButton>
        <GrButton>Copy link</GrButton>
        <GrBadge>Pro</GrBadge>
      </div>
    </GrConfigProvider>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Локальный проп всегда сильнее конфига — у кнопки-переключателя выше явно задан
      <code>variant="ghost"</code>, и она не меняется.
    </p>
  </div>
</template>

Чтобы компонент умел читать конфиг, его настраиваемый проп обязан иметь дефолт undefined, а «настоящий» дефолт — жить в резолвере useGrComponentProp. Иначе Vue подставит дефолт раньше, чем компонент заглянет в конфиг, и отличить «пользователь передал значение» от «сработал дефолт» будет невозможно.

Императивные диалоги наследуют конфиг

useDialogService монтирует хост в body, вне дерева компонентов, — обычный inject туда не дотягивается. Пакет закрывает это сам: сервис захватывает конфиг в момент вызова useDialogService(), и диалог получает те же дефолты, что и контролы вокруг. От приложения ничего не требуется.

Размер в провайдере:
кнопка снаружи — для сравнения размеров

Диалог монтируется в body, вне дерева провайдера, но кнопки в нём приходят того же размера, что и контролы вокруг. Последний ответ:

Dialog
<!-- DialogCaller.vue -->
<script setup lang="ts">
import { GrButton, useDialogService } from '@feugene/granularity'

/**
 * Отдельный компонент здесь по существу, а не для красоты: `useDialogService()`
 * захватывает конфиг в `setup`, поэтому вызывать его нужно там, где компонент
 * уже находится внутри `GrConfigProvider`.
 */
const emit = defineEmits<{ (e: 'answer', value: string): void }>()

const dialogs = useDialogService()

async function ask(): Promise<void> {
  const confirmed = await dialogs.confirm('Удалить черновик? Действие необратимо.')
  emit('answer', confirmed ? 'подтвердил' : 'отменил')
}
</script>

<template>
  <div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
    <GrButton @click="ask">
      Открыть диалог
    </GrButton>
    <span class="text-sm text-[var(--gr-muted-fg)]">
      кнопка снаружи — для сравнения размеров
    </span>
  </div>
</template>

<!-- GrConfigProviderDialogDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrConfigProvider } from '@feugene/granularity'

import DialogCaller from './DialogCaller.vue'

const size = ref<'sm' | 'lg'>('sm')
const lastAnswer = ref<string | null>(null)
</script>

<template>
  <div class="grid gap-4">
    <div class="flex items-center gap-3">
      <span class="text-sm font-medium">Размер в провайдере:</span>
      <GrButton
        v-for="s in (['sm', 'lg'] as const)"
        :key="s"
        size="sm"
        :variant="size === s ? 'primary' : 'outline'"
        @click="size = s"
      >
        {{ s }}
      </GrButton>
    </div>

    <!-- Вызывающий компонент внутри провайдера — значит и диалог унаследует конфиг. -->
    <GrConfigProvider :size="size">
      <DialogCaller @answer="lastAnswer = $event" />
    </GrConfigProvider>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Диалог монтируется в <code>body</code>, вне дерева провайдера, но кнопки в нём
      приходят того же размера, что и контролы вокруг. Последний ответ:
      <code>{{ lastAnswer ?? '—' }}</code>
    </p>
  </div>
</template>

Захват происходит в useDialogService(), а не при вызове confirm(). Поэтому сервис нужно получать в setup компонента, находящегося внутри провайдера: сервис-синглтон из модуля или стора дерева не видит и откатится на дефолты компонентов. Приоритет внутри диалога: опции вызова → useDialogService(defaults) → провайдер → дефолты компонентов.

Чтение конфига в своём компоненте

Любой компонент читает конфиг ближайшего провайдера через useGrConfig() — так подключаются собственные контролы. Провайдер отдаёт size и componentDefaults (per-component дефолтные пропсы); вне провайдера всё разрешается в fallback-значения.

Inside a provider
sizelg
GrButton default variantsecondary
No provider (fallbacks)
size
GrButton default variant

Read
<!-- ConfigReader.vue -->
<script setup lang="ts">
import { GrBadge, useGrConfig } from '@feugene/granularity'

// Любой компонент может прочитать конфиг ближайшего GrConfigProvider.
const config = useGrConfig()
</script>

<template>
  <div class="grid gap-2 rounded-lg border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-3 text-sm">
    <div class="flex items-center gap-2">
      <span class="text-[var(--gr-muted-fg)]">size</span>
      <GrBadge tone="info">{{ config.size.value ?? '—' }}</GrBadge>
    </div>
    <div class="flex items-center gap-2">
      <span class="text-[var(--gr-muted-fg)]">GrButton default variant</span>
      <GrBadge tone="success">{{ config.componentDefaults.value.GrButton?.variant ?? '—' }}</GrBadge>
    </div>
  </div>
</template>

<!-- GrConfigProviderReadDemo.vue -->
<script setup lang="ts">
import { GrConfigProvider } from '@feugene/granularity'

import ConfigReader from './ConfigReader.vue'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-2">
    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Inside a provider
      </div>
      <GrConfigProvider
        size="lg"
        :component-defaults="{ GrButton: { variant: 'secondary' } }"
      >
        <ConfigReader />
      </GrConfigProvider>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        No provider (fallbacks)
      </div>
      <ConfigReader />
    </div>
  </div>
</template>

Провайдер также принимает проп i18n — адаптер переводов прокидывается вложенным компонентам через общий inject-ключ (иначе приложение инжектит его вручную).

Остров темы, включая телепортированные панели

Проп theme кладёт data-theme на обёртку провайдера — тёмный остров внутри светлой страницы работает без дополнительных стилей, потому что темы объявлены атрибутным селектором. Панели селекта и дропдауна телепортируются в body, вне обёртки, и всё равно остаются тёмными: в дереве компонентов они внутри, поэтому тему берут из контекста и ставят себе сами.

Тёмный остров внутри страницы

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

import { GrButton, GrConfigProvider, GrDropdown, GrInput, GrSelect } from '@feugene/granularity'

const value = ref('a')
const options = [
  { value: 'a', label: 'Первый' },
  { value: 'b', label: 'Второй' },
]
</script>

<template>
  <!-- Тема поддерева: `data-theme` на обёртке провайдера. Тема документа
       остаётся за `useTheme` — это именно остров. -->
  <GrConfigProvider theme="dark" size="sm">
    <div class="grid gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-[var(--gr-card-fg)]">
      <div class="text-sm font-semibold">
        Тёмный остров внутри страницы
      </div>

      <div class="flex flex-wrap items-center gap-3">
        <GrInput model-value="Поле" class="max-w-[12rem]" aria-label="Поле острова" />

        <!-- Панели телепортируются в body, вне обёртки провайдера, и всё равно
             остаются тёмными: тему они ставят себе сами из контекста. -->
        <GrSelect v-model="value" :options="options" class="max-w-[12rem]" aria-label="Выбор" />

        <GrDropdown width="12rem">
          <template #trigger="{ triggerProps }">
            <GrButton variant="outline" v-bind="triggerProps">
              Меню
            </GrButton>
          </template>

          <template #content>
            <div class="grid gap-1">
              <button
                v-for="item in ['Открыть', 'Дублировать', 'Удалить']"
                :key="item"
                type="button"
                role="menuitem"
                class="rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--gr-accent)]"
              >
                {{ item }}
              </button>
            </div>
          </template>
        </GrDropdown>
      </div>
    </div>
  </GrConfigProvider>
</template>

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