Merge pull request #29986 from backstage/canon-menu-updates

Canon - Improve nested menu + combobox
This commit is contained in:
Charles de Dreuille
2025-05-16 19:03:37 +01:00
committed by GitHub
11 changed files with 1064 additions and 49 deletions
+5
View File
@@ -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.
@@ -155,3 +155,48 @@ You can additionally configure how quickly the menu opens on hover using the `de
</Menu.Portal>
</Menu.Root>`}
/>
### Nested navigation
You can nest menus to create a more complex navigation structure.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="Submenu" />}
code={`<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Submenu Item 1</Menu.Item>
<Menu.Item>Submenu Item 2</Menu.Item>
<Menu.Item>Submenu Item 3</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>`}
/>
+137 -11
View File
@@ -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);
+137 -11
View File
@@ -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;
}
+137 -11
View File
@@ -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);
+25
View File
@@ -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<typeof marginPropDefs>;
// @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<HTMLDivElement>
>;
};
// @public (undocumented)
@@ -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<HTMLDivElement>(null);
// Scroll the item into view when it becomes highlighted
useEffect(() => {
if (isHighlighted && itemRef.current) {
itemRef.current.scrollIntoView({ block: 'nearest' });
}
}, [isHighlighted]);
return (
<div
ref={itemRef}
className="canon-SubmenuComboboxItem"
id={itemId}
role="option"
aria-selected={isSelected}
data-highlighted={isHighlighted ? true : undefined}
data-selected={isSelected ? true : undefined}
data-disabled={option.disabled ? true : undefined}
onMouseOver={() => !option.disabled && onItemActive(optionIndex)}
onClick={() => !option.disabled && onItemSelect(option.value)}
>
<div className="canon-SubmenuComboboxItemCheckbox">
{isSelected && <Icon aria-hidden="true" name="check" size={12} />}
</div>
<div className="canon-SubmenuComboboxItemLabel">{option.label}</div>
</div>
);
}
/** @public */
export const Combobox = forwardRef<HTMLDivElement, MenuComboboxProps>(
(props, ref) => {
const {
options,
value,
onValueChange,
multiselect = false,
className,
...rest
} = props;
const triggerId = useId();
const listboxId = `${triggerId}-listbox`;
// State management
const [filterString, setFilterString] = useState<string>('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(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<HTMLInputElement>) => {
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 (
<div
ref={ref}
role="combobox"
className={clsx('canon-MenuCombobox', className)}
{...rest}
>
<input
className="canon-SubmenuComboboxSearch"
type="text"
role="combobox"
placeholder="Filter..."
aria-labelledby={triggerId}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeDescendantId}
aria-expanded="true"
aria-haspopup="listbox"
value={filterString}
onKeyDown={handleKeyDown}
onChange={handleSearchChange}
/>
<div
role="listbox"
id={listboxId}
tabIndex={-1}
aria-multiselectable={multiselect ? true : undefined}
className="canon-SubmenuComboboxItems"
>
{filteredOptions.length === 0 ? (
<div className="canon-SubmenuComboboxNoResults">
No results found
</div>
) : (
filteredOptions.map((option, index) => (
<ComboboxItem
key={option.value}
option={option}
optionIndex={index}
value={value}
activeOptionIndex={activeOptionIndex}
onItemActive={setActiveOptionIndex}
onItemSelect={handleValueChange}
listboxId={listboxId}
/>
))
)}
</div>
</div>
);
},
);
Combobox.displayName = 'Combobox';
@@ -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<typeof meta>;
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 => (
<Button
{...props}
size="medium"
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
@@ -71,3 +83,149 @@ export const OpenOnHover: Story = {
openOnHover: true,
},
};
export const Submenu: Story = {
args: {
children: (
<>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Submenu Item 1</Menu.Item>
<Menu.Item>Submenu Item 2</Menu.Item>
<Menu.Item>Submenu Item 3</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</>
),
},
};
export const SubmenuCombobox = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
return (
<Flex direction="column" gap="1" align="start">
<Text style={{ marginBottom: 16 }}>
{selectedValues.length === 0
? 'Which is your favorite fruit?'
: `Yum, ${selectedValues[0]} is delicious!`}
</Text>
<Menu.Root>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Select Fruits
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="start">
<Menu.Popup>
<Menu.Item>Regular Item</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Combobox
options={options}
value={selectedValues}
onValueChange={setSelectedValues}
/>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Menu.Item>Another Item</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Flex>
);
};
export const SubmenuComboboxMultiselect = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
return (
<Flex direction="column" gap="1" align="start">
<Text style={{ marginBottom: 16 }}>
{selectedValues.length === 0
? 'Tell us what fruits you like.'
: `${selectedValues.join(
', ',
)} would make for a great, healthy smoothy!`}
</Text>
<Menu.Root>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Select Fruits
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Regular Item</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Combobox
multiselect
options={options}
value={selectedValues}
onValueChange={setSelectedValues}
/>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Menu.Item>Another Item</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Flex>
);
};
@@ -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;
}
+8 -2
View File
@@ -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<typeof MenuPrimitive.Trigger>,
@@ -180,12 +182,15 @@ MenuCheckboxItemIndicator.displayName =
const MenuSubmenuTrigger = forwardRef<
React.ElementRef<typeof MenuPrimitive.SubmenuTrigger>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger>
>(({ className, ...props }, ref) => (
>(({ className, children, ...props }, ref) => (
<MenuPrimitive.SubmenuTrigger
ref={ref}
className={clsx('canon-MenuSubmenuTrigger', className)}
{...props}
/>
>
<div>{children}</div>
<Icon aria-label="Submenu indicator icon" name="chevron-right" size={20} />
</MenuPrimitive.SubmenuTrigger>
));
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
@@ -220,4 +225,5 @@ export const Menu: MenuComponent = {
CheckboxItemIndicator: MenuCheckboxItemIndicator,
SubmenuTrigger: MenuSubmenuTrigger,
Separator: MenuSeparator,
Combobox,
};
@@ -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<HTMLDivElement>
>;
};