@box/blueprint-web 18.2.2 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/lib-esm/blueprint-configuration-context/consts.d.ts +2 -2
  2. package/dist/lib-esm/blueprint-configuration-context/consts.js +2 -2
  3. package/dist/lib-esm/components.css +452 -258
  4. package/dist/lib-esm/grid-list-item/types.d.ts +1 -1
  5. package/dist/lib-esm/index.css +451 -257
  6. package/dist/lib-esm/large-list-item/types.d.ts +1 -1
  7. package/dist/lib-esm/modal/constants.d.ts +1 -0
  8. package/dist/lib-esm/modal/constants.js +3 -1
  9. package/dist/lib-esm/modal/index.d.ts +1 -1
  10. package/dist/lib-esm/modal/modal-header-actions.d.ts +12 -0
  11. package/dist/lib-esm/modal/modal-header-actions.js +57 -0
  12. package/dist/lib-esm/modal/modal-header.d.ts +4 -4
  13. package/dist/lib-esm/modal/modal-header.js +10 -3
  14. package/dist/lib-esm/modal/modal.d.ts +4 -1
  15. package/dist/lib-esm/modal/modal.module.js +1 -1
  16. package/dist/lib-esm/modal/types.d.ts +12 -0
  17. package/dist/lib-esm/primitives/notification/hooks/use-hover-focus-expand.d.ts +17 -0
  18. package/dist/lib-esm/primitives/notification/hooks/use-hover-focus-expand.js +85 -0
  19. package/dist/lib-esm/primitives/notification/hooks/use-notification-exit-animation.d.ts +21 -0
  20. package/dist/lib-esm/primitives/notification/hooks/use-notification-exit-animation.js +141 -0
  21. package/dist/lib-esm/primitives/notification/hooks/use-stack-layer-metrics.d.ts +19 -0
  22. package/dist/lib-esm/primitives/notification/hooks/use-stack-layer-metrics.js +96 -0
  23. package/dist/lib-esm/primitives/notification/hooks/use-staggered-notification-admission.d.ts +10 -0
  24. package/dist/lib-esm/primitives/notification/hooks/use-staggered-notification-admission.js +126 -0
  25. package/dist/lib-esm/primitives/notification/load-animated-notification-viewport.d.ts +11 -0
  26. package/dist/lib-esm/primitives/notification/load-animated-notification-viewport.js +24 -0
  27. package/dist/lib-esm/primitives/notification/notification-provider.d.ts +8 -0
  28. package/dist/lib-esm/primitives/notification/notification-provider.js +29 -3
  29. package/dist/lib-esm/primitives/notification/notification-stack-utils.d.ts +21 -0
  30. package/dist/lib-esm/primitives/notification/notification-stack-utils.js +38 -0
  31. package/dist/lib-esm/primitives/notification/notification-stack.module.js +4 -0
  32. package/dist/lib-esm/primitives/notification/notification.module.js +1 -1
  33. package/dist/lib-esm/primitives/notification/notification.viewport-animated.d.ts +7 -0
  34. package/dist/lib-esm/primitives/notification/notification.viewport-animated.js +147 -0
  35. package/dist/lib-esm/primitives/notification/notification.viewport.d.ts +5 -0
  36. package/dist/lib-esm/primitives/notification/notification.viewport.js +51 -1
  37. package/dist/lib-esm/primitives/notification/private-notification-element-animated.d.ts +32 -0
  38. package/dist/lib-esm/primitives/notification/private-notification-element-animated.js +198 -0
  39. package/dist/lib-esm/primitives/notification/private-notification-element.d.ts +3 -0
  40. package/dist/lib-esm/primitives/notification/private-notification-element.js +3 -0
  41. package/dist/lib-esm/small-list-item/types.d.ts +1 -1
  42. package/dist/lib-esm/types/selection-visibility.d.ts +4 -0
  43. package/dist/lib-esm/util-components/base-grid-list-item/base-grid-list-item-actions.js +4 -2
  44. package/dist/lib-esm/util-components/base-grid-list-item/base-grid-list-item.module.js +1 -1
  45. package/dist/lib-esm/util-components/base-grid-list-item/base-grid-list.js +3 -1
  46. package/dist/lib-esm/util-components/base-grid-list-item/types.d.ts +9 -0
  47. package/package.json +3 -3
@@ -5,7 +5,7 @@ export type LargeListSubtitleProps = BaseGridListSubtitleProps;
5
5
  export type LargeListDescriptionProps = BaseGridListDescriptionProps;
6
6
  export type LargeListActionsProps = BaseGridListActionsProps;
7
7
  export type LargeListActionIconButtonProps = BaseGridListActionIconButtonProps;
8
- export type LargeListProps = Omit<BaseGridListProps, 'layoutStyle'>;
8
+ export type LargeListProps = Omit<BaseGridListProps, 'layoutStyle' | 'selectionVisibility'>;
9
9
  export type LargeListItemProps = BaseGridListItemProps;
10
10
  export interface LargeListSnippetProps {
11
11
  /** Regular expression pattern to be highlighted in snippetText */
@@ -1 +1,2 @@
1
1
  export declare const MODAL_LOADING_ANIMATION_DURATION = 200;
2
+ export declare const MODAL_SMALL_VIEWPORT_MAX_WIDTH = 459;
@@ -1,3 +1,5 @@
1
1
  const MODAL_LOADING_ANIMATION_DURATION = 200; // 200ms
2
+ // Mirrors the breakpoint at which Modal.Close switches to the small-utility variant
3
+ const MODAL_SMALL_VIEWPORT_MAX_WIDTH = 459;
2
4
 
3
- export { MODAL_LOADING_ANIMATION_DURATION };
5
+ export { MODAL_LOADING_ANIMATION_DURATION, MODAL_SMALL_VIEWPORT_MAX_WIDTH };
@@ -1,4 +1,4 @@
1
1
  export { AlertModal } from './alert-modal';
2
2
  export type { AlertModalProps } from './alert-modal.types';
3
3
  export { Modal } from './modal';
4
- export type { ModalCloseProps, ModalContentProps, ModalHeaderProps, ModalHeaderTitleAddonRenderProps, ModalLoadingProps, ModalProps, ModalTriggerProps, } from './types';
4
+ export type { ModalCloseProps, ModalContentProps, ModalHeaderActionProps, ModalHeaderActionsProps, ModalHeaderProps, ModalHeaderTitleAddonRenderProps, ModalLoadingProps, ModalProps, ModalTriggerProps, } from './types';
@@ -0,0 +1,12 @@
1
+ import { type ModalHeaderActionProps, type ModalHeaderActionsProps } from './types';
2
+ /**
3
+ * Container for actions pinned next to Modal.Close. They are out of the header flow, so the header reserves space
4
+ * for them next to the title, for up to 2 Modal.Header.Action elements.
5
+ * Only the first action is shown on small viewports, so the second one must not be essential.
6
+ */
7
+ export declare const ModalHeaderActions: ({ children }: ModalHeaderActionsProps) => import("react/jsx-runtime").JSX.Element;
8
+ /**
9
+ * Icon button rendered in Modal.Header.Actions. Like Modal.Close, it switches to the `small-utility` variant
10
+ * on small viewports. Can be wrapped in triggers using `asChild`, such as DropdownMenu.Trigger or Tooltip.
11
+ */
12
+ export declare const ModalHeaderAction: import("react").ForwardRefExoticComponent<Omit<ModalHeaderActionProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,57 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { forwardRef, useContext, createContext, Children, isValidElement } from 'react';
3
+ import { IconButton } from '../primitives/icon-button/icon-button.js';
4
+ import { useMedia } from '../utils/useMedia.js';
5
+ import { MODAL_SMALL_VIEWPORT_MAX_WIDTH } from './constants.js';
6
+ import styles from './modal.module.js';
7
+
8
+ const MAX_HEADER_ACTIONS = 2;
9
+ // Undefined outside of Modal.Header.Actions, to detect actions rendered without it
10
+ const IsSmallViewportContext = /*#__PURE__*/createContext(undefined);
11
+ /**
12
+ * Container for actions pinned next to Modal.Close. They are out of the header flow, so the header reserves space
13
+ * for them next to the title, for up to 2 Modal.Header.Action elements.
14
+ * Only the first action is shown on small viewports, so the second one must not be essential.
15
+ */
16
+ const ModalHeaderActions = ({
17
+ children
18
+ }) => {
19
+ const isSmallViewport = useMedia(`(max-width: ${MODAL_SMALL_VIEWPORT_MAX_WIDTH}px)`);
20
+ if (process.env.NODE_ENV !== 'production') {
21
+ const elementsCount = Children.toArray(children).filter(isValidElement).length;
22
+ if (elementsCount > MAX_HEADER_ACTIONS) {
23
+ // eslint-disable-next-line no-console
24
+ console.warn(`Modal.Header.Actions supports at most ${MAX_HEADER_ACTIONS} actions, as the title reserves space only for them. ` + `Received ${elementsCount}. Render a single "More options" action that opens a DropdownMenu instead.`);
25
+ }
26
+ }
27
+ return jsx(IsSmallViewportContext.Provider, {
28
+ value: isSmallViewport,
29
+ children: jsx("div", {
30
+ className: styles.modalHeaderActions,
31
+ children: children
32
+ })
33
+ });
34
+ };
35
+ /**
36
+ * Icon button rendered in Modal.Header.Actions. Like Modal.Close, it switches to the `small-utility` variant
37
+ * on small viewports. Can be wrapped in triggers using `asChild`, such as DropdownMenu.Trigger or Tooltip.
38
+ */
39
+ const ModalHeaderAction = /*#__PURE__*/forwardRef((props, forwardedRef) => {
40
+ const isSmallViewport = useContext(IsSmallViewportContext);
41
+ if (process.env.NODE_ENV !== 'production' && isSmallViewport === undefined) {
42
+ // eslint-disable-next-line no-console
43
+ console.warn('Modal.Header.Action must be rendered inside Modal.Header.Actions, ' + 'which pins it next to Modal.Close and reserves space for it next to the title.');
44
+ }
45
+ return isSmallViewport ? jsx(IconButton, {
46
+ ...props,
47
+ ref: forwardedRef,
48
+ variant: "small-utility"
49
+ }) : jsx(IconButton, {
50
+ ...props,
51
+ ref: forwardedRef,
52
+ size: "large"
53
+ });
54
+ });
55
+ ModalHeaderAction.displayName = 'ModalHeaderAction';
56
+
57
+ export { ModalHeaderAction, ModalHeaderActions };
@@ -1,5 +1,5 @@
1
1
  import { type ModalHeaderProps } from './types';
2
- /**
3
- * Header of the Modal
4
- */
5
- export declare const ModalHeader: import("react").ForwardRefExoticComponent<ModalHeaderProps & import("react").RefAttributes<HTMLHeadingElement>>;
2
+ export declare const ModalHeader: import("react").ForwardRefExoticComponent<ModalHeaderProps & import("react").RefAttributes<HTMLHeadingElement>> & {
3
+ Action: import("react").ForwardRefExoticComponent<Omit<import("./types").ModalHeaderActionProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
4
+ Actions: ({ children }: import("./types").ModalHeaderActionsProps) => import("react/jsx-runtime").JSX.Element;
5
+ };
@@ -6,6 +6,7 @@ import { TooltipWrapper } from '../utils/TooltipWrapper.js';
6
6
  import { useIsEllipsized } from '../utils/useIsEllipsized.js';
7
7
  import { Text } from '../text/text.js';
8
8
  import { ModalDescription } from './modal-description.js';
9
+ import { ModalHeaderActions, ModalHeaderAction } from './modal-header-actions.js';
9
10
  import { ModalTitle } from './modal-title.js';
10
11
  import styles from './modal.module.js';
11
12
 
@@ -35,7 +36,8 @@ const ModalHeaderTruncatableTitle = ({
35
36
  /**
36
37
  * Header of the Modal
37
38
  */
38
- const ModalHeader = /*#__PURE__*/forwardRef(({
39
+ const ModalHeaderComponent = /*#__PURE__*/forwardRef(({
40
+ actions,
39
41
  backButton,
40
42
  children,
41
43
  className,
@@ -49,7 +51,8 @@ const ModalHeader = /*#__PURE__*/forwardRef(({
49
51
  const titleContainerRef = useRef(null);
50
52
  const headerClasses = clsx({
51
53
  [styles.headerDividerLine]: isScrolledUnderHeader,
52
- [styles.modalHeaderWithAddon]: renderTitleAddon
54
+ [styles.modalHeaderWithAddon]: renderTitleAddon,
55
+ [styles.modalHeaderWithActions]: actions
53
56
  }, styles.modalHeader);
54
57
  const title = jsx(ModalTitle, {
55
58
  ...rest,
@@ -92,8 +95,12 @@ const ModalHeader = /*#__PURE__*/forwardRef(({
92
95
  })
93
96
  })
94
97
  })]
95
- }) : title]
98
+ }) : title, actions]
96
99
  });
97
100
  });
101
+ const ModalHeader = Object.assign(ModalHeaderComponent, {
102
+ Action: ModalHeaderAction,
103
+ Actions: ModalHeaderActions
104
+ });
98
105
 
99
106
  export { ModalHeader };
@@ -50,7 +50,10 @@ export declare const Modal: (({ children, modal, ...props }: ModalProps) => impo
50
50
  startIcon?: import("../button/types").SvgIconComponent;
51
51
  }, "ref">) & import("react").RefAttributes<HTMLButtonElement>>;
52
52
  };
53
- Header: import("react").ForwardRefExoticComponent<import("./types").ModalHeaderProps & import("react").RefAttributes<HTMLHeadingElement>>;
53
+ Header: import("react").ForwardRefExoticComponent<import("./types").ModalHeaderProps & import("react").RefAttributes<HTMLHeadingElement>> & {
54
+ Action: import("react").ForwardRefExoticComponent<Omit<import("./types").ModalHeaderActionProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
55
+ Actions: ({ children }: import("./types").ModalHeaderActionsProps) => import("react/jsx-runtime").JSX.Element;
56
+ };
54
57
  Loading: import("react").ForwardRefExoticComponent<import("./types").ModalLoadingProps & import("react").RefAttributes<HTMLDivElement>>;
55
58
  ScrollableContainer: import("react").ForwardRefExoticComponent<import("./types").ModalScrollableContainerProps & {
56
59
  'data-testid'?: string;
@@ -1,4 +1,4 @@
1
1
  import '../index.css';
2
- var styles = {"overlay":"bp_modal_module_overlay--aa987","headerDividerLine":"bp_modal_module_headerDividerLine--aa987","footerDividerLine":"bp_modal_module_footerDividerLine--aa987","content":"bp_modal_module_content--aa987","smallSizeModal":"bp_modal_module_smallSizeModal--aa987","mediumSizeModal":"bp_modal_module_mediumSizeModal--aa987","largeSizeModal":"bp_modal_module_largeSizeModal--aa987","xlargeSizeModal":"bp_modal_module_xlargeSizeModal--aa987","loadingContainerActive":"bp_modal_module_loadingContainerActive--aa987","popup_bounce_in":"bp_modal_module_popup_bounce_in--aa987","bpModalEnter":"bp_modal_module_bpModalEnter--aa987","bpModalExit":"bp_modal_module_bpModalExit--aa987","scrollableContainer":"bp_modal_module_scrollableContainer--aa987","close":"bp_modal_module_close--aa987","onColor":"bp_modal_module_onColor--aa987","backButton":"bp_modal_module_backButton--aa987","modalHeader":"bp_modal_module_modalHeader--aa987","modalTitle":"bp_modal_module_modalTitle--aa987","modalHeaderWithAddon":"bp_modal_module_modalHeaderWithAddon--aa987","modalTitleWithAddon":"bp_modal_module_modalTitleWithAddon--aa987","modalTitleLabel":"bp_modal_module_modalTitleLabel--aa987","modalTitleAddon":"bp_modal_module_modalTitleAddon--aa987","modalTitleText":"bp_modal_module_modalTitleText--aa987","modalTitleWithSubtitle":"bp_modal_module_modalTitleWithSubtitle--aa987","body":"bp_modal_module_body--aa987","footer":"bp_modal_module_footer--aa987","footerButton":"bp_modal_module_footerButton--aa987","loadingContainer":"bp_modal_module_loadingContainer--aa987","loading":"bp_modal_module_loading--aa987","loadingOverlay":"bp_modal_module_loadingOverlay--aa987","loadingContent":"bp_modal_module_loadingContent--aa987","loadingContentVisible":"bp_modal_module_loadingContentVisible--aa987"};
2
+ var styles = {"overlay":"bp_modal_module_overlay--ecdb9","headerDividerLine":"bp_modal_module_headerDividerLine--ecdb9","footerDividerLine":"bp_modal_module_footerDividerLine--ecdb9","content":"bp_modal_module_content--ecdb9","smallSizeModal":"bp_modal_module_smallSizeModal--ecdb9","mediumSizeModal":"bp_modal_module_mediumSizeModal--ecdb9","largeSizeModal":"bp_modal_module_largeSizeModal--ecdb9","xlargeSizeModal":"bp_modal_module_xlargeSizeModal--ecdb9","loadingContainerActive":"bp_modal_module_loadingContainerActive--ecdb9","popup_bounce_in":"bp_modal_module_popup_bounce_in--ecdb9","bpModalEnter":"bp_modal_module_bpModalEnter--ecdb9","bpModalExit":"bp_modal_module_bpModalExit--ecdb9","scrollableContainer":"bp_modal_module_scrollableContainer--ecdb9","close":"bp_modal_module_close--ecdb9","modalHeaderActions":"bp_modal_module_modalHeaderActions--ecdb9","onColor":"bp_modal_module_onColor--ecdb9","backButton":"bp_modal_module_backButton--ecdb9","modalHeader":"bp_modal_module_modalHeader--ecdb9","modalTitle":"bp_modal_module_modalTitle--ecdb9","modalHeaderWithActions":"bp_modal_module_modalHeaderWithActions--ecdb9","modalHeaderWithAddon":"bp_modal_module_modalHeaderWithAddon--ecdb9","modalTitleWithAddon":"bp_modal_module_modalTitleWithAddon--ecdb9","modalTitleLabel":"bp_modal_module_modalTitleLabel--ecdb9","modalTitleAddon":"bp_modal_module_modalTitleAddon--ecdb9","modalTitleText":"bp_modal_module_modalTitleText--ecdb9","modalTitleWithSubtitle":"bp_modal_module_modalTitleWithSubtitle--ecdb9","body":"bp_modal_module_body--ecdb9","footer":"bp_modal_module_footer--ecdb9","footerButton":"bp_modal_module_footerButton--ecdb9","loadingContainer":"bp_modal_module_loadingContainer--ecdb9","loading":"bp_modal_module_loading--ecdb9","loadingOverlay":"bp_modal_module_loadingOverlay--ecdb9","loadingContent":"bp_modal_module_loadingContent--ecdb9","loadingContentVisible":"bp_modal_module_loadingContentVisible--ecdb9"};
3
3
 
4
4
  export { styles as default };
@@ -2,6 +2,7 @@ import { type DialogContentProps, type DialogDescriptionProps, type DialogPortal
2
2
  import { type FunctionComponent, type HTMLAttributes, type PropsWithChildren, type ReactElement, type ReactNode, type RefObject, type SVGProps } from 'react';
3
3
  import { type LoadingIndicatorProps } from '../loading-indicator/loading-indicator';
4
4
  import { type IconButtonProps } from '../primitives/icon-button';
5
+ import { type IconButtonVariantsProps } from '../primitives/icon-button/types';
5
6
  export type ModalProps = DialogProps;
6
7
  /** The size of the modal content.
7
8
  * small: max-width: 460px
@@ -58,7 +59,18 @@ export interface ModalHeaderTitleAddonRenderProps {
58
59
  /** Ref to the title area container, for observing changes to its size. */
59
60
  containerRef: RefObject<HTMLDivElement>;
60
61
  }
62
+ export interface ModalHeaderActionsProps {
63
+ /**
64
+ * Up to 2 Modal.Header.Action elements, which can be wrapped in triggers using `asChild`.
65
+ * Only the first one is shown on small viewports, so the second one must not be essential.
66
+ */
67
+ children: ReactNode;
68
+ }
69
+ /** The size and variant are set by Blueprint, to match Modal.Close. */
70
+ export type ModalHeaderActionProps = Omit<IconButtonVariantsProps, 'size' | 'variant'>;
61
71
  export interface ModalHeaderProps extends HTMLAttributes<HTMLHeadingElement> {
72
+ /** Actions next to the Close button, rendered with Modal.Header.Actions. */
73
+ actions?: ReactNode;
62
74
  backButton?: ReactElement;
63
75
  /** Content of the Header element */
64
76
  children: ReactNode;
@@ -0,0 +1,17 @@
1
+ import { type FocusEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from 'react';
2
+ export interface HoverFocusExpandResult {
3
+ isExpanded: boolean;
4
+ stackRef: {
5
+ current: HTMLElement | null;
6
+ };
7
+ handleMouseEnter: () => void;
8
+ handleMouseLeave: (event: MouseEvent<HTMLElement>) => void;
9
+ handlePointerLeave: (event: ReactPointerEvent<HTMLElement>) => void;
10
+ handleFocus: (event: FocusEvent<HTMLElement>) => void;
11
+ handleBlur: (event: FocusEvent<HTMLElement>) => void;
12
+ }
13
+ /**
14
+ * Expanded/collapsed deck from hover (fine pointer), keyboard focus, or a pointer outside the stack.
15
+ * `canExpand` is false for a single toast; `isAnimationEnabled` false turns expansion off entirely.
16
+ */
17
+ export declare const useHoverFocusExpand: (canExpand: boolean, isAnimationEnabled: boolean) => HoverFocusExpandResult;
@@ -0,0 +1,85 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+
3
+ const FINE_POINTER_HOVER_QUERY = '(hover: hover) and (pointer: fine)';
4
+ const canHoverWithFinePointer = () => typeof window !== 'undefined' && window.matchMedia(FINE_POINTER_HOVER_QUERY).matches;
5
+ /** Blur pointer-focus on the toast (not `:focus-visible`) so TTL can restart on leave. */
6
+ const releasePointerFocus = container => {
7
+ const {
8
+ activeElement
9
+ } = document;
10
+ if (container instanceof HTMLElement && activeElement instanceof HTMLElement && container.contains(activeElement) && !activeElement.matches(':focus-visible')) {
11
+ activeElement.blur();
12
+ }
13
+ };
14
+ /**
15
+ * Expanded/collapsed deck from hover (fine pointer), keyboard focus, or a pointer outside the stack.
16
+ * `canExpand` is false for a single toast; `isAnimationEnabled` false turns expansion off entirely.
17
+ */
18
+ const useHoverFocusExpand = (canExpand, isAnimationEnabled) => {
19
+ const canExpandWhenAnimated = canExpand && isAnimationEnabled;
20
+ const [isExpanded, setIsExpanded] = useState(false);
21
+ const stackRef = useRef(null);
22
+ const isListExpanded = isExpanded && canExpandWhenAnimated;
23
+ useEffect(() => {
24
+ if (!canExpandWhenAnimated && isExpanded) {
25
+ setIsExpanded(false);
26
+ }
27
+ }, [canExpandWhenAnimated, isExpanded]);
28
+ useEffect(() => {
29
+ if (!isListExpanded) {
30
+ return undefined;
31
+ }
32
+ const handlePointerDownOutside = event => {
33
+ const stack = stackRef.current;
34
+ if (stack && event.target instanceof Node && !stack.contains(event.target)) {
35
+ setIsExpanded(false);
36
+ }
37
+ };
38
+ document.addEventListener('pointerdown', handlePointerDownOutside);
39
+ return () => document.removeEventListener('pointerdown', handlePointerDownOutside);
40
+ }, [isListExpanded]);
41
+ const expand = () => {
42
+ if (canExpandWhenAnimated) {
43
+ setIsExpanded(true);
44
+ }
45
+ };
46
+ return {
47
+ isExpanded: isListExpanded,
48
+ stackRef,
49
+ handleMouseEnter: () => {
50
+ if (canHoverWithFinePointer()) {
51
+ expand();
52
+ }
53
+ },
54
+ handlePointerLeave: event => {
55
+ // Fires on the viewport <ol> before Radix's wrapper `pointerleave`.
56
+ releasePointerFocus(event.currentTarget);
57
+ },
58
+ handleMouseLeave: event => {
59
+ const {
60
+ activeElement
61
+ } = document;
62
+ if (activeElement instanceof HTMLElement && event.currentTarget.contains(activeElement) && activeElement.matches(':focus-visible')) {
63
+ return;
64
+ }
65
+ releasePointerFocus(event.currentTarget);
66
+ setIsExpanded(false);
67
+ },
68
+ handleFocus: event => {
69
+ if (event.target instanceof HTMLElement && event.target.matches(':focus-visible')) {
70
+ expand();
71
+ }
72
+ },
73
+ handleBlur: event => {
74
+ if (event.currentTarget.contains(event.relatedTarget)) {
75
+ return;
76
+ }
77
+ if (canHoverWithFinePointer() && event.currentTarget.matches(':hover')) {
78
+ return;
79
+ }
80
+ setIsExpanded(false);
81
+ }
82
+ };
83
+ };
84
+
85
+ export { useHoverFocusExpand };
@@ -0,0 +1,21 @@
1
+ import { type Dispatch, type SetStateAction, type TransitionEvent } from 'react';
2
+ type SetOpen = Dispatch<SetStateAction<boolean | undefined>>;
3
+ /**
4
+ * Defers `setOpen(false)` until the exit CSS transition finishes. `removeNotification`
5
+ * unmounts immediately, so without this the stack never gets a chance to reflow.
6
+ */
7
+ export declare function useNotificationExitAnimation({ open, setOpen, isAnimationEnabled, onExitStart, }: {
8
+ open: boolean;
9
+ setOpen: SetOpen;
10
+ isAnimationEnabled: boolean;
11
+ /** Fires when exit begins so the stack can drop this layer from layout. */
12
+ onExitStart?: () => void;
13
+ }): {
14
+ radixOpen: boolean;
15
+ isExiting: boolean;
16
+ hasExitStyles: boolean;
17
+ handleOpenChange: (nextOpen: boolean) => void;
18
+ completeExit: () => void;
19
+ };
20
+ export declare function handleNotificationExitTransitionEnd(event: TransitionEvent<HTMLElement>, isExiting: boolean, completeExit: () => void): void;
21
+ export {};
@@ -0,0 +1,141 @@
1
+ import { bpDurationMedium } from '@box/blueprint-web-assets/tokens/tokens';
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+ import { noop } from '../../../utils/noop.js';
4
+ import { prefersReducedMotion } from '../notification-stack-utils.js';
5
+
6
+ const EXIT_DURATION_MS = Number.parseFloat(bpDurationMedium);
7
+ /** Safety net only — primary completion is `transitionend` on opacity/transform. */
8
+ const NOTIFICATION_EXIT_ANIMATION_FALLBACK_MS = EXIT_DURATION_MS + 50;
9
+ const EXIT_TRANSITION_PROPERTIES = new Set(['opacity', 'transform']);
10
+ /**
11
+ * Defers `setOpen(false)` until the exit CSS transition finishes. `removeNotification`
12
+ * unmounts immediately, so without this the stack never gets a chance to reflow.
13
+ */
14
+ function useNotificationExitAnimation({
15
+ open,
16
+ setOpen,
17
+ isAnimationEnabled,
18
+ onExitStart
19
+ }) {
20
+ const [isExiting, setIsExiting] = useState(false);
21
+ // Keep exit styles applied through the final unmount frame to avoid a visible flash.
22
+ const [hasExitStyles, setHasExitStyles] = useState(false);
23
+ const exitFallbackTimerRef = useRef(null);
24
+ const exitCommitRafRef = useRef(null);
25
+ const isExitingRef = useRef(isExiting);
26
+ isExitingRef.current = isExiting;
27
+ const clearExitTimers = useCallback(() => {
28
+ if (exitFallbackTimerRef.current !== null) {
29
+ clearTimeout(exitFallbackTimerRef.current);
30
+ exitFallbackTimerRef.current = null;
31
+ }
32
+ if (exitCommitRafRef.current !== null) {
33
+ cancelAnimationFrame(exitCommitRafRef.current);
34
+ exitCommitRafRef.current = null;
35
+ }
36
+ }, []);
37
+ const clearExitState = useCallback(() => {
38
+ clearExitTimers();
39
+ isExitingRef.current = false;
40
+ setIsExiting(false);
41
+ setHasExitStyles(false);
42
+ }, [clearExitTimers]);
43
+ useEffect(() => {
44
+ if (!isAnimationEnabled) {
45
+ const shouldCommit = isExitingRef.current || exitCommitRafRef.current !== null;
46
+ clearExitState();
47
+ if (shouldCommit) {
48
+ setOpen(false);
49
+ }
50
+ }
51
+ }, [clearExitState, isAnimationEnabled, setOpen]);
52
+ useEffect(() => {
53
+ if (!open && isExiting) {
54
+ clearExitState();
55
+ }
56
+ }, [clearExitState, isExiting, open]);
57
+ useEffect(() => () => clearExitTimers(), [clearExitTimers]);
58
+ const handleOpenChange = useCallback(nextOpen => {
59
+ if (!isAnimationEnabled) {
60
+ setOpen(nextOpen);
61
+ return;
62
+ }
63
+ if (nextOpen) {
64
+ clearExitState();
65
+ setOpen(true);
66
+ return;
67
+ }
68
+ if (isExiting) {
69
+ return;
70
+ }
71
+ // Reduced-motion CSS drops the transition, so `transitionend` would never fire.
72
+ if (open && !prefersReducedMotion()) {
73
+ setIsExiting(true);
74
+ setHasExitStyles(true);
75
+ onExitStart?.();
76
+ return;
77
+ }
78
+ setIsExiting(false);
79
+ setHasExitStyles(false);
80
+ setOpen(false);
81
+ }, [clearExitState, isAnimationEnabled, isExiting, onExitStart, open, setOpen]);
82
+ const completeExit = useCallback(() => {
83
+ if (!isAnimationEnabled || !isExitingRef.current) {
84
+ return;
85
+ }
86
+ clearExitTimers();
87
+ isExitingRef.current = false;
88
+ setIsExiting(false);
89
+ // Next frame so opacity/transform paint before Radix unmounts.
90
+ exitCommitRafRef.current = requestAnimationFrame(() => {
91
+ exitCommitRafRef.current = null;
92
+ setOpen(false);
93
+ setHasExitStyles(false);
94
+ });
95
+ }, [clearExitTimers, isAnimationEnabled, setOpen]);
96
+ const completeExitRef = useRef(completeExit);
97
+ completeExitRef.current = completeExit;
98
+ useEffect(() => {
99
+ if (!isAnimationEnabled || !isExiting) {
100
+ return undefined;
101
+ }
102
+ clearExitTimers();
103
+ exitFallbackTimerRef.current = setTimeout(() => {
104
+ exitFallbackTimerRef.current = null;
105
+ completeExitRef.current();
106
+ }, NOTIFICATION_EXIT_ANIMATION_FALLBACK_MS);
107
+ return () => {
108
+ if (exitFallbackTimerRef.current !== null) {
109
+ clearTimeout(exitFallbackTimerRef.current);
110
+ exitFallbackTimerRef.current = null;
111
+ }
112
+ };
113
+ }, [clearExitTimers, isAnimationEnabled, isExiting]);
114
+ if (!isAnimationEnabled) {
115
+ return {
116
+ radixOpen: open,
117
+ isExiting: false,
118
+ hasExitStyles: false,
119
+ handleOpenChange: setOpen,
120
+ completeExit: noop
121
+ };
122
+ }
123
+ return {
124
+ radixOpen: isExiting ? true : open,
125
+ isExiting,
126
+ hasExitStyles,
127
+ handleOpenChange,
128
+ completeExit
129
+ };
130
+ }
131
+ function handleNotificationExitTransitionEnd(event, isExiting, completeExit) {
132
+ if (event.target !== event.currentTarget || !isExiting || event.type === 'transitioncancel') {
133
+ return;
134
+ }
135
+ if (!EXIT_TRANSITION_PROPERTIES.has(event.propertyName)) {
136
+ return;
137
+ }
138
+ completeExit();
139
+ }
140
+
141
+ export { handleNotificationExitTransitionEnd, useNotificationExitAnimation };
@@ -0,0 +1,19 @@
1
+ export interface StackMetrics {
2
+ offsets: number[];
3
+ collapsedHeight: number;
4
+ expandedHeight: number;
5
+ }
6
+ interface StackLayerMetricsResult {
7
+ metrics: StackMetrics;
8
+ getLayerRef: (depth: number) => (node: HTMLLIElement | null) => void;
9
+ }
10
+ /**
11
+ * Measures each layer's height so the deck can report collapsed (peek) vs expanded (full list)
12
+ * size, and re-measures on resize. `depthOffsetPx`/`gapPx` are injected so this stays token-agnostic.
13
+ *
14
+ * Layers are observed from their ref callbacks, not from an effect keyed on `layerCount`:
15
+ * Radix portals toast content only once the viewport node is known, so layer nodes can attach
16
+ * (or be replaced) a commit after the count changed.
17
+ */
18
+ export declare const useStackLayerMetrics: (layerCount: number, depthOffsetPx: number, gapPx: number) => StackLayerMetricsResult;
19
+ export {};
@@ -0,0 +1,96 @@
1
+ import { useState, useRef, useLayoutEffect } from 'react';
2
+ import { NOTIFICATION_STACK_VISIBLE_DEPTH } from '../notification-stack-utils.js';
3
+
4
+ const emptyMetrics = {
5
+ offsets: [],
6
+ collapsedHeight: 0,
7
+ expandedHeight: 0
8
+ };
9
+ const metricsEqual = (left, right) => left.collapsedHeight === right.collapsedHeight && left.expandedHeight === right.expandedHeight && left.offsets.length === right.offsets.length && left.offsets.every((value, index) => value === right.offsets[index]);
10
+ /**
11
+ * Measures each layer's height so the deck can report collapsed (peek) vs expanded (full list)
12
+ * size, and re-measures on resize. `depthOffsetPx`/`gapPx` are injected so this stays token-agnostic.
13
+ *
14
+ * Layers are observed from their ref callbacks, not from an effect keyed on `layerCount`:
15
+ * Radix portals toast content only once the viewport node is known, so layer nodes can attach
16
+ * (or be replaced) a commit after the count changed.
17
+ */
18
+ const useStackLayerMetrics = (layerCount, depthOffsetPx, gapPx) => {
19
+ const [metrics, setMetrics] = useState(emptyMetrics);
20
+ const layerRefs = useRef([]);
21
+ const layerRefCallbacks = useRef(new Map());
22
+ const observerRef = useRef(null);
23
+ const measureRef = useRef(() => undefined);
24
+ measureRef.current = () => {
25
+ const heights = layerRefs.current.slice(0, layerCount).map(node => node?.offsetHeight ?? 0);
26
+ let cumulative = 0;
27
+ const offsets = heights.map(height => {
28
+ const offset = cumulative;
29
+ cumulative += height + gapPx;
30
+ return offset;
31
+ });
32
+ const collapsedVisible = Math.min(layerCount, NOTIFICATION_STACK_VISIBLE_DEPTH);
33
+ setMetrics(previous => {
34
+ const nextMetrics = {
35
+ offsets,
36
+ collapsedHeight: (heights[0] ?? 0) + Math.max(0, collapsedVisible - 1) * depthOffsetPx,
37
+ expandedHeight: Math.max(0, cumulative - gapPx)
38
+ };
39
+ return metricsEqual(previous, nextMetrics) ? previous : nextMetrics;
40
+ });
41
+ };
42
+ const getLayerRef = depth => {
43
+ let callback = layerRefCallbacks.current.get(depth);
44
+ if (!callback) {
45
+ callback = node => {
46
+ const previous = layerRefs.current[depth];
47
+ if (previous === node) {
48
+ return;
49
+ }
50
+ const observer = observerRef.current;
51
+ const isPreviousStillLayer = layerRefs.current.some((other, index) => index !== depth && other === previous);
52
+ if (previous && observer && !isPreviousStillLayer) {
53
+ observer.unobserve(previous);
54
+ }
55
+ layerRefs.current[depth] = node;
56
+ if (!node) {
57
+ return;
58
+ }
59
+ // ResizeObserver reports the initial size on observe; without it, measure now.
60
+ if (observer) {
61
+ observer.observe(node);
62
+ } else {
63
+ measureRef.current();
64
+ }
65
+ };
66
+ layerRefCallbacks.current.set(depth, callback);
67
+ }
68
+ return callback;
69
+ };
70
+ useLayoutEffect(() => {
71
+ if (typeof ResizeObserver === 'undefined') {
72
+ return undefined;
73
+ }
74
+ const observer = new ResizeObserver(() => measureRef.current());
75
+ observerRef.current = observer;
76
+ layerRefs.current.forEach(node => {
77
+ if (node) {
78
+ observer.observe(node);
79
+ }
80
+ });
81
+ return () => {
82
+ observer.disconnect();
83
+ observerRef.current = null;
84
+ };
85
+ }, []);
86
+ useLayoutEffect(() => {
87
+ layerRefs.current.length = layerCount;
88
+ measureRef.current();
89
+ }, [layerCount, depthOffsetPx, gapPx]);
90
+ return {
91
+ metrics,
92
+ getLayerRef
93
+ };
94
+ };
95
+
96
+ export { useStackLayerMetrics };
@@ -0,0 +1,10 @@
1
+ export interface AdmissionResult<TValue> {
2
+ visibleNotifications: Map<string, TValue> | undefined;
3
+ enteringId: string | null;
4
+ }
5
+ /**
6
+ * Admits burst arrivals one at a time (`NOTIFICATION_STACK_STAGGER_MS` apart), in the
7
+ * iteration order of `notifications` — pass it oldest-first so the newest lands on top last.
8
+ * First-mount ids, `isAnimationEnabled === false`, and `prefers-reduced-motion` skip the stagger.
9
+ */
10
+ export declare const useStaggeredNotificationAdmission: <TValue>(notifications: Map<string, TValue> | undefined, isAnimationEnabled: boolean) => AdmissionResult<TValue>;