From 4ff1da45302c962562bff59b77244dd7bbf75c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8E?= Date: Sat, 25 Jul 2026 01:51:53 +0800 Subject: [PATCH 1/2] feat(tab-bar): implement liquid glass effect and capsule theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add the public effect="glass" API and the capsule theme - render layered glass materials and a shared selection indicator while preserving existing TabBar behavior - generate superellipse displacement and specular textures for lifecycle-managed SVG filters - provide a readable CSS fallback for unsupported browsers, SSR, Canvas failures, and runtime capability changes - synchronize pointer press feedback across the capsule, icons, and labels - support multiple instances, runtime effect switching, resource cleanup, and reduced-motion preferences - add the Liquid Glass demo, documentation, runtime tests, interaction tests, and displacement coverage - exclude demos and tests from production builds and verify the shared common submodule integrity feat(tab-bar): 实现液态玻璃效果与胶囊主题 - 新增公开的 effect="glass" API 和 capsule 主题 - 渲染分层玻璃材质与共享选中指示器,同时保持现有 TabBar 行为不变 - 生成超椭圆位移与高光纹理,并管理 SVG Filter 的完整生命周期 - 为不支持的浏览器、SSR、Canvas 失败和运行时能力变化提供可读的 CSS 降级效果 - 为胶囊、图标和文字提供同步的指针按压反馈 - 支持多实例、运行时效果切换、资源清理和 reduced-motion 偏好 - 补充 Liquid Glass 示例、组件文档、运行时测试、交互测试和位移效果测试 - 从生产构建中排除 Demo 与测试文件,并校验 common 子模块完整性 --- package.json | 1 + scripts/rollup.config.js | 5 +- src/_common | 2 +- .../__test__/__snapshots__/demo.test.jsx.snap | 777 +++++++++++++++++- src/tab-bar/__test__/demo.test.jsx | 2 + src/tab-bar/__test__/index.test.jsx | 123 ++- .../liquid-glass-displacement.test.ts | 86 ++ src/tab-bar/__test__/liquid-glass-map.test.ts | 146 ++++ src/tab-bar/__test__/liquid-glass.test.tsx | 421 ++++++++++ src/tab-bar/demos/glass.vue | 75 ++ src/tab-bar/demos/mobile.vue | 6 +- src/tab-bar/liquid-glass-map.ts | 219 +++++ src/tab-bar/props.ts | 13 +- src/tab-bar/tab-bar-item.tsx | 69 +- src/tab-bar/tab-bar.en-US.md | 11 +- src/tab-bar/tab-bar.md | 11 +- src/tab-bar/tab-bar.tsx | 169 +++- src/tab-bar/type.ts | 11 +- src/tab-bar/useTabBarGlassFilter.ts | 297 +++++++ tsconfig.build.json | 14 +- 20 files changed, 2426 insertions(+), 32 deletions(-) create mode 100644 src/tab-bar/__test__/liquid-glass-displacement.test.ts create mode 100644 src/tab-bar/__test__/liquid-glass-map.test.ts create mode 100644 src/tab-bar/__test__/liquid-glass.test.tsx create mode 100644 src/tab-bar/demos/glass.vue create mode 100644 src/tab-bar/liquid-glass-map.ts create mode 100644 src/tab-bar/useTabBarGlassFilter.ts diff --git a/package.json b/package.json index 0a2ffc99d..f09320784 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "scripts": { "init": "git submodule init && git submodule update", + "measure:tab-bar-glass": "vitest run src/tab-bar/__test__/liquid-glass-displacement.test.ts --reporter=verbose", "start": "npm run dev", "prepare": "husky", "dev": "cd site && cross-env NODE_ENV=development vite", diff --git a/scripts/rollup.config.js b/scripts/rollup.config.js index 19e036893..bd6746c7c 100644 --- a/scripts/rollup.config.js +++ b/scripts/rollup.config.js @@ -35,9 +35,10 @@ const inputList = [ 'src/**/*.ts', 'src/**/*.vue', 'src/**/*.tsx', - '!src/**/demos', + '!src/**/demos/**', '!src/**/*.d.ts', - '!src/**/__tests__', + '!src/**/__test__/**', + '!src/**/__tests__/**', ]; const getPlugins = ({ diff --git a/src/_common b/src/_common index 87824d0d2..ecff38ac3 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit 87824d0d280408303e350f3b7ec7736ae72728c6 +Subproject commit ecff38ac3ecb874704932ea7e9b40cca5e4b556c diff --git a/src/tab-bar/__test__/__snapshots__/demo.test.jsx.snap b/src/tab-bar/__test__/__snapshots__/demo.test.jsx.snap index 423e6c48c..94aea9a05 100644 --- a/src/tab-bar/__test__/__snapshots__/demo.test.jsx.snap +++ b/src/tab-bar/__test__/__snapshots__/demo.test.jsx.snap @@ -904,6 +904,382 @@ exports[`TabBar > TabBar customVue demo works fine 1`] = ` `; +exports[`TabBar > TabBar glassVue demo works fine 1`] = ` +
+
+
+ +
+ +
+
+ +
+ +
+`; + exports[`TabBar > TabBar mobileVue demo works fine 1`] = `
TabBar mobileVue demo works fine 1`] = `

- 03 自定义 + 03 Liquid Glass +

+

+ 悬浮玻璃材质标签栏 +

+
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+

+ 04 自定义

{ + describe('effect contract', () => { + it('uses normal as the default effect', () => { + expect(TabBar.props.effect.default).toBe('normal'); + }); + + it('accepts normal and glass effects', () => { + expect(TabBar.props.effect.validator('normal')).toBe(true); + expect(TabBar.props.effect.validator('glass')).toBe(true); + }); + + it('rejects unsupported effects', () => { + expect(TabBar.props.effect.validator('other')).toBe(false); + }); + }); + describe('props', () => { + it('moves one shared glass selection capsule between round items', async () => { + const value = ref('1'); + const wrapper = mount({ + render: () => ( + + {list.map((item) => ( + {item.text} + ))} + + ), + }); + + const indicator = wrapper.get('.t-tab-bar__selection-indicator'); + expect(wrapper.findAll('.t-tab-bar__selection-indicator')).toHaveLength(1); + expect(indicator.element.style.width).toBe(`${100 / list.length}%`); + expect(indicator.element.style.transform).toBe('translate3d(0%, 0, 0)'); + + await wrapper.get('[name="label_2"] > .t-tab-bar-item__content').trigger('click'); + expect(indicator.element.style.transform).toBe('translate3d(100%, 0, 0)'); + }); + + it.each([2, 3, 4, 5])('keeps the shared capsule aligned with %s items', async (itemCount) => { + const value = ref('item-0'); + const items = Array.from({ length: itemCount }, (_, index) => ({ + name: `item-${index}`, + value: `item-${index}`, + })); + const wrapper = mount({ + render: () => ( + + {items.map((item) => ( + {item.value} + ))} + + ), + }); + const indicator = wrapper.get('.t-tab-bar__selection-indicator'); + + expect(indicator.element.style.width).toBe(`${100 / itemCount}%`); + await wrapper.get(`[name="item-${itemCount - 1}"] > .t-tab-bar-item__content`).trigger('click'); + expect(indicator.element.style.transform).toBe(`translate3d(${(itemCount - 1) * 100}%, 0, 0)`); + }); + + it('scales the pressed item and shared capsule until the press ends', async () => { + const value = ref('1'); + const wrapper = mount({ + render: () => ( + + {list.map((item) => ( + {item.text} + ))} + + ), + }); + + const indicator = wrapper.get('.t-tab-bar__selection-indicator'); + const firstItem = wrapper.get('[name="label_1"] > .t-tab-bar-item__content'); + const secondItem = wrapper.get('[name="label_2"] > .t-tab-bar-item__content'); + + await secondItem.trigger('pointerdown', { button: 0 }); + expect(firstItem.classes()).not.toContain('t-tab-bar-item__content--checked'); + expect(secondItem.classes()).toContain('t-tab-bar-item__content--checked'); + expect(secondItem.classes()).toContain('t-tab-bar-item__content--pressed'); + expect(indicator.classes()).toContain('t-tab-bar__selection-indicator--pressed'); + expect(indicator.element.style.transform).toBe('translate3d(100%, 0, 0)'); + + await secondItem.trigger('pointercancel'); + expect(firstItem.classes()).toContain('t-tab-bar-item__content--checked'); + expect(secondItem.classes()).not.toContain('t-tab-bar-item__content--checked'); + expect(secondItem.classes()).not.toContain('t-tab-bar-item__content--pressed'); + expect(indicator.classes()).not.toContain('t-tab-bar__selection-indicator--pressed'); + expect(indicator.element.style.transform).toBe('translate3d(0%, 0, 0)'); + }); + + it.each([ + ['glass normal capsule', { effect: 'glass', shape: 'normal', theme: 'capsule' }], + ['glass round tag', { effect: 'glass', shape: 'round', theme: 'tag' }], + ['normal round tag', { effect: 'normal', shape: 'round', theme: 'tag' }], + ])('does not render the shared capsule in %s mode', (_, tabBarProps) => { + const wrapper = mount({ + render: () => ( + + {list.map((item) => ( + {item.text} + ))} + + ), + }); + + expect(wrapper.find('.t-tab-bar__selection-track').exists()).toBe(false); + }); + + it('renders the shared capsule in normal round capsule mode', () => { + const wrapper = mount({ + render: () => ( + + {list.map((item) => ( + {item.text} + ))} + + ), + }); + + expect(wrapper.findAll('.t-tab-bar__selection-indicator')).toHaveLength(1); + }); + it('bordered', async () => { const wrapper = mount(TabBar, { shallow: true, diff --git a/src/tab-bar/__test__/liquid-glass-displacement.test.ts b/src/tab-bar/__test__/liquid-glass-displacement.test.ts new file mode 100644 index 000000000..26dbc644d --- /dev/null +++ b/src/tab-bar/__test__/liquid-glass-displacement.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_TAB_BAR_GLASS_TUNING, TabBarGlassTextures, createTabBarGlassTextures } from '../liquid-glass-map'; + +const SVG_CHANNEL_MIDPOINT = 0.5; +const CHANNEL_MAX = 255; + +interface DisplacementMetrics { + width: number; + dpr: number; + edgeDisplacement: number; + centerDrift: number; + disabledDifference: number; +} + +function channelOffset(channel: number, scale: number) { + return (channel / CHANNEL_MAX - SVG_CHANNEL_MIDPOINT) * scale; +} + +function measureTexture(textures: TabBarGlassTextures, cssWidth: number): DisplacementMetrics { + let edgeDisplacement = 0; + let centerDrift = 0; + + for (let offset = 0; offset < textures.displacement.length; offset += 4) { + if (!textures.displacement[offset + 3]) continue; + + const red = textures.displacement[offset]; + const green = textures.displacement[offset + 1]; + const displacement = Math.hypot( + channelOffset(red, textures.displacementScale), + channelOffset(green, textures.displacementScale), + ); + const isNeutralCenter = red === 128 && green === 128; + + if (isNeutralCenter) centerDrift = Math.max(centerDrift, displacement); + else edgeDisplacement = Math.max(edgeDisplacement, displacement); + } + + return { + width: cssWidth, + dpr: textures.dpr, + edgeDisplacement, + centerDrift, + disabledDifference: edgeDisplacement, + }; +} + +function createMetrics(width: number, dpr: number) { + const height = 64; + const textures = createTabBarGlassTextures({ + width, + height, + radius: height / 2, + dpr, + tuning: DEFAULT_TAB_BAR_GLASS_TUNING, + }); + + if (!textures) throw new Error(`Failed to build ${width}px DPR${dpr} displacement texture.`); + return measureTexture(textures, width); +} + +describe('TabBar Liquid Glass displacement acceptance', () => { + it('quantifies edge displacement, center stability, and the disabled A/B difference', () => { + const metrics = [320, 390, 430, 620].flatMap((width) => [1, 2].map((dpr) => createMetrics(width, dpr))); + + metrics.forEach((entry) => { + expect(entry.edgeDisplacement).toBeGreaterThanOrEqual(6); + expect(entry.edgeDisplacement).toBeLessThanOrEqual(12); + expect(entry.centerDrift).toBeLessThanOrEqual(1); + expect(entry.disabledDifference).toBeGreaterThanOrEqual(6); + }); + + process.stdout.write( + `${JSON.stringify( + metrics.map((entry) => ({ + width: entry.width, + dpr: entry.dpr, + edgeDisplacement: Number(entry.edgeDisplacement.toFixed(3)), + centerDrift: Number(entry.centerDrift.toFixed(3)), + disabledDifference: Number(entry.disabledDifference.toFixed(3)), + })), + null, + 2, + )}\n`, + ); + }); +}); diff --git a/src/tab-bar/__test__/liquid-glass-map.test.ts b/src/tab-bar/__test__/liquid-glass-map.test.ts new file mode 100644 index 000000000..00d97e832 --- /dev/null +++ b/src/tab-bar/__test__/liquid-glass-map.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + createRefractionProfile, + createTabBarGlassTextures, + DEFAULT_TAB_BAR_GLASS_TUNING, + MAX_TAB_BAR_GLASS_DPR, + MAX_TAB_BAR_GLASS_TEXTURE_PIXELS, +} from '../liquid-glass-map'; + +const getPixel = (data: Uint8ClampedArray, width: number, x: number, y: number) => { + const offset = (y * width + x) * 4; + return Array.from(data.slice(offset, offset + 4)); +}; + +const createFixture = () => { + const textures = createTabBarGlassTextures({ width: 100, height: 40, radius: 20, dpr: 1 }); + if (!textures) throw new Error('Expected texture fixture'); + return textures; +}; + +describe('createRefractionProfile', () => { + it('is deterministic and neutral at an index of refraction of one', () => { + const neutral = createRefractionProfile({ surface: 'squircle', thicknessRatio: 1, refractiveIndex: 1 }); + + expect(createRefractionProfile({ surface: 'squircle', thicknessRatio: 1, refractiveIndex: 1.5 })).toEqual( + createRefractionProfile({ surface: 'squircle', thicknessRatio: 1, refractiveIndex: 1.5 }), + ); + expect(Math.max(...neutral.map(Math.abs))).toBeLessThan(0.000001); + }); + + it('increases profile magnitude when thickness increases', () => { + const thin = createRefractionProfile({ surface: 'squircle', thicknessRatio: 0.2, refractiveIndex: 1.5 }); + const thick = createRefractionProfile({ surface: 'squircle', thicknessRatio: 1.2, refractiveIndex: 1.5 }); + + expect(Math.max(...thick.map(Math.abs))).toBeGreaterThan(Math.max(...thin.map(Math.abs))); + }); +}); + +describe('createTabBarGlassTextures', () => { + it('keeps explicit defaults byte-identical to implicit defaults', () => { + expect( + createTabBarGlassTextures({ width: 100, height: 40, radius: 20, dpr: 1, tuning: DEFAULT_TAB_BAR_GLASS_TUNING }), + ).toEqual(createFixture()); + }); + + it('keeps the center neutral and limits displacement to the bezel', () => { + const textures = createFixture(); + + expect(getPixel(textures.displacement, textures.width, 50, 20)).toEqual([128, 128, 0, 255]); + expect(getPixel(textures.displacement, textures.width, 50, 1)[1]).not.toBe(128); + }); + + it('maps the full bezel ratio range to the radius without early saturation', () => { + const ratios = [0.5, 0.8, 0.9, 1]; + const textures = ratios.map((bezelRatio) => + createTabBarGlassTextures({ width: 100, height: 40, radius: 20, dpr: 1, tuning: { bezelRatio } }), + ); + + expect(textures.map((texture) => texture?.bezelWidth)).toEqual([10, 16, 18, 20]); + expect(new Set(textures.map((texture) => Array.from(texture?.displacement ?? []).join(','))).size).toBe( + ratios.length, + ); + }); + + it('fades continuously to a neutral center at a full-width bezel', () => { + const textures = createTabBarGlassTextures({ + width: 100, + height: 40, + radius: 20, + dpr: 1, + tuning: { bezelRatio: 1 }, + }); + if (!textures) throw new Error('Expected full-width bezel fixture'); + + const innerPixels = [16, 17, 18, 19, 20].map((y) => getPixel(textures.displacement, textures.width, 50, y)); + const adjacentDeltas = innerPixels.slice(1).map((pixel, index) => Math.abs(pixel[1] - innerPixels[index][1])); + + expect(Math.max(...adjacentDeltas)).toBeLessThanOrEqual(2); + expect(innerPixels.at(-1)).toEqual([128, 128, 0, 255]); + }); + + it('encodes opposite directions on opposing bezel edges', () => { + const textures = createFixture(); + + expect(getPixel(textures.displacement, textures.width, 1, 20)[0]).not.toBe( + getPixel(textures.displacement, textures.width, 98, 20)[0], + ); + expect(getPixel(textures.displacement, textures.width, 50, 1)[1]).not.toBe( + getPixel(textures.displacement, textures.width, 50, 38)[1], + ); + }); + + it('changes geometry with the physical calibration values', () => { + const base = createFixture(); + const varied = createTabBarGlassTextures({ + width: 100, + height: 40, + radius: 20, + dpr: 1, + tuning: { thicknessRatio: 1.2, bezelRatio: 0.7, refractiveIndex: 2, displacementGain: 1.5, surface: 'lip' }, + }); + + expect(varied?.bezelWidth).not.toBe(base.bezelWidth); + expect(varied?.displacement).not.toEqual(base.displacement); + }); + + it('keeps specular alpha in the bezel and responds to its direction', () => { + const topLight = createFixture(); + const southEast = createTabBarGlassTextures({ + width: 100, + height: 40, + radius: 20, + dpr: 1, + tuning: { lightAngle: 45 }, + }); + + expect(getPixel(topLight.specular, topLight.width, 50, 20)[3]).toBe(0); + expect(getPixel(topLight.specular, topLight.width, 25, 3)[3]).toBe( + getPixel(topLight.specular, topLight.width, 74, 3)[3], + ); + expect(getPixel(topLight.specular, topLight.width, 50, 2)[3]).toBeGreaterThan( + getPixel(topLight.specular, topLight.width, 50, 37)[3], + ); + expect(southEast?.specular).not.toEqual(topLight.specular); + }); + + it('fades specular continuously into the neutral center', () => { + const textures = createFixture(); + const innerEdgeAlpha = [10, 12, 14, 16, 18].map((y) => getPixel(textures.specular, textures.width, 50, y)[3]); + + expect(innerEdgeAlpha.slice(1).every((alpha, index) => alpha <= innerEdgeAlpha[index])).toBe(true); + expect(innerEdgeAlpha.at(-1)).toBe(0); + expect(innerEdgeAlpha.filter((alpha) => alpha > 0).length).toBeGreaterThanOrEqual(3); + expect(Math.max(...innerEdgeAlpha.slice(1).map((alpha, index) => innerEdgeAlpha[index] - alpha))).toBeLessThan(50); + }); + + it('returns null for zero-sized textures and respects DPR and pixel limits', () => { + expect(createTabBarGlassTextures({ width: 0, height: 40, radius: 20, dpr: 1 })).toBeNull(); + const dpr = createTabBarGlassTextures({ width: 100, height: 40, radius: 20, dpr: 4 }); + const budgeted = createTabBarGlassTextures({ width: 4000, height: 1000, radius: 500, dpr: 2 }); + + expect(dpr?.dpr).toBe(MAX_TAB_BAR_GLASS_DPR); + expect(budgeted?.width && budgeted.height).toBeDefined(); + expect((budgeted?.width ?? 0) * (budgeted?.height ?? 0)).toBeLessThanOrEqual(MAX_TAB_BAR_GLASS_TEXTURE_PIXELS); + }); +}); diff --git a/src/tab-bar/__test__/liquid-glass.test.tsx b/src/tab-bar/__test__/liquid-glass.test.tsx new file mode 100644 index 000000000..a5cc86703 --- /dev/null +++ b/src/tab-bar/__test__/liquid-glass.test.tsx @@ -0,0 +1,421 @@ +import { computed, createSSRApp, h, nextTick, provide, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { renderToString } from 'vue/server-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import TabBar from '../tab-bar'; +import TabBarItem from '../tab-bar-item'; +import { tabBarGlassDevContextKey } from '../useTabBarGlassFilter'; + +interface RuntimeOptions { + canvasContextFails?: boolean; + chromium?: boolean; + includeResizeObserver?: boolean; + toDataURLFails?: boolean; +} + +type AnimationFrameCallback = (timestamp: number) => void; +type ObserverCallback = (entries: unknown[], observer: unknown) => void; + +function installGlassRuntime(options: RuntimeOptions = {}) { + const { canvasContextFails = false, chromium = true, includeResizeObserver = true, toDataURLFails = false } = options; + const frames = new Map(); + const observers: MockResizeObserver[] = []; + let frameId = 0; + let rect = { width: 390, height: 64 }; + + class MockResizeObserver { + callback: ObserverCallback; + + observe = vi.fn(); + + disconnect = vi.fn(); + + constructor(callback: ObserverCallback) { + this.callback = callback; + observers.push(this); + } + + trigger() { + this.callback([], this); + } + } + + function MockImageData(this: ImageData, data: Uint8ClampedArray, width: number, height: number) { + Object.assign(this, { data, width, height }); + } + + const requestAnimationFrame = vi.fn((callback: AnimationFrameCallback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }); + const cancelAnimationFrame = vi.fn((id: number) => frames.delete(id)); + const context = { putImageData: vi.fn() }; + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation(() => (canvasContextFails ? null : (context as unknown as CanvasRenderingContext2D))); + const toDataURL = vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(function toDataURL( + this: HTMLCanvasElement, + ) { + if (toDataURLFails) throw new Error('encoding failed'); + return `data:image/png;base64,${this.width}x${this.height}`; + }); + + vi.stubGlobal('requestAnimationFrame', requestAnimationFrame); + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame); + vi.stubGlobal('ImageData', MockImageData); + vi.stubGlobal('CSS', { supports: vi.fn(() => true) }); + vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + chromium + ? 'Mozilla/5.0 AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36' + : 'Mozilla/5.0 AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15', + ); + if (includeResizeObserver) vi.stubGlobal('ResizeObserver', MockResizeObserver); + else vi.stubGlobal('ResizeObserver', undefined); + + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 1 }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => + ({ + width: rect.width, + height: rect.height, + x: 0, + y: 0, + top: 0, + right: rect.width, + bottom: rect.height, + left: 0, + toJSON: () => ({}), + }) as DOMRect, + ); + + return { + observers, + requestAnimationFrame, + cancelAnimationFrame, + getContext, + toDataURL, + runFrames() { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(performance.now())); + }, + setRect(width: number, height: number) { + rect = { width, height }; + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + document.body.innerHTML = ''; +}); + +describe('TabBar Liquid Glass runtime', () => { + it('keeps the default mode free of glass runtime DOM', () => { + const wrapper = mount(TabBar, { props: { fixed: false } }); + + expect(wrapper.classes()).not.toContain('t-tab-bar--glass'); + expect(wrapper.find('[class*="__glass-"]').exists()).toBe(false); + }); + + it('keeps the original round tag selection without a shared indicator in glass mode', () => { + const wrapper = mount(TabBar, { + props: { effect: 'glass', fixed: false, shape: 'round', theme: 'tag', value: 'home' }, + slots: { + default: () => [ + h(TabBarItem, { value: 'home' }, () => 'Home'), + h(TabBarItem, { value: 'profile' }, () => 'Profile'), + ], + }, + }); + + expect(wrapper.classes()).not.toContain('t-tab-bar--theme-capsule'); + expect(wrapper.find('.t-tab-bar__selection-indicator').exists()).toBe(false); + expect(wrapper.find('.t-tab-bar-item__content--tag.t-tab-bar-item__content--checked').exists()).toBe(true); + }); + + it.each(['normal', 'glass'] as const)('renders the shared capsule indicator with the %s material', (effect) => { + const wrapper = mount(TabBar, { + props: { effect, fixed: false, shape: 'round', theme: 'capsule', value: 'home' }, + slots: { + default: () => [ + h(TabBarItem, { value: 'home' }, () => 'Home'), + h(TabBarItem, { value: 'profile' }, () => 'Profile'), + ], + }, + }); + + expect(wrapper.classes()).toContain('t-tab-bar--theme-capsule'); + expect(wrapper.find('.t-tab-bar__selection-indicator').exists()).toBe(true); + expect(wrapper.find('.t-tab-bar-item__content--capsule.t-tab-bar-item__content--checked').exists()).toBe(true); + }); + + it('mounts glass layers and creates an SVG filter after enhancement succeeds', async () => { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + runtime.runFrames(); + await nextTick(); + + expect(wrapper.find('filter').exists()).toBe(true); + expect(wrapper.find('.t-tab-bar__glass-refraction').attributes('style')).toContain('url'); + }); + + it('renders only the CSS glass baseline during SSR', async () => { + const html = await renderToString(h(TabBar, { effect: 'glass', fixed: false })); + + expect(html).toContain('t-tab-bar--glass'); + expect(html).toContain('t-tab-bar__glass-base'); + expect(html).not.toContain('t-tab-bar__glass-filter'); + expect(html).not.toContain(' { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + runtime.runFrames(); + await nextTick(); + + const filter = wrapper.find('filter').element; + expect(Array.from(filter.children).map((node) => node.tagName)).toEqual([ + 'feGaussianBlur', + 'feImage', + 'feDisplacementMap', + 'feColorMatrix', + 'feImage', + 'feComposite', + 'feComponentTransfer', + 'feBlend', + 'feBlend', + ]); + }); + + it('hydrates the glass baseline without a structure mismatch', async () => { + vi.stubGlobal('ResizeObserver', undefined); + const App = { render: () => h(TabBar, { effect: 'glass', fixed: false }) }; + const html = await renderToString(h(App)); + const container = document.createElement('div'); + container.innerHTML = html; + document.body.append(container); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const app = createSSRApp(App); + app.mount(container); + await nextTick(); + + expect(warning.mock.calls.flat().join(' ')).not.toContain('Hydration'); + app.unmount(); + }); + + it('uses a unique filter ID for each instance', async () => { + const runtime = installGlassRuntime(); + const Host = { + render: () => + h('div', [h(TabBar, { effect: 'glass', fixed: false }), h(TabBar, { effect: 'glass', fixed: false })]), + }; + const wrapper = mount(Host); + runtime.runFrames(); + await nextTick(); + + const ids = wrapper.findAll('filter').map((filter) => filter.attributes('id')); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); + + it('coalesces repeated resize notifications into one rebuild', async () => { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + runtime.runFrames(); + await nextTick(); + runtime.requestAnimationFrame.mockClear(); + runtime.toDataURL.mockClear(); + runtime.setRect(430, 64); + + runtime.observers[0].trigger(); + runtime.observers[0].trigger(); + runtime.observers[0].trigger(); + + expect(runtime.requestAnimationFrame).toHaveBeenCalledTimes(1); + runtime.runFrames(); + await nextTick(); + expect(runtime.toDataURL).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); + + it('rebuilds once when private calibration tuning changes', async () => { + const runtime = installGlassRuntime(); + const tuning = ref({ displacementGain: 1, textureDpr: 1 }); + const onRebuild = vi.fn(); + const Host = { + setup() { + provide(tabBarGlassDevContextKey, { tuning: computed(() => tuning.value), onRebuild }); + return () => h(TabBar, { effect: 'glass', fixed: false }); + }, + }; + const wrapper = mount(Host); + runtime.runFrames(); + await nextTick(); + runtime.requestAnimationFrame.mockClear(); + + tuning.value = { displacementGain: 1.5, textureDpr: 2 }; + await nextTick(); + + expect(runtime.requestAnimationFrame).toHaveBeenCalledTimes(1); + runtime.runFrames(); + await nextTick(); + expect(onRebuild).toHaveBeenCalledTimes(2); + expect(onRebuild.mock.lastCall?.[0]).toMatchObject({ + textureWidth: 780, + textureHeight: 128, + displacementUrl: expect.stringContaining('data:image/png;base64,'), + specularUrl: expect.stringContaining('data:image/png;base64,'), + specularMaxAlpha: expect.any(Number), + specularMeanAlpha: expect.any(Number), + specularCoverage: expect.any(Number), + }); + wrapper.unmount(); + }); + + it('ignores the private calibration channel in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const runtime = installGlassRuntime(); + const onRebuild = vi.fn(); + const Host = { + setup() { + provide(tabBarGlassDevContextKey, { + tuning: computed(() => ({ displacementGain: 0 })), + onRebuild, + shouldEnhance: () => false, + }); + return () => h(TabBar, { effect: 'glass', fixed: false }); + }, + }; + const wrapper = mount(Host); + runtime.runFrames(); + await nextTick(); + + expect(wrapper.find('filter').exists()).toBe(true); + expect(onRebuild).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('rebuilds the texture when shape changes', async () => { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false, shape: 'normal' } }); + runtime.runFrames(); + await nextTick(); + runtime.toDataURL.mockClear(); + + await wrapper.setProps({ shape: 'round' }); + await nextTick(); + runtime.runFrames(); + await nextTick(); + + expect(runtime.toDataURL).toHaveBeenCalledTimes(2); + }); + + it('creates and cleans resources when effect changes', async () => { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'normal', fixed: false } }); + + await wrapper.setProps({ effect: 'glass' }); + await nextTick(); + runtime.runFrames(); + await nextTick(); + expect(wrapper.find('filter').exists()).toBe(true); + const glassObserver = runtime.observers.at(-1); + + await wrapper.setProps({ effect: 'normal' }); + await nextTick(); + expect(wrapper.find('[class*="__glass-"]').exists()).toBe(false); + expect(glassObserver?.disconnect).toHaveBeenCalledTimes(1); + }); + + it('disconnects the observer and cancels a pending frame on unmount', () => { + const runtime = installGlassRuntime(); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + + wrapper.unmount(); + + expect(runtime.observers[0].disconnect).toHaveBeenCalledTimes(1); + expect(runtime.cancelAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it('keeps the CSS fallback when Canvas context creation fails', async () => { + const runtime = installGlassRuntime({ canvasContextFails: true }); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + runtime.runFrames(); + await nextTick(); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + }); + + it('keeps the CSS fallback when texture encoding fails', async () => { + const runtime = installGlassRuntime({ toDataURLFails: true }); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + runtime.runFrames(); + await nextTick(); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + }); + + it('can force the CSS fallback without creating enhancement resources', async () => { + const runtime = installGlassRuntime(); + const tuning = ref({ displacementGain: 1 }); + const Host = { + setup() { + provide(tabBarGlassDevContextKey, { + tuning: computed(() => tuning.value), + shouldEnhance: () => false, + }); + return () => h(TabBar, { effect: 'glass', fixed: false }); + }, + }; + const wrapper = mount(Host); + await nextTick(); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + expect(runtime.requestAnimationFrame).not.toHaveBeenCalled(); + expect(runtime.getContext).not.toHaveBeenCalled(); + expect(runtime.toDataURL).not.toHaveBeenCalled(); + + tuning.value = { displacementGain: 1.5 }; + await nextTick(); + + expect(wrapper.find('filter').exists()).toBe(false); + expect(runtime.requestAnimationFrame).not.toHaveBeenCalled(); + expect(runtime.getContext).not.toHaveBeenCalled(); + expect(runtime.toDataURL).not.toHaveBeenCalled(); + }); + + it('uses the CSS fallback without enhancement work on WebKit', async () => { + const runtime = installGlassRuntime({ chromium: false }); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + await nextTick(); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + expect(runtime.requestAnimationFrame).not.toHaveBeenCalled(); + expect(runtime.getContext).not.toHaveBeenCalled(); + expect(runtime.toDataURL).not.toHaveBeenCalled(); + }); + + it('does not add a global resize listener without ResizeObserver', async () => { + installGlassRuntime({ includeResizeObserver: false }); + const addEventListener = vi.spyOn(window, 'addEventListener'); + const wrapper = mount(TabBar, { props: { effect: 'glass', fixed: false } }); + await nextTick(); + + expect(wrapper.find('.t-tab-bar__glass-base').exists()).toBe(true); + expect(wrapper.find('filter').exists()).toBe(false); + expect(addEventListener).not.toHaveBeenCalledWith('resize', expect.any(Function)); + }); +}); diff --git a/src/tab-bar/demos/glass.vue b/src/tab-bar/demos/glass.vue new file mode 100644 index 000000000..ef4a3143c --- /dev/null +++ b/src/tab-bar/demos/glass.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/tab-bar/demos/mobile.vue b/src/tab-bar/demos/mobile.vue index 032367cb3..2046c1006 100644 --- a/src/tab-bar/demos/mobile.vue +++ b/src/tab-bar/demos/mobile.vue @@ -20,7 +20,10 @@ - + + + +

@@ -33,6 +36,7 @@ import PureIconDemo from './pure-icon.vue'; import TextSpreadDemo from './text-spread.vue'; import BadgePropsDemo from './badge-props.vue'; import RoundPropsDemo from './round.vue'; +import GlassDemo from './glass.vue'; import CustomPropsDemo from './custom.vue'; diff --git a/src/tab-bar/liquid-glass-map.ts b/src/tab-bar/liquid-glass-map.ts new file mode 100644 index 000000000..d07fb6af5 --- /dev/null +++ b/src/tab-bar/liquid-glass-map.ts @@ -0,0 +1,219 @@ +export const MAX_TAB_BAR_GLASS_DPR = 2; +export const MAX_TAB_BAR_GLASS_TEXTURE_PIXELS = 512 * 1024; + +export type TabBarGlassSurface = 'squircle' | 'lip'; + +export interface TabBarGlassTuning { + surface: TabBarGlassSurface; + thicknessRatio: number; + bezelRatio: number; + refractiveIndex: number; + displacementGain: number; + blur: number; + specularOpacity: number; + specularSaturation: number; + lightAngle: number; +} + +export interface TabBarGlassTextureOptions { + width: number; + height: number; + radius: number; + dpr: number; + tuning?: Partial; +} + +export const DEFAULT_TAB_BAR_GLASS_TUNING: Readonly = Object.freeze({ + surface: 'squircle', + thicknessRatio: 1, + bezelRatio: 0.9, + refractiveIndex: 2, + displacementGain: 1.5, + blur: 0.6, + specularOpacity: 0.75, + specularSaturation: 2, + lightAngle: 270, +}); + +export interface TabBarGlassTextures { + width: number; + height: number; + radius: number; + dpr: number; + bezelWidth: number; + displacementScale: number; + blur: number; + specularOpacity: number; + specularSaturation: number; + displacement: Uint8ClampedArray; + specular: Uint8ClampedArray; +} + +const CHANNEL_NEUTRAL = 128; +const CHANNEL_RANGE = 127; +const PROFILE_SAMPLES = 96; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const smoothstep = (edge0: number, edge1: number, value: number) => { + const position = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return position * position * (3 - 2 * position); +}; + +const sampleProfile = (profile: Float64Array, position: number) => { + const sample = clamp(position, 0, 1) * (profile.length - 1); + const lowerIndex = Math.floor(sample); + const upperIndex = Math.min(lowerIndex + 1, profile.length - 1); + const fraction = sample - lowerIndex; + return profile[lowerIndex] * (1 - fraction) + profile[upperIndex] * fraction; +}; + +function resolveTextureSize(width: number, height: number, dpr: number) { + const desiredWidth = Math.max(1, Math.ceil(width * dpr)); + const desiredHeight = Math.max(1, Math.ceil(height * dpr)); + const pixels = desiredWidth * desiredHeight; + const scale = pixels > MAX_TAB_BAR_GLASS_TEXTURE_PIXELS ? Math.sqrt(MAX_TAB_BAR_GLASS_TEXTURE_PIXELS / pixels) : 1; + return { + width: Math.max(1, Math.floor(desiredWidth * scale)), + height: Math.max(1, Math.floor(desiredHeight * scale)), + }; +} + +function surfaceHeight(surface: TabBarGlassSurface, position: number) { + const x = clamp(position, 0, 1); + const squircle = (1 - (1 - x) ** 4) ** 0.25; + if (surface === 'squircle') return squircle; + + const inner = (1 - (1 - Math.min(x * 2, 1)) ** 4) ** 0.25; + const outer = 1 - Math.sqrt(Math.max(0, 1 - (1 - x) ** 2)); + const transition = x * x * x * (x * (x * 6 - 15) + 10); + return inner * (1 - transition) + (outer + 0.1) * transition; +} + +export function createRefractionProfile( + tuning: Pick, +) { + const index = clamp(tuning.refractiveIndex, 1, 2.5); + const thickness = clamp(tuning.thicknessRatio, 0, 2); + const eta = 1 / index; + const profile = new Float64Array(PROFILE_SAMPLES); + + for (let sample = 0; sample < PROFILE_SAMPLES; sample += 1) { + const x = sample / (PROFILE_SAMPLES - 1); + const height = surfaceHeight(tuning.surface, x); + const nextHeight = surfaceHeight(tuning.surface, Math.min(1, x + 1 / PROFILE_SAMPLES)); + const slope = (nextHeight - height) * PROFILE_SAMPLES; + const normalLength = Math.hypot(slope, 1); + const normalX = -slope / normalLength; + const normalY = -1 / normalLength; + const incidentDot = normalY; + const discriminant = 1 - eta * eta * (1 - incidentDot * incidentDot); + + if (discriminant <= 0) continue; + const root = Math.sqrt(discriminant); + const refractedX = -(eta * incidentDot + root) * normalX; + const refractedY = eta - (eta * incidentDot + root) * normalY; + profile[sample] = refractedY === 0 ? 0 : refractedX * ((height + thickness) / refractedY); + } + + return profile; +} + +function roundedRectangleField(x: number, y: number, width: number, height: number, radius: number) { + const halfWidth = width / 2; + const halfHeight = height / 2; + const centeredX = x - halfWidth; + const centeredY = y - halfHeight; + const localX = Math.abs(centeredX) - (halfWidth - radius); + const localY = Math.abs(centeredY) - (halfHeight - radius); + const outsideX = Math.max(localX, 0); + const outsideY = Math.max(localY, 0); + const outside = Math.hypot(outsideX, outsideY); + const inside = Math.min(Math.max(localX, localY), 0); + + if (outside > 0) { + return { + distance: outside + inside - radius, + normalX: (Math.sign(centeredX) * outsideX) / outside, + normalY: (Math.sign(centeredY) * outsideY) / outside, + }; + } + + return { + distance: inside - radius, + normalX: localX > localY ? Math.sign(centeredX) : 0, + normalY: localX > localY ? 0 : Math.sign(centeredY), + }; +} + +function specularBand(edgePosition: number) { + const position = clamp(edgePosition, 0, 1); + const outerFade = smoothstep(0, 0.16, position); + const innerFade = (1 - position) ** 1.15; + return outerFade * innerFade; +} + +export function createTabBarGlassTextures(options: TabBarGlassTextureOptions): TabBarGlassTextures | null { + const cssWidth = Number.isFinite(options.width) ? Math.max(0, options.width) : 0; + const cssHeight = Number.isFinite(options.height) ? Math.max(0, options.height) : 0; + if (!cssWidth || !cssHeight) return null; + + const dpr = clamp(Number.isFinite(options.dpr) ? options.dpr : 1, 1, MAX_TAB_BAR_GLASS_DPR); + const radius = clamp(Number.isFinite(options.radius) ? options.radius : 0, 0, Math.min(cssWidth, cssHeight) / 2); + const tuning = { ...DEFAULT_TAB_BAR_GLASS_TUNING, ...options.tuning }; + const bezelExtent = radius || Math.min(cssWidth, cssHeight) / 2; + const bezelWidth = bezelExtent * clamp(tuning.bezelRatio, 0.1, 1); + const profile = createRefractionProfile(tuning); + const maxProfile = Math.max(...profile.map(Math.abs), 0.0001); + const scale = clamp(cssHeight * 0.2 * clamp(tuning.displacementGain, 0, 2), 0, 18); + const { width, height } = resolveTextureSize(cssWidth, cssHeight, dpr); + const scaleX = width / cssWidth; + const scaleY = height / cssHeight; + const displacement = new Uint8ClampedArray(width * height * 4); + const specular = new Uint8ClampedArray(width * height * 4); + const lightAngle = clamp(tuning.lightAngle, 0, 360) * (Math.PI / 180); + const lightX = Math.cos(lightAngle); + const lightY = Math.sin(lightAngle); + + for (let pixelY = 0; pixelY < height; pixelY += 1) { + const y = (pixelY + 0.5) / scaleY; + for (let pixelX = 0; pixelX < width; pixelX += 1) { + const x = (pixelX + 0.5) / scaleX; + const offset = (pixelY * width + pixelX) * 4; + const field = roundedRectangleField(x, y, cssWidth, cssHeight, radius); + displacement[offset] = CHANNEL_NEUTRAL; + displacement[offset + 1] = CHANNEL_NEUTRAL; + + if (field.distance > 0) continue; + displacement[offset + 3] = 255; + if (field.distance < -bezelWidth) continue; + + const edgePosition = clamp(-field.distance / bezelWidth, 0, 1); + const innerFade = 1 - smoothstep(0.82, 1, edgePosition); + const refraction = (sampleProfile(profile, edgePosition) / maxProfile) * innerFade; + displacement[offset] = Math.round(CHANNEL_NEUTRAL - field.normalX * refraction * CHANNEL_RANGE); + displacement[offset + 1] = Math.round(CHANNEL_NEUTRAL - field.normalY * refraction * CHANNEL_RANGE); + + const light = Math.max(0, field.normalX * lightX + field.normalY * lightY); + const intensity = clamp(light * specularBand(edgePosition), 0, 1); + specular[offset] = 255; + specular[offset + 1] = 255; + specular[offset + 2] = 255; + specular[offset + 3] = Math.round(255 * intensity ** 1.15); + } + } + + return { + width, + height, + radius, + dpr, + bezelWidth, + displacementScale: scale, + blur: clamp(tuning.blur, 0, 4), + specularOpacity: clamp(tuning.specularOpacity, 0, 1), + specularSaturation: clamp(tuning.specularSaturation, 1, 6), + displacement, + specular, + }; +} diff --git a/src/tab-bar/props.ts b/src/tab-bar/props.ts index a1dc04a24..adc6b0135 100644 --- a/src/tab-bar/props.ts +++ b/src/tab-bar/props.ts @@ -13,6 +13,15 @@ export default { type: Boolean, default: true, }, + /** 标签栏的材质效果 */ + effect: { + type: String as PropType, + default: 'normal' as TdTabBarProps['effect'], + validator(val: TdTabBarProps['effect']): boolean { + if (!val) return true; + return ['normal', 'glass'].includes(val); + }, + }, /** 是否固定在底部 */ fixed: { type: Boolean, @@ -39,13 +48,13 @@ export default { type: Boolean, default: true, }, - /** 选项风格 */ + /** 选项风格。normal 为弱选中,tag 为逐项标签选中,capsule 为共享胶囊选中态 */ theme: { type: String as PropType, default: 'normal' as TdTabBarProps['theme'], validator(val: TdTabBarProps['theme']): boolean { if (!val) return true; - return ['normal', 'tag'].includes(val); + return ['normal', 'tag', 'capsule'].includes(val); }, }, /** 当前选中标签的索引 */ diff --git a/src/tab-bar/tab-bar-item.tsx b/src/tab-bar/tab-bar-item.tsx index 5ab53ac4e..2676ab3d7 100644 --- a/src/tab-bar/tab-bar-item.tsx +++ b/src/tab-bar/tab-bar-item.tsx @@ -1,4 +1,4 @@ -import { defineComponent, inject, computed, ref, watch, ComponentInternalInstance } from 'vue'; +import { defineComponent, inject, computed, ref, watch, onBeforeUnmount, ComponentInternalInstance } from 'vue'; import { ViewListIcon as TViewListIcon } from 'tdesign-icons-vue-next'; import TBadge from '../badge'; import { TdBadgeProps } from '../badge/type'; @@ -17,7 +17,8 @@ export default defineComponent({ const tabBarItemClass = usePrefixClass('tab-bar-item'); const { t, globalConfig } = useConfig('tabBar'); - const { split, shape, theme, defaultIndex, activeValue, itemCount, updateChild } = inject('tab-bar'); + const { split, shape, theme, defaultIndex, activeValue, itemCount, pressedValue, updateChild, updatePressed } = + inject('tab-bar'); const currentName = initName(defaultIndex); const textNode = ref(); @@ -62,7 +63,6 @@ export default defineComponent({ } return currentName === activeValue.value; }); - const isSpread = ref(false); watch(isChecked, (newValue) => { if (!newValue) { @@ -81,17 +81,61 @@ export default defineComponent({ const isToggleCurrent = computed(() => Array.isArray(activeValue.value) && activeValue.value[0] === currentName); + const isPressable = computed(() => theme.value === 'capsule' && shape.value === 'round'); + const isPressed = computed(() => isPressable.value && pressedValue.value === currentName); + const isPreviewingPress = computed(() => isPressable.value && typeof pressedValue.value !== 'undefined'); + const isVisuallyChecked = computed(() => + isPreviewingPress.value ? pressedValue.value === currentName : isChecked.value, + ); + let releaseTimer = 0; + + const clearReleaseTimer = () => { + if (!releaseTimer || typeof window === 'undefined') return; + window.clearTimeout(releaseTimer); + releaseTimer = 0; + }; + + const startPress = (event: PointerEvent) => { + if (!isPressable.value || event.button !== 0) return; + clearReleaseTimer(); + updatePressed(currentName); + }; + + const endPress = () => { + clearReleaseTimer(); + if (pressedValue.value === currentName) updatePressed(); + }; + + const scheduleEndPress = () => { + if (typeof window === 'undefined') { + endPress(); + return; + } + clearReleaseTimer(); + releaseTimer = window.setTimeout(endPress, 0); + }; + + const cancelPressOnLeave = (event: PointerEvent) => { + if (event.buttons) endPress(); + }; + const toggle = () => { - if (hasSubTabBar.value) { - isSpread.value = !isSpread.value; - if (!isToggleCurrent.value) { - updateChild([currentName]); - return; + try { + if (hasSubTabBar.value) { + isSpread.value = !isSpread.value; + if (!isToggleCurrent.value) { + updateChild([currentName]); + return; + } } + updateChild(currentName); + } finally { + endPress(); } - updateChild(currentName); }; + onBeforeUnmount(endPress); + const hasChildren = computed(() => { return Number(props.subTabBar?.length) > 0; }); @@ -177,12 +221,17 @@ export default defineComponent({
{badge()} diff --git a/src/tab-bar/tab-bar.en-US.md b/src/tab-bar/tab-bar.en-US.md index 6b93531d4..37e9bdce1 100644 --- a/src/tab-bar/tab-bar.en-US.md +++ b/src/tab-bar/tab-bar.en-US.md @@ -7,12 +7,13 @@ name | type | default | description | required -- | -- | -- | -- | -- bordered | Boolean | true | \- | N +effect | String | normal | Tab bar material effect。options: normal/glass | N fixed | Boolean | true | \- | N placeholder | Boolean | false | `1.12.0` | N safeAreaInsetBottom | Boolean | true | \- | N shape | String | normal | options: normal/round | N split | Boolean | true | \- | N -theme | String | normal | options: normal/tag | N +theme | String | normal | Option style. normal uses a weak active state, tag uses an item tag, and capsule uses a shared capsule indicator. options: normal/tag/capsule | N value | String / Number / Array | - | `v-model` and `v-model:value` is supported。Typescript: `string \| number \| Array` | N defaultValue | String / Number / Array | - | uncontrolled property。Typescript: `string \| number \| Array` | N zIndex | Number | 1 | `1.12.0` | N @@ -42,6 +43,14 @@ Name | Default Value | Description --td-tab-bar-bg-color | @bg-color-container | - --td-tab-bar-border-color | @border-color | - --td-tab-bar-round-shadow | @shadow-3 | - +--td-tab-bar-glass-bg-color | rgba(255, 255, 255, 50%) | Glass material baseline fill +--td-tab-bar-glass-shadow | @shadow-3 | Glass material shadow +--td-tab-bar-glass-fallback-blur | 8px | Gaussian blur radius when SVG enhancement is unavailable +--td-tab-bar-glass-sheen-opacity | 1 | Material sheen opacity +--td-tab-bar-selected-bg-color | @brand-color | Selected capsule color for a round TabBar +--td-tab-bar-selected-bg-opacity | 16% | Selected capsule color mix for a round TabBar +--td-tab-bar-selected-sheen-opacity | 0.62 | Round selected capsule sheen opacity +--td-tab-bar-selected-border-color | @component-border | Normal round selected outline color --td-tab-bar-active-bg | @brand-color-light | - --td-tab-bar-active-color | @brand-color | - --td-tab-bar-color | @text-color-primary | - diff --git a/src/tab-bar/tab-bar.md b/src/tab-bar/tab-bar.md index 480e52c88..997a49f6f 100644 --- a/src/tab-bar/tab-bar.md +++ b/src/tab-bar/tab-bar.md @@ -7,12 +7,13 @@ 名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- bordered | Boolean | true | 是否显示外边框 | N +effect | String | normal | 标签栏的材质效果。可选项:normal/glass | N fixed | Boolean | true | 是否固定在底部 | N placeholder | Boolean | false | `1.12.0`。固定在底部时是否开启占位 | N safeAreaInsetBottom | Boolean | true | 是否开启底部安全区适配 | N shape | String | normal | 标签栏的形状。可选项:normal/round | N split | Boolean | true | 是否需要分割线 | N -theme | String | normal | 选项风格。可选项:normal/tag | N +theme | String | normal | 选项风格。normal 为弱选中,tag 为逐项标签选中,capsule 为共享胶囊选中态。可选项:normal/tag/capsule | N value | String / Number / Array | - | 当前选中标签的索引。支持语法糖 `v-model` 或 `v-model:value`。TS 类型:`string \| number \| Array` | N defaultValue | String / Number / Array | - | 当前选中标签的索引。非受控属性。TS 类型:`string \| number \| Array` | N zIndex | Number | 1 | `1.12.0`。标签栏层级 | N @@ -42,6 +43,14 @@ value | String / Number | - | 标识符 | N --td-tab-bar-bg-color | @bg-color-container | - --td-tab-bar-border-color | @border-color | - --td-tab-bar-round-shadow | @shadow-3 | - +--td-tab-bar-glass-bg-color | rgba(255, 255, 255, 50%) | 玻璃材质基线填充色 +--td-tab-bar-glass-shadow | @shadow-3 | 玻璃材质阴影 +--td-tab-bar-glass-fallback-blur | 8px | SVG 增强不可用时的高斯模糊半径 +--td-tab-bar-glass-sheen-opacity | 1 | 材质高光轮廓透明度 +--td-tab-bar-selected-bg-color | @brand-color | 圆角 TabBar 选中态胶囊颜色 +--td-tab-bar-selected-bg-opacity | 16% | 圆角 TabBar 选中态胶囊颜色混合比例 +--td-tab-bar-selected-sheen-opacity | 0.62 | 圆角 TabBar 选中态高光轮廓透明度 +--td-tab-bar-selected-border-color | @component-border | normal round 选中态描边颜色 --td-tab-bar-active-bg | @brand-color-light | - --td-tab-bar-active-color | @brand-color | - --td-tab-bar-color | @text-color-primary | - diff --git a/src/tab-bar/tab-bar.tsx b/src/tab-bar/tab-bar.tsx index 1628a319f..d1223cb01 100644 --- a/src/tab-bar/tab-bar.tsx +++ b/src/tab-bar/tab-bar.tsx @@ -1,10 +1,11 @@ -import { defineComponent, ref, provide, Ref, computed, toRefs, VNode, CSSProperties } from 'vue'; +import { defineComponent, ref, provide, inject, Ref, computed, toRefs, VNode, CSSProperties } from 'vue'; import TabBarProps from './props'; import useChildSlots from '../hooks/useChildSlots'; import useVModel from '../hooks/useVModel'; import { useTNodeJSX } from '../hooks/tnode'; import { usePrefixClass } from '../hooks/useClass'; import useElementRect from '../hooks/useElementRect'; +import { tabBarGlassDevContextKey, useTabBarGlassFilter } from './useTabBarGlassFilter'; export default defineComponent({ name: 'TTabBar', @@ -21,21 +22,38 @@ export default defineComponent({ const defaultIndex: Ref = ref(-1); const itemCount = ref(0); + const pressedValue = ref(); const updateChild = (currentValue: number | string) => { setActiveValue(currentValue); }; + const updatePressed = (currentValue?: number | string) => { + pressedValue.value = currentValue; + }; + const rootClass = computed(() => [ `${tabBarClass.value}`, { [`${tabBarClass.value}--bordered`]: props.bordered, [`${tabBarClass.value}--fixed`]: props.fixed, + [`${tabBarClass.value}--glass`]: props.effect === 'glass', [`${tabBarClass.value}--safe`]: props.safeAreaInsetBottom, + [`${tabBarClass.value}--theme-capsule`]: props.theme === 'capsule', }, `${tabBarClass.value}--${props.shape}`, ]); + // 调参注入仅服务本地 Demo 与测试;生产构建固定使用已冻结的内部默认值。 + const glassDevContext = + process.env.NODE_ENV === 'production' ? undefined : inject(tabBarGlassDevContextKey, undefined); + const glassFilterState = useTabBarGlassFilter({ + root, + enabled: computed(() => props.effect === 'glass'), + shape: computed(() => props.shape), + devContext: glassDevContext, + }); + const styles = computed(() => ({ zIndex: props.zIndex, })); @@ -52,29 +70,164 @@ export default defineComponent({ defaultIndex, activeValue, itemCount, + pressedValue, updateChild, + updatePressed, }); // 在渲染函数中调用插槽函数并更新子节点数量 const updateItemCount = (vNodes?: VNode[]) => { if (!vNodes || !Array.isArray(vNodes)) { itemCount.value = 0; - return; + return []; } const childSlots = useChildSlots('TTabBarItem', vNodes); itemCount.value = childSlots.length; + return childSlots; + }; + + const renderSelectionIndicator = (items: VNode[]) => { + if (props.theme !== 'capsule' || props.shape !== 'round' || !items.length) return null; + + const isPressed = typeof pressedValue.value !== 'undefined'; + const activeSelection = Array.isArray(activeValue.value) ? activeValue.value[0] : activeValue.value; + const selectedValue = isPressed ? pressedValue.value : activeSelection; + const selectedIndex = items.findIndex((item, index) => { + const itemValue = typeof item.props?.value === 'undefined' ? index : item.props.value; + return itemValue === selectedValue; + }); + if (selectedIndex < 0) return null; + + const indicatorStyle: CSSProperties = { + width: `${100 / items.length}%`, + transform: `translate3d(${selectedIndex * 100}%, 0, 0)`, + }; + + return ( +