@awsui/components-react
Version:
On July 19th, 2022, we launched [Cloudscape Design System](https://cloudscape.design). Cloudscape is an evolution of AWS-UI. It consists of user interface guidelines, front-end components, design resources, and development tools for building intuitive, en
218 lines • 16.3 kB
JavaScript
import { __rest } from "tslib";
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TransitionGroup } from 'react-transition-group';
import clsx from 'clsx';
import { findUpUntil } from '@awsui/component-toolkit/dom';
import { getAnalyticsMetadataAttribute } from '@awsui/component-toolkit/internal/analytics-metadata';
import { useInternalI18n } from '../i18n/context';
import InternalIcon from '../icon/internal';
import { animate, getDOMRects } from '../internal/animate';
import { Transition } from '../internal/components/transition';
import { getVisualContextClassname } from '../internal/components/visual-context';
import customCssProps from '../internal/generated/custom-css-properties';
import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update';
import { useUniqueId } from '../internal/hooks/use-unique-id';
import { scrollElementIntoView } from '../internal/utils/scrollable-containers';
import { throttle } from '../internal/utils/throttle';
import { getComponentsAnalyticsMetadata, getItemAnalyticsMetadata } from './analytics-metadata/utils';
import { useFlashbar } from './common';
import { Flash, focusFlashById } from './flash';
import { counterTypes, getFlashTypeCount, getItemColor, getVisibleCollapsedItems } from './utils';
import styles from './styles.css.js';
// If the number of items is equal or less than this value,
// the toggle element will not be displayed and the Flashbar will look like a regular single-item Flashbar.
const maxNonCollapsibleItems = 1;
const resizeListenerThrottleDelay = 100;
export default function CollapsibleFlashbar(_a) {
var { items } = _a, restProps = __rest(_a, ["items"]);
const [enteringItems, setEnteringItems] = useState([]);
const [exitingItems, setExitingItems] = useState([]);
const [isFlashbarStackExpanded, setIsFlashbarStackExpanded] = useState(false);
const getElementsToAnimate = useCallback(() => {
const flashElements = isFlashbarStackExpanded ? expandedItemRefs.current : collapsedItemRefs.current;
return Object.assign(Object.assign({}, flashElements), { notificationBar: notificationBarRef.current });
}, [isFlashbarStackExpanded]);
const prepareAnimations = useCallback(() => {
const rects = getDOMRects(getElementsToAnimate());
setInitialAnimationState(rects);
}, [getElementsToAnimate]);
const { baseProps, breakpoint, isReducedMotion, isVisualRefresh, mergedRef, ref } = useFlashbar(Object.assign(Object.assign({ items }, restProps), { onItemsAdded: newItems => {
setEnteringItems([...enteringItems, ...newItems]);
}, onItemsChanged: options => {
// If not all items have ID, we can still animate collapse/expand transitions
// because we can rely on each item's index in the original array,
// but we can't do that when elements are added or removed, since the index changes.
if ((options === null || options === void 0 ? void 0 : options.allItemsHaveId) && !(options === null || options === void 0 ? void 0 : options.isReducedMotion)) {
prepareAnimations();
}
}, onItemsRemoved: removedItems => {
setExitingItems([...exitingItems, ...removedItems]);
} }));
const collapsedItemRefs = useRef({});
const expandedItemRefs = useRef({});
const [initialAnimationState, setInitialAnimationState] = useState(null);
const listElementRef = useRef(null);
const notificationBarRef = useRef(null);
const [transitioning, setTransitioning] = useState(false);
const flashbarElementId = useUniqueId('flashbar');
const itemCountElementId = useUniqueId('item-count');
if (items.length <= maxNonCollapsibleItems && isFlashbarStackExpanded) {
setIsFlashbarStackExpanded(false);
}
const animateFlash = !isReducedMotion;
function toggleCollapseExpand() {
if (!isReducedMotion) {
prepareAnimations();
}
setIsFlashbarStackExpanded(prev => !prev);
}
useLayoutEffect(() => {
if (isFlashbarStackExpanded && (items === null || items === void 0 ? void 0 : items.length)) {
const mostRecentItem = items[0];
if (mostRecentItem.id !== undefined) {
focusFlashById(ref.current, mostRecentItem.id);
}
}
// Run this after expanding, but not every time the items change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFlashbarStackExpanded]);
// When collapsing, scroll up if necessary to avoid losing track of the focused button
useEffectOnUpdate(() => {
if (!isFlashbarStackExpanded && notificationBarRef.current) {
scrollElementIntoView(notificationBarRef.current);
}
}, [isFlashbarStackExpanded]);
const updateBottomSpacing = useMemo(() => throttle(() => {
// Allow vertical space between Flashbar and page bottom only when the Flashbar is reaching the end of the page,
// otherwise avoid spacing with eventual sticky elements below.
const listElement = listElementRef === null || listElementRef === void 0 ? void 0 : listElementRef.current;
const flashbar = listElement === null || listElement === void 0 ? void 0 : listElement.parentElement;
if (listElement && flashbar) {
// Make sure the bottom padding is present when we make the calculations,
// then we might decide to remove it or not.
flashbar.classList.remove(styles.floating);
const windowHeight = window.innerHeight;
// Take the parent region into account if using the App Layout, because it might have additional margins.
// Otherwise we use the Flashbar component for this calculation.
const outerElement = findUpUntil(flashbar, element => element.getAttribute('role') === 'region') || flashbar;
const applySpacing = isFlashbarStackExpanded && Math.ceil(outerElement.getBoundingClientRect().bottom) >= windowHeight;
if (!applySpacing) {
flashbar.classList.add(styles.floating);
}
}
}, resizeListenerThrottleDelay), [isFlashbarStackExpanded]);
useLayoutEffect(() => {
window.addEventListener('resize', updateBottomSpacing);
return () => {
window.removeEventListener('resize', updateBottomSpacing);
updateBottomSpacing.cancel();
};
}, [updateBottomSpacing]);
const { i18nStrings } = restProps;
const i18n = useInternalI18n('flashbar');
const ariaLabel = i18n('i18nStrings.ariaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.ariaLabel);
const notificationBarText = i18n('i18nStrings.notificationBarText', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.notificationBarText);
const notificationBarAriaLabel = i18n('i18nStrings.notificationBarAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.notificationBarAriaLabel);
const iconAriaLabels = {
errorIconAriaLabel: i18n('i18nStrings.errorIconAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.errorIconAriaLabel),
inProgressIconAriaLabel: i18n('i18nStrings.inProgressIconAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.inProgressIconAriaLabel),
infoIconAriaLabel: i18n('i18nStrings.infoIconAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.infoIconAriaLabel),
successIconAriaLabel: i18n('i18nStrings.successIconAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.successIconAriaLabel),
warningIconAriaLabel: i18n('i18nStrings.warningIconAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.warningIconAriaLabel),
};
useLayoutEffect(() => {
// When `useLayoutEffect` is called, the DOM is updated but has not been painted yet,
// so it's a good moment to trigger animations that will make calculations based on old and new DOM state.
// The old state is kept in `initialAnimationState`
// and the new state can be retrieved from the current DOM elements.
if (initialAnimationState) {
updateBottomSpacing();
animate({
elements: getElementsToAnimate(),
oldState: initialAnimationState,
newElementInitialState: ({ top }) => ({ scale: 0.9, y: -0.2 * top }),
onTransitionsEnd: () => setTransitioning(false),
});
setTransitioning(true);
setInitialAnimationState(null);
}
}, [updateBottomSpacing, getElementsToAnimate, initialAnimationState, isFlashbarStackExpanded]);
const isCollapsible = items.length > maxNonCollapsibleItems;
const countByType = getFlashTypeCount(items);
const numberOfColorsInStack = new Set(items.map(getItemColor)).size;
const maxSlots = Math.max(numberOfColorsInStack, 3);
const stackDepth = Math.min(maxSlots, items.length);
const itemsToShow = isFlashbarStackExpanded
? items.map((item, index) => (Object.assign(Object.assign({}, item), { expandedIndex: index })))
: getVisibleCollapsedItems(items, stackDepth).map((item, index) => (Object.assign(Object.assign({}, item), { collapsedIndex: index })));
const getItemId = (item) => { var _a, _b; return (_b = (_a = item.id) !== null && _a !== void 0 ? _a : item.expandedIndex) !== null && _b !== void 0 ? _b : 0; };
// This check allows us to use the standard "enter" Transition only when the notification was not existing before.
// If instead it was moved to the top of the stack but was already present in the array
// (e.g, after dismissing another notification),
// we need to use different, more custom and more controlled animations.
const hasEntered = (item) => enteringItems.some(_item => _item.id && _item.id === item.id);
const hasLeft = (item) => !('expandedIndex' in item);
const hasEnteredOrLeft = (item) => hasEntered(item) || hasLeft(item);
const showInnerContent = (item) => isFlashbarStackExpanded || hasLeft(item) || ('expandedIndex' in item && item.expandedIndex === 0);
const shouldUseStandardAnimation = (item, index) => index === 0 && hasEnteredOrLeft(item);
const getAnimationElementId = (item) => `flash-${getItemId(item)}`;
const renderList = () => (React.createElement("ul", { ref: listElementRef, className: clsx(styles['flash-list'], isFlashbarStackExpanded ? styles.expanded : styles.collapsed, transitioning && styles['animation-running'], initialAnimationState && styles['animation-ready'], isVisualRefresh && styles['visual-refresh']), id: flashbarElementId, "aria-label": ariaLabel, "aria-describedby": isCollapsible ? itemCountElementId : undefined, style: !isFlashbarStackExpanded || transitioning
? {
[customCssProps.flashbarStackDepth]: stackDepth,
}
: undefined },
React.createElement(ListWrapper, { withMotion: !isReducedMotion }, itemsToShow.map((item, index) => (React.createElement(Transition, { key: getItemId(item), in: !hasLeft(item), onStatusChange: status => {
if (status === 'entered') {
setEnteringItems([]);
}
else if (status === 'exited') {
setExitingItems([]);
}
} }, (state, transitionRootElement) => {
var _a, _b, _c;
return (React.createElement("li", Object.assign({ "aria-hidden": !showInnerContent(item), className: showInnerContent(item)
? clsx(styles['flash-list-item'], !isFlashbarStackExpanded && styles.item, !collapsedItemRefs.current[getAnimationElementId(item)] && styles['expanded-only'])
: clsx(styles.flash, styles[`flash-type-${(_a = item.type) !== null && _a !== void 0 ? _a : 'info'}`], styles.item), ref: element => {
if (isFlashbarStackExpanded) {
expandedItemRefs.current[getAnimationElementId(item)] = element;
}
else {
collapsedItemRefs.current[getAnimationElementId(item)] = element;
}
}, style: !isFlashbarStackExpanded || transitioning
? {
[customCssProps.flashbarStackIndex]: (_c = (_b = item.collapsedIndex) !== null && _b !== void 0 ? _b : item.expandedIndex) !== null && _c !== void 0 ? _c : index,
}
: undefined, key: getItemId(item) }, getAnalyticsMetadataAttribute(getItemAnalyticsMetadata(index + 1, item.type || 'info', item.id))), showInnerContent(item) && (React.createElement(Flash
// eslint-disable-next-line react/forbid-component-props
, Object.assign({
// eslint-disable-next-line react/forbid-component-props
className: clsx(animateFlash && styles['flash-with-motion'], isVisualRefresh && styles['flash-refresh']), key: getItemId(item), ref: shouldUseStandardAnimation(item, index) ? transitionRootElement : undefined, transitionState: shouldUseStandardAnimation(item, index) ? state : undefined, i18nStrings: iconAriaLabels }, item)))));
}))))));
return (React.createElement("div", Object.assign({}, baseProps, { className: clsx(baseProps.className, styles.flashbar, styles[`breakpoint-${breakpoint}`], styles.stack, isCollapsible && styles.collapsible, items.length === 2 && styles['short-list'], isFlashbarStackExpanded && styles.expanded, isVisualRefresh && styles['visual-refresh']), ref: mergedRef }, getAnalyticsMetadataAttribute(getComponentsAnalyticsMetadata(items.length, true, isFlashbarStackExpanded))),
isFlashbarStackExpanded && renderList(),
isCollapsible && (React.createElement("div", Object.assign({ className: clsx(styles['notification-bar'], isVisualRefresh && styles['visual-refresh'], isFlashbarStackExpanded ? styles.expanded : styles.collapsed, transitioning && styles['animation-running'], items.length === 2 && styles['short-list'], getVisualContextClassname('flashbar') // Visual context is needed for focus ring to be white
), onClick: toggleCollapseExpand, ref: notificationBarRef }, getAnalyticsMetadataAttribute({
action: 'expand',
detail: {
label: 'h2',
expanded: `${!isFlashbarStackExpanded}`,
},
})),
React.createElement("span", { "aria-live": "polite", className: styles.status, role: "status", id: itemCountElementId },
notificationBarText && React.createElement("h2", { className: styles.header }, notificationBarText),
React.createElement("span", { className: styles['item-count'] }, counterTypes.map(({ type, labelName, iconName }) => (React.createElement(NotificationTypeCount, { key: type, iconName: iconName, label: iconAriaLabels[labelName], count: countByType[type] }))))),
React.createElement("button", { "aria-controls": flashbarElementId, "aria-describedby": itemCountElementId, "aria-expanded": isFlashbarStackExpanded, "aria-label": notificationBarAriaLabel, className: clsx(styles.button, isFlashbarStackExpanded && styles.expanded) },
React.createElement(InternalIcon, { className: styles.icon, size: "normal", name: "angle-down" })))),
!isFlashbarStackExpanded && renderList()));
}
const NotificationTypeCount = ({ iconName, label, count, }) => {
return (React.createElement("span", { className: styles['type-count'] },
React.createElement("span", { title: label },
React.createElement(InternalIcon, { name: iconName, ariaLabel: label })),
React.createElement("span", { className: styles['count-number'] }, count)));
};
const ListWrapper = ({ children, withMotion }) => withMotion ? React.createElement(TransitionGroup, { component: null }, children) : React.createElement(React.Fragment, null, children);
//# sourceMappingURL=collapsible-flashbar.js.map