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={` ( + + )} + /> + + + + 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 => ( + )} + /> + + + + 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!`} + + + ( + + )} + /> + + + + 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!`} + + + ( + + )} + /> + + + + 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 + >; };