Skip to content

Commit 7b71a03

Browse files
authored
feat(ui5-timeline): introduce header and info-bar slots (#13548)
## High-level Architecture additions - Added a `header` slot for a controls bar above the items. - Added an `infoBar` slot for a status bar below the controls, reflecting the active state. - Added a `stickyHeader` boolean that pins the header bar to the top while scrolling. ## Overview The Timeline had no built-in way to place controls (search, filter, sort) above its items. Applications had to build custom layouts around it, leading to inconsistent patterns and no standardized sticky behavior. ## What we did Added two optional slots above the items area: - **`header`** — for controls (search field, sort/filter buttons). Typically holds a `ui5-toolbar`. - **`infoBar`** — for status display (active filter tokens, item count). Typically holds a `ui5-bar`. Plus: | Property | What it does | |----------|-------------| | `stickyHeader` | Pins both bars at the top while scrolling | ## Key points - The Timeline does **not** search, sort, or filter — apps stay in full control. - Sticky behavior is **CSS-only** — no JS measurement needed. - Fully **backwards compatible** — Timelines without these slots work as before.
1 parent 294a2dd commit 7b71a03

19 files changed

Lines changed: 1960 additions & 10 deletions

File tree

packages/fiori/cypress/specs/Timeline.cy.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,4 +581,114 @@ describe("TimelineItem iconTooltip", () => {
581581
});
582582
});
583583

584+
describe("Timeline header and info-bar slots", () => {
585+
it("renders the header slot when content is provided", () => {
586+
cy.mount(
587+
<Timeline>
588+
<div slot="header" id="header-content" data-testid="header">Controls</div>
589+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
590+
</Timeline>
591+
);
592+
593+
cy.get("[ui5-timeline]")
594+
.shadow()
595+
.find(".ui5-timeline-header")
596+
.should("exist");
597+
598+
cy.get("#header-content").should("be.visible");
599+
});
600+
601+
it("renders the info-bar slot when content is provided", () => {
602+
cy.mount(
603+
<Timeline>
604+
<div slot="infoBar" id="info-bar-content">Active filters: 2</div>
605+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
606+
</Timeline>
607+
);
608+
609+
cy.get("[ui5-timeline]")
610+
.shadow()
611+
.find(".ui5-timeline-info-bar")
612+
.should("exist");
613+
614+
cy.get("#info-bar-content").should("be.visible");
615+
});
616+
617+
it("does not render header or info-bar wrappers when slots are empty", () => {
618+
cy.mount(
619+
<Timeline>
620+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
621+
</Timeline>
622+
);
623+
624+
cy.get("[ui5-timeline]")
625+
.shadow()
626+
.find(".ui5-timeline-header")
627+
.should("not.exist");
628+
629+
cy.get("[ui5-timeline]")
630+
.shadow()
631+
.find(".ui5-timeline-info-bar")
632+
.should("not.exist");
633+
});
634+
635+
it("renders both slots side by side when both are provided", () => {
636+
cy.mount(
637+
<Timeline>
638+
<div slot="header" id="hdr">Search/Filter/Sort</div>
639+
<div slot="infoBar" id="ifb">Status: 3 items</div>
640+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
641+
</Timeline>
642+
);
643+
644+
cy.get("[ui5-timeline]")
645+
.shadow()
646+
.find(".ui5-timeline-header")
647+
.should("exist");
648+
649+
cy.get("[ui5-timeline]")
650+
.shadow()
651+
.find(".ui5-timeline-info-bar")
652+
.should("exist");
653+
654+
cy.get("#hdr").should("be.visible");
655+
cy.get("#ifb").should("be.visible");
656+
});
657+
658+
it("reflects stickyHeader as the [sticky-header] host attribute and pins the whole header area", () => {
659+
cy.mount(
660+
<Timeline stickyHeader={true}>
661+
<div slot="header" id="hdr">Header</div>
662+
<div slot="infoBar" id="ifb">Info</div>
663+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
664+
</Timeline>
665+
);
666+
667+
cy.get("[ui5-timeline]")
668+
.should("have.attr", "sticky-header");
669+
670+
cy.get("[ui5-timeline]")
671+
.shadow()
672+
.find(".ui5-timeline-header-area")
673+
.should("have.css", "position", "sticky");
674+
});
675+
676+
it("does not apply sticky positioning when stickyHeader is false (default)", () => {
677+
cy.mount(
678+
<Timeline>
679+
<div slot="header" id="hdr">Header</div>
680+
<div slot="infoBar" id="ifb">Info</div>
681+
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
682+
</Timeline>
683+
);
684+
685+
cy.get("[ui5-timeline]")
686+
.should("not.have.attr", "sticky-header");
687+
688+
cy.get("[ui5-timeline]")
689+
.shadow()
690+
.find(".ui5-timeline-header-area")
691+
.should("not.have.css", "position", "sticky");
692+
});
693+
});
584694

packages/fiori/src/Timeline.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
2-
import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js";
2+
import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
33
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
44
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
55
import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
@@ -70,6 +70,23 @@ const GROWING_WITH_SCROLL_DEBOUNCE_RATE = 250; // ms
7070
* These entries can be generated by the system (for example, value XY changed from A to B), or added manually.
7171
* There are two distinct variants of the timeline: basic and social. The basic timeline is read-only,
7272
* while the social timeline offers a high level of interaction and collaboration, and is integrated within SAP Jam.
73+
*
74+
* ### Header and Info Bar Slots
75+
*
76+
* The Timeline exposes two named slots above the items area:
77+
*
78+
* - `header` — for a controls bar (search field, filter trigger, sort toggle, etc.).
79+
* The most common pattern is to place a `ui5-toolbar` containing a search input and buttons that open
80+
* a filter dialog or toggle sort direction. The Timeline itself performs no filtering, sorting, or
81+
* searching — the application listens for events from its own controls and reorders, hides, or
82+
* adds items in the default slot accordingly.
83+
*
84+
* - `infoBar` — for a status bar that reflects the result of the controls (active filters,
85+
* applied sort, current search query). Typically contains tokens, labels, or a `ui5-bar`.
86+
*
87+
* The Timeline itself does not filter, sort, or search — the application owns that logic.
88+
* Use `stickyHeader` to pin both bars while the Timeline's items scroll. Give the Timeline
89+
* a constrained height in this mode so it owns its scrollbar.
7390
* @constructor
7491
* @extends UI5Element
7592
* @public
@@ -153,6 +170,22 @@ class Timeline extends UI5Element {
153170
@property()
154171
growing: `${TimelineGrowingMode}` = "None";
155172

173+
/**
174+
* Defines whether the content of the `header` and `infoBar` slots remains visible when the user scrolls the Timeline.
175+
*
176+
* **Note:** The bars pin to the Timeline's own scrollport. Give the Timeline a
177+
* constrained height (for example `style="height: 32rem"`) so its items scroll
178+
* inside it. Placing the Timeline inside an externally scrolling ancestor while
179+
* leaving the Timeline itself unsized is not supported in this mode — the bars
180+
* will scroll away with the ancestor.
181+
*
182+
* @default false
183+
* @public
184+
* @since 2.22.0
185+
*/
186+
@property({ type: Boolean })
187+
stickyHeader = false;
188+
156189
/**
157190
* Defines the active state of the `More` button.
158191
* @private
@@ -167,12 +200,35 @@ class Timeline extends UI5Element {
167200
@slot({ type: HTMLElement, individualSlots: true, "default": true })
168201
items!: DefaultSlot<ITimelineItem>;
169202

203+
/**
204+
* Defines the content of the Timeline's header area, displayed above the items.
205+
* Typically a `ui5-toolbar` with search, sort, and filter controls.
206+
*
207+
* @public
208+
* @since 2.22.0
209+
*/
210+
@slot()
211+
header!: Slot<HTMLElement>;
212+
213+
/**
214+
* Defines the content of the Timeline's info bar area, displayed below the header
215+
* and above the items. Use for status display (applied filters, sort direction, counts).
216+
*
217+
* @public
218+
* @since 2.22.0
219+
*/
220+
@slot()
221+
infoBar!: Slot<HTMLElement>;
222+
170223
@query(".ui5-timeline-end-marker")
171224
timelineEndMarker!: HTMLElement;
172225

173226
@query((`[id="ui5-timeline-growing-btn"]`))
174227
growingButton!: HTMLElement;
175228

229+
@query(".ui5-timeline-scroll-container")
230+
_scrollContainer!: HTMLElement;
231+
176232
@i18n("@ui5/webcomponents-fiori")
177233
static i18nBundle: I18nBundle;
178234

@@ -195,6 +251,14 @@ class Timeline extends UI5Element {
195251
: Timeline.i18nBundle.getText(TIMELINE_ARIA_LABEL);
196252
}
197253

254+
get _hasHeader(): boolean {
255+
return this.header.length > 0;
256+
}
257+
258+
get _hasInfoBar(): boolean {
259+
return this.infoBar.length > 0;
260+
}
261+
198262
get showBusyIndicatorOverlay() {
199263
return !this.growsWithButton && this.loading;
200264
}
@@ -308,6 +372,27 @@ class Timeline extends UI5Element {
308372
this._itemNavigation.setCurrentItem(target);
309373
}
310374

375+
_onwheel(e: WheelEvent) {
376+
// In horizontal layout, translate vertical wheel into horizontal scroll
377+
// so a regular mouse wheel can scroll through items.
378+
if (this.layout !== TimelineLayout.Horizontal || !e.deltaY || e.deltaX) {
379+
return;
380+
}
381+
382+
const container = this._scrollContainer;
383+
if (!container) {
384+
return;
385+
}
386+
387+
const canScroll = container.scrollWidth > container.clientWidth;
388+
if (!canScroll) {
389+
return;
390+
}
391+
392+
container.scrollLeft += e.deltaY;
393+
e.preventDefault();
394+
}
395+
311396
onBeforeRendering() {
312397
this._itemNavigation._navigationMode = this.layout === TimelineLayout.Horizontal ? NavigationMode.Horizontal : NavigationMode.Vertical;
313398

packages/fiori/src/TimelineTemplate.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function TimelineTemplate(this: Timeline) {
1313
aria-label={this.ariaLabel}
1414
onFocusIn={this._onfocusin}
1515
onKeyDown={this._onkeydown}
16+
onWheel={this._onwheel}
1617
>
1718
<BusyIndicator
1819
id={`${this._id}-busyIndicator`}
@@ -21,7 +22,20 @@ export default function TimelineTemplate(this: Timeline) {
2122
class="ui5-timeline-busy-indicator"
2223
>
2324
<div class="ui5-timeline-scroll-container">
24-
25+
{(this._hasHeader || this._hasInfoBar) &&
26+
<div class="ui5-timeline-header-area">
27+
{this._hasHeader &&
28+
<div class="ui5-timeline-header">
29+
<slot name="header"></slot>
30+
</div>
31+
}
32+
{this._hasInfoBar &&
33+
<div class="ui5-timeline-info-bar">
34+
<slot name="infoBar"></slot>
35+
</div>
36+
}
37+
</div>
38+
}
2539
<div class="ui5-timeline-list"
2640
role={listRole}
2741
aria-live="polite"

packages/fiori/src/themes/Timeline.css

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,25 @@
5959
margin-inline-start: var(--_ui5_tl_li_margin_bottom);
6060
}
6161

62+
/* Scroll container */
6263
:host([layout="Horizontal"]) .ui5-timeline-scroll-container {
63-
overflow: auto;
64+
overflow-x: auto;
65+
overflow-y: hidden;
6466
/* The padding values of the parent container are added to the size of scroll container */
6567
width: calc(100% + var(--_ui5_timeline_scroll_container_offset));
6668
}
6769

70+
:host([layout="Vertical"]) .ui5-timeline-scroll-container {
71+
height: 100%;
72+
width: 100%;
73+
overflow: auto;
74+
}
75+
76+
.ui5-timeline-scroll-container {
77+
display: flex;
78+
flex-direction: column;
79+
}
80+
6881
:host([loading]) .ui5-timeline-growing-button-busy-indicator:not([_is-busy]) {
6982
display: none;
7083
}
@@ -77,12 +90,28 @@
7790
box-sizing: border-box;
7891
}
7992

80-
:host([layout="Vertical"]) .ui5-timeline-scroll-container {
81-
height: 100%;
82-
width: 100%;
83-
}
84-
8593
:host([growing="Scroll"]) .ui5-timeline-end-marker {
8694
/* Ensure the list-end-marker has a block property to always be stretched and "visible" on the screen */
8795
display: inline-block;
88-
}
96+
}
97+
98+
/* Header area — wraps header + info-bar slots above the items */
99+
.ui5-timeline-header-area {
100+
flex-shrink: 0;
101+
width: 100%;
102+
background: var(--sapBackgroundColor);
103+
margin-block-end: 1rem;
104+
}
105+
106+
.ui5-timeline-header,
107+
.ui5-timeline-info-bar {
108+
flex-shrink: 0;
109+
}
110+
111+
/* Sticky header — pins to the top in vertical layout and to the inline-start in horizontal layout */
112+
:host([sticky-header]) .ui5-timeline-header-area {
113+
position: sticky;
114+
top: 0;
115+
inset-inline-start: 0;
116+
z-index: 2;
117+
}

0 commit comments

Comments
 (0)