前端代码
This commit is contained in:
@@ -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']
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user