Skip to content

Commit 0264719

Browse files
committed
feat(NotificationList): allow to change the queue retrieval policy
1 parent 16072bd commit 0264719

2 files changed

Lines changed: 67 additions & 54 deletions

File tree

src/components/Notifications/NotificationList.tsx

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,25 @@ export type NotificationListVerticalAlignment = 'bottom' | 'top';
1919
* different notification triggers a swap (immediate or after `minDisplayMs`, see the
2020
* scheduling effect); returning `null` triggers the empty-slot exit.
2121
*
22-
* Default: `defaultPickNext` — applies persistent-wins, same-type-refresh and FIFO rules
23-
* from the design spec. Pass your own selector to opt out of any of those policies.
24-
* The library also exports `pickOldest` (FIFO) and `pickNewest` (LIFO) as low-level
25-
* building blocks you can compose.
22+
* Default: `createDefaultPickNext(pickOldest)` — applies persistent-wins,
23+
* same-type-refresh and FIFO queue rules from the design spec. Pass
24+
* `pickNext={createDefaultPickNext(pickNewest)}` to swap FIFO for LIFO without
25+
* reimplementing those rules, or your own `pickNext` to opt out entirely.
2626
*/
2727
export type PickNextNotification = (
2828
notifications: Notification[],
2929
displayed: Notification | null,
3030
) => Notification | null;
3131

32+
/**
33+
* Picks the next queued notification when no higher-priority rule applies (persistent
34+
* wins, same-type refresh). Used by `createDefaultPickNext`.
35+
*/
36+
export type PickFromQueue = (
37+
notifications: Notification[],
38+
displayed: Notification | null,
39+
) => Notification | null;
40+
3241
export type NotificationListProps = {
3342
/** Optional class name for the list container */
3443
className?: string;
@@ -53,8 +62,7 @@ export type NotificationListProps = {
5362
/**
5463
* Override the candidate-selection policy. The function decides which notification
5564
* should be displayed given the store and the currently displayed one. Defaults to
56-
* `defaultPickNext`, which applies the persistent-wins / same-type-refresh / FIFO rules
57-
* documented on `PickNextNotification`.
65+
* `createDefaultPickNext(pickOldest)`. Pass `createDefaultPickNext(pickNewest)` for LIFO.
5866
*/
5967
pickNext?: PickNextNotification;
6068
/** Panel target consumed by this list. */
@@ -101,8 +109,8 @@ const isPersistent = (notification: Notification) => !notification.duration;
101109
const haveSameType = (a: Notification | null, b: Notification | null) =>
102110
!!a?.type && !!b?.type && a.type === b.type;
103111

104-
/** FIFO selector — oldest `createdAt` other than `displayed` wins. */
105-
export const pickOldest: PickNextNotification = (notifications, displayed) => {
112+
/** FIFO queue selector — oldest `createdAt` other than `displayed` wins. */
113+
export const pickOldest: PickFromQueue = (notifications, displayed) => {
106114
const excludeId = displayed?.id ?? null;
107115
let oldest: Notification | null = null;
108116
for (const notification of notifications) {
@@ -114,8 +122,8 @@ export const pickOldest: PickNextNotification = (notifications, displayed) => {
114122
return oldest;
115123
};
116124

117-
/** LIFO selector — newest `createdAt` other than `displayed` wins. */
118-
export const pickNewest: PickNextNotification = (notifications, displayed) => {
125+
/** LIFO queue selector — newest `createdAt` other than `displayed` wins. */
126+
export const pickNewest: PickFromQueue = (notifications, displayed) => {
119127
const excludeId = displayed?.id ?? null;
120128
let newest: Notification | null = null;
121129
for (const notification of notifications) {
@@ -160,9 +168,9 @@ const pickNewestOfType = (
160168
};
161169

162170
/**
163-
* Default `PickNextNotification` selector. Encodes the snackbar concurrency rules from
164-
* the design spec — the scheduling effect only decides *when* to swap, this function
165-
* decides *what* to swap to.
171+
* Builds the default `PickNextNotification` selector with a configurable queue fallback.
172+
* Encodes the snackbar concurrency rules from the design spec — the scheduling effect
173+
* only decides *when* to swap, the returned function decides *what* to swap to.
166174
*
167175
* Rules, in order of precedence:
168176
* 1. **Persistent wins.** Persistent variants (no `duration`) jump ahead of any
@@ -173,52 +181,56 @@ const pickNewestOfType = (
173181
* same `type` collapses to its latest occurrence. (The scheduling effect detects
174182
* this via `haveSameType` and bypasses the dwell window so the refresh feels
175183
* instant; this function just makes sure the latest same-type is returned.)
176-
* 4. **FIFO fallback.** Otherwise, the oldest queued notification (other than the
177-
* displayed one) is shown next, so every notification is guaranteed at least
178-
* `minDisplayMs` of visibility — nothing is silently dropped.
184+
* 4. **Queue fallback.** Otherwise, `pickFromQueue` selects the next notification
185+
* (default: `pickOldest` / FIFO).
179186
*
180187
* Exported so callers that want to layer behavior on top of the design rules can wrap
181-
* this function instead of rewriting it.
188+
* the result instead of rewriting it.
182189
*/
183-
export const defaultPickNext: PickNextNotification = (notifications, displayed) => {
184-
if (notifications.length === 0) return null;
190+
export const createDefaultPickNext =
191+
(pickFromQueue: PickFromQueue = pickOldest): PickNextNotification =>
192+
(notifications, displayed) => {
193+
if (notifications.length === 0) return null;
185194

186-
const newestPersistent = pickNewestPersistent(notifications, null);
195+
const newestPersistent = pickNewestPersistent(notifications, null);
187196

188-
if (!displayed) {
189-
return newestPersistent ?? pickOldest(notifications, null);
190-
}
197+
if (!displayed) {
198+
return newestPersistent ?? pickFromQueue(notifications, null);
199+
}
191200

192-
const displayedInStore = notifications.some(({ id }) => id === displayed.id);
193-
if (!displayedInStore) {
194-
return newestPersistent ?? pickOldest(notifications, null);
195-
}
201+
const displayedInStore = notifications.some(({ id }) => id === displayed.id);
202+
if (!displayedInStore) {
203+
return newestPersistent ?? pickFromQueue(notifications, null);
204+
}
196205

197-
if (isPersistent(displayed)) {
198-
// Currently showing a persistent: only a newer persistent can replace it. Reuse the
199-
// earlier lookup when it already points at a different persistent.
200-
const newerPersistent =
201-
newestPersistent && newestPersistent.id !== displayed.id
202-
? newestPersistent
203-
: pickNewestPersistent(notifications, displayed.id);
204-
if (newerPersistent && newerPersistent.createdAt > displayed.createdAt) {
205-
return newerPersistent;
206+
if (isPersistent(displayed)) {
207+
// Currently showing a persistent: only a newer persistent can replace it. Reuse the
208+
// earlier lookup when it already points at a different persistent.
209+
const newerPersistent =
210+
newestPersistent && newestPersistent.id !== displayed.id
211+
? newestPersistent
212+
: pickNewestPersistent(notifications, displayed.id);
213+
if (newerPersistent && newerPersistent.createdAt > displayed.createdAt) {
214+
return newerPersistent;
215+
}
216+
return displayed;
206217
}
207-
return displayed;
208-
}
209218

210-
// Currently showing a transient.
211-
// 1. Same-type "refresh of current" wins immediately — a repeated trigger of the
212-
// displayed snackbar collapses to the latest occurrence of that type.
213-
const sameTypeNewest = pickNewestOfType(notifications, displayed.type, displayed.id);
214-
if (sameTypeNewest) return sameTypeNewest;
219+
// Currently showing a transient.
220+
// 1. Same-type "refresh of current" wins immediately — a repeated trigger of the
221+
// displayed snackbar collapses to the latest occurrence of that type.
222+
const sameTypeNewest = pickNewestOfType(notifications, displayed.type, displayed.id);
223+
if (sameTypeNewest) return sameTypeNewest;
215224

216-
// 2. Any queued persistent jumps ahead of the queue.
217-
if (newestPersistent) return newestPersistent;
225+
// 2. Any queued persistent jumps ahead of the queue.
226+
if (newestPersistent) return newestPersistent;
218227

219-
// 3. FIFO fallback.
220-
return pickOldest(notifications, displayed) ?? displayed;
221-
};
228+
// 3. Queue fallback.
229+
return pickFromQueue(notifications, displayed) ?? displayed;
230+
};
231+
232+
/** Default selector — design-spec rules with FIFO queue fallback (`pickOldest`). */
233+
const defaultPickNext = createDefaultPickNext(pickOldest);
222234

223235
export const NotificationList = ({
224236
className,
@@ -316,7 +328,7 @@ export const NotificationList = ({
316328
}, [displayedNotification, notifications, pickNext]);
317329

318330
// Main scheduling effect — owns *when* swaps happen. The *what* is delegated entirely
319-
// to `pickNext` (default: `defaultPickNext`).
331+
// to `pickNext` (default: `createDefaultPickNext(pickOldest)`).
320332
//
321333
// Runs on every change to `displayedNotification`, `notifications`, `pickNext`, or
322334
// `transitionState`, and decides one of four outcomes:

src/components/Notifications/__tests__/NotificationList.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { act, fireEvent, render, screen } from '@testing-library/react';
33

4-
import { NotificationList, pickNewest } from '../NotificationList';
4+
import { createDefaultPickNext, NotificationList, pickNewest } from '../NotificationList';
55
import { useNotificationApi } from '../hooks/useNotificationApi';
66
import { useNotifications } from '../hooks/useNotifications';
77
import { ComponentProvider } from '../../../context/ComponentContext';
@@ -183,15 +183,16 @@ describe('NotificationList', () => {
183183
expect(screen.queryByTestId('notification-n-2')).not.toBeInTheDocument();
184184
});
185185

186-
it('uses a custom pickNext to override the default FIFO order', () => {
186+
it('uses createDefaultPickNext to switch the default selector to LIFO', () => {
187187
currentNotifications = [
188188
transientFixture({ createdAt: 1, id: 'n-1', message: 'First' }),
189189
transientFixture({ createdAt: 2, id: 'n-2', message: 'Second' }),
190190
transientFixture({ createdAt: 3, id: 'n-3', message: 'Third' }),
191191
];
192192

193+
const pickNextLifo = createDefaultPickNext(pickNewest);
193194
const { rerender } = render(
194-
<NotificationList minDisplayMs={500} pickNext={pickNewest} />,
195+
<NotificationList minDisplayMs={500} pickNext={pickNextLifo} />,
195196
);
196197

197198
// With LIFO ordering the newest queued notification is shown first.
@@ -200,11 +201,11 @@ describe('NotificationList', () => {
200201
act(() => {
201202
vi.advanceTimersByTime(500);
202203
});
203-
rerender(<NotificationList minDisplayMs={500} pickNext={pickNewest} />);
204+
rerender(<NotificationList minDisplayMs={500} pickNext={pickNextLifo} />);
204205
act(() => {
205206
vi.advanceTimersByTime(EXIT_ANIMATION_MS);
206207
});
207-
rerender(<NotificationList minDisplayMs={500} pickNext={pickNewest} />);
208+
rerender(<NotificationList minDisplayMs={500} pickNext={pickNextLifo} />);
208209

209210
// After dwell + exit, the next-newest replaces it.
210211
expect(remove).toHaveBeenCalledWith('n-3');

0 commit comments

Comments
 (0)