diff --git a/.changeset/content-header-responsive-text.md b/.changeset/content-header-responsive-text.md new file mode 100644 index 00000000000..15fa6f28ca6 --- /dev/null +++ b/.changeset/content-header-responsive-text.md @@ -0,0 +1,8 @@ +--- +'@siemens/ix': major +'@siemens/ix-angular': major +'@siemens/ix-react': major +'@siemens/ix-vue': major +--- + +**Breaking change:** `ix-content-header` titles and subtitles now wrap by default. Set `textOverflow="ellipsis"` to preserve the previous single-line ellipsis behavior without native tooltips. diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 0bf8c89505b..84a5e6f3bd9 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -7,6 +7,7 @@ This document aims to provide a clear and detailed overview of all significant m Please select your target version +- [Version v6.0.0](./BREAKING_CHANGES/v6.md) - [Version v5.0.0](./BREAKING_CHANGES/v5.md) - [Version v4.0.0](./BREAKING_CHANGES/v4.md) - [Version v3.0.0](./BREAKING_CHANGES/v3.md) diff --git a/BREAKING_CHANGES/v5.md b/BREAKING_CHANGES/v5.md index 68da8174a17..40205617880 100644 --- a/BREAKING_CHANGES/v5.md +++ b/BREAKING_CHANGES/v5.md @@ -79,7 +79,7 @@ The following previously deprecated components, properties, and overloads are re | Removed | Replacement | | ----------------------- | ---------------------------------------- | | `ix-input-group` | `ix-input` (or another form-ready input) | -| `ix-validation-tooltip` | `ix-input` / `ix-select` validation text | +| `ix-validation-tooltip` | `ix-input` / `ix-select` validation text | | `ix-drawer` | `ix-pane` | ### Migrate from `ix-input-group` @@ -188,12 +188,12 @@ Invalid messages are rendered through `ix-field-wrapper` (icon + message below t ### Removed properties -| Component | Removed property | Replacement | -| ----------------------- | -------------------------------- | ------------------------------------------------------------------------- | -| `ix-application-header` | `ariaLabelMenuExpandIconButton` | Not needed (menu expand control is not exposed to assistive technologies) | -| `ix-avatar` | `a11yLabel` | Host `aria-label` | -| `ix-breadcrumb-item` | `ariaLabelButton` | Host `aria-label` | -| `ix-select` | `ariaLabelChevronDownIconButton` | Not needed (chevron control is not in the accessibility tree) | +| Component | Removed property | Replacement | +| ----------------------- | -------------------------------- | ------------------------------------------------------------------------------------ | +| `ix-application-header` | `ariaLabelMenuExpandIconButton` | Not needed (menu expand control is not exposed to assistive technologies) | +| `ix-avatar` | `a11yLabel` | Host `aria-label` | +| `ix-breadcrumb-item` | `ariaLabelButton` | Host `aria-label` | +| `ix-select` | `ariaLabelChevronDownIconButton` | Not needed (chevron control is not in the accessibility tree) | | `ix-slider` | `error` | `class="ix-invalid"` and `invalid-text` (invalid message uses field-wrapper styling) | ### Removed util overload @@ -220,7 +220,7 @@ Breadcrumb items now require a stable `breadcrumbKey`. Breadcrumb click events u | `nextItems = ['Next item']` | `nextItems = [{ label: 'Next item', breadcrumbKey: 'next-item' }]` | | Click events emitted a label string | Click events emit `{ breadcrumbKey, label }` | -### Before +### `ix-breadcrumb` before ```html @@ -233,7 +233,7 @@ Breadcrumb items now require a stable `breadcrumbKey`. Breadcrumb click events u breadcrumb.nextItems = ['Next item']; ``` -### After +### `ix-breadcrumb` after ```html @@ -249,7 +249,7 @@ breadcrumb.nextItems = [{ label: 'Next item', breadcrumbKey: 'next-item' }]; TypeScript consumers can use the exported `BreadcrumbClick` type for breadcrumb click payloads and `nextItems` entries. -### Migration rules +### `ix-breadcrumb` migration rules 1. Add a unique `breadcrumbKey`/`breadcrumb-key` value to every `ix-breadcrumb-item`. 2. Replace every string entry in `nextItems` with an object containing `label` and `breadcrumbKey`. @@ -261,7 +261,7 @@ The show-all button and show-more card now reveal all hidden cards by default in If your application handled `showAllClick` or `showMoreCardClick` to implement custom behavior and expects IX to leave card visibility unchanged, call `preventDefault()` on the event. -### Before +### `ix-card-list` before ```ts cardList.addEventListener('showAllClick', (event) => { @@ -269,7 +269,7 @@ cardList.addEventListener('showAllClick', (event) => { }); ``` -### After +### `ix-card-list` after ```ts cardList.addEventListener('showAllClick', (event) => { @@ -317,9 +317,9 @@ Use this section as the single source of truth for all V5 theming updates. 1. Remove all theme classes in the form `theme--` from `` and ``. 2. Convert each legacy class `theme--` to: -```html - -``` + ```html + + ``` 3. Never set only one of `data-ix-theme` or `data-ix-color-schema`. Set both together. 4. Treat `theme`, `colorSchema`, and `mode` as separate concepts: @@ -487,7 +487,7 @@ The JavaScript **`showModal(config)`** API (and framework helpers built on it) u | `keyboard` | Remove from config. Escape / dismiss behavior is not controlled by this flag; use **`beforeDismiss`** on **`ix-modal`** / config when you need to veto dismissal. | | `title` | Remove from config. Set a title in your modal **content** (e.g. **`ix-modal-header`** and slotted markup) instead. | -### Before +### `ModalConfig` before ```ts import { showModal } from '@siemens/ix'; @@ -500,7 +500,7 @@ await showModal({ }); ``` -### After +### `ModalConfig` after ```ts import { showModal } from '@siemens/ix'; @@ -510,7 +510,7 @@ await showModal({ }); ``` -### Migration rules +### `ModalConfig` migration rules 1. Delete **`container`**, **`keyboard`**, and **`title`** from every **`showModal`** / **`ModalConfig`** object. 2. Rely on modal **content** (and **`ix-modal-header`**) for titles. @@ -522,7 +522,7 @@ await showModal({ For Angular consumers only, `LoadingService.showModalLoading()` has the same return type change. -### Before +### `showModalLoading()` before ```ts const loading = showModalLoading({ message: 'Loading...' }); @@ -531,7 +531,7 @@ loading.update('Still loading...'); loading.finish('Done'); ``` -### After +### `showModalLoading()` after ```ts const loading = await showModalLoading({ message: 'Loading...' }); @@ -546,7 +546,7 @@ The same migration applies to `showModalLoading()` from `@siemens/ix-react` and `@siemens/ix-aggrid` now declares `@siemens/ix` as a peer dependency instead of installing it transitively. Applications that use the AG Grid theme must add `@siemens/ix` to their own dependencies so IX CSS imports and related assets resolve explicitly from the app. -### Before +### `ix-aggrid` before ```json { @@ -556,7 +556,7 @@ The same migration applies to `showModalLoading()` from `@siemens/ix-react` and } ``` -### After +### `ix-aggrid` after ```json { @@ -567,7 +567,7 @@ The same migration applies to `showModalLoading()` from `@siemens/ix-react` and } ``` -### Migration rules +### `ix-aggrid` migration rules 1. Add `@siemens/ix` to the application package that consumes `@siemens/ix-aggrid`. 2. Keep the installed `@siemens/ix` version within the peer dependency range required by `@siemens/ix-aggrid`. @@ -670,7 +670,7 @@ tabs.addEventListener('selectedChange', (e) => handleTab(e.detail)); // index: 0 tabs.addEventListener('tabChange', (e) => handleTab(e.detail)); // key: 'tab-1', 'tab-2' ``` -### Validation checklist +### `ix-tabs` validation checklist 1. No remaining `selected` prop usage on `ix-tabs`. 2. Every `ix-tab-item` has a unique `tab-key` attribute. @@ -694,7 +694,7 @@ tabs.addEventListener('tabChange', (e) => handleTab(e.detail)); // key: 'tab-1', | `ix-menu-about` | `suppressLegacyTabs` | Opts into the slotted `ix-tabs` integration path | | `ix-menu-settings` | `suppressLegacyTabs` | Opts into the slotted `ix-tabs` integration path | -### New required props +### New required menu props | Component | Prop | Description | | ----------------------- | -------- | ------------------------------------- | @@ -711,7 +711,7 @@ tabs.addEventListener('tabChange', (e) => handleTab(e.detail)); // key: 'tab-1', `ix-menu-about-item` changed from `shadow: true` to `shadow: false`. If you targeted internal elements via `::part()` or shadow DOM queries, update to plain CSS selectors. -### Migration +### Menu tabs migration ```html @@ -780,7 +780,7 @@ tabs.addEventListener('tabChange', (e) => handleTab(e.detail)); // key: 'tab-1', News content ``` -### Validation checklist +### Menu tabs validation checklist 1. No remaining `active-tab-label` usage on `ix-menu-about` — use `active-tab-key`. 2. Slotted `ix-tabs` integrations set `suppress-legacy-tabs` on the parent menu component. diff --git a/BREAKING_CHANGES/v6.md b/BREAKING_CHANGES/v6.md new file mode 100644 index 00000000000..a5b27454ec8 --- /dev/null +++ b/BREAKING_CHANGES/v6.md @@ -0,0 +1,19 @@ +# Breaking Changes V6 + +This document lists breaking changes introduced in Siemens Industrial Experience V6. + +## Content header text wraps by default + +`ix-content-header` titles and subtitles now wrap when horizontal space is limited. Previously, they remained on a single line and used an ellipsis for overflowing text. + +To preserve the previous single-line behavior, set `text-overflow="ellipsis"`: + +```html + +``` + +Ellipsis visually truncates the text without adding a tooltip. Keep the default wrapping behavior when the complete text must remain visible. + +### Validation checklist + +1. Every `ix-content-header` that must remain single-line uses `text-overflow="ellipsis"`. diff --git a/packages/angular-standalone-test-app/src/app/app.routes.ts b/packages/angular-standalone-test-app/src/app/app.routes.ts index 4e27b954f4b..9a2befddff6 100644 --- a/packages/angular-standalone-test-app/src/app/app.routes.ts +++ b/packages/angular-standalone-test-app/src/app/app.routes.ts @@ -130,6 +130,10 @@ export const routePaths: IxPreviewRoutes = { import('../preview-examples/content-header').then((m) => m.default), 'preview/content-header-no-back': () => import('../preview-examples/content-header-no-back').then((m) => m.default), + 'preview/content-header-text-overflow': () => + import('../preview-examples/content-header-text-overflow').then( + (m) => m.default + ), 'preview/content-header-with-slot': () => import('../preview-examples/content-header-with-slot').then( (m) => m.default diff --git a/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.html b/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.html new file mode 100644 index 00000000000..6824e96c7b3 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.html @@ -0,0 +1,19 @@ + + + + Button1 + Button2 + Button3 + diff --git a/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.ts b/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.ts new file mode 100644 index 00000000000..4de80cbff01 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/content-header-text-overflow.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxButton, IxContentHeader } from '@siemens/ix-angular/standalone'; + +@Component({ + selector: 'app-example', + imports: [IxContentHeader, IxButton], + templateUrl: './content-header-text-overflow.html', +}) +export default class ContentHeaderTextOverflow {} diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index 5e100902591..f46033ebda3 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -63,6 +63,7 @@ import Chip from '../preview-examples/chip'; import ContentExample from '../preview-examples/content'; import ContentHeader from '../preview-examples/content-header'; import ContentHeaderNoBack from '../preview-examples/content-header-no-back'; +import ContentHeaderTextOverflow from '../preview-examples/content-header-text-overflow'; import ContentHeaderWithSlot from '../preview-examples/content-header-with-slot'; import CustomField from '../preview-examples/custom-field'; import CustomFieldValidation from '../preview-examples/custom-field-validation'; @@ -552,6 +553,10 @@ const routes: Routes = [ path: 'content-header', component: ContentHeader, }, + { + path: 'content-header-text-overflow', + component: ContentHeaderTextOverflow, + }, { path: 'content-header-with-slot', component: ContentHeaderWithSlot, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 9a21de06d88..f24261842dd 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -72,6 +72,7 @@ import Chip from '../preview-examples/chip'; import ContentExample from '../preview-examples/content'; import ContentHeader from '../preview-examples/content-header'; import ContentHeaderNoBack from '../preview-examples/content-header-no-back'; +import ContentHeaderTextOverflow from '../preview-examples/content-header-text-overflow'; import ContentHeaderWithSlot from '../preview-examples/content-header-with-slot'; import CustomField from '../preview-examples/custom-field'; import CustomFieldValidation from '../preview-examples/custom-field-validation'; @@ -350,6 +351,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; ContentExample, ContentHeader, ContentHeaderNoBack, + ContentHeaderTextOverflow, ContentHeaderWithSlot, DatepickerRange, Datepicker, diff --git a/packages/angular-test-app/src/preview-examples/content-header-text-overflow.html b/packages/angular-test-app/src/preview-examples/content-header-text-overflow.html new file mode 100644 index 00000000000..6824e96c7b3 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/content-header-text-overflow.html @@ -0,0 +1,19 @@ + + + + Button1 + Button2 + Button3 + diff --git a/packages/angular-test-app/src/preview-examples/content-header-text-overflow.ts b/packages/angular-test-app/src/preview-examples/content-header-text-overflow.ts new file mode 100644 index 00000000000..ef9a1d93c32 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/content-header-text-overflow.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './content-header-text-overflow.html', +}) +export default class ContentHeaderTextOverflow {} diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index c839e47ede8..8041aa36082 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -712,14 +712,14 @@ export declare interface IxContent extends Components.IxContent {} @ProxyCmp({ - inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'variant'] + inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'textOverflow', 'variant'] }) @Component({ selector: 'ix-content-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'variant'], + inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'textOverflow', 'variant'], outputs: ['backButtonClick'], standalone: false }) diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index b402e16959f..2e52574f4fd 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -823,14 +823,14 @@ export declare interface IxContent extends Components.IxContent {} @ProxyCmp({ defineCustomElementFn: defineIxContentHeader, - inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'variant'] + inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'textOverflow', 'variant'] }) @Component({ selector: 'ix-content-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'variant'], + inputs: ['hasBackButton', 'headerSubtitle', 'headerTitle', 'textOverflow', 'variant'], outputs: ['backButtonClick'], }) export class IxContentHeader { diff --git a/packages/core/component-api.json b/packages/core/component-api.json index 1ca5d264fc8..548ab357b76 100644 --- a/packages/core/component-api.json +++ b/packages/core/component-api.json @@ -2254,6 +2254,17 @@ "required": false, "deprecated": false }, + { + "name": "textOverflow", + "attribute": "text-overflow", + "type": "\"ellipsis\" | \"wrap\"", + "mutable": false, + "reflectToAttribute": true, + "default": "'wrap'", + "optional": false, + "required": false, + "deprecated": false + }, { "name": "variant", "attribute": "variant", diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 91346c33a99..d5fb21a58ae 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -22,7 +22,7 @@ import { InputState } from "./components/category-filter/input-state"; import { ChatAttachmentStatus } from "./components/chat-attachment/chat-attachment.types"; import { ChipVariant } from "./components/chip/chip.types"; import { ColumnSize } from "./components/col/col.types"; -import { ContentHeaderVariant } from "./components/content-header/content-header.types"; +import { ContentHeaderTextOverflow, ContentHeaderVariant } from "./components/content-header/content-header.types"; import { CssGridTemplateType } from "./components/css-grid/css-grid.types"; import { ButtonVariant as ButtonVariant1 } from "./components/button/button"; import { DateDropdownOption, DateRangeChangeEvent } from "./components/date-dropdown/date-dropdown.types"; @@ -79,7 +79,7 @@ export { InputState } from "./components/category-filter/input-state"; export { ChatAttachmentStatus } from "./components/chat-attachment/chat-attachment.types"; export { ChipVariant } from "./components/chip/chip.types"; export { ColumnSize } from "./components/col/col.types"; -export { ContentHeaderVariant } from "./components/content-header/content-header.types"; +export { ContentHeaderTextOverflow, ContentHeaderVariant } from "./components/content-header/content-header.types"; export { CssGridTemplateType } from "./components/css-grid/css-grid.types"; export { ButtonVariant as ButtonVariant1 } from "./components/button/button"; export { DateDropdownOption, DateRangeChangeEvent } from "./components/date-dropdown/date-dropdown.types"; @@ -1051,6 +1051,12 @@ export namespace Components { * Title of Header */ "headerTitle"?: string; + /** + * Controls how the title and subtitle handle limited horizontal space. Ellipsis visually truncates the text without adding a tooltip. + * @since 6.0.0 + * @default 'wrap' + */ + "textOverflow": ContentHeaderTextOverflow; /** * Variant of content header * @default 'primary' @@ -8020,6 +8026,12 @@ declare namespace LocalJSX { * Triggered when back button is clicked */ "onBackButtonClick"?: (event: IxContentHeaderCustomEvent) => void; + /** + * Controls how the title and subtitle handle limited horizontal space. Ellipsis visually truncates the text without adding a tooltip. + * @since 6.0.0 + * @default 'wrap' + */ + "textOverflow"?: ContentHeaderTextOverflow; /** * Variant of content header * @default 'primary' @@ -12383,6 +12395,7 @@ declare namespace LocalJSX { "variant": ContentHeaderVariant; "headerTitle": string; "headerSubtitle": string | undefined; + "textOverflow": ContentHeaderTextOverflow; "hasBackButton": boolean; } interface IxCssGridItemAttributes { diff --git a/packages/core/src/components/content-header/content-header.scss b/packages/core/src/components/content-header/content-header.scss index b973297e24f..ab93d598df5 100644 --- a/packages/core/src/components/content-header/content-header.scss +++ b/packages/core/src/components/content-header/content-header.scss @@ -18,19 +18,30 @@ flex-direction: column; flex: 1 1 0; min-width: 0; - white-space: nowrap; margin-right: 0.5rem; - .titleOverflow { - overflow: hidden; - text-overflow: ellipsis; + .headerText { + min-width: 0; + overflow-wrap: anywhere; + + &.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } .headerTitleRow { display: flex; + .headerText { + flex: 0 1 auto; + } + .headerSlot { display: inline-flex; + flex: 0 0 auto; + align-self: flex-start; margin-left: 0.5rem; } } diff --git a/packages/core/src/components/content-header/content-header.tsx b/packages/core/src/components/content-header/content-header.tsx index ef271ac3f9e..7ee0aab6674 100644 --- a/packages/core/src/components/content-header/content-header.tsx +++ b/packages/core/src/components/content-header/content-header.tsx @@ -9,7 +9,10 @@ import { iconArrowLeft } from '@siemens/ix-icons/icons'; import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core'; -import type { ContentHeaderVariant } from './content-header.types'; +import type { + ContentHeaderTextOverflow, + ContentHeaderVariant, +} from './content-header.types'; /** * @slot header - Content to be placed in the header area next to the title @@ -36,6 +39,14 @@ export class ContentHeader { */ @Prop() headerSubtitle: string | undefined = undefined; + /** + * Controls how the title and subtitle handle limited horizontal space. + * Ellipsis visually truncates the text without adding a tooltip. + * + * @since 6.0.0 + */ + @Prop({ reflect: true }) textOverflow: ContentHeaderTextOverflow = 'wrap'; + /** * Display a back button */ @@ -64,7 +75,8 @@ export class ContentHeader { format={this.variant === 'secondary' ? 'h4' : 'h3'} class={{ secondary: this.variant === 'secondary', - titleOverflow: true, + headerText: true, + truncate: this.textOverflow === 'ellipsis', }} > {this.headerTitle} @@ -79,9 +91,9 @@ export class ContentHeader { text-color={'soft'} class={{ subtitle: this.variant === 'secondary', - titleOverflow: true, + headerText: true, + truncate: this.textOverflow === 'ellipsis', }} - title={this.headerSubtitle} > {this.headerSubtitle} diff --git a/packages/core/src/components/content-header/content-header.types.ts b/packages/core/src/components/content-header/content-header.types.ts index 942be25721c..7501762d555 100644 --- a/packages/core/src/components/content-header/content-header.types.ts +++ b/packages/core/src/components/content-header/content-header.types.ts @@ -7,3 +7,12 @@ * LICENSE file in the root directory of this source tree. */ export type ContentHeaderVariant = 'primary' | 'secondary'; + +/** + * Controls how content header titles and subtitles handle limited horizontal space. + * `'wrap'` is the default. Use `'ellipsis'` for explicit single-line truncation. + * Ellipsis visually truncates the text without adding a tooltip. + * + * @since 6.0.0 + */ +export type ContentHeaderTextOverflow = 'wrap' | 'ellipsis'; diff --git a/packages/core/src/components/content-header/test/content-header.ct.ts b/packages/core/src/components/content-header/test/content-header.ct.ts new file mode 100644 index 00000000000..4f74d5b1a57 --- /dev/null +++ b/packages/core/src/components/content-header/test/content-header.ct.ts @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { expect, type Locator } from '@playwright/test'; +import { regressionTest, viewPorts } from '@utils/test'; + +const getTextLayout = (text: Locator) => + text.evaluate((element) => { + const style = getComputedStyle(element); + + return { + clientWidth: element.clientWidth, + height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat(style.lineHeight), + overflow: style.overflow, + scrollWidth: element.scrollWidth, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + }; + }); + +regressionTest('accessibility', async ({ mount, makeAxeBuilder }) => { + await mount(` + + Online + Edit + + `); + + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); +}); + +regressionTest('renders', async ({ mount, page }) => { + await mount(` + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + await expect(header).toHaveAttribute('text-overflow', 'wrap'); + await expect(header).toHaveJSProperty('textOverflow', 'wrap'); + await expect(page.getByText('Content title', { exact: true })).toBeVisible(); + await expect(page.getByText('Subtitle', { exact: true })).toBeVisible(); +}); + +regressionTest( + 'wraps title and subtitle within the available component width by default', + async ({ mount, page }) => { + const title = + 'A responsive content header title that remains completely visible'; + const subtitle = + 'Supporting context also wraps naturally in constrained containers'; + + await mount(` + + Header action + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const subtitleText = header.getByText(subtitle, { exact: true }); + + for (const text of [titleText, subtitleText]) { + const layout = await getTextLayout(text); + + expect(layout.height).toBeGreaterThan(layout.lineHeight); + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); + await expect(text).not.toHaveAttribute('title'); + } + } +); + +regressionTest( + 'wraps at the small viewport and remains on one line at larger viewports', + async ({ mount, page }) => { + const title = + 'Responsive content header text uses the space available to the component'; + + await page.setViewportSize(viewPorts.sm); + await mount( + `` + ); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const getLineCount = async () => { + const layout = await getTextLayout(titleText); + return layout.height / layout.lineHeight; + }; + + expect(await getLineCount()).toBeGreaterThan(1); + + await page.setViewportSize(viewPorts.md); + expect(await getLineCount()).toBe(1); + + await page.setViewportSize(viewPorts.lg); + expect(await getLineCount()).toBe(1); + } +); + +regressionTest( + 'breaks long unbroken text without horizontal overflow', + async ({ mount, page }) => { + const title = + 'production-line-identifier-without-natural-break-opportunities-123456789'; + + await mount(` + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const layout = await getTextLayout(titleText); + + expect(layout.height).toBeGreaterThan(layout.lineHeight); + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); + } +); + +regressionTest( + 'keeps header content and actions aligned with the first title line', + async ({ mount, page }) => { + const title = + 'A constrained title that wraps while adjacent content keeps its place'; + const subtitle = 'Supporting context stays clear of the action area'; + + await mount(` + + Online + Non-wrapping action + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const subtitleText = header.getByText(subtitle, { exact: true }); + const pill = header.getByText('Online', { exact: true }); + const action = page.getByRole('button', { name: 'Non-wrapping action' }); + const backButton = page.getByRole('button', { name: 'Arrow Left' }); + + const titleBox = await titleText.boundingBox(); + const subtitleBox = await subtitleText.boundingBox(); + const pillBox = await pill.boundingBox(); + const actionBox = await action.boundingBox(); + const backButtonBox = await backButton.boundingBox(); + const titleLineHeight = await titleText.evaluate((element) => + Number.parseFloat(getComputedStyle(element).lineHeight) + ); + + if (!titleBox || !subtitleBox || !pillBox || !actionBox || !backButtonBox) { + throw new Error( + 'Expected content header elements to have bounding boxes' + ); + } + + expect(titleBox.height).toBeGreaterThan(titleLineHeight); + expect(pillBox.y).toBeLessThan(titleBox.y + titleLineHeight); + expect(actionBox.y).toBeLessThan(titleBox.y + titleLineHeight); + expect(backButtonBox.y).toBeLessThan(titleBox.y + titleLineHeight); + expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(pillBox.x); + expect(subtitleBox.x + subtitleBox.width).toBeLessThanOrEqual(actionBox.x); + + await header.evaluate((element) => { + element.style.width = '80rem'; + }); + const wideActionBox = await action.boundingBox(); + if (!wideActionBox) { + throw new Error('Expected wide action to have a bounding box'); + } + + expect(actionBox.height).toBe(wideActionBox.height); + expect(actionBox.width).toBe(wideActionBox.width); + } +); + +regressionTest( + 'truncates title and subtitle on separate lines without native tooltips', + async ({ mount, page }) => { + const title = 'A long title that is truncated in compact layouts'; + const subtitle = 'A long subtitle that is truncated independently'; + + await mount(` + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const subtitleText = header.getByText(subtitle, { exact: true }); + + for (const text of [titleText, subtitleText]) { + const overflow = await getTextLayout(text); + + expect(overflow.height).toBe(overflow.lineHeight); + expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth); + expect(overflow.overflow).toBe('hidden'); + expect(overflow.textOverflow).toBe('ellipsis'); + expect(overflow.whiteSpace).toBe('nowrap'); + await expect(text).not.toHaveAttribute('title'); + } + + const titleBox = await titleText.boundingBox(); + const subtitleBox = await subtitleText.boundingBox(); + if (!titleBox || !subtitleBox) { + throw new Error('Expected title and subtitle to have bounding boxes'); + } + + expect(titleBox.y + titleBox.height).toBeLessThanOrEqual(subtitleBox.y); + } +); + +regressionTest( + 'updates overflow behavior when textOverflow changes at runtime', + async ({ mount, page }) => { + const title = 'A title that switches between wrapping and truncation'; + + await mount(` + + `); + + const header = page.locator('ix-content-header'); + await expect(header).toHaveClass(/\bhydrated\b/); + const titleText = header.getByText(title, { exact: true }); + const getHeight = () => + titleText.evaluate((element) => element.getBoundingClientRect().height); + + const wrappedHeight = await getHeight(); + + await header.evaluate((element: HTMLIxContentHeaderElement) => { + element.textOverflow = 'ellipsis'; + }); + + await expect(header).toHaveAttribute('text-overflow', 'ellipsis'); + await expect(titleText).not.toHaveAttribute('title'); + await expect.poll(getHeight).toBeLessThan(wrappedHeight); + + await header.evaluate((element: HTMLIxContentHeaderElement) => { + element.textOverflow = 'wrap'; + }); + + await expect(header).toHaveAttribute('text-overflow', 'wrap'); + await expect(titleText).not.toHaveAttribute('title'); + await expect.poll(getHeight).toBe(wrappedHeight); + } +); diff --git a/packages/html-test-app/src/preview-examples/content-header-text-overflow.html b/packages/html-test-app/src/preview-examples/content-header-text-overflow.html new file mode 100644 index 00000000000..cb8abf6a288 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/content-header-text-overflow.html @@ -0,0 +1,30 @@ + + + + + + + + Content header text overflow example + + + + Button1 + Button2 + Button3 + + + + diff --git a/packages/react-test-app/src/main.tsx b/packages/react-test-app/src/main.tsx index ca9ff00430f..fb56624aec3 100644 --- a/packages/react-test-app/src/main.tsx +++ b/packages/react-test-app/src/main.tsx @@ -68,6 +68,7 @@ import Chip from './preview-examples/chip'; import Content from './preview-examples/content'; import ContentHeader from './preview-examples/content-header'; import ContentHeaderNoBack from './preview-examples/content-header-no-back'; +import ContentHeaderTextOverflow from './preview-examples/content-header-text-overflow'; import ContentHeaderWithSlot from './preview-examples/content-header-with-slot'; import CustomField from './preview-examples/custom-field'; import CustomFieldValidation from './preview-examples/custom-field-validation'; @@ -343,6 +344,7 @@ const routes: IxPreviewRoutes = { '/preview/datetime-range': DatetimeRange, '/preview/content-header-no-back': ContentHeaderNoBack, '/preview/content-header': ContentHeader, + '/preview/content-header-text-overflow': ContentHeaderTextOverflow, '/preview/content-header-with-slot': ContentHeaderWithSlot, '/preview/datepicker-range': DatepickerRange, '/preview/datepicker': Datepicker, diff --git a/packages/react-test-app/src/preview-examples/content-header-text-overflow.tsx b/packages/react-test-app/src/preview-examples/content-header-text-overflow.tsx new file mode 100644 index 00000000000..21036f82a5e --- /dev/null +++ b/packages/react-test-app/src/preview-examples/content-header-text-overflow.tsx @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IxButton, IxContentHeader } from '@siemens/ix-react'; + +export default () => { + return ( + + Button1 + Button2 + Button3 + + ); +}; diff --git a/packages/react-test-app/src/preview-examples/content-header.tsx b/packages/react-test-app/src/preview-examples/content-header.tsx index 7da1b544556..d3ce25a435b 100644 --- a/packages/react-test-app/src/preview-examples/content-header.tsx +++ b/packages/react-test-app/src/preview-examples/content-header.tsx @@ -12,9 +12,9 @@ import { IxButton, IxContentHeader } from '@siemens/ix-react'; export default () => { return ( Button1 Button2 diff --git a/packages/react/src/components/components.server.ts b/packages/react/src/components/components.server.ts index 533a80a1f45..c520bf30625 100644 --- a/packages/react/src/components/components.server.ts +++ b/packages/react/src/components/components.server.ts @@ -590,6 +590,7 @@ export const IxContentHeader: StencilReactComponent) : undefined, diff --git a/packages/storybook-docs/src/stories/content-header.stories.ts b/packages/storybook-docs/src/stories/content-header.stories.ts new file mode 100644 index 00000000000..c42dee5bcff --- /dev/null +++ b/packages/storybook-docs/src/stories/content-header.stories.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { Components } from '@siemens/ix/components'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +type Element = Components.IxContentHeader; + +const meta = { + title: 'Example/ContentHeader', + tags: [], + parameters: { + a11y: { + test: 'error', + }, + docs: { + description: { + component: + '`textOverflow="wrap"` is the default. Use `textOverflow="ellipsis"` to opt into visual single-line truncation. Ellipsis does not add a tooltip; use wrapping when the complete text must remain visible.', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const title = + 'Content title that demonstrates behavior when horizontal space is limited'; +const subtitle = + 'Supporting context that demonstrates behavior when horizontal space is limited'; + +export const TextOverflow: Story = { + render: () => html` +
+
+

Wrap (default)

+ +
+ +
+

Ellipsis

+ +
+
+ `, +}; diff --git a/packages/vue-test-app/src/Root.vue b/packages/vue-test-app/src/Root.vue index 8956459be4a..bfeec18357d 100644 --- a/packages/vue-test-app/src/Root.vue +++ b/packages/vue-test-app/src/Root.vue @@ -65,6 +65,7 @@ import Checkbox from './preview-examples/checkbox.vue'; import Chip from './preview-examples/chip.vue'; import ContentHeaderNoBack from './preview-examples/content-header-no-back.vue'; import ContentHeader from './preview-examples/content-header.vue'; +import ContentHeaderTextOverflow from './preview-examples/content-header-text-overflow.vue'; import ContentHeaderWithSlot from './preview-examples/content-header-with-slot.vue'; import Content from './preview-examples/content.vue'; import CustomFieldValidation from './preview-examples/custom-field-validation.vue'; @@ -325,6 +326,7 @@ const routes: IxPreviewRoutes = { '/preview/datetime-range': DatetimeRange, '/preview/content-header-no-back': ContentHeaderNoBack, '/preview/content-header': ContentHeader, + '/preview/content-header-text-overflow': ContentHeaderTextOverflow, '/preview/content-header-with-slot': ContentHeaderWithSlot, '/preview/datepicker-range': DatepickerRange, '/preview/datepicker': Datepicker, diff --git a/packages/vue-test-app/src/preview-examples/content-header-text-overflow.vue b/packages/vue-test-app/src/preview-examples/content-header-text-overflow.vue new file mode 100644 index 00000000000..3d8cf47c3e9 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/content-header-text-overflow.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/packages/vue/src/components/ix-content-header.ts b/packages/vue/src/components/ix-content-header.ts index 93c00ca39d9..1c31715b770 100644 --- a/packages/vue/src/components/ix-content-header.ts +++ b/packages/vue/src/components/ix-content-header.ts @@ -10,6 +10,7 @@ export const IxContentHeader: StencilVueComponent = /*@__PU 'variant', 'headerTitle', 'headerSubtitle', + 'textOverflow', 'hasBackButton', 'backButtonClick' ], [ diff --git a/testing/framework-tests/__snapshots__/content-header-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/content-header-aria-snapshot.yaml index fcabc1223ec..73c1f9d8ace 100644 --- a/testing/framework-tests/__snapshots__/content-header-aria-snapshot.yaml +++ b/testing/framework-tests/__snapshots__/content-header-aria-snapshot.yaml @@ -1,5 +1,6 @@ -- button "Arrow Left" -- text: Content title Subtitle -- button "Button1" -- button "Button2" -- button "Button3" \ No newline at end of file +- main: + - button "Arrow Left" + - text: Content title Subtitle + - button "Button1" + - button "Button2" + - button "Button3" \ No newline at end of file diff --git a/testing/framework-tests/__snapshots__/content-header-text-overflow-aria-snapshot.yaml b/testing/framework-tests/__snapshots__/content-header-text-overflow-aria-snapshot.yaml new file mode 100644 index 00000000000..1f33a5d87e7 --- /dev/null +++ b/testing/framework-tests/__snapshots__/content-header-text-overflow-aria-snapshot.yaml @@ -0,0 +1,6 @@ +- main: + - button "Arrow Left" + - text: Content title that remains compact when horizontal space is limited Supporting context is truncated independently when space is limited + - button "Button1" + - button "Button2" + - button "Button3" \ No newline at end of file diff --git a/testing/framework-tests/tests/generated/axe.ts b/testing/framework-tests/tests/generated/axe.ts index 4a9a26c0ba6..be85bd21d4e 100644 --- a/testing/framework-tests/tests/generated/axe.ts +++ b/testing/framework-tests/tests/generated/axe.ts @@ -51,6 +51,7 @@ import './chip-axe.spec.ts'; import './content-axe.spec.ts'; import './content-header-axe.spec.ts'; import './content-header-no-back-axe.spec.ts'; +import './content-header-text-overflow-axe.spec.ts'; import './content-header-with-slot-axe.spec.ts'; import './custom-field-axe.spec.ts'; import './custom-field-validation-axe.spec.ts'; diff --git a/testing/framework-tests/tests/generated/content-header-text-overflow-axe.spec.ts b/testing/framework-tests/tests/generated/content-header-text-overflow-axe.spec.ts new file mode 100644 index 00000000000..6cdcc254a04 --- /dev/null +++ b/testing/framework-tests/tests/generated/content-header-text-overflow-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('content-header-text-overflow - accessibility check', async ({ page }) => { + await page.goto('/preview/content-header-text-overflow'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/content-header-text-overflow.spec.ts b/testing/framework-tests/tests/generated/content-header-text-overflow.spec.ts new file mode 100644 index 00000000000..3b94ef82991 --- /dev/null +++ b/testing/framework-tests/tests/generated/content-header-text-overflow.spec.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('content-header-text-overflow', async ({ page }) => { + await page.goto('/preview/content-header-text-overflow'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'content-header-text-overflow.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/index.ts b/testing/framework-tests/tests/generated/index.ts index 7d859c76091..b0f9891a445 100644 --- a/testing/framework-tests/tests/generated/index.ts +++ b/testing/framework-tests/tests/generated/index.ts @@ -102,6 +102,8 @@ import './content-axe.spec.ts'; import './content-header-axe.spec.ts'; import './content-header-no-back-axe.spec.ts'; import './content-header-no-back.spec.ts'; +import './content-header-text-overflow-axe.spec.ts'; +import './content-header-text-overflow.spec.ts'; import './content-header-with-slot-axe.spec.ts'; import './content-header-with-slot.spec.ts'; import './content-header.spec.ts'; diff --git a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts index 2f3846c2e94..bff40c1e4b4 100644 --- a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts +++ b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts @@ -206,6 +206,7 @@ export const testIds = [ "content", "content-header", "content-header-with-slot", + "content-header-text-overflow", "content-header-no-back", "chip", "checkbox", diff --git a/testing/framework-tests/tests/working-with-axe.spec.ts b/testing/framework-tests/tests/working-with-axe.spec.ts index edd92d36306..fffd5c72e17 100644 --- a/testing/framework-tests/tests/working-with-axe.spec.ts +++ b/testing/framework-tests/tests/working-with-axe.spec.ts @@ -68,6 +68,10 @@ import './generated/popover-axe.spec'; // Chip tests import './generated/chip-axe.spec'; +// Content header tests +import './generated/content-header-axe.spec'; +import './generated/content-header-text-overflow-axe.spec'; + // Chat tests import './generated/chat-axe.spec'; import './generated/chat-ai-message-axe.spec'; diff --git a/testing/framework-tests/tests/working.spec.ts b/testing/framework-tests/tests/working.spec.ts index 14876835fcb..dacaa9e2805 100644 --- a/testing/framework-tests/tests/working.spec.ts +++ b/testing/framework-tests/tests/working.spec.ts @@ -68,6 +68,10 @@ import './generated/popover.spec'; // Chip tests import './generated/chip.spec'; +// Content header tests +import './generated/content-header.spec'; +import './generated/content-header-text-overflow.spec'; + // Chat tests import './generated/chat.spec'; import './generated/chat-ai-message.spec'; diff --git a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-dark-linux.png index 1b318c78220..fd4bc4773a7 100644 Binary files a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-light-linux.png index 688d1120e47..f839f0c0596 100644 Binary files a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-basic-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-dark-linux.png index 3dfb5a3429b..350cb0e97cc 100644 Binary files a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-light-linux.png index fedff546231..ea2aea3dd5f 100644 Binary files a/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/content-header/content-header.e2e.ts/content-header-secondary-1-chromium---classic-light-linux.png differ diff --git a/testing/visual-testing/tests/content-header/basic/index.html b/testing/visual-testing/tests/content-header/basic/index.html index 1d877dc1404..1d3645b6b7e 100644 --- a/testing/visual-testing/tests/content-header/basic/index.html +++ b/testing/visual-testing/tests/content-header/basic/index.html @@ -48,7 +48,6 @@ Button2 Button3 - Button2 Button3 - + + Label + Button1 + Button2 + Button3 + - + diff --git a/testing/visual-testing/tests/content-header/secondary/index.html b/testing/visual-testing/tests/content-header/secondary/index.html index 96fee1d83fe..49777c01f0b 100644 --- a/testing/visual-testing/tests/content-header/secondary/index.html +++ b/testing/visual-testing/tests/content-header/secondary/index.html @@ -71,6 +71,18 @@ Button2 Button3 + + Label + Button1 + Button2 + Button3 +