@box/blueprint-web 18.7.1 → 18.9.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.
@@ -0,0 +1,2 @@
1
+ import { type AvatarGroupProps } from './types';
2
+ export declare const AvatarGroup: import("react").ForwardRefExoticComponent<AvatarGroupProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,156 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { bpGray10 } from '@box/blueprint-web-assets/tokens/tokens';
3
+ import clsx from 'clsx';
4
+ import { forwardRef, Children, cloneElement, isValidElement } from 'react';
5
+ import '../../blueprint-configuration-context/blueprint-configuration-context.js';
6
+ import '../../blueprint-configuration-context/consts.js';
7
+ import { useBlueprintConfiguration } from '../../blueprint-configuration-context/useBlueprintConfiguration.js';
8
+ import { Focusable } from '../../focusable/focusable.js';
9
+ import { useTravelTooltip, TravelTooltipBubble } from '../../tooltip/travel-tooltip.js';
10
+ import { composeEventHandlers } from '../../utils/composeEventHandlers.js';
11
+ import { useForkRef } from '../../utils/useForkRef.js';
12
+ import { useUniqueId } from '../../utils/useUniqueId.js';
13
+ import { Avatar } from '../avatar.js';
14
+ import styles from './avatar-group.module.js';
15
+ import { useAvatarGroupHover } from './use-avatar-group-hover.js';
16
+ import { getAvatarGroupVisibility } from './utils.js';
17
+
18
+ const overflowColor = bpGray10;
19
+ function isAvatarElement(child) {
20
+ return /*#__PURE__*/isValidElement(child) && child.type === Avatar;
21
+ }
22
+ const AvatarGroup = /*#__PURE__*/forwardRef((props, forwardedRef) => {
23
+ const {
24
+ children,
25
+ className,
26
+ getTooltipContent,
27
+ max,
28
+ overflowLabel,
29
+ overlap,
30
+ ringColor,
31
+ size,
32
+ style,
33
+ onMouseLeave,
34
+ onPointerLeave,
35
+ onBlur,
36
+ ...rest
37
+ } = props;
38
+ const {
39
+ componentsWithAnimationEnabled
40
+ } = useBlueprintConfiguration();
41
+ const isAnimationEnabled = componentsWithAnimationEnabled.includes('AvatarGroup');
42
+ const {
43
+ groupRef: hoverGroupRef,
44
+ onGroupLeave,
45
+ onItemEnter
46
+ } = useAvatarGroupHover(isAnimationEnabled);
47
+ const {
48
+ activeTrigger,
49
+ groupRef: travelGroupRef,
50
+ hideFocusIfLeavingGroup,
51
+ hidePointerIfLeavingGroup,
52
+ label,
53
+ showForFocus,
54
+ showForPointer,
55
+ textRef,
56
+ tooltipRef
57
+ } = useTravelTooltip();
58
+ const ref = useForkRef(useForkRef(hoverGroupRef, travelGroupRef), forwardedRef);
59
+ const tooltipId = useUniqueId('avatar-group-tooltip');
60
+ const avatars = Children.toArray(children).filter(isAvatarElement);
61
+ const {
62
+ visibleCount,
63
+ overflowCount
64
+ } = getAvatarGroupVisibility(avatars.length, max);
65
+ const visibleAvatars = avatars.slice(0, visibleCount);
66
+ const hasTooltips = Boolean(getTooltipContent || overflowLabel);
67
+ const customProperties = {
68
+ ...(overlap ? {
69
+ '--avatar-group-overlap': overlap
70
+ } : {}),
71
+ ...(ringColor ? {
72
+ '--avatar-group-ring-color': ringColor
73
+ } : {})
74
+ };
75
+ const leaveFromPointer = event => {
76
+ if (hidePointerIfLeavingGroup(event)) {
77
+ onGroupLeave();
78
+ }
79
+ };
80
+ const leaveFromFocus = event => {
81
+ if (hideFocusIfLeavingGroup(event)) {
82
+ onGroupLeave();
83
+ }
84
+ };
85
+ const activateItem = (index, trigger, tooltip, show) => {
86
+ onItemEnter(index);
87
+ if (tooltip) {
88
+ show(trigger, tooltip);
89
+ }
90
+ };
91
+ return jsxs("div", {
92
+ ...rest,
93
+ ref: ref,
94
+ className: clsx(styles.avatarGroup, className),
95
+ "data-bp-animated": isAnimationEnabled ? 'true' : 'false',
96
+ onBlur: composeEventHandlers(onBlur, leaveFromFocus),
97
+ onMouseLeave: onMouseLeave,
98
+ onPointerLeave: composeEventHandlers(onPointerLeave, leaveFromPointer),
99
+ role: "group",
100
+ style: overlap || ringColor || style ? {
101
+ ...customProperties,
102
+ ...style
103
+ } : undefined,
104
+ children: [visibleAvatars.map((child, index) => {
105
+ const tooltip = getTooltipContent?.(child.props.text, index);
106
+ return jsx(Focusable, {
107
+ "aria-describedby": tooltip ? tooltipId : undefined,
108
+ focusable: Boolean(tooltip),
109
+ // eslint-disable-next-line react/jsx-no-bind
110
+ onFocusVisible: event => activateItem(index, event.currentTarget, tooltip, showForFocus),
111
+ // eslint-disable-next-line react/jsx-no-bind
112
+ onPointerEnter: event => activateItem(index, event.currentTarget, tooltip, showForPointer),
113
+ children: jsx("div", {
114
+ className: styles.item,
115
+ "data-avatar-group-item": "",
116
+ children: jsx("div", {
117
+ className: styles.itemMotion,
118
+ children: size ? /*#__PURE__*/cloneElement(child, {
119
+ size
120
+ }) : child
121
+ })
122
+ })
123
+ }, child.key ?? index);
124
+ }), overflowCount > 0 && jsx(Focusable, {
125
+ "aria-describedby": overflowLabel ? tooltipId : undefined,
126
+ "aria-label": overflowLabel,
127
+ focusable: Boolean(overflowLabel),
128
+ // eslint-disable-next-line react/jsx-no-bind
129
+ onFocusVisible: event => activateItem(visibleAvatars.length, event.currentTarget, overflowLabel, showForFocus),
130
+ // eslint-disable-next-line react/jsx-no-bind
131
+ onPointerEnter: event => activateItem(visibleAvatars.length, event.currentTarget, overflowLabel, showForPointer),
132
+ children: jsx("div", {
133
+ className: styles.item,
134
+ "data-avatar-group-item": "",
135
+ children: jsx("div", {
136
+ className: styles.itemMotion,
137
+ children: jsx(Avatar, {
138
+ color: overflowColor,
139
+ size: size,
140
+ text: `+${overflowCount}`
141
+ })
142
+ })
143
+ })
144
+ }), hasTooltips ? jsx(TravelTooltipBubble, {
145
+ animated: isAnimationEnabled,
146
+ id: tooltipId,
147
+ label: label,
148
+ show: Boolean(activeTrigger),
149
+ textRef: textRef,
150
+ tooltipRef: tooltipRef
151
+ }) : null]
152
+ });
153
+ });
154
+ AvatarGroup.displayName = 'AvatarGroup';
155
+
156
+ export { AvatarGroup };
@@ -0,0 +1,4 @@
1
+ import '../../index.css';
2
+ var styles = {"avatarGroup":"bp_avatar_group_module_avatarGroup--c775b","item":"bp_avatar_group_module_item--c775b","itemMotion":"bp_avatar_group_module_itemMotion--c775b"};
3
+
4
+ export { styles as default };
@@ -0,0 +1,27 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ import { type AvatarSize } from '../types';
3
+ export interface AvatarGroupProps extends Omit<ComponentPropsWithoutRef<'div'>, 'role'> {
4
+ /**
5
+ * Avatar children to display. Compose with `<Avatar>` nodes — the group clones
6
+ * them to apply `size` and the overlap/ring treatment.
7
+ */
8
+ children?: ReactNode;
9
+ /** Maximum number of avatars shown before an overflow `+N` avatar is rendered. */
10
+ max?: number;
11
+ /** Size passed through to each Avatar, including the overflow avatar. */
12
+ size?: AvatarSize;
13
+ /**
14
+ * Overlap distance between adjacent avatars as a CSS length.
15
+ * @default `--bp-space-020` (8px).
16
+ */
17
+ overlap?: string;
18
+ /**
19
+ * Ring colour around each avatar as a CSS color.
20
+ * @default `--bp-gray-white`.
21
+ */
22
+ ringColor?: string;
23
+ /** Accessible label for the overflow avatar. */
24
+ overflowLabel?: string;
25
+ /** Tooltip label for a visible avatar. Omit to skip tooltips. */
26
+ getTooltipContent?(text: string | undefined, index: number): string | undefined;
27
+ }
@@ -0,0 +1,6 @@
1
+ export declare function applyAvatarGroupHover(items: HTMLElement[], activeIndex: number | null): void;
2
+ export declare function useAvatarGroupHover(enabled: boolean): {
3
+ groupRef: import("react").RefObject<HTMLDivElement>;
4
+ onGroupLeave: () => void;
5
+ onItemEnter: (index: number) => void;
6
+ };
@@ -0,0 +1,61 @@
1
+ import { useRef, useCallback } from 'react';
2
+
3
+ // Transitions.dev avatar hover. Lift is negative so the active item rises.
4
+ const LIFT = -4;
5
+ const SCALE = 1.05;
6
+ // TODO(ewang): confirm duration — 200/140 has no --bp-duration-* pair (medium=150, long=300)
7
+ const DUR_IN = '200ms';
8
+ const DUR_OUT = '140ms';
9
+ // TODO(ewang): confirm curve — Transitions.dev 0.22,1,0.36,1 vs --bp-curve-large-on
10
+ const EASE_IN = 'cubic-bezier(0.22, 1, 0.36, 1)';
11
+ // Leave is a plain ease-out — no spring overshoot.
12
+ const EASE_OUT = 'cubic-bezier(0.4, 0, 0.2, 1)';
13
+ function applyAvatarGroupHover(items, activeIndex) {
14
+ const isLeaving = activeIndex === null;
15
+ const ease = isLeaving ? EASE_OUT : EASE_IN;
16
+ const duration = isLeaving ? DUR_OUT : DUR_IN;
17
+ items.forEach(item => {
18
+ item.style.transitionDuration = duration;
19
+ item.style.transitionTimingFunction = ease;
20
+ });
21
+ items.forEach((item, index) => {
22
+ if (activeIndex === null) {
23
+ item.style.setProperty('--shift', '0px');
24
+ item.style.setProperty('--scale-active', '1');
25
+ item.style.zIndex = '';
26
+ return;
27
+ }
28
+ const isActive = index === activeIndex;
29
+ item.style.setProperty('--shift', isActive ? `${LIFT}px` : '0px');
30
+ item.style.setProperty('--scale-active', isActive ? String(SCALE) : '1');
31
+ item.style.zIndex = isActive ? '3' : '';
32
+ });
33
+ }
34
+ function useAvatarGroupHover(enabled) {
35
+ const groupRef = useRef(null);
36
+ const getItems = useCallback(() => {
37
+ if (!groupRef.current) {
38
+ return [];
39
+ }
40
+ return [...groupRef.current.querySelectorAll('[data-avatar-group-item]')];
41
+ }, []);
42
+ const onItemEnter = useCallback(index => {
43
+ if (!enabled) {
44
+ return;
45
+ }
46
+ applyAvatarGroupHover(getItems(), index);
47
+ }, [enabled, getItems]);
48
+ const onGroupLeave = useCallback(() => {
49
+ if (!enabled) {
50
+ return;
51
+ }
52
+ applyAvatarGroupHover(getItems(), null);
53
+ }, [enabled, getItems]);
54
+ return {
55
+ groupRef,
56
+ onGroupLeave,
57
+ onItemEnter
58
+ };
59
+ }
60
+
61
+ export { applyAvatarGroupHover, useAvatarGroupHover };
@@ -0,0 +1,5 @@
1
+ export type AvatarGroupVisibility = {
2
+ visibleCount: number;
3
+ overflowCount: number;
4
+ };
5
+ export declare function getAvatarGroupVisibility(totalCount: number, max?: number): AvatarGroupVisibility;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Splits a group of avatars into the number shown and the leftover overflow count
3
+ * rendered as a `+N` avatar.
4
+ *
5
+ * `max` is the visible count before overflow. When omitted, every avatar is shown.
6
+ */
7
+ function getVisibleCountForMax(totalCount, max) {
8
+ if (max === Number.POSITIVE_INFINITY) {
9
+ return totalCount;
10
+ }
11
+ if (!Number.isFinite(max)) {
12
+ return 0;
13
+ }
14
+ const normalizedMax = Math.max(0, Math.trunc(max));
15
+ return Math.min(totalCount, normalizedMax);
16
+ }
17
+ function getAvatarGroupVisibility(totalCount, max) {
18
+ if (max === undefined) {
19
+ return {
20
+ visibleCount: totalCount,
21
+ overflowCount: 0
22
+ };
23
+ }
24
+ const visibleCount = getVisibleCountForMax(totalCount, max);
25
+ return {
26
+ visibleCount,
27
+ overflowCount: totalCount - visibleCount
28
+ };
29
+ }
30
+
31
+ export { getAvatarGroupVisibility };
@@ -1,4 +1,4 @@
1
1
  import '../index.css';
2
- var styles = {"avatar":"bp_avatar_module_avatar--ed504","text":"bp_avatar_module_text--ed504","xsmall":"bp_avatar_module_xsmall--ed504","length-1":"bp_avatar_module_length-1--ed504","length-2":"bp_avatar_module_length-2--ed504","small":"bp_avatar_module_small--ed504","medium":"bp_avatar_module_medium--ed504","large":"bp_avatar_module_large--ed504","length-3":"bp_avatar_module_length-3--ed504","length-4":"bp_avatar_module_length-4--ed504","xlarge":"bp_avatar_module_xlarge--ed504","xxlarge":"bp_avatar_module_xxlarge--ed504","badge":"bp_avatar_module_badge--ed504","image":"bp_avatar_module_image--ed504","loading":"bp_avatar_module_loading--ed504","anonymousAvatar":"bp_avatar_module_anonymousAvatar--ed504","iconContainer":"bp_avatar_module_iconContainer--ed504"};
2
+ var styles = {"avatar":"bp_avatar_module_avatar--42c50","text":"bp_avatar_module_text--42c50","xsmall":"bp_avatar_module_xsmall--42c50","length-1":"bp_avatar_module_length-1--42c50","length-2":"bp_avatar_module_length-2--42c50","small":"bp_avatar_module_small--42c50","medium":"bp_avatar_module_medium--42c50","large":"bp_avatar_module_large--42c50","length-3":"bp_avatar_module_length-3--42c50","length-4":"bp_avatar_module_length-4--42c50","xlarge":"bp_avatar_module_xlarge--42c50","xxlarge":"bp_avatar_module_xxlarge--42c50","badge":"bp_avatar_module_badge--42c50","image":"bp_avatar_module_image--42c50","loading":"bp_avatar_module_loading--42c50","anonymousAvatar":"bp_avatar_module_anonymousAvatar--42c50","iconContainer":"bp_avatar_module_iconContainer--42c50"};
3
3
 
4
4
  export { styles as default };
@@ -1,2 +1,4 @@
1
1
  export { Avatar } from './avatar';
2
+ export { AvatarGroup } from './avatar-group/avatar-group';
3
+ export { type AvatarGroupProps } from './avatar-group/types';
2
4
  export { type AvatarProps } from './types';
@@ -1,3 +1,4 @@
1
+ import { type AnimationEventHandler } from 'react';
1
2
  import { type BadgeProps } from './types';
2
3
  export type BaseBadgeProps = Omit<BadgeProps, 'children'> & {
3
4
  /** The color of the badge background. */
@@ -5,5 +6,9 @@ export type BaseBadgeProps = Omit<BadgeProps, 'children'> & {
5
6
  className?: string;
6
7
  /** The child element to display in the badge: string for NumericBadge and svg for IconBadge). */
7
8
  children?: React.ReactElement;
9
+ 'data-bp-animated'?: 'true' | 'false';
10
+ 'data-bp-animation-generation'?: number;
11
+ 'data-state'?: 'idle' | 'playing';
12
+ onAnimationEnd?: AnimationEventHandler<HTMLDivElement>;
8
13
  };
9
- export declare const BaseBadge: ({ backgroundColor, className, children, size, offset }: BaseBadgeProps) => import("react/jsx-runtime").JSX.Element;
14
+ export declare const BaseBadge: ({ backgroundColor, className, children, size, offset, "data-bp-animated": dataBpAnimated, "data-bp-animation-generation": dataBpAnimationGeneration, "data-state": dataState, onAnimationEnd, }: BaseBadgeProps) => import("react/jsx-runtime").JSX.Element;
@@ -8,11 +8,19 @@ const BaseBadge = ({
8
8
  className,
9
9
  children,
10
10
  size,
11
- offset
11
+ offset,
12
+ 'data-bp-animated': dataBpAnimated,
13
+ 'data-bp-animation-generation': dataBpAnimationGeneration,
14
+ 'data-state': dataState,
15
+ onAnimationEnd
12
16
  }) => {
13
17
  const offsetStyle = mapValues(offset, value => Number.isInteger(value) ? `${value}px` : `0`);
14
18
  return jsx("div", {
15
19
  className: clsx(className, styles.badge, styles[`${size}SizeBadge`]),
20
+ "data-bp-animated": dataBpAnimated,
21
+ "data-bp-animation-generation": dataBpAnimationGeneration,
22
+ "data-state": dataState,
23
+ onAnimationEnd: onAnimationEnd,
16
24
  style: {
17
25
  backgroundColor,
18
26
  ...offsetStyle
@@ -1,4 +1,4 @@
1
1
  import '../index.css';
2
- var styles = {"badgeContainer":"bp_base_badge_module_badgeContainer--cc070","badge":"bp_base_badge_module_badge--cc070","smallSizeBadge":"bp_base_badge_module_smallSizeBadge--cc070","mediumSizeBadge":"bp_base_badge_module_mediumSizeBadge--cc070","largeSizeBadge":"bp_base_badge_module_largeSizeBadge--cc070","xlargeSizeBadge":"bp_base_badge_module_xlargeSizeBadge--cc070","xxlargeSizeBadge":"bp_base_badge_module_xxlargeSizeBadge--cc070","iconBadge":"bp_base_badge_module_iconBadge--cc070","interactiveWrapper":"bp_base_badge_module_interactiveWrapper--cc070","statusBadge":"bp_base_badge_module_statusBadge--cc070","numericBadge":"bp_base_badge_module_numericBadge--cc070","numericBadgeSingleDigit":"bp_base_badge_module_numericBadgeSingleDigit--cc070","numericBadgeMoreDigits":"bp_base_badge_module_numericBadgeMoreDigits--cc070"};
2
+ var styles = {"badgeContainer":"bp_base_badge_module_badgeContainer--44c64","badge":"bp_base_badge_module_badge--44c64","smallSizeBadge":"bp_base_badge_module_smallSizeBadge--44c64","mediumSizeBadge":"bp_base_badge_module_mediumSizeBadge--44c64","largeSizeBadge":"bp_base_badge_module_largeSizeBadge--44c64","xlargeSizeBadge":"bp_base_badge_module_xlargeSizeBadge--44c64","xxlargeSizeBadge":"bp_base_badge_module_xxlargeSizeBadge--44c64","iconBadge":"bp_base_badge_module_iconBadge--44c64","interactiveWrapper":"bp_base_badge_module_interactiveWrapper--44c64","statusBadge":"bp_base_badge_module_statusBadge--44c64","numericBadge":"bp_base_badge_module_numericBadge--44c64","bpNumericBadgePopIn":"bp_base_badge_module_bpNumericBadgePopIn--44c64","numericBadgeSingleDigit":"bp_base_badge_module_numericBadgeSingleDigit--44c64","numericBadgeMoreDigits":"bp_base_badge_module_numericBadgeMoreDigits--44c64"};
3
3
 
4
4
  export { styles as default };
@@ -1,8 +1,12 @@
1
1
  import { jsxs, jsx } from 'react/jsx-runtime';
2
2
  import { WatermelonRed110 } from '@box/blueprint-web-assets/tokens/tokens';
3
3
  import clsx from 'clsx';
4
- import { forwardRef, Children, cloneElement } from 'react';
4
+ import { forwardRef, useState, useCallback, Children, cloneElement } from 'react';
5
+ import '../blueprint-configuration-context/blueprint-configuration-context.js';
6
+ import '../blueprint-configuration-context/consts.js';
7
+ import { useBlueprintConfiguration } from '../blueprint-configuration-context/useBlueprintConfiguration.js';
5
8
  import { Text } from '../text/text.js';
9
+ import { useOneShotAnimation, ONE_SHOT_ANIMATION_GENERATION_ATTRIBUTE } from '../utils/use-one-shot-animation.js';
6
10
  import { useUniqueId } from '../utils/useUniqueId.js';
7
11
  import { BaseBadge } from './base-badge.js';
8
12
  import styles from './base-badge.module.js';
@@ -14,6 +18,32 @@ const NumericBadge = /*#__PURE__*/forwardRef((props, forwardedRef) => {
14
18
  offset,
15
19
  render
16
20
  } = props;
21
+ const {
22
+ componentsWithAnimationEnabled
23
+ } = useBlueprintConfiguration();
24
+ const isAnimationEnabled = componentsWithAnimationEnabled.includes('NumericBadge');
25
+ const {
26
+ animationState,
27
+ animationGeneration,
28
+ onAnimationEnd
29
+ } = useOneShotAnimation(value, isAnimationEnabled);
30
+ // First paint must play enter: the hook's initial trigger is idle and does not
31
+ // auto-play on mount. Flip to entered on animationend so idle does not loop.
32
+ const [hasEntered, setHasEntered] = useState(false);
33
+ const handleAnimationEnd = useCallback(event => {
34
+ if (event.target !== event.currentTarget) {
35
+ return;
36
+ }
37
+ // A completion from the previous keyed node must not finish this generation.
38
+ if (event.currentTarget.getAttribute(ONE_SHOT_ANIMATION_GENERATION_ATTRIBUTE) !== String(animationGeneration)) {
39
+ return;
40
+ }
41
+ if (!hasEntered) {
42
+ setHasEntered(true);
43
+ }
44
+ onAnimationEnd(event);
45
+ }, [animationGeneration, hasEntered, onAnimationEnd]);
46
+ const animationDataState = isAnimationEnabled && (!hasEntered || animationState === 'playing') ? 'playing' : 'idle';
17
47
  const valueId = useUniqueId('numeric-badge-value');
18
48
  // In the case of a deeply nested child (e.g., the child is wrapped by a tooltip), the render prop must be passed instead of children to correctly apply aria-describedby.
19
49
  let badgeChild;
@@ -36,7 +66,11 @@ const NumericBadge = /*#__PURE__*/forwardRef((props, forwardedRef) => {
36
66
  [styles.numericBadgeMoreDigits]: value.length > 1,
37
67
  [styles.numericBadgeSingleDigit]: value.length === 1
38
68
  }),
69
+ "data-bp-animated": isAnimationEnabled ? 'true' : 'false',
70
+ "data-bp-animation-generation": animationGeneration,
71
+ "data-state": animationDataState,
39
72
  offset: offset,
73
+ onAnimationEnd: handleAnimationEnd,
40
74
  size: "medium",
41
75
  children: jsx(Text, {
42
76
  as: "span",
@@ -45,7 +79,7 @@ const NumericBadge = /*#__PURE__*/forwardRef((props, forwardedRef) => {
45
79
  variant: "labelBold",
46
80
  children: value
47
81
  })
48
- })]
82
+ }, animationGeneration)]
49
83
  });
50
84
  });
51
85