From f726297733a26706d6ab23d0511be15e55c1cbfa Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 16 May 2025 16:35:06 +0100 Subject: [PATCH 1/5] Improve nested menu + combobox Signed-off-by: Charles de Dreuille --- packages/canon/css/components.css | 136 +++++++++- packages/canon/css/menu.css | 136 +++++++++- packages/canon/css/styles.css | 136 +++++++++- .../canon/src/components/Menu/Combobox.tsx | 246 ++++++++++++++++++ .../src/components/Menu/Menu.stories.tsx | 164 +++++++++++- .../canon/src/components/Menu/Menu.styles.css | 136 +++++++++- packages/canon/src/components/Menu/Menu.tsx | 10 +- packages/canon/src/components/Menu/types.ts | 25 ++ 8 files changed, 937 insertions(+), 52 deletions(-) create mode 100644 packages/canon/src/components/Menu/Combobox.tsx diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index d479444df9..cd0809dadb 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -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); diff --git a/packages/canon/css/menu.css b/packages/canon/css/menu.css index de811cde77..d901273cb3 100644 --- a/packages/canon/css/menu.css +++ b/packages/canon/css/menu.css @@ -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; +} diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 6c53aaa198..08aaa80970 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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); diff --git a/packages/canon/src/components/Menu/Combobox.tsx b/packages/canon/src/components/Menu/Combobox.tsx new file mode 100644 index 0000000000..8f2955f5e6 --- /dev/null +++ b/packages/canon/src/components/Menu/Combobox.tsx @@ -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(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..a41faffc19 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 } 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..27e60708aa 100644 --- a/packages/canon/src/components/Menu/Menu.styles.css +++ b/packages/canon/src/components/Menu/Menu.styles.css @@ -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; +} 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..7bc4fa47aa 100644 --- a/packages/canon/src/components/Menu/types.ts +++ b/packages/canon/src/components/Menu/types.ts @@ -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 + >; }; + +/** @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; +} From 4551fb753076f8a3e39aad51a7856a1322ce9238 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 16 May 2025 16:45:30 +0100 Subject: [PATCH 2/5] Improve report + naming Signed-off-by: Charles de Dreuille --- .changeset/upset-papayas-flash.md | 5 +++ packages/canon/report.api.md | 25 ++++++++++++++ .../canon/src/components/Menu/Combobox.tsx | 6 ++-- packages/canon/src/components/Menu/types.ts | 34 +++++++++---------- 4 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 .changeset/upset-papayas-flash.md 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/packages/canon/report.api.md b/packages/canon/report.api.md index cbbdd4db33..87d6e11f6e 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'; @@ -891,6 +892,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; @@ -910,6 +932,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 index 8f2955f5e6..7d683c4718 100644 --- a/packages/canon/src/components/Menu/Combobox.tsx +++ b/packages/canon/src/components/Menu/Combobox.tsx @@ -26,7 +26,7 @@ import { useEffect, } from 'react'; import clsx from 'clsx'; -import { ComboboxOption, ComboboxProps } from './types'; +import { MenuComboboxOption, MenuComboboxProps } from './types'; import { Icon } from '@backstage/canon'; const getListboxItemId = (listboxId: string, optionValue: string): string => @@ -42,7 +42,7 @@ function ComboboxItem({ onItemSelect, listboxId, }: { - option: ComboboxOption; + option: MenuComboboxOption; optionIndex: number; value?: string[]; activeOptionIndex: number; @@ -85,7 +85,7 @@ function ComboboxItem({ } /** @public */ -export const Combobox = forwardRef( +export const Combobox = forwardRef( (props, ref) => { const { options, diff --git a/packages/canon/src/components/Menu/types.ts b/packages/canon/src/components/Menu/types.ts index 7bc4fa47aa..9071f9a921 100644 --- a/packages/canon/src/components/Menu/types.ts +++ b/packages/canon/src/components/Menu/types.ts @@ -21,6 +21,22 @@ import { 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 = { Root: typeof MenuPrimitive.Root; @@ -41,22 +57,6 @@ export type MenuComponent = { SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger; Separator: typeof MenuPrimitive.Separator; Combobox: ForwardRefExoticComponent< - ComboboxProps & RefAttributes + MenuComboboxProps & RefAttributes >; }; - -/** @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; -} From ea4628d64d9dc9a5e2980c3eaadaa07f5d6c6a8f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 16 May 2025 17:20:49 +0100 Subject: [PATCH 3/5] Fix some styling Signed-off-by: Charles de Dreuille --- .../canon/src/components/Menu/Combobox.tsx | 3 +- .../canon/src/components/Menu/Menu.styles.css | 29 ++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/canon/src/components/Menu/Combobox.tsx b/packages/canon/src/components/Menu/Combobox.tsx index 7d683c4718..c9593ed2a4 100644 --- a/packages/canon/src/components/Menu/Combobox.tsx +++ b/packages/canon/src/components/Menu/Combobox.tsx @@ -201,7 +201,7 @@ export const Combobox = forwardRef( className="canon-SubmenuComboboxSearch" type="text" role="combobox" - placeholder="Filter" + placeholder="Filter..." aria-labelledby={triggerId} aria-controls={listboxId} aria-autocomplete="list" @@ -212,7 +212,6 @@ export const Combobox = forwardRef( onKeyDown={handleKeyDown} onChange={handleSearchChange} /> -
Date: Fri, 16 May 2025 17:32:44 +0100 Subject: [PATCH 4/5] Build CSS Signed-off-by: Charles de Dreuille --- packages/canon/css/components.css | 31 ++++++++++++++++++++++--------- packages/canon/css/menu.css | 31 ++++++++++++++++++++++--------- packages/canon/css/styles.css | 31 ++++++++++++++++++++++--------- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index cd0809dadb..3ef8175c46 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -683,7 +683,6 @@ } .canon-MenuPopup { - padding: var(--canon-space-1) 0; background-color: var(--canon-bg-surface-1); border: 1px solid var(--canon-border); color: var(--canon-fg-primary); @@ -695,6 +694,7 @@ flex-direction: column; transition: transform .15s, opacity .15s; display: flex; + position: relative; overflow: auto; &[data-starting-style], &[data-ending-style] { @@ -715,9 +715,18 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; + &:first-child { + margin-top: var(--canon-space-1); + } + + &:last-child { + margin-bottom: var(--canon-space-1); + } + &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -736,6 +745,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; @@ -759,22 +769,23 @@ } .canon-SubmenuComboboxSearch { - padding: var(--canon-space-2) var(--canon-space-5); + 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); - outline: none; 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); } - &:focus { - border-color: var(--canon-border-hover); - } - &:disabled { opacity: .6; cursor: not-allowed; @@ -790,8 +801,9 @@ } .canon-SubmenuComboboxNoResults { - padding: var(--canon-space-3); - padding-left: var(--canon-space-5); + 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); } @@ -809,6 +821,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; diff --git a/packages/canon/css/menu.css b/packages/canon/css/menu.css index d901273cb3..7671217198 100644 --- a/packages/canon/css/menu.css +++ b/packages/canon/css/menu.css @@ -3,7 +3,6 @@ } .canon-MenuPopup { - padding: var(--canon-space-1) 0; background-color: var(--canon-bg-surface-1); border: 1px solid var(--canon-border); color: var(--canon-fg-primary); @@ -15,6 +14,7 @@ flex-direction: column; transition: transform .15s, opacity .15s; display: flex; + position: relative; overflow: auto; &[data-starting-style], &[data-ending-style] { @@ -35,9 +35,18 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; + &:first-child { + margin-top: var(--canon-space-1); + } + + &:last-child { + margin-bottom: var(--canon-space-1); + } + &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -56,6 +65,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; @@ -79,22 +89,23 @@ } .canon-SubmenuComboboxSearch { - padding: var(--canon-space-2) var(--canon-space-5); + 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); - outline: none; 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); } - &:focus { - border-color: var(--canon-border-hover); - } - &:disabled { opacity: .6; cursor: not-allowed; @@ -110,8 +121,9 @@ } .canon-SubmenuComboboxNoResults { - padding: var(--canon-space-3); - padding-left: var(--canon-space-5); + 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); } @@ -129,6 +141,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 08aaa80970..07bac6155c 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -9907,7 +9907,6 @@ } .canon-MenuPopup { - padding: var(--canon-space-1) 0; background-color: var(--canon-bg-surface-1); border: 1px solid var(--canon-border); color: var(--canon-fg-primary); @@ -9919,6 +9918,7 @@ flex-direction: column; transition: transform .15s, opacity .15s; display: flex; + position: relative; overflow: auto; &[data-starting-style], &[data-ending-style] { @@ -9939,9 +9939,18 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; + &:first-child { + margin-top: var(--canon-space-1); + } + + &:last-child { + margin-bottom: var(--canon-space-1); + } + &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -9960,6 +9969,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; @@ -9983,22 +9993,23 @@ } .canon-SubmenuComboboxSearch { - padding: var(--canon-space-2) var(--canon-space-5); + 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); - outline: none; 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); } - &:focus { - border-color: var(--canon-border-hover); - } - &:disabled { opacity: .6; cursor: not-allowed; @@ -10014,8 +10025,9 @@ } .canon-SubmenuComboboxNoResults { - padding: var(--canon-space-3); - padding-left: var(--canon-space-5); + 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); } @@ -10033,6 +10045,7 @@ font-size: var(--canon-font-size-3); cursor: pointer; outline: 0; + flex-shrink: 0; text-decoration: none; display: flex; From 384a4892abb560059d7cdc450db1b6343b8f00b9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 16 May 2025 18:27:15 +0100 Subject: [PATCH 5/5] Improve docs Signed-off-by: Charles de Dreuille --- .../src/app/(docs)/components/menu/page.mdx | 45 +++++++++++++++++++ packages/canon/css/components.css | 9 ++-- packages/canon/css/menu.css | 9 ++-- packages/canon/css/styles.css | 9 ++-- .../src/components/Menu/Menu.stories.tsx | 10 ++--- .../canon/src/components/Menu/Menu.styles.css | 9 ++-- 6 files changed, 70 insertions(+), 21 deletions(-) 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 23f1b69728..124d575a96 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -701,6 +701,7 @@ 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; flex-direction: column; @@ -735,10 +736,6 @@ margin-top: var(--canon-space-1); } - &:last-child { - margin-bottom: var(--canon-space-1); - } - &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -765,6 +762,10 @@ color: var(--canon-fg-secondary); } + &:first-child { + margin-top: var(--canon-space-1); + } + &[data-popup-open], &[data-highlighted] { background-color: var(--canon-gray-3); diff --git a/packages/canon/css/menu.css b/packages/canon/css/menu.css index 7671217198..4c9433275c 100644 --- a/packages/canon/css/menu.css +++ b/packages/canon/css/menu.css @@ -9,6 +9,7 @@ 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; flex-direction: column; @@ -43,10 +44,6 @@ margin-top: var(--canon-space-1); } - &:last-child { - margin-bottom: var(--canon-space-1); - } - &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -73,6 +70,10 @@ color: var(--canon-fg-secondary); } + &:first-child { + margin-top: var(--canon-space-1); + } + &[data-popup-open], &[data-highlighted] { background-color: var(--canon-gray-3); diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index c441260aaa..084aef2855 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -9925,6 +9925,7 @@ 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; flex-direction: column; @@ -9959,10 +9960,6 @@ margin-top: var(--canon-space-1); } - &:last-child { - margin-bottom: var(--canon-space-1); - } - &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -9989,6 +9986,10 @@ color: var(--canon-fg-secondary); } + &:first-child { + margin-top: var(--canon-space-1); + } + &[data-popup-open], &[data-highlighted] { background-color: var(--canon-gray-3); diff --git a/packages/canon/src/components/Menu/Menu.stories.tsx b/packages/canon/src/components/Menu/Menu.stories.tsx index a41faffc19..578ce945ae 100644 --- a/packages/canon/src/components/Menu/Menu.stories.tsx +++ b/packages/canon/src/components/Menu/Menu.stories.tsx @@ -16,7 +16,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Menu } from './Menu'; -import { Text, Icon, Button } from '../../index'; +import { Text, Icon, Button, Flex } from '../../index'; import { useState } from 'react'; const meta = { @@ -131,7 +131,7 @@ export const SubmenuCombobox = () => { const [selectedValues, setSelectedValues] = useState([]); return ( - <> + {selectedValues.length === 0 ? 'Which is your favorite fruit?' @@ -173,7 +173,7 @@ export const SubmenuCombobox = () => { - + ); }; @@ -181,7 +181,7 @@ export const SubmenuComboboxMultiselect = () => { const [selectedValues, setSelectedValues] = useState([]); return ( - <> + {selectedValues.length === 0 ? 'Tell us what fruits you like.' @@ -226,6 +226,6 @@ export const SubmenuComboboxMultiselect = () => { - + ); }; diff --git a/packages/canon/src/components/Menu/Menu.styles.css b/packages/canon/src/components/Menu/Menu.styles.css index 4e2b473162..07d257f57f 100644 --- a/packages/canon/src/components/Menu/Menu.styles.css +++ b/packages/canon/src/components/Menu/Menu.styles.css @@ -16,6 +16,7 @@ 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] { @@ -44,10 +45,6 @@ margin-top: var(--canon-space-1); } - &:last-child { - margin-bottom: var(--canon-space-1); - } - &[data-highlighted] { background-color: var(--canon-gray-3); } @@ -74,6 +71,10 @@ color: var(--canon-fg-secondary); } + &:first-child { + margin-top: var(--canon-space-1); + } + &[data-popup-open], &[data-highlighted] { background-color: var(--canon-gray-3);