GrConfigProvider
Берут, когда размеры контролов едины на приложение.
Когда брать
- размеры контролов едины на приложение —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Дефолтный размер контролов для вложенных компонентов. |
componentDefaults | GrComponentDefaults | undefined | undefined | Дефолтные пропсы по компонентам: `{ GrButton: { variant: 'secondary' } }`. |
i18n | GranularityI18nAdapter | null | undefined | undefined | Адаптер переводов (fint-i18n-совместимый). Прокидывается вложенным компонентам. |
locale | string | undefined | undefined | Просьба к адаптеру переключить язык (`syncLocale`). Источником истины остаётся сам адаптер — провайдер лишь передаёт ему намерение. |
theme | string | undefined | undefined | Тема поддерева: значение уезжает в `data-theme`. Тема **документа** — работа `useTheme`/`initThemeEarly`, здесь именно остров. |
portalTarget | string | HTMLElement | undefined | undefined | Куда монтировать оверлеи поддерева. По умолчанию — общий `#gr-portal` в `body`. Своё значение нужно там, где приложение живёт в контейнере: микрофронтенд, shadow DOM, CSS-скоупинг под конкретным корнем. |
zIndexBase | number | undefined | undefined | База шкалы слоёв. Переменные `--gr-z-*` пересчитываются от неё и ставятся на `<html>`: панели телепортируются в `body`, и переменные поддерева до них не доходят. |
tag | string | undefined | "div" | Тег обёртки. По умолчанию прозрачный `<div style="display:contents">`. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Поддерево, которому адресованы настройки. |
Примеры 6
Размер по умолчанию для вложенных контролов
GrConfigProvider задаёт дефолтный size для всех вложенных контролов, которые его поддерживают (сейчас — GrButton и GrInput). У самих контролов проп size не указан — он приходит из провайдера. Локальный size на компоненте всегда побеждает.
Активный размер: md. Проп 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) наследуются.
size="lg"size="sm"<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 задаёт дефолтные пропсы по имени компонента: оформление всего поддерева описывается одним объектом, а у самих компонентов пропы не указаны. Локальный проп всегда побеждает конфиг. Набор настраиваемых пропов закрытый (GrButton — variant/tone/size/square, GrInput — size/clearable, GrBadge — tone/size/radius): через конфиг настраивается оформление, но не modelValue и не обработчики.
Локальный проп всегда сильнее конфига — у кнопки-переключателя выше явно задан variant="ghost", и она не меняется.
<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, вне дерева провайдера, но кнопки в нём приходят того же размера, что и контролы вокруг. Последний ответ: —
<!-- 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-значения.
<!-- 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, вне обёртки, и всё равно остаются тёмными: в дереве компонентов они внутри, поэтому тему берут из контекста и ставят себе сами.
<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>