Merge pull request #29986 from backstage/canon-menu-updates
Canon - Improve nested menu + combobox
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
Update Menu component in Canon to make the UI more condensed. We are also adding a new Combobox option for nested navigation.
|
||||
@@ -155,3 +155,48 @@ You can additionally configure how quickly the menu opens on hover using the `de
|
||||
</Menu.Portal>
|
||||
</Menu.Root>`}
|
||||
/>
|
||||
|
||||
### Nested navigation
|
||||
|
||||
You can nest menus to create a more complex navigation structure.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<MenuSnippet story="Submenu" />}
|
||||
code={`<Menu.Trigger
|
||||
render={props => (
|
||||
<Button
|
||||
{...props}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
iconEnd={<Icon name="chevron-down" />}
|
||||
>
|
||||
Menu
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={8} align="start">
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Settings</Menu.Item>
|
||||
<Menu.Item>Invite new members</Menu.Item>
|
||||
<Menu.Item>Download app</Menu.Item>
|
||||
<Menu.Item>Log out</Menu.Item>
|
||||
<Menu.Root>
|
||||
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Submenu Item 1</Menu.Item>
|
||||
<Menu.Item>Submenu Item 2</Menu.Item>
|
||||
<Menu.Item>Submenu Item 3</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>`}
|
||||
/>
|
||||
|
||||
@@ -695,16 +695,20 @@
|
||||
}
|
||||
|
||||
.canon-MenuPopup {
|
||||
padding: var(--canon-space-2);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
border: 1px solid var(--canon-border);
|
||||
color: var(--canon-fg-primary);
|
||||
transform-origin: var(--transform-origin);
|
||||
max-width: min(var(--available-width), 340px);
|
||||
max-height: min(var(--available-height), 500px);
|
||||
padding-bottom: var(--canon-space-1);
|
||||
border-radius: .375rem;
|
||||
outline: none;
|
||||
max-width: 340px;
|
||||
flex-direction: column;
|
||||
transition: transform .15s, opacity .15s;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
|
||||
&[data-starting-style], &[data-ending-style] {
|
||||
opacity: 0;
|
||||
@@ -713,26 +717,61 @@
|
||||
}
|
||||
|
||||
.canon-MenuItem {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
padding: var(--canon-space-2) var(--canon-space-2);
|
||||
padding-right: var(--canon-space-4);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
z-index: 0;
|
||||
background-color: var(--canon-bg-tint-hover);
|
||||
position: relative;
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
.canon-MenuSubmenuTrigger {
|
||||
user-select: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
& .canon-Icon {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-popup-open], &[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
|
||||
& .canon-Icon {
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +781,93 @@
|
||||
margin: .375rem 1rem;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxSearch {
|
||||
padding-inline: var(--canon-space-3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--canon-border);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
line-height: 140%;
|
||||
font-size: var(--canon-font-size-3);
|
||||
z-index: 1;
|
||||
outline: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItems {
|
||||
padding-top: var(--canon-space-2);
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxNoResults {
|
||||
padding-inline: var(--canon-space-3);
|
||||
padding-top: var(--canon-space-2);
|
||||
padding-bottom: var(--canon-space-4);
|
||||
color: var(--canon-fg-secondary);
|
||||
font-size: var(--canon-font-size-3);
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItem {
|
||||
user-select: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemCheckbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
background: var(--canon-bg-surface-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canon-Link {
|
||||
font-family: var(--canon-font-regular);
|
||||
color: var(--canon-fg-link);
|
||||
|
||||
+137
-11
@@ -3,16 +3,20 @@
|
||||
}
|
||||
|
||||
.canon-MenuPopup {
|
||||
padding: var(--canon-space-2);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
border: 1px solid var(--canon-border);
|
||||
color: var(--canon-fg-primary);
|
||||
transform-origin: var(--transform-origin);
|
||||
max-width: min(var(--available-width), 340px);
|
||||
max-height: min(var(--available-height), 500px);
|
||||
padding-bottom: var(--canon-space-1);
|
||||
border-radius: .375rem;
|
||||
outline: none;
|
||||
max-width: 340px;
|
||||
flex-direction: column;
|
||||
transition: transform .15s, opacity .15s;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
|
||||
&[data-starting-style], &[data-ending-style] {
|
||||
opacity: 0;
|
||||
@@ -21,26 +25,61 @@
|
||||
}
|
||||
|
||||
.canon-MenuItem {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
padding: var(--canon-space-2) var(--canon-space-2);
|
||||
padding-right: var(--canon-space-4);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
z-index: 0;
|
||||
background-color: var(--canon-bg-tint-hover);
|
||||
position: relative;
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
.canon-MenuSubmenuTrigger {
|
||||
user-select: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
& .canon-Icon {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-popup-open], &[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
|
||||
& .canon-Icon {
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +88,90 @@
|
||||
height: 1px;
|
||||
margin: .375rem 1rem;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxSearch {
|
||||
padding-inline: var(--canon-space-3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--canon-border);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
line-height: 140%;
|
||||
font-size: var(--canon-font-size-3);
|
||||
z-index: 1;
|
||||
outline: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItems {
|
||||
padding-top: var(--canon-space-2);
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxNoResults {
|
||||
padding-inline: var(--canon-space-3);
|
||||
padding-top: var(--canon-space-2);
|
||||
padding-bottom: var(--canon-space-4);
|
||||
color: var(--canon-fg-secondary);
|
||||
font-size: var(--canon-font-size-3);
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItem {
|
||||
user-select: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemCheckbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
background: var(--canon-bg-surface-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+137
-11
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
|
||||
import { Breakpoint as Breakpoint_2 } from '@backstage/canon';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
|
||||
import { ComponentProps } from 'react';
|
||||
import { Context } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { FC } from 'react';
|
||||
@@ -893,6 +894,27 @@ export type MarginProps = GetPropDefTypes<typeof marginPropDefs>;
|
||||
// @public (undocumented)
|
||||
export const Menu: MenuComponent;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MenuComboboxOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuComboboxProps extends ComponentProps<'div'> {
|
||||
// (undocumented)
|
||||
closeParentOnEsc?: boolean;
|
||||
// (undocumented)
|
||||
multiselect?: boolean;
|
||||
// (undocumented)
|
||||
onValueChange?: (value: string[]) => void;
|
||||
// (undocumented)
|
||||
options: MenuComboboxOption[];
|
||||
// (undocumented)
|
||||
value?: string[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type MenuComponent = {
|
||||
Root: typeof Menu_2.Root;
|
||||
@@ -912,6 +934,9 @@ export type MenuComponent = {
|
||||
CheckboxItemIndicator: typeof Menu_2.CheckboxItemIndicator;
|
||||
SubmenuTrigger: typeof Menu_2.SubmenuTrigger;
|
||||
Separator: typeof Menu_2.Separator;
|
||||
Combobox: ForwardRefExoticComponent<
|
||||
MenuComboboxProps & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useId,
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { MenuComboboxOption, MenuComboboxProps } from './types';
|
||||
import { Icon } from '@backstage/canon';
|
||||
|
||||
const getListboxItemId = (listboxId: string, optionValue: string): string =>
|
||||
`${listboxId}-option-${optionValue}`;
|
||||
|
||||
// Internal component for rendering individual items
|
||||
function ComboboxItem({
|
||||
option,
|
||||
optionIndex,
|
||||
value,
|
||||
activeOptionIndex,
|
||||
onItemActive,
|
||||
onItemSelect,
|
||||
listboxId,
|
||||
}: {
|
||||
option: MenuComboboxOption;
|
||||
optionIndex: number;
|
||||
value?: string[];
|
||||
activeOptionIndex: number;
|
||||
onItemActive: (index: number) => void;
|
||||
onItemSelect: (value: string) => void;
|
||||
listboxId: string;
|
||||
}) {
|
||||
const isSelected = value?.includes(option.value) ?? false;
|
||||
const isHighlighted = optionIndex === activeOptionIndex;
|
||||
const itemId = getListboxItemId(listboxId, option.value);
|
||||
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll the item into view when it becomes highlighted
|
||||
useEffect(() => {
|
||||
if (isHighlighted && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [isHighlighted]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="canon-SubmenuComboboxItem"
|
||||
id={itemId}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
data-highlighted={isHighlighted ? true : undefined}
|
||||
data-selected={isSelected ? true : undefined}
|
||||
data-disabled={option.disabled ? true : undefined}
|
||||
onMouseOver={() => !option.disabled && onItemActive(optionIndex)}
|
||||
onClick={() => !option.disabled && onItemSelect(option.value)}
|
||||
>
|
||||
<div className="canon-SubmenuComboboxItemCheckbox">
|
||||
{isSelected && <Icon aria-hidden="true" name="check" size={12} />}
|
||||
</div>
|
||||
<div className="canon-SubmenuComboboxItemLabel">{option.label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const Combobox = forwardRef<HTMLDivElement, MenuComboboxProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
multiselect = false,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const triggerId = useId();
|
||||
const listboxId = `${triggerId}-listbox`;
|
||||
|
||||
// State management
|
||||
const [filterString, setFilterString] = useState<string>('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(0);
|
||||
|
||||
// Filter options based on input
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!filterString) return options;
|
||||
const lowerFilterString = filterString.toLocaleLowerCase('en-US');
|
||||
return options.filter(option =>
|
||||
option.label.toLocaleLowerCase('en-US').includes(lowerFilterString),
|
||||
);
|
||||
}, [filterString, options]);
|
||||
|
||||
// Get the active descendant ID for accessibility
|
||||
const activeDescendantId =
|
||||
activeOptionIndex >= 0 && filteredOptions.length > 0
|
||||
? getListboxItemId(listboxId, filteredOptions[activeOptionIndex].value)
|
||||
: undefined;
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(toggledValue: string) => {
|
||||
let newValue: string[];
|
||||
if (multiselect) {
|
||||
newValue = value?.includes(toggledValue)
|
||||
? value.filter(v => v !== toggledValue)
|
||||
: [...(value ?? []), toggledValue];
|
||||
} else {
|
||||
newValue = value?.includes(toggledValue) ? [] : [toggledValue];
|
||||
}
|
||||
|
||||
onValueChange?.(newValue);
|
||||
},
|
||||
[multiselect, onValueChange, value],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterString(e.target.value);
|
||||
setActiveOptionIndex(0);
|
||||
e.preventDefault();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
let wasEscapeKey = false;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex(prev =>
|
||||
Math.min(prev + 1, filteredOptions.length - 1),
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex(prev => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex(Math.max(filteredOptions.length - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (
|
||||
activeOptionIndex >= 0 &&
|
||||
!filteredOptions[activeOptionIndex].disabled
|
||||
) {
|
||||
handleValueChange(filteredOptions[activeOptionIndex].value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// The Menu component should handle this
|
||||
wasEscapeKey = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!wasEscapeKey) {
|
||||
// Stop propagation so Menu components don't prevent the input from updating
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[filteredOptions, activeOptionIndex, handleValueChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="combobox"
|
||||
className={clsx('canon-MenuCombobox', className)}
|
||||
{...rest}
|
||||
>
|
||||
<input
|
||||
className="canon-SubmenuComboboxSearch"
|
||||
type="text"
|
||||
role="combobox"
|
||||
placeholder="Filter..."
|
||||
aria-labelledby={triggerId}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendantId}
|
||||
aria-expanded="true"
|
||||
aria-haspopup="listbox"
|
||||
value={filterString}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
id={listboxId}
|
||||
tabIndex={-1}
|
||||
aria-multiselectable={multiselect ? true : undefined}
|
||||
className="canon-SubmenuComboboxItems"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="canon-SubmenuComboboxNoResults">
|
||||
No results found
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => (
|
||||
<ComboboxItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
optionIndex={index}
|
||||
value={value}
|
||||
activeOptionIndex={activeOptionIndex}
|
||||
onItemActive={setActiveOptionIndex}
|
||||
onItemSelect={handleValueChange}
|
||||
listboxId={listboxId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Combobox.displayName = 'Combobox';
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Menu } from './Menu';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import { Text, Icon, Button, Flex } from '../../index';
|
||||
import { useState } from 'react';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Menu',
|
||||
@@ -27,6 +27,18 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const options = [
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
{ label: 'Banana', value: 'banana' },
|
||||
{ label: 'Blueberry', value: 'blueberry' },
|
||||
{ label: 'Cherry', value: 'cherry' },
|
||||
{ label: 'Durian', value: 'durian' },
|
||||
{ label: 'Elderberry', value: 'elderberry' },
|
||||
{ label: 'Fig', value: 'fig' },
|
||||
{ label: 'Grape', value: 'grape' },
|
||||
{ label: 'Honeydew', value: 'honeydew' },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
@@ -35,7 +47,7 @@ export const Default: Story = {
|
||||
render={props => (
|
||||
<Button
|
||||
{...props}
|
||||
size="medium"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
iconEnd={<Icon name="chevron-down" />}
|
||||
>
|
||||
@@ -71,3 +83,149 @@ export const OpenOnHover: Story = {
|
||||
openOnHover: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Submenu: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<Button
|
||||
{...props}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
iconEnd={<Icon name="chevron-down" />}
|
||||
>
|
||||
Menu
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={8} align="start">
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Settings</Menu.Item>
|
||||
<Menu.Item>Invite new members</Menu.Item>
|
||||
<Menu.Item>Download app</Menu.Item>
|
||||
<Menu.Item>Log out</Menu.Item>
|
||||
<Menu.Root>
|
||||
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Submenu Item 1</Menu.Item>
|
||||
<Menu.Item>Submenu Item 2</Menu.Item>
|
||||
<Menu.Item>Submenu Item 3</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const SubmenuCombobox = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1" align="start">
|
||||
<Text style={{ marginBottom: 16 }}>
|
||||
{selectedValues.length === 0
|
||||
? 'Which is your favorite fruit?'
|
||||
: `Yum, ${selectedValues[0]} is delicious!`}
|
||||
</Text>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<Button
|
||||
{...props}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
iconEnd={<Icon name="chevron-down" />}
|
||||
>
|
||||
Select Fruits
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="start">
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Regular Item</Menu.Item>
|
||||
<Menu.Root>
|
||||
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={8} align="start">
|
||||
<Menu.Popup>
|
||||
<Menu.Combobox
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
onValueChange={setSelectedValues}
|
||||
/>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Menu.Item>Another Item</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubmenuComboboxMultiselect = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1" align="start">
|
||||
<Text style={{ marginBottom: 16 }}>
|
||||
{selectedValues.length === 0
|
||||
? 'Tell us what fruits you like.'
|
||||
: `${selectedValues.join(
|
||||
', ',
|
||||
)} would make for a great, healthy smoothy!`}
|
||||
</Text>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<Button
|
||||
{...props}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
iconEnd={<Icon name="chevron-down" />}
|
||||
>
|
||||
Select Fruits
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Popup>
|
||||
<Menu.Item>Regular Item</Menu.Item>
|
||||
<Menu.Root>
|
||||
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Popup>
|
||||
<Menu.Combobox
|
||||
multiselect
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
onValueChange={setSelectedValues}
|
||||
/>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Menu.Item>Another Item</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
}
|
||||
|
||||
.canon-MenuPopup {
|
||||
padding: var(--canon-space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
border: 1px solid var(--canon-border);
|
||||
color: var(--canon-fg-primary);
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
transform-origin: var(--transform-origin);
|
||||
max-width: 340px;
|
||||
max-width: min(var(--available-width), 340px);
|
||||
max-height: min(var(--available-height), 500px);
|
||||
transition: transform 150ms, opacity 150ms;
|
||||
position: relative;
|
||||
padding-bottom: var(--canon-space-1);
|
||||
|
||||
&[data-starting-style],
|
||||
&[data-ending-style] {
|
||||
@@ -23,25 +27,61 @@
|
||||
|
||||
.canon-MenuItem {
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
color: var(--canon-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--canon-radius-2);
|
||||
padding: var(--canon-space-2) var(--canon-space-2);
|
||||
padding-right: var(--canon-space-4);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
background-color: var(--canon-bg-tint-hover);
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
.canon-MenuSubmenuTrigger {
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--canon-space-2);
|
||||
color: var(--canon-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
& .canon-Icon {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--canon-space-1);
|
||||
}
|
||||
|
||||
&[data-popup-open],
|
||||
&[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
|
||||
.canon-Icon {
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,3 +90,91 @@
|
||||
height: 1px;
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxSearch {
|
||||
padding-inline: var(--canon-space-3);
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--canon-border);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
color: var(--canon-fg-primary);
|
||||
line-height: 140%;
|
||||
outline: none;
|
||||
font-size: var(--canon-font-size-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItems {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--canon-space-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxNoResults {
|
||||
padding-inline: var(--canon-space-3);
|
||||
padding-top: var(--canon-space-2);
|
||||
padding-bottom: var(--canon-space-4);
|
||||
color: var(--canon-fg-secondary);
|
||||
font-size: var(--canon-font-size-3);
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItem {
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--canon-space-2);
|
||||
color: var(--canon-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--canon-radius-2);
|
||||
margin-inline: var(--canon-space-1);
|
||||
padding-inline: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--canon-gray-3);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemCheckbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-primary);
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
background: var(--canon-bg-surface-1);
|
||||
}
|
||||
|
||||
.canon-SubmenuComboboxItemLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { forwardRef } from 'react';
|
||||
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
|
||||
import clsx from 'clsx';
|
||||
import { MenuComponent } from './types';
|
||||
import { Combobox } from './Combobox';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
const MenuTrigger = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Trigger>,
|
||||
@@ -180,12 +182,15 @@ MenuCheckboxItemIndicator.displayName =
|
||||
const MenuSubmenuTrigger = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.SubmenuTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
ref={ref}
|
||||
className={clsx('canon-MenuSubmenuTrigger', className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<div>{children}</div>
|
||||
<Icon aria-label="Submenu indicator icon" name="chevron-right" size={20} />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
));
|
||||
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
|
||||
|
||||
@@ -220,4 +225,5 @@ export const Menu: MenuComponent = {
|
||||
CheckboxItemIndicator: MenuCheckboxItemIndicator,
|
||||
SubmenuTrigger: MenuSubmenuTrigger,
|
||||
Separator: MenuSeparator,
|
||||
Combobox,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,29 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
|
||||
import {
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
ComponentProps,
|
||||
} from 'react';
|
||||
|
||||
/** @public */
|
||||
export type MenuComboboxOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export interface MenuComboboxProps extends ComponentProps<'div'> {
|
||||
options: MenuComboboxOption[];
|
||||
value?: string[];
|
||||
onValueChange?: (value: string[]) => void;
|
||||
multiselect?: boolean;
|
||||
closeParentOnEsc?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type MenuComponent = {
|
||||
@@ -34,4 +56,7 @@ export type MenuComponent = {
|
||||
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
|
||||
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
|
||||
Separator: typeof MenuPrimitive.Separator;
|
||||
Combobox: ForwardRefExoticComponent<
|
||||
MenuComboboxProps & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user