@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
355 lines (349 loc) • 10.9 kB
JavaScript
import { ReactStore, createSelector, createSelectorMemoized } from '@base-ui/utils/store';
import { generateId } from '@base-ui/utils/generateId';
import { ownerDocument } from '@base-ui/utils/owner';
import { Timeout } from '@base-ui/utils/useTimeout';
import { resolvePromiseOptions } from "./utils/resolvePromiseOptions.js";
import { activeElement, contains, getTarget } from "../floating-ui-react/utils.js";
import { isFocusVisible } from "./utils/focusVisible.js";
const toastMapSelector = createSelectorMemoized(state => state.toasts, toasts => {
const map = new Map();
let visibleIndex = 0;
let offsetY = 0;
toasts.forEach((toast, toastIndex) => {
const isEnding = toast.transitionStatus === 'ending';
map.set(toast.id, {
value: toast,
domIndex: toastIndex,
visibleIndex: isEnding ? -1 : visibleIndex,
offsetY
});
offsetY += toast.height || 0;
if (!isEnding) {
visibleIndex += 1;
}
});
return map;
});
export const selectors = {
toasts: createSelector(state => state.toasts),
isEmpty: createSelector(state => state.toasts.length === 0),
toast: createSelector(toastMapSelector, (toastMap, id) => toastMap.get(id)?.value),
toastIndex: createSelector(toastMapSelector, (toastMap, id) => toastMap.get(id)?.domIndex ?? -1),
toastOffsetY: createSelector(toastMapSelector, (toastMap, id) => toastMap.get(id)?.offsetY ?? 0),
toastVisibleIndex: createSelector(toastMapSelector, (toastMap, id) => toastMap.get(id)?.visibleIndex ?? -1),
hovering: createSelector(state => state.hovering),
focused: createSelector(state => state.focused),
expanded: createSelector(state => state.hovering || state.focused),
expandedOrOutOfFocus: createSelector(state => state.hovering || state.focused || !state.isWindowFocused),
prevFocusElement: createSelector(state => state.prevFocusElement)
};
export class ToastStore extends ReactStore {
timers = new Map();
areTimersPaused = false;
constructor(initialState) {
super(initialState, {}, selectors);
}
setFocused(focused) {
this.set('focused', focused);
}
setHovering(hovering) {
this.set('hovering', hovering);
}
setIsWindowFocused(isWindowFocused) {
this.set('isWindowFocused', isWindowFocused);
}
setPrevFocusElement(prevFocusElement) {
this.set('prevFocusElement', prevFocusElement);
}
setViewport = viewport => {
this.set('viewport', viewport);
};
disposeEffect = () => {
return () => {
this.timers.forEach(timer => {
timer.timeout?.clear();
});
this.timers.clear();
};
};
removeToast(toastId) {
const index = selectors.toastIndex(this.state, toastId);
if (index === -1) {
return;
}
const toast = this.state.toasts[index];
toast?.onRemove?.();
const newToasts = [...this.state.toasts];
newToasts.splice(index, 1);
this.setToasts(newToasts);
}
addToast = toast => {
const {
toasts,
timeout,
limit
} = this.state;
const id = toast.id || generateId('toast');
const toastToAdd = {
...toast,
id,
transitionStatus: 'starting'
};
const updatedToasts = [toastToAdd, ...toasts];
const activeToasts = updatedToasts.filter(t => t.transitionStatus !== 'ending');
// Mark oldest toasts for removal when over limit
if (activeToasts.length > limit) {
const excessCount = activeToasts.length - limit;
const oldestActiveToasts = activeToasts.slice(-excessCount);
const limitedIds = new Set(oldestActiveToasts.map(t => t.id));
this.setToasts(updatedToasts.map(t => {
const limited = limitedIds.has(t.id);
if (t.limited !== limited) {
return {
...t,
limited
};
}
return t;
}));
} else {
this.setToasts(updatedToasts.map(t => t.limited ? {
...t,
limited: false
} : t));
}
const duration = toastToAdd.timeout ?? timeout;
if (toastToAdd.type !== 'loading' && duration > 0) {
this.scheduleTimer(id, duration, () => this.closeToast(id));
}
if (selectors.expandedOrOutOfFocus(this.state)) {
this.pauseTimers();
}
return id;
};
updateToast = (id, updates) => {
this.updateToastInternal(id, updates);
};
updateToastInternal = (id, updates) => {
const {
timeout,
toasts
} = this.state;
const prevToast = selectors.toast(this.state, id) ?? null;
if (!prevToast) {
return;
}
// Ignore updates for toasts that are already closing.
// This prevents races where async updates (e.g. promise success/error)
// can block a dismissal from completing.
if (prevToast.transitionStatus === 'ending') {
return;
}
const nextToast = {
...prevToast,
...updates
};
this.setToasts(toasts.map(toast => toast.id === id ? {
...toast,
...updates
} : toast));
const nextTimeout = nextToast.timeout ?? timeout;
const prevTimeout = prevToast?.timeout ?? timeout;
const timeoutUpdated = Object.hasOwn(updates, 'timeout');
const shouldHaveTimer = nextToast.transitionStatus !== 'ending' && nextToast.type !== 'loading' && nextTimeout > 0;
const hasTimer = this.timers.has(id);
const timeoutChanged = prevTimeout !== nextTimeout;
const wasLoading = prevToast?.type === 'loading';
if (!shouldHaveTimer && hasTimer) {
const timer = this.timers.get(id);
timer?.timeout?.clear();
this.timers.delete(id);
return;
}
// Schedule or reschedule timer if needed
if (shouldHaveTimer && (!hasTimer || timeoutChanged || timeoutUpdated || wasLoading)) {
const timer = this.timers.get(id);
if (timer) {
timer.timeout?.clear();
this.timers.delete(id);
}
this.scheduleTimer(id, nextTimeout, () => this.closeToast(id));
if (selectors.expandedOrOutOfFocus(this.state)) {
this.pauseTimers();
}
}
};
closeToast = toastId => {
const toast = selectors.toast(this.state, toastId);
toast?.onClose?.();
const {
limit,
toasts
} = this.state;
let activeIndex = 0;
const newToasts = toasts.map(item => {
if (item.id === toastId) {
return {
...item,
transitionStatus: 'ending',
height: 0
};
}
if (item.transitionStatus === 'ending') {
return item;
}
const isLimited = activeIndex >= limit;
activeIndex += 1;
return item.limited !== isLimited ? {
...item,
limited: isLimited
} : item;
});
const timer = this.timers.get(toastId);
if (timer && timer.timeout) {
timer.timeout.clear();
this.timers.delete(toastId);
}
this.handleFocusManagement(toastId);
this.setToasts(newToasts);
};
promiseToast = (promiseValue, options) => {
// Create a loading toast (which does not auto-dismiss).
const loadingOptions = resolvePromiseOptions(options.loading);
const id = this.addToast({
...loadingOptions,
type: 'loading'
});
const handledPromise = promiseValue.then(result => {
const successOptions = resolvePromiseOptions(options.success, result);
this.updateToast(id, {
...successOptions,
type: 'success',
timeout: successOptions.timeout
});
return result;
}).catch(error => {
const errorOptions = resolvePromiseOptions(options.error, error);
this.updateToast(id, {
...errorOptions,
type: 'error',
timeout: errorOptions.timeout
});
return Promise.reject(error);
});
// Private API used exclusively by `Manager` to handoff the promise
// back to the manager after it's handled here.
if ({}.hasOwnProperty.call(options, 'setPromise')) {
options.setPromise(handledPromise);
}
return handledPromise;
};
pauseTimers() {
if (this.areTimersPaused) {
return;
}
this.areTimersPaused = true;
this.timers.forEach(timer => {
if (timer.timeout) {
timer.timeout.clear();
const elapsed = Date.now() - timer.start;
const remaining = timer.delay - elapsed;
timer.remaining = remaining > 0 ? remaining : 0;
}
});
}
resumeTimers() {
if (!this.areTimersPaused) {
return;
}
this.areTimersPaused = false;
this.timers.forEach((timer, id) => {
timer.remaining = timer.remaining > 0 ? timer.remaining : timer.delay;
timer.timeout ??= Timeout.create();
timer.timeout.start(timer.remaining, () => {
this.timers.delete(id);
timer.callback();
});
timer.start = Date.now();
});
}
restoreFocusToPrevElement() {
this.state.prevFocusElement?.focus({
preventScroll: true
});
}
handleDocumentPointerDown = event => {
if (event.pointerType !== 'touch') {
return;
}
const target = getTarget(event);
if (contains(this.state.viewport, target)) {
return;
}
this.resumeTimers();
this.update({
hovering: false,
focused: false
});
};
scheduleTimer(id, delay, callback) {
const start = Date.now();
const shouldStartActive = !selectors.expandedOrOutOfFocus(this.state);
const currentTimeout = shouldStartActive ? Timeout.create() : undefined;
currentTimeout?.start(delay, () => {
this.timers.delete(id);
callback();
});
this.timers.set(id, {
timeout: currentTimeout,
start: shouldStartActive ? start : 0,
delay,
remaining: delay,
callback
});
}
setToasts(newToasts) {
const updates = {
toasts: newToasts
};
if (newToasts.length === 0) {
updates.hovering = false;
updates.focused = false;
}
this.update(updates);
}
handleFocusManagement(toastId) {
const activeEl = activeElement(ownerDocument(this.state.viewport));
if (!this.state.viewport || !contains(this.state.viewport, activeEl) || !isFocusVisible(activeEl)) {
return;
}
const toasts = selectors.toasts(this.state);
const currentIndex = selectors.toastIndex(this.state, toastId);
let nextToast = null;
// Try to find the next toast that isn't animating out
let index = currentIndex + 1;
while (index < toasts.length) {
if (toasts[index].transitionStatus !== 'ending') {
nextToast = toasts[index];
break;
}
index += 1;
}
// Go backwards if no next toast is found
if (!nextToast) {
index = currentIndex - 1;
while (index >= 0) {
if (toasts[index].transitionStatus !== 'ending') {
nextToast = toasts[index];
break;
}
index -= 1;
}
}
if (nextToast) {
nextToast.ref?.current?.focus();
} else {
this.restoreFocusToPrevElement();
}
}
}