From 83195deaab35ac886f145f51e93d35cdd283898d Mon Sep 17 00:00:00 2001 From: Sangeetha Babu Date: Wed, 25 Feb 2026 16:41:14 +0530 Subject: [PATCH 01/11] feat(tabs): create vertical tabs story --- .../src/components/tabs/defs.ts | 25 ++++ .../src/components/tabs/tabs-story.scss | 18 +++ .../src/components/tabs/tabs.scss | 73 +++++++++++ .../src/components/tabs/tabs.stories.ts | 118 ++++++++++++++++++ .../src/components/tabs/tabs.ts | 62 +++++++-- 5 files changed, 289 insertions(+), 7 deletions(-) diff --git a/packages/web-components/src/components/tabs/defs.ts b/packages/web-components/src/components/tabs/defs.ts index 200e34e93d02..920ee68c54a2 100644 --- a/packages/web-components/src/components/tabs/defs.ts +++ b/packages/web-components/src/components/tabs/defs.ts @@ -54,3 +54,28 @@ export enum TABS_TYPE { */ CONTAINED = 'contained', } + +/** + * Tabs orientation. + */ +export enum TABS_ORIENTATION { + /** + * Horizontal orientation. + */ + HORIZONTAL = 'horizontal', + + /** + * Vertical orientation. + */ + VERTICAL = 'vertical', +} + +/** + * Vertical navigation direction, associated with key symbols. + */ +export const VERTICAL_NAVIGATION_DIRECTION = { + Up: -1, + ArrowUp: -1, + Down: 1, + ArrowDown: 1, +}; diff --git a/packages/web-components/src/components/tabs/tabs-story.scss b/packages/web-components/src/components/tabs/tabs-story.scss index f1b5ad3822b7..dce505b47bf6 100644 --- a/packages/web-components/src/components/tabs/tabs-story.scss +++ b/packages/web-components/src/components/tabs/tabs-story.scss @@ -14,3 +14,21 @@ align-self: stretch; padding: $spacing-05; } + +// Vertical tabs container for story examples +.#{$prefix}--vertical-tabs-container { + display: grid; + block-size: 100%; + grid-template-columns: repeat(16, 1fr); + + // Tab panels positioning for vertical layout + .#{$prefix}-ce-demo-devenv--tab-panels { + padding: $spacing-05; + grid-column: 3/-1; + overflow-y: auto; + + @media (width >= 1056px) { + grid-column: 5/-1; + } + } +} diff --git a/packages/web-components/src/components/tabs/tabs.scss b/packages/web-components/src/components/tabs/tabs.scss index a4d9a9d41f54..fa6bdf0be1c1 100644 --- a/packages/web-components/src/components/tabs/tabs.scss +++ b/packages/web-components/src/components/tabs/tabs.scss @@ -316,3 +316,76 @@ $inset-transition: inset 110ms motion(standard, productive); background-color: SelectedItem; } } + +//----------------------------- +// Vertical Tabs Orientation +//----------------------------- + +:host(#{$prefix}-tabs[orientation='vertical']) { + background: $layer; + box-shadow: inset -1px 0 $border-subtle; + grid-column: span 2; + max-block-size: none; + + @include breakpoint(lg) { + grid-column: span 4; + } + + .#{$prefix}--tabs-nav-content-container { + block-size: 100%; + overflow-x: hidden; + overflow-y: auto; + } + + .#{$prefix}--tabs-nav-content { + position: relative; + block-size: 100%; + } + + .#{$prefix}--tabs-nav { + position: relative; + } + + .#{$prefix}--tab--list { + display: flex; + flex-direction: column; + } + + .#{$prefix}--tab--overflow-nav-button { + display: none; + } +} + +:host(#{$prefix}-tabs[orientation='vertical']) ::slotted(#{$prefix}-tab) { + flex: none; + background-color: $layer-01; + block-size: $spacing-10; + border-block-end: 1px solid $border-subtle; + border-inline-end: 1px solid $border-subtle; + box-shadow: inset 3px 0 0 0 $border-subtle; + inline-size: 100%; +} + +:host(#{$prefix}-tabs[orientation='vertical']) + ::slotted(#{$prefix}-tab[selected]) { + border-inline: none; + box-shadow: inset 3px 0 0 0 $border-interactive; +} + +:host(#{$prefix}-tabs[orientation='vertical']) + ::slotted(#{$prefix}-tab:not([selected]):not([disabled]):hover) { + background-color: $layer-hover; + box-shadow: inset 3px 0 0 0 $border-strong; +} + +:host(#{$prefix}-tabs[orientation='vertical']) { + .#{$prefix}--tabs__nav-item-label { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: var(--cds-body-compact-01-line-height); + text-overflow: ellipsis; + white-space: normal; + } +} diff --git a/packages/web-components/src/components/tabs/tabs.stories.ts b/packages/web-components/src/components/tabs/tabs.stories.ts index 551d2733f6b5..8c385d135259 100644 --- a/packages/web-components/src/components/tabs/tabs.stories.ts +++ b/packages/web-components/src/components/tabs/tabs.stories.ts @@ -199,6 +199,124 @@ export const skeleton = { `, }; +export const Vertical = { + args, + argTypes, + render: ({ disabled, contained, selectionMode }) => { + const handleBeforeSelected = (event: CustomEvent) => { + if (disabled) { + event.preventDefault(); + } + }; + + return html` + +
+ + Tab label 1 + + Tab label 2 + + + Tab label 3 + + Tab label 4 + Tab label 5 + Tab label 6 + Tab label 7 + +
+ + + + + + + +
+
+ `; + }, +}; + export default { title: 'Components/Tabs', actions: { argTypesRegex: '^on.*' }, diff --git a/packages/web-components/src/components/tabs/tabs.ts b/packages/web-components/src/components/tabs/tabs.ts index 7a7543fb31c9..87bbef1364d0 100644 --- a/packages/web-components/src/components/tabs/tabs.ts +++ b/packages/web-components/src/components/tabs/tabs.ts @@ -18,12 +18,23 @@ import ChevronRight16 from '@carbon/icons/es/chevron--right/16.js'; import CDSContentSwitcher, { NAVIGATION_DIRECTION, } from '../content-switcher/content-switcher'; -import { TABS_KEYBOARD_ACTION, TABS_TYPE } from './defs'; +import { + TABS_KEYBOARD_ACTION, + TABS_ORIENTATION, + TABS_TYPE, + VERTICAL_NAVIGATION_DIRECTION, +} from './defs'; import CDSTab from './tab'; import styles from './tabs.scss?lit'; import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; -export { NAVIGATION_DIRECTION, TABS_KEYBOARD_ACTION, TABS_TYPE }; +export { + NAVIGATION_DIRECTION, + TABS_KEYBOARD_ACTION, + TABS_ORIENTATION, + TABS_TYPE, + VERTICAL_NAVIGATION_DIRECTION, +}; /** * Tabs. @@ -132,7 +143,11 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { @HostListener('keydown') protected _handleKeydown(event: KeyboardEvent) { const { key } = event; - const action = (this.constructor as typeof CDSTabs).getAction(key); + const isVertical = this.orientation === TABS_ORIENTATION.VERTICAL; + const action = (this.constructor as typeof CDSTabs).getAction( + key, + isVertical + ); const enabledTabs = this.querySelectorAll(`${prefix}-tab:not([disabled])`); switch (action) { case TABS_KEYBOARD_ACTION.HOME: @@ -159,7 +174,10 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { break; case TABS_KEYBOARD_ACTION.NAVIGATING: { - const direction = NAVIGATION_DIRECTION[key]; + // Get direction based on orientation + const direction = isVertical + ? VERTICAL_NAVIGATION_DIRECTION[key] + : NAVIGATION_DIRECTION[key]; if (direction) { this._navigate(direction); } @@ -295,6 +313,19 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { @property({ reflect: true }) type = TABS_TYPE.REGULAR; + /** + * Tabs orientation. Determines the layout direction of tabs. + */ + @property({ reflect: true }) + orientation = TABS_ORIENTATION.HORIZONTAL; + + /** + * Custom height for vertical tabs. Only applies when orientation is vertical. + * Can be any valid CSS height value (e.g., '500px', '50vh', '100%'). + */ + @property({ attribute: 'custom-height' }) + customHeight?: string; + /** * `true` if left-hand scroll intersection sentinel intersects with the host element. * In this condition, the left-hand paginator button should be hidden. @@ -418,6 +449,18 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { // Call super to keep selection/value in sync super.updated?.(changedProperties); + // Apply custom height for vertical tabs + if ( + changedProperties.has('orientation') || + changedProperties.has('customHeight') + ) { + if (this.orientation === TABS_ORIENTATION.VERTICAL && this.customHeight) { + this.style.height = this.customHeight; + } else { + this.style.removeProperty('height'); + } + } + if (changedProperties.has('value')) { const tab = this.querySelector( `${prefix}-tab[value="${this.value}"]` @@ -607,16 +650,21 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { /** * @param key The key symbol. - * @returns A action for dropdown for the given key symbol. + * @param isVertical Whether the tabs are in vertical orientation. + * @returns A action for tabs for the given key symbol. */ - static getAction(key: string) { + static getAction(key: string, isVertical = false) { if (key === 'Home') { return TABS_KEYBOARD_ACTION.HOME; } if (key === 'End') { return TABS_KEYBOARD_ACTION.END; } - if (key in NAVIGATION_DIRECTION) { + // Check for navigation keys based on orientation + const navigationKeys = isVertical + ? VERTICAL_NAVIGATION_DIRECTION + : NAVIGATION_DIRECTION; + if (key in navigationKeys) { return TABS_KEYBOARD_ACTION.NAVIGATING; } if (key === 'Enter' || key === ' ') { From 468ed11889084fe7837ec3c5eb1553ac0a1996e2 Mon Sep 17 00:00:00 2001 From: Sangeetha Babu Date: Fri, 27 Feb 2026 10:47:31 +0530 Subject: [PATCH 02/11] feat(tabs): panel component --- .../src/components/tabs/index.ts | 1 + .../src/components/tabs/tab-panel.ts | 40 ++++ .../src/components/tabs/tabs.scss | 37 ++++ .../src/components/tabs/tabs.stories.ts | 204 +++++++++--------- 4 files changed, 182 insertions(+), 100 deletions(-) create mode 100644 packages/web-components/src/components/tabs/tab-panel.ts diff --git a/packages/web-components/src/components/tabs/index.ts b/packages/web-components/src/components/tabs/index.ts index d760574fd781..95a512350d6a 100644 --- a/packages/web-components/src/components/tabs/index.ts +++ b/packages/web-components/src/components/tabs/index.ts @@ -7,5 +7,6 @@ import './tabs'; import './tab'; +import './tab-panel'; import './tab-skeleton'; import './tabs-skeleton'; diff --git a/packages/web-components/src/components/tabs/tab-panel.ts b/packages/web-components/src/components/tabs/tab-panel.ts new file mode 100644 index 000000000000..31a9a97f2a64 --- /dev/null +++ b/packages/web-components/src/components/tabs/tab-panel.ts @@ -0,0 +1,40 @@ +/** + * Copyright IBM Corp. 2019, 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { prefix } from '../../globals/settings'; +import styles from './tabs.scss?lit'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; + +/** + * Tab panel component. + * A simple container for tab content that can be shown/hidden based on tab selection. + * + * @element cds-tab-panel + */ +@customElement(`${prefix}-tab-panel`) +export default class CDSTabPanel extends LitElement { + /** + * `true` if this tab panel should be hidden. + */ + @property({ type: Boolean, reflect: true }) + hidden = false; + + connectedCallback() { + super.connectedCallback(); + if (!this.hasAttribute('role')) { + this.setAttribute('role', 'tabpanel'); + } + } + + render() { + return html` `; + } + + static styles = styles; +} diff --git a/packages/web-components/src/components/tabs/tabs.scss b/packages/web-components/src/components/tabs/tabs.scss index fa6bdf0be1c1..b2db3ebc980a 100644 --- a/packages/web-components/src/components/tabs/tabs.scss +++ b/packages/web-components/src/components/tabs/tabs.scss @@ -20,6 +20,7 @@ @use '@carbon/styles/scss/utilities/layout'; @use '@carbon/styles/scss/utilities/convert'; @use '@carbon/styles/scss/utilities/skeleton' as *; +@use '@carbon/styles/scss/utilities/update_fields_on_layer' as *; $inset-transition: inset 110ms motion(standard, productive); @@ -389,3 +390,39 @@ $inset-transition: inset 110ms motion(standard, productive); white-space: normal; } } + +//----------------------------- +// Tab Panel +//----------------------------- + +:host(#{$prefix}-tab-panel) { + @extend .#{$prefix}--tab-content; + + display: block; + padding: layout.density('padding-inline'); + + &:focus { + @include focus-outline('outline'); + } +} + +:host(#{$prefix}-tab-panel[hidden]) { + display: none; +} + +// Tab panel styles for contained tabs +:host(#{$prefix}-tabs[type='contained']) ~ :host(#{$prefix}-tab-panel) { + background: $layer; +} + +// Tab panel styles for vertical tabs +:host(#{$prefix}-tabs[orientation='vertical']) ~ :host(#{$prefix}-tab-panel) { + @include update_fields_on_layer; + + grid-column: 3/-1; + overflow-y: auto; + + @include breakpoint(lg) { + grid-column: 5/-1; + } +} diff --git a/packages/web-components/src/components/tabs/tabs.stories.ts b/packages/web-components/src/components/tabs/tabs.stories.ts index 8c385d135259..73e03b8eddf3 100644 --- a/packages/web-components/src/components/tabs/tabs.stories.ts +++ b/packages/web-components/src/components/tabs/tabs.stories.ts @@ -213,106 +213,110 @@ export const Vertical = { -
- - Tab label 1 - - Tab label 2 - - - Tab label 3 - - Tab label 4 - Tab label 5 - Tab label 6 - Tab label 7 - -
- - - - - - - -
-
+ + Tab label 1 + + Tab label 2 + + + Tab label 3 + + Tab label 4 + Tab label 5 + Tab label 6 + Tab label 7 + + + + + + + + `; }, }; From b4d0066b83a70b368bd243052f26bb967ac505ec Mon Sep 17 00:00:00 2001 From: Sangeetha Babu Date: Tue, 3 Mar 2026 10:35:16 +0530 Subject: [PATCH 03/11] feat(tabs): vertical tab wrapper component --- .../src/components/tabs/defs.ts | 15 -- .../src/components/tabs/index.ts | 2 +- .../src/components/tabs/tab-panel.ts | 40 ----- .../src/components/tabs/tabs-story.scss | 18 -- .../src/components/tabs/tabs-vertical.ts | 158 ++++++++++++++++++ .../src/components/tabs/tabs.scss | 151 ++++++++++------- .../src/components/tabs/tabs.stories.ts | 134 ++++++++------- .../src/components/tabs/tabs.ts | 29 +--- 8 files changed, 324 insertions(+), 223 deletions(-) delete mode 100644 packages/web-components/src/components/tabs/tab-panel.ts create mode 100644 packages/web-components/src/components/tabs/tabs-vertical.ts diff --git a/packages/web-components/src/components/tabs/defs.ts b/packages/web-components/src/components/tabs/defs.ts index 920ee68c54a2..105caf27b2f0 100644 --- a/packages/web-components/src/components/tabs/defs.ts +++ b/packages/web-components/src/components/tabs/defs.ts @@ -55,21 +55,6 @@ export enum TABS_TYPE { CONTAINED = 'contained', } -/** - * Tabs orientation. - */ -export enum TABS_ORIENTATION { - /** - * Horizontal orientation. - */ - HORIZONTAL = 'horizontal', - - /** - * Vertical orientation. - */ - VERTICAL = 'vertical', -} - /** * Vertical navigation direction, associated with key symbols. */ diff --git a/packages/web-components/src/components/tabs/index.ts b/packages/web-components/src/components/tabs/index.ts index 95a512350d6a..1fd2859e20d0 100644 --- a/packages/web-components/src/components/tabs/index.ts +++ b/packages/web-components/src/components/tabs/index.ts @@ -7,6 +7,6 @@ import './tabs'; import './tab'; -import './tab-panel'; import './tab-skeleton'; import './tabs-skeleton'; +import './tabs-vertical'; diff --git a/packages/web-components/src/components/tabs/tab-panel.ts b/packages/web-components/src/components/tabs/tab-panel.ts deleted file mode 100644 index 31a9a97f2a64..000000000000 --- a/packages/web-components/src/components/tabs/tab-panel.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2026 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { prefix } from '../../globals/settings'; -import styles from './tabs.scss?lit'; -import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; - -/** - * Tab panel component. - * A simple container for tab content that can be shown/hidden based on tab selection. - * - * @element cds-tab-panel - */ -@customElement(`${prefix}-tab-panel`) -export default class CDSTabPanel extends LitElement { - /** - * `true` if this tab panel should be hidden. - */ - @property({ type: Boolean, reflect: true }) - hidden = false; - - connectedCallback() { - super.connectedCallback(); - if (!this.hasAttribute('role')) { - this.setAttribute('role', 'tabpanel'); - } - } - - render() { - return html` `; - } - - static styles = styles; -} diff --git a/packages/web-components/src/components/tabs/tabs-story.scss b/packages/web-components/src/components/tabs/tabs-story.scss index dce505b47bf6..f1b5ad3822b7 100644 --- a/packages/web-components/src/components/tabs/tabs-story.scss +++ b/packages/web-components/src/components/tabs/tabs-story.scss @@ -14,21 +14,3 @@ align-self: stretch; padding: $spacing-05; } - -// Vertical tabs container for story examples -.#{$prefix}--vertical-tabs-container { - display: grid; - block-size: 100%; - grid-template-columns: repeat(16, 1fr); - - // Tab panels positioning for vertical layout - .#{$prefix}-ce-demo-devenv--tab-panels { - padding: $spacing-05; - grid-column: 3/-1; - overflow-y: auto; - - @media (width >= 1056px) { - grid-column: 5/-1; - } - } -} diff --git a/packages/web-components/src/components/tabs/tabs-vertical.ts b/packages/web-components/src/components/tabs/tabs-vertical.ts new file mode 100644 index 000000000000..57dd91869abe --- /dev/null +++ b/packages/web-components/src/components/tabs/tabs-vertical.ts @@ -0,0 +1,158 @@ +/** + * Copyright IBM Corp. 2019, 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { prefix } from '../../globals/settings'; +import styles from './tabs.scss?lit'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; + +/** + * Vertical tabs container component. + * A layout wrapper that arranges a `` alongside its associated tab + * panels in a CSS grid, providing the vertical-tab appearance. + * + * Uses two named slots: + * - `slot="tabs"` — receives the `` navigation element + * - `slot="panel"` — receives `
` elements, wrapped in a + * shadow-DOM `
` for reliable layout styling + * + * The slotted `` receives a `vertical` boolean attribute set in + * `firstUpdated()` — the correct LitElement lifecycle hook because: + * - `connectedCallback` fires during HTML parsing before children are available + * - `firstUpdated()` runs after the first render, when all light-DOM children + * are parsed and upgraded + * + * Height behaviour (mirrors React `` auto-sizing logic): + * - When `custom-height` is provided, it is applied directly to the host element. + * - When `custom-height` is omitted, the host height is auto-calculated as the + * maximum of: (a) the tallest panel height, and (b) the total height of all + * tabs. This ensures all tabs are always fully visible without scrolling, and + * the panel area is tall enough to show the largest panel's content. + * + * @element cds-tabs-vertical + * @slot tabs - The `` navigation element. + * @slot panel - One or more `
` elements. + */ +@customElement(`${prefix}-tabs-vertical`) +export default class CDSTabsVertical extends LitElement { + /** + * Optional height for the vertical tabs container. + * Accepts any valid CSS height value (e.g. '500px', '50vh', '100%'). + * When omitted, the container height is auto-calculated from the tallest + * panel and total tabs height — matching React `` behaviour. + */ + @property({ attribute: 'custom-height' }) + customHeight?: string; + + private _resizeObserver: ResizeObserver | null = null; + + firstUpdated() { + // Light-DOM children are available here (unlike connectedCallback). + // Set `vertical` on the slotted so that + // :host(cds-tabs[vertical]) styles in tabs.scss apply. + const tabs = this.querySelector(`${prefix}-tabs`); + if (tabs) { + tabs.setAttribute('vertical', ''); + } + + // Defer height calculation to after the browser has painted so that + // slotted light-DOM children have their layout computed (offsetHeight > 0). + // Using requestAnimationFrame mirrors React's useIsomorphicEffect timing. + requestAnimationFrame(() => { + this._applyHeight(); + + // Re-calculate whenever any child resizes (e.g. font load, dynamic content). + const resizeObserver = new ResizeObserver(() => { + if (!this.customHeight) { + this._applyHeight(); + } + }); + this._resizeObserver = resizeObserver; + this.querySelectorAll('[slot="panel"]').forEach((panel) => { + resizeObserver.observe(panel); + }); + }); + } + + updated(changedProperties) { + super.updated?.(changedProperties); + if (changedProperties.has('customHeight')) { + this._applyHeight(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + } + + /** + * Applies height to the host element. + * + * - If `customHeight` is set, use it directly (opt-in explicit height). + * - Otherwise, auto-calculates the height as the maximum of: + * 1. The tallest panel — all panels are temporarily un-hidden to measure, + * then re-hidden. Mirrors React `TabPanels` logic (`Tabs.tsx` ~line 1756). + * 2. The total scroll height of all tabs — ensures all tabs are always + * fully visible without the tab list needing to scroll. + */ + private _applyHeight() { + if (this.customHeight) { + this.style.height = this.customHeight; + return; + } + + // Measure all slotted panels. + const panels = Array.from( + this.querySelectorAll('[slot="panel"]') + ); + + if (panels.length === 0) { + return; + } + + // Record hidden states, then un-hide all to get accurate offsetHeight. + const hiddenStates = panels.map((panel) => panel.hidden); + panels.forEach((panel) => { + panel.hidden = false; + }); + + // Find the tallest panel. + const tallestPanel = Math.max(...panels.map((panel) => panel.offsetHeight)); + + // Restore original hidden states. + panels.forEach((panel, index) => { + panel.hidden = hiddenStates[index]; + }); + + // Measure total height of all tabs so they are all visible without scrolling. + // scrollHeight gives the full content height regardless of overflow clipping. + const tabsEl = this.querySelector(`${prefix}-tabs`); + const tabsScrollHeight = tabsEl?.scrollHeight ?? 0; + + const height = Math.max(tallestPanel, tabsScrollHeight); + + if (height > 0) { + this.style.height = `${height}px`; + } else { + this.style.removeProperty('height'); + } + } + + render() { + return html` + +
+ +
+ `; + } + + static styles = styles; +} diff --git a/packages/web-components/src/components/tabs/tabs.scss b/packages/web-components/src/components/tabs/tabs.scss index b2db3ebc980a..c89f7b9ee676 100644 --- a/packages/web-components/src/components/tabs/tabs.scss +++ b/packages/web-components/src/components/tabs/tabs.scss @@ -93,7 +93,8 @@ $inset-transition: inset 110ms motion(standard, productive); align-items: center; padding: $spacing-04 $spacing-05 $spacing-03; - border-block-end: $tab-underline-color; + border-block-end: var(--#{$prefix}-tab-selected-underline-size, 2px) solid + $border-subtle; color: $text-secondary; inline-size: 100%; text-align: start; @@ -187,7 +188,8 @@ $inset-transition: inset 110ms motion(standard, productive); outline: none; .#{$prefix}--tabs__nav-link { - border-block-end: $tab-underline-disabled; + border-block-end: var(--#{$prefix}-tab-selected-underline-size, 2px) solid + $border-disabled; color: $tab-text-disabled; outline: none; pointer-events: none; @@ -237,7 +239,8 @@ $inset-transition: inset 110ms motion(standard, productive); .#{$prefix}--tabs__nav-link { @include type-style('body-short-01'); - border-block-end: $tab-underline-disabled; + border-block-end: var(--#{$prefix}-tab-selected-underline-size, 2px) solid + $border-disabled; color: $tab-text-disabled; @@ -318,20 +321,82 @@ $inset-transition: inset 110ms motion(standard, productive); } } +// In vertical layout, suppress the horizontal selection indicator (blue +// border-block-end) by overriding it to match the unselected tab border. +// CSS custom properties inherit across shadow boundaries, so setting +// --cds-tab-selected-underline-color on cds-tabs[vertical] is picked up +// inside each cds-tab's shadow root by the rule below. +:host(#{$prefix}-tabs[vertical]) { + --#{$prefix}-tab-selected-underline-color: #{$border-subtle}; + --#{$prefix}-tab-selected-underline-size: 1px; +} + +:host(#{$prefix}-tab[selected]) .#{$prefix}--tabs__nav-link { + border-block-end: var(--#{$prefix}-tab-selected-underline-size, 2px) solid + var(--#{$prefix}-tab-selected-underline-color, #{$border-interactive}); +} + //----------------------------- -// Vertical Tabs Orientation +// Vertical Tabs Wrapper //----------------------------- -:host(#{$prefix}-tabs[orientation='vertical']) { +:host(#{$prefix}-tabs-vertical) { + @include emit-layout-tokens(); + @include layout.use('density', $default: 'normal'); + + display: grid; + block-size: 100%; + // tabs slot occupies first 2 columns (lg: 4), panel-container fills the rest + grid-template-columns: repeat(16, 1fr); + + // The tabs slot host — positions the cds-tabs nav rail + ::slotted(#{$prefix}-tabs) { + grid-column: span 2; + + @include breakpoint(lg) { + grid-column: span 4; + } + } + + // Shadow-DOM wrapper for the panel slot — styled directly (no ::slotted needed) + .#{$prefix}-panel-container { + @include update_fields_on_layer; + + background: $layer; + grid-column: 3 / -1; + overflow-y: auto; + + @include breakpoint(lg) { + grid-column: 5 / -1; + } + } +} + +// Slotted tab panel divs — match React's cds--tab-content styles +:host(#{$prefix}-tabs-vertical) ::slotted([role='tabpanel']) { + // stylelint-disable-next-line declaration-no-important + padding: layout.density('padding-inline') !important; + block-size: 100%; + inline-size: 100%; + outline: none; +} + +:host(#{$prefix}-tabs-vertical) + .panel-container + ::slotted([role='tabpanel']:focus) { + @include focus-outline('outline'); +} + +// cds-tabs when slotted inside cds-tabs-vertical (slot="tabs") +:host(#{$prefix}-tabs-vertical) ::slotted(#{$prefix}-tabs) { background: $layer; box-shadow: inset -1px 0 $border-subtle; - grid-column: span 2; max-block-size: none; +} - @include breakpoint(lg) { - grid-column: span 4; - } - +// Internal styles for cds-tabs when inside cds-tabs-vertical +// These are applied via a CSS custom property / attribute set by the wrapper +:host(#{$prefix}-tabs[vertical]) { .#{$prefix}--tabs-nav-content-container { block-size: 100%; overflow-x: hidden; @@ -355,74 +420,34 @@ $inset-transition: inset 110ms motion(standard, productive); .#{$prefix}--tab--overflow-nav-button { display: none; } + + .#{$prefix}--tabs__nav-item-label { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: var(--cds-body-compact-01-line-height); + text-overflow: ellipsis; + white-space: normal; + } } -:host(#{$prefix}-tabs[orientation='vertical']) ::slotted(#{$prefix}-tab) { +:host(#{$prefix}-tabs[vertical]) ::slotted(#{$prefix}-tab) { flex: none; background-color: $layer-01; block-size: $spacing-10; - border-block-end: 1px solid $border-subtle; border-inline-end: 1px solid $border-subtle; box-shadow: inset 3px 0 0 0 $border-subtle; inline-size: 100%; } -:host(#{$prefix}-tabs[orientation='vertical']) - ::slotted(#{$prefix}-tab[selected]) { +:host(#{$prefix}-tabs[vertical]) ::slotted(#{$prefix}-tab[selected]) { border-inline: none; box-shadow: inset 3px 0 0 0 $border-interactive; } -:host(#{$prefix}-tabs[orientation='vertical']) +:host(#{$prefix}-tabs[vertical]) ::slotted(#{$prefix}-tab:not([selected]):not([disabled]):hover) { background-color: $layer-hover; box-shadow: inset 3px 0 0 0 $border-strong; } - -:host(#{$prefix}-tabs[orientation='vertical']) { - .#{$prefix}--tabs__nav-item-label { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-height: var(--cds-body-compact-01-line-height); - text-overflow: ellipsis; - white-space: normal; - } -} - -//----------------------------- -// Tab Panel -//----------------------------- - -:host(#{$prefix}-tab-panel) { - @extend .#{$prefix}--tab-content; - - display: block; - padding: layout.density('padding-inline'); - - &:focus { - @include focus-outline('outline'); - } -} - -:host(#{$prefix}-tab-panel[hidden]) { - display: none; -} - -// Tab panel styles for contained tabs -:host(#{$prefix}-tabs[type='contained']) ~ :host(#{$prefix}-tab-panel) { - background: $layer; -} - -// Tab panel styles for vertical tabs -:host(#{$prefix}-tabs[orientation='vertical']) ~ :host(#{$prefix}-tab-panel) { - @include update_fields_on_layer; - - grid-column: 3/-1; - overflow-y: auto; - - @include breakpoint(lg) { - grid-column: 5/-1; - } -} diff --git a/packages/web-components/src/components/tabs/tabs.stories.ts b/packages/web-components/src/components/tabs/tabs.stories.ts index 73e03b8eddf3..059f3610d7c6 100644 --- a/packages/web-components/src/components/tabs/tabs.stories.ts +++ b/packages/web-components/src/components/tabs/tabs.stories.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { html } from 'lit'; +import { html, nothing } from 'lit'; import { TABS_TYPE } from './tabs'; import styles from './tabs-story.scss?lit'; import { prefix } from '../../globals/settings'; @@ -200,9 +200,19 @@ export const skeleton = { }; export const Vertical = { - args, - argTypes, - render: ({ disabled, contained, selectionMode }) => { + args: { + ...args, + customHeight: '', + }, + argTypes: { + ...argTypes, + customHeight: { + control: 'text', + description: + 'Optional height for the vertical tabs container. Accepts any valid CSS height value (e.g. "500px", "50vh"). If omitted, the container grows to fit its content.', + }, + }, + render: ({ disabled, contained, selectionMode, customHeight }) => { const handleBeforeSelected = (event: CustomEvent) => { if (disabled) { event.preventDefault(); @@ -213,55 +223,58 @@ export const Vertical = { - - Tab label 1 - - Tab label 2 - - - Tab label 3 - - Tab label 4 - Tab label 5 - Tab label 6 - Tab label 7 - + + Tab label 1 + + Tab label 2 + + + Tab label 3 + + Tab label 4 + Tab label 5 + Tab label 6 + Tab label 7 + +