@box/blueprint-web 12.135.1 → 12.136.1

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.
@@ -1,10 +1,11 @@
1
1
  import noop from 'lodash/noop';
2
- import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { useState, useRef, useCallback, useLayoutEffect } from 'react';
3
3
 
4
- const getInitialState = (crumbCount, isTruncationRequiredBasedOnCrumbCount) => ({
5
- isTruncationRequired: isTruncationRequiredBasedOnCrumbCount,
6
- visibleCrumbCount: isTruncationRequiredBasedOnCrumbCount ? 3 : crumbCount
7
- });
4
+ const MAX_VISIBLE_CRUMBS = 3;
5
+ /**
6
+ * Calculates how many crumbs (from the end) can fit within the available width.
7
+ * Returns at least 1 to ensure we always show the current page.
8
+ */
8
9
  const calculateVisibleCrumbCount = (crumbWidths, availableWidth, gap, maxVisible) => {
9
10
  let crumbWidthWithoutGaps = 0;
10
11
  let visibleCount = 0;
@@ -20,15 +21,21 @@ const calculateVisibleCrumbCount = (crumbWidths, availableWidth, gap, maxVisible
20
21
  }
21
22
  return Math.max(1, visibleCount);
22
23
  };
23
- const measureCrumbWidths = (children, totalCrumbCount) => {
24
- // The first child is the icon button when we always truncate
25
- const crumbs = children.slice(1);
26
- const widths = [];
27
- const startIndex = totalCrumbCount - crumbs.length;
28
- crumbs.forEach((crumb, i) => {
29
- widths[startIndex + i] = crumb.offsetWidth;
24
+ /**
25
+ * Measures the width of each crumb element.
26
+ *
27
+ * For the last crumb (no separator), we measure just the text element.
28
+ * For other crumbs, we measure the full <li> to include the separator.
29
+ */
30
+ const measureCrumbWidths = (crumbElements, isLastCrumbIncluded) => {
31
+ return crumbElements.map((element, index) => {
32
+ const isLastCrumb = isLastCrumbIncluded && index === crumbElements.length - 1;
33
+ if (isLastCrumb) {
34
+ const textElement = element.firstElementChild;
35
+ return textElement?.offsetWidth ?? element.offsetWidth;
36
+ }
37
+ return element.offsetWidth;
30
38
  });
31
- return widths;
32
39
  };
33
40
  /**
34
41
  * Calculate total width of crumbs including gaps
@@ -39,97 +46,50 @@ const calculateTotalWidth = (widths, gap) => {
39
46
  return totalCrumbsWidth + totalGapWidth;
40
47
  };
41
48
  /**
42
- * Handle >3 crumbs case: Always truncate, show icon button + up to 3 crumbs
49
+ * Determines the best truncation state using a cascading fallback strategy.
50
+ *
51
+ * Priority order (from most preferred to last resort):
52
+ * 1. Show all crumbs without truncation or ellipsis
53
+ * 2. Show icon + fewer crumbs without ellipsis
54
+ * 3. Show icon + 1 crumb with ellipsis (last resort)
43
55
  */
44
- const handleAlwaysTruncate = (children, context) => {
45
- const {
46
- containerWidth,
47
- gap,
48
- iconButtonWidth,
49
- crumbCount,
50
- storedCrumbWidths
51
- } = context;
52
- // If we don't have stored crumb widths yet, measure them
53
- const crumbWidths = storedCrumbWidths.length < crumbCount ? measureCrumbWidths(children, crumbCount) : storedCrumbWidths;
54
- // Icon button not yet rendered - use conservative fallback
55
- if (iconButtonWidth === 0) {
56
+ const handleTruncationState = (crumbWidths, containerWidth, iconButtonWidth, gap) => {
57
+ const crumbCount = crumbWidths.length;
58
+ // Edge case: no crumbs
59
+ if (crumbCount === 0) {
56
60
  return {
57
- state: {
58
- isTruncationRequired: true,
59
- visibleCrumbCount: 1
60
- },
61
- crumbWidths
61
+ isTruncationRequired: false,
62
+ ellipsizeLastCrumb: false,
63
+ visibleCrumbCount: 0
62
64
  };
63
65
  }
64
- const availableWidth = containerWidth - iconButtonWidth - gap;
65
- const visibleCount = calculateVisibleCrumbCount(crumbWidths, availableWidth, gap, 3);
66
- return {
67
- state: {
68
- isTruncationRequired: true,
69
- visibleCrumbCount: visibleCount
70
- },
71
- crumbWidths
72
- };
73
- };
74
- /**
75
- * Get crumb widths for total width calculation
76
- * Factored out into its own function to reduce cognitive complexity
77
- */
78
- const getCrumbWidths = (children, storedWidths, crumbCount, isCurrentlyTruncated) => {
79
- if (storedWidths.length === crumbCount) {
80
- return storedWidths; // Already have all widths
81
- }
82
- // The first child could be the icon button, so we don't want to measure it as part of the crumbs
83
- const crumbElements = isCurrentlyTruncated ? children.slice(1) : children;
84
- return crumbElements.map(crumbElement => crumbElement.offsetWidth);
85
- };
86
- /**
87
- * Handle ≤3 crumbs case: Render all first, detect overflow, truncate if needed
88
- */
89
- const handleConditionalTruncate = (children, context, isCurrentlyTruncated) => {
90
- const {
91
- containerWidth,
92
- gap,
93
- iconButtonWidth,
94
- crumbCount,
95
- storedCrumbWidths
96
- } = context;
97
- // Get crumb widths when all crumbs are visible (not truncated)
98
- const crumbWidths = !isCurrentlyTruncated && children.length === crumbCount ? children.map(crumbElement => crumbElement.offsetWidth) // fresh measurement
99
- : storedCrumbWidths; // Reuse stored
100
- // Calculate total width to check for overflow
101
- const widthsForCalculation = getCrumbWidths(children, crumbWidths, crumbCount, isCurrentlyTruncated);
102
- const totalWidth = calculateTotalWidth(widthsForCalculation, gap);
103
- const areCrumbsOverflowingWithoutTruncation = totalWidth > containerWidth;
104
- // No overflow - show all crumbs
105
- if (!areCrumbsOverflowingWithoutTruncation) {
66
+ const totalWidthAllCrumbs = calculateTotalWidth(crumbWidths, gap);
67
+ const availableWidthWithIcon = Math.max(0, containerWidth - iconButtonWidth - gap);
68
+ // Check if all crumbs fit without truncation (up to MAX_VISIBLE_CRUMBS)
69
+ if (crumbCount <= MAX_VISIBLE_CRUMBS && totalWidthAllCrumbs <= containerWidth) {
106
70
  return {
107
- state: {
108
- isTruncationRequired: false,
109
- visibleCrumbCount: crumbCount
110
- },
111
- crumbWidths
71
+ isTruncationRequired: false,
72
+ ellipsizeLastCrumb: false,
73
+ visibleCrumbCount: crumbCount
112
74
  };
113
75
  }
114
- // If we are overflowing, now calculate how many crumbs fit with icon button
115
- // Icon button not yet rendered - use conservative fallback
116
- if (iconButtonWidth === 0) {
76
+ // Single crumb: never show icon, just ellipsize if needed
77
+ if (crumbCount === 1) {
117
78
  return {
118
- state: {
119
- isTruncationRequired: true,
120
- visibleCrumbCount: 1
121
- },
122
- crumbWidths
79
+ isTruncationRequired: false,
80
+ visibleCrumbCount: 1,
81
+ ellipsizeLastCrumb: totalWidthAllCrumbs > containerWidth
123
82
  };
124
83
  }
125
- const availableWidth = containerWidth - iconButtonWidth - gap;
126
- const visibleCount = calculateVisibleCrumbCount(crumbWidths, availableWidth, gap, crumbCount);
84
+ // Calculate how many crumbs fit with the icon button
85
+ const visibleCrumbCount = calculateVisibleCrumbCount(crumbWidths, availableWidthWithIcon, gap, MAX_VISIBLE_CRUMBS);
86
+ // If only 1 crumb fits and it still doesn't fit fully, enable ellipsis
87
+ const lastCrumbWidth = crumbWidths[crumbWidths.length - 1];
88
+ const ellipsizeLastCrumb = visibleCrumbCount === 1 && lastCrumbWidth > availableWidthWithIcon;
127
89
  return {
128
- state: {
129
- isTruncationRequired: true,
130
- visibleCrumbCount: visibleCount
131
- },
132
- crumbWidths
90
+ isTruncationRequired: true,
91
+ visibleCrumbCount,
92
+ ellipsizeLastCrumb
133
93
  };
134
94
  };
135
95
  /**
@@ -141,79 +101,84 @@ const performTruncationCalculation = ({
141
101
  measuredIconButtonWidth,
142
102
  storedCrumbWidths,
143
103
  crumbCount,
144
- isTruncationRequiredBaseOnCrumbLength,
145
104
  isTruncationRequired,
146
105
  setState
147
106
  }) => {
148
107
  const containerWidth = container.clientWidth;
149
- const children = Array.from(container.children);
150
108
  const computedStyle = getComputedStyle(container);
151
109
  const gap = parseFloat(computedStyle.gap) || 0;
110
+ const children = Array.from(container.children);
152
111
  if (iconButtonRef.current) {
153
112
  measuredIconButtonWidth.current = iconButtonRef.current.offsetWidth;
154
113
  }
155
- const context = {
156
- containerWidth,
157
- gap,
158
- iconButtonWidth: measuredIconButtonWidth.current,
159
- crumbCount,
160
- storedCrumbWidths: storedCrumbWidths.current
161
- };
162
- const result = isTruncationRequiredBaseOnCrumbLength ? handleAlwaysTruncate(children, context) : handleConditionalTruncate(children, context, isTruncationRequired);
163
- storedCrumbWidths.current = result.crumbWidths;
164
- setState(result.state);
114
+ // When truncated, first child is icon button, so skip it
115
+ const crumbElements = isTruncationRequired ? children.slice(1) : children;
116
+ if (storedCrumbWidths.current.length < crumbCount && crumbElements.length > 0) {
117
+ const startIndex = crumbCount - crumbElements.length;
118
+ const isLastCrumbIncluded = startIndex + crumbElements.length === crumbCount;
119
+ const measuredWidths = measureCrumbWidths(crumbElements, isLastCrumbIncluded);
120
+ measuredWidths.forEach((width, i) => {
121
+ storedCrumbWidths.current[startIndex + i] = width;
122
+ });
123
+ }
124
+ if (storedCrumbWidths.current.length < crumbCount) {
125
+ return;
126
+ }
127
+ setState(handleTruncationState(storedCrumbWidths.current, containerWidth, measuredIconButtonWidth.current, gap));
165
128
  };
166
129
  /**
167
- * Hook which calculates how many crumbs can be displayed when folder-tree truncation is enabled.
130
+ * Hook that calculates optimal breadcrumb truncation for folder-tree display.
168
131
  *
169
- * ## Truncation Rules
170
- * - **≤3 crumbs**: Render all crumbs first, detect overflow, truncate only if needed
171
- * - **>3 crumbs**: Render truncated state immediately, show icon button + up to 3 crumbs
132
+ * Uses a cascading fallback strategy to show as many crumbs as possible without
133
+ * text ellipsis, falling back to ellipsis only as a last resort.
172
134
  *
173
- * @param containerRef - Ref to the main ol element
174
- * @param crumbs - Array of breadcrumb items
175
- * @param isEnabled - Whether folder-tree truncation is enabled. Can be false when responsive behavior is triggered
176
- * @returns Object containing wouldCrumbsOverflow, visibleCrumbCount, and iconButtonRef
135
+ * @param containerRef - Ref to the breadcrumb list container (ol element)
136
+ * @param crumbs - Array of breadcrumb items to display
137
+ * @param isMobile - Whether the breadcrumb is on a mobile device
177
138
  */
178
- const useFolderTreeTruncation = (containerRef, crumbs, isEnabled) => {
179
- const isTruncationRequiredBaseOnCrumbLength = crumbs.length > 3;
180
- const [state, setState] = useState(() => getInitialState(crumbs.length, isTruncationRequiredBaseOnCrumbLength));
181
- const animationFrameId = useRef(null);
139
+ const useFolderTreeTruncation = (containerRef, crumbs, isMobile) => {
140
+ const crumbCount = crumbs.length;
141
+ // Initial state: for 4+ crumbs, start truncated; otherwise show all
142
+ const initialState = {
143
+ isTruncationRequired: crumbCount > MAX_VISIBLE_CRUMBS,
144
+ ellipsizeLastCrumb: false,
145
+ visibleCrumbCount: Math.min(crumbCount, MAX_VISIBLE_CRUMBS)
146
+ };
147
+ const [state, setState] = useState(initialState);
182
148
  const iconButtonRef = useRef(null);
183
- // Refs are used here to persist values
184
- const measuredIconButtonWidth = useRef(0);
149
+ // Cache for measured values to avoid re-measuring on every resize
185
150
  const storedCrumbWidths = useRef([]);
186
- const prevCrumbsLength = useRef(crumbs.length);
187
- // Clear stored widths when user navigates and the visible crumbs change
188
- if (prevCrumbsLength.current !== crumbs.length) {
151
+ const measuredIconButtonWidth = useRef(0);
152
+ const prevCrumbCount = useRef(crumbCount);
153
+ // Reset cache when crumbs change (user navigated)
154
+ if (prevCrumbCount.current !== crumbCount) {
189
155
  storedCrumbWidths.current = [];
190
- setState(getInitialState(crumbs.length, isTruncationRequiredBaseOnCrumbLength));
191
- prevCrumbsLength.current = crumbs.length;
156
+ measuredIconButtonWidth.current = 0;
157
+ prevCrumbCount.current = crumbCount;
158
+ setState(initialState);
192
159
  }
193
160
  const calculateTruncation = useCallback(() => {
194
161
  const container = containerRef.current;
195
162
  if (!container) {
196
163
  return;
197
164
  }
198
- animationFrameId.current = requestAnimationFrame(() => {
199
- performTruncationCalculation({
200
- container,
201
- iconButtonRef,
202
- measuredIconButtonWidth,
203
- storedCrumbWidths,
204
- crumbCount: crumbs.length,
205
- isTruncationRequiredBaseOnCrumbLength,
206
- isTruncationRequired: state.isTruncationRequired,
207
- setState
208
- });
165
+ performTruncationCalculation({
166
+ container,
167
+ iconButtonRef,
168
+ measuredIconButtonWidth,
169
+ storedCrumbWidths,
170
+ crumbCount,
171
+ isTruncationRequired: state.isTruncationRequired,
172
+ setState
209
173
  });
210
- }, [containerRef, crumbs.length, state.isTruncationRequired, isTruncationRequiredBaseOnCrumbLength]);
211
- useEffect(() => {
174
+ }, [containerRef, crumbCount, state.isTruncationRequired]);
175
+ useLayoutEffect(() => {
212
176
  // Reset state when truncation is disabled (e.g., responsive breakpoint triggers mobile view)
213
- if (!isEnabled) {
177
+ if (isMobile) {
214
178
  setState({
215
179
  isTruncationRequired: false,
216
- visibleCrumbCount: crumbs.length
180
+ ellipsizeLastCrumb: false,
181
+ visibleCrumbCount: crumbCount
217
182
  });
218
183
  measuredIconButtonWidth.current = 0;
219
184
  storedCrumbWidths.current = [];
@@ -230,11 +195,8 @@ const useFolderTreeTruncation = (containerRef, crumbs, isEnabled) => {
230
195
  calculateTruncation();
231
196
  return () => {
232
197
  observer.disconnect();
233
- if (animationFrameId.current !== null) {
234
- cancelAnimationFrame(animationFrameId.current);
235
- }
236
198
  };
237
- }, [containerRef, isEnabled, calculateTruncation, crumbs.length]);
199
+ }, [calculateTruncation, containerRef, crumbCount, isMobile]);
238
200
  return {
239
201
  ...state,
240
202
  iconButtonRef
@@ -204,6 +204,10 @@ export interface ComboboxBaseProps<Multiple extends boolean, FreeInput extends b
204
204
  * Callback used when combobox input/textarea loses focus
205
205
  */
206
206
  onBlur?: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
207
+ /**
208
+ * Callback when text is pasted into the input
209
+ */
210
+ onPaste?: React.ClipboardEventHandler<HTMLInputElement>;
207
211
  /**
208
212
  * aria-label passed to the Combobox clear button. If not provided, the clear button is not shown.
209
213
  */