diff --git a/.specs/tab-view.md b/.specs/tab-view.md index 7c26f0e11..72ae2338f 100644 --- a/.specs/tab-view.md +++ b/.specs/tab-view.md @@ -7,9 +7,9 @@ spec_version: 2 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3374-6191 node_id: 3374:6191 -checksum: 9a23a13b0205624a780b6ac76086455a35d38ac2b266757cbf91080258308e6b +checksum: 38b0be72fee4fa515e9917d1f931e6b0d567009d1e3fb46dd32d4e3ad86c6538 created: 2026-05-22 -last_updated: 2026-07-22 +last_updated: 2026-07-28 --- # Tab View — Component Spec @@ -53,6 +53,7 @@ Helps users move between views or sections. Migrated from the existing implement | open/close | `animate-none` | semantic | `motion-reduce:animate-none` | | scroll edge fade appears / leaves | `animate-fade-in` / `animate-fade-out` | semantic | `motion-reduce:animate-none` | | state change | `transition-colors duration-150 ease-out` | inline | `motion-reduce:transition-none` | +| panel change | the incoming panel slides in from the side being navigated toward (`translate-x-[var(--spacing-md)]` / negative) while fading | `duration['moderate-02']` + `curve['productive-entrance']` from the theme animation catalog | `motion-reduce:transition-none` | ## Tokens diff --git a/packages/webkit/src/components/navigation/tab-view/presets/transitions.ts b/packages/webkit/src/components/navigation/tab-view/presets/transitions.ts index 79a69c9dd..5da30a08c 100644 --- a/packages/webkit/src/components/navigation/tab-view/presets/transitions.ts +++ b/packages/webkit/src/components/navigation/tab-view/presets/transitions.ts @@ -12,11 +12,21 @@ export const tabViewIndicatorMotion = { slide: { duration: duration['moderate-02'], curve: curve['productive-entrance'] } } as const +/* + * `translate` is listed alongside `transform` on purpose. Tailwind v4 compiles + * `translate-x-*` to the standalone `translate` property (`translate: var(--tw-translate-x) + * var(--tw-translate-y)`), and its `.transform` utility no longer covers translation — + * it only composes rotate/skew. A list of just `transform, opacity` therefore leaves the + * slide offset un-interpolated: it snaps to its final position while only the fade plays, + * so a panel/month swap reads as a pop instead of a slide. Tailwind's own + * `transition-transform` expands to `transform, translate, scale, rotate` for the same + * reason. Any offset expressed with a `translate-*` utility must keep `translate` here. + */ export const getTabViewPanelTransitionStyle = (): { transition: string } => { const { duration: transitionDuration, curve: transitionTimingFunction } = tabViewPanelMotion.enter return { - transition: `transform ${transitionDuration} ${transitionTimingFunction}, opacity ${transitionDuration} ${transitionTimingFunction}` + transition: `translate ${transitionDuration} ${transitionTimingFunction}, transform ${transitionDuration} ${transitionTimingFunction}, opacity ${transitionDuration} ${transitionTimingFunction}` } } diff --git a/packages/webkit/src/components/navigation/tab-view/tab-view.test.ts b/packages/webkit/src/components/navigation/tab-view/tab-view.test.ts index 106c98236..7a738ed86 100644 --- a/packages/webkit/src/components/navigation/tab-view/tab-view.test.ts +++ b/packages/webkit/src/components/navigation/tab-view/tab-view.test.ts @@ -6,6 +6,7 @@ import { defineComponent, ref } from 'vue' import * as stories from '../../../../../../apps/storybook/src/stories/components/navigation/tab-view/TabView.stories' import { expectNoA11yViolations } from '../../../test/axe' import TabView from './index' +import { getTabViewPanelTransitionStyle } from './presets/transitions' import TabViewContent from './tab-view-content.vue' import TabViewItem from './tab-view-item.vue' import TabViewList from './tab-view-list.vue' @@ -39,6 +40,21 @@ const Tree = defineComponent({ }) describe('TabView (composition)', () => { + // The panel slide offset is a Tailwind `translate-x-*` utility, which in v4 compiles to + // the standalone `translate` property — NOT to `transform`. If `translate` is dropped + // from this list the offset stops interpolating and the swap pops instead of sliding, + // in this component and in Calendar's month strip, which shares the preset. + // is stubbed by Vue Test Utils, so the rendered animation cannot be asserted; this + // guards the css contract behind it. Verified in real Chromium: with only + // `transform, opacity`, a mid-flight sample is already at its final value. + describe('panel motion contract', () => { + it('transitions translate (not just transform) so translate-x offsets interpolate', () => { + const { transition } = getTabViewPanelTransitionStyle() + expect(transition).toContain('translate ') + expect(transition).toContain('opacity ') + }) + }) + describe('compound API (index.js dot-notation)', () => { it('attaches every public sub-component to the root compound', () => { // Grounded in index.js: TabView === TabViewRoot with Root/List/Item/