Improve nested menu + combobox

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-05-16 16:35:06 +01:00
parent bb68c51045
commit f726297733
8 changed files with 937 additions and 52 deletions
+124 -12
View File
@@ -683,16 +683,19 @@
}
.canon-MenuPopup {
padding: var(--canon-space-2);
padding: var(--canon-space-1) 0;
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);
border-radius: .375rem;
outline: none;
max-width: 340px;
flex-direction: column;
transition: transform .15s, opacity .15s;
overflow: hidden;
display: flex;
overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -701,26 +704,51 @@
}
.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;
text-decoration: none;
display: flex;
&:last-child {
border-bottom: none;
&[data-highlighted] {
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;
text-decoration: none;
display: flex;
& .canon-Icon {
color: var(--canon-fg-secondary);
}
&[data-highlighted] {
z-index: 0;
background-color: var(--canon-bg-tint-hover);
position: relative;
&[data-popup-open], &[data-highlighted] {
background-color: var(--canon-gray-3);
& .canon-Icon {
color: var(--canon-fg-primary);
}
}
}
@@ -730,6 +758,90 @@
margin: .375rem 1rem;
}
.canon-SubmenuComboboxSearch {
padding: var(--canon-space-2) var(--canon-space-5);
border: none;
border-bottom: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
color: var(--canon-fg-primary);
outline: none;
line-height: 140%;
&::placeholder {
color: var(--canon-fg-secondary);
}
&:focus {
border-color: var(--canon-border-hover);
}
&: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: var(--canon-space-3);
padding-left: var(--canon-space-5);
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;
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);
+124 -12
View File
@@ -3,16 +3,19 @@
}
.canon-MenuPopup {
padding: var(--canon-space-2);
padding: var(--canon-space-1) 0;
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);
border-radius: .375rem;
outline: none;
max-width: 340px;
flex-direction: column;
transition: transform .15s, opacity .15s;
overflow: hidden;
display: flex;
overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -21,26 +24,51 @@
}
.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;
text-decoration: none;
display: flex;
&:last-child {
border-bottom: none;
&[data-highlighted] {
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;
text-decoration: none;
display: flex;
& .canon-Icon {
color: var(--canon-fg-secondary);
}
&[data-highlighted] {
z-index: 0;
background-color: var(--canon-bg-tint-hover);
position: relative;
&[data-popup-open], &[data-highlighted] {
background-color: var(--canon-gray-3);
& .canon-Icon {
color: var(--canon-fg-primary);
}
}
}
@@ -49,3 +77,87 @@
height: 1px;
margin: .375rem 1rem;
}
.canon-SubmenuComboboxSearch {
padding: var(--canon-space-2) var(--canon-space-5);
border: none;
border-bottom: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
color: var(--canon-fg-primary);
outline: none;
line-height: 140%;
&::placeholder {
color: var(--canon-fg-secondary);
}
&:focus {
border-color: var(--canon-border-hover);
}
&: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: var(--canon-space-3);
padding-left: var(--canon-space-5);
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;
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;
}
+124 -12
View File
@@ -9907,16 +9907,19 @@
}
.canon-MenuPopup {
padding: var(--canon-space-2);
padding: var(--canon-space-1) 0;
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);
border-radius: .375rem;
outline: none;
max-width: 340px;
flex-direction: column;
transition: transform .15s, opacity .15s;
overflow: hidden;
display: flex;
overflow: auto;
&[data-starting-style], &[data-ending-style] {
opacity: 0;
@@ -9925,26 +9928,51 @@
}
.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;
text-decoration: none;
display: flex;
&:last-child {
border-bottom: none;
&[data-highlighted] {
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;
text-decoration: none;
display: flex;
& .canon-Icon {
color: var(--canon-fg-secondary);
}
&[data-highlighted] {
z-index: 0;
background-color: var(--canon-bg-tint-hover);
position: relative;
&[data-popup-open], &[data-highlighted] {
background-color: var(--canon-gray-3);
& .canon-Icon {
color: var(--canon-fg-primary);
}
}
}
@@ -9954,6 +9982,90 @@
margin: .375rem 1rem;
}
.canon-SubmenuComboboxSearch {
padding: var(--canon-space-2) var(--canon-space-5);
border: none;
border-bottom: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
color: var(--canon-fg-primary);
outline: none;
line-height: 140%;
&::placeholder {
color: var(--canon-fg-secondary);
}
&:focus {
border-color: var(--canon-border-hover);
}
&: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: var(--canon-space-3);
padding-left: var(--canon-space-5);
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;
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);
@@ -0,0 +1,246 @@
/*
* 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 { ComboboxOption, ComboboxProps } 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: ComboboxOption;
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, ComboboxProps>(
(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 } 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 (
<>
<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>
</>
);
};
export const SubmenuComboboxMultiselect = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
return (
<>
<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>
</>
);
};
@@ -3,15 +3,18 @@
}
.canon-MenuPopup {
padding: var(--canon-space-2);
padding: var(--canon-space-1) 0;
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;
&[data-starting-style],
@@ -23,25 +26,51 @@
.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;
&:last-child {
border-bottom: none;
&[data-highlighted] {
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;
& .canon-Icon {
color: var(--canon-fg-secondary);
}
&[data-popup-open],
&[data-highlighted] {
z-index: 0;
position: relative;
background-color: var(--canon-bg-tint-hover);
background-color: var(--canon-gray-3);
.canon-Icon {
color: var(--canon-fg-primary);
}
}
}
@@ -50,3 +79,88 @@
height: 1px;
background-color: var(--color-gray-200);
}
.canon-SubmenuComboboxSearch {
padding: var(--canon-space-2) var(--canon-space-5);
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;
&::placeholder {
color: var(--canon-fg-secondary);
}
&:focus {
border-color: var(--canon-border-hover);
}
&: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: var(--canon-space-3);
padding-left: var(--canon-space-5);
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;
&[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,13 @@
* 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 MenuComponent = {
@@ -34,4 +40,23 @@ export type MenuComponent = {
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
Separator: typeof MenuPrimitive.Separator;
Combobox: ForwardRefExoticComponent<
ComboboxProps & RefAttributes<HTMLDivElement>
>;
};
/** @public */
export type ComboboxOption = {
label: string;
value: string;
disabled?: boolean;
};
/** @public */
export interface ComboboxProps extends ComponentProps<'div'> {
options: ComboboxOption[];
value?: string[];
onValueChange?: (value: string[]) => void;
multiselect?: boolean;
closeParentOnEsc?: boolean;
}