diff --git a/.changeset/upset-papayas-flash.md b/.changeset/upset-papayas-flash.md
new file mode 100644
index 0000000000..fae7347e0d
--- /dev/null
+++ b/.changeset/upset-papayas-flash.md
@@ -0,0 +1,5 @@
+---
+'@backstage/canon': patch
+---
+
+Update Menu component in Canon to make the UI more condensed. We are also adding a new Combobox option for nested navigation.
diff --git a/canon-docs/src/app/(docs)/components/menu/page.mdx b/canon-docs/src/app/(docs)/components/menu/page.mdx
index 98c4949438..524db26cd1 100644
--- a/canon-docs/src/app/(docs)/components/menu/page.mdx
+++ b/canon-docs/src/app/(docs)/components/menu/page.mdx
@@ -155,3 +155,48 @@ You can additionally configure how quickly the menu opens on hover using the `de
`}
/>
+
+### Nested navigation
+
+You can nest menus to create a more complex navigation structure.
+
+ }
+ code={`
(
+ }
+ >
+ Menu
+
+ )}
+ />
+
+
+
+ Settings
+ Invite new members
+ Download app
+ Log out
+
+ Submenu
+
+
+
+ Submenu Item 1
+ Submenu Item 2
+ Submenu Item 3
+
+
+
+
+
+
+ `}
+/>
diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css
index 23133c9966..124d575a96 100644
--- a/packages/canon/css/components.css
+++ b/packages/canon/css/components.css
@@ -695,16 +695,20 @@
}
.canon-MenuPopup {
- padding: var(--canon-space-2);
background-color: var(--canon-bg-surface-1);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
transform-origin: var(--transform-origin);
+ max-width: min(var(--available-width), 340px);
+ max-height: min(var(--available-height), 500px);
+ padding-bottom: var(--canon-space-1);
border-radius: .375rem;
outline: none;
- max-width: 340px;
+ flex-direction: column;
transition: transform .15s, opacity .15s;
- overflow: hidden;
+ display: flex;
+ position: relative;
+ overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -713,26 +717,61 @@
}
.canon-MenuItem {
- cursor: default;
user-select: none;
+ align-items: center;
gap: var(--canon-space-2);
+ height: 32px;
color: var(--canon-fg-primary);
border-radius: var(--canon-radius-2);
- padding: var(--canon-space-2) var(--canon-space-2);
- padding-right: var(--canon-space-4);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
cursor: pointer;
outline: 0;
+ flex-shrink: 0;
text-decoration: none;
display: flex;
- &:last-child {
- border-bottom: none;
+ &:first-child {
+ margin-top: var(--canon-space-1);
}
&[data-highlighted] {
- z-index: 0;
- background-color: var(--canon-bg-tint-hover);
- position: relative;
+ background-color: var(--canon-gray-3);
+ }
+}
+
+.canon-MenuSubmenuTrigger {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ & .canon-Icon {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:first-child {
+ margin-top: var(--canon-space-1);
+ }
+
+ &[data-popup-open], &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+
+ & .canon-Icon {
+ color: var(--canon-fg-primary);
+ }
}
}
@@ -742,6 +781,93 @@
margin: .375rem 1rem;
}
+.canon-SubmenuComboboxSearch {
+ padding-inline: var(--canon-space-3);
+ border: none;
+ border-bottom: 1px solid var(--canon-border);
+ background-color: var(--canon-bg-surface-1);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ line-height: 140%;
+ font-size: var(--canon-font-size-3);
+ z-index: 1;
+ outline: none;
+ position: sticky;
+ top: 0;
+
+ &::placeholder {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:disabled {
+ opacity: .6;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItems {
+ padding-top: var(--canon-space-2);
+ outline: none;
+ flex-direction: column;
+ display: flex;
+ overflow-y: auto;
+}
+
+.canon-SubmenuComboboxNoResults {
+ padding-inline: var(--canon-space-3);
+ padding-top: var(--canon-space-2);
+ padding-bottom: var(--canon-space-4);
+ color: var(--canon-fg-secondary);
+ font-size: var(--canon-font-size-3);
+}
+
+.canon-SubmenuComboboxItem {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+ }
+
+ &[data-disabled] {
+ opacity: .5;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItemCheckbox {
+ width: 16px;
+ height: 16px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ border: 1px solid var(--canon-border);
+ background: var(--canon-bg-surface-1);
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+}
+
+.canon-SubmenuComboboxItemLabel {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ overflow: hidden;
+}
+
.canon-Link {
font-family: var(--canon-font-regular);
color: var(--canon-fg-link);
diff --git a/packages/canon/css/menu.css b/packages/canon/css/menu.css
index de811cde77..4c9433275c 100644
--- a/packages/canon/css/menu.css
+++ b/packages/canon/css/menu.css
@@ -3,16 +3,20 @@
}
.canon-MenuPopup {
- padding: var(--canon-space-2);
background-color: var(--canon-bg-surface-1);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
transform-origin: var(--transform-origin);
+ max-width: min(var(--available-width), 340px);
+ max-height: min(var(--available-height), 500px);
+ padding-bottom: var(--canon-space-1);
border-radius: .375rem;
outline: none;
- max-width: 340px;
+ flex-direction: column;
transition: transform .15s, opacity .15s;
- overflow: hidden;
+ display: flex;
+ position: relative;
+ overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -21,26 +25,61 @@
}
.canon-MenuItem {
- cursor: default;
user-select: none;
+ align-items: center;
gap: var(--canon-space-2);
+ height: 32px;
color: var(--canon-fg-primary);
border-radius: var(--canon-radius-2);
- padding: var(--canon-space-2) var(--canon-space-2);
- padding-right: var(--canon-space-4);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
cursor: pointer;
outline: 0;
+ flex-shrink: 0;
text-decoration: none;
display: flex;
- &:last-child {
- border-bottom: none;
+ &:first-child {
+ margin-top: var(--canon-space-1);
}
&[data-highlighted] {
- z-index: 0;
- background-color: var(--canon-bg-tint-hover);
- position: relative;
+ background-color: var(--canon-gray-3);
+ }
+}
+
+.canon-MenuSubmenuTrigger {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ & .canon-Icon {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:first-child {
+ margin-top: var(--canon-space-1);
+ }
+
+ &[data-popup-open], &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+
+ & .canon-Icon {
+ color: var(--canon-fg-primary);
+ }
}
}
@@ -49,3 +88,90 @@
height: 1px;
margin: .375rem 1rem;
}
+
+.canon-SubmenuComboboxSearch {
+ padding-inline: var(--canon-space-3);
+ border: none;
+ border-bottom: 1px solid var(--canon-border);
+ background-color: var(--canon-bg-surface-1);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ line-height: 140%;
+ font-size: var(--canon-font-size-3);
+ z-index: 1;
+ outline: none;
+ position: sticky;
+ top: 0;
+
+ &::placeholder {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:disabled {
+ opacity: .6;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItems {
+ padding-top: var(--canon-space-2);
+ outline: none;
+ flex-direction: column;
+ display: flex;
+ overflow-y: auto;
+}
+
+.canon-SubmenuComboboxNoResults {
+ padding-inline: var(--canon-space-3);
+ padding-top: var(--canon-space-2);
+ padding-bottom: var(--canon-space-4);
+ color: var(--canon-fg-secondary);
+ font-size: var(--canon-font-size-3);
+}
+
+.canon-SubmenuComboboxItem {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+ }
+
+ &[data-disabled] {
+ opacity: .5;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItemCheckbox {
+ width: 16px;
+ height: 16px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ border: 1px solid var(--canon-border);
+ background: var(--canon-bg-surface-1);
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+}
+
+.canon-SubmenuComboboxItemLabel {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ overflow: hidden;
+}
diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css
index 42d47267f8..084aef2855 100644
--- a/packages/canon/css/styles.css
+++ b/packages/canon/css/styles.css
@@ -9919,16 +9919,20 @@
}
.canon-MenuPopup {
- padding: var(--canon-space-2);
background-color: var(--canon-bg-surface-1);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
transform-origin: var(--transform-origin);
+ max-width: min(var(--available-width), 340px);
+ max-height: min(var(--available-height), 500px);
+ padding-bottom: var(--canon-space-1);
border-radius: .375rem;
outline: none;
- max-width: 340px;
+ flex-direction: column;
transition: transform .15s, opacity .15s;
- overflow: hidden;
+ display: flex;
+ position: relative;
+ overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -9937,26 +9941,61 @@
}
.canon-MenuItem {
- cursor: default;
user-select: none;
+ align-items: center;
gap: var(--canon-space-2);
+ height: 32px;
color: var(--canon-fg-primary);
border-radius: var(--canon-radius-2);
- padding: var(--canon-space-2) var(--canon-space-2);
- padding-right: var(--canon-space-4);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
cursor: pointer;
outline: 0;
+ flex-shrink: 0;
text-decoration: none;
display: flex;
- &:last-child {
- border-bottom: none;
+ &:first-child {
+ margin-top: var(--canon-space-1);
}
&[data-highlighted] {
- z-index: 0;
- background-color: var(--canon-bg-tint-hover);
- position: relative;
+ background-color: var(--canon-gray-3);
+ }
+}
+
+.canon-MenuSubmenuTrigger {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ & .canon-Icon {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:first-child {
+ margin-top: var(--canon-space-1);
+ }
+
+ &[data-popup-open], &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+
+ & .canon-Icon {
+ color: var(--canon-fg-primary);
+ }
}
}
@@ -9966,6 +10005,93 @@
margin: .375rem 1rem;
}
+.canon-SubmenuComboboxSearch {
+ padding-inline: var(--canon-space-3);
+ border: none;
+ border-bottom: 1px solid var(--canon-border);
+ background-color: var(--canon-bg-surface-1);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ line-height: 140%;
+ font-size: var(--canon-font-size-3);
+ z-index: 1;
+ outline: none;
+ position: sticky;
+ top: 0;
+
+ &::placeholder {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:disabled {
+ opacity: .6;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItems {
+ padding-top: var(--canon-space-2);
+ outline: none;
+ flex-direction: column;
+ display: flex;
+ overflow-y: auto;
+}
+
+.canon-SubmenuComboboxNoResults {
+ padding-inline: var(--canon-space-3);
+ padding-top: var(--canon-space-2);
+ padding-bottom: var(--canon-space-4);
+ color: var(--canon-fg-secondary);
+ font-size: var(--canon-font-size-3);
+}
+
+.canon-SubmenuComboboxItem {
+ user-select: none;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--canon-space-2);
+ height: 32px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ outline: 0;
+ flex-shrink: 0;
+ text-decoration: none;
+ display: flex;
+
+ &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+ }
+
+ &[data-disabled] {
+ opacity: .5;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItemCheckbox {
+ width: 16px;
+ height: 16px;
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-2);
+ border: 1px solid var(--canon-border);
+ background: var(--canon-bg-surface-1);
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+}
+
+.canon-SubmenuComboboxItemLabel {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ overflow: hidden;
+}
+
.canon-Link {
font-family: var(--canon-font-regular);
color: var(--canon-fg-link);
diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md
index ecfdfde188..ff8c36e5ec 100644
--- a/packages/canon/report.api.md
+++ b/packages/canon/report.api.md
@@ -7,6 +7,7 @@ import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
import { Breakpoint as Breakpoint_2 } from '@backstage/canon';
import { ChangeEvent } from 'react';
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
+import { ComponentProps } from 'react';
import { Context } from 'react';
import type { CSSProperties } from 'react';
import { FC } from 'react';
@@ -893,6 +894,27 @@ export type MarginProps = GetPropDefTypes;
// @public (undocumented)
export const Menu: MenuComponent;
+// @public (undocumented)
+export type MenuComboboxOption = {
+ label: string;
+ value: string;
+ disabled?: boolean;
+};
+
+// @public (undocumented)
+export interface MenuComboboxProps extends ComponentProps<'div'> {
+ // (undocumented)
+ closeParentOnEsc?: boolean;
+ // (undocumented)
+ multiselect?: boolean;
+ // (undocumented)
+ onValueChange?: (value: string[]) => void;
+ // (undocumented)
+ options: MenuComboboxOption[];
+ // (undocumented)
+ value?: string[];
+}
+
// @public (undocumented)
export type MenuComponent = {
Root: typeof Menu_2.Root;
@@ -912,6 +934,9 @@ export type MenuComponent = {
CheckboxItemIndicator: typeof Menu_2.CheckboxItemIndicator;
SubmenuTrigger: typeof Menu_2.SubmenuTrigger;
Separator: typeof Menu_2.Separator;
+ Combobox: ForwardRefExoticComponent<
+ MenuComboboxProps & RefAttributes
+ >;
};
// @public (undocumented)
diff --git a/packages/canon/src/components/Menu/Combobox.tsx b/packages/canon/src/components/Menu/Combobox.tsx
new file mode 100644
index 0000000000..c9593ed2a4
--- /dev/null
+++ b/packages/canon/src/components/Menu/Combobox.tsx
@@ -0,0 +1,245 @@
+/*
+ * 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,
+ useState,
+ useMemo,
+ useCallback,
+ useId,
+ ChangeEvent,
+ KeyboardEvent,
+ useRef,
+ useEffect,
+} from 'react';
+import clsx from 'clsx';
+import { MenuComboboxOption, MenuComboboxProps } from './types';
+import { Icon } from '@backstage/canon';
+
+const getListboxItemId = (listboxId: string, optionValue: string): string =>
+ `${listboxId}-option-${optionValue}`;
+
+// Internal component for rendering individual items
+function ComboboxItem({
+ option,
+ optionIndex,
+ value,
+ activeOptionIndex,
+ onItemActive,
+ onItemSelect,
+ listboxId,
+}: {
+ option: MenuComboboxOption;
+ optionIndex: number;
+ value?: string[];
+ activeOptionIndex: number;
+ onItemActive: (index: number) => void;
+ onItemSelect: (value: string) => void;
+ listboxId: string;
+}) {
+ const isSelected = value?.includes(option.value) ?? false;
+ const isHighlighted = optionIndex === activeOptionIndex;
+ const itemId = getListboxItemId(listboxId, option.value);
+
+ const itemRef = useRef(null);
+
+ // Scroll the item into view when it becomes highlighted
+ useEffect(() => {
+ if (isHighlighted && itemRef.current) {
+ itemRef.current.scrollIntoView({ block: 'nearest' });
+ }
+ }, [isHighlighted]);
+
+ return (
+ !option.disabled && onItemActive(optionIndex)}
+ onClick={() => !option.disabled && onItemSelect(option.value)}
+ >
+
+ {isSelected && }
+
+
{option.label}
+
+ );
+}
+
+/** @public */
+export const Combobox = forwardRef(
+ (props, ref) => {
+ const {
+ options,
+ value,
+ onValueChange,
+ multiselect = false,
+ className,
+ ...rest
+ } = props;
+
+ const triggerId = useId();
+ const listboxId = `${triggerId}-listbox`;
+
+ // State management
+ const [filterString, setFilterString] = useState('');
+ const [activeOptionIndex, setActiveOptionIndex] = useState(0);
+
+ // Filter options based on input
+ const filteredOptions = useMemo(() => {
+ if (!filterString) return options;
+ const lowerFilterString = filterString.toLocaleLowerCase('en-US');
+ return options.filter(option =>
+ option.label.toLocaleLowerCase('en-US').includes(lowerFilterString),
+ );
+ }, [filterString, options]);
+
+ // Get the active descendant ID for accessibility
+ const activeDescendantId =
+ activeOptionIndex >= 0 && filteredOptions.length > 0
+ ? getListboxItemId(listboxId, filteredOptions[activeOptionIndex].value)
+ : undefined;
+
+ const handleValueChange = useCallback(
+ (toggledValue: string) => {
+ let newValue: string[];
+ if (multiselect) {
+ newValue = value?.includes(toggledValue)
+ ? value.filter(v => v !== toggledValue)
+ : [...(value ?? []), toggledValue];
+ } else {
+ newValue = value?.includes(toggledValue) ? [] : [toggledValue];
+ }
+
+ onValueChange?.(newValue);
+ },
+ [multiselect, onValueChange, value],
+ );
+
+ const handleSearchChange = useCallback(
+ (e: ChangeEvent) => {
+ setFilterString(e.target.value);
+ setActiveOptionIndex(0);
+ e.preventDefault();
+ },
+ [],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ let wasEscapeKey = false;
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setActiveOptionIndex(prev =>
+ Math.min(prev + 1, filteredOptions.length - 1),
+ );
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ setActiveOptionIndex(prev => Math.max(prev - 1, 0));
+ break;
+ case 'Home':
+ e.preventDefault();
+ setActiveOptionIndex(0);
+ break;
+ case 'End':
+ e.preventDefault();
+ setActiveOptionIndex(Math.max(filteredOptions.length - 1, 0));
+ break;
+ case 'Enter':
+ e.preventDefault();
+ if (
+ activeOptionIndex >= 0 &&
+ !filteredOptions[activeOptionIndex].disabled
+ ) {
+ handleValueChange(filteredOptions[activeOptionIndex].value);
+ }
+ break;
+ case 'Escape':
+ // The Menu component should handle this
+ wasEscapeKey = true;
+ break;
+ default:
+ break;
+ }
+
+ if (!wasEscapeKey) {
+ // Stop propagation so Menu components don't prevent the input from updating
+ e.stopPropagation();
+ }
+ },
+ [filteredOptions, activeOptionIndex, handleValueChange],
+ );
+
+ return (
+
+
+
+ {filteredOptions.length === 0 ? (
+
+ No results found
+
+ ) : (
+ filteredOptions.map((option, index) => (
+
+ ))
+ )}
+
+
+ );
+ },
+);
+Combobox.displayName = 'Combobox';
diff --git a/packages/canon/src/components/Menu/Menu.stories.tsx b/packages/canon/src/components/Menu/Menu.stories.tsx
index 5d04a2f19c..578ce945ae 100644
--- a/packages/canon/src/components/Menu/Menu.stories.tsx
+++ b/packages/canon/src/components/Menu/Menu.stories.tsx
@@ -16,8 +16,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Menu } from './Menu';
-import { Button } from '../Button';
-import { Icon } from '../Icon';
+import { Text, Icon, Button, Flex } from '../../index';
+import { useState } from 'react';
const meta = {
title: 'Components/Menu',
@@ -27,6 +27,18 @@ const 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: (
@@ -35,7 +47,7 @@ export const Default: Story = {
render={props => (
}
>
@@ -71,3 +83,149 @@ export const OpenOnHover: Story = {
openOnHover: true,
},
};
+
+export const Submenu: Story = {
+ args: {
+ children: (
+ <>
+ (
+ }
+ >
+ Menu
+
+ )}
+ />
+
+
+
+ Settings
+ Invite new members
+ Download app
+ Log out
+
+ Submenu
+
+
+
+ Submenu Item 1
+ Submenu Item 2
+ Submenu Item 3
+
+
+
+
+
+
+
+ >
+ ),
+ },
+};
+
+export const SubmenuCombobox = () => {
+ const [selectedValues, setSelectedValues] = useState([]);
+
+ return (
+
+
+ {selectedValues.length === 0
+ ? 'Which is your favorite fruit?'
+ : `Yum, ${selectedValues[0]} is delicious!`}
+
+
+ (
+ }
+ >
+ Select Fruits
+
+ )}
+ />
+
+
+
+ Regular Item
+
+ Fruits
+
+
+
+
+
+
+
+
+ Another Item
+
+
+
+
+
+ );
+};
+
+export const SubmenuComboboxMultiselect = () => {
+ const [selectedValues, setSelectedValues] = useState([]);
+
+ return (
+
+
+ {selectedValues.length === 0
+ ? 'Tell us what fruits you like.'
+ : `${selectedValues.join(
+ ', ',
+ )} would make for a great, healthy smoothy!`}
+
+
+ (
+ }
+ >
+ Select Fruits
+
+ )}
+ />
+
+
+
+ Regular Item
+
+ Fruits
+
+
+
+
+
+
+
+
+ Another Item
+
+
+
+
+
+ );
+};
diff --git a/packages/canon/src/components/Menu/Menu.styles.css b/packages/canon/src/components/Menu/Menu.styles.css
index 403d34294e..07d257f57f 100644
--- a/packages/canon/src/components/Menu/Menu.styles.css
+++ b/packages/canon/src/components/Menu/Menu.styles.css
@@ -3,16 +3,20 @@
}
.canon-MenuPopup {
- padding: var(--canon-space-2);
+ display: flex;
+ flex-direction: column;
border-radius: 0.375rem;
background-color: var(--canon-bg-surface-1);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
outline: none;
- overflow: hidden;
+ overflow: auto;
transform-origin: var(--transform-origin);
- max-width: 340px;
+ max-width: min(var(--available-width), 340px);
+ max-height: min(var(--available-height), 500px);
transition: transform 150ms, opacity 150ms;
+ position: relative;
+ padding-bottom: var(--canon-space-1);
&[data-starting-style],
&[data-ending-style] {
@@ -23,25 +27,61 @@
.canon-MenuItem {
outline: 0;
- cursor: default;
user-select: none;
display: flex;
+ height: 32px;
+ align-items: center;
gap: var(--canon-space-2);
color: var(--canon-fg-primary);
text-decoration: none;
border-radius: var(--canon-radius-2);
- padding: var(--canon-space-2) var(--canon-space-2);
- padding-right: var(--canon-space-4);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
cursor: pointer;
+ flex-shrink: 0;
- &:last-child {
- border-bottom: none;
+ &:first-child {
+ margin-top: var(--canon-space-1);
}
&[data-highlighted] {
- z-index: 0;
- position: relative;
- background-color: var(--canon-bg-tint-hover);
+ background-color: var(--canon-gray-3);
+ }
+}
+
+.canon-MenuSubmenuTrigger {
+ outline: 0;
+ user-select: none;
+ display: flex;
+ height: 32px;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--canon-space-2);
+ color: var(--canon-fg-primary);
+ text-decoration: none;
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ flex-shrink: 0;
+
+ & .canon-Icon {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:first-child {
+ margin-top: var(--canon-space-1);
+ }
+
+ &[data-popup-open],
+ &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+
+ .canon-Icon {
+ color: var(--canon-fg-primary);
+ }
}
}
@@ -50,3 +90,91 @@
height: 1px;
background-color: var(--color-gray-200);
}
+
+.canon-SubmenuComboboxSearch {
+ padding-inline: var(--canon-space-3);
+ height: 32px;
+ border: none;
+ border-bottom: 1px solid var(--canon-border);
+ background-color: var(--canon-bg-surface-1);
+ color: var(--canon-fg-primary);
+ line-height: 140%;
+ outline: none;
+ font-size: var(--canon-font-size-3);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+
+ &::placeholder {
+ color: var(--canon-fg-secondary);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItems {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ padding-top: var(--canon-space-2);
+ outline: none;
+}
+
+.canon-SubmenuComboboxNoResults {
+ padding-inline: var(--canon-space-3);
+ padding-top: var(--canon-space-2);
+ padding-bottom: var(--canon-space-4);
+ color: var(--canon-fg-secondary);
+ font-size: var(--canon-font-size-3);
+}
+
+.canon-SubmenuComboboxItem {
+ outline: 0;
+ user-select: none;
+ display: flex;
+ height: 32px;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--canon-space-2);
+ color: var(--canon-fg-primary);
+ text-decoration: none;
+ border-radius: var(--canon-radius-2);
+ margin-inline: var(--canon-space-1);
+ padding-inline: var(--canon-space-2);
+ font-size: var(--canon-font-size-3);
+ cursor: pointer;
+ user-select: none;
+ flex-shrink: 0;
+
+ &[data-highlighted] {
+ background-color: var(--canon-gray-3);
+ }
+
+ &[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.canon-SubmenuComboboxItemCheckbox {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ color: var(--canon-fg-primary);
+ flex-shrink: 0;
+ border-radius: var(--canon-radius-2);
+ border: 1px solid var(--canon-border);
+ background: var(--canon-bg-surface-1);
+}
+
+.canon-SubmenuComboboxItemLabel {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/packages/canon/src/components/Menu/Menu.tsx b/packages/canon/src/components/Menu/Menu.tsx
index 6b93d14865..123b01b283 100644
--- a/packages/canon/src/components/Menu/Menu.tsx
+++ b/packages/canon/src/components/Menu/Menu.tsx
@@ -18,6 +18,8 @@ import { forwardRef } from 'react';
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
import clsx from 'clsx';
import { MenuComponent } from './types';
+import { Combobox } from './Combobox';
+import { Icon } from '../Icon';
const MenuTrigger = forwardRef<
React.ElementRef,
@@ -180,12 +182,15 @@ MenuCheckboxItemIndicator.displayName =
const MenuSubmenuTrigger = forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+>(({ className, children, ...props }, ref) => (
+ >
+ {children}
+
+
));
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
@@ -220,4 +225,5 @@ export const Menu: MenuComponent = {
CheckboxItemIndicator: MenuCheckboxItemIndicator,
SubmenuTrigger: MenuSubmenuTrigger,
Separator: MenuSeparator,
+ Combobox,
};
diff --git a/packages/canon/src/components/Menu/types.ts b/packages/canon/src/components/Menu/types.ts
index 63f8069f61..9071f9a921 100644
--- a/packages/canon/src/components/Menu/types.ts
+++ b/packages/canon/src/components/Menu/types.ts
@@ -13,7 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
+import {
+ ForwardRefExoticComponent,
+ RefAttributes,
+ ComponentProps,
+} from 'react';
+
+/** @public */
+export type MenuComboboxOption = {
+ label: string;
+ value: string;
+ disabled?: boolean;
+};
+
+/** @public */
+export interface MenuComboboxProps extends ComponentProps<'div'> {
+ options: MenuComboboxOption[];
+ value?: string[];
+ onValueChange?: (value: string[]) => void;
+ multiselect?: boolean;
+ closeParentOnEsc?: boolean;
+}
/** @public */
export type MenuComponent = {
@@ -34,4 +56,7 @@ export type MenuComponent = {
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
Separator: typeof MenuPrimitive.Separator;
+ Combobox: ForwardRefExoticComponent<
+ MenuComboboxProps & RefAttributes
+ >;
};