Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions packages/fiori/cypress/specs/Timeline.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,156 @@ describe("TimelineItem iconTooltip", () => {
});
});

describe("Timeline header and info-bar slots", () => {
it("renders the header slot when content is provided", () => {
cy.mount(
<Timeline>
<div slot="header" id="header-content" data-testid="header">Controls</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-header")
.should("exist");

cy.get("#header-content").should("be.visible");
});

it("renders the info-bar slot when content is provided", () => {
cy.mount(
<Timeline>
<div slot="infoBar" id="info-bar-content">Active filters: 2</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-info-bar")
.should("exist");

cy.get("#info-bar-content").should("be.visible");
});

it("does not render header or info-bar wrappers when slots are empty", () => {
cy.mount(
<Timeline>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-header")
.should("not.exist");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-info-bar")
.should("not.exist");
});

it("renders both slots side by side when both are provided", () => {
cy.mount(
<Timeline>
<div slot="header" id="hdr">Search/Filter/Sort</div>
<div slot="infoBar" id="ifb">Status: 3 items</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-header")
.should("exist");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-info-bar")
.should("exist");

cy.get("#hdr").should("be.visible");
cy.get("#ifb").should("be.visible");
});

it("reflects stickyHeader as the [sticky-header] host attribute and applies sticky positioning", () => {
cy.mount(
<Timeline stickyHeader={true}>
<div slot="header" id="hdr">Header</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.should("have.attr", "sticky-header");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-header")
.should("have.css", "position", "sticky");
});

it("reflects stickyInfoBar as the [sticky-info-bar] host attribute and applies sticky positioning", () => {
cy.mount(
<Timeline stickyInfoBar={true}>
<div slot="infoBar" id="ifb">Info</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.should("have.attr", "sticky-info-bar");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-info-bar")
.should("have.css", "position", "sticky");
});

it("does not apply sticky positioning when stickyHeader is false (default)", () => {
cy.mount(
<Timeline>
<div slot="header" id="hdr">Header</div>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.should("not.have.attr", "sticky-header");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-header")
.should("not.have.css", "position", "sticky");
});

it("noScrollContainer defaults to false and the host has no [no-scroll-container] attribute", () => {
cy.mount(
<Timeline>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.should("not.have.attr", "no-scroll-container");
});

it("removes overflow:auto from the scroll container when noScrollContainer is set", () => {
cy.mount(
<Timeline noScrollContainer>
<TimelineItem titleText="Item" subtitleText="now" icon={calendar}></TimelineItem>
</Timeline>
);

cy.get("[ui5-timeline]")
.should("have.attr", "no-scroll-container");

cy.get("[ui5-timeline]")
.shadow()
.find(".ui5-timeline-scroll-container")
.should("not.have.css", "overflow-y", "auto");
});
});

95 changes: 94 additions & 1 deletion packages/fiori/src/Timeline.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js";
import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
Expand Down Expand Up @@ -70,6 +70,23 @@ const GROWING_WITH_SCROLL_DEBOUNCE_RATE = 250; // ms
* These entries can be generated by the system (for example, value XY changed from A to B), or added manually.
* There are two distinct variants of the timeline: basic and social. The basic timeline is read-only,
* while the social timeline offers a high level of interaction and collaboration, and is integrated within SAP Jam.
*
* ### Header and Info Bar Slots
*
* The Timeline exposes two named slots above the items area:
*
* - `header` — for a controls bar (search field, filter trigger, sort toggle, etc.).
* The most common pattern is to place a `ui5-bar` containing a search input and buttons that open
* a filter dialog or toggle sort direction. The Timeline itself performs no filtering, sorting, or
* searching — the application listens for events from its own controls and reorders, hides, or
* adds items in the default slot accordingly.
*
* - `infoBar` — for a status bar that reflects the result of the controls (active filters,
* applied sort, current search query). Typically contains tokens, labels, or a `ui5-bar`.
*
* Either slot can be made sticky using `stickyHeader` and `stickyInfoBar`. Sticky behavior
* applies relative to the Timeline's internal scroll container by default, and relative
* to the nearest ancestor scroll container when `noScrollContainer` is set.
* @constructor
* @extends UI5Element
* @public
Expand Down Expand Up @@ -153,6 +170,46 @@ class Timeline extends UI5Element {
@property()
growing: `${TimelineGrowingMode}` = "None";

/**
* Defines whether the Timeline relinquishes its internal scroll container.
*
* By default the Timeline scrolls internally; sticky header and info bar stick to the
* top of the Timeline. When set to `true`, the Timeline does not clip or scroll its
* content — the application is expected to provide a scroll container on an ancestor
* element, and sticky slots will stick to that ancestor instead.
*
* **Note:** When the layout is `Horizontal`, items scroll horizontally inside the Timeline
* by default. Setting `noScrollContainer` in horizontal layout means the application must
* also provide horizontal scrolling on an ancestor; otherwise items will overflow without a
* scrollbar.
*
* @default false
* @public
* @since 2.22.0
*/
@property({ type: Boolean })
noScrollContainer = false;

/**
* Defines whether the content of the `header` slot remains visible when the user scrolls the Timeline.
*
* @default false
* @public
* @since 2.22.0
*/
@property({ type: Boolean })
stickyHeader = false;

/**
* Defines whether the content of the `infoBar` slot remains visible when the user scrolls the Timeline.
*
* @default false
* @public
* @since 2.22.0
*/
@property({ type: Boolean })
stickyInfoBar = false;

/**
* Defines the active state of the `More` button.
* @private
Expand All @@ -167,6 +224,34 @@ class Timeline extends UI5Element {
@slot({ type: HTMLElement, individualSlots: true, "default": true })
items!: DefaultSlot<ITimelineItem>;

/**
* Defines the content of the Timeline's header area, displayed above the items.
*
* The most common use case is a controls bar with a search field, sort toggle,
* and a filter trigger. Typically a `ui5-bar` is placed in this slot. The Timeline
* itself does not filter, sort, or search — the application listens for events from
* its own controls and updates the items in the default slot accordingly.
*
* @public
* @since 2.22.0
*/
@slot()
header!: Slot<HTMLElement>;

/**
* Defines the content of the Timeline's info bar area, displayed below the header
* and above the items.
*
* Use this slot to surface state derived from the header controls — for example,
* a list of currently applied filters, the active sort direction, or the current
* search query.
*
* @public
* @since 2.22.0
*/
@slot()
infoBar!: Slot<HTMLElement>;

@query(".ui5-timeline-end-marker")
timelineEndMarker!: HTMLElement;

Expand Down Expand Up @@ -195,6 +280,14 @@ class Timeline extends UI5Element {
: Timeline.i18nBundle.getText(TIMELINE_ARIA_LABEL);
}

get _hasHeader(): boolean {
return this.header.length > 0;
}

get _hasInfoBar(): boolean {
return this.infoBar.length > 0;
}

get showBusyIndicatorOverlay() {
return !this.growsWithButton && this.loading;
}
Expand Down
11 changes: 10 additions & 1 deletion packages/fiori/src/TimelineTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ export default function TimelineTemplate(this: Timeline) {
class="ui5-timeline-busy-indicator"
>
<div class="ui5-timeline-scroll-container">

{this._hasHeader &&
<div class="ui5-timeline-header">
<slot name="header"></slot>
</div>
}
{this._hasInfoBar &&
<div class="ui5-timeline-info-bar">
<slot name="infoBar"></slot>
</div>
}
<div class="ui5-timeline-list"
role={listRole}
aria-live="polite"
Expand Down
57 changes: 50 additions & 7 deletions packages/fiori/src/themes/Timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,26 @@
margin-inline-start: var(--_ui5_tl_li_margin_bottom);
}

:host([layout="Horizontal"]) .ui5-timeline-scroll-container {
/* Scroll container — Timeline owns scrolling by default; opt out with [no-scroll-container] */
:host(:not([no-scroll-container])[layout="Horizontal"]) .ui5-timeline-scroll-container {
overflow: auto;
/* The padding values of the parent container are added to the size of scroll container */
width: calc(100% + var(--_ui5_timeline_scroll_container_offset));
}

:host(:not([no-scroll-container])[layout="Vertical"]) .ui5-timeline-scroll-container {
height: 100%;
width: 100%;
overflow: auto;
}

/* When [no-scroll-container] is set, the scroll container is a transparent flex layer:
no overflow, no clipping. The application owns scrolling on an ancestor. */
.ui5-timeline-scroll-container {
display: flex;
flex-direction: column;
}

:host([loading]) .ui5-timeline-growing-button-busy-indicator:not([_is-busy]) {
display: none;
}
Expand All @@ -77,12 +91,41 @@
box-sizing: border-box;
}

:host([layout="Vertical"]) .ui5-timeline-scroll-container {
height: 100%;
width: 100%;
}

:host([growing="Scroll"]) .ui5-timeline-end-marker {
/* Ensure the list-end-marker has a block property to always be stretched and "visible" on the screen */
display: inline-block;
}
}

/* Header and info-bar slots — render above the items area inside the scroll container,
so they participate in the same scroll context (required for position: sticky to work). */
.ui5-timeline-header,
.ui5-timeline-info-bar {
flex-shrink: 0;
background: var(--sapBackgroundColor);
}

.ui5-timeline-header {
margin-block-end: 0.5rem;
}

/* Sticky behavior — opt-in via host attributes; CSS-only, no JS measurement.
When both are sticky, normal stacking + flex layout keeps them in order:
the header sticks at top:0, the info-bar sticks just below it because the
header occupies the top portion of the scroll container's viewport. */
:host([sticky-header]) .ui5-timeline-header {
position: sticky;
top: 0;
z-index: 2;
}

:host([sticky-info-bar]) .ui5-timeline-info-bar {
position: sticky;
top: 0;
z-index: 1;
}

:host([sticky-header][sticky-info-bar]) .ui5-timeline-info-bar {
/* When both are sticky, info-bar must rest below the header.
Apps can override this CSS variable to match their header's height. */
top: var(--_ui5_timeline_sticky_header_height, 3rem);
}
Loading
Loading