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(pie-toast-provider): DSW-2222 toast provider basic functionality #2098

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 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
9 changes: 9 additions & 0 deletions .changeset/tiny-eels-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@justeattakeaway/pie-toast-provider": minor
"@justeattakeaway/pie-toast": minor
"@justeattakeaway/pie-webc": minor
"pie-storybook": minor
"pie-monorepo": minor
---

[Added] - priority order for the toast provider
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
],
[
"4",
"Positive - actionable"
"Success - actionable"
],
[
"5",
Expand All @@ -34,7 +34,7 @@
],
[
"8",
"Positive"
"Success"
],
[
"9",
Expand Down
111 changes: 103 additions & 8 deletions apps/pie-storybook/stories/pie-toast-provider.stories.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
import { html } from 'lit';
import { type Meta } from '@storybook/web-components';
import { action } from '@storybook/addon-actions';

import '@justeattakeaway/pie-toast-provider';
import { type ToastProviderProps } from '@justeattakeaway/pie-toast-provider';
import { toaster } from '@justeattakeaway/pie-toast-provider';
import { type ToastProviderProps, defaultProps } from '@justeattakeaway/pie-toast-provider';
import '@justeattakeaway/pie-button';
import '@justeattakeaway/pie-tag';

import { createStory } from '../utilities';

type ToastProviderStoryMeta = Meta<ToastProviderProps>;

const defaultArgs: ToastProviderProps = {};
const defaultArgs: ToastProviderProps = {
...defaultProps,
options: {
duration: 3000,
isDismissible: true,
onPieToastOpen: action('onPieToastOpen'),
onPieToastClose: action('onPieToastClose'),
onPieToastLeadingActionClick: action('onPieToastLeadingActionClick'),
},
};

const toastProviderStoryMeta: ToastProviderStoryMeta = {
title: 'Toast Provider',
component: 'pie-toast-provider',
argTypes: {},
argTypes: {
options: {
description: 'Default options for all toasts; accepts all toast props.',
control: 'object',
defaultValue: {
summary: defaultProps.options,
},
},
},
args: defaultArgs,
parameters: {
design: {
Expand All @@ -25,10 +45,85 @@ const toastProviderStoryMeta: ToastProviderStoryMeta = {

export default toastProviderStoryMeta;

// TODO: remove the eslint-disable rule when props are added
// eslint-disable-next-line no-empty-pattern
const Template = ({}: ToastProviderProps) => html`
<pie-toast-provider></pie-toast-provider>
const Template = ({ options }: ToastProviderProps) => {
const onQueueUpdate = (event: CustomEvent) => {
const queueLength = document.querySelector('#queue-length-tag') as HTMLElement;
if (queueLength) {
queueLength.textContent = `Toast Queue Length: ${event.detail.length}`;
}
};

return html`
<pie-toast-provider
.options=${options}
@pie-toast-provider-queue-update=${onQueueUpdate}>
</pie-toast-provider>

<pie-tag id="queue-length-tag" variant="information" style="margin-top: 16px;">
Toast Queue Length: 0
</pie-tag>

<div style="margin-top: 16px; display: flex; gap: 16px; flex-wrap: wrap;">
<pie-button
@click=${() => {
toaster.create({
message: 'Low Priority Info',
variant: 'info',
});
Comment on lines +67 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be ok to move the indentation of these buttons click handlers content?

Suggested change
<pie-button
@click=${() => {
toaster.create({
message: 'Low Priority Info',
variant: 'info',
});
<pie-button
@click=${() => {
toaster.create({
message: 'Low Priority Info',
variant: 'info',
});

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's auto-formatted on save for me 🙄 are you getting the same thing locally?

Screen.Recording.2024-12-02.at.13.06.43.mov

}}>
Trigger Info Toast (Low Priority)
</pie-button>

<pie-button
@click=${() => {
toaster.create({
message: 'Medium Priority Warning Toast',
variant: 'warning',
});
}}>
Trigger Warning Toast (Medium Priority)
</pie-button>

<pie-button
@click=${() => {
toaster.create({
message: 'High Priority Error Toast',
variant: 'error',
});
}}>
Trigger Error Toast (High Priority)
</pie-button>

<pie-button
@click=${() => {
toaster.create({
message: 'Actionable Info Toast',
variant: 'info',
leadingAction: { text: 'Retry' },
});
}}>
Trigger Actionable Info Toast
</pie-button>

<pie-button
@click=${() => {
toaster.create({
message: 'Persistent Toast',
duration: null,
});
}}>
Trigger Persistent Toast
</pie-button>

<pie-button
variant="secondary"
@click=${() => {
toaster.clearAll();
}}>
Clear All Toasts
</pie-button>
</div>
`;
};

export const Default = createStory<ToastProviderProps>(Template, defaultArgs)();
1 change: 1 addition & 0 deletions packages/components/pie-toast-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"cem-plugin-module-file-extensions": "0.0.5"
},
"dependencies": {
"@justeattakeaway/pie-toast": "0.5.0",
"@justeattakeaway/pie-webc-core": "0.24.2"
},
"volta": {
Expand Down
57 changes: 54 additions & 3 deletions packages/components/pie-toast-provider/src/defs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,54 @@
// TODO - please remove the eslint disable comment below when you add props to this interface
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ToastProviderProps {}
import { type ToastProps } from '@justeattakeaway/pie-toast';

import { type ComponentDefaultProps } from '@justeattakeaway/pie-webc-core';

export const PRIORITY_ORDER: { [x: string]: number } = {
'error-actionable': 1,
error: 2,
'warning-actionable': 3,
'success-actionable': 4,
'info-actionable': 5,
'neutral-actionable': 6,
warning: 7,
success: 8,
info: 9,
neutral: 10,
};

export interface ExtendedToastProps extends ToastProps {
/**
* Triggered when the user interacts with the close icon or when the toast auto dismiss.
*/
onPieToastClose?: () => void;

/**
* Triggered when the toast is opened.
*/
onPieToastOpen?: () => void;

/**
* Triggered when the user interacts with the leading action.
*/
onPieToastLeadingActionClick?: (event: Event) => void;
}

export interface ToastProviderProps {
/**
* Default options for all toasts; accepts all toast props.
*/
options?: Partial<ExtendedToastProps>;
}

export type DefaultProps = ComponentDefaultProps<ToastProviderProps>;

export const defaultProps: DefaultProps = {
options: {},
};

/**
* Event name for when the toast provider queue is updated.
*
* @constant
*/

export const ON_TOAST_PROVIDER_QUEUE_UPDATE_EVENT = 'pie-toast-provider-queue-update';
135 changes: 130 additions & 5 deletions packages/components/pie-toast-provider/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,144 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { RtlMixin, defineCustomElement } from '@justeattakeaway/pie-webc-core';

import {
LitElement,
html,
nothing,
unsafeCSS,
type PropertyValues,
} from 'lit';
import { state, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import {
RtlMixin,
defineCustomElement,
dispatchCustomEvent,
} from '@justeattakeaway/pie-webc-core';
import '@justeattakeaway/pie-toast';
import styles from './toast-provider.scss?inline';
import { type ToastProviderProps } from './defs';
import {
defaultProps,
PRIORITY_ORDER,
type ToastProviderProps,
type ExtendedToastProps,
ON_TOAST_PROVIDER_QUEUE_UPDATE_EVENT,
} from './defs';

// Valid values available to consumers
export * from './defs';
export { toaster } from './toaster';

const componentSelector = 'pie-toast-provider';

/**
* @tagname pie-toast-provider
* @event {CustomEvent} pie-toast-provider-queue-update - when a toast is added or removed from the queue.
*/
export class PieToastProvider extends RtlMixin(LitElement) implements ToastProviderProps {
@state()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when a consumer mistakenly adds a second Toast Provider to an app?

Copy link
Member Author

@raoufswe raoufswe Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We currently assume only one toast provider, rendered at the root level of the consumer app, and toasts are displayed in the body as specified in design specs here

However, this might change as designers plan to support rendering toasts within modals (sub-containers) and this will be addressed once we receive the complete design requirements and will probably require the usage of multiple providers either identified by ids or querying the closest provider in the DOM tree when toaster.create is called.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that is expected to have a single provider at the moment, that's why I ask what happens if the user does a mistake. Everything still works perfectly when more than one provider is in the DOM? Should we help the user to avoid this mistake?

private _toasts: ExtendedToastProps[] = [];

@state()
private _currentToast: ExtendedToastProps | null = null;

@property({ type: Object })
public options = defaultProps.options;

updated (changedProperties: PropertyValues<this>): void {
if (changedProperties.has('_toasts' as keyof PieToastProvider)) {
this._dispatchQueueUpdateEvent();
}
}

private _dispatchQueueUpdateEvent (): void {
dispatchCustomEvent(
this, ON_TOAST_PROVIDER_QUEUE_UPDATE_EVENT,
this._toasts,
);
}

/**
* Get the priority for a toast.
* @param {string} type - The variant type of the toast.
* @param {boolean} hasAction - Whether the toast has an action.
* @returns {number} - The priority based on the variant and action.
*/
private getPriority (type: ExtendedToastProps['variant'], hasAction: boolean): number {
const key = `${type}${hasAction ? '-actionable' : ''}`;
return PRIORITY_ORDER[key];
}

/**
* Handles the dismissal of the current toast and displays the next one in the queue (if any).
*/
private _dismissToast () {
this._currentToast?.onPieToastClose?.();
this._currentToast = null;
requestAnimationFrame(() => { this._showNextToast(); });
}

/**
* Displays the next toast in the queue, if available.
*/
private _showNextToast () {
if (this._toasts.length > 0) {
const [nextToast, ...remainingToasts] = this._toasts;
this._currentToast = nextToast;
this._toasts = remainingToasts;
} else {
this._currentToast = null;
}
}

/**
* Adds a new toast to the queue and triggers its display if no toast is currently active.
* @param {ToastProps} toast - The toast props to display.
*/
public createToast (toast: ExtendedToastProps) {
const newToast = { ...this.options, ...toast };

this._toasts = [...this._toasts, newToast].sort((a, b) => {
const priorityB = this.getPriority(b.variant, !!b.leadingAction?.text);
const priorityA = this.getPriority(a.variant, !!a.leadingAction?.text);

return priorityA - priorityB;
});

if (!this._currentToast) {
this._showNextToast();
}
}

/**
*
* Clears all toasts from the queue and dismisses the currently visible toast.
*/
public clearToasts () {
this._toasts = [];
this._currentToast = null;
}

render () {
return html`<h1 data-test-id="pie-toast-provider">Hello world!</h1>`;
const { _currentToast, _dismissToast } = this;

return html`
<div class="c-toast-provider" data-test-id="pie-toast-provider">
${_currentToast
? html`
<pie-toast
message="${_currentToast.message}"
variant="${ifDefined(_currentToast.variant)}"
?isStrong="${_currentToast.isStrong}"
?isDismissible="${_currentToast.isDismissible}"
?isMultiline="${_currentToast.isMultiline}"
.leadingAction="${_currentToast.leadingAction}"
.duration="${typeof _currentToast.duration === 'undefined' ? nothing : _currentToast.duration}"
@pie-toast-close="${_dismissToast}"
@pie-toast-open="${_currentToast.onPieToastOpen}"
@pie-toast-leading-action-click="${_currentToast.onPieToastLeadingActionClick}">
</pie-toast>
`
: nothing}
</div>
raoufswe marked this conversation as resolved.
Show resolved Hide resolved
`;
}

// Renders a `CSSResult` generated from SCSS by Vite
Expand All @@ -28,3 +152,4 @@ declare global {
[componentSelector]: PieToastProvider;
}
}

14 changes: 14 additions & 0 deletions packages/components/pie-toast-provider/src/toast-provider.scss
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
@use '@justeattakeaway/pie-css/scss' as p;
@use '@justeattakeaway/pie-css/scss/settings' as *;


.c-toast-provider {
--toast-provider-offset: var(--dt-spacing-d);

position: absolute;
inset-inline-start: var(--toast-provider-offset);
inset-block-end: var(--toast-provider-offset);

@include media('>md') {
--toast-offset: var(--dt-spacing-e);
}
}
Loading
Loading