Merge pull request #32313 from backstage/bui-popover

BUI - Add new Popover component
This commit is contained in:
Charles de Dreuille
2026-01-14 22:22:14 +00:00
committed by GitHub
14 changed files with 578 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Added new Popover component for Backstage UI with automatic overflow handling, and full placement support. Also introduced `--bui-shadow` token for consistent elevation styling across overlay components (Popover, Tooltip, Menu).
File diff suppressed because one or more lines are too long
+2
View File
@@ -100,6 +100,7 @@
--bui-ring: #1f5493;
--bui-scrollbar: #a0a0a03b;
--bui-scrollbar-thumb: #a0a0a0;
--bui-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
--bui-animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
}
@@ -161,6 +162,7 @@
--bui-ring: #1f5493;
--bui-scrollbar: #3636363a;
--bui-scrollbar-thumb: #575757;
--bui-shadow: none;
}
}
+25 -5
View File
@@ -27,7 +27,7 @@ import type { MenuProps as MenuProps_2 } from 'react-aria-components';
import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components';
import type { MenuTriggerProps as MenuTriggerProps_2 } from 'react-aria-components';
import type { ModalOverlayProps } from 'react-aria-components';
import type { PopoverProps } from 'react-aria-components';
import { PopoverProps as PopoverProps_2 } from 'react-aria-components';
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
import { ReactElement } from 'react';
@@ -1025,7 +1025,7 @@ export interface MenuAutocompleteListBoxProps<T>
// (undocumented)
placeholder?: string;
// (undocumented)
placement?: PopoverProps['placement'];
placement?: PopoverProps_2['placement'];
// (undocumented)
virtualized?: boolean;
}
@@ -1041,7 +1041,7 @@ export interface MenuAutocompleteProps<T>
// (undocumented)
placeholder?: string;
// (undocumented)
placement?: PopoverProps['placement'];
placement?: PopoverProps_2['placement'];
// (undocumented)
virtualized?: boolean;
}
@@ -1106,7 +1106,7 @@ export interface MenuListBoxProps<T>
// (undocumented)
maxWidth?: string;
// (undocumented)
placement?: PopoverProps['placement'];
placement?: PopoverProps_2['placement'];
// (undocumented)
virtualized?: boolean;
}
@@ -1120,7 +1120,7 @@ export interface MenuProps<T>
// (undocumented)
maxWidth?: string;
// (undocumented)
placement?: PopoverProps['placement'];
placement?: PopoverProps_2['placement'];
// (undocumented)
virtualized?: boolean;
}
@@ -1206,6 +1206,26 @@ export interface PaginationOptions {
showPageSizeOptions?: boolean;
}
// @public
export const Popover: ForwardRefExoticComponent<
PopoverProps & RefAttributes<HTMLDivElement>
>;
// @public
export const PopoverDefinition: {
readonly classNames: {
readonly root: 'bui-Popover';
readonly arrow: 'bui-PopoverArrow';
readonly content: 'bui-PopoverContent';
};
};
// @public
export interface PopoverProps extends Omit<PopoverProps_2, 'children'> {
children: React.ReactNode;
hideArrow?: boolean;
}
// @public (undocumented)
export interface QueryOptions<TFilter> {
// (undocumented)
@@ -20,6 +20,7 @@
.bui-MenuPopover {
display: flex;
flex-direction: column;
box-shadow: var(--bui-shadow);
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-2);
background: var(--bui-bg-surface-1);
@@ -18,13 +18,124 @@
@layer components {
.bui-Popover {
margin-right: 12px;
box-shadow: var(--bui-shadow);
border-radius: var(--bui-radius-3);
background: var(--bui-bg-surface-1);
border: 1px solid var(--bui-gray-3);
forced-color-adjust: none;
outline: none;
max-height: inherit;
display: flex;
flex-direction: column;
/* fixes FF gap */
transform: translate3d(0, 0, 0);
transition: transform 200ms, opacity 200ms;
&[data-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
}
&[data-placement='top'] {
--origin: translateY(4px);
&:has(.bui-PopoverArrow) {
margin-bottom: var(--bui-space-3);
}
}
&[data-placement='right'] {
--origin: translateX(-4px);
&:has(.bui-PopoverArrow) {
margin-left: var(--bui-space-3);
}
}
&[data-placement='bottom'] {
--origin: translateY(-4px);
&:has(.bui-PopoverArrow) {
margin-top: var(--bui-space-3);
}
}
&[data-placement='left'] {
--origin: translateX(4px);
&:has(.bui-PopoverArrow) {
margin-right: var(--bui-space-3);
}
}
}
.bui-PopoverContent {
overflow-x: hidden;
overflow-y: auto;
background-color: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-3);
padding-block: var(--bui-space-1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: var(--bui-space-4);
flex: 1 1 auto;
min-height: 0;
}
.bui-PopoverArrow {
& svg {
display: block;
/* The popover is rendered overlaying the main
popover element by 1px. This causes the borders
to overlap, which causes minor visual artifacts
with transparent border colors. To mitigate this,
we split the stroke and fill across separate
elements in order to guarantee that the stroke is
always overlaying a consistent color. */
path:nth-child(1) {
fill: var(--bui-bg-surface-1);
}
path:nth-child(2) {
fill: var(--bui-gray-3);
}
/* The arrow svg overlaps the popover by 2px, so we
need to adjust the margins accordingly. */
--popover-arrow-overlap: -2px;
}
&[data-placement='top'] svg {
margin-top: var(--popover-arrow-overlap);
}
&[data-placement='bottom'] svg {
margin-bottom: var(--popover-arrow-overlap);
transform: rotate(180deg);
}
&[data-placement='right'] svg {
margin-right: var(--popover-arrow-overlap);
transform: rotate(90deg);
}
&[data-placement='left'] svg {
margin-left: var(--popover-arrow-overlap);
transform: rotate(-90deg);
}
}
[data-theme='dark'] {
.bui-Popover {
background: var(--bui-bg-surface-2);
border: 1px solid var(--bui-gray-4);
}
.bui-PopoverArrow {
svg path:nth-child(1) {
fill: var(--bui-bg-surface-2);
}
svg path:nth-child(2) {
fill: var(--bui-gray-4);
}
}
}
}
@@ -0,0 +1,278 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 preview from '../../../../../.storybook/preview';
import { Popover } from './Popover';
import { Button } from '../Button/Button';
import { DialogTrigger } from '../Dialog/Dialog';
import { Text } from '../Text/Text';
import { Flex } from '../Flex/Flex';
const meta = preview.meta({
title: 'Backstage UI/Popover',
component: Popover,
parameters: { layout: 'centered' },
argTypes: {
isOpen: {
control: { type: 'boolean' },
},
hideArrow: {
control: { type: 'boolean' },
},
placement: {
control: { type: 'select' },
options: [
'top',
'top start',
'top end',
'bottom',
'bottom start',
'bottom end',
'left',
'left start',
'left end',
'right',
'right start',
'right end',
],
},
offset: {
control: { type: 'number' },
},
},
render: ({ children, isOpen, hideArrow, placement, offset }) => (
<DialogTrigger>
<Button>Open Popover</Button>
<Popover
isOpen={isOpen}
hideArrow={hideArrow}
placement={placement}
offset={offset}
>
{children ?? <Text>This is a popover</Text>}
</Popover>
</DialogTrigger>
),
});
export const Default = meta.story({
args: {
children: <Text>This is a popover</Text>,
},
});
export const IsOpen = Default.extend({
args: {
isOpen: true,
},
});
export const HideArrow = Default.extend({
args: {
isOpen: true,
hideArrow: true,
},
});
export const TopPlacement = Default.extend({
args: {
isOpen: true,
placement: 'top',
},
});
export const RightPlacement = Default.extend({
args: {
isOpen: true,
placement: 'right',
},
});
export const BottomPlacement = Default.extend({
args: {
isOpen: true,
placement: 'bottom',
},
});
export const LeftPlacement = Default.extend({
args: {
isOpen: true,
placement: 'left',
},
});
export const AllPlacements = Default.extend({
parameters: {
controls: {
exclude: ['placement'],
},
},
args: {
isOpen: true,
},
render: ({ isOpen, hideArrow }) => {
return (
<DialogTrigger>
<Button>Open Popovers</Button>
<Popover isOpen={isOpen} placement="top" hideArrow={hideArrow}>
<Text>Top placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="right" hideArrow={hideArrow}>
<Text>Right placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="bottom" hideArrow={hideArrow}>
<Text>Bottom placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="left" hideArrow={hideArrow}>
<Text>Left placement</Text>
</Popover>
</DialogTrigger>
);
},
});
export const AllPlacementsNoArrow = Default.extend({
parameters: {
controls: {
exclude: ['placement', 'hideArrow'],
},
},
args: {
isOpen: true,
hideArrow: true,
},
render: ({ isOpen }) => {
return (
<DialogTrigger>
<Button>Open Popovers</Button>
<Popover isOpen={isOpen} placement="top" hideArrow>
<Text>Top placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="right" hideArrow>
<Text>Right placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="bottom" hideArrow>
<Text>Bottom placement</Text>
</Popover>
<Popover isOpen={isOpen} placement="left" hideArrow>
<Text>Left placement</Text>
</Popover>
</DialogTrigger>
);
},
});
export const WithRichContent = Default.extend({
args: {
isOpen: true,
},
render: ({ isOpen, hideArrow, placement }) => (
<DialogTrigger>
<Button>Open Popover</Button>
<Popover isOpen={isOpen} hideArrow={hideArrow} placement={placement}>
<Flex direction="column" gap="3" style={{ width: '280px' }}>
<Text style={{ fontWeight: 'bold' }}>Popover Title</Text>
<Text>
This is a popover with rich content. It can contain multiple
elements and formatted text.
</Text>
<Flex gap="2" justify="end">
<Button variant="tertiary" size="small">
Cancel
</Button>
<Button variant="primary" size="small">
Confirm
</Button>
</Flex>
</Flex>
</Popover>
</DialogTrigger>
),
});
export const CustomOffset = Default.extend({
args: {
isOpen: true,
offset: 20,
placement: 'bottom',
},
});
export const NonModal = Default.extend({
args: {
isOpen: true,
},
render: ({ isOpen, hideArrow, placement }) => (
<DialogTrigger>
<Button>Open Non-Modal Popover</Button>
<Popover
isOpen={isOpen}
hideArrow={hideArrow}
placement={placement}
isNonModal
>
<Text>
This is a non-modal popover. You can interact with other elements on
the page while it's open.
</Text>
</Popover>
</DialogTrigger>
),
});
export const WithLongContent = Default.extend({
args: {
isOpen: true,
},
render: ({ isOpen, hideArrow, placement }) => (
<DialogTrigger>
<Button>Open Popover</Button>
<Popover isOpen={isOpen} hideArrow={hideArrow} placement={placement}>
<Flex direction="column" gap="3" style={{ width: '320px' }}>
<Text style={{ fontWeight: 'bold' }}>Long Content Example</Text>
<Text>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</Text>
<Text>
Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit
anim id est laborum.
</Text>
<Text>
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium, totam rem aperiam, eaque ipsa
quae ab illo inventore veritatis et quasi architecto beatae vitae
dicta sunt explicabo.
</Text>
<Text>
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut
fugit, sed quia consequuntur magni dolores eos qui ratione
voluptatem sequi nesciunt.
</Text>
<Text>
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
consectetur, adipisci velit, sed quia non numquam eius modi tempora
incidunt ut labore et dolore magnam aliquam quaerat voluptatem.
</Text>
</Flex>
</Popover>
</DialogTrigger>
),
});
@@ -0,0 +1,97 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { forwardRef } from 'react';
import { OverlayArrow, Popover as AriaPopover } from 'react-aria-components';
import clsx from 'clsx';
import { PopoverProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { PopoverDefinition } from './definition';
import styles from './Popover.module.css';
/**
* A popover component built on React Aria Components that displays floating
* content anchored to a trigger element.
*
* @remarks
* The Popover component supports multiple placements (top, right, bottom, left),
* automatic viewport-constrained scrolling, and conditional arrow rendering. It
* automatically handles positioning, collision detection, and ARIA attributes for
* accessibility. Content is automatically padded and scrollable when it exceeds
* available space.
*
* @example
* Basic usage with DialogTrigger:
* ```tsx
* <DialogTrigger>
* <Button>Open Popover</Button>
* <Popover>
* <Text>Popover content</Text>
* </Popover>
* </DialogTrigger>
* ```
*
* @example
* With custom placement and no arrow:
* ```tsx
* <DialogTrigger>
* <Button>Open</Button>
* <Popover placement="right" hideArrow>
* <Text>Content without arrow</Text>
* </Popover>
* </DialogTrigger>
* ```
*
* @public
*/
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
(props, ref) => {
const { classNames, cleanedProps } = useStyles(PopoverDefinition, props);
const { className, children, hideArrow, ...rest } = cleanedProps;
return (
<AriaPopover
className={clsx(classNames.root, styles[classNames.root], className)}
{...rest}
ref={ref}
>
{({ trigger }) => (
<>
{!hideArrow &&
trigger !== 'MenuTrigger' &&
trigger !== 'SubmenuTrigger' && (
<OverlayArrow
className={clsx(classNames.arrow, styles[classNames.arrow])}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10.3356 7.39793L15.1924 3.02682C15.9269 2.36577 16.8801 2 17.8683 2H20V7.94781e-07L1.74846e-07 -9.53674e-07L0 2L1.4651 2C2.4532 2 3.4064 2.36577 4.1409 3.02682L8.9977 7.39793C9.378 7.7402 9.9553 7.74021 10.3356 7.39793Z" />
<path d="M11.0046 8.14124C10.2439 8.82575 9.08939 8.82578 8.32869 8.14122L3.47189 3.77011C2.92109 3.27432 2.20619 2.99999 1.46509 2.99999L4.10999 3L8.99769 7.39793C9.37799 7.7402 9.95529 7.7402 10.3356 7.39793L15.2226 3L17.8683 2.99999C17.1271 2.99999 16.4122 3.27432 15.8614 3.77011L11.0046 8.14124Z" />
</svg>
</OverlayArrow>
)}
<div
className={clsx(classNames.content, styles[classNames.content])}
>
{children}
</div>
</>
)}
</AriaPopover>
);
},
);
Popover.displayName = 'Popover';
@@ -23,5 +23,7 @@ import type { ComponentDefinition } from '../../types';
export const PopoverDefinition = {
classNames: {
root: 'bui-Popover',
arrow: 'bui-PopoverArrow',
content: 'bui-PopoverContent',
},
} as const satisfies ComponentDefinition;
+3 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { Popover } from './Popover';
export type { PopoverProps } from './types';
export { PopoverDefinition } from './definition';
@@ -0,0 +1,38 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { PopoverProps as AriaPopoverProps } from 'react-aria-components';
/**
* Properties for {@link Popover}
*
* @public
*/
export interface PopoverProps extends Omit<AriaPopoverProps, 'children'> {
/**
* The content to display inside the popover.
* Content is automatically wrapped with padding and scroll behavior.
*/
children: React.ReactNode;
/**
* Whether to hide the arrow pointing to the trigger element.
* Arrow is also automatically hidden for MenuTrigger and SubmenuTrigger contexts.
*
* @defaultValue false
*/
hideArrow?: boolean;
}
@@ -18,8 +18,7 @@
@layer components {
.bui-Tooltip {
box-shadow: 0 10px 15px -3px rgba(0 0 0 / 0.1),
0 4px 6px -4px rgba(0 0 0 / 0.1);
box-shadow: var(--bui-shadow);
border-radius: 4px;
background: var(--bui-bg-surface-1);
border: 1px solid var(--bui-gray-3);
+7
View File
@@ -146,6 +146,10 @@
--bui-scrollbar: #a0a0a03b;
--bui-scrollbar-thumb: #a0a0a0;
/* Shadows */
--bui-shadow: 0 10px 15px -3px rgba(0 0 0 / 0.1),
0 4px 6px -4px rgba(0 0 0 / 0.1);
--bui-animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@@ -226,5 +230,8 @@
--bui-ring: #1f5493;
--bui-scrollbar: #3636363a;
--bui-scrollbar-thumb: #575757;
/* Shadows */
--bui-shadow: none;
}
}
+1
View File
@@ -47,6 +47,7 @@ export * from './components/Text';
export * from './components/TextField';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/Popover';
export * from './components/SearchField';
export * from './components/Link';
export * from './components/Select';