@box/blueprint-web 16.12.1 → 16.13.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.
@@ -5,15 +5,20 @@ import { bpSize030, bpIconIconOnLight } from '@box/blueprint-web-assets/tokens/t
5
5
  import * as ScrollArea from '@radix-ui/react-scroll-area';
6
6
  import * as SelectPrimitive from '@radix-ui/react-select';
7
7
  import clsx from 'clsx';
8
- import { forwardRef, useCallback, useLayoutEffect } from 'react';
8
+ import { forwardRef, useEffect, useCallback, useLayoutEffect } from 'react';
9
+ import '../blueprint-configuration-context/blueprint-configuration-context.js';
10
+ import '../blueprint-configuration-context/consts.js';
11
+ import { useBlueprintConfiguration } from '../blueprint-configuration-context/useBlueprintConfiguration.js';
9
12
  import { useBlueprintModernization } from '../blueprint-modernization-context/useBlueprintModernization.js';
10
13
  import { InlineError } from '../primitives/inline-error/inline-error.js';
11
14
  import { Text } from '../text/text.js';
12
15
  import { useLabelable } from '../util-components/labelable/useLabelable.js';
16
+ import { composeEventHandlers } from '../utils/composeEventHandlers.js';
13
17
  import { useControllableState } from '../utils/useControllableState.js';
14
18
  import { useUniqueId } from '../utils/useUniqueId.js';
15
19
  import { SelectProvider, useSelectContext } from './select-context.js';
16
20
  import styles from './select.module.js';
21
+ import { useSelectExitAnimation, handleSelectContentExitAnimationEnd } from './utils/use-select-exit-animation.js';
17
22
 
18
23
  const Root = /*#__PURE__*/forwardRef((props, forwardedRef) => {
19
24
  const {
@@ -57,24 +62,44 @@ const Root = /*#__PURE__*/forwardRef((props, forwardedRef) => {
57
62
  const {
58
63
  enableModernizedComponents
59
64
  } = useBlueprintModernization();
65
+ const {
66
+ componentsWithAnimationEnabled
67
+ } = useBlueprintConfiguration();
68
+ const isAnimationEnabled = componentsWithAnimationEnabled.includes('Select');
60
69
  const hasError = !!error && !disabled;
61
- // Restrict consumers from opening options menu when Root is disabled
62
- const isSelectDefaultOpen = defaultOpen && !disabled;
63
- const isSelectOpen = open && !disabled;
64
- const handleOnChange = isOpen => {
65
- if (!readonly) {
66
- onOpenChange?.(isOpen);
70
+ const [openState, setOpenState] = useControllableState({
71
+ prop: open,
72
+ defaultProp: defaultOpen,
73
+ onChange: isOpen => {
74
+ if (!readonly) {
75
+ onOpenChange?.(isOpen);
76
+ }
67
77
  }
68
- };
78
+ });
79
+ const {
80
+ radixOpen,
81
+ isExiting,
82
+ hasExitStyles,
83
+ handleOpenChange,
84
+ completeExit
85
+ } = useSelectExitAnimation({
86
+ open: Boolean(openState),
87
+ setOpen: setOpenState,
88
+ isAnimationEnabled
89
+ });
90
+ useEffect(() => {
91
+ if (disabled || readonly) {
92
+ setOpenState(prev => prev ? false : prev);
93
+ }
94
+ }, [disabled, readonly, setOpenState]);
69
95
  const selectProps = {
70
96
  name,
71
97
  disabled,
72
98
  required,
73
99
  value,
74
- defaultOpen: !readonly && isSelectDefaultOpen,
75
- open: !readonly && isSelectOpen,
100
+ open: !readonly && !disabled && radixOpen,
76
101
  onValueChange,
77
- onOpenChange: handleOnChange
102
+ onOpenChange: handleOpenChange
78
103
  };
79
104
  const Label = useLabelable(label, selectId, required);
80
105
  const inlineErrorId = useUniqueId('inline-error-');
@@ -104,11 +129,16 @@ const Root = /*#__PURE__*/forwardRef((props, forwardedRef) => {
104
129
  className: clsx(styles.container, {
105
130
  [styles.disabled]: disabled
106
131
  }, className),
132
+ "data-bp-animated": isAnimationEnabled ? 'true' : 'false',
107
133
  "data-modern": enableModernizedComponents ? 'true' : 'false',
108
134
  children: [jsx(Label, {
109
135
  className: styles.label,
110
136
  hideLabel: hideLabel
111
137
  }), jsx(SelectProvider, {
138
+ completeExit: completeExit,
139
+ hasExitStyles: hasExitStyles,
140
+ isAnimationEnabled: isAnimationEnabled,
141
+ isExiting: isExiting,
112
142
  truncateText: truncateText,
113
143
  children: jsxs(SelectPrimitive.Root, {
114
144
  ...selectProps,
@@ -150,7 +180,9 @@ const Trigger = /*#__PURE__*/forwardRef((props, forwardedRef) => {
150
180
  ...triggerProps
151
181
  } = props;
152
182
  const {
153
- getLeftElement
183
+ getLeftElement,
184
+ hasExitStyles,
185
+ isAnimationEnabled
154
186
  } = useSelectContext();
155
187
  const selectedLeftElement = value ? getLeftElement(value) : null;
156
188
  const shouldShowPlaceholderLeftElement = !value && placeholderLeftElement;
@@ -166,6 +198,9 @@ const Trigger = /*#__PURE__*/forwardRef((props, forwardedRef) => {
166
198
  [styles.error]: hasError,
167
199
  [styles.readonly]: readonly
168
200
  }),
201
+ ...(isAnimationEnabled && hasExitStyles && {
202
+ 'data-bp-exiting': 'true'
203
+ }),
169
204
  id: selectId,
170
205
  children: [jsxs("span", {
171
206
  className: styles.triggerValue,
@@ -189,21 +224,34 @@ const Content = /*#__PURE__*/forwardRef((props, forwardedRef) => {
189
224
  children,
190
225
  className,
191
226
  container,
227
+ onAnimationEnd: onAnimationEndProp,
192
228
  ...contentProps
193
229
  } = props;
194
230
  const {
195
231
  enableModernizedComponents
196
232
  } = useBlueprintModernization();
197
233
  const {
234
+ completeExit,
235
+ hasExitStyles,
236
+ isAnimationEnabled,
237
+ isExiting,
198
238
  truncateText
199
239
  } = useSelectContext();
240
+ const handleExitAnimationEvent = useCallback(event => {
241
+ handleSelectContentExitAnimationEnd(event, isExiting, completeExit);
242
+ }, [completeExit, isExiting]);
200
243
  return jsx(SelectPrimitive.Portal, {
201
244
  container: container,
202
245
  children: jsx(SelectPrimitive.Content, {
203
246
  ...contentProps,
204
247
  ref: forwardedRef,
205
248
  className: clsx(styles.content, className),
249
+ "data-bp-animated": isAnimationEnabled ? 'true' : 'false',
250
+ "data-bp-exiting": hasExitStyles ? 'true' : 'false',
206
251
  "data-modern": enableModernizedComponents ? 'true' : 'false',
252
+ onAnimationEnd: composeEventHandlers(onAnimationEndProp, handleExitAnimationEvent, {
253
+ checkForDefaultPrevented: false
254
+ }),
207
255
  position: "popper",
208
256
  sideOffset: 8,
209
257
  children: jsx(ScrollArea.Root, {
@@ -1,4 +1,4 @@
1
1
  import '../index.css';
2
- var styles = {"container":"bp_select_module_container--a177a","label":"bp_select_module_label--a177a","triggerBtn":"bp_select_module_triggerBtn--a177a","ellipsis":"bp_select_module_ellipsis--a177a","disabled":"bp_select_module_disabled--a177a","triggerValue":"bp_select_module_triggerValue--a177a","triggerLeftElement":"bp_select_module_triggerLeftElement--a177a","readonly":"bp_select_module_readonly--a177a","error":"bp_select_module_error--a177a","iconWrapper":"bp_select_module_iconWrapper--a177a","iconCaret":"bp_select_module_iconCaret--a177a","iconPencilCrossed":"bp_select_module_iconPencilCrossed--a177a","content":"bp_select_module_content--a177a","viewport":"bp_select_module_viewport--a177a","option":"bp_select_module_option--a177a","secondaryText":"bp_select_module_secondaryText--a177a","textWrapper":"bp_select_module_textWrapper--a177a","leftElement":"bp_select_module_leftElement--a177a","hasLeftElement":"bp_select_module_hasLeftElement--a177a","indicator":"bp_select_module_indicator--a177a","separator":"bp_select_module_separator--a177a","withTruncatedText":"bp_select_module_withTruncatedText--a177a"};
2
+ var styles = {"container":"bp_select_module_container--80de0","label":"bp_select_module_label--80de0","triggerBtn":"bp_select_module_triggerBtn--80de0","ellipsis":"bp_select_module_ellipsis--80de0","disabled":"bp_select_module_disabled--80de0","triggerValue":"bp_select_module_triggerValue--80de0","triggerLeftElement":"bp_select_module_triggerLeftElement--80de0","readonly":"bp_select_module_readonly--80de0","error":"bp_select_module_error--80de0","iconWrapper":"bp_select_module_iconWrapper--80de0","iconCaret":"bp_select_module_iconCaret--80de0","iconPencilCrossed":"bp_select_module_iconPencilCrossed--80de0","content":"bp_select_module_content--80de0","viewport":"bp_select_module_viewport--80de0","option":"bp_select_module_option--80de0","secondaryText":"bp_select_module_secondaryText--80de0","textWrapper":"bp_select_module_textWrapper--80de0","leftElement":"bp_select_module_leftElement--80de0","hasLeftElement":"bp_select_module_hasLeftElement--80de0","indicator":"bp_select_module_indicator--80de0","separator":"bp_select_module_separator--80de0","withTruncatedText":"bp_select_module_withTruncatedText--80de0"};
3
3
 
4
4
  export { styles as default };
@@ -120,6 +120,10 @@ export interface SelectContentProps extends React.ComponentPropsWithRef<'div'> {
120
120
  * Can be prevented.
121
121
  */
122
122
  onEscapeKeyDown?: RadixSelectContentProps['onEscapeKeyDown'];
123
+ /**
124
+ * Event handler called when a CSS animation on the content ends.
125
+ */
126
+ onAnimationEnd?: React.AnimationEventHandler<HTMLDivElement>;
123
127
  }
124
128
  export interface SelectOptionProps extends React.ComponentPropsWithRef<'div'> {
125
129
  /**
@@ -0,0 +1,20 @@
1
+ import { type AnimationEvent, type Dispatch, type SetStateAction } from 'react';
2
+ type SetOpen = Dispatch<SetStateAction<boolean | undefined>>;
3
+ /**
4
+ * Deferred close for Select menu exit animations on Radix Select 2.0.x, which unmounts
5
+ * content immediately without `@radix-ui/react-presence`. Keeps Radix `open` true while
6
+ * exit CSS runs, then commits the close on `animationend`.
7
+ */
8
+ export declare function useSelectExitAnimation({ open, setOpen, isAnimationEnabled, }: {
9
+ open: boolean;
10
+ setOpen: SetOpen;
11
+ isAnimationEnabled: boolean;
12
+ }): {
13
+ radixOpen: boolean;
14
+ isExiting: boolean;
15
+ hasExitStyles: boolean;
16
+ handleOpenChange: (nextOpen: boolean) => void;
17
+ completeExit: () => void;
18
+ };
19
+ export declare function handleSelectContentExitAnimationEnd(event: AnimationEvent<HTMLDivElement>, isExiting: boolean, completeExit: () => void): void;
20
+ export {};
@@ -0,0 +1,153 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { bpDurationShort } from '@box/blueprint-web-assets/tokens/tokens';
3
+ import { noop } from '../../utils/noop.js';
4
+
5
+ const completeExitNoop = noop;
6
+ const EXIT_DURATION_MS = Number.parseFloat(bpDurationShort);
7
+ const SELECT_EXIT_ANIMATION_FALLBACK_MS = EXIT_DURATION_MS * 3;
8
+ /**
9
+ * Deferred close for Select menu exit animations on Radix Select 2.0.x, which unmounts
10
+ * content immediately without `@radix-ui/react-presence`. Keeps Radix `open` true while
11
+ * exit CSS runs, then commits the close on `animationend`.
12
+ */
13
+ function useSelectExitAnimation({
14
+ open,
15
+ setOpen,
16
+ isAnimationEnabled
17
+ }) {
18
+ const [isExiting, setIsExiting] = useState(false);
19
+ // Keep exit styles applied through the final unmount frame to avoid a visible flash.
20
+ const [hasExitStyles, setHasExitStyles] = useState(false);
21
+ const exitFallbackTimerRef = useRef(null);
22
+ const exitCommitRafRef = useRef(null);
23
+ const isExitingRef = useRef(isExiting);
24
+ isExitingRef.current = isExiting;
25
+ const clearExitFallbackTimer = useCallback(() => {
26
+ if (exitFallbackTimerRef.current !== null) {
27
+ clearTimeout(exitFallbackTimerRef.current);
28
+ exitFallbackTimerRef.current = null;
29
+ }
30
+ }, []);
31
+ const cancelExitCommitRaf = useCallback(() => {
32
+ if (exitCommitRafRef.current !== null) {
33
+ cancelAnimationFrame(exitCommitRafRef.current);
34
+ exitCommitRafRef.current = null;
35
+ return true;
36
+ }
37
+ return false;
38
+ }, []);
39
+ const clearExitState = useCallback(() => {
40
+ clearExitFallbackTimer();
41
+ cancelExitCommitRaf();
42
+ isExitingRef.current = false;
43
+ setIsExiting(false);
44
+ setHasExitStyles(false);
45
+ }, [cancelExitCommitRaf, clearExitFallbackTimer]);
46
+ useEffect(() => {
47
+ if (!isAnimationEnabled) {
48
+ const wasExiting = isExitingRef.current;
49
+ const hadPendingCommit = exitCommitRafRef.current !== null;
50
+ clearExitState();
51
+ if (wasExiting || hadPendingCommit) {
52
+ setOpen(false);
53
+ }
54
+ }
55
+ }, [clearExitState, isAnimationEnabled, setOpen]);
56
+ useEffect(() => {
57
+ if (!open && isExiting) {
58
+ clearExitState();
59
+ }
60
+ }, [clearExitState, isExiting, open]);
61
+ useEffect(() => () => {
62
+ clearExitFallbackTimer();
63
+ cancelExitCommitRaf();
64
+ }, [cancelExitCommitRaf, clearExitFallbackTimer]);
65
+ const handleOpenChange = useCallback(nextOpen => {
66
+ if (!isAnimationEnabled) {
67
+ setOpen(nextOpen);
68
+ return;
69
+ }
70
+ if (nextOpen) {
71
+ clearExitState();
72
+ setOpen(true);
73
+ return;
74
+ }
75
+ if (isExiting) {
76
+ return;
77
+ }
78
+ if (open) {
79
+ setIsExiting(true);
80
+ setHasExitStyles(true);
81
+ return;
82
+ }
83
+ setIsExiting(false);
84
+ setHasExitStyles(false);
85
+ setOpen(false);
86
+ }, [clearExitState, isAnimationEnabled, isExiting, open, setOpen]);
87
+ const completeExit = useCallback(() => {
88
+ if (!isAnimationEnabled || !isExitingRef.current) {
89
+ return;
90
+ }
91
+ clearExitFallbackTimer();
92
+ cancelExitCommitRaf();
93
+ isExitingRef.current = false;
94
+ setIsExiting(false);
95
+ // Commit close on the next frame so exit styles (opacity/transform) paint before Radix unmounts.
96
+ exitCommitRafRef.current = requestAnimationFrame(() => {
97
+ exitCommitRafRef.current = null;
98
+ setOpen(false);
99
+ setHasExitStyles(false);
100
+ });
101
+ }, [cancelExitCommitRaf, clearExitFallbackTimer, isAnimationEnabled, setOpen]);
102
+ const completeExitRef = useRef(completeExit);
103
+ completeExitRef.current = completeExit;
104
+ useEffect(() => {
105
+ if (!isAnimationEnabled || !isExiting) {
106
+ return undefined;
107
+ }
108
+ clearExitFallbackTimer();
109
+ exitFallbackTimerRef.current = setTimeout(() => {
110
+ exitFallbackTimerRef.current = null;
111
+ completeExitRef.current();
112
+ }, SELECT_EXIT_ANIMATION_FALLBACK_MS);
113
+ return clearExitFallbackTimer;
114
+ }, [clearExitFallbackTimer, isAnimationEnabled, isExiting]);
115
+ if (!isAnimationEnabled) {
116
+ return {
117
+ radixOpen: open,
118
+ isExiting: false,
119
+ hasExitStyles: false,
120
+ handleOpenChange: setOpen,
121
+ completeExit: completeExitNoop
122
+ };
123
+ }
124
+ return {
125
+ radixOpen: isExiting ? true : open,
126
+ isExiting,
127
+ hasExitStyles,
128
+ handleOpenChange,
129
+ completeExit
130
+ };
131
+ }
132
+ function handleSelectContentExitAnimationEnd(event, isExiting, completeExit) {
133
+ if (event.target !== event.currentTarget || !isExiting || event.type === 'animationcancel') {
134
+ return;
135
+ }
136
+ const {
137
+ getAnimations
138
+ } = event.currentTarget;
139
+ if (!getAnimations) {
140
+ return;
141
+ }
142
+ const animations = getAnimations.call(event.currentTarget);
143
+ if (animations.length === 0) {
144
+ return;
145
+ }
146
+ const hasRunningAnimations = animations.some(animation => animation.playState !== 'finished');
147
+ if (hasRunningAnimations) {
148
+ return;
149
+ }
150
+ completeExit();
151
+ }
152
+
153
+ export { handleSelectContentExitAnimationEnd, useSelectExitAnimation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@box/blueprint-web",
3
- "version": "16.12.1",
3
+ "version": "16.13.0",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "@ariakit/react": "0.4.21",
52
52
  "@ariakit/react-core": "0.4.21",
53
- "@box/blueprint-web-assets": "^5.5.8",
53
+ "@box/blueprint-web-assets": "^5.5.9",
54
54
  "@internationalized/date": "^3.12.0",
55
55
  "@radix-ui/react-accordion": "1.1.2",
56
56
  "@radix-ui/react-checkbox": "1.0.4",
@@ -79,7 +79,7 @@
79
79
  "type-fest": "^3.2.0"
80
80
  },
81
81
  "devDependencies": {
82
- "@box/storybook-utils": "^1.1.32",
82
+ "@box/storybook-utils": "^1.1.33",
83
83
  "@figma/code-connect": "1.4.4",
84
84
  "@types/react": "^18.0.0",
85
85
  "@types/react-dom": "^18.0.0",