Skip to content

Commit

Permalink
[CL-485] Add small delay for async action loading state (#12835)
Browse files Browse the repository at this point in the history
  • Loading branch information
vleague2 authored Feb 25, 2025
1 parent d11321e commit 6d1914f
Show file tree
Hide file tree
Showing 16 changed files with 253 additions and 97 deletions.
29 changes: 17 additions & 12 deletions libs/components/src/async-actions/bit-action.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,35 @@ export class BitActionDirective implements OnDestroy {
private destroy$ = new Subject<void>();
private _loading$ = new BehaviorSubject<boolean>(false);

disabled = false;

@Input("bitAction") handler: FunctionReturningAwaitable;

/**
* Observable of loading behavior subject
*
* Used in `form-button.directive.ts`
*/
readonly loading$ = this._loading$.asObservable();

constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService,
) {}

get loading() {
return this._loading$.value;
}

set loading(value: boolean) {
this._loading$.next(value);
this.buttonComponent.loading = value;
this.buttonComponent.loading.set(value);
}

disabled = false;

@Input("bitAction") handler: FunctionReturningAwaitable;

constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService,
@Optional() private logService?: LogService,
) {}

@HostListener("click")
protected async onClick() {
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled) {
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled()) {
return;
}

Expand Down
6 changes: 3 additions & 3 deletions libs/components/src/async-actions/form-button.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ export class BitFormButtonDirective implements OnDestroy {
if (submitDirective && buttonComponent) {
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
if (this.type === "submit") {
buttonComponent.loading = loading;
buttonComponent.loading.set(loading);
} else {
buttonComponent.disabled = this.disabled || loading;
buttonComponent.disabled.set(this.disabled || loading);
}
});

submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
if (this.disabled !== false) {
buttonComponent.disabled = this.disabled || disabled;
buttonComponent.disabled.set(this.disabled || disabled);
}
});
}
Expand Down
29 changes: 26 additions & 3 deletions libs/components/src/async-actions/standalone.mdx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Meta } from "@storybook/addon-docs";
import { Meta, Story } from "@storybook/addon-docs";
import * as stories from "./standalone.stories.ts";

<Meta title="Component Library/Async Actions/Standalone/Documentation" />
<Meta of={stories} />

# Standalone Async Actions

These directives should be used when building a standalone button that triggers a long running task
in the background, eg. Refresh buttons. For non-submit buttons that are associated with forms see
[Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page).

If the long running background task resolves quickly (e.g. less than 75 ms), the loading spinner
will not display on the button. This prevents an undesirable "flicker" of the loading spinner when
it is not necessary for the user to see it.

## Usage

Adding async actions to standalone buttons requires the following 2 steps
Adding async actions to standalone buttons requires the following 2 steps:

### 1. Add a handler to your `Component`

Expand Down Expand Up @@ -60,3 +65,21 @@ from how click handlers are usually defined with the output syntax `(click)="han

<button bitIconButton="bwi-trash" [bitAction]="handler"></button>`;
```

## Stories

### Promise resolves -- loading spinner is displayed

<Story of={stories.UsingPromise} />

### Promise resolves -- quickly without loading spinner

<Story of={stories.ActionResolvesQuickly} />

### Promise rejects

<Story of={stories.RejectedPromise} />

### Observable

<Story of={stories.UsingObservable} />
35 changes: 32 additions & 3 deletions libs/components/src/async-actions/standalone.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { IconButtonModule } from "../icon-button";

import { BitActionDirective } from "./bit-action.directive";

const template = `
const template = /*html*/ `
<button bitButton buttonType="primary" [bitAction]="action" class="tw-mr-2">
Perform action
Perform action {{ statusEmoji }}
</button>
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;

Expand All @@ -22,9 +22,30 @@ const template = `
selector: "app-promise-example",
})
class PromiseExampleComponent {
statusEmoji = "🟡";
action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
setTimeout(() => {
resolve();
this.statusEmoji = "🟢";
}, 5000);
});
};
}

@Component({
template,
selector: "app-action-resolves-quickly",
})
class ActionResolvesQuicklyComponent {
statusEmoji = "🟡";

action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(() => {
resolve();
this.statusEmoji = "🟢";
}, 50);
});
};
}
Expand Down Expand Up @@ -59,6 +80,7 @@ export default {
PromiseExampleComponent,
ObservableExampleComponent,
RejectedPromiseExampleComponent,
ActionResolvesQuicklyComponent,
],
imports: [ButtonModule, IconButtonModule, BitActionDirective],
providers: [
Expand Down Expand Up @@ -100,3 +122,10 @@ export const RejectedPromise: ObservableStory = {
template: `<app-rejected-promise-example></app-rejected-promise-example>`,
}),
};

export const ActionResolvesQuickly: PromiseStory = {
render: (args) => ({
props: args,
template: `<app-action-resolves-quickly></app-action-resolves-quickly>`,
}),
};
4 changes: 2 additions & 2 deletions libs/components/src/button/button.component.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<ng-content></ng-content>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</span>
Expand Down
72 changes: 55 additions & 17 deletions libs/components/src/button/button.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common";
import { Input, HostBinding, Component } from "@angular/core";
import { Input, HostBinding, Component, model, computed } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounce, interval } from "rxjs";

import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";

Expand Down Expand Up @@ -49,6 +51,9 @@ const buttonStyles: Record<ButtonType, string[]> = {
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
standalone: true,
imports: [NgClass],
host: {
"[attr.disabled]": "disabledAttr()",
},
})
export class ButtonComponent implements ButtonLikeAbstraction {
@HostBinding("class") get classList() {
Expand All @@ -64,24 +69,41 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"tw-no-underline",
"hover:tw-no-underline",
"focus:tw-outline-none",
"disabled:tw-bg-secondary-300",
"disabled:hover:tw-bg-secondary-300",
"disabled:tw-border-secondary-300",
"disabled:hover:tw-border-secondary-300",
"disabled:!tw-text-muted",
"disabled:hover:!tw-text-muted",
"disabled:tw-cursor-not-allowed",
"disabled:hover:tw-no-underline",
]
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
.concat(buttonStyles[this.buttonType ?? "secondary"]);
.concat(buttonStyles[this.buttonType ?? "secondary"])
.concat(
this.showDisabledStyles() || this.disabled()
? [
"disabled:tw-bg-secondary-300",
"disabled:hover:tw-bg-secondary-300",
"disabled:tw-border-secondary-300",
"disabled:hover:tw-border-secondary-300",
"disabled:!tw-text-muted",
"disabled:hover:!tw-text-muted",
"disabled:tw-cursor-not-allowed",
"disabled:hover:tw-no-underline",
]
: [],
);
}

@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
protected disabledAttr = computed(() => {
const disabled = this.disabled() != null && this.disabled() !== false;
return disabled || this.loading() ? true : null;
});

/**
* Determine whether it is appropriate to display the disabled styles. We only want to show
* the disabled styles if the button is truly disabled, or if the loading styles are also
* visible.
*
* We can't use `disabledAttr` for this, because it returns `true` when `loading` is `true`.
* We only want to show disabled styles during loading if `showLoadingStyles` is `true`.
*/
protected showDisabledStyles = computed(() => {
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
});

@Input() buttonType: ButtonType;

Expand All @@ -96,7 +118,23 @@ export class ButtonComponent implements ButtonLikeAbstraction {
this._block = coerceBooleanProperty(value);
}

@Input() loading = false;
loading = model<boolean>(false);

/**
* Determine whether it is appropriate to display a loading spinner. We only want to show
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents
* a spinner "flash" for actions that are synchronous/nearly synchronous.
*
* We can't use `loading` for this, because we still need to disable the button during
* the full `loading` state. I.e. we only want the spinner to be debounced, not the
* loading state.
*
* This pattern of converting a signal to an observable and back to a signal is not
* recommended. TODO -- find better way to use debounce with signals (CL-596)
*/
protected showLoadingStyle = toSignal(
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
);

@Input() disabled = false;
disabled = model<boolean>(false);
}
4 changes: 2 additions & 2 deletions libs/components/src/icon-button/icon-button.component.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i
class="bwi bwi-spinner bwi-spin"
Expand Down
Loading

0 comments on commit 6d1914f

Please sign in to comment.