20260824172723
This commit is contained in:
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)"
|
||||
|
||||
Reference in new issue
Block a user