{iconStart}
{children}
@@ -242,11 +264,9 @@ export const MenuItem = (props: MenuItemProps) => {
-
- );
- }
-
- return content;
+
+
+ );
};
/** @public */
diff --git a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx
new file mode 100644
index 0000000000..c0e1aca932
--- /dev/null
+++ b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx
@@ -0,0 +1,200 @@
+/*
+ * 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 type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ MenuTrigger,
+ SubmenuTrigger,
+ Menu,
+ MenuAutocomplete,
+ MenuItem,
+} from './index';
+import { Button } from '../..';
+import { useState, useEffect } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+const meta = {
+ title: 'Backstage UI/MenuAutocomplete',
+ component: MenuTrigger,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+const options = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Banana', value: 'banana' },
+ { label: 'Blueberry', value: 'blueberry' },
+ { label: 'Cherry', value: 'cherry' },
+ { label: 'Durian', value: 'durian' },
+ { label: 'Elderberry', value: 'elderberry' },
+ { label: 'Fig', value: 'fig' },
+ { label: 'Grape', value: 'grape' },
+ { label: 'Honeydew', value: 'honeydew' },
+];
+
+export const Default: Story = {
+ args: {
+ children: null,
+ },
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const PreviewAutocompleteMenu: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const Virtualized: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [pokemon, setPokemon] = useState<
+ Array<{ name: string; url: string }>
+ >([]);
+
+ useEffect(() => {
+ fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
+ .then(response => response.json())
+ .then(data => {
+ setPokemon(data.results);
+ })
+ .catch(error => {
+ console.error('Error fetching Pokemon:', error);
+ });
+ }, []);
+
+ return (
+
+
+
+ {pokemon.map((p, index) => (
+
+ ))}
+
+
+ );
+ },
+};
+
+export const VirtualizedMaxHeight: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [pokemon, setPokemon] = useState<
+ Array<{ name: string; url: string }>
+ >([]);
+
+ useEffect(() => {
+ fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
+ .then(response => response.json())
+ .then(data => {
+ setPokemon(data.results);
+ })
+ .catch(error => {
+ console.error('Error fetching Pokemon:', error);
+ });
+ }, []);
+
+ return (
+
+
+
+ {pokemon.map((p, index) => (
+
+ ))}
+
+
+ );
+ },
+};
+
+export const Submenu: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => (
+
+
+
+
+ ),
+};
diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx
new file mode 100644
index 0000000000..733d813773
--- /dev/null
+++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx
@@ -0,0 +1,185 @@
+/*
+ * 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 type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ MenuTrigger,
+ MenuAutocompleteListbox,
+ MenuListBoxItem,
+ Menu,
+ MenuItem,
+ SubmenuTrigger,
+} from './index';
+import { Button, Flex, Text } from '../..';
+import { useState } from 'react';
+import { Selection } from 'react-aria-components';
+import { MemoryRouter } from 'react-router-dom';
+
+const meta = {
+ title: 'Backstage UI/MenuAutocompleteListBox',
+ component: MenuTrigger,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const options = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Banana', value: 'banana' },
+ { label: 'Blueberry', value: 'blueberry' },
+ { label: 'Cherry', value: 'cherry' },
+ { label: 'Durian', value: 'durian' },
+ { label: 'Elderberry', value: 'elderberry' },
+ { label: 'Fig', value: 'fig' },
+ { label: 'Grape', value: 'grape' },
+ { label: 'Honeydew', value: 'honeydew' },
+];
+
+export const Default: Story = {
+ args: {
+ children: null,
+ },
+ render: () => {
+ const [selected, setSelected] = useState(
+ new Set([options[2].value]),
+ );
+
+ return (
+
+ Selected: {Array.from(selected).join(', ')}
+
+
+
+ {options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+ },
+};
+
+export const PreviewListbox: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [selected, setSelected] = useState(
+ new Set([options[2].value]),
+ );
+
+ return (
+
+ Selected: {Array.from(selected).join(', ')}
+
+
+
+ {options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+ },
+};
+
+export const PreviewListboxMultiple: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [selected, setSelected] = useState(
+ new Set([options[2].value, options[3].value]),
+ );
+
+ return (
+
+ Selected: {Array.from(selected).join(', ')}
+
+
+
+ {options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+ },
+};
+
+export const Submenu: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [selected, setSelected] = useState(
+ new Set([options[2].value]),
+ );
+
+ return (
+
+ Selected: {Array.from(selected).join(', ')}
+
+
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx
new file mode 100644
index 0000000000..68aa071faf
--- /dev/null
+++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx
@@ -0,0 +1,89 @@
+/*
+ * 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 type { Meta, StoryObj } from '@storybook/react-vite';
+import { MenuTrigger, MenuListBox, MenuListBoxItem } from './index';
+import { Button, Flex, Text } from '../..';
+import { useState } from 'react';
+import { Selection } from 'react-aria-components';
+import { MemoryRouter } from 'react-router-dom';
+
+const meta = {
+ title: 'Backstage UI/MenuListBox',
+ component: MenuTrigger,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ children: null,
+ },
+ render: () => (
+
+
+
+ Item 1
+ Item 2
+ Item 3
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: () => {
+ const [selected, setSelected] = useState(new Set(['paul']));
+
+ return (
+
+ Selected: {Array.from(selected).join(', ')}
+
+
+
+
+ John Lennon
+
+
+ Paul McCartney
+
+
+ George Harrison
+
+
+ Ringo Starr
+
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/Menu/types.ts b/packages/ui/src/components/Menu/types.ts
index 683d0055b7..caf90742a8 100644
--- a/packages/ui/src/components/Menu/types.ts
+++ b/packages/ui/src/components/Menu/types.ts
@@ -52,6 +52,9 @@ export interface MenuAutocompleteProps
Omit, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
+ virtualized?: boolean;
+ maxWidth?: string;
+ maxHeight?: string;
}
/** @public */
diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts
index 931bfacca7..fe040d09b1 100644
--- a/packages/ui/src/utils/componentDefinitions.ts
+++ b/packages/ui/src/utils/componentDefinitions.ts
@@ -165,6 +165,7 @@ export const componentDefinitions = {
item: 'bui-MenuItem',
itemListBox: 'bui-MenuItemListBox',
itemListBoxCheck: 'bui-MenuItemListBoxCheck',
+ itemWrapper: 'bui-MenuItemWrapper',
itemContent: 'bui-MenuItemContent',
itemArrow: 'bui-MenuItemArrow',
separator: 'bui-MenuSeparator',