GrConfigProvider

Package: @feugene/granularitycoreGroup: utilities

Provides global defaults (control size, per-component props, i18n) to nested components — imperative dialogs included.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the sizes of the controls are uniform across the applicationsize once instead of a prop on every field;
  • a component has its own defaults in this projectcomponentDefaults changes them without touching the places of use;
  • a translation is being connected — the t adapter and locale reach every component of the package;
  • a subtree lives in a theme of its own — a dark panel inside a light application without a global switch;
  • overlays have to lie above someone else’szIndexBase shifts the whole layer scale of the package.

When to take something else

NeedTake
The value is needed by a single componenta prop on the component itself
The palette changes, not the defaultspackages/granularity/docs/theming.md
Translations are needed, not their connectionpackages/granularity/docs/localization.md

It renders transparently (display: contents) and works through provide/inject, so it does not change the layout and may stand anywhere — including several times nested.

The resolution order

The local prop of the component → componentDefaults[Component] → the global size → the component’s own default. Hence the rule for the authors of components: a prop configurable through the provider is declared with an undefined default, otherwise Vue substitutes its own value before the component looks into the config.

The effective config can be read from the application with useGrConfig() — it is public.

Two size scales

The size of the provider is about controls (xs | sm | md | lg). Overlays have a scale of their own (sm | md | lg | xl | full), and the global size does not touch them: xs means nothing for a modal window. The size of a window is set pointwise:

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

That is how GrModal, GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette and GrDrawer are configured.

i18n

The adapter is handed down always and through a facade rather than by value: an adapter created asynchronously (the usual loading of a locale) will reach the children when it appears, and swapping the adapter on a change of language redraws the strings. If the prop is not set, the facade delegates to the adapter found higher up the tree — installation through app.use() keeps working.

locale is a request to the adapter to switch (syncLocale). The source of truth remains the adapter itself: the provider neither stores the locale nor substitutes it.

The theme of a subtree

theme puts the value into data-theme on the wrapper. The themes are declared with an attribute selector ([data-theme='dark']), so a “dark island” inside a light page works with no extra styles.

The panels of overlays are teleported into body, that is, in the DOM they live outside the wrapper — but in the component tree they stay inside, so inject reaches them and they set the theme for themselves. That covers the modal, the drawer, the dropdown, the popover, the tooltip, the selects, the toaster and the image viewer.

The theme of the document is the job of useTheme/initThemeEarly. The prop of the provider is precisely about an island; there are no two mechanisms for the same thing in the package.

The layer scale

zIndexBase recomputes --gr-z-* from the base (dropdown +0, tooltip +50, modal +100, toast +200) and sets them on <html>, restoring the previous values on unmount.

On :root rather than on the wrapper for exactly the reason the theme is set by the panels themselves: a panel moves into body and does not see the variables of the subtree. “Layers by subtree” would be a false promise, so the scale is one per document — a second provider with a different base warns about the conflict in a dev build.

The same result is achieved with four lines of CSS; the prop is needed where the base comes from the runtime (a micro-frontend inside someone else’s application).

The mounting point of the overlays

portalTarget names the container the overlays of the subtree move into: modals, select panels, toasts, imperative dialogs. By default that is the shared #gr-portal in body, which the package creates itself on the first opening.

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

It is needed where the application lives in a container of its own: a micro-frontend inside someone else’s page, a shadow DOM, CSS scoping under a particular root. The value is inherited by nested providers, and an individual component may override it with the teleportTo prop.

The provider only names the target — it does not create the DOM: the container has to exist by the time an overlay opens. There is a single requirement on it, but a strict one: no transform, filter, contain, perspective or will-change — they create a containing block for position: fixed, and floating panels will start computing their position from the container rather than from the viewport. The details — ../z-index.md.

Playground 5

Loading…

Code
<GrConfigProvider />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe default size of the controls for the nested components.
componentDefaultsGrComponentDefaults | undefinedundefinedDefault props per component: `{ GrButton: { variant: 'secondary' } }`.
i18nGranularityI18nAdapter | null | undefinedundefinedThe translation adapter (fint-i18n compatible). It is passed to the nested components.
localestring | undefinedundefinedA request to the adapter to switch the language (`syncLocale`). The source of truth remains the adapter itself — the provider only passes the intent to it.
themestring | undefinedundefinedThe theme of the subtree: the value travels into `data-theme`. The theme of the **document** is the job of `useTheme`/`initThemeEarly`; this is precisely an island.
portalTargetstring | HTMLElement | undefinedundefinedWhere to mount the overlays of the subtree. By default — the shared `#gr-portal` in `body`. A value of your own is needed where the application lives in a container: a micro-frontend, a shadow DOM, CSS scoping under a particular root.
zIndexBasenumber | undefinedundefinedThe base of the layer scale. The `--gr-z-*` variables are recomputed from it and set on `<html>`: panels are teleported into `body`, and the variables of a subtree do not reach them.
tagstring | undefined"div"The tag of the wrapper. By default a transparent `<div style="display:contents">`.

Slots

SlotTypeDescription
defaultanyThe subtree the settings are addressed to.

Examples 6

Default size for nested controls

Активный размер: 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>

Nested providers merge

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>

Default props per component

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>

Imperative dialogs inherit the config

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

Диалог монтируется в 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>

Read the config in your own component

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>

Theme island (including teleported panels)

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

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>

Component documentationAll components