diff --git a/packages/core/src/components/breadcrumbs-next/BreadcrumbsNext.stories.tsx b/packages/core/src/components/breadcrumbs-next/BreadcrumbsNext.stories.tsx new file mode 100644 index 00000000000..123bcd69ff9 --- /dev/null +++ b/packages/core/src/components/breadcrumbs-next/BreadcrumbsNext.stories.tsx @@ -0,0 +1,74 @@ +/* + * (c) Copyright 2026 Palantir Technologies Inc. All rights reserved. + */ + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { storybookLayoutDecorator } from "@storybook-common"; +import React from "react"; + +import { Boundary } from "../../common/boundary"; +import type { BreadcrumbProps } from "../breadcrumbs/breadcrumb"; + +import { BreadcrumbsNext } from "./breadcrumbsNext"; + +const SAMPLE_ITEMS: BreadcrumbProps[] = [ + { text: "Home", href: "#", icon: "home" }, + { text: "Projects", href: "#", icon: "projects" }, + { text: "Blueprint", href: "#" }, + { text: "Components", href: "#" }, + { text: "Breadcrumbs" }, +]; + +type StoryArgs = React.ComponentProps & { width?: number }; + +const meta: Meta = { + title: "Core/BreadcrumbsNext", + component: BreadcrumbsNext, + decorators: [storybookLayoutDecorator], + tags: ["autodocs"], + args: { + items: SAMPLE_ITEMS, + collapseFrom: Boundary.START, + width: 400, + }, + argTypes: { + collapseFrom: { + control: "select", + options: Object.values(Boundary), + }, + minVisibleItems: { + control: "number", + }, + width: { control: { type: "range", min: 100, max: 800, step: 10 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * A basic breadcrumbs component with default styling. Adjust the width slider to see overflow behavior. + */ +export const Default: Story = { + render: ({ width, ...args }) => ( +
+ +
+ ), +}; + +/** + * Interactive playground with all props toggleable via Storybook controls. + */ +export const Playground: Story = { + args: { + items: SAMPLE_ITEMS, + collapseFrom: Boundary.START, + minVisibleItems: 0, + }, + render: ({ width, ...args }) => ( +
+ +
+ ), +}; diff --git a/packages/core/src/components/breadcrumbs-next/breadcrumbsNext.tsx b/packages/core/src/components/breadcrumbs-next/breadcrumbsNext.tsx new file mode 100644 index 00000000000..a73ab529a4f --- /dev/null +++ b/packages/core/src/components/breadcrumbs-next/breadcrumbsNext.tsx @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import classNames from "classnames"; +import { memo, useCallback } from "react"; + +import { Boundary, Classes, DISPLAYNAME_PREFIX, type Props, removeNonHTMLProps } from "../../common"; +import { Breadcrumb, type BreadcrumbProps } from "../breadcrumbs/breadcrumb"; +import { Menu } from "../menu/menu"; +import { MenuItem } from "../menu/menuItem"; +import { OverflowList, type OverflowListProps } from "../overflow-list/overflowList"; +import { PopoverNext } from "../popover-next/popoverNext"; +import type { PopoverNextProps } from "../popover-next/popoverNextProps"; + +const EMPTY_ITEMS: readonly BreadcrumbProps[] = []; + +export interface BreadcrumbsNextProps extends Props { + /** + * Callback invoked to render visible breadcrumbs. Best practice is to + * render a `` element. If `currentBreadcrumbRenderer` is also + * supplied, that callback will be used for the current breadcrumb instead. + * + * @default Breadcrumb + */ + breadcrumbRenderer?: (props: BreadcrumbProps) => React.JSX.Element; + + /** + * Which direction the breadcrumbs should collapse from: start or end. + * + * @default Boundary.START + */ + collapseFrom?: Boundary; + + /** + * Callback invoked to render the current breadcrumb, which is the last + * element in the `items` array. + * + * If this prop is omitted, `breadcrumbRenderer` will be invoked for the + * current breadcrumb instead. + */ + currentBreadcrumbRenderer?: (props: BreadcrumbProps) => React.JSX.Element; + + /** + * All breadcrumbs to display. Breadcrumbs that do not fit in the container + * will be rendered in an overflow menu instead. + * + * @default [] + */ + items?: readonly BreadcrumbProps[]; + + /** + * The minimum number of visible breadcrumbs that should never collapse into + * the overflow menu, regardless of DOM dimensions. + * + * @default 0 + */ + minVisibleItems?: number; + + /** + * Props to spread to the `OverflowList` popover target. + */ + overflowButtonProps?: React.HTMLProps; + + /** + * Props to spread to `OverflowList`. Note that `items`, + * `overflowRenderer`, and `visibleItemRenderer` cannot be changed. + */ + overflowListProps?: Partial< + Omit, "items" | "overflowRenderer" | "visibleItemRenderer"> + >; + + /** + * Props to spread to the `PopoverNext` showing the overflow menu. + */ + popoverNextProps?: Partial< + Omit< + PopoverNextProps, + "children" | "content" | "defaultIsOpen" | "disabled" | "fill" | "renderTarget" | "targetTagName" + > + >; +} + +/** + * BreadcrumbsNext component. + * + * Uses PopoverNext (Floating UI) for the overflow menu instead of Popover (Popper.js). + * + * @see https://blueprintjs.com/docs/#core/components/breadcrumbs + */ +export const BreadcrumbsNext: React.FC = memo(props => { + const { + breadcrumbRenderer, + className, + collapseFrom = Boundary.START, + currentBreadcrumbRenderer, + items = EMPTY_ITEMS, + minVisibleItems = 0, + overflowButtonProps, + overflowListProps = {}, + popoverNextProps, + } = props; + + const renderBreadcrumb = useCallback( + (breadcrumbProps: BreadcrumbProps, isCurrent: boolean) => { + if (isCurrent && currentBreadcrumbRenderer != null) { + return currentBreadcrumbRenderer(breadcrumbProps); + } else if (breadcrumbRenderer != null) { + return breadcrumbRenderer(breadcrumbProps); + } else { + return ; + } + }, + [breadcrumbRenderer, currentBreadcrumbRenderer], + ); + + const renderBreadcrumbWrapper = useCallback( + (breadcrumbProps: BreadcrumbProps, index: number) => { + const isCurrent = items[items.length - 1] === breadcrumbProps; + return
  • {renderBreadcrumb(breadcrumbProps, isCurrent)}
  • ; + }, + [items, renderBreadcrumb], + ); + + const renderOverflowBreadcrumb = useCallback((breadcrumbProps: BreadcrumbProps, index: number) => { + const isClickable = breadcrumbProps.href != null || breadcrumbProps.onClick != null; + const htmlProps = removeNonHTMLProps(breadcrumbProps); + return ; + }, []); + + const renderOverflow = useCallback( + (overflowItems: readonly BreadcrumbProps[]) => { + let orderedItems = overflowItems; + if (collapseFrom === Boundary.START) { + orderedItems = overflowItems.slice().reverse(); + } + + return ( +
  • + {orderedItems.map(renderOverflowBreadcrumb)}} + disabled={orderedItems.length === 0} + > + + +
  • + ); + }, + [collapseFrom, overflowButtonProps, popoverNextProps, renderOverflowBreadcrumb], + ); + + return ( + + ); +}); + +BreadcrumbsNext.displayName = `${DISPLAYNAME_PREFIX}.BreadcrumbsNext`; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 448a9fda8b0..51911acf5c4 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -17,6 +17,7 @@ export { Alert, type AlertProps } from "./alert/alert"; export { Breadcrumb, type BreadcrumbProps } from "./breadcrumbs/breadcrumb"; export { Breadcrumbs, type BreadcrumbsProps } from "./breadcrumbs/breadcrumbs"; +export { BreadcrumbsNext, type BreadcrumbsNextProps } from "./breadcrumbs-next/breadcrumbsNext"; export { AnchorButton, Button } from "./button/buttons"; export type { AnchorButtonProps,