diff --git a/packages/fiori/cypress/specs/Timeline.cy.tsx b/packages/fiori/cypress/specs/Timeline.cy.tsx index 0e19b814380b..e72ae273b65f 100644 --- a/packages/fiori/cypress/specs/Timeline.cy.tsx +++ b/packages/fiori/cypress/specs/Timeline.cy.tsx @@ -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( + +
Controls
+ +
+ ); + + 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( + +
Active filters: 2
+ +
+ ); + + 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( + + + + ); + + 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( + +
Search/Filter/Sort
+
Status: 3 items
+ +
+ ); + + 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( + +
Header
+ +
+ ); + + 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( + +
Info
+ +
+ ); + + 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( + +
Header
+ +
+ ); + + 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( + + + + ); + + 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( + + + + ); + + 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"); + }); +}); diff --git a/packages/fiori/src/Timeline.ts b/packages/fiori/src/Timeline.ts index 77aed3ff5e68..a5c0a5f5e61a 100644 --- a/packages/fiori/src/Timeline.ts +++ b/packages/fiori/src/Timeline.ts @@ -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"; @@ -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 @@ -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 @@ -167,6 +224,34 @@ class Timeline extends UI5Element { @slot({ type: HTMLElement, individualSlots: true, "default": true }) items!: DefaultSlot; + /** + * 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; + + /** + * 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; + @query(".ui5-timeline-end-marker") timelineEndMarker!: HTMLElement; @@ -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; } diff --git a/packages/fiori/src/TimelineTemplate.tsx b/packages/fiori/src/TimelineTemplate.tsx index 7818da88a07a..113fece566c6 100644 --- a/packages/fiori/src/TimelineTemplate.tsx +++ b/packages/fiori/src/TimelineTemplate.tsx @@ -21,7 +21,16 @@ export default function TimelineTemplate(this: Timeline) { class="ui5-timeline-busy-indicator" >
- + {this._hasHeader && +
+ +
+ } + {this._hasInfoBar && +
+ +
+ }
ui5-timeline +
+

Showcase — Timeline with header & infoBar slots

+

+ The Timeline exposes two named slots above the items area: header for a controls bar + (search / sort / filter triggers) and infoBar for a status bar that reflects the + applied state. Both can be made sticky via stickyHeader / stickyInfoBar. + The Timeline itself does not filter, sort or search — the application owns that logic. +

+ +
+
+ + + + Sort: Ascending + Filter + + + + Sort: Ascending · No filters · No search + + + + + + + + + + + +
+ +
+

Try it

+
    +
  • Type in the search field — items filter live by author or title.
  • +
  • Click Sort — items reorder by date; the info bar updates.
  • +
  • Click Filter — pick authors in the dialog and apply.
  • +
  • Scroll the Timeline — both bars stick to the top of the scroll container.
  • +
+

+ The Timeline does not implement search/sort/filter itself. Application code listens to + events from the controls and reorders, hides or adds items in the default slot. +

+
+
+ + + + Stanislava Baltova + Sarah Kerrigan + John Smith + +
+ Apply + Cancel +
+
+ + +
+

Timeline within Card Vertical

@@ -417,6 +552,49 @@

Timeline with Various Timeline Item States

+ +
+

Timeline with header and info-bar slots (sticky)

+

Demonstrates the canonical pattern: a controls bar in header with search/sort/filter triggers, and an info bar in info-bar reflecting the applied state. Scroll the Timeline to see sticky behavior.

+
+ + + + + + + + Sort: Ascending · No filters + + + + + + + + + + +
+
+ +
+

Timeline with slots, no internal scroll container (page-level scrolling)

+

When noScrollContainer is set, the Timeline does not clip or scroll its content. Use this when an ancestor (e.g. a DynamicPage) owns scrolling.

+
+ + + Header sticks to the ancestor's scroll + + + + + + + + +
+
diff --git a/packages/fiori/test/pages/TimelineHeaderInfoBar.html b/packages/fiori/test/pages/TimelineHeaderInfoBar.html new file mode 100644 index 000000000000..05ec39ddd61e --- /dev/null +++ b/packages/fiori/test/pages/TimelineHeaderInfoBar.html @@ -0,0 +1,540 @@ + + + + + + Timeline — header & infoBar slots showcase + + + + + + + + + +
+ + +
+
+

1. Full filter pipeline (search + sort + multi-criteria filter)

+

Activity log with type, priority, status and author. Header sticks to the top; info bar surfaces every applied criterion as removable tokens.

+
+
+ + + + Sort: Newest + Filter + + + + +
+ Showing 0 of 0 +
+
+ + + Initial alignment with all stakeholders. + Visual design walk-through and feedback. + Backlog refinement and capacity planning. + Authentication endpoint signature changed. + Re-prioritised the next two sprints. + v1.2.4 hotfix to production. + Demo of new dashboard. + What went well, what to improve. + Search latency up by 40%. + Scope and timing for the next release. + Reviewing PR #482. + v1.3.0 rollout. + Roundtable with three pilot customers. + CVE-2017-XXXX mitigation. +
+
+
+ + +
+
+

2. Minimal — search only with live count

+

Smallest meaningful use of the slots: a search input in header, a live count in infoBar. Sticky header only.

+
+
+ + + + + + 8 of 8 activities + + + + + + + + + + +
+
+ + +
+
+

3. noScrollContainer + ancestor scroll container

+

The ancestor (dashed border) owns scrolling. The Timeline's sticky header sticks to the ancestor — common when a Timeline is embedded in a DynamicPage.

+
+
+ + + Header sticks to the ancestor + Add row + + + 6 rows + + + + + + + + +
+
+ +
+ + + +
+ +
+ Type + + Meeting + Review + Deploy + Incident + +
+ +
+ Status + + Open + In Progress + Done + +
+ +
+ Author + + Stanislava Baltova + Sarah Kerrigan + John Smith + +
+ +
+ Priority + + Any + Low + Medium + High + +
+ +
+ Date range + +
+
+ +
+ Apply + Clear all + Cancel +
+
+ + + + + + diff --git a/packages/website/docs/_components_pages/fiori/Timeline/Timeline.mdx b/packages/website/docs/_components_pages/fiori/Timeline/Timeline.mdx index b9f6544b7bbb..bfcff161e6cb 100644 --- a/packages/website/docs/_components_pages/fiori/Timeline/Timeline.mdx +++ b/packages/website/docs/_components_pages/fiori/Timeline/Timeline.mdx @@ -4,6 +4,7 @@ import InCard from "../../../_samples/fiori/Timeline/InCard/InCard.md"; import WithGroups from "../../../_samples/fiori/Timeline/WithGroups/WithGroups.md"; import WithState from "../../../_samples/fiori/Timeline/WithState/WithState.md"; import WithGrowing from "../../../_samples/fiori/Timeline/WithGrowing/WithGrowing.md"; +import WithFilter from "../../../_samples/fiori/Timeline/WithFilter/WithFilter.md"; <%COMPONENT_OVERVIEW%> @@ -31,4 +32,14 @@ import WithGrowing from "../../../_samples/fiori/Timeline/WithGrowing/WithGrowin ### Timeline with Growing - \ No newline at end of file + + +### Timeline with Header and Info Bar + +The `header` slot accepts any content — typically a `ui5-bar` containing a search field and triggers +for sort and filter dialogs. The `infoBar` slot reflects the resulting state (active filters, +sort direction, current search query). Both slots can be made sticky via the `stickyHeader` and +`stickyInfoBar` properties. The Timeline itself does not filter, sort, or search — the +application owns that logic. + + \ No newline at end of file diff --git a/packages/website/docs/_samples/fiori/Timeline/WithFilter/WithFilter.md b/packages/website/docs/_samples/fiori/Timeline/WithFilter/WithFilter.md new file mode 100644 index 000000000000..0c062a836e84 --- /dev/null +++ b/packages/website/docs/_samples/fiori/Timeline/WithFilter/WithFilter.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + + diff --git a/packages/website/docs/_samples/fiori/Timeline/WithFilter/main.js b/packages/website/docs/_samples/fiori/Timeline/WithFilter/main.js new file mode 100644 index 000000000000..68373cdce3ef --- /dev/null +++ b/packages/website/docs/_samples/fiori/Timeline/WithFilter/main.js @@ -0,0 +1,88 @@ +import "@ui5/webcomponents/dist/Bar.js"; +import "@ui5/webcomponents/dist/Button.js"; +import "@ui5/webcomponents/dist/Dialog.js"; +import "@ui5/webcomponents/dist/Input.js"; +import "@ui5/webcomponents/dist/Label.js"; +import "@ui5/webcomponents/dist/List.js"; +import "@ui5/webcomponents/dist/ListItemStandard.js"; + +import "@ui5/webcomponents-fiori/dist/Timeline.js"; +import "@ui5/webcomponents-fiori/dist/TimelineItem.js"; + +import "@ui5/webcomponents-icons/dist/calendar.js"; +import "@ui5/webcomponents-icons/dist/sort.js"; +import "@ui5/webcomponents-icons/dist/filter.js"; + +const timeline = document.getElementById("filterableTimeline"); +const searchInput = document.getElementById("searchInput"); +const sortButton = document.getElementById("sortButton"); +const filterButton = document.getElementById("filterButton"); +const filterDialog = document.getElementById("filterDialog"); +const filterList = document.getElementById("filterList"); +const filterDialogApply = document.getElementById("filterDialogApply"); +const filterDialogCancel = document.getElementById("filterDialogCancel"); +const activeStateLabel = document.getElementById("activeStateLabel"); + +const allEntries = Array.from(timeline.querySelectorAll("[ui5-timeline-item]")); +let isAscending = true; +let activeAuthors = []; +let searchQuery = ""; + +function applyFilters() { + const filtered = allEntries.filter(item => { + const author = item.getAttribute("data-author") || ""; + const title = item.getAttribute("title-text") || ""; + + const matchesAuthor = activeAuthors.length === 0 || activeAuthors.includes(author); + const matchesSearch = searchQuery === "" + || author.toLowerCase().includes(searchQuery) + || title.toLowerCase().includes(searchQuery); + + return matchesAuthor && matchesSearch; + }); + + const sorted = filtered.slice().sort((firstItem, secondItem) => { + const firstSubtitle = firstItem.getAttribute("subtitle-text") || ""; + const secondSubtitle = secondItem.getAttribute("subtitle-text") || ""; + return isAscending + ? firstSubtitle.localeCompare(secondSubtitle) + : secondSubtitle.localeCompare(firstSubtitle); + }); + + allEntries.forEach(item => { + if (item.parentElement === timeline) { + timeline.removeChild(item); + } + }); + sorted.forEach(item => timeline.appendChild(item)); + + const sortLabel = `Sort: ${isAscending ? "Ascending" : "Descending"}`; + const filterLabel = activeAuthors.length === 0 ? "No filters" : `Filter: ${activeAuthors.join(", ")}`; + const searchLabel = searchQuery === "" ? "No search" : `Search: "${searchQuery}"`; + activeStateLabel.textContent = `${sortLabel} · ${filterLabel} · ${searchLabel}`; +} + +searchInput.addEventListener("input", event => { + searchQuery = event.target.value.trim().toLowerCase(); + applyFilters(); +}); + +sortButton.addEventListener("click", () => { + isAscending = !isAscending; + sortButton.textContent = `Sort: ${isAscending ? "Ascending" : "Descending"}`; + applyFilters(); +}); + +filterButton.addEventListener("click", () => { + filterDialog.open = true; +}); + +filterDialogApply.addEventListener("click", () => { + activeAuthors = filterList.getSelectedItems().map(item => item.textContent.trim()); + filterDialog.open = false; + applyFilters(); +}); + +filterDialogCancel.addEventListener("click", () => { + filterDialog.open = false; +}); diff --git a/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.html b/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.html new file mode 100644 index 000000000000..04bcf45cdc38 --- /dev/null +++ b/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.html @@ -0,0 +1,52 @@ + + + + + + + + Sample + + + + + + + + + Sort: Ascending + Filter + + + + Sort: Ascending · No filters · No search + + + + + + + + + + + + + + + Stanislava Baltova + Sarah Kerrigan + John Smith + +
+ Apply + Cancel +
+
+ + + + + + + diff --git a/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.tsx b/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.tsx new file mode 100644 index 000000000000..a6c798ba05fe --- /dev/null +++ b/packages/website/docs/_samples/fiori/Timeline/WithFilter/sample.tsx @@ -0,0 +1,153 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import { useMemo, useState } from "react"; + +import TimelineClass from "@ui5/webcomponents-fiori/dist/Timeline.js"; +import TimelineItemClass from "@ui5/webcomponents-fiori/dist/TimelineItem.js"; +import BarClass from "@ui5/webcomponents/dist/Bar.js"; +import ButtonClass from "@ui5/webcomponents/dist/Button.js"; +import DialogClass from "@ui5/webcomponents/dist/Dialog.js"; +import InputClass from "@ui5/webcomponents/dist/Input.js"; +import LabelClass from "@ui5/webcomponents/dist/Label.js"; +import ListClass from "@ui5/webcomponents/dist/List.js"; +import ListItemStandardClass from "@ui5/webcomponents/dist/ListItemStandard.js"; + +import "@ui5/webcomponents-icons/dist/calendar.js"; +import "@ui5/webcomponents-icons/dist/sort.js"; +import "@ui5/webcomponents-icons/dist/filter.js"; + +const Timeline = createReactComponent(TimelineClass); +const TimelineItem = createReactComponent(TimelineItemClass); +const Bar = createReactComponent(BarClass); +const Button = createReactComponent(ButtonClass); +const Dialog = createReactComponent(DialogClass); +const Input = createReactComponent(InputClass); +const Label = createReactComponent(LabelClass); +const List = createReactComponent(ListClass); +const ListItemStandard = createReactComponent(ListItemStandardClass); + +type Entry = { + id: string; + titleText: string; + subtitleText: string; + author: string; +}; + +const ENTRIES: Entry[] = [ + { id: "1", titleText: "Project kickoff", subtitleText: "20.02.2017 09:00", author: "Stanislava Baltova" }, + { id: "2", titleText: "Design review", subtitleText: "22.02.2017 11:30", author: "Sarah Kerrigan" }, + { id: "3", titleText: "Sprint planning", subtitleText: "24.02.2017 14:00", author: "John Smith" }, + { id: "4", titleText: "Backlog grooming", subtitleText: "26.02.2017 10:00", author: "Stanislava Baltova" }, + { id: "5", titleText: "Demo to stakeholders", subtitleText: "28.02.2017 15:00", author: "Sarah Kerrigan" }, + { id: "6", titleText: "Retrospective", subtitleText: "01.03.2017 16:00", author: "John Smith" }, + { id: "7", titleText: "Release planning", subtitleText: "03.03.2017 09:30", author: "Stanislava Baltova" }, + { id: "8", titleText: "Deployment", subtitleText: "05.03.2017 12:00", author: "Sarah Kerrigan" }, +]; + +const AUTHORS = ["Stanislava Baltova", "Sarah Kerrigan", "John Smith"]; + +function App() { + const [isAscending, setIsAscending] = useState(true); + const [activeAuthors, setActiveAuthors] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [pendingAuthors, setPendingAuthors] = useState([]); + const [isFilterDialogOpen, setIsFilterDialogOpen] = useState(false); + + const visibleEntries = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const filtered = ENTRIES.filter(entry => { + const matchesAuthor = activeAuthors.length === 0 || activeAuthors.includes(entry.author); + const matchesSearch = query === "" + || entry.author.toLowerCase().includes(query) + || entry.titleText.toLowerCase().includes(query); + return matchesAuthor && matchesSearch; + }); + + return filtered.slice().sort((firstEntry, secondEntry) => { + return isAscending + ? firstEntry.subtitleText.localeCompare(secondEntry.subtitleText) + : secondEntry.subtitleText.localeCompare(firstEntry.subtitleText); + }); + }, [activeAuthors, searchQuery, isAscending]); + + const sortLabel = `Sort: ${isAscending ? "Ascending" : "Descending"}`; + const filterLabel = activeAuthors.length === 0 ? "No filters" : `Filter: ${activeAuthors.join(", ")}`; + const searchLabel = searchQuery.trim() === "" ? "No search" : `Search: "${searchQuery.trim()}"`; + + const openFilterDialog = () => { + setPendingAuthors(activeAuthors); + setIsFilterDialogOpen(true); + }; + + return ( + <> + + + setSearchQuery((event.target as HTMLInputElement).value)} + /> + + + + + + + + + {visibleEntries.map(entry => ( + + ))} + + + setIsFilterDialogOpen(false)} + > + + {AUTHORS.map(author => ( + { + setPendingAuthors(prev => + prev.includes(author) + ? prev.filter(name => name !== author) + : [...prev, author] + ); + }} + > + {author} + + ))} + +
+ + +
+
+ + ); +} + +export default App;