前端代码

This commit is contained in:
2026-08-13 22:35:13 +08:00
parent 9044bafdff
commit f1a0b17bb0
412 changed files with 36769 additions and 6617 deletions
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '../utils'
import { buttonVariants, BUTTON_VARIANTS, BUTTON_SIZES } from './index'
defineOptions({ name: 'UiButton' })
const props = defineProps({
variant: { type: String, default: 'default', validator: (v) => BUTTON_VARIANTS.includes(v) },
size: { type: String, default: 'default', validator: (v) => BUTTON_SIZES.includes(v) },
// 透传给 reka-ui Primitive:默认渲染 <button>,asChild 时把行为合并到插槽根元素
as: { type: [String, Object], default: 'button' },
asChild: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
// 异步提交(保存等)时置 true:禁用重复点击并在文案前转圈
loading: { type: Boolean, default: false },
class: { type: null, default: '' }
})
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:disabled="isDisabled || undefined"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<span
v-if="loading"
class="h-3.5 w-3.5 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<slot />
</Primitive>
</template>
@@ -0,0 +1,63 @@
import { cva } from 'class-variance-authority'
import { FOCUS_RING } from '../utils'
export { default as Button } from './Button.vue'
/**
* 变体 / 尺寸的可选值集中导出:作为单一事实源,既驱动下面 cva 的语义,
* 也供 Button.vue 的 prop `validator` 复用做白名单校验(JS 栈下等价于 TS 的字面量联合约束)。
* 新增 / 改名 variant 或 size 时,这里与下方 cva 的 variants 同步增改。
*/
export const BUTTON_VARIANTS = [
'default',
'destructive',
'outline',
'outline-primary',
'outline-success',
'secondary',
'ghost'
]
export const BUTTON_SIZES = ['default', 'sm', 'lg', 'icon']
/**
* 按钮样式变体。
*
* 所有颜色都走项目既有的主题 token(--primary-color / --error-color / --container-bg-color /
* --border-color / --muted-color ...),因此天然跟随 themeStore 的换肤与暗色模式,
* 调用方无需再内联 `:style="{ backgroundColor: settings.themeColor }"`。
*
* 注意:刻意不使用 `bg-primary/10` 这类对 CSS 变量 token 取透明度的写法,
* 改用语义 token(如 hover:bg-muted)保证在亮/暗两套配色下都成立。
*/
export const buttonVariants = cva(
// 项目未启用「button 背景重置」式 preflight,原生 <button> 会带浏览器默认底色(#efefef)。
// 基类先用 bg-transparent 兜底,各 variant 的显式底色会经 twMerge 覆盖它;
// 唯独 ghost 没有底色,正好保持透明(hover 再上 muted)。
`appearance-none inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md bg-transparent text-sm font-medium transition-all duration-150 ${FOCUS_RING} disabled:pointer-events-none disabled:opacity-50`,
{
variants: {
variant: {
default: 'bg-primary text-white shadow-sm hover:bg-primary-600',
destructive: 'bg-error text-white shadow-sm hover:bg-error-600',
outline: 'border border-border bg-container text-base-text hover:bg-muted',
// 带色描边的次级按钮:常态描边 + 文字着色,hover 填充实色、文字转白;
// 调用方用 variant 表达颜色,不再手写 border-/text- 覆盖。
'outline-primary': 'border border-primary bg-container text-primary hover:bg-primary hover:text-white',
'outline-success': 'border border-success bg-container text-success hover:bg-success hover:text-white',
// hover 走语义 token 换色(与其余 variant 同机制),不再用 opacity
secondary: 'bg-muted text-base-text hover:bg-control-track',
ghost: 'text-base-text hover:bg-muted'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-6 text-base',
icon: 'h-8 w-8'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
@@ -0,0 +1,37 @@
<script setup>
import { CheckboxRoot, CheckboxIndicator } from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
// 复选框:reka-ui CheckboxRoot / CheckboxIndicator 底座,颜色只用语义 token。
// 关态描边 + container 底,选中态 primary 实底 + 白色对勾(图标走全局 svg-icon)。
defineOptions({ name: 'UiCheckbox' })
const props = defineProps({
disabled: { type: Boolean, default: false },
// 纯图形控件的可访问名:无可见文案时由调用方按语义传入
ariaLabel: { type: String, default: undefined },
class: { type: null, default: '' }
})
// reka CheckboxRoot 用 `checked` 双向绑定;对外仍走 defineModel 的 modelValue
const modelValue = defineModel({ type: Boolean, default: false })
</script>
<template>
<CheckboxRoot
v-model:checked="modelValue"
:disabled="disabled"
:aria-label="ariaLabel"
:class="
cn(
'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border bg-container text-white transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary disabled:cursor-not-allowed disabled:opacity-50',
FOCUS_RING,
props.class
)
"
>
<CheckboxIndicator class="flex items-center justify-center">
<svg-icon icon="lucide:check" class="h-3 w-3" aria-hidden="true" />
</CheckboxIndicator>
</CheckboxRoot>
</template>
@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'
@@ -0,0 +1,285 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
PopoverClose,
ColorAreaRoot,
ColorAreaArea,
ColorAreaThumb,
ColorSliderRoot,
ColorSliderTrack,
ColorSliderThumb,
ColorFieldRoot,
ColorFieldInput,
ColorSwatchPickerRoot,
ColorSwatchPickerItem,
parseColor,
colorToString
} from 'reka-ui'
import { themeSettings } from '@/theme/settings'
import { cn, FOCUS_RING, FOCUS_RING_WITHIN } from '../utils'
import { COLOR_FORMATS } from './index'
defineOptions({ name: 'UiColorPicker' })
const props = defineProps({
// 是否提供透明度通道(顶栏 / 标签背景需要 rgba)
alpha: { type: Boolean, default: false },
// 'hex' | 'rgb':输出格式。语义 / 主题色用 hex;带 alpha 的背景用 rgb。
format: { type: String, default: 'hex', validator: (v) => COLOR_FORMATS.includes(v) },
// 预设色卡(十六进制数组)
swatches: { type: Array, default: () => [] },
// 空值时触发器上的占位文案(如顶栏背景"留空跟随主题")
placeholder: { type: String, default: '默认' },
// 触发器是否展示颜色文本:语义色那种一排小色块用 false(只显示色块)
showValue: { type: Boolean, default: true },
// 原生 title(hover 提示);无障碍场景下同时作为可访问名兜底
title: { type: String, default: undefined },
// 纯图形触发器的可访问名(屏幕阅读器),优先级高于 title / placeholder
ariaLabel: { type: String, default: undefined },
// 是否提供「清空」按钮:清空后 modelValue 置空、由调用方语义回落(如顶栏背景留空跟随主题)。
// 语义色 / 主题色这类不允许为空的场景保持 false。
clearable: { type: Boolean, default: false },
class: { type: null, default: '' }
})
// 双向绑定统一走 defineModel,取代手写 modelValue prop + emit
const modelValue = defineModel({ type: String, default: '' })
// 交互面板恒用 hsb:「饱和度·明度」面板与 hue 色相条只在 HSL/HSB 空间才有对应通道,
// RGB 空间的合法通道是 red/green/blue/alpha——此前 rgb 模式误把 colorSpace 切成 'rgb',
// 通道取值 undefined,面板/色相条完全失效(顶栏/标签栏背景选不了色)。输出格式由 toOut 负责转换。
const colorSpace = 'hsb'
// 输出格式:reka 的 colorToString 只认 hex / rgb / hsb / hsl('rgba'/'hexa' 会抛
// Unknown format,此前导致 alpha 模式写回失败、拖完颜色不生效)。
// 'rgb' 在 alpha < 1 时会自动输出 rgba(...) 字符串,无需单独的 'rgba' 格式。
const outFormat = computed(() => (props.format === 'rgb' ? 'rgb' : 'hex'))
// 空值兜底色取自默认主题色单一真源(settings.themeColor),不再手抄字面量 #2264f2 / rgba(93,135,255,1)。
// alpha 模式补足透明度通道(满不透明);派生失败则回落到 hex 本身(仍可被 parseColor 解析)。
const fallback = computed(() => {
const base = themeSettings.themeColor
if (!props.alpha) return base
try {
return colorToString(parseColor(base), 'rgb')
} catch {
return base
}
})
const safeParse = (value) => {
try {
return parseColor(value)
} catch {
return parseColor(fallback.value)
}
}
// 关键:所有颜色部件共享同一个 reka Color「对象」(而非各自解析字符串),
// 这样 area 的饱和度/明度、hue 条、alpha 条、数值框、色卡始终基于同一份颜色,互不丢失分量。
const color = ref(safeParse(modelValue.value || fallback.value))
const toOut = (c) => {
try {
return colorToString(c, outFormat.value)
} catch {
return modelValue.value
}
}
// 拖动色相/面板时 @update:color 会高频触发,父级(全局主题应用)开销大导致卡顿。
// 本地 color 立即更新保证滑块跟手;对外写回 modelValue 防抖,停手 100ms 后才应用主题。
let emitTimer = null
let pendingColor = null
const cancelEmit = () => {
if (emitTimer) {
clearTimeout(emitTimer)
emitTimer = null
}
}
// flush:立刻把挂起的最终颜色写回(卸载 / 收尾时调用),
// 避免「拖完颜色 <100ms 内就关抽屉 / 切 tab / 重置」导致最终值被丢弃、所见≠所存。
const flushEmit = () => {
if (!emitTimer) return
cancelEmit()
if (pendingColor != null) {
modelValue.value = toOut(pendingColor)
pendingColor = null
}
}
const onColorUpdate = (next) => {
color.value = next
pendingColor = next
cancelEmit()
emitTimer = setTimeout(() => {
emitTimer = null
pendingColor = null
modelValue.value = toOut(next)
}, 100)
}
onBeforeUnmount(flushEmit)
// 预设色卡:ColorSwatchPicker 的 v-model 是十六进制字符串
const hexValue = computed(() => {
try {
return colorToString(color.value, 'hex')
} catch {
return ''
}
})
const onSwatchPick = (hex) => {
if (!hex) return
onColorUpdate(safeParse(hex))
}
// 清空:取消挂起的防抖写回,置空 modelValue(调用方据空值回落语义默认),面板色复位到兜底色
const onClear = () => {
cancelEmit()
pendingColor = null
modelValue.value = ''
color.value = safeParse(fallback.value)
}
// 外部 modelValue 变化(如点主题色预设圆点)时同步进来;与当前一致则跳过,避免回环。
watch(modelValue, (value) => {
if (!value) return
if (value !== toOut(color.value)) color.value = safeParse(value)
})
const hasValue = computed(() => Boolean(modelValue.value))
const previewStyle = computed(() => ({ backgroundColor: modelValue.value || 'transparent' }))
// 透明区域用的棋盘格底纹:格子色走 border token(亮 / 暗自适应),不再硬编码裸 hex。
const CHECKER =
'linear-gradient(45deg,rgb(var(--border-color)) 25%,transparent 25%),linear-gradient(-45deg,rgb(var(--border-color)) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgb(var(--border-color)) 75%),linear-gradient(-45deg,transparent 75%,rgb(var(--border-color)) 75%)'
const checkerStyle = {
backgroundImage: CHECKER,
backgroundSize: '8px 8px',
backgroundPosition: '0 0,0 4px,4px -4px,-4px 0'
}
</script>
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<button
type="button"
:aria-label="ariaLabel || title || placeholder"
:title="title"
:class="cn(
'inline-flex h-8 items-center gap-2 rounded-md border border-border bg-container text-[13px] text-base-text transition-colors hover:border-primary',
FOCUS_RING,
showValue ? 'px-2' : 'w-8 justify-center px-0',
props.class
)"
>
<span
class="relative shrink-0 overflow-hidden rounded border border-border"
:class="showValue ? 'h-4 w-4' : 'h-5 w-5'"
:style="checkerStyle"
>
<span class="absolute inset-0" :style="previewStyle" />
</span>
<template v-if="showValue">
<span v-if="hasValue" class="font-mono text-xs text-muted-foreground">{{ modelValue }}</span>
<span v-else class="text-xs text-muted-foreground">{{ placeholder }}</span>
</template>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
:side-offset="6"
align="end"
class="z-[3000] w-60 rounded-lg border border-border bg-container p-3 text-base-text shadow-lg"
>
<!-- 饱和度 / 明度面板 -->
<ColorAreaRoot
v-slot="{ style }"
:model-value="color"
:color-space="colorSpace"
x-channel="saturation"
y-channel="brightness"
class="relative h-32 w-full overflow-hidden rounded-md"
@update:color="onColorUpdate"
>
<ColorAreaArea :style="style" class="h-full w-full">
<ColorAreaThumb class="h-3.5 w-3.5 rounded-full border-2 border-white shadow ring-1 ring-black/25" />
</ColorAreaArea>
</ColorAreaRoot>
<!-- 色相条 -->
<ColorSliderRoot
v-slot="{ style }"
:model-value="color"
channel="hue"
:color-space="colorSpace"
class="relative mt-3 flex h-4 w-full items-center"
@update:color="onColorUpdate"
>
<ColorSliderTrack :style="style" class="h-2 w-full rounded-full" />
<ColorSliderThumb class="block h-4 w-4 rounded-full border-2 border-white shadow ring-1 ring-black/25" />
</ColorSliderRoot>
<!-- 透明度条 -->
<ColorSliderRoot
v-if="alpha"
v-slot="{ style }"
:model-value="color"
channel="alpha"
:color-space="colorSpace"
class="relative mt-3 flex h-4 w-full items-center"
@update:color="onColorUpdate"
>
<div class="relative h-2 w-full overflow-hidden rounded-full" :style="checkerStyle">
<ColorSliderTrack :style="style" class="absolute inset-0 rounded-full" />
</div>
<ColorSliderThumb class="block h-4 w-4 rounded-full border-2 border-white shadow ring-1 ring-black/25" />
</ColorSliderRoot>
<!-- 数值输入 -->
<ColorFieldRoot
:model-value="color"
:class="cn('mt-3 flex h-8 items-center rounded-md border border-border bg-container px-2 transition-colors focus-within:border-primary', FOCUS_RING_WITHIN)"
@update:color="onColorUpdate"
>
<ColorFieldInput class="w-full bg-transparent text-center font-mono text-xs text-base-text outline-none" />
</ColorFieldRoot>
<!-- 预设色卡 -->
<ColorSwatchPickerRoot
v-if="swatches.length"
:model-value="hexValue"
class="mt-3 flex flex-wrap gap-1.5"
@update:model-value="onSwatchPick"
>
<ColorSwatchPickerItem
v-for="s in swatches"
:key="s"
:value="s"
:class="cn('h-5 w-5 cursor-pointer overflow-hidden rounded border border-border transition-transform hover:scale-110 data-[state=checked]:ring-2 data-[state=checked]:ring-primary data-[state=checked]:ring-offset-1 data-[state=checked]:ring-offset-container', FOCUS_RING)"
>
<!-- Swatch 自动取色(Alpha 组件渲染不稳定),直接用 hex 画背景就完事了 -->
<span class="block h-full w-full" :style="{ backgroundColor: s }" />
</ColorSwatchPickerItem>
</ColorSwatchPickerRoot>
<!-- 清空(恢复语义默认,如“跟随主题”):opt-in,语义色/主题色等不允许为空的场景不展示 -->
<PopoverClose v-if="clearable" as-child>
<button
type="button"
:class="cn(
'mt-3 w-full rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary hover:text-primary',
FOCUS_RING
)"
@click="onClear"
>
清空({{ placeholder }})
</button>
</PopoverClose>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
@@ -0,0 +1,4 @@
export { default as ColorPicker } from './ColorPicker.vue'
// 有限枚举 format 的可选值集中导出,作为单一事实源,供 prop validator 复用(对齐 Button 的 BUTTON_VARIANTS)
export const COLOR_FORMATS = ['hex', 'rgb']
@@ -0,0 +1,99 @@
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
import DropdownMenuContent from './DropdownMenuContent.vue'
import DropdownMenuItem from './DropdownMenuItem.vue'
import { DROPDOWN_MENU_TRIGGERS } from './index'
defineOptions({ name: 'UiDropdownMenu' })
const props = defineProps({
// 便捷模式菜单项:{ label, value?, danger?, disabled?, ...自定义字段 },
// 选中时整个 item 原样从 @select 抛出;需要完全自定义面板时用 #content 插槽替代
items: { type: Array, default: () => [] },
trigger: {
type: String,
default: 'click',
validator: (v) => DROPDOWN_MENU_TRIGGERS.includes(v)
},
side: { type: String, default: 'bottom' },
align: { type: String, default: 'center' },
sideOffset: { type: Number, default: 6 },
// 指向触发器的小箭头,不需要时显式关闭
arrow: { type: Boolean, default: true },
// 透传给浮层面板的附加 class
contentClass: { type: null, default: '' }
})
const emit = defineEmits(['select'])
// 展开态受控:click 模式完全交给 reka(trigger 点击 / Esc / 点击外部),
// hover 模式在此基础上叠加「悬停展开、移出延迟收起」;两者共用同一个 open。
const open = ref(false)
// 移出后的收起延迟:给指针从触发器滑向面板留出走位时间,进入面板即取消
let closeTimer = null
const cancelClose = () => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
const onHoverEnter = () => {
if (props.trigger !== 'hover') return
cancelClose()
open.value = true
}
const onHoverLeave = () => {
if (props.trigger !== 'hover') return
cancelClose()
closeTimer = setTimeout(() => {
open.value = false
}, 120)
}
onBeforeUnmount(cancelClose)
// hover 模式关闭时不把焦点还给触发器:reka 的 closeAutoFocus 会程序化聚焦 trigger,
// Chrome 会从面板传播 :focus-visible,留下一圈并非键盘操作引起的 focus ring
const onCloseAutoFocus = (e) => {
if (props.trigger === 'hover') e.preventDefault()
}
</script>
<template>
<!-- hover 模式必须非 modal:modal 会给 body 设 pointer-events:none,
触发器立刻收到 pointerleave → 关闭 → 事件恢复 → pointerenter → 重开,造成闪烁死循环 -->
<DropdownMenuRoot v-model:open="open" :modal="trigger !== 'hover'">
<DropdownMenuTrigger
as-child
@pointerenter="onHoverEnter"
@pointerleave="onHoverLeave"
>
<!-- 触发器:任意元素 / 组件,reka 通过 asChild 把展开行为合并到插槽根元素 -->
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent
:side="side"
:align="align"
:side-offset="sideOffset"
:arrow="arrow"
:class="contentClass"
@pointerenter="onHoverEnter"
@pointerleave="onHoverLeave"
@close-auto-focus="onCloseAutoFocus"
>
<slot name="content">
<DropdownMenuItem
v-for="(item, index) in items"
:key="item.value ?? index"
:disabled="item.disabled"
:danger="item.danger"
@select="emit('select', item)"
>
{{ item.label }}
</DropdownMenuItem>
</slot>
</DropdownMenuContent>
</DropdownMenuRoot>
</template>
@@ -0,0 +1,51 @@
<script setup>
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuPortal
} from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiDropdownMenuContent', inheritAttrs: false })
// 面板:bg-container 浮层底 + shadow-sider,z-[3000] 保证盖过 EP 浮层;
// 进出场按官方文档用 --reka-dropdown-menu-content-transform-origin 做缩放淡入 / 淡出,
// 从触发器方向弹出。inheritAttrs 关闭后用 v-bind="$attrs" 把外部事件
// (如 hover 触发的 pointerenter/leave)落到真正的浮层元素上,而不是不渲染节点的 Portal。
const props = defineProps({
side: { type: String, default: 'bottom' },
align: { type: String, default: 'center' },
sideOffset: { type: Number, default: 6 },
// 指向触发器的小箭头(官方 anatomy 的 DropdownMenuArrow),不需要时显式关闭
arrow: { type: Boolean, default: true },
class: { type: null, default: '' }
})
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="$attrs"
:side="side"
:align="align"
:side-offset="sideOffset"
:class="cn(
'z-[3000] min-w-40 rounded-[10px] border border-border bg-container p-1.5 text-base-text shadow-sider',
'origin-[var(--reka-dropdown-menu-content-transform-origin)] data-[state=open]:animate-[popper-in_0.2s_cubic-bezier(0.16,1,0.3,1)] data-[state=closed]:animate-[popper-out_0.15s_ease-in]',
props.class
)"
>
<slot />
<!-- 箭头与面板一体化,两步处理(本地坐标经 reka 各朝向的旋转后方向恒定):
1. -translate-y-px 向面板内嵌 1px,盖住面板 border 从箭头底部穿过的缝隙;
2. drop-shadow 沿三角形轮廓给两条斜边描 1px border 色(底边嵌进面板不受影响),
否则 fill 与面板同色时箭头在同色背景上不可见 -->
<DropdownMenuArrow
v-if="arrow"
:width="14"
:height="7"
class="-translate-y-px fill-container [filter:drop-shadow(0_1px_0_rgb(var(--border-color)))]"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { DropdownMenuItem } from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiDropdownMenuItem', inheritAttrs: false })
// 高亮态按官方 demo 走主题色实底 + 白字(data-[highlighted] 同时覆盖鼠标悬停与键盘导航);
// reka 的 @select 事件经 $attrs 透传给调用方
const props = defineProps({
disabled: { type: Boolean, default: false },
// 危险语义项(如删除 / 登出),红色文本、高亮时红色实底
danger: { type: Boolean, default: false },
class: { type: null, default: '' }
})
</script>
<template>
<DropdownMenuItem
v-bind="$attrs"
:disabled="disabled"
:class="cn(
'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-[14px] text-base-text outline-none transition-colors data-[highlighted]:bg-primary data-[highlighted]:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
danger && 'text-error data-[highlighted]:bg-error data-[highlighted]:text-white',
props.class
)"
>
<slot />
</DropdownMenuItem>
</template>
@@ -0,0 +1,19 @@
<script setup>
import { DropdownMenuLabel } from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiDropdownMenuLabel' })
// 分组标签(非交互项),弱化文本区分于可选中的菜单项
const props = defineProps({
class: { type: null, default: '' }
})
</script>
<template>
<DropdownMenuLabel
:class="cn('block cursor-default px-2.5 py-2 text-[13px] text-muted-foreground outline-none', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { DropdownMenuSeparator } from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiDropdownMenuSeparator' })
const props = defineProps({
class: { type: null, default: '' }
})
</script>
<template>
<DropdownMenuSeparator
:class="cn('mx-2 my-1 h-px bg-border opacity-50', props.class)"
/>
</template>
@@ -0,0 +1,13 @@
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
/**
* 触发方式的可选值集中导出:作为单一事实源,供 DropdownMenu.vue 的 prop `validator`
* 复用做白名单校验,也便于调用方按需引用。
* - click:点击触发器展开 / 收起(reka 默认交互)
* - hover:悬停触发器展开、移出(触发器与面板都离开)后延迟收起,对齐顶栏工具区的悬停习惯
*/
export const DROPDOWN_MENU_TRIGGERS = ['click', 'hover']
+14
View File
@@ -0,0 +1,14 @@
// 基础组件库:以无样式的 reka-ui 原语为底座,仅用项目既有的 UnoCSS 主题 token 上色,
// 跟随 themeStore 换肤 / 暗色模式。规范见 aiDoc/frontend-backend/component-library.md。
export * from './button'
export * from './input'
export * from './checkbox'
export * from './dropdown-menu'
export * from './select'
export * from './switch'
export * from './slider'
export * from './number-field'
export * from './color-picker'
export * from './page-tab'
export * from './menu'
export { cn } from './utils'
@@ -0,0 +1,124 @@
<script setup>
import { computed, ref } from 'vue'
import { cn, FOCUS_RING_WITHIN } from '../utils'
import { INPUT_SIZES, INPUT_VARIANTS } from './index'
// 文本 / 密码输入框:无样式原生 <input> 外裹一层带主题描边与聚焦态的容器,
// 颜色只用语义 token(container / border / primary / error / muted-foreground),
// 天然跟随 themeStore 换肤与暗色模式。
// - variant=default:盒式(描边 + container 底 + 圆角 + 聚焦环)
// - variant=underline:下划线式(仅底边,聚焦时底边转 primary,用于极简排版)
// password 类型可选「显示 / 隐藏」切换。
defineOptions({ name: 'UiInput', inheritAttrs: false })
const props = defineProps({
// 原生 input type:text / password / email / tel ...
type: { type: String, default: 'text' },
size: {
type: String,
default: 'default',
validator: (v) => INPUT_SIZES.includes(v)
},
variant: {
type: String,
default: 'default',
validator: (v) => INPUT_VARIANTS.includes(v)
},
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
// 前 / 后置图标:Iconify 名(优先 lucide 集,如 'lucide:user')
prefixIcon: { type: String, default: '' },
suffixIcon: { type: String, default: '' },
// 校验失败态:描边与聚焦态转 error 色
invalid: { type: Boolean, default: false },
// type=password 时是否显示「显示 / 隐藏密码」切换按钮
showPassword: { type: Boolean, default: false },
ariaLabel: { type: String, default: undefined },
class: { type: null, default: '' }
})
// 双向绑定统一走 defineModel,取代手写 modelValue prop + defineEmits + @update 三段样板
const modelValue = defineModel({ type: String, default: '' })
const revealed = ref(false)
const innerType = computed(() =>
props.type === 'password' && props.showPassword && revealed.value
? 'text'
: props.type
)
const showToggle = computed(
() => props.type === 'password' && props.showPassword
)
const containerClass = computed(() => {
const base =
'group flex items-center gap-2 text-base-text transition-colors'
const sizeCls = props.size === 'lg' ? 'h-11' : 'h-9'
const disabledCls = props.disabled && 'cursor-not-allowed opacity-50'
if (props.variant === 'underline') {
return cn(
base,
sizeCls,
'rounded-none border-0 border-b bg-transparent px-0',
props.invalid
? 'border-error focus-within:border-error'
: 'border-border focus-within:border-primary',
disabledCls,
props.class
)
}
return cn(
base,
sizeCls,
'rounded-md border bg-container px-3.5',
props.invalid
? 'border-error focus-within:border-error focus-within:ring-2 focus-within:ring-error-300'
: cn('border-border focus-within:border-primary', FOCUS_RING_WITHIN),
disabledCls,
props.class
)
})
</script>
<template>
<div :class="containerClass">
<svg-icon
v-if="prefixIcon"
:icon="prefixIcon"
class="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<input
v-bind="$attrs"
v-model="modelValue"
:type="innerType"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
:aria-invalid="invalid || undefined"
class="h-full w-full min-w-0 flex-1 bg-transparent text-sm text-base-text outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
<button
v-if="showToggle"
type="button"
tabindex="-1"
:aria-label="revealed ? '隐藏密码' : '显示密码'"
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-primary"
@click="revealed = !revealed"
>
<svg-icon
:icon="revealed ? 'lucide:eye' : 'lucide:eye-off'"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<svg-icon
v-else-if="suffixIcon"
:icon="suffixIcon"
class="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
</template>
@@ -0,0 +1,9 @@
export { default as Input } from './Input.vue'
/**
* 尺寸 / 变体可选值集中导出:作为单一事实源,供 Input.vue 的 prop `validator` 复用做白名单校验。
* - size:default(h-9) / lg(h-11)
* - variant:default(盒式) / underline(下划线式)
*/
export const INPUT_SIZES = ['default', 'lg']
export const INPUT_VARIANTS = ['default', 'underline']
@@ -0,0 +1,213 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
NavigationMenuRoot,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuTrigger,
NavigationMenuContent,
NavigationMenuLink,
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent
} from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
import { MENU_GAP, visibleItems } from './shared'
import NavMenuPanel from './NavMenuPanel.vue'
import HorizontalMoreNode from './HorizontalMoreNode.vue'
defineOptions({ name: 'KraHorizontalMenu' })
const props = defineProps({
items: { type: Array, default: () => [] },
active: { type: String, default: '' },
class: { type: null, default: '' }
})
const emit = defineEmits(['select'])
const topItems = computed(() => visibleItems(props.items))
const hasKids = (i) => visibleItems(i.children).length > 0
// 顶级项高亮:激活键等于自身或落在其子树内(父项随激活子路由高亮)
const containsKey = (node, key) => {
const children = node.children || []
return node.name === key || children.some((c) => containsKey(c, key))
}
const isActive = (item) => !!props.active && containsKey(item, props.active)
const triggerClass = (activeState) =>
cn(
'group inline-flex h-9 shrink-0 appearance-none items-center gap-1.5 rounded-md bg-transparent px-3 text-[14px] text-base-text transition-colors hover:bg-muted data-[state=open]:bg-muted',
activeState && 'bg-[rgb(var(--primary-color)/0.12)] text-active font-medium',
FOCUS_RING
)
const onSelect = (key) => emit('select', key)
/* ---------- 溢出计算:可见区放不下的顶级项收进「更多」 ---------- */
const rootRef = ref(null)
const measureRef = ref(null)
const itemWidths = ref([])
const visibleCount = ref(topItems.value.length)
const MORE_BTN = 36 // 「更多」按钮固定尺寸(w-9)
// 溢出预留:更多按钮宽度 + 其与菜单列表的间距(ml,取 MENU_GAP)
const moreReserve = MORE_BTN + MENU_GAP
const measure = () => {
if (!measureRef.value) return
itemWidths.value = [...measureRef.value.children].map(
(el) => el.getBoundingClientRect().width
)
compute()
}
const compute = () => {
const root = rootRef.value
if (!root || !itemWidths.value.length) return
const avail = root.clientWidth
const total = itemWidths.value.reduce((a, w, i) => a + w + (i ? MENU_GAP : 0), 0)
if (total <= avail) {
visibleCount.value = topItems.value.length
return
}
let sum = 0
let count = 0
for (let i = 0; i < itemWidths.value.length; i++) {
const next = sum + (i ? MENU_GAP : 0) + itemWidths.value[i]
if (next + moreReserve > avail) break
sum = next
count++
}
visibleCount.value = Math.max(count, 0)
}
const shownItems = computed(() => topItems.value.slice(0, visibleCount.value))
const overflowItems = computed(() => topItems.value.slice(visibleCount.value))
const moreActive = computed(() => overflowItems.value.some((i) => isActive(i)))
let ro = null
onMounted(() => {
nextTick(measure)
// web 字体加载完成后项宽会变化,补测一次
document.fonts?.ready?.then(() => measure())
ro = new ResizeObserver(() => compute())
if (rootRef.value) ro.observe(rootRef.value)
})
onBeforeUnmount(() => ro && ro.disconnect())
watch(topItems, () => nextTick(measure))
</script>
<template>
<div ref="rootRef" :class="cn('relative w-full min-w-0', props.class)">
<NavigationMenuRoot class="relative flex items-center">
<NavigationMenuList class="flex items-center" :style="{ gap: MENU_GAP + 'px' }">
<NavigationMenuItem
v-for="item in shownItems"
:key="item.name"
:value="item.name"
class="relative"
>
<template v-if="hasKids(item)">
<NavigationMenuTrigger :class="triggerClass(isActive(item))">
<component :is="item.meta.icon" v-if="item.meta.icon" class="h-4 w-4" />
{{ item.meta.title }}
<svg-icon
icon="lucide:chevron-down"
class="h-3.5 w-3.5 transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</NavigationMenuTrigger>
<NavigationMenuContent
class="kra-nav-content absolute left-0 top-full z-[3000] mt-1.5 rounded-lg border border-border bg-container p-1.5 shadow-sider"
>
<NavMenuPanel
:items="visibleItems(item.children)"
:active="active"
@select="onSelect"
/>
</NavigationMenuContent>
</template>
<NavigationMenuLink
v-else
:active="isActive(item)"
:class="triggerClass(isActive(item))"
@select.prevent="onSelect(item.name)"
>
<component :is="item.meta.icon" v-if="item.meta.icon" class="h-4 w-4" />
{{ item.meta.title }}
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
<!-- 更多:溢出的顶级项级联展开(DropdownMenuSub) -->
<DropdownMenuRoot v-if="overflowItems.length">
<DropdownMenuTrigger
aria-label="更多"
:class="cn(triggerClass(moreActive), 'ml-2 w-9 justify-center px-0')"
>
<svg-icon icon="lucide:ellipsis" class="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="6"
align="end"
class="z-[3000] min-w-40 rounded-lg border border-border bg-container p-1.5 shadow-sider"
>
<HorizontalMoreNode
v-for="item in overflowItems"
:key="item.name"
:node="item"
@select="onSelect"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</NavigationMenuRoot>
<!-- 隐藏测量行:按自然宽度渲染全部顶级项,供溢出计算用 -->
<div
class="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden="true"
>
<div ref="measureRef" class="flex items-center" :style="{ gap: MENU_GAP + 'px' }">
<span
v-for="item in topItems"
:key="item.name"
class="inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[14px]"
>
<component :is="item.meta.icon" v-if="item.meta.icon" class="h-4 w-4" />
{{ item.meta.title }}
<svg-icon v-if="hasKids(item)" icon="lucide:chevron-down" class="h-3.5 w-3.5" />
</span>
</div>
</div>
</div>
</template>
<style>
/* 下拉展开 / 关闭过渡:下滑 + 淡入淡出 */
.kra-nav-content[data-state='open'] {
animation: kra-nav-in 220ms ease;
}
.kra-nav-content[data-state='closed'] {
animation: kra-nav-out 160ms ease forwards;
}
@keyframes kra-nav-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes kra-nav-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-6px);
}
}
</style>
@@ -0,0 +1,52 @@
<script setup>
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent
} from 'reka-ui'
import { visibleItems } from './shared'
defineOptions({ name: 'KraHorizontalMoreNode' })
const props = defineProps({
node: { type: Object, required: true }
})
const emit = defineEmits(['select'])
const hasKids = () => visibleItems(props.node.children).length > 0
const itemClass =
'flex w-full cursor-pointer items-center gap-2 whitespace-nowrap rounded-md px-3 py-2.5 text-[14px] text-base-text outline-none transition-colors data-[highlighted]:bg-muted data-[state=open]:bg-muted'
</script>
<template>
<DropdownMenuSub v-if="hasKids()">
<DropdownMenuSubTrigger :class="itemClass">
<component :is="node.meta.icon" v-if="node.meta.icon" class="h-4 w-4 shrink-0" />
<span class="flex-1">{{ node.meta.title }}</span>
<svg-icon icon="lucide:chevron-right" class="h-3.5 w-3.5 shrink-0" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:side-offset="4"
class="z-[3000] min-w-40 rounded-lg border border-border bg-container p-1.5 shadow-sider"
>
<HorizontalMoreNode
v-for="c in visibleItems(node.children)"
:key="c.name"
:node="c"
@select="(k) => emit('select', k)"
/>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
@select="emit('select', node.name)"
>
<component :is="node.meta.icon" v-if="node.meta.icon" class="h-4 w-4 shrink-0" />
{{ node.meta.title }}
</DropdownMenuItem>
</template>
@@ -0,0 +1,84 @@
<script setup>
import { computed, provide, toRef } from 'vue'
import HorizontalMenu from './HorizontalMenu.vue'
import MenuItem from './MenuItem.vue'
import { MENU_THEMES, findMenuPath, visibleItems } from './shared'
defineOptions({ name: 'KraMenu' })
const props = defineProps({
items: { type: Array, default: () => [] },
orientation: {
type: String,
default: 'vertical',
validator: (v) => ['vertical', 'horizontal'].includes(v)
},
theme: {
type: String,
default: 'design',
validator: (v) => MENU_THEMES.includes(v)
},
collapsed: { type: Boolean, default: false },
active: { type: String, default: '' },
itemHeight: { type: Number, default: 48 },
class: { type: null, default: '' }
})
const emit = defineEmits(['select'])
// 展开的分支 key 列表;上层用 v-model:open-keys 双向绑定(激活路径/分组默认展开由 useMenuActive 维护)
const openKeys = defineModel('openKeys', { type: Array, default: () => [] })
const topItems = computed(() => visibleItems(props.items))
// 侧栏底色由外层布局容器负责铺满整栏;nav 保持透明。
// 非 design 且未折叠时给左右留白,让圆角项与栏边有距离;design 走全宽(左侧竖条贴边)。
const navPadClass = computed(() =>
props.collapsed || props.theme === 'design' ? 'px-0' : 'px-2'
)
const select = (key) => emit('select', key)
const collectKeys = (node) => {
const keys = [node.name]
const children = node.children || []
children.forEach((c) => keys.push(...collectKeys(c)))
return keys
}
// 展开策略:group 自由多开;其余风格 unique-opened(只保留当前路径,收起同级)
const toggle = (node) => {
const opened = openKeys.value.includes(node.name)
if (props.theme === 'group') {
openKeys.value = opened
? openKeys.value.filter((k) => k !== node.name)
: [...openKeys.value, node.name]
return
}
if (opened) {
const sub = collectKeys(node)
openKeys.value = openKeys.value.filter((k) => !sub.includes(k))
} else {
openKeys.value = findMenuPath(props.items, node.name)
}
}
provide('kraMenuCtx', {
theme: toRef(props, 'theme'),
collapsed: toRef(props, 'collapsed'),
active: toRef(props, 'active'),
itemHeight: toRef(props, 'itemHeight'),
openKeys,
select,
toggle
})
</script>
<template>
<HorizontalMenu
v-if="orientation === 'horizontal'"
:items="topItems"
:active="active"
:class="props.class"
@select="select"
/>
<nav v-else :class="['flex flex-col gap-0.5', navPadClass, props.class]">
<MenuItem v-for="item in topItems" :key="item.name" :node="item" :depth="0" />
</nav>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { inject, provide, ref } from 'vue'
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
import { FOCUS_RING } from '../utils'
import { MENU_ICON_BUTTON } from './variants'
import { visibleItems } from './shared'
import MenuItem from './MenuItem.vue'
defineOptions({ name: 'KraMenuFlyout' })
const props = defineProps({
node: { type: Object, required: true }
})
const ctx = inject('kraMenuCtx')
const open = ref(false)
let timer = null
const enter = () => {
clearTimeout(timer)
open.value = true
}
const leave = () => {
timer = setTimeout(() => (open.value = false), 120)
}
const children = () => visibleItems(props.node.children)
const isActive = () => ctx.active.value === props.node.name
// 飞出面板:按展开态渲染子树,且用面板内自持的展开态,
// 不回写外层侧栏的 openKeys(避免折叠飞出里的展开污染整栏)。
const flyoutOpen = ref([])
const flyoutToggle = (node) => {
flyoutOpen.value = flyoutOpen.value.includes(node.name)
? flyoutOpen.value.filter((k) => k !== node.name)
: [...flyoutOpen.value, node.name]
}
provide('kraMenuCtx', {
...ctx,
collapsed: ref(false),
openKeys: flyoutOpen,
toggle: flyoutToggle
})
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
:class="[MENU_ICON_BUTTON, FOCUS_RING, isActive() && 'text-active']"
:style="{ height: ctx.itemHeight.value + 'px' }"
:title="node.meta.title"
@mouseenter="enter"
@mouseleave="leave"
@click="ctx.select(node.name)"
>
<component :is="node.meta.icon" v-if="node.meta.icon" class="h-5 w-5" />
<span v-else class="text-[13px]">{{ node.meta.title[0] }}</span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="right"
align="start"
:side-offset="10"
class="z-[3000] min-w-44 rounded-lg border border-border bg-container p-1.5 shadow-sider"
@mouseenter="enter"
@mouseleave="leave"
>
<div class="px-3 py-1.5 text-[13px] font-semibold text-base-text">
{{ node.meta.title }}
</div>
<MenuItem
v-for="child in children()"
:key="child.name"
:node="child"
:depth="1"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
@@ -0,0 +1,133 @@
<script setup>
import { computed, inject } from 'vue'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent } from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
import { menuItemVariants } from './variants'
import { menuIndent, visibleItems } from './shared'
import MenuFlyout from './MenuFlyout.vue'
defineOptions({ name: 'KraMenuItem' })
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 }
})
// 由 Menu.vue provide 的运行时上下文(全为 ref + 回调)
const ctx = inject('kraMenuCtx')
const children = computed(() => visibleItems(props.node.children))
const hasChildren = computed(() => children.value.length > 0)
const isActive = computed(() => ctx.active.value === props.node.name)
const isOpen = computed(() => ctx.openKeys.value.includes(props.node.name))
// group 风格:顶层分支渲染成「分组标题」
const isGroupHeader = computed(
() => ctx.theme.value === 'group' && props.depth === 0 && hasChildren.value
)
// 折叠图标栏:折叠态 + 顶层 + 有子级 → 飞出
const isFlyout = computed(
() => ctx.collapsed.value && props.depth === 0 && hasChildren.value
)
// 折叠态顶层叶子:只显图标
const iconOnly = computed(() => ctx.collapsed.value && props.depth === 0)
const rowStyle = computed(() => ({
height: ctx.itemHeight.value + 'px',
// design 全宽、按层级右移;其余留基础左内边距(折叠图标态无内边距)
paddingLeft: iconOnly.value ? '0px' : menuIndent(props.depth, ctx.theme.value)
}))
const rowClass = computed(() =>
cn(
menuItemVariants({
theme: ctx.theme.value,
active: isActive.value,
role: isGroupHeader.value ? 'header' : 'item'
}),
iconOnly.value ? 'justify-center px-0' : 'px-3',
FOCUS_RING
)
)
const onLeaf = () => ctx.select(props.node.name)
const onToggle = () => ctx.toggle(props.node)
</script>
<template>
<!-- 折叠飞出 -->
<MenuFlyout v-if="isFlyout" :node="node" />
<!-- 分支(含 group 分组标题):Collapsible -->
<CollapsibleRoot v-else-if="hasChildren" :open="isOpen" @update:open="onToggle">
<CollapsibleTrigger :class="cn(rowClass, 'text-left')" :style="rowStyle">
<component
:is="node.meta.icon"
v-if="node.meta.icon && !isGroupHeader"
class="h-[18px] w-[18px] shrink-0"
/>
<span class="flex-1 truncate">{{ node.meta.title }}</span>
<svg-icon
icon="lucide:chevron-down"
class="h-4 w-4 shrink-0 transition-transform duration-200"
:class="isOpen ? 'rotate-180' : ''"
/>
</CollapsibleTrigger>
<CollapsibleContent class="kra-collapsible-content">
<MenuItem
v-for="child in children"
:key="child.name"
:node="child"
:depth="depth + 1"
/>
</CollapsibleContent>
</CollapsibleRoot>
<!-- 叶子 -->
<button
v-else
type="button"
:class="rowClass"
:style="rowStyle"
:title="iconOnly ? node.meta.title : null"
@click="onLeaf"
>
<component
:is="node.meta.icon"
v-if="node.meta.icon"
class="h-[18px] w-[18px] shrink-0"
/>
<span v-else-if="iconOnly" class="text-[13px]">{{ node.meta.title[0] }}</span>
<span v-if="!iconOnly" class="flex-1 truncate text-left">{{
node.meta.title
}}</span>
</button>
</template>
<style>
/* 展开 / 收起过渡:高度取 reka 提供的 --reka-collapsible-content-height */
.kra-collapsible-content {
overflow: hidden;
}
.kra-collapsible-content[data-state='open'] {
animation: kra-collapse-down 200ms ease-out;
}
.kra-collapsible-content[data-state='closed'] {
animation: kra-collapse-up 200ms ease-out;
}
@keyframes kra-collapse-down {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes kra-collapse-up {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
}
</style>
@@ -0,0 +1,54 @@
<script setup>
import { NavigationMenuLink } from 'reka-ui'
import { cn } from '../utils'
import { menuIndent, visibleItems } from './shared'
defineOptions({ name: 'KraNavMenuPanel' })
const props = defineProps({
items: { type: Array, default: () => [] },
active: { type: String, default: '' },
depth: { type: Number, default: 0 }
})
const emit = defineEmits(['select'])
const hasKids = (i) => visibleItems(i.children).length > 0
const linkClass = (name) =>
cn(
'flex w-full cursor-pointer items-center gap-2 whitespace-nowrap rounded-md py-2.5 pr-6 text-[14px] outline-none transition-colors hover:bg-muted',
props.active === name ? 'text-active font-medium' : 'text-base-text'
)
</script>
<template>
<ul class="flex min-w-max flex-col gap-1">
<li v-for="node in visibleItems(items)" :key="node.name">
<!-- 分支:分组标题 + 递归子列表 -->
<template v-if="hasKids(node)">
<div
class="whitespace-nowrap pb-1 pr-6 pt-2 text-[12px] font-semibold text-muted-foreground"
:style="{ paddingLeft: menuIndent(depth) }"
>
{{ node.meta.title }}
</div>
<NavMenuPanel
:items="visibleItems(node.children)"
:active="active"
:depth="depth + 1"
@select="(k) => emit('select', k)"
/>
</template>
<!-- 叶子:NavigationMenuLink -->
<NavigationMenuLink
v-else
:active="active === node.name"
:class="linkClass(node.name)"
:style="{ paddingLeft: menuIndent(depth) }"
@select.prevent="emit('select', node.name)"
>
<component :is="node.meta.icon" v-if="node.meta.icon" class="h-4 w-4 shrink-0" />
{{ node.meta.title }}
</NavigationMenuLink>
</li>
</ul>
</template>
@@ -0,0 +1,4 @@
// 只导出 Menu(PascalCase)→ 由 global.js 自动注册为 <g-menu>。
// MenuItem / MenuFlyout / Horizontal* 保持私有,避免泄漏为 g- 全局标签。
export { default as Menu } from './Menu.vue'
export { MENU_THEMES } from './shared'
@@ -0,0 +1,55 @@
// menu 的有限枚举与纯工具单一事实源(不含 Vue / 主题依赖,供组件与组合式共用,避免循环依赖)。
export const MENU_THEMES = ['design', 'light', 'group']
// 顶级横向菜单项间距(px):同时驱动模板 gap 与溢出计算,避免二者漂移。
export const MENU_GAP = 8
// 菜单缩进:基础左内边距 + 每级步长(design 风格步长更大,配合左侧主色竖条)。
const MENU_INDENT_BASE = 12
const MENU_INDENT_STEP = 12
const MENU_INDENT_STEP_DESIGN = 16
export const menuIndent = (depth, theme) =>
MENU_INDENT_BASE +
depth * (theme === 'design' ? MENU_INDENT_STEP_DESIGN : MENU_INDENT_STEP) +
'px'
// 过滤隐藏项:菜单各处渲染前的统一入口。
export const visibleItems = (items) => {
const list = items || []
return list.filter((i) => !i.hidden)
}
// key 是否落在某节点子树内(含自身)。
export const isKeyInSubtree = (node, key) => {
const children = node.children || []
return node.name === key || children.some((c) => isKeyInSubtree(c, key))
}
// 在根级菜单里求 key 所属的一级菜单节点(跳过隐藏一级),找不到返回 null。
export const resolveActiveTop = (rootMenus, key) => {
const topMenus = rootMenus || []
return topMenus.find((top) => !top.hidden && isKeyInSubtree(top, key)) || null
}
/**
* 在菜单树中按 name 求从根到该节点的祖先路径(含自身),找不到返回 []。
* @param {Array<{name:string,children?:Array}>} menus
* @param {string} targetKey
* @returns {string[]}
*/
export function findMenuPath(menus, targetKey) {
const path = []
const dfs = (item) => {
path.push(item.name)
if (item.name === targetKey) return true
for (const child of item.children || []) {
if (dfs(child)) return true
}
path.pop()
return false
}
for (const m of menus || []) {
if (dfs(m)) return [...path]
}
return []
}
@@ -0,0 +1,58 @@
import { cva } from 'class-variance-authority'
// 菜单项配色变体:三种菜单风格 × 选中态 × 角色(普通项 / 分组标题)。
// 深浅由侧栏容器的语义 token 决定(见 useSidebarTheme 的 .kra-sider-dark 作用域)。
// 配色只用语义 token + 主色弱底 rgb(var(--primary-color)/α)。
export const menuItemVariants = cva(
'group/mi relative flex w-full items-center gap-2 appearance-none bg-transparent cursor-pointer select-none whitespace-nowrap text-[14px] leading-none transition-colors outline-none',
{
variants: {
theme: {
design: 'rounded-none',
light: 'rounded-[var(--kra-radius)]',
group: 'rounded-lg'
},
active: { true: '', false: '' },
role: { item: '', header: '' }
},
compoundVariants: [
// design:全宽直角 + 左侧主色竖条 + 主色弱底(保留原风格观感)
{
theme: 'design',
active: true,
class:
"text-active font-medium bg-[rgb(var(--primary-color)/0.12)] before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:rounded-r before:bg-primary before:content-['']"
},
{
theme: 'design',
active: false,
class: 'text-base-text hover:bg-[rgb(var(--primary-color)/0.08)]'
},
// light:白底 + 主色实底选中
{ theme: 'light', active: true, class: 'bg-primary text-white' },
{ theme: 'light', active: false, class: 'text-base-text hover:bg-muted' },
// group:分组标题 = 加粗基础色;子项 = 弱色;选中 = 浅灰药丸
{ theme: 'group', role: 'header', class: 'text-base-text font-semibold' },
{
theme: 'group',
role: 'item',
active: true,
class: 'bg-[rgb(var(--primary-color)/0.12)] text-active font-medium'
},
{
theme: 'group',
role: 'item',
active: false,
class: 'text-muted-foreground hover:bg-muted hover:text-base-text'
}
],
defaultVariants: { theme: 'design', active: false, role: 'item' }
}
)
// 侧栏容器底色:统一跟随 container(随全局明暗 / .kra-sider-dark 作用域自适应)。
export const SIDEBAR_SURFACE = 'bg-container text-base-text'
// 折叠图标栏 / 飞出触发的基础按钮样式(图标居中、hover 底;focus ring 由调用方叠加)。
export const MENU_ICON_BUTTON =
'relative flex w-full appearance-none items-center justify-center rounded-lg bg-transparent text-base-text transition-colors hover:bg-muted'
@@ -0,0 +1,64 @@
<script setup>
import { computed } from 'vue'
import {
NumberFieldRoot,
NumberFieldInput,
NumberFieldIncrement,
NumberFieldDecrement
} from 'reka-ui'
import { cn, FOCUS_RING_WITHIN } from '../utils'
defineOptions({ name: 'UiNumberField' })
const props = defineProps({
min: { type: Number, default: undefined },
max: { type: Number, default: undefined },
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
class: { type: null, default: '' }
})
// 双向绑定统一走 defineModel,取代手写 modelValue prop + defineEmits + @update 三段样板
const modelValue = defineModel({ type: Number, default: 0 })
// 清空输入框时 reka 可能回写 null/NaN,下游会把它拼成 `${val}px` 破坏布局;
// 用可写 computed 拦掉非法值,保留上一个合法值。
const innerValue = computed({
get: () => modelValue.value,
set: (val) => {
if (val == null || Number.isNaN(val)) return
modelValue.value = val
}
})
</script>
<template>
<NumberFieldRoot
v-model="innerValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:class="cn(
'inline-flex h-8 items-center overflow-hidden rounded-md border border-border bg-container text-base-text transition-colors focus-within:border-primary',
FOCUS_RING_WITHIN,
props.class
)"
>
<NumberFieldDecrement
aria-label="减少"
class="flex h-full w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-primary disabled:opacity-50"
>
<svg-icon icon="lucide:minus" class="h-3.5 w-3.5" aria-hidden="true" />
</NumberFieldDecrement>
<NumberFieldInput
class="h-full w-full min-w-0 flex-1 border-x border-border bg-transparent text-center text-[13px] text-base-text outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<NumberFieldIncrement
aria-label="增加"
class="flex h-full w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-primary disabled:opacity-50"
>
<svg-icon icon="lucide:plus" class="h-3.5 w-3.5" aria-hidden="true" />
</NumberFieldIncrement>
</NumberFieldRoot>
</template>
@@ -0,0 +1 @@
export { default as NumberField } from './NumberField.vue'
@@ -0,0 +1,33 @@
<script setup>
// 默认风格:扁平药丸;选中 / hover 用主色弱底 + 主色文字。
// 卡片样式(border/shadow)靠 html.kra-card--border / .kra-card--shadow 切换,
// 在下方全局 style 块中声明。相邻 item 间距由上层 flex gap 控制,圆角跟随 --kra-radius。
defineOptions({ name: 'PageTabButton' })
const props = defineProps({
active: { type: Boolean, default: false },
class: { type: null, default: '' }
})
</script>
<template>
<div
class="kra-tab-btn mb-1.2 relative inline-flex items-center gap-.8 h-9 cursor-pointer select-none whitespace-nowrap rounded-[var(--kra-radius)] px-3.5 text-[14px] transition-all duration-200 hover:text-active hover:bg-[rgb(var(--primary-color)/0.08)]"
:class="[active ? 'text-active bg-[rgb(var(--primary-color)/.12)] font-medium' : 'text-base-text', props.class]"
>
<slot name="prefix" />
<slot />
<slot name="suffix" />
</div>
</template>
<!-- 卡片模式:border / shadow 写在全局 style 块,由 html 上的 class 切换自动应用 -->
<style>
html.kra-card--border .kra-tab-btn {
border: 1px solid rgba(0, 0, 0, 0.08);
}
html.kra-card--shadow .kra-tab-btn {
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 8px rgba(0, 0, 0, 0.04);
}
</style>
@@ -0,0 +1,88 @@
<script setup>
import ChromeTabBg from './ChromeTabBg.vue'
// Chrome 风格(移植自 Soybean):弧形梯形底片 + 标签右侧短竖线。
// 相邻标签靠 -18px 交叠、靠 z-index 抬升当前 / hover 项;
// 竖线在自身 hover / 选中时隐藏,选中标签靠交叠盖住左邻竖线。
// 弧形底片 fill=currentColor,由 .__bg 的 color 控制:仅选中显示(主色弱底),未选透明。
// hover 对齐真实 Chrome:非激活标签 hover 出「内嵌圆角胶囊」,不点亮整个弧形底片;
// 胶囊用全局统一的 hover 淡底 rgb(var(--primary-color)/0.08)(与 ButtonTab / el-tree 相通)。
defineOptions({ name: 'PageTabChrome' })
const props = defineProps({
active: { type: Boolean, default: false },
class: { type: null, default: '' }
})
</script>
<template>
<div
class="kra-tab-chrome relative -mr-[18px] inline-flex h-full cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap px-6 text-[13px] transition-colors duration-200"
:class="[{ 'is-active': active }, props.class]"
>
<div class="kra-tab-chrome__bg pointer-events-none absolute inset-0 z-[-1] h-full w-full">
<ChromeTabBg />
</div>
<!-- 非激活 hover 的内嵌胶囊(仿真实 Chrome:hover 是圆角胶囊,选中才是弧形选项卡) -->
<div class="kra-tab-chrome__hover pointer-events-none absolute z-[-1]" />
<slot name="prefix" />
<slot />
<slot name="suffix" />
<div class="kra-tab-chrome__divider" />
</div>
</template>
<style scoped>
.kra-tab-chrome {
z-index: 0;
color: rgb(var(--base-text-color));
}
.kra-tab-chrome:hover {
z-index: 9;
}
.kra-tab-chrome.is-active {
z-index: 10;
color: rgb(var(--primary-color));
}
/* 弧形底片填充:fill=currentColor 跟随这里的 color。仅选中显示;
用不透明的浅主色(primary-50)填充(而非半透明),才能盖住交叠到下面的相邻标签内容,
避免“透出”竖线/×,亮/暗由 token 自适配。 */
.kra-tab-chrome__bg {
color: transparent;
}
.kra-tab-chrome.is-active .kra-tab-chrome__bg {
color: rgb(var(--primary-50-color));
}
/* 非激活 hover:内嵌圆角胶囊(仿真实 Chrome)。用全局统一 hover 淡底(主色 8% wash),
不与弧形底片同形;相邻竖线在 hover 时已隐藏,半透明底下只有标签栏背景,不会透出杂物。 */
.kra-tab-chrome__hover {
inset: 5px 12px;
border-radius: 8px;
background: rgb(var(--primary-color) / 0.08);
opacity: 0;
transition: opacity 0.2s;
}
.kra-tab-chrome:not(.is-active):hover .kra-tab-chrome__hover {
opacity: 1;
}
/* 短竖线:每个标签右侧一根;自身 hover / 选中时隐藏,
选中标签靠 -18px 交叠 + z-index 盖住其左邻标签的竖线(与 Soybean 一致) */
.kra-tab-chrome__divider {
position: absolute;
right: 7px;
top: 50%;
width: 1px;
height: 14px;
transform: translateY(-50%);
background-color: rgb(var(--border-color));
}
.kra-tab-chrome:hover .kra-tab-chrome__divider,
.kra-tab-chrome.is-active .kra-tab-chrome__divider,
.kra-tab-chrome:has(+ .kra-tab-chrome:hover) .kra-tab-chrome__divider,
.kra-tab-chrome:has(+ .kra-tab-chrome.is-active) .kra-tab-chrome__divider {
opacity: 0;
}
</style>
@@ -0,0 +1,51 @@
<script setup>
// Chrome 标签的弧形底片(带圆角的梯形),移植自 Soybean 的 chrome-tab-bg:
// fill=currentColor,由外层 .__bg 的 color 控制填充色,跟随主题 / 暗色。
//
// 高度自适配:底片纵向拉伸铺满整个标签高度,底边始终贴住标签栏底部(Chrome 观感靠“标签底与内容区无缝相接”)。
// symbol 用 preserveAspectRatio="none" 只做纵向缩放;横向仍靠固定 214 宽的 <use> 保持圆角形状不变形(Soybean 的“固定圆角+中段拉伸”技巧)。
// 注意:不要把 <use> 的 height 写成固定像素(如 36),否则标签实际高度一旦不等于该值,底片就会顶对齐、底部空出缝隙、标签“飘”起来贴不到底。
defineOptions({ name: 'PageTabChromeBg' })
</script>
<template>
<svg class="h-full w-full">
<defs>
<symbol
id="kra-chrome-geometry-left"
viewBox="0 0 214 36"
preserveAspectRatio="none"
>
<path d="M17 0h197v36H0v-2c4.5 0 9-3.5 9-8V8c0-4.5 3.5-8 8-8z" />
</symbol>
<symbol
id="kra-chrome-geometry-right"
viewBox="0 0 214 36"
preserveAspectRatio="none"
>
<use xlink:href="#kra-chrome-geometry-left" />
</symbol>
<clipPath>
<rect x="0" width="100%" height="100%" />
</clipPath>
</defs>
<svg width="51%" height="100%">
<use
xlink:href="#kra-chrome-geometry-left"
width="214"
height="100%"
fill="currentColor"
/>
</svg>
<g transform="scale(-1, 1)">
<svg x="-100%" y="0" width="51%" height="100%">
<use
xlink:href="#kra-chrome-geometry-right"
width="214"
height="100%"
fill="currentColor"
/>
</svg>
</g>
</svg>
</template>
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue'
import { cn } from '../utils'
import ButtonTab from './ButtonTab.vue'
import ChromeTab from './ChromeTab.vue'
import SliderTab from './SliderTab.vue'
import { PAGE_TAB_MODES } from './shared'
// 单个标签页(纯展示)风格切换由 mode 决定(button / chrome / slider),三种模式各是一个无逻辑的展示子组件。
// 上层在 <g-page-tab> 上绑定的原生事件(@click / @mousedown / @contextmenu 等)会经 attribute fallthrough 透传到根元素;关闭则是显式 close 事件。
defineOptions({ name: 'PageTab' })
const props = defineProps({
// 风格:button(描边胶囊) / chrome(浏览器标签) / slider(底部指示条)
mode: {
type: String,
default: 'button',
validator: (v) => PAGE_TAB_MODES.includes(v)
},
// 是否选中态(由上层根据“当前激活的 key”计算后传入,组件自身不持有激活状态)
active: { type: Boolean, default: false },
// 是否显示关闭按钮
closable: { type: Boolean, default: true },
// 透传到根:外部布局 / 覆盖用,cn() 最后合并
class: { type: null, default: '' },
// 各模式独立的覆盖 class
buttonClass: { type: null, default: '' },
chromeClass: { type: null, default: '' },
sliderClass: { type: null, default: '' }
})
const emit = defineEmits(['close'])
const tabComponent = computed(
() => ({ button: ButtonTab, chrome: ChromeTab, slider: SliderTab })[props.mode]
)
const modeClass = computed(
() =>
({
button: props.buttonClass,
chrome: props.chromeClass,
slider: props.sliderClass
})[props.mode]
)
// 关闭按钮独立于标签本体:阻断冒泡,避免触发上层的“切换 / 中键关闭 / 右键菜单”
const handleClose = (e) => {
e.stopPropagation()
emit('close')
}
</script>
<template>
<component
:is="tabComponent"
:active="active"
:class="cn(modeClass, props.class)"
>
<template #prefix>
<slot name="prefix" />
</template>
<slot />
<template #suffix>
<slot name="suffix">
<button
v-if="closable"
type="button"
aria-label="关闭标签"
class="-mr-1 ml-0.5 inline-flex h-4 w-4 shrink-0 appearance-none items-center justify-center rounded-full bg-transparent text-muted-foreground transition-colors hover:text-base-text kra-tab-close-btn"
@click.stop="handleClose"
@mousedown.stop
@pointerdown.stop
>
<svg-icon icon="lucide:x" class="h-3 w-3" />
</button>
</slot>
</template>
</component>
</template>
<style scoped>
/* 关闭按钮 hover 圆:用 muted-foreground 淡色替代 control-track(与标签 hover 底色同色),
亮/暗模式下都与 control-track 形成可见反差。 */
.kra-tab-close-btn:hover {
background-color: rgb(var(--muted-foreground-color) / 0.2);
}
</style>
@@ -0,0 +1,30 @@
<script setup>
import { cn } from '../utils'
// slider 风格:底部 2px 主色指示条 + 激活弱底,扁平现代。
// 弱底用 muted(暗色安全的语义 token),指示与文字走 primary。
defineOptions({ name: 'PageTabSlider' })
const props = defineProps({
active: { type: Boolean, default: false },
class: { type: null, default: '' }
})
</script>
<template>
<div
:class="
cn(
'group relative inline-flex h-full cursor-pointer select-none items-center justify-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-1 text-[13px] transition-colors duration-200',
active
? 'border-primary bg-muted text-primary'
: 'border-transparent text-base-text hover:text-primary',
props.class
)
"
>
<slot name="prefix" />
<slot />
<slot name="suffix" />
</div>
</template>
@@ -0,0 +1,4 @@
// 只导出 PageTab(PascalCase)→ 由 global.js 自动注册为 <g-page-tab>。
// button / chrome / slider 三个模式子组件保持私有(不从这里导出),避免泄漏为 g- 全局标签。
export { default as PageTab } from './PageTab.vue'
export { PAGE_TAB_MODES } from './shared'
@@ -0,0 +1,3 @@
// page-tab 的有限枚举单一事实源:mode 的可选值集中在这里,
// 供 PageTab 的 prop validator 与外部使用复用,避免和 index.js 形成循环依赖。
export const PAGE_TAB_MODES = ['button', 'chrome', 'slider']
@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue'
import { SelectRoot } from 'reka-ui'
import SelectTrigger from './SelectTrigger.vue'
import SelectContent from './SelectContent.vue'
import SelectItem from './SelectItem.vue'
defineOptions({ name: 'UiSelect' })
/** @typedef {{ label: string, value: string | number | boolean, disabled?: boolean }} SelectOption */
const props = defineProps({
// 便捷模式:传入 options 即自动渲染 trigger / content / items;
// 不传 options 则回退到默认插槽(granular 模式:自行组合 SelectTrigger / SelectContent / SelectItem)。
/** @type {SelectOption[]} */
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择' },
disabled: { type: Boolean, default: false },
// 透传到触发器,便于外部传布局 class(如 min-w-24)
class: { type: null, default: '' },
contentClass: { type: null, default: '' }
})
// 双向绑定统一走 defineModel;放宽到 string | number | boolean,回写时保留 option.value 的原类型。
const modelValue = defineModel({ type: [String, Number, Boolean], default: undefined })
const hasOptions = computed(() => props.options.length > 0)
// reka SelectRoot / SelectItem 只按字符串匹配(item 的 value 也被 String 化),
// 这里维护 String(value) -> 原始 value 映射:入参把 modelValue 串化喂给 reka,
// 回写时再用映射还原成原始类型,避免数字 / 布尔 value 被抹平成字符串。
const valueMap = computed(() => new Map(props.options.map((o) => [String(o.value), o.value])))
const innerValue = computed(() => (modelValue.value == null ? undefined : String(modelValue.value)))
const onUpdate = (next) => {
// granular 模式下 options 为空、映射命不中时原样透传(保持既有字符串语义)
modelValue.value = valueMap.value.has(next) ? valueMap.value.get(next) : next
}
// 便捷模式下用本地 options 直接算出当前项文案,
// 避免依赖 reka-ui SelectValue 在面板首次挂载前回填选中文本的时机问题。
const currentLabel = computed(
() => props.options.find((o) => String(o.value) === String(modelValue.value))?.label
)
</script>
<template>
<SelectRoot
:model-value="innerValue"
:disabled="disabled"
@update:model-value="onUpdate"
>
<template v-if="hasOptions">
<SelectTrigger :class="props.class">
<span v-if="currentLabel != null" class="truncate">{{ currentLabel }}</span>
<span v-else class="truncate text-muted-foreground">{{ placeholder }}</span>
</SelectTrigger>
<SelectContent :class="contentClass">
<SelectItem
v-for="opt in options"
:key="String(opt.value)"
:value="String(opt.value)"
:disabled="opt.disabled"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</template>
<slot v-else />
</SelectRoot>
</template>
@@ -0,0 +1,30 @@
<script setup>
import { SelectContent, SelectPortal, SelectViewport } from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiSelectContent' })
const props = defineProps({
class: { type: null, default: '' },
position: { type: String, default: 'popper', validator: (v) => ['popper', 'item-aligned'].includes(v) },
sideOffset: { type: Number, default: 4 }
})
</script>
<template>
<SelectPortal>
<SelectContent
:position="position"
:side-offset="sideOffset"
:class="cn(
'relative z-[3000] min-w-[8rem] overflow-hidden rounded-md border border-border bg-container text-base-text shadow-lg',
position === 'popper' && 'w-[var(--reka-select-trigger-width)]',
props.class
)"
>
<SelectViewport class="max-h-72 overflow-y-auto p-1">
<slot />
</SelectViewport>
</SelectContent>
</SelectPortal>
</template>
@@ -0,0 +1,32 @@
<script setup>
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import { cn } from '../utils'
defineOptions({ name: 'UiSelectItem' })
const props = defineProps({
value: { type: String, required: true },
disabled: { type: Boolean, default: false },
class: { type: null, default: '' }
})
</script>
<template>
<SelectItem
:value="value"
:disabled="disabled"
:class="cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-[13px] text-base-text outline-none transition-colors data-[highlighted]:bg-muted data-[highlighted]:text-primary data-[state=checked]:font-medium data-[state=checked]:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)"
>
<span class="absolute left-2 inline-flex items-center justify-center text-primary">
<SelectItemIndicator>
<svg-icon icon="lucide:check" class="h-3.5 w-3.5" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,25 @@
<script setup>
import { SelectTrigger, SelectIcon } from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
defineOptions({ name: 'UiSelectTrigger' })
const props = defineProps({
class: { type: null, default: '' }
})
</script>
<template>
<SelectTrigger
:class="cn(
'flex h-8 items-center justify-between gap-2 rounded-md border border-border bg-container px-3 text-[13px] text-base-text transition-colors hover:border-primary focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground',
FOCUS_RING,
props.class
)"
>
<slot />
<SelectIcon>
<svg-icon icon="lucide:chevron-down" class="h-4 w-4 shrink-0 opacity-60" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -0,0 +1,6 @@
export { default as Select } from './Select.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectItem } from './SelectItem.vue'
// granular 模式可直接复用 reka-ui 的 SelectValue(在自定义 trigger 内展示当前选中文本)
export { SelectValue } from 'reka-ui'
@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue'
import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
defineOptions({ name: 'UiSlider' })
/** @typedef {Record<string | number, string>} SliderMarks 刻度:键为刻度值、值为下方文字 */
// 对外用「单值 number」表达,内部按 reka 约定包成数组。
const props = defineProps({
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
step: { type: Number, default: 1 },
// 形如 { 0: '0', 0.5: '0.5', 1: '1' },在轨道下方渲染刻度文字
marks: { type: Object, default: null },
disabled: { type: Boolean, default: false },
// 拨块的可访问名(屏幕阅读器),调用方按语义传入,如「圆角大小」
ariaLabel: { type: String, default: undefined },
class: { type: null, default: '' }
})
// 双向绑定走 defineModel(对外单值 number),用可写 computed 桥到 reka 要求的数组形态
const modelValue = defineModel({ type: Number, default: 0 })
const sliderValue = computed({
get: () => [modelValue.value],
set: (val) => {
modelValue.value = Array.isArray(val) ? val[0] : val
}
})
const markList = computed(() => {
if (!props.marks) return []
const span = props.max - props.min || 1
return Object.entries(props.marks).map(([value, label]) => ({
value: Number(value),
label,
percent: ((Number(value) - props.min) / span) * 100
}))
})
</script>
<template>
<div :class="cn('w-full', props.class)">
<SliderRoot
v-model="sliderValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
class="relative cursor-pointer flex h-5 w-full touch-none select-none items-center disabled:opacity-50"
>
<SliderTrack class="relative h-1.5 w-full grow overflow-hidden rounded-full bg-control-track">
<SliderRange class="absolute h-full rounded-full bg-primary" />
</SliderTrack>
<SliderThumb
:aria-label="ariaLabel"
:class="cn('block h-4 w-4 rounded-full border-2 border-primary bg-container shadow transition-colors', FOCUS_RING)"
/>
</SliderRoot>
<div v-if="markList.length" class="relative mt-1 h-3.5">
<span
v-for="m in markList"
:key="m.value"
class="absolute -translate-x-1/2 text-[10px] leading-none text-muted-foreground"
:style="{ left: m.percent + '%' }"
>{{ m.label }}</span>
</div>
</div>
</template>
@@ -0,0 +1 @@
export { default as Slider } from './Slider.vue'
@@ -0,0 +1,35 @@
<script setup>
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn, FOCUS_RING } from '../utils'
defineOptions({ name: 'UiSwitch' })
const props = defineProps({
disabled: { type: Boolean, default: false },
// 纯图形控件的可访问名:屏幕阅读器据此朗读该开关含义,调用方按语义传入
ariaLabel: { type: String, default: undefined },
class: { type: null, default: '' }
})
// 双向绑定统一走 defineModel,取代手写 modelValue prop + defineEmits + @update 三段样板
const modelValue = defineModel({ type: Boolean, default: false })
</script>
<template>
<SwitchRoot
v-model="modelValue"
:disabled="disabled"
:aria-label="ariaLabel"
:class="cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors duration-200 bg-control-track data-[state=checked]:bg-primary disabled:cursor-not-allowed disabled:opacity-50',
FOCUS_RING,
props.class
)"
>
<!-- 旋钮固定浅色:需在 off=control-track / on=primary 两种轨道色上都清晰可见,
故不随暗色翻深、不走 bg-container(旋钮无描边,仅靠填充色与轨道对比) -->
<SwitchThumb
class="pointer-events-none block h-4 w-4 translate-x-0.5 rounded-full bg-white shadow transition-transform duration-200 data-[state=checked]:translate-x-[16px]"
/>
</SwitchRoot>
</template>
@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'
+18
View File
@@ -0,0 +1,18 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* 合并 class:clsx 处理条件类,tailwind-merge 消解冲突的原子类(presetWind3 与 Tailwind v3 同语法)。
* 这是 reka-ui / shadcn-vue 体系约定俗成的 `cn()` 基础工具,后续基础组件统一用它拼接 class。
* @param {...any} inputs
* @returns {string}
*/
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
// 焦点态单一真源,全组件统一引用,避免各处手写导致漂移:
// - FOCUS_RING:控件本体可聚焦(focus-visible,仅键盘聚焦显形)。
// - FOCUS_RING_WITHIN:包裹型容器(内部 input 才真正聚焦)用 focus-within。
export const FOCUS_RING = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300'
export const FOCUS_RING_WITHIN = 'focus-within:ring-2 focus-within:ring-primary-300'