diff --git a/2nd-gen/packages/swc/components/tabs/tab-panel.css b/2nd-gen/packages/swc/components/tabs/tab-panel.css index 54133b17385..24adcf236de 100644 --- a/2nd-gen/packages/swc/components/tabs/tab-panel.css +++ b/2nd-gen/packages/swc/components/tabs/tab-panel.css @@ -12,7 +12,7 @@ :host { display: block; - padding-block-start: var(--swc-tab-panel-padding-top, 12px); + padding-block-start: var(--_swc-tab-panel-padding-top, token("spacing-200")); } :host([aria-hidden="true"]) { diff --git a/2nd-gen/packages/swc/components/tabs/tab.css b/2nd-gen/packages/swc/components/tabs/tab.css index 1ef9fe15da4..cc1de6d35e8 100644 --- a/2nd-gen/packages/swc/components/tabs/tab.css +++ b/2nd-gen/packages/swc/components/tabs/tab.css @@ -45,6 +45,12 @@ font-size: token("font-size-100"); font-weight: token("regular-font-weight"); line-height: token("line-height-100"); + + &:lang(ja), + &:lang(zh), + &:lang(ko) { + line-height: token("cjk-line-height-100"); + } } /* ── Icon slot ─────────────────────────────────────────── */ diff --git a/2nd-gen/packages/swc/components/tabs/test/vrt/tabs-custom-properties.vrt.ts b/2nd-gen/packages/swc/components/tabs/test/vrt/tabs-custom-properties.vrt.ts new file mode 100644 index 00000000000..9cac7d8a5a6 --- /dev/null +++ b/2nd-gen/packages/swc/components/tabs/test/vrt/tabs-custom-properties.vrt.ts @@ -0,0 +1,134 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import '@adobe/spectrum-wc/components/tabs/swc-tabs.js'; +import '@adobe/spectrum-wc/components/tabs/swc-tab.js'; +import '@adobe/spectrum-wc/components/tabs/swc-tab-panel.js'; + +import type { CustomPropertyCase } from '../../../../.storybook/helpers/index.js'; +import { + coveredCustomProperties, + customPropertyRows, + theme, + verifyCustomPropertyCoverage, + vrtParameters, +} from '../../../../.storybook/helpers/index.js'; +import customElementsManifest from '../../../../dist/custom-elements.json'; + +// Metadata + +const meta: Meta = { + title: 'Tabs/Tabs VRT', + component: 'swc-tabs', + tags: ['dev'], +}; + +export default meta; + +// Helpers + +// One row per public custom property: a reference element beside the same +// element with that one property overridden to an obviously different +// value, so a real difference confirms the override still applies. Split +// into two groups (Tabs, Tab) since the three-element architecture documents +// custom properties on separate classes (Tabs.ts, Tab.ts), each verified +// against its own manifest entry below. TabPanel.ts has no documented +// `@cssprop` (its CSS only uses the private `--_swc-tab-panel-padding-top`), +// so there's nothing to verify coverage for there. + +const TABS_CUSTOM_PROPERTY_CASES: readonly CustomPropertyCase<`--swc-tabs-${string}`>[] = + [{ property: '--swc-tabs-indicator-color', value: 'magenta' }]; + +const renderTabsCase = (_testCase: CustomPropertyCase, style?: string) => html` + + Overview + Specifications + +

Overview content.

+
+ +

Specifications content.

+
+
+`; + +// Tab renders meaningfully as a standalone custom element (its template has +// no dependency on a parent `swc-tabs`), so these properties are tested +// directly on `` rather than wrapped in a full tab group - the same +// granularity button-custom-properties.vrt.ts uses for `` outside +// of button-group. +const TAB_CUSTOM_PROPERTY_CASES: readonly CustomPropertyCase<`--swc-tab-${string}`>[] = + [ + { property: '--swc-tab-height', value: '80px' }, + { property: '--swc-tab-padding-block', value: '40px' }, + { property: '--swc-tab-padding-block-end', value: '40px' }, + { property: '--swc-tab-text-color', value: 'magenta' }, + ]; + +const renderTabCase = (_testCase: CustomPropertyCase, style?: string) => html` + Overview +`; + +// A plain heading, not `row()`: `customPropertyRows()` already returns one +// fully-built `row()` per property (its own caption plus a flex-wrap line of +// items), so wrapping that array in another `row()` would flex-wrap those +// pre-built blocks together as if they were plain items, corrupting the +// per-property layout instead of stacking them. +const sectionHeading = (label: string) => html` + + ${label} + +`; + +const customPropertiesContent = () => html` + ${sectionHeading('Tabs')} + ${customPropertyRows(TABS_CUSTOM_PROPERTY_CASES, renderTabsCase)} + ${sectionHeading('Tab')} + ${customPropertyRows(TAB_CUSTOM_PROPERTY_CASES, renderTabCase)} +`; + +const coveredTabsCustomProperties = coveredCustomProperties( + TABS_CUSTOM_PROPERTY_CASES +); +const coveredTabCustomProperties = coveredCustomProperties( + TAB_CUSTOM_PROPERTY_CASES +); + +const verifyCoverage = async () => { + await verifyCustomPropertyCoverage({ + customElementsManifest, + modulePath: 'components/tabs/Tabs.ts', + declarationName: 'Tabs', + coveredProperties: coveredTabsCustomProperties, + }); + await verifyCustomPropertyCoverage({ + customElementsManifest, + modulePath: 'components/tabs/Tab.ts', + declarationName: 'Tab', + coveredProperties: coveredTabCustomProperties, + }); +}; + +// VRT stories + +export const CustomProperties: Story = { + render: () => theme(customPropertiesContent(), 'light', 'ltr'), + parameters: vrtParameters, + play: verifyCoverage, +}; diff --git a/2nd-gen/packages/swc/components/tabs/test/vrt/tabs.vrt.ts b/2nd-gen/packages/swc/components/tabs/test/vrt/tabs.vrt.ts new file mode 100644 index 00000000000..b41e7377286 --- /dev/null +++ b/2nd-gen/packages/swc/components/tabs/test/vrt/tabs.vrt.ts @@ -0,0 +1,324 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { + TAB_DENSITIES, + type TabDensity, + TABS_DIRECTIONS, + type TabsDirection, +} from '@adobe/spectrum-wc-core/components/tabs'; + +import '@adobe/spectrum-wc/components/tabs/swc-tabs.js'; +import '@adobe/spectrum-wc/components/tabs/swc-tab.js'; +import '@adobe/spectrum-wc/components/tabs/swc-tab-panel.js'; + +import type { ForcedPseudoState } from '../../../../.storybook/helpers/index.js'; +import { + forcedColorsVrtParameters, + forcePseudoStates, + row, + theme, + vrtParameters, +} from '../../../../.storybook/helpers/index.js'; + +// Metadata + +const meta: Meta = { + title: 'Tabs/Tabs VRT', + component: 'swc-tabs', + tags: ['dev'], +}; + +export default meta; + +// Helpers + +type TabCase = { + id: string; + label: string; + disabled?: boolean; + icon?: string; + iconOnly?: boolean; + forceState?: ForcedPseudoState; +}; + +const tab = ({ + id, + label, + disabled = false, + icon, + iconOnly = false, + forceState, +}: TabCase) => html` + + ${icon + ? html` + + ` + : nothing} + ${iconOnly ? nothing : label} + +`; + +const panel = (id: string, content: string) => html` + +

${content}

+
+`; + +// Default 3-tab content shared by every case below that doesn't need its own +// custom anatomy/state combination. `tab-id` is reused across separate +// `swc-tabs` instances deliberately: each container only ever queries its own +// direct children (Tabs.base.ts's `managePanels`/`handleTabSlotChange`), so +// IDs don't need to be page-unique the way the auto-generated `id` attribute +// (used for ARIA wiring) does. +const DEFAULT_TABS = html` + ${tab({ id: '1', label: 'Overview' })} + ${tab({ id: '2', label: 'Specifications' })} + ${tab({ id: '3', label: 'Guidelines' })} +`; + +const DEFAULT_PANELS = html` + ${panel('1', 'Overview content for the selected tab.')} + ${panel('2', 'Specifications content goes here.')} + ${panel('3', 'Guidelines content goes here.')} +`; + +type TabsGroupCase = { + direction?: TabsDirection; + density?: TabDensity; + disabled?: boolean; + selected?: string; + accessibleLabel: string; + tabs?: unknown; + panels?: unknown; +}; + +const renderTabs = ({ + direction, + density, + disabled = false, + selected = '1', + accessibleLabel, + tabs = DEFAULT_TABS, + panels = DEFAULT_PANELS, +}: TabsGroupCase) => html` + + ${tabs} ${panels} + +`; + +// Density labels double as captions: `accessible-label` is aria-only (see +// Tabs.ts's render), so nothing in the visible output otherwise distinguishes +// a `compact` group from a `regular` one within the same direction row. Kept +// local rather than added to the shared `.storybook/helpers/vrt.ts` (which +// would affect every other VRT file using it) to keep this VRT-only PR +// scoped to tabs. +const DENSITY_LABELS = { + regular: 'Regular', + compact: 'Compact', +} as const satisfies Record; + +const withCaption = (content: unknown, label: string) => html` +
+ ${content} + ${label} +
+`; + +const permutationContent = () => html` + ${TABS_DIRECTIONS.map((direction) => + row( + TAB_DENSITIES.map((density) => + withCaption( + renderTabs({ + direction, + density, + accessibleLabel: `${direction} ${density} example`, + }), + DENSITY_LABELS[density] + ) + ), + direction + ) + )} + ${row( + [ + renderTabs({ + selected: 'selected', + accessibleLabel: 'Individual tab states', + tabs: html` + ${tab({ id: 'default', label: 'Default' })} + ${tab({ id: 'selected', label: 'Selected' })} + ${tab({ id: 'disabled', label: 'Disabled', disabled: true })} + `, + panels: html` + ${panel('default', 'Default tab content.')} + ${panel('selected', 'Selected tab content.')} + ${panel('disabled', 'Disabled tab content.')} + `, + }), + ], + 'Tab states' + )} + ${row( + [renderTabs({ disabled: true, accessibleLabel: 'Disabled container' })], + 'Disabled container' + )} + ${row( + [ + renderTabs({ + accessibleLabel: 'Text-only anatomy', + }), + renderTabs({ + accessibleLabel: 'Icon and text anatomy', + tabs: html` + ${tab({ id: '1', label: 'Dashboard', icon: '☰' })} + ${tab({ id: '2', label: 'Reports', icon: '📊' })} + ${tab({ id: '3', label: 'Settings', icon: '⚙' })} + `, + panels: html` + ${panel('1', 'Dashboard content.')} ${panel('2', 'Reports content.')} + ${panel('3', 'Settings content.')} + `, + }), + renderTabs({ + accessibleLabel: 'Icon-only anatomy', + tabs: html` + ${tab({ id: '1', label: 'Dashboard', icon: '☰', iconOnly: true })} + ${tab({ id: '2', label: 'Reports', icon: '📊', iconOnly: true })} + ${tab({ id: '3', label: 'Settings', icon: '⚙', iconOnly: true })} + `, + panels: html` + ${panel('1', 'Dashboard content.')} ${panel('2', 'Reports content.')} + ${panel('3', 'Settings content.')} + `, + }), + ], + 'Anatomy' + )} + ${row( + [ + renderTabs({ + // Baseline "Selected" tab isolates the underline from the forced + // pseudo-states below, so it doesn't read as part of Hover/Active. + selected: '0', + accessibleLabel: 'Forced pseudo-states', + tabs: html` + ${tab({ id: '0', label: 'Selected' })} + ${tab({ id: '1', label: 'Hover', forceState: 'hover' })} + ${tab({ + id: '2', + label: 'Focus-visible', + forceState: 'focus-visible', + })} + ${tab({ id: '3', label: 'Active', forceState: 'active' })} + `, + panels: html` + ${panel('0', 'Selected content.')} ${panel('1', 'Hover content.')} + ${panel('2', 'Focus content.')} ${panel('3', 'Active content.')} + `, + }), + ], + 'Forced states' + )} + ${row( + [ + renderTabs({ + accessibleLabel: '承認ワークフロー', + tabs: html` + ${tab({ id: '1', label: '概要' })} ${tab({ id: '2', label: '仕様' })} + ${tab({ id: '3', label: 'ガイドライン' })} + `, + panels: html` + ${panel('1', '概要コンテンツ。')} ${panel('2', '仕様コンテンツ。')} + ${panel('3', 'ガイドラインコンテンツ。')} + `, + }), + renderTabs({ + accessibleLabel: '승인 워크플로', + tabs: html` + ${tab({ id: '1', label: '개요' })} ${tab({ id: '2', label: '사양' })} + ${tab({ id: '3', label: '가이드라인' })} + `, + panels: html` + ${panel('1', '개요 콘텐츠.')} ${panel('2', '사양 콘텐츠.')} + ${panel('3', '가이드라인 콘텐츠.')} + `, + }), + renderTabs({ + accessibleLabel: '审批工作流', + tabs: html` + ${tab({ id: '1', label: '概览' })} ${tab({ id: '2', label: '规格' })} + ${tab({ id: '3', label: '准则' })} + `, + panels: html` + ${panel('1', '概览内容。')} ${panel('2', '规格内容。')} + ${panel('3', '准则内容。')} + `, + }), + ], + 'CJK language' + )} +`; + +const forceTabStates = forcePseudoStates('swc-tab[data-force-state]'); + +// VRT stories + +// Direction (horizontal, vertical) x density (regular, compact), individual +// tab states (default/selected/disabled within one group), a fully disabled +// container, anatomy (text-only, icon+text, icon-only), forced +// hover/focus-visible/active on individual tabs (see the `play` function +// below - tab.css styles these on `:host()` directly, so no internal +// selector is needed), and CJK tab labels. Rendered once in light/ltr and +// once in dark/rtl below (that combination covers both axes; RTL also +// exercises the selection indicator's mirrored `transform-origin` for +// horizontal direction), all still in a single story so it costs one +// snapshot. +export const Permutations: Story = { + render: () => html` + ${theme(permutationContent(), 'light', 'ltr')} + ${theme(permutationContent(), 'dark', 'rtl')} + `, + parameters: vrtParameters, + play: forceTabStates, +}; + +// `forced-colors` is a real browser media feature Chromatic can emulate +// directly, unlike :hover/:active/:focus-visible above. Forced-colors mode +// replaces the whole page's palette, so it needs its own story/snapshot +// rather than folding into Permutations. Tab, Tabs' selection indicator, and +// TabPanel all define their own forced-colors overrides, so this covers all +// three parts of the three-element architecture. +export const ForcedColors: Story = { + render: () => theme(permutationContent(), 'light', 'ltr'), + parameters: forcedColorsVrtParameters, + play: forceTabStates, +};