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)