reui
Frame
Frame — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/frame/Frame.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { frameVariants, type FrameVariants } from "./variants"
const props = defineProps<{
/** Доп. классы. Имя `class` — соглашение shadcn-vue (не `className`). */
class?: HTMLAttributes["class"]
variant?: FrameVariants["variant"]
spacing?: FrameVariants["spacing"]
stacked?: FrameVariants["stacked"]
dense?: FrameVariants["dense"]
}>()
</script>
<template>
<div
:class="
cn(
frameVariants({
variant: props.variant,
spacing: props.spacing,
stacked: props.stacked,
dense: props.dense,
}),
props.class
)
"
data-slot="frame"
:data-spacing="props.spacing"
>
<slot />
</div>
</template>
src/reui/frame/FrameDescription.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
:class="cn('text-muted-foreground text-sm', props.class)"
data-slot="frame-panel-description"
>
<slot />
</div>
</template>
src/reui/frame/FrameFooter.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<footer
:class="
cn(
'flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)',
props.class
)
"
data-slot="frame-panel-footer"
>
<slot />
</footer>
</template>
src/reui/frame/FrameHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<header
:class="
cn(
'flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)',
props.class
)
"
data-slot="frame-panel-header"
>
<slot />
</header>
</template>
src/reui/frame/FramePanel.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
fit?: boolean
}>()
</script>
<template>
<div
:class="
cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via class overrides these by Tailwind source order - no ! needed.
'relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs',
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!props.fit && 'grow',
'before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5',
'dark:bg-clip-border dark:before:shadow-white/5',
'px-(--frame-panel-px) py-(--frame-panel-py)',
props.class
)
"
data-slot="frame-panel"
>
<slot />
</div>
</template>
src/reui/frame/FrameTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
:class="cn('text-sm font-semibold', props.class)"
data-slot="frame-panel-title"
>
<slot />
</div>
</template>
src/reui/frame/index.ts
export { default as Frame } from "./Frame.vue"
export { default as FramePanel } from "./FramePanel.vue"
export { default as FrameHeader } from "./FrameHeader.vue"
export { default as FrameTitle } from "./FrameTitle.vue"
export { default as FrameDescription } from "./FrameDescription.vue"
export { default as FrameFooter } from "./FrameFooter.vue"
export { frameVariants, type FrameVariants } from "./variants"
src/reui/frame/variants.ts
import { cva, type VariantProps } from "class-variance-authority"
/**
* Перенесено дословно из ReUI (registry-reui/bases/radix/reui/frame.tsx, MIT).
*
* cva-блоки — чистые JS-объекты и переносятся между React и Vue без единой
* правки. Это и есть главный рычаг всего порта: вся визуальная семантика
* живёт здесь, а фреймворк-специфичной остаётся только разметка.
*/
export const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric inner radius: the panel corner nests smoothly inside the frame
// corner instead of matching it. The panel sits inset from the frame's outer
// edge by the frame's 1px border + --frame-px padding, so its radius is
// reduced by that same gap (radius − gap keeps the two arcs parallel). This
// base value assumes the bordered default/inverse frame; `ghost` drops the
// 1px border term and `dense` pins it back to the frame radius (its panels
// are pulled flush to the edge).
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
// No frame border, so the panel is inset by --frame-px padding only.
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
},
// Header/footer vertical rhythm is tighter than the panel body's, and
// the gap widens as the frame grows: the bars read as chrome rather than
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
// style-*.css overrides them - so this single ladder drives all shadcn
// styles. `px` is deliberately left level with the body so header,
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
// the practical floor, since anything lower stops reading as padding.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
// their corners align with the frame radius rather than nesting inside it.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
export type FrameVariants = VariantProps<typeof frameVariants>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/frame.jsonЗависимости реестра
npm-зависимости
- class-variance-authority
Источник: порт из ReUI (Keenthemes, MIT)