primitives

Chart

Chart — базовый примитив RevueUI (Reka UI + shadcn-совместимый API).

Загрузка превью…

src/ui/chart/ChartContainer.vue

<!--
  Порт ChartContainer + ChartStyle из registry/bases/radix/ui/chart.tsx.

  Расхождение с оригиналом (задокументировано в docs/PORTING.md §19):
  оригинал оборачивает children в `RechartsPrimitive.ResponsiveContainer`
  (`initialDimension` проп существует только ради него). Прямого Vue-аналога
  recharts нет, а `vue-chrts` (замена по PLAN.md) — не набор примитивов
  вокруг произвольного чарта, а набор готовых компонентов (`BarChart`,
  `LineChart`, ...), каждый сам меряет свой контейнер через ResizeObserver
  (Unovis) — обёртка `ResponsiveContainer` ему не нужна и не имеет смысла.
  Поэтому здесь `ResponsiveContainer`/`initialDimension` не перенесены:
  `<ChartContainer>` — это только `data-slot`/`data-chart` div + `ChartStyle`
  + default-слот. Разметка/классы/`data-*` вокруг него — дословно.
-->
<script setup lang="ts">
import { computed, useId } from "vue"
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { provideChart } from "./context"
import { THEMES, type ChartConfig } from "./types"

const props = defineProps<{
  id?: string
  class?: HTMLAttributes["class"]
  config: ChartConfig
}>()

provideChart({
  config: props.config,
})

const uniqueId = useId()
const chartId = computed(
  () => `chart-${props.id ?? uniqueId.replace(/:/g, "")}`
)

const colorConfig = computed(() =>
  Object.entries(props.config).filter(([, cfg]) => cfg.theme ?? cfg.color)
)

const styleCss = computed(() => {
  if (!colorConfig.value.length) {
    return ""
  }

  return Object.entries(THEMES)
    .map(
      ([theme, prefix]) => `
${prefix} [data-chart=${chartId.value}] {
${colorConfig.value
  .map(([key, itemConfig]) => {
    const color =
      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
      itemConfig.color
    return color ? `  --color-${key}: ${color};` : null
  })
  .filter(Boolean)
  .join("\n")}
}
`
    )
    .join("\n")
})
</script>

<template>
  <div
    data-slot="chart"
    :data-chart="chartId"
    :class="
      cn(
        `cn-chart flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden`,
        props.class
      )
    "
  >
    <!--
      Литеральный <style> внутри <template> запрещён компилятором Vue
      ("Tags with side effect are ignored in client component templates").
      Динамический :is="'style'" обходит эту проверку компиляции; на
      визуальный диф не влияет в любом случае — <style> ничего не рисует.
    -->
    <component :is="'style'" v-if="styleCss">{{ styleCss }}</component>
    <slot />
  </div>
</template>

src/ui/chart/ChartLegendContent.vue

<!--
  Порт ChartLegendContent из registry/bases/radix/ui/chart.tsx.

  Как и у ChartTooltipContent, форматирующая логика перенесена дословно.
  Оригинальный `ChartLegend` (голый реэкспорт `RechartsPrimitive.Legend`)
  не портирован — recharts-аналога во Vue нет; `vue-chrts` рисует легенду
  сам из проп `categories`, без composable content-слота (см.
  docs/PORTING.md §6/§19). Потребитель адаптера собирает `payload` вручную
  из `categories` (см. `apps/proof/src/cases/ui-chart/vue.vue`).
-->
<script setup lang="ts">
import { computed, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useChart } from "./context"
import { getPayloadConfigFromPayload } from "./getPayloadConfigFromPayload"
import type { ChartLegendPayloadItem } from "./types"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    hideIcon?: boolean
    payload?: ChartLegendPayloadItem[]
    verticalAlign?: "top" | "bottom"
    nameKey?: string
  }>(),
  {
    hideIcon: false,
    verticalAlign: "bottom",
  }
)

const { config } = useChart()

const filteredPayload = computed(
  () => props.payload?.filter((item) => item.type !== "none") ?? []
)
</script>

<template>
  <div
    v-if="payload?.length"
    :class="
      cn(
        'flex items-center justify-center gap-4',
        verticalAlign === 'top' ? 'pb-3' : 'pt-3',
        props.class
      )
    "
  >
    <template v-for="(item, index) in filteredPayload" :key="index">
      <div
        :class="
          cn('[&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground flex items-center gap-1.5')
        "
      >
        <component
          :is="getPayloadConfigFromPayload(config, item, `${nameKey ?? item.dataKey ?? 'value'}`)?.icon"
          v-if="!hideIcon && getPayloadConfigFromPayload(config, item, `${nameKey ?? item.dataKey ?? 'value'}`)?.icon"
        />
        <div
          v-else
          class="h-2 w-2 shrink-0 rounded-[2px]"
          :style="{ backgroundColor: item.color }"
        />
        {{ getPayloadConfigFromPayload(config, item, `${nameKey ?? item.dataKey ?? "value"}`)?.label }}
      </div>
    </template>
  </div>
</template>

src/ui/chart/ChartTooltipContent.vue

<!--
  Порт ChartTooltipContent из registry/bases/radix/ui/chart.tsx.

  Форматирующая логика (label/индикатор/значение) перенесена дословно —
  она не завязана на recharts, только на форму `payload` (массив пунктов
  тултипа). Оригинальный `ChartTooltip` (голый реэкспорт
  `RechartsPrimitive.Tooltip`) не портирован — во Vue нет composable-тултипа,
  который можно воткнуть внутрь произвольного чарта, как у recharts;
  `vue-chrts` (см. docs/PORTING.md §6/§19) отдаёт свой тултип через слот
  `#tooltip="{ values }"` на самом компоненте чарта. Этот компонент
  принимает уже готовый `payload` в форме recharts — потребитель адаптера
  (см. `apps/proof/src/cases/ui-chart/vue.vue`) собирает такой `payload` из
  `values`/`categories` вручную и передаёт сюда как проп.
-->
<script setup lang="ts">
import { computed, h, type HTMLAttributes, type VNodeChild } from "vue"
import { cn } from "@/lib/utils"
import { useChart } from "./context"
import { getPayloadConfigFromPayload } from "./getPayloadConfigFromPayload"
import type { ChartTooltipPayloadItem } from "./types"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    active?: boolean
    payload?: ChartTooltipPayloadItem[]
    indicator?: "line" | "dot" | "dashed"
    hideLabel?: boolean
    hideIndicator?: boolean
    label?: VNodeChild
    labelFormatter?: (
      label: VNodeChild,
      payload: ChartTooltipPayloadItem[]
    ) => VNodeChild
    labelClassName?: HTMLAttributes["class"]
    formatter?: (
      value: ChartTooltipPayloadItem["value"],
      name: ChartTooltipPayloadItem["name"],
      item: ChartTooltipPayloadItem,
      index: number,
      payload: ChartTooltipPayloadItem["payload"]
    ) => VNodeChild
    color?: string
    nameKey?: string
    labelKey?: string
  }>(),
  {
    indicator: "dot",
    hideLabel: false,
    hideIndicator: false,
  }
)

const { config } = useChart()

const tooltipLabel = computed<VNodeChild>(() => {
  if (props.hideLabel || !props.payload?.length) {
    return null
  }

  const [item] = props.payload
  const key = `${props.labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
  const itemConfig = getPayloadConfigFromPayload(config, item, key)
  const value =
    !props.labelKey && typeof props.label === "string"
      ? (config[props.label]?.label ?? props.label)
      : itemConfig?.label

  if (props.labelFormatter) {
    return h(
      "div",
      { class: cn("font-medium", props.labelClassName) },
      [props.labelFormatter(value, props.payload)]
    )
  }

  if (!value) {
    return null
  }

  return h("div", { class: cn("font-medium", props.labelClassName) }, [
    value as VNodeChild,
  ])
})

const nestLabel = computed(
  () => props.payload?.length === 1 && props.indicator !== "dot"
)

const filteredPayload = computed(
  () => props.payload?.filter((item) => item.type !== "none") ?? []
)
</script>

<template>
  <div
    v-if="active && payload?.length"
    :class="cn('cn-chart-tooltip grid min-w-32 items-start', props.class)"
  >
    <component :is="() => (!nestLabel ? tooltipLabel : null)" />
    <div class="grid gap-1.5">
      <template v-for="(item, index) in filteredPayload" :key="index">
        <div
          :class="
            cn(
              '[&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2',
              indicator === 'dot' && 'items-center'
            )
          "
        >
          <template
            v-if="formatter && item?.value !== undefined && item.name"
          >
            <component
              :is="
                () => formatter!(item.value, item.name, item, index, item.payload)
              "
            />
          </template>
          <template v-else>
            <component
              :is="getPayloadConfigFromPayload(config, item, `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`)?.icon"
              v-if="getPayloadConfigFromPayload(config, item, `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`)?.icon"
            />
            <div
              v-else-if="!hideIndicator"
              :class="
                cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
                  'h-2.5 w-2.5': indicator === 'dot',
                  'w-1': indicator === 'line',
                  'w-0 border-[1.5px] border-dashed bg-transparent':
                    indicator === 'dashed',
                  'my-0.5': nestLabel && indicator === 'dashed',
                })
              "
              :style="{
                '--color-bg': color ?? (item.payload?.fill as string | undefined) ?? item.color,
                '--color-border': color ?? (item.payload?.fill as string | undefined) ?? item.color,
              }"
            />
            <div
              :class="
                cn(
                  'flex flex-1 justify-between leading-none',
                  nestLabel ? 'items-end' : 'items-center'
                )
              "
            >
              <div class="grid gap-1.5">
                <component :is="() => (nestLabel ? tooltipLabel : null)" />
                <span class="text-muted-foreground">{{
                  getPayloadConfigFromPayload(config, item, `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`)?.label ?? item.name
                }}</span>
              </div>
              <span
                v-if="item.value != null"
                class="font-mono font-medium text-foreground tabular-nums"
                >{{
                  typeof item.value === "number"
                    ? item.value.toLocaleString()
                    : String(item.value)
                }}</span
              >
            </div>
          </template>
        </div>
      </template>
    </div>
  </div>
</template>

src/ui/chart/context.ts

// Порт ChartContext/useChart из registry/bases/radix/ui/chart.tsx.
import { inject, provide, type InjectionKey } from "vue"
import type { ChartConfig } from "./types"

export type ChartContextValue = {
  config: ChartConfig
}

const ChartContextKey: InjectionKey<ChartContextValue> = Symbol("ChartContext")

export function provideChart(value: ChartContextValue) {
  provide(ChartContextKey, value)
}

export function useChart(): ChartContextValue {
  const context = inject(ChartContextKey)

  if (!context) {
    throw new Error("useChart must be used within a <ChartContainer />")
  }

  return context
}

src/ui/chart/getPayloadConfigFromPayload.ts

// Порт getPayloadConfigFromPayload из registry/bases/radix/ui/chart.tsx —
// перенесено дословно (логика библиотеко-независима, TS-типизация не менялась
// по существу, только под собственные типы payload вместо recharts).
import type { ChartConfig } from "./types"

export function getPayloadConfigFromPayload(
  config: ChartConfig,
  payload: unknown,
  key: string
) {
  if (typeof payload !== "object" || payload === null) {
    return undefined
  }

  const payloadPayload =
    "payload" in payload &&
    typeof (payload as Record<string, unknown>).payload === "object" &&
    (payload as Record<string, unknown>).payload !== null
      ? ((payload as Record<string, unknown>).payload as Record<
          string,
          unknown
        >)
      : undefined

  let configLabelKey: string = key

  if (
    key in payload &&
    typeof (payload as Record<string, unknown>)[key] === "string"
  ) {
    configLabelKey = (payload as Record<string, unknown>)[key] as string
  } else if (
    payloadPayload &&
    key in payloadPayload &&
    typeof payloadPayload[key] === "string"
  ) {
    configLabelKey = payloadPayload[key] as string
  }

  return configLabelKey in config ? config[configLabelKey] : config[key]
}

src/ui/chart/index.ts

// Порт registry/bases/radix/ui/chart.tsx.
//
// ГЛАВНОЕ РАСХОЖДЕНИЕ С ОРИГИНАЛОМ (см. docs/PORTING.md §19 для полного
// разбора): оригинал экспортирует не сами графики, а обвязку поверх
// recharts — `ChartContainer` (контейнер + CSS-переменные цвета),
// `ChartTooltip`/`ChartLegend` (голые реэкспорты `RechartsPrimitive.Tooltip`/
// `.Legend`) и `ChartTooltipContent`/`ChartLegendContent` (кастомный
// рендер содержимого тултипа/легенды). Прямого Vue-порта recharts не
// существует (PLAN.md, карта замен стека): здесь портирована только
// библиотеко-независимая часть — `ChartContainer`, `ChartStyle`-логика
// (встроена в `ChartContainer`), `ChartTooltipContent`, `ChartLegendContent`,
// `ChartConfig`, `getPayloadConfigFromPayload`. `ChartTooltip`/`ChartLegend`
// НЕ экспортируются — во Vue нет composable-примитива, который можно было
// бы воткнуть таким же образом в произвольный чарт; конкретная чарт-
// библиотека (в проекте — `vue-chrts`, см. PLAN.md) отдаёт тултип/легенду
// через собственные слоты/пропы, и адаптация — на стороне потребителя
// (пример — `apps/proof/src/cases/ui-chart/vue.vue`).
export { default as ChartContainer } from "./ChartContainer.vue"
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue"
export { default as ChartLegendContent } from "./ChartLegendContent.vue"
export { useChart } from "./context"
export { getPayloadConfigFromPayload } from "./getPayloadConfigFromPayload"
export type {
  ChartConfig,
  ChartTooltipPayloadItem,
  ChartLegendPayloadItem,
} from "./types"

src/ui/chart/types.ts

// Порт registry/bases/radix/ui/chart.tsx — типы.
//
// Оригинал жёстко завязан на recharts (`RechartsPrimitive.Tooltip`/
// `.Legend`/`.ResponsiveContainer`, `TooltipValueType`, `DefaultTooltipContentProps`,
// `DefaultLegendContentProps`). Прямого Vue-порта recharts не существует
// (см. docs/PORTING.md §6/§19), поэтому здесь типы payload-элементов
// объявлены самостоятельно — по форме идентичны тому, что recharts кладёт
// в `payload` у `Tooltip`/`Legend`, чтобы `ChartTooltipContent`/
// `ChartLegendContent` можно было переиспользовать с любой чарт-библиотекой,
// которая способна отдать данные в такой форме (в т.ч. vue-chrts — см.
// пример адаптера в кейсе `ui-chart`).
import type { Component, VNodeChild } from "vue"

export const THEMES = { light: "", dark: ".dark" } as const

export type ChartConfig = Record<
  string,
  {
    label?: VNodeChild
    icon?: Component
  } & (
    | { color?: string; theme?: never }
    | { color?: never; theme: Record<keyof typeof THEMES, string> }
  )
>

export type ChartTooltipPayloadItem = {
  dataKey?: string | number
  name?: string | number
  value?: number | string
  color?: string
  type?: "none" | (string & {})
  payload?: Record<string, unknown>
}

export type ChartLegendPayloadItem = {
  dataKey?: string | number
  value?: string | number
  color?: string
  type?: "none" | (string & {})
}

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/chart.json

Зависимости реестра

Источник: порт из ReUI (Keenthemes, MIT)