diff --git a/change/@fluentui-react-utilities-22f9b966-d085-4744-a402-a333796c653f.json b/change/@fluentui-react-utilities-22f9b966-d085-4744-a402-a333796c653f.json new file mode 100644 index 0000000000000..8d9dec270c209 --- /dev/null +++ b/change/@fluentui-react-utilities-22f9b966-d085-4744-a402-a333796c653f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Implement a priority queue", + "packageName": "@fluentui/react-utilities", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-toast/src/state/types.ts b/packages/react-components/react-toast/src/state/types.ts index 849df3c37d10b..7966d6011303b 100644 --- a/packages/react-components/react-toast/src/state/types.ts +++ b/packages/react-components/react-toast/src/state/types.ts @@ -13,12 +13,15 @@ export interface ToastOptions { pauseOnWindowBlur: boolean; pauseOnHover: boolean; toasterId: ToasterId | undefined; + priority: number; + dispatchedAt: number; } export interface ToasterOptions - extends Pick { + extends Pick { offset?: number[]; toasterId?: ToasterId; + limit?: number; } export interface Toast extends ToastOptions { @@ -31,11 +34,11 @@ export interface CommonToastDetail { toasterId?: ToasterId; } -export interface ShowToastEventDetail extends Partial, CommonToastDetail { +export interface ShowToastEventDetail extends Partial>, CommonToastDetail { toastId: ToastId; } -export interface UpdateToastEventDetail extends Partial, CommonToastDetail { +export interface UpdateToastEventDetail extends Partial>, CommonToastDetail { toastId: ToastId; } diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index 577f0b7193f5a..a78b25aaf5e8f 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -5,20 +5,19 @@ import { Toast, ToastPosition, ToasterOptions } from './types'; import { ToasterProps } from '../components/Toaster'; export function useToaster(options: ToasterProps = {}) { - const { toasterId, ...rest } = options; const forceRender = useForceUpdate(); - const defaultToastOptions = useToastOptions(rest); + const toasterOptions = useToasterOptions(options); const toasterRef = React.useRef(null); - const [toaster] = React.useState(() => new Toaster(toasterId)); + const [toaster] = React.useState(() => new Toaster()); React.useEffect(() => { if (toasterRef.current) { - toaster.connectToDOM(toasterRef.current, defaultToastOptions); + toaster.connectToDOM(toasterRef.current, toasterOptions); toaster.onUpdate = forceRender; } return () => toaster.disconnect(); - }, [toaster, defaultToastOptions, forceRender]); + }, [toaster, forceRender, toasterOptions]); const getToastsToRender = React.useCallback( (cb: (position: ToastPosition, toasts: Toast[]) => T) => { @@ -53,8 +52,8 @@ export function useToaster(options: ToasterProps = }; } -function useToastOptions(options: ToasterProps): Partial { - const { pauseOnHover, pauseOnWindowBlur, position, timeout } = options; +function useToasterOptions(options: ToasterProps): Partial { + const { pauseOnHover, pauseOnWindowBlur, position, timeout, limit, toasterId } = options; return React.useMemo>( () => ({ @@ -62,7 +61,9 @@ function useToastOptions(options: ToasterProps): Partial { pauseOnWindowBlur, position, timeout, + limit, + toasterId, }), - [pauseOnHover, pauseOnWindowBlur, position, timeout], + [pauseOnHover, pauseOnWindowBlur, position, timeout, limit, toasterId], ); } diff --git a/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts index a4a95f6c60e4a..7fefb15daf1d3 100644 --- a/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts +++ b/packages/react-components/react-toast/src/state/vanilla/dispatchToast.ts @@ -3,7 +3,11 @@ import { EVENTS } from '../constants'; let counter = 0; -export function dispatchToast(content: unknown, options: Partial = {}, targetDocument: Document) { +export function dispatchToast( + content: unknown, + options: Partial> = {}, + targetDocument: Document, +) { const detail: ShowToastEventDetail = { ...options, content, diff --git a/packages/react-components/react-toast/src/state/vanilla/toaster.ts b/packages/react-components/react-toast/src/state/vanilla/toaster.ts index 4379b1d2addf0..cf118cf260b9d 100644 --- a/packages/react-components/react-toast/src/state/vanilla/toaster.ts +++ b/packages/react-components/react-toast/src/state/vanilla/toaster.ts @@ -1,3 +1,5 @@ +// The underlying implementation of priority queue is vanilla js +import { createPriorityQueue, PriorityQueue } from '@fluentui/react-utilities'; import { Toast, ToasterOptions, @@ -5,9 +7,9 @@ import { ToastOptions, ToastListenerMap, UpdateToastEventDetail, - ToasterId, ShowToastEventDetail, CommonToastDetail, + ToasterId, } from '../types'; import { EVENTS } from '../constants'; @@ -27,26 +29,37 @@ export class Toaster { public toasts: Map; public onUpdate: () => void; private toasterElement?: HTMLElement; - private toasterOptions: ToasterOptions; - private toasterId?: ToastId; + private toastOptions: Pick; + private toasterId?: ToasterId; + private queue: PriorityQueue; + private limit: number; private listeners = new Map(); - constructor(toasterId?: ToasterId) { - this.toasterId = toasterId; + constructor() { this.toasts = new Map(); this.visibleToasts = new Set(); this.onUpdate = () => null; - this.toasterOptions = { + this.toastOptions = { + priority: 0, pauseOnHover: false, pauseOnWindowBlur: false, position: 'bottom-right', timeout: 3000, }; + this.queue = createPriorityQueue((a, b) => { + if (a.priority === b.priority) { + return a.dispatchedAt - b.dispatchedAt; + } + + return a.priority - b.priority; + }); + this.limit = Number.POSITIVE_INFINITY; } public disconnect() { this.toasts.clear(); + this.queue.clear(); for (const [event, callback] of this.listeners.entries()) { this._removeEventListener(event, callback); @@ -57,8 +70,11 @@ export class Toaster { } public connectToDOM(toasterElement: HTMLElement, options: Partial) { + const { limit = Number.POSITIVE_INFINITY, toasterId, ...rest } = options; this.toasterElement = toasterElement; - assignDefined(this.toasterOptions, options); + this.limit = limit; + this.toasterId = toasterId; + assignDefined(this.toastOptions, rest); const buildToast: ToastListenerMap[typeof EVENTS.show] = e => this._buildToast(e.detail); const updateToast: ToastListenerMap[typeof EVENTS.update] = e => this._updateToast(e.detail); @@ -124,6 +140,7 @@ export class Toaster { private _dismissAllToasts() { this.visibleToasts.clear(); + this.queue.clear(); this.onUpdate(); } @@ -141,23 +158,33 @@ export class Toaster { const remove = () => { this.toasts.delete(toastId); + if (this.queue.peek()) { + const nextToast = this.queue.dequeue(); + this.toasts.set(nextToast.toastId, nextToast); + this.visibleToasts.add(nextToast.toastId); + } this.onUpdate(); }; const toast: Toast = { - ...this.toasterOptions, + ...this.toastOptions, close, remove, toastId, content, updateId: 0, toasterId, + dispatchedAt: Date.now(), }; assignDefined(toast, toastOptions); - this.visibleToasts.add(toastId); - this.toasts.set(toastId, toast); - this.onUpdate(); + if (this.visibleToasts.size >= this.limit) { + this.queue.enqueue(toast); + } else { + this.toasts.set(toastId, toast); + this.visibleToasts.add(toastId); + this.onUpdate(); + } } } diff --git a/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx new file mode 100644 index 0000000000000..5aa10ed96a7f5 --- /dev/null +++ b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Toaster, useToastController } from '@fluentui/react-toast'; +import { useId } from '@fluentui/react-components'; + +export const ToasterLimit = () => { + const toasterId = useId('toaster'); + const { dispatchToast } = useToastController(); + const notify = () => dispatchToast('This is a toast', { toasterId }); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-toast/stories/Toast/index.stories.tsx b/packages/react-components/react-toast/stories/Toast/index.stories.tsx index 89c85d36333af..7e1b0cda0f868 100644 --- a/packages/react-components/react-toast/stories/Toast/index.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/index.stories.tsx @@ -8,6 +8,7 @@ export { PauseOnWindowBlur } from './PauseOnWindowBlur.stories'; export { PauseOnHover } from './PauseOnHover.stories'; export { UpdateToast } from './UpdateToast.stories'; export { MultipeToasters } from './MultipleToasters.stories'; +export { ToasterLimit } from './ToasterLimit.stories'; export default { title: 'Preview Components/Toast', diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md index a03ffd720af38..1c29e73b0cddc 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -28,6 +28,9 @@ export type ComponentState = { [Key in keyof Slots]: ReplaceNullWithUndefined>; }; +// @internal (undocumented) +export function createPriorityQueue(compare: PriorityQueueCompareFn): PriorityQueue; + // @public export type ExtractSlotProps = Exclude; @@ -113,6 +116,26 @@ export type NativeTouchOrMouseEvent = MouseEvent | TouchEvent; // @public export function omit, Exclusions extends (keyof TObj)[]>(obj: TObj, exclusions: Exclusions): Omit; +// @internal (undocumented) +export interface PriorityQueue { + // (undocumented) + all: () => T[]; + // (undocumented) + clear: () => void; + // (undocumented) + contains: (item: T) => boolean; + // (undocumented) + dequeue: () => T; + // (undocumented) + enqueue: (item: T) => void; + // (undocumented) + peek: () => T | null; + // (undocumented) + remove: (item: T) => void; + // (undocumented) + size: () => number; +} + // @public (undocumented) export type ReactTouchOrMouseEvent = React_2.MouseEvent | React_2.TouchEvent; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index c2d0023253e0b..a9a94d94460db 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -50,8 +50,11 @@ export { isHTMLElement, isInteractiveHTMLElement, omit, + createPriorityQueue, } from './utils/index'; +export type { PriorityQueue } from './utils/priorityQueue'; + export { applyTriggerPropsToChildren, getTriggerChild, isFluentTrigger } from './trigger/index'; export type { FluentTriggerComponent, TriggerProps } from './trigger/index'; diff --git a/packages/react-components/react-utilities/src/utils/index.ts b/packages/react-components/react-utilities/src/utils/index.ts index 2e80429d3cab2..c25cc8e7d4205 100644 --- a/packages/react-components/react-utilities/src/utils/index.ts +++ b/packages/react-components/react-utilities/src/utils/index.ts @@ -6,3 +6,4 @@ export * from './omit'; export * from './properties'; export * from './isHTMLElement'; export * from './isInteractiveHTMLElement'; +export * from './priorityQueue'; diff --git a/packages/react-components/react-utilities/src/utils/priorityQueue.test.ts b/packages/react-components/react-utilities/src/utils/priorityQueue.test.ts new file mode 100644 index 0000000000000..7da2cbc2a4125 --- /dev/null +++ b/packages/react-components/react-utilities/src/utils/priorityQueue.test.ts @@ -0,0 +1,90 @@ +import { createPriorityQueue } from './priorityQueue'; + +describe('Priority queue', () => { + it('can use comparator to order in increasing priority', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + const expected = []; + while (priorityQueue.size() > 0) { + expected.push(priorityQueue.dequeue()); + } + + expect(expected.length).toBe(values.length); + expect(expected).toEqual([1, 2, 3, 5, 7, 8, 11, 67]); + }); + + it('can use comparator to order in decreasing priority', () => { + const priorityQueue = createPriorityQueue((a, b) => b - a); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + const expected = []; + while (priorityQueue.size() > 0) { + expected.push(priorityQueue.dequeue()); + } + + expect(expected.length).toBe(values.length); + expect(expected).toEqual([1, 2, 3, 5, 7, 8, 11, 67].reverse()); + }); + + it('peek should return the same value as dequeue', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + expect(priorityQueue.peek()).toBe(1); + expect(priorityQueue.dequeue()).toBe(1); + }); + + it('clear should empty priority queue', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + priorityQueue.clear(); + expect(priorityQueue.size()).toBe(0); + }); + + it('dequeue should throw if the queue is empty', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + priorityQueue.clear(); + expect(priorityQueue.dequeue).toThrow('Priority queue empty'); + }); + + it('remove should delete item from the queue without affecting order', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + priorityQueue.remove(11); + + const expected = []; + while (priorityQueue.size() > 0) { + expected.push(priorityQueue.dequeue()); + } + + expect(expected.length).toBe(values.length - 1); + expect(expected).toEqual([1, 2, 3, 5, 7, 8, 67]); + }); + + it('contains should return true if element is in the queue', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + expect(priorityQueue.contains(1)).toBe(true); + }); + + it('contains should return true if element is not in the queue', () => { + const priorityQueue = createPriorityQueue((a, b) => a - b); + const values = [3, 8, 2, 5, 11, 67, 1, 7]; + values.forEach(value => priorityQueue.enqueue(value)); + + expect(priorityQueue.contains(99)).toBe(false); + }); +}); diff --git a/packages/react-components/react-utilities/src/utils/priorityQueue.ts b/packages/react-components/react-utilities/src/utils/priorityQueue.ts new file mode 100644 index 0000000000000..8a4092e318440 --- /dev/null +++ b/packages/react-components/react-utilities/src/utils/priorityQueue.ts @@ -0,0 +1,131 @@ +/** + * @internal + */ +export type PriorityQueueCompareFn = (a: T, b: T) => number; + +/** + * @internal + */ +export interface PriorityQueue { + all: () => T[]; + clear: () => void; + contains: (item: T) => boolean; + dequeue: () => T; + enqueue: (item: T) => void; + peek: () => T | null; + remove: (item: T) => void; + size: () => number; +} + +/** + * @internal + * @param compare - comparison function for items + * @returns Priority queue implemented with a min heap + */ +export function createPriorityQueue(compare: PriorityQueueCompareFn): PriorityQueue { + const arr: T[] = []; + let size = 0; + + const swap = (a: number, b: number) => { + const tmp = arr[a]; + arr[a] = arr[b]; + arr[b] = tmp; + }; + + const heapify = (i: number) => { + let smallest = i; + const l = left(i); + const r = right(i); + + if (l < size && compare(arr[l], arr[smallest]) < 0) { + smallest = l; + } + + if (r < size && compare(arr[r], arr[smallest]) < 0) { + smallest = r; + } + + if (smallest !== i) { + swap(smallest, i); + heapify(smallest); + } + }; + + const dequeue = () => { + if (size === 0) { + throw new Error('Priority queue empty'); + } + + const res = arr[0]; + arr[0] = arr[--size]; + heapify(0); + + return res; + }; + + const peek = () => { + if (size === 0) { + return null; + } + + return arr[0]; + }; + + const enqueue = (item: T) => { + arr[size++] = item; + let i = size - 1; + let p = parent(i); + while (i > 0 && compare(arr[p], arr[i]) > 0) { + swap(p, i); + i = p; + p = parent(i); + } + }; + + const contains = (item: T) => { + const index = arr.indexOf(item); + return index >= 0 && index < size; + }; + + const remove = (item: T) => { + const i = arr.indexOf(item); + + if (i === -1 || i >= size) { + return; + } + + arr[i] = arr[--size]; + heapify(i); + }; + + const clear = () => { + size = 0; + }; + + const all = () => { + return arr.slice(0, size); + }; + + return { + all, + clear, + contains, + dequeue, + enqueue, + peek, + remove, + size: () => size, + }; +} + +const left = (i: number) => { + return 2 * i + 1; +}; + +const right = (i: number) => { + return 2 * i + 2; +}; + +const parent = (i: number) => { + return Math.floor((i - 1) / 2); +};