20260824172723

This commit is contained in:
oneao committed 2026-08-24 17:27:24 +08:00
1 parent 791d1e01d7
commit 97aea665f9
10 files changed
+443 -723

No files matched your search

@@ -28,6 +28,8 @@
.input-wrapper.input-focused {
border-color: var(--fms-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
z-index: 1;
}
/* 多行模式:紧凑的垂直布局,footer 只保留计数和手柄需要的空间 */
@@ -0,0 +1,68 @@
<script setup>
/**
* Scrollbar 通用滚动容器(基于 OverlayScrollbars v2):
* - 隐藏原生滚动条,自绘悬浮滚动条(不占布局高度)
* - 内容溢出时自动出现,支持横向 / 纵向 / 双向滚动
* - 用法:<Scrollbar class="xxx"><内容/></Scrollbar>
* 参考文档:https://kingsora.github.io/OverlayScrollbars/
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { OverlayScrollbars } from 'overlayscrollbars'
import 'overlayscrollbars/styles/overlayscrollbars.css'
const props = defineProps({
/** 滚动条主题(自定义 class 会加到滚动条元素上,配合全局样式定制外观) */
theme: { type: String, default: '' },
/** 滚动条显示策略:visible 常显 / auto 溢出时显示 / hidden 隐藏 */
visibility: {
type: String,
default: 'auto',
validator: (value) => ['visible', 'auto', 'hidden'].includes(value),
},
/** 自动隐藏:never 永不 / scroll 滚动时显示 / move 鼠标移动或滚动显示 / leave 移出后隐藏 */
autoHide: {
type: String,
default: 'leave',
validator: (value) => ['never', 'scroll', 'move', 'leave'].includes(value),
},
/** 是否允许点击轨道跳转(true / 'instant' 瞬间跳转 / false) */
clickScroll: { type: [Boolean, String], default: true },
/** 无障碍标签 */
ariaLabel: { type: String, default: '滚动区域' },
})
const el = ref(null)
let instance = null
onMounted(() => {
if (!el.value) return
instance = OverlayScrollbars(el.value, {
scrollbars: {
theme: props.theme || undefined,
visibility: props.visibility,
autoHide: props.autoHide,
clickScroll: props.clickScroll,
},
aria: { label: props.ariaLabel },
})
})
onBeforeUnmount(() => {
instance?.destroy()
instance = null
})
/** 供外部调用 */
defineExpose({
/** 更新(内容/尺寸变化后重新计算滚动条) */
update: () => instance?.update(),
/** 获取实例内部元素 */
getElements: () => instance?.getElements?.(),
})
</script>
<template>
<div ref="el" class="fms-scrollbar">
<slot />
</div>
</template>
@@ -28,6 +28,8 @@
font-size: var(--fms-font-size);
line-height: var(--fms-line-height);
cursor: pointer;
position: relative;
z-index: 0;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
@@ -46,6 +48,8 @@
.select-trigger-focused {
border-color: var(--fms-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
z-index: 1;
}
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入 */
@@ -30,8 +30,9 @@
flex-shrink: 0;
}
/* tab 列表:承担方向排列、间距与滚动,flex: 1 占满剩余空间,
* 将 leftExtra / rightExtra 推到标签栏两端而非紧贴标签 */
/* tab 列表:承担方向排列、间距,flex: 1 占满剩余空间,
* 将 leftExtra / rightExtra 推到标签栏两端而非紧贴标签;
* 滚动由外层 Scrollbar 组件(.tabs-nav-scroll)接管,列表本身不设 overflow */
.tabs-nav-list {
display: flex;
flex: 1 1 auto;
@@ -39,10 +40,20 @@
min-height: 0;
}
/* Scrollbar 容器:占满 nav 剩余空间,由组件自绘悬浮滚动条 */
.tabs-nav-scroll {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.tabs-top .tabs-nav {
flex-direction: row;
align-items: flex-end;
overflow-x: auto;
}
/* line 类型有底部分割线;pill 类型不需要——胶囊背景已足够区分 */
.tabs-line.tabs-top .tabs-nav {
border-bottom: 1px solid var(--fms-border);
}
@@ -54,7 +65,6 @@
.tabs-left .tabs-nav {
flex-direction: column;
overflow-y: auto;
border-right: 1px solid var(--fms-border);
}
@@ -65,7 +75,6 @@
.tabs-right .tabs-nav {
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--fms-border);
}
@@ -154,45 +163,69 @@
outline-offset: 2px;
}
/* 激活:指示条 + 主色文字 + 字重,不只依赖颜色表达选中 */
/* 激活:主色文字 + 字重,不只依赖颜色表达选中 */
.tabs-tab-active {
color: var(--fms-primary);
font-weight: 600;
}
/* ==================== line 类型(默认):下划线指示条 ==================== */
/* line 指示条:top 为下边框,left/right 为侧边框,无需 JS 测量 */
.tabs-top .tabs-tab {
.tabs-line.tabs-top .tabs-tab {
border-bottom: 2px solid transparent;
}
.tabs-top .tabs-tab-active {
.tabs-line.tabs-top .tabs-tab-active {
border-bottom-color: var(--fms-primary);
}
.tabs-left .tabs-tab,
.tabs-right .tabs-tab {
.tabs-line.tabs-left .tabs-tab,
.tabs-line.tabs-right .tabs-tab {
border-top: none;
border-bottom: none;
}
.tabs-left .tabs-tab {
.tabs-line.tabs-left .tabs-tab {
border-right: 2px solid transparent;
}
.tabs-left .tabs-tab-active {
.tabs-line.tabs-left .tabs-tab-active {
border-right-color: var(--fms-primary);
background: var(--fms-secondary-hover);
}
.tabs-right .tabs-tab {
.tabs-line.tabs-right .tabs-tab {
border-left: 2px solid transparent;
}
.tabs-right .tabs-tab-active {
.tabs-line.tabs-right .tabs-tab-active {
border-left-color: var(--fms-primary);
background: var(--fms-secondary-hover);
}
/* ==================== pill 类型(胶囊):圆角背景式激活 ==================== */
.tabs-pill .tabs-nav-list {
gap: 4px;
}
.tabs-pill .tabs-tab {
border-radius: 999px;
padding: 0 12px;
transition:
color 150ms ease,
background 150ms ease;
}
.tabs-pill .tabs-tab:hover:not(:disabled) {
background: var(--fms-secondary);
}
.tabs-pill .tabs-tab-active {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
border-bottom: none;
font-weight: 500;
}
/* 禁用:降透明 + not-allowed(原生 disabled 同时保证不可交互) */
.tabs-tab:disabled {
color: var(--fms-disabled-text);
@@ -1,5 +1,6 @@
<script setup>
import { computed, Fragment, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from 'vue'
import Scrollbar from '@/components/ui/scrollbar/scrollbar.vue'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
@@ -11,6 +12,14 @@ const props = defineProps({
default: 'top',
validator: (value) => ['top', 'left', 'right'].includes(value),
},
/** 样式类型:line 下划线指示条,pill 胶囊式标签 */
type: {
type: String,
default: 'line',
validator: (value) => ['line', 'pill'].includes(value),
},
/** 仅渲染标签栏(不渲染内容区):用于纯导航场景,内容由外部 v-show 控制 */
navOnly: { type: Boolean, default: false },
/** 标签列表在标签栏内居中显示(top 水平居中,left/right 垂直居中) */
centered: { type: Boolean, default: false },
/** 整组禁用 */
@@ -230,46 +239,52 @@ function onTablistKeydown(event) {
<template>
<div
class="tabs"
:class="[`tabs-${placement}`, { 'tabs-disabled': disabled, 'tabs-centered': centered }]"
:class="[
`tabs-${placement}`,
`tabs-${type}`,
{ 'tabs-disabled': disabled, 'tabs-centered': centered, 'tabs-nav-only': navOnly },
]"
>
<div class="tabs-nav">
<div v-if="slots.leftExtra" class="tabs-nav-extra">
<slot name="leftExtra" />
</div>
<div
class="tabs-nav-list"
role="tablist"
:aria-orientation="placement === 'top' ? 'horizontal' : 'vertical'"
@keydown="onTablistKeydown"
>
<button
v-for="(pane, index) in mergedPanes"
:key="pane.key"
:ref="(el) => setTabRef(pane.key, el)"
type="button"
class="tabs-tab"
:class="{
'tabs-tab-active': currentKey === pane.key,
'tabs-tab-disabled': pane.disabled,
}"
role="tab"
:id="`${baseId}-tab-${index}`"
:aria-selected="currentKey === pane.key"
:aria-controls="`${baseId}-panel-${index}`"
:tabindex="currentKey === pane.key ? 0 : -1"
:disabled="disabled || pane.disabled"
@click="(event) => onTabClick(pane, event)"
<Scrollbar class="tabs-nav-scroll">
<div
class="tabs-nav-list"
role="tablist"
:aria-orientation="placement === 'top' ? 'horizontal' : 'vertical'"
@keydown="onTablistKeydown"
>
<component :is="pane.labelRender" v-if="pane.labelRender" />
<span v-else-if="pane.labelText !== undefined">{{ pane.labelText }}</span>
</button>
</div>
<button
v-for="(pane, index) in mergedPanes"
:key="pane.key"
:ref="(el) => setTabRef(pane.key, el)"
type="button"
class="tabs-tab"
:class="{
'tabs-tab-active': currentKey === pane.key,
'tabs-tab-disabled': pane.disabled,
}"
role="tab"
:id="`${baseId}-tab-${index}`"
:aria-selected="currentKey === pane.key"
:aria-controls="`${baseId}-panel-${index}`"
:tabindex="currentKey === pane.key ? 0 : -1"
:disabled="disabled || pane.disabled"
@click="(event) => onTabClick(pane, event)"
>
<component :is="pane.labelRender" v-if="pane.labelRender" />
<span v-else-if="pane.labelText !== undefined">{{ pane.labelText }}</span>
</button>
</div>
</Scrollbar>
<div v-if="slots.rightExtra" class="tabs-nav-extra">
<slot name="rightExtra" />
</div>
</div>
<div class="tabs-content">
<div v-if="!navOnly" class="tabs-content">
<template v-for="(pane, index) in mergedPanes" :key="pane.key">
<div
v-if="renderedKeys.has(pane.key)"