Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui5-timeline): introduce "growing" property #10470

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
195 changes: 191 additions & 4 deletions packages/fiori/src/Timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js";
import {
isTabNext,
isTabPrevious,
isSpace,
isEnter,
} from "@ui5/webcomponents-base/dist/Keys.js";
import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
import type ToggleButton from "@ui5/webcomponents/dist/ToggleButton.js";
import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
import NavigationMode from "@ui5/webcomponents-base/dist/types/NavigationMode.js";
import { TIMELINE_ARIA_LABEL } from "./generated/i18n/i18n-defaults.js";
import TimelineTemplate from "./TimelineTemplate.js";
import "./TimelineItem.js";
import "./TimelineGroupItem.js";

import TimelineItem from "./TimelineItem.js";
import TimelineGroupItem from "./TimelineGroupItem.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import type { ChangeInfo } from "@ui5/webcomponents-base/dist/UI5Element.js";
import debounce from "@ui5/webcomponents-base/dist/util/debounce.js";
// Styles
import TimelineCss from "./generated/themes/Timeline.css.js";
import TimelineLayout from "./types/TimelineLayout.js";
import {TimelineLayout, TimeLineGrowingMode} from "./types/TimelineLayout.js";

/**
* Interface for components that may be slotted inside `ui5-timeline` as items
Expand All @@ -43,6 +48,7 @@ interface ITimelineItem extends UI5Element, ITabbable {

const SHORT_LINE_WIDTH = "ShortLineWidth";
const LARGE_LINE_WIDTH = "LargeLineWidth";
const GROWING_WITH_SCROLL_DEBOUNCE_RATE = 250; // ms

/**
* @class
Expand All @@ -65,8 +71,24 @@ const LARGE_LINE_WIDTH = "LargeLineWidth";
renderer: jsxRenderer,
styles: TimelineCss,
template: TimelineTemplate,
dependencies: [BusyIndicator, TimelineItem, TimelineGroupItem],
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
})

/**
* Fired when the user presses the `More` button or scrolls to the Timeline's end.
*
* **Note:** The event will be fired if `growing` is set to `Button` or `Scroll`.
* @public
* @since 2.0.0
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*/
@event("load-more", {
bubbles: true,
})

class Timeline extends UI5Element {
eventDetails!: {
"load-more": void,
}
/**
* Defines the items orientation.
* @default "Vertical"
Expand All @@ -85,6 +107,52 @@ class Timeline extends UI5Element {
@property()
accessibleName?: string;

/**
* Defines if the component would display a loading indicator over the Timeline.
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*
* @default false
* @since 2.0.0
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
* @public
*/
@property({ type: Boolean })
loading = false;

/**
* Defines the delay in milliseconds, after which the loading indicator will show up for this component.
* @default 1000
* @public
*/
@property({ type: Number })
loadingDelay = 1000;

/**
* Defines whether the Timeline will have growing capability either by pressing a `More` button,
* or via user scroll. In both cases `load-more` event is fired.
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*
* Available options:
*
* `Button` - Shows a `More` button at the bottom of the Timeline, pressing of which triggers the `load-more` event.
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*
* `Scroll` - The `load-more` event is triggered when the user scrolls to the bottom of the Timeline;
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*
* `None` (default) - The growing is off.
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
*
* **Restrictions:** `growing="Scroll"` is not supported for Internet Explorer,
* and the component will fallback to `growing="Button"`.
* @default "None"
* @since 2.0.0
* @public
*/
@property()
growing: `${TimeLineGrowingMode}` = "None";

/**
* Defines the active state of the `More` button.
* @private
*/
@property({ type: Boolean })
_loadMoreActive = false;

/**
* Determines the content of the `ui5-timeline`.
* @public
Expand All @@ -96,13 +164,22 @@ class Timeline extends UI5Element {
static i18nBundle: I18nBundle;

_itemNavigation: ItemNavigation;
growingIntersectionObserver?: IntersectionObserver | null;
timeLineEndObserved: boolean;
initialIntersection: boolean;

constructor() {
super();

this._itemNavigation = new ItemNavigation(this, {
getItemsCallback: () => this._navigatableItems,
});

this.timeLineEndObserved = false;
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved

// Indicates the Timeline bottom most part has been detected by the IntersectionObserver
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
// for the first time.
this.initialIntersection = true;
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}

get ariaLabel() {
Expand All @@ -111,6 +188,116 @@ class Timeline extends UI5Element {
: Timeline.i18nBundle.getText(TIMELINE_ARIA_LABEL);
}

get growsOnScroll(): boolean {
return this.growing === TimeLineGrowingMode.Scroll;
}

get timeLineEndDOM(): Element {
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
return this.shadowRoot!.querySelector(".ui5-time-line-end-marker")!;
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}

get growingButtonIcon() {
return this.layout === TimelineLayout.Horizontal ? "process" : "drill-down";
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}

get moreBtn(): HTMLElement | null {
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
const domRef = this.getDomRef();

if (this.growsWithButton && domRef) {
return domRef.querySelector<HTMLElement>(`#${this._id}-growingButton`);
}

return null;
}

get showBusyIndicatorOverlay() {
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
return !this.growsWithButton && this.loading;
}

get growsWithButton(): boolean {
return this.growing === TimeLineGrowingMode.Button;
}

onAfterRendering() {
if (this.growsOnScroll) {
this.observeTimeLineEnd();
}
}

onEnterDOM() {
this.growingIntersectionObserver = this.getIntersectionObserver();
}

onExitDOM() {
this.growingIntersectionObserver!.disconnect();
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
this.growingIntersectionObserver = null;
this.timeLineEndObserved = false;
}

observeTimeLineEnd() {
if (!this.timeLineEndObserved) {
this.getIntersectionObserver().observe(this.timeLineEndDOM);
this.timeLineEndObserved = true;
}
}

getIntersectionObserver(): IntersectionObserver {
if (!this.growingIntersectionObserver) {
this.growingIntersectionObserver = new IntersectionObserver(this.onInteresection.bind(this), {
root: document,
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
threshold: 1.0,
});
}

return this.growingIntersectionObserver;
}

onInteresection(entries: Array<IntersectionObserverEntry>) {
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
if (this.initialIntersection) {
this.initialIntersection = false;
return;
}

if (entries.some(entry => entry.isIntersecting)) {
debounce(this.loadMore.bind(this), GROWING_WITH_SCROLL_DEBOUNCE_RATE);
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}
}

loadMore() {
this.fireDecoratorEvent("load-more");
}

_onLoadMoreKeydown(e: KeyboardEvent) {
if (isSpace(e)) {
e.preventDefault();
this._loadMoreActive = true;
}

if (isEnter(e)) {
this._onLoadMoreClick();
this._loadMoreActive = true;
}
}

_onLoadMoreKeyup(e: KeyboardEvent) {
if (isSpace(e)) {
this._onLoadMoreClick();
}
this._loadMoreActive = false;
}

onInvalidation(change: ChangeInfo) {
console.error(change)
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
if (change.type === "property" && change.name === "growing") {
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
this.timeLineEndObserved = false;
this.getIntersectionObserver().disconnect();
}
}

_onLoadMoreClick() {
this.fireDecoratorEvent("load-more");
}

_onfocusin(e: FocusEvent) {
let target = e.target as ITimelineItem | ToggleButton;

Expand Down
2 changes: 1 addition & 1 deletion packages/fiori/src/TimelineGroupItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import TimelineLayout from "./types/TimelineLayout.js";
import {TimelineLayout} from "./types/TimelineLayout.js";
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
import type { ITimelineItem } from "./Timeline.js";

import TimelineGroupItemTemplate from "./TimelineGroupItemTemplate.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/fiori/src/TimelineItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import type Link from "@ui5/webcomponents/dist/Link.js";
import type { ITimelineItem } from "./Timeline.js";
import TimelineItemTemplate from "./TimelineItemTemplate.js";
import type TimelineLayout from "./types/TimelineLayout.js";
// Styles
import {TimelineLayout} from "./types/TimelineLayout.js";
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
import TimelineItemCss from "./generated/themes/TimelineItem.css.js";

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/fiori/src/TimelineItemTemplate.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type TimelineItem from "./TimelineItem.js";
import Link from "@ui5/webcomponents/dist/Link.js";
import Icon from "@ui5/webcomponents/dist/Icon.js";
import TimelineLayout from "./types/TimelineLayout.js";
import { TimelineLayout } from "./types/TimelineLayout.js";

export default function TimelineItemTemplate(this: TimelineItem) {
return (
Expand Down
60 changes: 50 additions & 10 deletions packages/fiori/src/TimelineTemplate.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,60 @@
import type Timeline from "./Timeline.js";
import Button from "@ui5/webcomponents/dist/Button.js";
import Timeline from "./Timeline.js";
import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js";
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved

export default function TimelineTemplate(this: Timeline) {
return (
<div class="ui5-timeline-root"
onFocusIn={this._onfocusin}
onKeyDown={this._onkeydown}>

<div class="ui5-timeline-scroll-container">
<ul class="ui5-timeline-list" aria-live="polite" aria-label={this.ariaLabel}>
{this.items.map(item =>
<li class="ui5-timeline-list-item">
<slot name={item._individualSlot}></slot>
</li>
)}
</ul>
</div>
<div class="ui5-timeline-scroll-container">
<ul class="ui5-timeline-list" aria-live="polite" aria-label={this.ariaLabel}>
{this.items.map(item =>
<li class="ui5-timeline-list-item">
<slot name={item._individualSlot}></slot>
</li>
)}
{ this.growsWithButton && moreRow.call(this)}
{ this.growsOnScroll && endRow.call(this)}
</ul>
</div>

</div>
);
}

function moreRow(this: Timeline) {
return (
<li class="ui5-timeline-list-item ui5-timeline-list-growing">
<div class="ui5-tli-icon-outer">
<Button icon={this.growingButtonIcon}
id={`${this._id}-growingButton`}
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
class={{
"ui5-table-growing-row-inner": true,
"ui5-table-growing-row-inner--active": this._loadMoreActive
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}}
tabindex={0}
onClick={this._onLoadMoreClick}
onKeyDown={this._onLoadMoreKeydown}
onKeyUp={this._onLoadMoreKeyup}
></Button>
</div>
{this.loading &&
<BusyIndicator
delay={this.loadingDelay}
class="ui5-list-growing-button-busy-indicator"
active>
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
</BusyIndicator>
}
</li>
)
}

function endRow(this: Timeline) {
return (
<div tabindex={-1} aria-hidden="true" class="ui5-time-line-end-marker">
<span tabindex={-1} aria-hidden="true" class="ui5-time-line-end-marker"></span>
</div>
)
}
15 changes: 15 additions & 0 deletions packages/fiori/src/themes/Timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
padding: 0;
}

:host([layout="Horizontal"]) .ui5-timeline-list-item.ui5-timeline-list-growing {
display: flex;
flex-direction: column;
justify-content: center;
}
:host([layout="Vertical"]) .ui5-timeline-list-item.ui5-timeline-list-growing {
display: flex;
flex-direction: row;
justify-content: center;
}

:host .ui5-time-line-end-marker {
margin: -1px;
Todor-ads marked this conversation as resolved.
Show resolved Hide resolved
}

:host([layout="Vertical"]) .ui5-timeline-list {
display: flex;
flex-direction: column;
Expand Down
Loading
Loading