diff --git a/packages/web-components/src/components/tabs/defs.ts b/packages/web-components/src/components/tabs/defs.ts index d37f29abb6e0..0544fa2f4ab2 100644 --- a/packages/web-components/src/components/tabs/defs.ts +++ b/packages/web-components/src/components/tabs/defs.ts @@ -55,6 +55,16 @@ export enum TABS_TYPE { CONTAINED = 'contained', } +/** + * Vertical navigation direction, associated with key symbols. + */ +export const VERTICAL_NAVIGATION_DIRECTION = { + Up: -1, + ArrowUp: -1, + Down: 1, + ArrowDown: 1, +}; + /** * Tabs icon sizes. */ diff --git a/packages/web-components/src/components/tabs/index.ts b/packages/web-components/src/components/tabs/index.ts index 2748172f3400..5f9d73362ca6 100644 --- a/packages/web-components/src/components/tabs/index.ts +++ b/packages/web-components/src/components/tabs/index.ts @@ -9,5 +9,6 @@ import './tabs'; import './tab'; import './tab-skeleton'; import './tabs-skeleton'; +import './tabs-vertical'; import '../badge-indicator'; import '../tooltip'; diff --git a/packages/web-components/src/components/tabs/tab.ts b/packages/web-components/src/components/tabs/tab.ts index e5028445d46d..f7b3d2e489ff 100644 --- a/packages/web-components/src/components/tabs/tab.ts +++ b/packages/web-components/src/components/tabs/tab.ts @@ -7,7 +7,7 @@ import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { property } from 'lit/decorators.js'; +import { property, state, query } from 'lit/decorators.js'; import { prefix } from '../../globals/settings'; import CDSContentSwitcherItem from '../content-switcher/content-switcher-item'; import { TABS_ICON_SIZE, TABS_TYPE } from './defs'; @@ -36,6 +36,13 @@ export default class CDSTab extends CDSContentSwitcherItem { @property({ reflect: true }) type = TABS_TYPE.REGULAR; + /** + * `true` if the tab is in vertical orientation. + * This is automatically set by the parent `` when it's in vertical mode. + */ + @property({ type: Boolean, reflect: true }) + vertical = false; + /** * `true` if this tab is icon-only. */ @@ -53,20 +60,66 @@ export default class CDSTab extends CDSContentSwitcherItem { */ @property() tabTitle; - /** * **Experimental**: Display an empty dot badge on the Tab. */ @property({ type: Boolean, reflect: true, attribute: 'badge-indicator' }) badgeIndicator = false; + /** + * `true` if the tab text is truncated with ellipsis. + * This state is automatically updated when the component renders in vertical mode. + */ + @state() + truncated = false; + + /** + * Reference to the label span element (only present in vertical mode). + * @private + */ + @query(`.${prefix}--tabs__nav-item-label`) + private _labelElement?: HTMLElement; + + /** + * Checks if the text overflow ellipsis is currently applied to the label. + * This is useful for determining if a tooltip should be shown. + * + * @returns `true` if text is truncated/clamped, `false` otherwise or if not in vertical mode + */ + isTextTruncated(): boolean { + if (!this.vertical || !this._labelElement) { + return false; + } + + // Compare scrollHeight with clientHeight to detect if content is overflowing + // When line-clamp is active and text exceeds 2 lines, scrollHeight > clientHeight + return this._labelElement.scrollHeight > this._labelElement.clientHeight; + } + + /** + * Updates the truncated state after the component has rendered. + */ + updated(changedProperties: Map) { + super.updated(changedProperties); + + // Check if text is truncated and update state when in vertical mode + if (this.vertical && this._labelElement) { + const isTruncated = this.isTextTruncated(); + if (this.truncated !== isTruncated) { + this.truncated = isTruncated; + } + } + } + /** * Handles `slotchange` event. */ protected _handleSlotChange({ target }: Event) { // Retrieve content of the slot to use for aria-label. const content = (target as HTMLSlotElement).assignedNodes(); - this.tabTitle = content[0]?.textContent?.trim() || undefined; + const textContent = content[0]?.textContent; + // Normalize whitespace: trim and replace multiple spaces with single space + this.tabTitle = textContent?.trim().replace(/\s+/g, ' ') || undefined; } connectedCallback() { @@ -82,6 +135,8 @@ export default class CDSTab extends CDSContentSwitcherItem { disabled, selected, tabTitle, + vertical, + truncated, _handleSlotChange: handleSlotChange, } = this; const accessibleLabel = tabTitle || this.getAttribute('aria-label'); @@ -97,9 +152,17 @@ export default class CDSTab extends CDSContentSwitcherItem { tabindex="${selected ? 0 : -1}" ?disabled="${disabled}" aria-selected="${selected}"> - - - + ${vertical + ? html` + + ` + : html` + + + + `} ${!disabled && badgeIndicator ? html`` : ''} diff --git a/packages/web-components/src/components/tabs/tabs-story.scss b/packages/web-components/src/components/tabs/tabs-story.scss index 7f305658e2f7..91580ee6de6c 100644 --- a/packages/web-components/src/components/tabs/tabs-story.scss +++ b/packages/web-components/src/components/tabs/tabs-story.scss @@ -28,3 +28,8 @@ #{$prefix}-tabs[type='contained'] ~ .#{$prefix}--tab-content { background: $layer; } + +.#{$prefix}-ce-demo-devenv--tab-story-button { + display: inline-block; + inline-size: auto; +} 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..1fdc5b57163b --- /dev/null +++ b/packages/web-components/src/components/tabs/tabs-vertical.ts @@ -0,0 +1,138 @@ +/** + * 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'; + +/** + * Breakpoint for switching between horizontal and vertical tab layouts. + * Matches Carbon's md breakpoint (673px) - below this, tabs display horizontally. + */ +const VERTICAL_TABS_BREAKPOINT = '(min-width: 673px)'; + +/** + * Vertical tabs container component. + * + * @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 { + /** + * Option to set a height style only if using vertical variation. + */ + @property({ attribute: 'custom-height' }) + customHeight?: string; + + private _mediaQueryList: MediaQueryList | null = null; + + private _handleViewportChange = (e: MediaQueryListEvent | MediaQueryList) => { + const tabs = this.querySelector(`${prefix}-tabs`); + if (e.matches) { + this.classList.add(`${prefix}--css-grid`); + if (tabs) { + tabs.setAttribute('vertical', ''); + tabs.removeAttribute('type'); + } + } else { + this.classList.remove(`${prefix}--css-grid`); + if (tabs) { + tabs.removeAttribute('vertical'); + tabs.setAttribute('type', 'contained'); + } + } + }; + + firstUpdated() { + this._mediaQueryList = window.matchMedia(VERTICAL_TABS_BREAKPOINT); + this._handleViewportChange(this._mediaQueryList); + this._mediaQueryList.addEventListener('change', this._handleViewportChange); + + requestAnimationFrame(() => { + this._applyHeight(); + }); + + const panelSlot = this.shadowRoot?.querySelector('slot[name="panel"]'); + panelSlot?.addEventListener('slotchange', () => { + this._applyHeight(); + }); + } + + updated(changedProperties) { + super.updated?.(changedProperties); + if (changedProperties.has('customHeight')) { + this._applyHeight(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._mediaQueryList?.removeEventListener( + 'change', + this._handleViewportChange + ); + this._mediaQueryList = null; + } + + private _applyHeight() { + const isVertical = this.classList.contains(`${prefix}--css-grid`); + + if (this.customHeight) { + this.style.height = this.customHeight; + return; + } + + if (!isVertical) { + this.style.removeProperty('height'); + return; + } + + const panels = Array.from( + this.querySelectorAll('[slot="panel"]') + ); + + if (panels.length === 0) { + return; + } + + const hiddenStates = panels.map((panel) => panel.hidden); + panels.forEach((panel) => { + panel.hidden = false; + }); + + const tallestPanel = Math.max(...panels.map((panel) => panel.offsetHeight)); + + panels.forEach((panel, index) => { + panel.hidden = hiddenStates[index]; + }); + + const tabsEl = this.querySelector(`${prefix}-tabs`); + const tabsHeight = tabsEl?.offsetHeight ?? 0; + + const height = Math.max(tallestPanel, tabsHeight); + 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.mdx b/packages/web-components/src/components/tabs/tabs.mdx index be76b5dd5d98..5da7521624b6 100644 --- a/packages/web-components/src/components/tabs/tabs.mdx +++ b/packages/web-components/src/components/tabs/tabs.mdx @@ -52,8 +52,16 @@ flexibility in what is in rendered inside of `cds-tab` and a tab's panel. +### Vertical + + + ## Component API +## `cds-tabs-vertical` + + + ## `cds-tabs` diff --git a/packages/web-components/src/components/tabs/tabs.scss b/packages/web-components/src/components/tabs/tabs.scss index fca75782be25..30f4ceac27f6 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); @@ -145,7 +146,7 @@ $inset-transition: inset 110ms motion(standard, productive); outline: none; } -:host(#{$prefix}-tab:not([type='contained'])) { +:host(#{$prefix}-tab:not([type='contained']):not([vertical])) { margin-inline-end: convert.to-rem(1px); .#{$prefix}--tabs__nav-link { @@ -154,9 +155,15 @@ $inset-transition: inset 110ms motion(standard, productive); } } -:host(#{$prefix}-tab:not([type='contained']):last-of-type) { +:host(#{$prefix}-tab:not([type='contained']):not([vertical]):last-of-type) { margin-inline-end: 0; } +:host(#{$prefix}-tab[vertical]) { + .#{$prefix}--tabs__nav-link { + padding-block-end: $spacing-03; + padding-block-start: $spacing-03; + } +} :host(#{$prefix}-tab[badge-indicator][icon-only]:not([icon-size='lg'])) #{$prefix}-badge-indicator, @@ -435,3 +442,153 @@ $inset-transition: inset 110ms motion(standard, productive); background-color: SelectedItem; } } + +:host(#{$prefix}-tab[vertical]) .#{$prefix}--tabs__nav-link { + display: flex; + align-items: center; + border-block-end: 1px solid $border-subtle; +} + +:host(#{$prefix}-tab[vertical][selected]) .#{$prefix}--tabs__nav-link { + border-block-end: 1px solid $border-subtle; +} + +:host(#{$prefix}-tab[vertical][disabled]) .#{$prefix}--tabs__nav-link { + border-block-end: 1px solid $border-subtle; +} + +//----------------------------- +// Vertical Tabs Wrapper +//----------------------------- + +:host(#{$prefix}-tabs-vertical) { + @include emit-layout-tokens(); + @include layout.use('density', $default: 'normal'); + + grid-column: span 2; + max-block-size: none; + + &.#{$prefix}--css-grid { + box-shadow: inset -1px 0 $border-subtle; + } + + ::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; + 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; + max-block-size: none; +} + +// 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; + 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; + } + + .#{$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}-tab[vertical]) { + flex: none; + background-color: $layer-01; + block-size: $spacing-10; + border-inline-end: 1px solid $border-subtle; + box-shadow: inset 3px 0 0 0 $border-subtle; + inline-size: 100%; + + .#{$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}-tab[vertical][disabled]) { + border-inline-end: 1px solid $border-subtle; + box-shadow: inset 3px 0 0 0 $border-subtle; +} + +:host(#{$prefix}-tab[vertical]:hover), +:host(#{$prefix}-tab[vertical][disabled]:hover) { + .#{$prefix}--tabs__nav-link { + border-block-end: 1px solid $border-subtle; + } +} + +:host(#{$prefix}-tab[vertical][selected]) { + border-inline: none; + box-shadow: inset 3px 0 0 0 $border-interactive; +} + +:host(#{$prefix}-tab[vertical]:not([selected]):not([disabled]):hover) { + background-color: $layer-hover; + box-shadow: inset 3px 0 0 0 $border-strong; +} diff --git a/packages/web-components/src/components/tabs/tabs.stories.ts b/packages/web-components/src/components/tabs/tabs.stories.ts index f9b5ece32497..35b7ab8d7b00 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 { ifDefined } from 'lit/directives/if-defined.js'; import { action } from 'storybook/actions'; import { TABS_ICON_SIZE, TABS_TYPE } from './tabs'; @@ -25,6 +25,9 @@ import IbmWatsonDiscovery16 from '@carbon/icons/es/ibm-watson--discovery/16.js'; import IbmWatsonDiscovery20 from '@carbon/icons/es/ibm-watson--discovery/20.js'; import '../button'; import '../checkbox'; +import '../layer'; +import '../radio-button'; +import '../stack'; import './index'; import '../text-input'; @@ -448,6 +451,185 @@ export const skeleton = { `, }; +export const Vertical = { + args: { + selectionMode: 'automatic', + selectedIndex: 0, + customHeight: '', + }, + argTypes: { + selectionMode: { + control: 'select', + description: + 'Choose whether or not to automatically change selection on focus when left/right arrow pressed.', + options: ['automatic', 'manual'], + }, + selectedIndex: { + control: 'number', + description: 'Specify a selected index for the initially selected tab.', + }, + 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: ({ selectionMode, selectedIndex, customHeight }) => { + const handleBeforeSelected = (event: CustomEvent) => { + action('cds-tabs-beingselected')(event.detail); + }; + + const handleSelected = (event: CustomEvent) => { + action('cds-tabs-selected')(event.detail); + }; + + return html` + + + + Dashboard + + Extra long label that will go two lines then truncate when it goes + beyond the Tab length + + + Activity + + Analyze + Investigate + Learn + Settings + + + + + + + + + + `; + }, +}; + export const WithIcons = { render: () => { return html` diff --git a/packages/web-components/src/components/tabs/tabs.ts b/packages/web-components/src/components/tabs/tabs.ts index 61f0111d90a7..e6011892063f 100644 --- a/packages/web-components/src/components/tabs/tabs.ts +++ b/packages/web-components/src/components/tabs/tabs.ts @@ -18,13 +18,19 @@ import ChevronRight16 from '@carbon/icons/es/chevron--right/16.js'; import CDSContentSwitcher, { NAVIGATION_DIRECTION, } from '../content-switcher/content-switcher'; -import { TABS_ICON_SIZE, TABS_KEYBOARD_ACTION, TABS_TYPE } from './defs'; +import { + VERTICAL_NAVIGATION_DIRECTION, + TABS_ICON_SIZE, + TABS_KEYBOARD_ACTION, + TABS_TYPE, +} from './defs'; import CDSTab from './tab'; import styles from './tabs.scss?lit'; import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; export { NAVIGATION_DIRECTION, + VERTICAL_NAVIGATION_DIRECTION, TABS_ICON_SIZE, TABS_KEYBOARD_ACTION, TABS_TYPE, @@ -143,8 +149,12 @@ 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 enabledTabs = this.querySelectorAll(`${prefix}-tab:not([disabled])`); + const { selectorItemEnabled } = this.constructor as typeof CDSTabs; + const action = (this.constructor as typeof CDSTabs).getAction( + key, + this.vertical + ); + const enabledTabs = this.querySelectorAll(selectorItemEnabled); switch (action) { case TABS_KEYBOARD_ACTION.HOME: { @@ -182,7 +192,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 = this.vertical + ? VERTICAL_NAVIGATION_DIRECTION[key] + : NAVIGATION_DIRECTION[key]; if (direction) { this._navigate(direction); } @@ -254,6 +267,23 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { if (nextItem) { (nextItem as CDSTab).hideDivider = true; } + + // Set vertical attribute on all tabs if this tabs component is vertical + this._updateTabsVerticalAttribute(); + } + + /** + * Updates the vertical attribute on all child tabs based on the vertical property. + */ + private _updateTabsVerticalAttribute() { + const { selectorItem } = this.constructor as typeof CDSTabs; + forEach(this.querySelectorAll(selectorItem), (tab) => { + if (this.vertical) { + (tab as CDSTab).setAttribute('vertical', ''); + } else { + (tab as CDSTab).removeAttribute('vertical'); + } + }); } protected _selectionDidChange( @@ -321,6 +351,13 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { @property({ reflect: true }) type = TABS_TYPE.REGULAR; + /** + * `true` if the tabs are in vertical orientation. + * This is automatically set by `cds-tabs-vertical`. + */ + @property({ type: Boolean }) + vertical = false; + /** * Specify the icon size used by icon-only tabs. */ @@ -452,6 +489,10 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { // Call super to keep selection/value in sync super.updated?.(changedProperties); + if (changedProperties.has('vertical')) { + this._updateTabsVerticalAttribute(); + } + if (changedProperties.has('value')) { const tab = this.querySelector( `${prefix}-tab[value="${this.value}"]` @@ -641,16 +682,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 === ' ') {