@box/blueprint-web 12.135.1 → 12.136.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.
@@ -6,5 +6,5 @@ interface BreadcrumbDropdownProps {
6
6
  onPageLinkClick: (id: string) => void;
7
7
  size: 'xsmall' | 'small' | 'medium' | 'large';
8
8
  }
9
- export declare function BreadcrumbDropdown({ crumbsToRender, iconButton, onPageLinkClick, size, listRef, }: BreadcrumbDropdownProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function BreadcrumbDropdown({ crumbsToRender, iconButton, listRef, onPageLinkClick, size, }: BreadcrumbDropdownProps): import("react/jsx-runtime").JSX.Element;
10
10
  export {};
@@ -1,17 +1,17 @@
1
1
  import { jsxs, jsx } from 'react/jsx-runtime';
2
2
  import { useCallback } from 'react';
3
3
  import { PointerRight } from '@box/blueprint-web-assets/icons/Fill';
4
+ import { EllipsizableText } from '../ellipsizable-text/ellipsizable-text.js';
4
5
  import { DropdownMenu } from '../primitives/dropdown-menu/index.js';
5
- import { Text } from '../text/text.js';
6
6
  import { getSeparatorSize } from './utils.js';
7
7
  import styles from './breadcrumb.module.js';
8
8
 
9
9
  function BreadcrumbDropdown({
10
10
  crumbsToRender,
11
11
  iconButton,
12
+ listRef,
12
13
  onPageLinkClick,
13
- size,
14
- listRef
14
+ size
15
15
  }) {
16
16
  const handlePageLinkClick = useCallback(crumbId => () => {
17
17
  onPageLinkClick(crumbId);
@@ -27,8 +27,10 @@ function BreadcrumbDropdown({
27
27
  className: styles.dropdownContent,
28
28
  children: crumbsToRender.map(crumb => jsx(DropdownMenu.Item, {
29
29
  onSelect: handlePageLinkClick(crumb.id),
30
- children: jsx(Text, {
30
+ children: jsx(EllipsizableText, {
31
31
  as: "span",
32
+ lineClamp: 1,
33
+ tooltipSide: "bottom",
32
34
  children: crumb.name
33
35
  })
34
36
  }, crumb.id))
@@ -1,50 +1,82 @@
1
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
+ import noop from 'lodash/noop';
2
3
  import { forwardRef, useRef } from 'react';
3
4
  import { FolderTree, PointerRight } from '@box/blueprint-web-assets/icons/Fill';
4
- import { Ellipsis } from '@box/blueprint-web-assets/icons/Medium';
5
5
  import { Home } from '@box/blueprint-web-assets/icons/MediumFilled';
6
- import noop from 'lodash/noop';
7
- import { IconButton } from '../primitives/icon-button/icon-button.js';
8
6
  import { useBreakpoint, Breakpoint } from '../utils/useBreakpoint.js';
9
- import { BreadcrumbDropdown } from './breadcrumb-dropdown.js';
10
- import { PageLink } from './page-link.js';
7
+ import { EllipsisTruncationView } from './ellipsis-truncation-view.js';
8
+ import { FolderTreeTruncationView } from './folder-tree-truncation-view.js';
9
+ import { FullView } from './full-view.js';
10
+ import { MobileView } from './mobile-view.js';
11
11
  import { useFolderTreeTruncation } from './useFolderTreeTruncation.js';
12
12
  import { getSeparatorSize } from './utils.js';
13
13
  import styles from './breadcrumb.module.js';
14
14
 
15
+ const ELLIPSIS_TRUNCATION_THRESHOLD = 7;
15
16
  const Breadcrumb = /*#__PURE__*/forwardRef((props, forwardedRef) => {
16
17
  const {
17
18
  breadcrumbAriaLabel,
18
19
  crumbs,
19
- truncatedLinksIconAriaLabel,
20
- rootIconAriaLabel,
21
- rootIconVariant,
22
20
  isInteractive = true,
23
21
  isResponsiveEnabled,
22
+ onPageLinkClick = noop,
23
+ rootIconAriaLabel,
24
+ rootIconVariant,
24
25
  size = 'medium',
26
+ truncatedLinksIconAriaLabel,
25
27
  truncationMethod = 'ellipsis',
26
- onPageLinkClick = noop,
27
28
  ...rest
28
29
  } = props;
29
- // Responsive detection: mobile/tablet takes priority over consumer-controlled truncationMethod
30
30
  const breakpoint = useBreakpoint();
31
31
  const isMobile = isResponsiveEnabled || breakpoint <= Breakpoint.Medium;
32
- // If there are more than 7 crumbs, break up crumbs into first link, ellipsis icon button, and current page ancestor
33
- const shouldUseEllipsisTruncation = !isMobile && truncationMethod === 'ellipsis' && crumbs && crumbs.length > 7;
34
- // Get the current page (last crumb) and all ancestors (all crumbs except last)
35
- const currentPage = crumbs[crumbs.length - 1];
36
- const ancestorCrumbs = crumbs.slice(0, -1);
37
- // Folder-tree truncation: detect overflow and show up to 3 visible crumbs
38
32
  const breadcrumbListRef = useRef(null);
39
- const isFolderTreeTruncationEnabled = !isMobile && truncationMethod === 'folder-tree';
40
33
  const {
34
+ ellipsizeLastCrumb,
35
+ iconButtonRef,
41
36
  isTruncationRequired,
42
- visibleCrumbCount,
43
- iconButtonRef
44
- } = useFolderTreeTruncation(breadcrumbListRef, crumbs, isFolderTreeTruncationEnabled);
45
- const shouldUseFolderTreeTruncation = isFolderTreeTruncationEnabled && isTruncationRequired && crumbs;
46
- const visibleCrumbs = shouldUseFolderTreeTruncation ? crumbs.slice(-visibleCrumbCount) : [];
47
- const hiddenCrumbs = shouldUseFolderTreeTruncation ? crumbs.slice(0, -visibleCrumbCount) : [];
37
+ visibleCrumbCount
38
+ } = useFolderTreeTruncation(breadcrumbListRef, crumbs, isMobile);
39
+ if (!crumbs || crumbs.length === 0) {
40
+ return null;
41
+ }
42
+ // Responsive detection: mobile/tablet takes priority over consumer-controlled truncationMethod
43
+ let breadcrumbList;
44
+ if (isMobile) {
45
+ breadcrumbList = jsx(MobileView, {
46
+ crumbs: crumbs,
47
+ isInteractive: isInteractive,
48
+ onPageLinkClick: onPageLinkClick,
49
+ size: size,
50
+ truncatedLinksIconAriaLabel: truncatedLinksIconAriaLabel
51
+ });
52
+ } else if (truncationMethod === 'ellipsis' && crumbs.length > ELLIPSIS_TRUNCATION_THRESHOLD) {
53
+ breadcrumbList = jsx(EllipsisTruncationView, {
54
+ crumbs: crumbs,
55
+ isInteractive: isInteractive,
56
+ onPageLinkClick: onPageLinkClick,
57
+ size: size,
58
+ truncatedLinksIconAriaLabel: truncatedLinksIconAriaLabel
59
+ });
60
+ } else if (truncationMethod === 'folder-tree' && isTruncationRequired) {
61
+ breadcrumbList = jsx(FolderTreeTruncationView, {
62
+ crumbs: crumbs,
63
+ ellipsizeLastCrumb: ellipsizeLastCrumb,
64
+ iconButtonRef: iconButtonRef,
65
+ isInteractive: isInteractive,
66
+ onPageLinkClick: onPageLinkClick,
67
+ size: size,
68
+ truncatedLinksIconAriaLabel: truncatedLinksIconAriaLabel,
69
+ visibleCrumbCount: visibleCrumbCount
70
+ });
71
+ } else {
72
+ breadcrumbList = jsx(FullView, {
73
+ crumbs: crumbs,
74
+ ellipsizeLastCrumb: ellipsizeLastCrumb,
75
+ isInteractive: isInteractive,
76
+ onPageLinkClick: onPageLinkClick,
77
+ size: size
78
+ });
79
+ }
48
80
  return jsx("nav", {
49
81
  ref: forwardedRef,
50
82
  "aria-label": breadcrumbAriaLabel,
@@ -59,84 +91,12 @@ const Breadcrumb = /*#__PURE__*/forwardRef((props, forwardedRef) => {
59
91
  "aria-label": rootIconAriaLabel
60
92
  }) : jsx(FolderTree, {
61
93
  "aria-label": rootIconAriaLabel
62
- }), rootIconVariant && jsx(PointerRight, {
94
+ }), jsx(PointerRight, {
63
95
  height: getSeparatorSize(size),
64
96
  role: "presentation",
65
97
  width: getSeparatorSize(size)
66
98
  })]
67
- }), isMobile && crumbs && currentPage && jsxs(Fragment, {
68
- children: [ancestorCrumbs.length > 0 && jsx(BreadcrumbDropdown, {
69
- crumbsToRender: ancestorCrumbs,
70
- iconButton: jsx(IconButton, {
71
- "aria-label": truncatedLinksIconAriaLabel,
72
- icon: FolderTree,
73
- size: "small"
74
- }),
75
- onPageLinkClick: onPageLinkClick,
76
- size: size
77
- }), jsx(PageLink, {
78
- crumb: currentPage,
79
- isInteractive: isInteractive,
80
- isLast: true,
81
- onPageLinkClick: onPageLinkClick,
82
- size: size
83
- })]
84
- }), shouldUseEllipsisTruncation && jsxs(Fragment, {
85
- children: [jsx(PageLink, {
86
- crumb: crumbs[0],
87
- isInteractive: isInteractive,
88
- isLast: false,
89
- onPageLinkClick: onPageLinkClick,
90
- size: size
91
- }), jsx(BreadcrumbDropdown, {
92
- crumbsToRender: crumbs.slice(1, crumbs.length - 2),
93
- iconButton: jsx(IconButton, {
94
- "aria-label": truncatedLinksIconAriaLabel,
95
- icon: Ellipsis,
96
- size: "small"
97
- }),
98
- onPageLinkClick: onPageLinkClick,
99
- size: size
100
- }), jsx(PageLink, {
101
- crumb: crumbs[crumbs.length - 2],
102
- isInteractive: isInteractive,
103
- isLast: false,
104
- onPageLinkClick: onPageLinkClick,
105
- size: size
106
- }), jsx(PageLink, {
107
- crumb: currentPage,
108
- isInteractive: isInteractive,
109
- isLast: true,
110
- onPageLinkClick: onPageLinkClick,
111
- size: size
112
- })]
113
- }), shouldUseFolderTreeTruncation && jsxs(Fragment, {
114
- children: [jsx(BreadcrumbDropdown, {
115
- crumbsToRender: hiddenCrumbs,
116
- iconButton: jsx(IconButton, {
117
- "aria-label": truncatedLinksIconAriaLabel,
118
- icon: FolderTree,
119
- size: "small"
120
- }),
121
- listRef: iconButtonRef,
122
- onPageLinkClick: onPageLinkClick,
123
- size: size
124
- }), visibleCrumbs.map((crumb, index) => jsx(PageLink, {
125
- crumb: crumb,
126
- isInteractive: isInteractive,
127
- isLast: index === visibleCrumbs.length - 1,
128
- onPageLinkClick: onPageLinkClick,
129
- size: size
130
- }, crumb.id))]
131
- }), !isMobile && !shouldUseEllipsisTruncation && !shouldUseFolderTreeTruncation && crumbs?.map((crumb, index) => {
132
- return jsx(PageLink, {
133
- crumb: crumb,
134
- isInteractive: isInteractive,
135
- isLast: index === crumbs.length - 1,
136
- onPageLinkClick: onPageLinkClick,
137
- size: size
138
- }, crumb.id);
139
- })]
99
+ }), breadcrumbList]
140
100
  })
141
101
  });
142
102
  });
@@ -1,4 +1,4 @@
1
1
  import '../index.css';
2
- var styles = {"container":"bp_breadcrumb_module_container--ee92d","breadcrumb":"bp_breadcrumb_module_breadcrumb--ee92d","pageLink":"bp_breadcrumb_module_pageLink--ee92d","linkWithHover":"bp_breadcrumb_module_linkWithHover--ee92d","dropdownContent":"bp_breadcrumb_module_dropdownContent--ee92d"};
2
+ var styles = {"container":"bp_breadcrumb_module_container--67af7","breadcrumb":"bp_breadcrumb_module_breadcrumb--67af7","pageLink":"bp_breadcrumb_module_pageLink--67af7","linkWithHover":"bp_breadcrumb_module_linkWithHover--67af7","dropdownContent":"bp_breadcrumb_module_dropdownContent--67af7"};
3
3
 
4
4
  export { styles as default };
@@ -0,0 +1,2 @@
1
+ import { type BreadcrumbViewProps } from './types';
2
+ export declare const EllipsisTruncationView: ({ crumbs, isInteractive, onPageLinkClick, size, truncatedLinksIconAriaLabel, }: BreadcrumbViewProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
2
+ import { Ellipsis } from '@box/blueprint-web-assets/icons/Medium';
3
+ import { IconButton } from '../primitives/icon-button/icon-button.js';
4
+ import { BreadcrumbDropdown } from './breadcrumb-dropdown.js';
5
+ import { PageLink } from './page-link.js';
6
+
7
+ const EllipsisTruncationView = ({
8
+ crumbs,
9
+ isInteractive,
10
+ onPageLinkClick,
11
+ size,
12
+ truncatedLinksIconAriaLabel
13
+ }) => {
14
+ const middleCrumbs = crumbs.slice(1, -2);
15
+ const lastTwoCrumbs = crumbs.slice(-2);
16
+ return jsxs(Fragment, {
17
+ children: [jsx(PageLink, {
18
+ crumb: crumbs[0],
19
+ isInteractive: isInteractive,
20
+ isLast: false,
21
+ onPageLinkClick: onPageLinkClick,
22
+ size: size
23
+ }), jsx(BreadcrumbDropdown, {
24
+ crumbsToRender: middleCrumbs,
25
+ iconButton: jsx(IconButton, {
26
+ "aria-label": truncatedLinksIconAriaLabel,
27
+ icon: Ellipsis,
28
+ size: "small"
29
+ }),
30
+ onPageLinkClick: onPageLinkClick,
31
+ size: size
32
+ }), lastTwoCrumbs.map((crumb, index) => jsx(PageLink, {
33
+ crumb: crumb,
34
+ isInteractive: isInteractive,
35
+ isLast: index === lastTwoCrumbs.length - 1,
36
+ onPageLinkClick: onPageLinkClick,
37
+ size: size
38
+ }, crumb.id))]
39
+ });
40
+ };
41
+
42
+ export { EllipsisTruncationView };
@@ -0,0 +1,2 @@
1
+ import { type FolderTreeTruncationViewProps } from './types';
2
+ export declare const FolderTreeTruncationView: ({ crumbs, ellipsizeLastCrumb, iconButtonRef, isInteractive, onPageLinkClick, size, truncatedLinksIconAriaLabel, visibleCrumbCount, }: FolderTreeTruncationViewProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
2
+ import { FolderTree } from '@box/blueprint-web-assets/icons/Fill';
3
+ import { IconButton } from '../primitives/icon-button/icon-button.js';
4
+ import { BreadcrumbDropdown } from './breadcrumb-dropdown.js';
5
+ import { PageLink } from './page-link.js';
6
+
7
+ const FolderTreeTruncationView = ({
8
+ crumbs,
9
+ ellipsizeLastCrumb,
10
+ iconButtonRef,
11
+ isInteractive,
12
+ onPageLinkClick,
13
+ size,
14
+ truncatedLinksIconAriaLabel,
15
+ visibleCrumbCount
16
+ }) => {
17
+ const visibleCrumbs = crumbs.slice(-visibleCrumbCount);
18
+ const hiddenCrumbs = crumbs.slice(0, -visibleCrumbCount);
19
+ return jsxs(Fragment, {
20
+ children: [jsx(BreadcrumbDropdown, {
21
+ crumbsToRender: hiddenCrumbs,
22
+ iconButton: jsx(IconButton, {
23
+ "aria-label": truncatedLinksIconAriaLabel,
24
+ icon: FolderTree,
25
+ size: "small"
26
+ }),
27
+ listRef: iconButtonRef,
28
+ onPageLinkClick: onPageLinkClick,
29
+ size: size
30
+ }), visibleCrumbs.map((crumb, index) => jsx(PageLink, {
31
+ crumb: crumb,
32
+ ellipsizeLastCrumb: ellipsizeLastCrumb,
33
+ isInteractive: isInteractive,
34
+ isLast: index === visibleCrumbs.length - 1,
35
+ onPageLinkClick: onPageLinkClick,
36
+ size: size
37
+ }, crumb.id))]
38
+ });
39
+ };
40
+
41
+ export { FolderTreeTruncationView };
@@ -0,0 +1,2 @@
1
+ import { type BreadcrumbViewProps } from './types';
2
+ export declare const FullView: ({ crumbs, ellipsizeLastCrumb, isInteractive, onPageLinkClick, size, }: Omit<BreadcrumbViewProps, "truncatedLinksIconAriaLabel">) => import("react/jsx-runtime").JSX.Element[];
@@ -0,0 +1,19 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { PageLink } from './page-link.js';
3
+
4
+ const FullView = ({
5
+ crumbs,
6
+ ellipsizeLastCrumb,
7
+ isInteractive,
8
+ onPageLinkClick,
9
+ size
10
+ }) => crumbs.map((crumb, index) => jsx(PageLink, {
11
+ crumb: crumb,
12
+ ellipsizeLastCrumb: ellipsizeLastCrumb,
13
+ isInteractive: isInteractive,
14
+ isLast: index === crumbs.length - 1,
15
+ onPageLinkClick: onPageLinkClick,
16
+ size: size
17
+ }, crumb.id));
18
+
19
+ export { FullView };
@@ -0,0 +1,2 @@
1
+ import { type BreadcrumbViewProps } from './types';
2
+ export declare const MobileView: ({ crumbs, isInteractive, onPageLinkClick, size, truncatedLinksIconAriaLabel, }: BreadcrumbViewProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
2
+ import { FolderTree } from '@box/blueprint-web-assets/icons/Fill';
3
+ import { IconButton } from '../primitives/icon-button/icon-button.js';
4
+ import { BreadcrumbDropdown } from './breadcrumb-dropdown.js';
5
+ import { PageLink } from './page-link.js';
6
+
7
+ const MobileView = ({
8
+ crumbs,
9
+ isInteractive,
10
+ onPageLinkClick,
11
+ size,
12
+ truncatedLinksIconAriaLabel
13
+ }) => {
14
+ const currentPage = crumbs[crumbs.length - 1];
15
+ const hiddenCrumbs = crumbs.slice(0, -1);
16
+ return jsxs(Fragment, {
17
+ children: [hiddenCrumbs.length > 0 && jsx(BreadcrumbDropdown, {
18
+ crumbsToRender: hiddenCrumbs,
19
+ iconButton: jsx(IconButton, {
20
+ "aria-label": truncatedLinksIconAriaLabel,
21
+ icon: FolderTree,
22
+ size: "small"
23
+ }),
24
+ onPageLinkClick: onPageLinkClick,
25
+ size: size
26
+ }), jsx(PageLink, {
27
+ crumb: currentPage,
28
+ ellipsizeLastCrumb: true,
29
+ isInteractive: isInteractive,
30
+ isLast: true,
31
+ onPageLinkClick: onPageLinkClick,
32
+ size: size
33
+ })]
34
+ });
35
+ };
36
+
37
+ export { MobileView };
@@ -1,17 +1,2 @@
1
- import { type Crumb } from './types';
2
- /**
3
- * A page link represents a combination of an optional folder icon, a single crumb, and a separator.
4
- */
5
- export interface PageLinkProps {
6
- /** The crumb to display in the page link. */
7
- crumb: Crumb;
8
- /** Whether the page link is the last crumb in the breadcrumb. */
9
- isLast: boolean;
10
- /** Whether the page link is interactive. */
11
- isInteractive: boolean;
12
- /** The callback to call when the page link is clicked. */
13
- onPageLinkClick: (id: string) => void;
14
- /** The text size of the page link. */
15
- size: 'xsmall' | 'small' | 'medium' | 'large';
16
- }
17
- export declare const PageLink: ({ crumb, isLast, isInteractive, onPageLinkClick, size }: PageLinkProps) => import("react/jsx-runtime").JSX.Element;
1
+ import { type PageLinkProps } from './types';
2
+ export declare const PageLink: ({ crumb, ellipsizeLastCrumb, isInteractive, isLast, onPageLinkClick, size, }: PageLinkProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,25 +1,34 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { useCallback } from 'react';
3
3
  import { PointerRight } from '@box/blueprint-web-assets/icons/Fill';
4
+ import { EllipsizableText } from '../ellipsizable-text/ellipsizable-text.js';
4
5
  import { Link } from '../primitives/link/link.js';
5
6
  import { Text } from '../text/text.js';
6
- import styles from './breadcrumb.module.js';
7
7
  import { getBoldTextVariantFromSize, getTextVariantFromSize, getSeparatorSize } from './utils.js';
8
+ import styles from './breadcrumb.module.js';
8
9
 
9
10
  const PageLink = ({
10
11
  crumb,
11
- isLast,
12
+ ellipsizeLastCrumb = false,
12
13
  isInteractive,
14
+ isLast,
13
15
  onPageLinkClick,
14
16
  size
15
17
  }) => {
16
18
  const handlePageLinkClick = useCallback(() => {
17
19
  onPageLinkClick(crumb.id);
18
- }, [onPageLinkClick, crumb.id]);
20
+ }, [crumb.id, onPageLinkClick]);
19
21
  if (isLast) {
20
22
  return jsx("li", {
21
23
  className: styles.pageLink,
22
- children: jsx(Text, {
24
+ children: ellipsizeLastCrumb ? jsx(EllipsizableText, {
25
+ "aria-current": "page",
26
+ as: "span",
27
+ color: "textOnLightDefault",
28
+ lineClamp: 1,
29
+ variant: getBoldTextVariantFromSize(size),
30
+ children: crumb.name
31
+ }) : jsx(Text, {
23
32
  "aria-current": "page",
24
33
  as: "span",
25
34
  color: "textOnLightDefault",
@@ -23,7 +23,8 @@ export type BreadcrumbProps = {
23
23
  size?: 'xsmall' | 'small' | 'medium' | 'large';
24
24
  /** Aria label for the icon button when breadcrumb is truncated. */
25
25
  truncatedLinksIconAriaLabel: string;
26
- /** Controls behavior when there are too many crumbs to display.
26
+ /**
27
+ * Controls behavior when there are too many crumbs to display.
27
28
  * Ellipsis shows the crumbs at the beginning and end, with an ellipsis icon in between.
28
29
  * Folder-tree dynamically detects container overflow and shows up to 3 visible crumbs with a folder tree icon dropdown.
29
30
  */
@@ -36,3 +37,53 @@ export interface rootIconProps {
36
37
  /** Determines which icon is displayed at the root. */
37
38
  rootIconVariant: 'home' | 'folder-tree';
38
39
  }
40
+ export interface BreadcrumbViewProps {
41
+ crumbs: Crumb[];
42
+ ellipsizeLastCrumb?: boolean;
43
+ isInteractive: boolean;
44
+ onPageLinkClick: (id: string) => void;
45
+ size: NonNullable<BreadcrumbProps['size']>;
46
+ truncatedLinksIconAriaLabel: string;
47
+ }
48
+ export interface FolderTreeTruncationViewProps extends BreadcrumbViewProps {
49
+ iconButtonRef: React.RefObject<HTMLLIElement>;
50
+ visibleCrumbCount: number;
51
+ }
52
+ /**
53
+ * A page link represents a combination of an optional folder icon, a single crumb, and a separator.
54
+ */
55
+ export interface PageLinkProps {
56
+ /** The crumb to display in the page link. */
57
+ crumb: Crumb;
58
+ /** Whether the last crumb should be ellipsized. */
59
+ ellipsizeLastCrumb?: boolean;
60
+ /** Whether the page link is interactive. */
61
+ isInteractive: boolean;
62
+ /** Whether the page link is the last crumb in the breadcrumb. */
63
+ isLast: boolean;
64
+ /** The callback to call when the page link is clicked. */
65
+ onPageLinkClick: (id: string) => void;
66
+ /** The text size of the page link. */
67
+ size: 'xsmall' | 'small' | 'medium' | 'large';
68
+ }
69
+ export interface TruncationState {
70
+ /** Whether the last crumb should be ellipsized (when it doesn't fit even with truncation) */
71
+ ellipsizeLastCrumb: boolean;
72
+ /** Whether the breadcrumb content would exceed the container width IF no truncation was being applied */
73
+ isTruncationRequired: boolean;
74
+ /** Number of visible crumbs (not in dropdown) to show (1-3) */
75
+ visibleCrumbCount: number;
76
+ }
77
+ export interface FolderTreeTruncationResult extends TruncationState {
78
+ /** Ref to attach to the icon button container for width measurement */
79
+ iconButtonRef: React.RefObject<HTMLLIElement>;
80
+ }
81
+ export interface PerformTruncationParams {
82
+ container: HTMLOListElement;
83
+ crumbCount: number;
84
+ iconButtonRef: React.RefObject<HTMLLIElement>;
85
+ isTruncationRequired: boolean;
86
+ measuredIconButtonWidth: React.MutableRefObject<number>;
87
+ setState: React.Dispatch<React.SetStateAction<TruncationState>>;
88
+ storedCrumbWidths: React.MutableRefObject<number[]>;
89
+ }
@@ -1,25 +1,12 @@
1
- import { type Crumb } from './types';
2
- interface FolderTreeTruncationState {
3
- /** Whether the breadcrumb content would exceed the container width IF no truncation was being applied */
4
- isTruncationRequired: boolean;
5
- /** Number of visible crumbs (not in dropdown) to show (1-3) */
6
- visibleCrumbCount: number;
7
- }
8
- interface FolderTreeTruncationResult extends FolderTreeTruncationState {
9
- /** Ref to attach to the icon button container li element for width measurement */
10
- iconButtonRef: React.RefObject<HTMLLIElement>;
11
- }
1
+ import { type Crumb, type FolderTreeTruncationResult } from './types';
12
2
  /**
13
- * Hook which calculates how many crumbs can be displayed when folder-tree truncation is enabled.
3
+ * Hook that calculates optimal breadcrumb truncation for folder-tree display.
14
4
  *
15
- * ## Truncation Rules
16
- * - **≤3 crumbs**: Render all crumbs first, detect overflow, truncate only if needed
17
- * - **>3 crumbs**: Render truncated state immediately, show icon button + up to 3 crumbs
5
+ * Uses a cascading fallback strategy to show as many crumbs as possible without
6
+ * text ellipsis, falling back to ellipsis only as a last resort.
18
7
  *
19
- * @param containerRef - Ref to the main ol element
20
- * @param crumbs - Array of breadcrumb items
21
- * @param isEnabled - Whether folder-tree truncation is enabled. Can be false when responsive behavior is triggered
22
- * @returns Object containing wouldCrumbsOverflow, visibleCrumbCount, and iconButtonRef
8
+ * @param containerRef - Ref to the breadcrumb list container (ol element)
9
+ * @param crumbs - Array of breadcrumb items to display
10
+ * @param isMobile - Whether the breadcrumb is on a mobile device
23
11
  */
24
- export declare const useFolderTreeTruncation: (containerRef: React.RefObject<HTMLOListElement>, crumbs: Crumb[], isEnabled: boolean) => FolderTreeTruncationResult;
25
- export {};
12
+ export declare const useFolderTreeTruncation: (containerRef: React.RefObject<HTMLOListElement>, crumbs: Crumb[], isMobile: boolean) => FolderTreeTruncationResult;
@@ -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
  */
@@ -2623,11 +2623,11 @@
2623
2623
  .bp_menu_item_sections_module_menuItemSideContent--5517f.bp_menu_item_sections_module_textOnLightSecondary--5517f{
2624
2624
  color:var(--menu-item-text-color);
2625
2625
  }
2626
- .bp_breadcrumb_module_container--ee92d{
2626
+ .bp_breadcrumb_module_container--67af7{
2627
2627
  height:var(--bp-size-060);
2628
2628
  width:100%;
2629
2629
  }
2630
- .bp_breadcrumb_module_container--ee92d .bp_breadcrumb_module_breadcrumb--ee92d{
2630
+ .bp_breadcrumb_module_container--67af7 .bp_breadcrumb_module_breadcrumb--67af7{
2631
2631
  align-items:center;
2632
2632
  display:flex;
2633
2633
  flex-wrap:nowrap;
@@ -2635,22 +2635,26 @@
2635
2635
  height:100%;
2636
2636
  list-style:none;
2637
2637
  margin:0;
2638
- overflow:hidden;
2639
2638
  padding:0;
2640
2639
  }
2641
- .bp_breadcrumb_module_container--ee92d .bp_breadcrumb_module_pageLink--ee92d{
2640
+ .bp_breadcrumb_module_container--67af7 .bp_breadcrumb_module_pageLink--67af7{
2642
2641
  align-items:center;
2643
2642
  display:flex;
2644
2643
  flex-shrink:0;
2645
2644
  gap:var(--bp-size-010);
2645
+ min-width:0;
2646
+ overflow:hidden;
2646
2647
  white-space:nowrap;
2647
2648
  }
2649
+ .bp_breadcrumb_module_container--67af7 .bp_breadcrumb_module_pageLink--67af7:last-child{
2650
+ flex-shrink:1;
2651
+ }
2648
2652
 
2649
- .bp_breadcrumb_module_linkWithHover--ee92d{
2653
+ .bp_breadcrumb_module_linkWithHover--67af7{
2650
2654
  cursor:pointer;
2651
2655
  position:relative;
2652
2656
  }
2653
- .bp_breadcrumb_module_linkWithHover--ee92d::after{
2657
+ .bp_breadcrumb_module_linkWithHover--67af7::after{
2654
2658
  background-color:var(--bp-text-text-on-light-secondary);
2655
2659
  bottom:0;
2656
2660
  content:"";
@@ -2662,12 +2666,13 @@
2662
2666
  transition:transform var(--animation-duration-2) var(--animation-easing-ease-base);
2663
2667
  width:100%;
2664
2668
  }
2665
- .bp_breadcrumb_module_linkWithHover--ee92d:hover::after{
2669
+ .bp_breadcrumb_module_linkWithHover--67af7:hover::after{
2666
2670
  transform:scaleX(1);
2667
2671
  }
2668
2672
 
2669
- .bp_breadcrumb_module_dropdownContent--ee92d{
2673
+ .bp_breadcrumb_module_dropdownContent--67af7{
2670
2674
  --blueprint-web-dropdown-menu-max-height:calc(var(--bp-size-300)*2);
2675
+ z-index:2147483646;
2671
2676
  }
2672
2677
  .bp_link_module_link--27104{
2673
2678
  color:var(--text-cta-link);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@box/blueprint-web",
3
- "version": "12.135.1",
3
+ "version": "12.136.0",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@ariakit/react": "0.4.15",
49
49
  "@ariakit/react-core": "0.4.15",
50
- "@box/blueprint-web-assets": "^4.101.2",
50
+ "@box/blueprint-web-assets": "^4.101.3",
51
51
  "@internationalized/date": "^3.7.0",
52
52
  "@radix-ui/react-accordion": "1.1.2",
53
53
  "@radix-ui/react-checkbox": "1.0.4",
@@ -77,7 +77,7 @@
77
77
  "type-fest": "^3.2.0"
78
78
  },
79
79
  "devDependencies": {
80
- "@box/storybook-utils": "^0.16.39",
80
+ "@box/storybook-utils": "^0.16.40",
81
81
  "@figma/code-connect": "1.3.12",
82
82
  "@types/react": "^18.0.0",
83
83
  "@types/react-dom": "^18.0.0",