diff --git a/src/components/sbb-tab-group/readme.md b/src/components/sbb-tab-group/readme.md index bc664afea0..dec14047ee 100644 --- a/src/components/sbb-tab-group/readme.md +++ b/src/components/sbb-tab-group/readme.md @@ -46,9 +46,10 @@ or using the `amount` slot of the `sbb-tab-title`. ## Properties -| Property | Attribute | Description | Type | Default | -| ---------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -| `initialSelectedIndex` | `initial-selected-index` | Sets the initial tab. If it matches a disabled tab or exceeds the length of the tab group, the first enabled tab will be selected. | `number` | `0` | +| Property | Attribute | Description | Type | Default | +| ---------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------- | +| `initialSelectedIndex` | `initial-selected-index` | Sets the initial tab. If it matches a disabled tab or exceeds the length of the tab group, the first enabled tab will be selected. | `number` | `0` | +| `size` | `size` | Size variant, either l or xl. | `"l" \| "xl"` | `'l'` | ## Events diff --git a/src/components/sbb-tab-group/sbb-tab-group.custom.d.ts b/src/components/sbb-tab-group/sbb-tab-group.custom.d.ts index 5dc3f7a70c..2d445617d6 100644 --- a/src/components/sbb-tab-group/sbb-tab-group.custom.d.ts +++ b/src/components/sbb-tab-group/sbb-tab-group.custom.d.ts @@ -13,4 +13,5 @@ export interface InterfaceSbbTabGroupTab extends HTMLStencilElement { relatedContent?: HTMLElement; index?: number; tabGroupActions?: InterfaceSbbTabGroupActions; + size: 'l' | 'xl'; } diff --git a/src/components/sbb-tab-group/sbb-tab-group.scss b/src/components/sbb-tab-group/sbb-tab-group.scss index 6202078a13..cc5a8b3e6b 100644 --- a/src/components/sbb-tab-group/sbb-tab-group.scss +++ b/src/components/sbb-tab-group/sbb-tab-group.scss @@ -7,7 +7,6 @@ .tab-group { display: flex; flex-wrap: wrap; - gap: var(--sbb-spacing-fixed-3x); } .tab-content { diff --git a/src/components/sbb-tab-group/sbb-tab-group.stories.tsx b/src/components/sbb-tab-group/sbb-tab-group.stories.tsx index 5c8df1d2ca..5ac5f0c30b 100644 --- a/src/components/sbb-tab-group/sbb-tab-group.stories.tsx +++ b/src/components/sbb-tab-group/sbb-tab-group.stories.tsx @@ -6,7 +6,7 @@ import { withActions } from '@storybook/addon-actions/decorator'; import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; import type { InputType } from '@storybook/types'; -const firstTabTitle = ({ label, ...args }): JSX.Element => ( +const firstTabTitle = (label, args): JSX.Element => ( {label} ); @@ -40,9 +40,9 @@ const tabPanelFour = (): JSX.Element => ( ); -const DefaultTemplate = (args): JSX.Element => ( - - {firstTabTitle(args)} +const DefaultTemplate = ({ size, label, ...args }): JSX.Element => ( + + {firstTabTitle(label, args)} {tabPanelOne()} Tab title two @@ -56,9 +56,9 @@ const DefaultTemplate = (args): JSX.Element => ( ); -const IconsAndNumbersTemplate = (args): JSX.Element => ( - - {firstTabTitle(args)} +const IconsAndNumbersTemplate = ({ size, label, ...args }): JSX.Element => ( + + {firstTabTitle(label, args)} {tabPanelOne()} @@ -78,10 +78,10 @@ const IconsAndNumbersTemplate = (args): JSX.Element => ( ); -const NestedTemplate = (args): JSX.Element => ( - - {firstTabTitle(args)} - +const NestedTemplate = ({ size, label, ...args }): JSX.Element => ( + + {firstTabTitle(label, args)} + Nested tab
Diam maecenas ultricies mi eget mauris pharetra et ultrices neque ornare aenean euismod @@ -143,16 +143,25 @@ const amount: InputType = { }, }; +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['l', 'xl'], +}; + const basicArgTypes: ArgTypes = { label, 'icon-name': iconName, amount: amount, + size: size, }; const basicArgs: Args = { label: 'Tab label one', 'icon-name': undefined, amount: undefined, + size: size.options[0], }; const templateRes = [ @@ -164,20 +173,34 @@ const templateRes = [ withActions as Decorator, ]; -export const defaultTabs: StoryObj = { +export const defaultTabsSizeL: StoryObj = { render: DefaultTemplate, argTypes: basicArgTypes, args: { ...basicArgs }, decorators: templateRes, }; -export const numbersAndIcons: StoryObj = { +export const numbersAndIconsSizeL: StoryObj = { render: IconsAndNumbersTemplate, argTypes: basicArgTypes, args: { ...basicArgs, amount: 16, 'icon-name': iconName.options[0] }, decorators: templateRes, }; +export const defaultTabsSizeXL: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, size: size.options[1] }, + decorators: templateRes, +}; + +export const numbersAndIconsSizeXL: StoryObj = { + render: IconsAndNumbersTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, amount: 16, 'icon-name': iconName.options[0], size: size.options[1] }, + decorators: templateRes, +}; + export const nestedTabGroups: StoryObj = { render: NestedTemplate, argTypes: basicArgTypes, diff --git a/src/components/sbb-tab-group/sbb-tab-group.tsx b/src/components/sbb-tab-group/sbb-tab-group.tsx index 027cf7e505..e7e4cffaa2 100644 --- a/src/components/sbb-tab-group/sbb-tab-group.tsx +++ b/src/components/sbb-tab-group/sbb-tab-group.tsx @@ -10,10 +10,11 @@ import { Listen, Method, Prop, + Watch, } from '@stencil/core'; import { InterfaceSbbTabGroupTab } from './sbb-tab-group.custom'; import { isArrowKeyPressed, getNextElementIndex, interactivityChecker } from '../../global/a11y'; -import { isValidAttribute, hostContext } from '../../global/dom'; +import { isValidAttribute, hostContext, toggleDatasetEntry } from '../../global/dom'; import { throttle } from '../../global/eventing'; import { AgnosticMutationObserver, AgnosticResizeObserver } from '../../global/observers'; @@ -40,25 +41,41 @@ let nextId = 0; tag: 'sbb-tab-group', }) export class SbbTabGroup implements ComponentInterface { - public tabs: InterfaceSbbTabGroupTab[] = []; + private _tabs: InterfaceSbbTabGroupTab[] = []; private _selectedTab: InterfaceSbbTabGroupTab; private _isNested: boolean; + private _tabGroupElement: HTMLElement; private _tabContentElement: HTMLElement; - private _tabAttributeObserver = new AgnosticMutationObserver( - this._onTabAttributesChange.bind(this), + private _tabAttributeObserver = new AgnosticMutationObserver((mutationsList) => + this._onTabAttributesChange(mutationsList), ); - private _tabContentResizeObserver = new AgnosticResizeObserver( - this._onTabContentElementResize.bind(this), + private _tabGroupResizeObserver = new AgnosticResizeObserver((entries) => + this._onTabGroupElementResize(entries), + ); + private _tabContentResizeObserver = new AgnosticResizeObserver((entries) => + this._onTabContentElementResize(entries), ); @Element() private _element: HTMLElement; + /** + * Size variant, either l or xl. + */ + @Prop() public size: InterfaceSbbTabGroupTab['size'] = 'l'; + /** * Sets the initial tab. If it matches a disabled tab or exceeds the length of * the tab group, the first enabled tab will be selected. */ @Prop() public initialSelectedIndex = 0; + @Watch('size') + public updateSize(): void { + for (const tab of this._tabs) { + tab.setAttribute('data-size', this.size); + } + } + /** * Emits an event on selected tab change */ @@ -73,7 +90,7 @@ export class SbbTabGroup implements ComponentInterface { */ @Method() public async disableTab(tabIndex: number): Promise { - this.tabs[tabIndex]?.tabGroupActions.disable(); + this._tabs[tabIndex]?.tabGroupActions.disable(); } /** @@ -82,7 +99,7 @@ export class SbbTabGroup implements ComponentInterface { */ @Method() public async enableTab(tabIndex: number): Promise { - this.tabs[tabIndex]?.tabGroupActions.enable(); + this._tabs[tabIndex]?.tabGroupActions.enable(); } /** @@ -91,7 +108,7 @@ export class SbbTabGroup implements ComponentInterface { */ @Method() public async activateTab(tabIndex: number): Promise { - this.tabs[tabIndex]?.tabGroupActions.select(); + this._tabs[tabIndex]?.tabGroupActions.select(); } private _getTabs(): InterfaceSbbTabGroupTab[] { @@ -101,7 +118,7 @@ export class SbbTabGroup implements ComponentInterface { } private get _enabledTabs(): InterfaceSbbTabGroupTab[] { - return this.tabs.filter( + return this._tabs.filter( (t) => !isValidAttribute(t, 'disabled') && interactivityChecker.isVisible(t), ); } @@ -111,24 +128,26 @@ export class SbbTabGroup implements ComponentInterface { } public componentDidLoad(): void { - this.tabs = this._getTabs(); - this.tabs.forEach((tab) => this._configure(tab)); + this._tabs = this._getTabs(); + this._tabs.forEach((tab) => this._configure(tab)); this._initSelection(); + this._tabGroupResizeObserver.observe(this._tabGroupElement); } public disconnectedCallback(): void { this._tabAttributeObserver.disconnect(); this._tabContentResizeObserver.disconnect(); + this._tabGroupResizeObserver.disconnect(); } private _onContentSlotChange = (): void => { this._tabContentElement = this._element.shadowRoot.querySelector('div.tab-content'); - const loadedTabs = this._getTabs().filter((tab) => !this.tabs.includes(tab)); + const loadedTabs = this._getTabs().filter((tab) => !this._tabs.includes(tab)); // if a new tab/content is added to the tab group if (loadedTabs.length) { loadedTabs.forEach((tab) => this._configure(tab)); - this.tabs = this.tabs.concat(loadedTabs); + this._tabs = this._tabs.concat(loadedTabs); } }; @@ -136,14 +155,15 @@ export class SbbTabGroup implements ComponentInterface { const tabs = this._getTabs(); // if a tab is removed from the tab group - if (tabs.length < this.tabs.length) { - const removedTabs = this.tabs.filter((tab) => !tabs.includes(tab)); + if (tabs.length < this._tabs.length) { + const removedTabs = this._tabs.filter((tab) => !tabs.includes(tab)); removedTabs.forEach((removedTab) => { removedTab.relatedContent?.remove(); }); - this.tabs = tabs; + this._tabs = tabs; } + this._tabs.forEach((tab: HTMLSbbTabTitleElement) => tab.setAttribute('data-size', this.size)); }; private _assignId(): string { @@ -153,10 +173,10 @@ export class SbbTabGroup implements ComponentInterface { private _initSelection(): void { if ( this.initialSelectedIndex >= 0 && - this.initialSelectedIndex < this.tabs.length && - !this.tabs[this.initialSelectedIndex].disabled + this.initialSelectedIndex < this._tabs.length && + !this._tabs[this.initialSelectedIndex].disabled ) { - this.tabs[this.initialSelectedIndex].tabGroupActions.select(); + this._tabs[this.initialSelectedIndex].tabGroupActions.select(); } else { this._enabledTabs[0]?.tabGroupActions.select(); } @@ -186,6 +206,26 @@ export class SbbTabGroup implements ComponentInterface { } } + private _onTabGroupElementResize(entries: ResizeObserverEntry[]): void { + for (const entry of entries) { + const tabTitles = ( + entry.target.firstElementChild as HTMLSlotElement + ).assignedElements() as HTMLSbbTabTitleElement[]; + + for (const tab of tabTitles) { + toggleDatasetEntry( + tab, + 'hasDivider', + tab === tabTitles[0] || tab.offsetLeft === tabTitles[0].offsetLeft, + ); + this._element.style.setProperty( + '--sbb-tab-group-width', + `${this._tabGroupElement.clientWidth}px`, + ); + } + } + } + private _onTabContentElementResize(entries: ResizeObserverEntry[]): void { for (const entry of entries) { const contentHeight = Math.floor(entry.contentRect.height); @@ -309,7 +349,7 @@ export class SbbTabGroup implements ComponentInterface { public render(): JSX.Element { return ( -
+
(this._tabGroupElement = el)}>
diff --git a/src/components/sbb-tab-title/sbb-tab-title.scss b/src/components/sbb-tab-title/sbb-tab-title.scss index 10d8d400e2..5a9272a44d 100644 --- a/src/components/sbb-tab-title/sbb-tab-title.scss +++ b/src/components/sbb-tab-title/sbb-tab-title.scss @@ -5,17 +5,14 @@ @include sbb.host-component-properties; :host { - --sbb-tab-title-height: #{sbb.px-to-rem-build(40)}; - --sbb-tab-title-border-radius: var(--sbb-border-radius-infinity); - --sbb-tab-title-color: var(--sbb-color-charcoal-default); + --sbb-tab-title-height: var(--sbb-spacing-fixed-12x); + --sbb-tab-title-color: var(--sbb-color-granite-default); --sbb-tab-title-icon-color: var(--sbb-color-black-default); --sbb-tab-title-background-color: var(--sbb-color-white-default); - --sbb-tab-title-border-color: var(--sbb-color-cloud-default); --sbb-tab-title-cursor: pointer; --sbb-tab-title-pointer-events: unset; - --sbb-tab-title-shift: translateY(0); --sbb-tab-title-inset: 0; - --sbb-tab-title-gap: var(--sbb-spacing-fixed-2x); + --sbb-tab-title-marker-transform: scale(0); --sbb-tab-title-text-decoration: none; --sbb-tab-title-animation-duration: var(--sbb-animation-duration-2x); --sbb-tab-title-animation-easing: var(--sbb-animation-easing); @@ -23,83 +20,79 @@ display: inline-block; max-width: 100%; + pointer-events: var(--sbb-tab-title-pointer-events); -webkit-tap-highlight-color: transparent; // Use !important here to not interfere with Firefox focus ring definition // which appears in normalize css of several frameworks. outline: none !important; + @include sbb.mq($from: medium) { + --sbb-tab-title-height: var(--sbb-spacing-fixed-14x); + } + @include sbb.if-forced-colors { --sbb-tab-title-color: ButtonText; --sbb-tab-title-icon-color: ButtonText; --sbb-tab-title-amount-color: ButtonText; - --sbb-tab-title-border-color: CanvasText; } } :host([disabled]:not([disabled='false'])) { - --sbb-tab-title-color: var(--sbb-color-granite-default); --sbb-tab-title-icon-color: var(--sbb-color-granite-default); --sbb-tab-title-background-color: var(--sbb-color-milk-default); - --sbb-tab-title-border-color: var(--sbb-color-cloud-default); --sbb-tab-title-cursor: unset; --sbb-tab-title-pointer-events: none; - --sbb-tab-title-amount-color: var(--sbb-color-granite-default); --sbb-tab-title-text-decoration: line-through; @include sbb.if-forced-colors { --sbb-tab-title-color: GrayText; --sbb-tab-title-icon-color: GrayText; --sbb-tab-title-amount-color: GrayText; - --sbb-tab-title-border-color: GrayText; } } // If active and not disabled :host([active]:not([active='false'], [disabled]:not([disabled='false']))) { - --sbb-tab-title-color: var(--sbb-color-white-default); + --sbb-tab-title-color: var(--sbb-color-charcoal-default); --sbb-tab-title-icon-color: var(--sbb-tab-title-color); --sbb-tab-title-background-color: var(--sbb-color-black-default); - --sbb-tab-title-border-color: var(--sbb-color-black-default); - --sbb-tab-title-amount-color: var(--sbb-color-cement-default); --sbb-tab-title-cursor: unset; --sbb-tab-title-pointer-events: none; + --sbb-tab-title-marker-transform: scale(1); @include sbb.if-forced-colors { --sbb-tab-title-color: ButtonText; --sbb-tab-title-icon-color: ButtonText; --sbb-tab-title-amount-color: ButtonText; - --sbb-tab-title-border-color: Highlight; } } -// Hover if not disabled, not active and not pressed -:host( - :hover:not( - [disabled]:not([disabled='false']), - [data-active], - :active, - [active]:not([active='false']) - ) - ) { +:host(:hover:not([disabled]:not([disabled='false']))) { @include sbb.hover-mq($hover: true) { - --sbb-tab-title-shift: translateY(calc(-1 * #{sbb.px-to-rem-build(1)})); - --sbb-tab-title-inset: calc(-1 * #{sbb.px-to-rem-build(2)}); - --sbb-tab-title-background-color: var(--sbb-color-milk-default); - - @include sbb.if-forced-colors { - --sbb-tab-title-border-color: Highlight; - } + --sbb-tab-title-marker-transform: scale(1); } } // Pressed/active state :host(:is([data-active], :active)) { - --sbb-tab-title-border-color: var(--sbb-color-black-default); - --sbb-tab-title-background-color: var(--sbb-color-white-default); + --sbb-tab-title-color: var(--sbb-color-charcoal-default); +} - @include sbb.if-forced-colors { - --sbb-tab-title-border-color: Highlight; +.sbb-tab-title__wrapper { + position: relative; + + // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. + :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { + &::before { + content: ''; + position: absolute; + display: block; + inset: calc((var(--sbb-focus-outline-width) + var(--sbb-focus-outline-offset)) * -1); + border: var(--sbb-focus-outline-width) solid var(--sbb-focus-outline-color); + border-radius: var(--sbb-border-radius-2x); + z-index: 1; + } } } @@ -109,66 +102,79 @@ min-height: var(--sbb-tab-title-height); display: flex; align-items: center; - padding-block: var(--sbb-spacing-fixed-2x); - padding-inline: var(--sbb-spacing-fixed-5x); - gap: var(--sbb-tab-title-gap); + padding-inline: var(--sbb-spacing-responsive-xs); + gap: var(--sbb-spacing-fixed-2x); user-select: none; cursor: var(--sbb-tab-title-cursor); transition: color var(--sbb-tab-title-animation-duration) var(--sbb-tab-title-animation-easing); color: var(--sbb-tab-title-icon-color); - pointer-events: var(--sbb-tab-title-pointer-events); + + // Show a border under the tab-group and between flex rows when the tab titles wrap to a new line + :host([data-has-divider]) & { + &::after { + content: ''; + position: absolute; + inset-inline-start: 0; + inset-block-end: 0; + width: var(--sbb-tab-group-width); + height: var(--sbb-border-width-1x); + background-color: var(--sbb-color-cloud-default); + } + } &::before { position: absolute; content: ''; - inset: var(--sbb-tab-title-inset); - border: var(--sbb-border-width-1x) solid var(--sbb-tab-title-border-color); - border-radius: var(--sbb-tab-title-border-radius); - background-color: var(--sbb-tab-title-background-color); - transition-duration: var(--sbb-tab-title-animation-duration); - transition-timing-function: var(--sbb-tab-title-animation-easing); - transition-property: inset, background-color, border-color, box-shadow; - - // Hide focus outline when focus origin is mouse or touch. This is being used in tooltip as a workaround. - :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { - @include sbb.focus-outline; + inset-inline: 0; + inset-block-end: 0; + height: #{sbb.px-to-rem-build(3)}; + background-color: var(--sbb-tab-title-color); + transform: var(--sbb-tab-title-marker-transform); + transition: { + duration: var(--sbb-tab-title-animation-duration); + timing-function: var(--sbb-tab-title-animation-easing); + property: transform, background-color; } - @include sbb.if-forced-colors { - border-width: var(--sbb-border-width-2x); - } + z-index: 1; } } +.sbb-tab-title__icon, +.sbb-tab-title__text, +.sbb-tab-title__amount { + text-decoration: var(--sbb-tab-title-text-decoration); +} + .sbb-tab-title__icon { display: flex; flex-shrink: 0; + color: var(--sbb-tab-title-color); + transition: color var(--sbb-tab-title-animation-duration) var(--sbb-tab-title-animation-easing); } .sbb-tab-title__text { - @include sbb.text-xs--bold; + @include sbb.text-m--bold; @include sbb.font-smoothing; @include sbb.ellipsis; + :host([data-size='xl']) & { + @include sbb.text-xl--bold; + } + color: var(--sbb-tab-title-color); transition: color var(--sbb-tab-title-animation-duration) var(--sbb-tab-title-animation-easing); } .sbb-tab-title__amount { - @include sbb.text-xs--regular; + @include sbb.text-m--regular; @include sbb.font-smoothing; + :host([data-size='xl']) & { + @include sbb.text-xl--regular; + } + display: flex; color: var(--sbb-tab-title-amount-color); transition: color var(--sbb-tab-title-animation-duration) var(--sbb-tab-title-animation-easing); } - -.sbb-tab-title__icon, -.sbb-tab-title__text, -.sbb-tab-title__amount { - transition: transform var(--sbb-tab-title-animation-duration) - var(--sbb-tab-title-animation-easing); - transform: var(--sbb-tab-title-shift); - will-change: transform; - text-decoration: var(--sbb-tab-title-text-decoration); -} diff --git a/src/components/sbb-tab-title/sbb-tab-title.spec.ts b/src/components/sbb-tab-title/sbb-tab-title.spec.ts index 620cae0755..b68745c04b 100644 --- a/src/components/sbb-tab-title/sbb-tab-title.spec.ts +++ b/src/components/sbb-tab-title/sbb-tab-title.spec.ts @@ -11,11 +11,13 @@ describe('sbb-tab-title', () => { expect(root).toEqualHtml(` -

- - - -

+
+

+ + + +

+
`); @@ -28,18 +30,20 @@ describe('sbb-tab-title', () => { }); expect(root).toEqualHtml(` - - -

- - - - - - -

-
-
+ + +
+

+ + + + + + +

+
+
+
`); }); @@ -52,14 +56,16 @@ describe('sbb-tab-title', () => { expect(root).toEqualHtml(` -

- - - - - 78 - -

+
+

+ + + + + 78 + +

+
`); diff --git a/src/components/sbb-tab-title/sbb-tab-title.tsx b/src/components/sbb-tab-title/sbb-tab-title.tsx index c3e1e613ca..c6b98cbc55 100644 --- a/src/components/sbb-tab-title/sbb-tab-title.tsx +++ b/src/components/sbb-tab-title/sbb-tab-title.tsx @@ -62,21 +62,23 @@ export class SbbTabTitle { const TAGNAME = `h${Number(this.level) < 7 ? this.level : '1'}`; return ( - - {(this.iconName || this._namedSlots['icon']) && ( - - {this.iconName && } +
+ + {(this.iconName || this._namedSlots['icon']) && ( + + {this.iconName && } + + )} + + - )} - - - - {(this.amount || this._namedSlots['amount']) && ( - - {this.amount} - - )} - + {(this.amount || this._namedSlots['amount']) && ( + + {this.amount} + + )} + +
); } }