Tab View 选项卡

FluereTabView + FluereTabViewItem 对位 WinUI 3 / Windows App SDK 的 Microsoft.UI.Xaml.Controls.TabView:一条可横向滚动的 tab 条 + 一个只显示选中项的内容区,支持关闭按钮、新建按钮、三种宽度模式,键位与焦点表现逐条抄源码。

<FluereTabView v-model:selected-index="selected" label="选项卡">
  <FluereTabViewItem header="文档" :is-closable="true">面板内容</FluereTabViewItem>
  <FluereTabViewItem header="图片">面板内容</FluereTabViewItem>
</FluereTabView>
第 1 个选项卡的内容。切换时上一个面板会被卸载(等价 WinUI 只有一个 ContentPresenter)。
基础形态:图标 + 文本 + 面板

版本与证据口径

项值
规格源 tagwinui3/release/2.5.1(commit ba3a8d5)
关键文件src/controls/dev/TabView/{TabView.xaml,TabView_themeresources.xaml,TabView.idl,TabView.cpp,TabViewItem.cpp,TabViewListView.cpp,TabViewAutomationPeer.cpp,TabViewItemAutomationPeer.cpp} + Generated/{TabView,TabViewItem}.properties.cpp + Strings/{en-us,zh-CN}/Resources.resw
内建文案关闭 / 新建 / 滚动 / 播报共 7 条逐字取自上游 Resources.resw(见 packages/ui/src/tab-view/locale.ts 的对照表)

perf2026 不采纳:上游存在 TabView_perf2026.xaml(已挂进 TabView.vcxitems:43),本库沿用 0.5.1 TitleBar 的裁定 —— 不读、不参考、不产生对应分支。

不存在 TabViewItem.idl:TabViewItem / TabViewItemTemplateSettings / TabViewListView / 两个 AutomationPeer 的公开 API 全部写在 TabView.idl:203-285 里。

模板与几何

结构内容
根两行:Auto(tab 条)/ *(内容区)
tab 条四列Auto MinWidth=2(TabStripHeader)| Auto(tab 条)| Auto(新建按钮,容器 Padding 3,0,0,3、底部对齐)| *(TabStripFooter),四列下方一条 1px 底边线
顶部留白tab 条上方 8px 来自 TabView.Padding = TabViewHeaderPadding(0,8,0,0)(经 TabListView.Padding 传到 ItemsPresenter),不是 margin
tab 项三列IconBox(Auto) | ContentPresenter(*) | CloseButton(Auto);ContentPresenter 原文没有 Margin,两侧间距全部来自 TabContainer.Padding
底边线TabViewBorderBrush(CardStrokeColorDefault,1px)
选中指示条SelectedBackgroundPath(顶部圆角 8、左右各外扩 4px、底部两侧是半径 4 的内凹圆角)+ 两个 4×4 弧块(Fill=TabViewBorderBrush),三者都 x:Load="False",选中时才 Visible

几何值逐条来自 TabView_themeresources.xaml:239-269:

量值
tab 最小高度32(TabViewItemMinHeight)
tab 最小 / 最大宽度100 / 240(只在 Equal 模式经代码生效)
tab 内边距8,3,4,3(关闭按钮可见)/ 8,3,8,3(不可见)
header 字号 / 图标盒12 / 16(图标间距 0,0,10,0)
关闭按钮32×24、圆角 4、字号 12、左边距 4
新建按钮32×24、圆角 4、字号 12、容器 3,0,0,3
滚动按钮32×24、字形字号 8;容器 8,0,3,3 / 3,0,8,3
分隔线1px、Margin 0,8,0,8
边框1;选中态 1,1,1,0 + Margin -1,0,-1,1
圆角选中背景顶角 OverlayCornerRadius(8)、三个按钮 ControlCornerRadius(4)

键盘(逐条对齐源码)

TabView 没有 OnKeyDown,只有三个 KeyboardAccelerator(作用域 = 该 TabView,焦点在内容区里也生效)+ 新建按钮的一条 KeyDown;方向键在 TabViewItem 级。

触发键前置条件效果
Ctrl+F4选中项存在且 IsClosable抛 tabCloseRequested(source: 'keyboard');不删除 tab
Ctrl+Tab—向后环形换选中(跳过禁用项):MoveSelection(true)
Ctrl+Shift+Tab—向前环形换选中:MoveSelection(false)
← / →(焦点在 tab 条内)不含 Alt / Ctrl / Meta在焦点环上移动焦点 —— tab1 → tab1 关闭按钮 → tab2 → … → 新建按钮 → 回到 tab1;不改选中
Space / Enter焦点在 tab 上选中该 tab(底座 TabsTrigger 的键位)

源码里没有的键(负面证据,不要按直觉补):Ctrl+T / Ctrl+W / Ctrl+1..9 / Home / End / Delete / Escape。它们属宿主应用职责(WinUI Gallery / Terminal 各自绑定)。

浏览器限制:Ctrl+Tab / Ctrl+F4 在 Chrome / Edge 里是「切换 / 关闭浏览器标签页」的保留快捷键,页面可能收不到 keydown;组件照实现,可用性取决于宿主环境。

选择与关闭语义

  • 焦点移动不改选中(SingleSelectionFollowsFocus=False):方向键只挪焦点,Ctrl+Tab / 点击 / Space / Enter 才改选中。
  • 组件绝不自行删除 tab:关闭按钮与 Ctrl+F4 都只抛 tabCloseRequested(对齐 TabView.TabCloseRequested,TabViewItem.CloseRequested 合并进同一个事件)。删不删、删完选谁由消费方决定。
  • 删掉当前选中项时自动迁移:按 TabView::OnItemsChanged 的规则 —— start = min(被移除下标, 剩余数 - 1),从该位置向后环形找第一个可用项;一圈都没有就不选中任何 tab。在受控模式下这个结果通过 update:selectedIndex 抛回。
  • 按身份跟随:给子项 :key(或 value)后,在选中项之前插入 / 重排不会改变选中项(等价 WinUI 的 SelectedItem);没给稳定标识时退回下标语义。
  • 焦点归还:切换 tab 时若焦点原本在旧面板里(面板会被卸载),焦点会送进新面板的第一个可聚焦元素;面板里没有可聚焦元素时回落到该 tab 本身(对齐 UpdateTabContent)。
  • 新增按钮只抛事件,并额外用 aria-live 播报「已添加新选项卡。」(对应上游的 UIA ItemAdded 通知)。
第 1 个选项卡的内容。切换时上一个面板会被卸载(等价 WinUI 只有一个 ContentPresenter)。
还没有触发事件关闭按钮与新建按钮都只抛事件:删不删、删完选谁由消费方决定。删掉当前选中项时,组件按 WinUI 的规则向后环形找第一个可用项。
关闭与新建:数据由消费方管理

宽度模式(tabWidthMode)

值WinUI语义
equal(缺省)Equal = 0每项宽度 = clamp(可用宽 / tab 数, 100, 240)(代码测量,见组件注释)
size-to-contentSizeToContent=1宽度由内容决定,不设 item 宽
compactCompact = 2同 size-to-content,但未被选中的 tab 折叠成只剩图标(选中项强制恢复)
选项卡一
Equal 下每个 tab 的宽度是 clamp(可用宽 / tab 数, 100, 240);Compact 只折叠未被选中的 tab,选中项强制恢复图标加文本。
三种宽度模式对照

溢出滚动

放不下时 tab 先被压到下限、再溢出:equal 模式(缺省)的单 tab 宽是 clamp(可用宽 / tab 数, 100, 240),所以空间不够时先压到 TabViewItemMinWidth = 100;一旦真的溢出(scrollWidth - clientWidth > 0.5),tab 条两侧出现滚动按钮,步长 50px,贴左禁止「向后」、贴右禁止「向前」(判定阈值 0.1),并且按住即连续滚动:按下立刻滚一次,50ms 后每 100ms 重复一次(对齐上游 RepeatButton 的 Delay="50" Interval="100",TabView.xaml:158,165)。

溢出时不画滚动条:上游 TabScrollViewerStyle 的模板(TabView.xaml:133-173)里没有滚动条部件,只有 ScrollContentPresenter 与左右两个箭头容器 —— ScrollViewer.ComputedHorizontalScrollBarVisibility 只绑给这两个容器的 Visibility(:157,163),也就是说它只决定「箭头出不出现」。本库照此实现:overflow-x: auto 保留原生滚动(滚轮 / Shift + 滚轮 / 触摸 / 横向滚轮增量),再用 scrollbar-width: none + ::-webkit-scrollbar 不画原生滚动条。于是 tab 高恒为 32、tab 条行高恒为 40(8 + 32):经典滚动条会给行加一条 ≈ 15px 的高度(有头 Chromium 实测 40 → 55),既撑高 tab 条又挤压内容。

两个按钮 tabindex="-1" + aria-hidden="true" —— 不进 Tab 序、对读屏隐藏,与 WinUI 的 IsTabStop=False + AccessibilityView=Raw 一致。隐藏滚动条后鼠标侧只剩箭头与滚轮(WinUI 的 tab 条本来就没有滚动条,因此这是对齐而不是让步)。

已知注意事项:在 FluereScrollView 内部(如本文档站),竖向滚轮按「最近的可滚容器独占」规则会被外层接管 —— 指针停在 tab 条上时 Shift + 滚轮不会滚动 tab 条(横向滚轮增量 / 触摸板横向滑动不受影响)。独立的页面里 Shift + 滚轮可正常横向滚动 tab 条(最小复现已验证)。这是滚动链归属的既有行为,不是滚动条隐藏引入的。

第 1 个选项卡的内容。切换时上一个面板会被卸载(等价 WinUI 只有一个 ContentPresenter)。
空间不够时先把每个 tab 压到下限 100px,再在两侧出现滚动按钮(步长 50px、贴边禁用、按住连续滚动):它们不进 Tab 序、对读屏隐藏。溢出时不画滚动条(上游模板里没有滚动条部件),tab 条高度恒定。
十个 tab 挤在 480px 里

状态与关闭按钮

状态触发表现(token)
rest—底 --layerOnMicaBaseAltFillColorTransparent(透明)、字 --colorNeutralForeground2
hover指针悬停(未选中 / 未禁用)底 --layerOnMicaBaseAltFillColorSecondary;右侧分隔线隐藏
pressed指针按下(未选中 / 未禁用)底 --layerOnMicaBaseAltFillColorDefault、字 --colorNeutralForeground3
selected选中底 --solidBackgroundFillColorTertiary + 顶角 8 + 1px --colorNeutralStrokeAlpha 描边 + 字 --colorNeutralForeground1 + SemiBold
disableddisabled字 --colorNeutralForegroundDisabled、不可点、不进焦点环
focus键盘聚焦useFocusVisual 双层焦点矩形(外 2px + 内 1px,等价 FocusVisualMargin = -3)
关闭按钮 hover/pressed指针悬停 / 按下关闭按钮底 --subtleFillColorSecondary / --subtleFillColorTertiary,字 --colorNeutralForeground1
新建按钮 hover/pressed指针悬停 / 按下同上

所有状态都是瞬时切换:上游 TabViewItem 的 hover / pressed / selected / disabled 全部是 VisualState.Setters,且所有 GoToState(..., false),文件里没有任何 EasingFunction。因此本组件不写任何 transition。

closeButtonOverlayMode 三档:auto(缺省)与 always 走同一个 default: 分支(总是显示),只有 on-pointer-over 是「悬停或选中才显示」—— 这是源码字面行为,不要按直觉把 auto 当成智能判断。

悬停 / 按下 / 选中 / 禁用四态的取值逐条来自 TabViewItem 的 VisualState;关闭按钮的叠加模式里 Auto 与 Always 行为相同(上游同一个 default 分支),只有 on-pointer-over 是「悬停或选中才显示」。
悬停 / 按下 / 选中 / 禁用四态的取值逐条来自 TabViewItem 的 VisualState;关闭按钮的叠加模式里 Auto 与 Always 行为相同(上游同一个 default 分支),只有 on-pointer-over 是「悬停或选中才显示」。
可关闭性、禁用与 on-pointer-over

与 WinUI 的差异(逐条)

  1. 子项是「数据型子组件」:FluereTabViewItem 不会挂载(父组件在渲染期读它的 props / slots),因此它不能 emit;TabViewItem.CloseRequested 合并进根组件的 tabCloseRequested。子项插槽不支持作用域参数(#header="{ … }" 拿不到值)。
  2. 子项必须是 FluereTabView 的直接子元素(可以包在 v-for / <template> / v-if 里)。套了额外的原生元素或自定义组件时它会被忽略,并在开发期打印一条警告。
  3. 没有 TabItemsSource / TabItemTemplate / 虚拟化:Web 侧用 v-for + 子组件即可。
  4. 没有 AddTabButtonCommand:只抛 addTabButtonClick。
  5. disabled 属性会被底座写成 disabled="false":浏览器与 AT 都忽略非表单元素上的该属性,禁用语义由 aria-disabled 表达(样式一律用 [data-disabled])。
  6. header 超出时省略号:WinUI 的 ContentPresenter 没设 TextTrimming(会硬裁),Web 侧用 text-overflow: ellipsis 更可读 —— 这是唯一一处主动的排版差异。
  7. 选中背景的 4×4 内凹圆角用 CSS mask 表达:上游是运行时生成的路径 + 两个弧块;等价几何,但不是逐像素一致(见「证据缺口」)。
  8. TabViewBorderBrush 的映射:上游 Light / Default 下它是指向 CardStrokeColorDefault(一个 Color)的别名,隐式转换路径无法从源码证实;本库按同值处理并沿用仓库既有映射 --colorNeutralStrokeAlpha(InfoBar / ContentDialog 用的就是它)。
  9. 不做:拖拽重排、tear-out 撕出、中键关闭、TabStripDragOver / TabStripDrop / TabDrag* 事件、ItemContainerTransitions(其时长 / 关键帧不在控件目录内)、高对比度色板(只保留 forced-colors 焦点环兜底)。

API

FluereTabView

属性(Props)类型默认对应 / 说明
selectedIndexnumber—v-model:selected-index;传了即受控,不传为非受控
defaultSelectedIndexnumber0非受控初始项(对齐 WinUI SelectedIndex 默认 0)
tabWidthMode'equal' | 'size-to-content' | 'compact''equal'TabWidthMode
closeButtonOverlayMode'auto' | 'on-pointer-over' | 'always''auto'CloseButtonOverlayMode;auto 与 always 同行为
isAddTabButtonVisiblebooleantrueIsAddTabButtonVisible
labelstring—tab 条的可访问名(aria-label)。上游 peer 无名称回退,建议必填
dir'ltr' | 'rtl'—只影响方向键的前进方向判定(FlowDirection)
motion'auto' | 'none''auto'只作用于滚动按钮的平滑滚动;auto 尊重 prefers-reduced-motion
localestring—内建文案 locale,优先于 FluereConfigProvider
事件(Emits)载荷说明
update:selectedIndexnumber选中变化;受控模式下含「迁移后的结果」
tabCloseRequested{ index, header, identity, source }关闭请求(source 为 'close-button' / 'keyboard');组件不删数据
addTabButtonClickMouseEvent新建按钮点击;组件不新增 tab
插槽(Slots)说明
defaultFluereTabViewItem 列表(必须是直接子元素)
tab-strip-header / tab-strip-footertab 条左右两端的自定义内容(TabStripHeader / TabStripFooter)
add新建按钮的图标(默认是 Add 字形)

FluereTabViewItem

属性(Props)类型默认对应 / 说明
headerstring''Header;#header 插槽优先于它
isClosablebooleantrueIsClosable(不是 IsTabClosable)
disabledbooleanfalseControl.IsEnabled = false
ariaLabelstring—AutomationProperties.Name;缺省时 AT 从 header 文本取名(对齐 peer)
valuestring | number—稳定标识(等价 :key),用于「按身份跟随选中」
插槽(Slots)说明
default(content)面板内容;非选中时会被卸载
header富文本标题(优先于 header 文本)
icon图标(16×16 盒)

浏览器实测(可复现)

Playwright + 系统 Chrome(1280×900 / DPR 1)在文档站本页实测:基础形态用 temp/pw-tab-view.mjs(53 条断言),溢出的滚动条 / 长按滚动专项用 temp/pw-tab-view-overflow.mjs(21 条断言,必须有头 —— 无头 / overlay 滚动条环境复现不出那 15px):

量实测
tab 条高 / 单 tab 高40(8px 顶部留白 + 32 + 行内 1px 底边线)/ 32
tab padding / header 字号3px 4px 3px 8px(关闭按钮可见,见下)/ 12px
Equal 单 tab 宽行宽 718 − 左内容 2 − 新建按钮区 35 − 右内容 0 = 681 ⇒ 227(3 项);7 项时仍按可用宽均分,溢出时落到下限 100
选中态伪元素填充 rgb(249, 249, 249)、顶角 8px、四边 -1px -4px 0px -4px、字重 600、border-width: 1px 1px 0px、margin: 0px -1px、z-index: 1
未选中 hoverrgba(0, 0, 0, 0.04)(令牌 rgba(0, 0, 0, 0.039),Chrome 序列化成 0.04)
焦点矩形tab 上 outline: 2px solid rgb(0,0,0) + outline-offset: 1px + box-shadow: rgb(255,255,255) 0 0 0 1px;roving 下聚焦项 tabindex=0
Ctrl+Tab选中 0 → 1,焦点仍停在 tab 条(只改选中、不动焦点)
溢出(480px / 10 个 tab)单 tab 100;两个滚动按钮可见且 aria-hidden=true;点「向前」scrollLeft +50;到右端后「向前」disabled、「向后」可用;按住「向前」会连续滚动(按下即滚一次,50ms 后每 100ms 一次,到右端自停)
溢出时不画滚动条(有头 Chromium,经典滚动条)修前 scroller.offsetHeight - clientHeight = 15、.fui-tab-view__strip-row 55、单 tab 仍 32;隐藏原生滚动条后行高 40、offsetHeight - clientHeight = 0、tab 32,且 scrollWidth (998) > clientWidth (355)、scrollBy(50) / Shift + 滚轮 仍生效 —— 测量方法:chromium.launch({ headless: false })(overlay 滚动条环境复现不出该 15px,必须用经典滚动条)
宽度模式size-to-content 宽度小于 Equal(按内容收紧);compact 未选中项 header display: none、图标盒 16、选中项仍显示文本
可关闭性 / 叠加模式isClosable=false 无关闭按钮;disabled 项 aria-disabled=true;on-pointer-over 未悬停时无按钮、悬停后出现
关闭链路点选中项后 Ctrl+F4 ⇒ 消费方删掉该项(3 → 2),且仍有选中项(向后环形迁移)
面板只有选中面板有内容,其余带 hidden
暗色选中填充 rgb(40, 40, 40)、前景 rgb(255, 255, 255)、未选中透明

实测抓到的两个真 bug(数值断言当时全绿,只有浏览器暴露): ① 模板里的 kebab-case 属性(:is-closable="false")在原始 vnode props 里的键就是 is-closable,只认 camelCase 会把 false 当「没传」⇒ 关闭按钮照常渲染(见 collection.ts#readProp); ② 选中背景是定位伪元素,按绘制次序会盖住常规流里的图标与文本 ⇒ 选中项整片纯色(现已把内容提升到同一层级,elementFromPoint 断言守住)。

实现要点与证据缺口

  • 子项收集在渲染期完成(collection.ts):provide/inject 注册式收集要等后代 setup(),SSR 直出会缺整条 tab 条;这里在父组件渲染期直接读子 vnode 的 props / slots(等价 React 的 cloneElement),一次渲染得到完整列表。
  • 选中解析是纯函数(resolveSelectedIndex):渲染期不写响应式状态,SSR 与水合一致;迁移结果在 onUpdated 里回填 / 抛回。
  • Equal 宽度在代码里量:上游 UpdateTabWidths 也是这么做的(CSS 无法表达:scroller 宽度由内容决定,flex: 1 1 0 会形成循环依赖)。量到之前 CSS 退回 inline-size: auto(仍受 min / max 夹持)。
  • 证据缺口:① 选中背景的路径真值由 StringCchPrintf + XamlReader::Load 运行时生成,仓库里没有可对照的实例字符串;② 三个 ThemeTransition(AddDeleteThemeTransition 等)与拖拽主题动画的时长 / 关键帧不在控件目录内;③ 高对比度色板全部来自系统运行时;④ TabViewBorderBrush 的 Color → Brush 隐式转换未证实;⑤ 上游键盘测试 TabViewTests.KeyboardTest 被标记 Ignore(#7546),Space 一类语义只能当参考。
v0.5.0-alpha.1