Move Menu to React Aria
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': minor
|
||||
---
|
||||
|
||||
**Breaking change** We are updating the Menu component to use React Aria under the hood. The structure and all props are changing to follow React Aria's guidance.
|
||||
+190
-129
@@ -9688,184 +9688,245 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuPositioner {
|
||||
z-index: 100;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.bui-MenuPopup {
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
.bui-MenuPopover {
|
||||
border: 1px solid var(--bui-border);
|
||||
border-radius: var(--bui-radius-2);
|
||||
background: var(--bui-bg-surface-1);
|
||||
color: var(--bui-fg-primary);
|
||||
transform-origin: var(--transform-origin);
|
||||
max-width: min(var(--available-width), 340px);
|
||||
max-height: min(var(--available-height), 500px);
|
||||
padding-bottom: var(--bui-space-1);
|
||||
border-radius: .375rem;
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
transition: transform .15s, opacity .15s;
|
||||
min-height: 0;
|
||||
transition: transform .2s, opacity .2s;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-starting-style], &[data-ending-style] {
|
||||
&[data-entering], &[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
transform: scale(.9);
|
||||
}
|
||||
|
||||
&[data-placement="top"] {
|
||||
--origin: translateY(8px);
|
||||
}
|
||||
|
||||
&[data-placement="bottom"] {
|
||||
--origin: translateY(-8px);
|
||||
}
|
||||
|
||||
&[data-placement="right"] {
|
||||
--origin: translateX(-8px);
|
||||
}
|
||||
|
||||
&[data-placement="left"] {
|
||||
--origin: translateX(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuContent {
|
||||
max-height: inherit;
|
||||
box-sizing: border-box;
|
||||
padding: var(--bui-space-1);
|
||||
outline: none;
|
||||
min-width: 150px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bui-MenuPopover .bui-ScrollAreaRoot {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-MenuPopover .bui-ScrollAreaScrollbar {
|
||||
margin-inline: var(--bui-space-1_5);
|
||||
}
|
||||
|
||||
.bui-MenuItem {
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
height: 32px;
|
||||
color: var(--bui-fg-primary);
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
height: 2rem;
|
||||
padding-inline: var(--bui-space-2);
|
||||
border-radius: var(--bui-radius-2);
|
||||
cursor: default;
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-6);
|
||||
outline: none;
|
||||
display: flex;
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--bui-space-1);
|
||||
&[data-focused], &[data-open] {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
&[data-color="danger"] {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-color="danger"][data-focused] {
|
||||
background: var(--bui-bg-danger);
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-has-submenu] {
|
||||
& > .bui-MenuItemArrow {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSubmenuTrigger {
|
||||
user-select: none;
|
||||
.bui-MenuItemListBox {
|
||||
height: 2rem;
|
||||
padding-inline: var(--bui-space-2);
|
||||
border-radius: var(--bui-radius-2);
|
||||
cursor: default;
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
height: 32px;
|
||||
color: var(--bui-fg-primary);
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
padding-inline: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
gap: var(--bui-space-6);
|
||||
outline: none;
|
||||
display: flex;
|
||||
|
||||
& .bui-Icon {
|
||||
color: var(--bui-fg-secondary);
|
||||
&:hover {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--bui-space-1);
|
||||
}
|
||||
|
||||
&[data-popup-open], &[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
|
||||
& .bui-Icon {
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSeparator {
|
||||
background-color: var(--color-gray-200);
|
||||
height: 1px;
|
||||
margin: .375rem 1rem;
|
||||
.bui-MenuItemListBoxCheck {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxSearch {
|
||||
padding-inline: var(--bui-space-3);
|
||||
.bui-MenuItemContent {
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
display: flex;
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemArrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: none;
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSection {
|
||||
&:first-child .bui-MenuSectionHeader {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSectionHeader {
|
||||
height: 2rem;
|
||||
padding-top: var(--bui-space-3);
|
||||
padding-left: var(--bui-space-2);
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-1);
|
||||
letter-spacing: .05rem;
|
||||
text-transform: uppercase;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-MenuSeparator {
|
||||
background: var(--bui-border);
|
||||
height: 1px;
|
||||
margin-inline: var(--bui-space-1_5);
|
||||
margin-block: var(--bui-space-1);
|
||||
}
|
||||
|
||||
.bui-MenuSearchField {
|
||||
font-family: var(--bui-font-regular);
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&[data-empty] {
|
||||
& .bui-MenuSearchFieldClear {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSearchFieldInput {
|
||||
padding: 0 var(--bui-space-3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-family: var(--bui-font-regular);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
color: var(--bui-fg-primary);
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--bui-fg-primary);
|
||||
line-height: 140%;
|
||||
font-size: var(--bui-font-size-3);
|
||||
z-index: 1;
|
||||
height: 2rem;
|
||||
cursor: inherit;
|
||||
outline: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItems {
|
||||
padding-top: var(--bui-space-2);
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxNoResults {
|
||||
padding-inline: var(--bui-space-3);
|
||||
padding-top: var(--bui-space-2);
|
||||
padding-bottom: var(--bui-space-4);
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItem {
|
||||
user-select: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
height: 32px;
|
||||
color: var(--bui-fg-primary);
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
padding-inline: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItemCheckbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-primary);
|
||||
border-radius: var(--bui-radius-2);
|
||||
border: 1px solid var(--bui-border);
|
||||
background: var(--bui-bg-surface-1);
|
||||
flex-shrink: 0;
|
||||
.bui-MenuSearchFieldClear {
|
||||
right: var(--bui-space-2);
|
||||
cursor: pointer;
|
||||
color: var(--bui-fg-secondary);
|
||||
background-color: #0000;
|
||||
border: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: color .2s ease-in-out;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItemLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.bui-MenuEmptyState {
|
||||
padding: var(--bui-space-1);
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-family: var(--bui-font-regular);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
}
|
||||
|
||||
.bui-Popover {
|
||||
|
||||
+120
-65
@@ -15,7 +15,13 @@ import type { ElementType } from 'react';
|
||||
import { ForwardRefExoticComponent } from 'react';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { LinkProps as LinkProps_2 } from 'react-aria-components';
|
||||
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
|
||||
import type { ListBoxItemProps } from 'react-aria-components';
|
||||
import type { ListBoxProps } from 'react-aria-components';
|
||||
import type { MenuItemProps as MenuItemProps_2 } from 'react-aria-components';
|
||||
import type { MenuProps as MenuProps_2 } from 'react-aria-components';
|
||||
import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components';
|
||||
import type { MenuTriggerProps as MenuTriggerProps_2 } from 'react-aria-components';
|
||||
import type { PopoverProps } from 'react-aria-components';
|
||||
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
|
||||
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
|
||||
import { ReactElement } from 'react';
|
||||
@@ -26,6 +32,8 @@ import { RowProps } from 'react-aria-components';
|
||||
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
|
||||
import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components';
|
||||
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
|
||||
import type { SeparatorProps } from 'react-aria-components';
|
||||
import type { SubmenuTriggerProps as SubmenuTriggerProps_2 } from 'react-aria-components';
|
||||
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
|
||||
import { TableBodyProps } from 'react-aria-components';
|
||||
import { TableHeaderProps } from 'react-aria-components';
|
||||
@@ -497,21 +505,21 @@ export const componentDefinitions: {
|
||||
};
|
||||
readonly Menu: {
|
||||
readonly classNames: {
|
||||
readonly trigger: 'bui-MenuTrigger';
|
||||
readonly backdrop: 'bui-MenuBackdrop';
|
||||
readonly positioner: 'bui-MenuPositioner';
|
||||
readonly popup: 'bui-MenuPopup';
|
||||
readonly arrow: 'bui-MenuArrow';
|
||||
readonly root: 'bui-Menu';
|
||||
readonly popover: 'bui-MenuPopover';
|
||||
readonly content: 'bui-MenuContent';
|
||||
readonly section: 'bui-MenuSection';
|
||||
readonly sectionHeader: 'bui-MenuSectionHeader';
|
||||
readonly item: 'bui-MenuItem';
|
||||
readonly group: 'bui-MenuGroup';
|
||||
readonly groupLabel: 'bui-MenuGroupLabel';
|
||||
readonly radioGroup: 'bui-MenuRadioGroup';
|
||||
readonly radioItem: 'bui-MenuRadioItem';
|
||||
readonly radioItemIndicator: 'bui-MenuRadioItemIndicator';
|
||||
readonly checkboxItem: 'bui-MenuCheckboxItem';
|
||||
readonly checkboxItemIndicator: 'bui-MenuCheckboxItemIndicator';
|
||||
readonly submenuTrigger: 'bui-MenuSubmenuTrigger';
|
||||
readonly itemListBox: 'bui-MenuItemListBox';
|
||||
readonly itemListBoxCheck: 'bui-MenuItemListBoxCheck';
|
||||
readonly itemContent: 'bui-MenuItemContent';
|
||||
readonly itemArrow: 'bui-MenuItemArrow';
|
||||
readonly separator: 'bui-MenuSeparator';
|
||||
readonly searchField: 'bui-MenuSearchField';
|
||||
readonly searchFieldInput: 'bui-MenuSearchFieldInput';
|
||||
readonly searchFieldClear: 'bui-MenuSearchFieldClear';
|
||||
readonly emptyState: 'bui-MenuEmptyState';
|
||||
};
|
||||
};
|
||||
readonly Popover: {
|
||||
@@ -976,16 +984,6 @@ export interface GridProps extends SpaceProps {
|
||||
// @public
|
||||
export const Header: (props: HeaderProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface HeaderMenuItem {
|
||||
// (undocumented)
|
||||
label: string;
|
||||
// (undocumented)
|
||||
onClick?: () => void;
|
||||
// (undocumented)
|
||||
value: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
|
||||
|
||||
@@ -1004,8 +1002,6 @@ export interface HeaderPageProps {
|
||||
// (undocumented)
|
||||
customActions?: React.ReactNode;
|
||||
// (undocumented)
|
||||
menuItems?: HeaderMenuItem[];
|
||||
// (undocumented)
|
||||
tabs?: HeaderTab[];
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
@@ -1018,8 +1014,6 @@ export interface HeaderProps {
|
||||
// (undocumented)
|
||||
icon?: React.ReactNode;
|
||||
// (undocumented)
|
||||
menuItems?: HeaderMenuItem[];
|
||||
// (undocumented)
|
||||
onTabSelectionChange?: TabsProps_2['onSelectionChange'];
|
||||
// (undocumented)
|
||||
tabs?: HeaderTab[];
|
||||
@@ -1247,52 +1241,107 @@ export const marginPropDefs: (spacingValues: string[]) => {
|
||||
export type MarginProps = GetPropDefTypes<typeof marginPropDefs>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const Menu: MenuComponent;
|
||||
export const Menu: (props: MenuProps<object>) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MenuComboboxOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export const MenuAutocomplete: (
|
||||
props: MenuAutocompleteProps<object>,
|
||||
) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuComboboxProps extends ComponentProps<'div'> {
|
||||
export const MenuAutocompleteListbox: (
|
||||
props: MenuAutocompleteListBoxProps<object>,
|
||||
) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuAutocompleteListBoxProps<T>
|
||||
extends ListBoxProps<T>,
|
||||
Omit<ListBoxProps<T>, 'children'> {
|
||||
// (undocumented)
|
||||
closeParentOnEsc?: boolean;
|
||||
placeholder?: string;
|
||||
// (undocumented)
|
||||
multiselect?: boolean;
|
||||
// (undocumented)
|
||||
onValueChange?: (value: string[]) => void;
|
||||
// (undocumented)
|
||||
options: MenuComboboxOption[];
|
||||
// (undocumented)
|
||||
value?: string[];
|
||||
placement?: PopoverProps['placement'];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type MenuComponent = {
|
||||
Root: typeof Menu_2.Root;
|
||||
Trigger: typeof Menu_2.Trigger;
|
||||
Portal: typeof Menu_2.Portal;
|
||||
Backdrop: typeof Menu_2.Backdrop;
|
||||
Positioner: typeof Menu_2.Positioner;
|
||||
Popup: typeof Menu_2.Popup;
|
||||
Arrow: typeof Menu_2.Arrow;
|
||||
Item: typeof Menu_2.Item;
|
||||
Group: typeof Menu_2.Group;
|
||||
GroupLabel: typeof Menu_2.GroupLabel;
|
||||
RadioGroup: typeof Menu_2.RadioGroup;
|
||||
RadioItem: typeof Menu_2.RadioItem;
|
||||
RadioItemIndicator: typeof Menu_2.RadioItemIndicator;
|
||||
CheckboxItem: typeof Menu_2.CheckboxItem;
|
||||
CheckboxItemIndicator: typeof Menu_2.CheckboxItemIndicator;
|
||||
SubmenuTrigger: typeof Menu_2.SubmenuTrigger;
|
||||
Separator: typeof Menu_2.Separator;
|
||||
Combobox: ForwardRefExoticComponent<
|
||||
MenuComboboxProps & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
};
|
||||
export interface MenuAutocompleteProps<T>
|
||||
extends MenuProps_2<T>,
|
||||
Omit<MenuProps_2<T>, 'children'> {
|
||||
// (undocumented)
|
||||
placeholder?: string;
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuItem: (props: MenuItemProps) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuItemProps
|
||||
extends MenuItemProps_2,
|
||||
Omit<MenuItemProps_2, 'children'> {
|
||||
// (undocumented)
|
||||
children: React.ReactNode;
|
||||
// (undocumented)
|
||||
color?: 'primary' | 'danger';
|
||||
// (undocumented)
|
||||
iconStart?: React.ReactNode;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuListBox: (props: MenuListBoxProps<object>) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuListBoxItem: (props: MenuListBoxItemProps) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuListBoxItemProps
|
||||
extends ListBoxItemProps,
|
||||
Omit<ListBoxItemProps, 'children'> {
|
||||
// (undocumented)
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuListBoxProps<T>
|
||||
extends ListBoxProps<T>,
|
||||
Omit<ListBoxProps<T>, 'children'> {
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuProps<T>
|
||||
extends MenuProps_2<T>,
|
||||
Omit<MenuProps_2<T>, 'children'> {
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuSection: (props: MenuSectionProps<object>) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuSectionProps<T>
|
||||
extends MenuSectionProps_2<T>,
|
||||
Omit<MenuSectionProps_2<T>, 'children'> {
|
||||
// (undocumented)
|
||||
children: React.ReactNode;
|
||||
// (undocumented)
|
||||
title: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuSeparator: (props: MenuSeparatorProps) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuSeparatorProps extends SeparatorProps {}
|
||||
|
||||
// @public (undocumented)
|
||||
export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface MenuTriggerProps extends MenuTriggerProps_2 {}
|
||||
|
||||
// @public (undocumented)
|
||||
export type NonStylingPropDef = {
|
||||
@@ -1563,6 +1612,12 @@ export type StylingPropDef = {
|
||||
parseValue?: (value: string) => string | undefined;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const SubmenuTrigger: (props: SubmenuTriggerProps) => JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SubmenuTriggerProps extends SubmenuTriggerProps_2 {}
|
||||
|
||||
// @public (undocumented)
|
||||
export const Switch: ForwardRefExoticComponent<
|
||||
SwitchProps & RefAttributes<HTMLLabelElement>
|
||||
|
||||
@@ -16,17 +16,23 @@
|
||||
|
||||
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { Header } from './Header';
|
||||
import { HeaderMenuItem, HeaderTab } from './types';
|
||||
import { Button } from '../Button';
|
||||
import { HeaderPage } from '../HeaderPage';
|
||||
import type { HeaderTab } from './types';
|
||||
import {
|
||||
Button,
|
||||
HeaderPage,
|
||||
Container,
|
||||
Text,
|
||||
ButtonIcon,
|
||||
MenuTrigger,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '../../';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Container } from '../Container';
|
||||
import { Text } from '../Text';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import {
|
||||
RiHeartLine,
|
||||
RiEmotionHappyLine,
|
||||
RiCloudy2Line,
|
||||
RiMore2Line,
|
||||
} from '@remixicon/react';
|
||||
import { HeaderPageBreadcrumb } from '../HeaderPage/types';
|
||||
|
||||
@@ -87,14 +93,23 @@ const tabs2: HeaderTab[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems: HeaderMenuItem[] = [
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
href: '/settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
href: '/invite-new-members',
|
||||
},
|
||||
{
|
||||
label: 'Logout',
|
||||
value: 'logout',
|
||||
onClick: () => {
|
||||
alert('logout');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -190,13 +205,6 @@ export const WithTabs: Story = {
|
||||
decorators: [withRouter],
|
||||
};
|
||||
|
||||
export const WithOptions: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
},
|
||||
decorators: [withRouter],
|
||||
};
|
||||
|
||||
export const WithCustomActions: Story = {
|
||||
args: {},
|
||||
decorators: [withRouter],
|
||||
@@ -208,29 +216,33 @@ export const WithCustomActions: Story = {
|
||||
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
|
||||
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
|
||||
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
|
||||
<MenuTrigger>
|
||||
<ButtonIcon variant="tertiary" icon={<RiMore2Line />} />
|
||||
<Menu placement="bottom end">
|
||||
{menuItems.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onAction={option.onClick}
|
||||
href={option.href}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithAllOptions: Story = {
|
||||
args: {
|
||||
title: 'My plugin',
|
||||
titleLink: '/',
|
||||
menuItems,
|
||||
},
|
||||
decorators: [withRouter],
|
||||
render: WithCustomActions.render,
|
||||
};
|
||||
|
||||
export const WithAllOptionsAndTabs: Story = {
|
||||
args: {
|
||||
...WithAllOptions.args,
|
||||
...WithCustomActions.args,
|
||||
tabs,
|
||||
},
|
||||
decorators: [withRouter],
|
||||
render: WithAllOptions.render,
|
||||
render: WithCustomActions.render,
|
||||
};
|
||||
|
||||
export const WithHeaderPage: Story = {
|
||||
@@ -252,7 +264,6 @@ export const WithHeaderPage: Story = {
|
||||
/>
|
||||
<HeaderPage
|
||||
title="Page title"
|
||||
menuItems={args.menuItems}
|
||||
tabs={tabs2}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -262,16 +273,12 @@ export const WithHeaderPage: Story = {
|
||||
};
|
||||
|
||||
export const WithLayout: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: args => (
|
||||
<>
|
||||
<Header {...args} tabs={tabs} />
|
||||
<HeaderPage
|
||||
title="Page title"
|
||||
menuItems={args.menuItems}
|
||||
tabs={tabs2}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -281,21 +288,17 @@ export const WithLayout: Story = {
|
||||
};
|
||||
|
||||
export const WithLayoutNoTabs: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: args => (
|
||||
<>
|
||||
<Header {...args} />
|
||||
<HeaderPage title="Page title" menuItems={args.menuItems} tabs={tabs2} />
|
||||
<HeaderPage title="Page title" tabs={tabs2} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithEverything: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
tabs,
|
||||
titleLink: '/',
|
||||
},
|
||||
@@ -314,7 +317,6 @@ export const WithEverything: Story = {
|
||||
/>
|
||||
<HeaderPage
|
||||
title="Page title"
|
||||
menuItems={args.menuItems}
|
||||
tabs={tabs2}
|
||||
customActions={
|
||||
<>
|
||||
|
||||
@@ -32,15 +32,8 @@ declare module 'react-aria-components' {
|
||||
* @public
|
||||
*/
|
||||
export const Header = (props: HeaderProps) => {
|
||||
const {
|
||||
tabs,
|
||||
icon,
|
||||
title,
|
||||
titleLink,
|
||||
menuItems,
|
||||
customActions,
|
||||
onTabSelectionChange,
|
||||
} = props;
|
||||
const { tabs, icon, title, titleLink, customActions, onTabSelectionChange } =
|
||||
props;
|
||||
|
||||
const { classNames } = useStyles('Header');
|
||||
|
||||
@@ -52,7 +45,6 @@ export const Header = (props: HeaderProps) => {
|
||||
icon={icon}
|
||||
title={title}
|
||||
titleLink={titleLink}
|
||||
menuItems={menuItems}
|
||||
customActions={customActions}
|
||||
hasTabs={hasTabs}
|
||||
/>
|
||||
|
||||
@@ -17,10 +17,8 @@
|
||||
import { Link, RouterProvider } from 'react-aria-components';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useRef } from 'react';
|
||||
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
|
||||
import { RiShapesLine } from '@remixicon/react';
|
||||
import type { HeaderToolbarProps } from './types';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { Menu } from '../Menu';
|
||||
import { Text } from '../Text';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
|
||||
@@ -30,7 +28,7 @@ import { useNavigate, useHref } from 'react-router-dom';
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
const { icon, title, titleLink, menuItems, customActions, hasTabs } = props;
|
||||
const { icon, title, titleLink, customActions, hasTabs } = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
let navigate = useNavigate();
|
||||
|
||||
@@ -63,34 +61,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
</div>
|
||||
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => option.onClick?.()}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { Header } from './Header';
|
||||
export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types';
|
||||
export type { HeaderProps, HeaderTab } from './types';
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface HeaderProps {
|
||||
title?: string;
|
||||
titleLink?: string;
|
||||
customActions?: React.ReactNode;
|
||||
menuItems?: HeaderMenuItem[];
|
||||
tabs?: HeaderTab[];
|
||||
onTabSelectionChange?: TabsProps['onSelectionChange'];
|
||||
}
|
||||
@@ -49,17 +48,6 @@ export interface HeaderTab {
|
||||
matchStrategy?: TabMatchStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an option item in the header dropdown menu.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderMenuItem {
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the HeaderToolbar component.
|
||||
*
|
||||
@@ -70,6 +58,5 @@ export interface HeaderToolbarProps {
|
||||
title?: HeaderProps['title'];
|
||||
titleLink?: HeaderProps['titleLink'];
|
||||
customActions?: HeaderProps['customActions'];
|
||||
menuItems?: HeaderProps['menuItems'];
|
||||
hasTabs?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,18 @@
|
||||
|
||||
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { HeaderPage } from './HeaderPage';
|
||||
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
|
||||
import type { HeaderTab } from '../Header/types';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Button } from '../Button';
|
||||
import { Container } from '../Container';
|
||||
import { Text } from '../Text';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Text,
|
||||
ButtonIcon,
|
||||
MenuTrigger,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '../../';
|
||||
import { RiMore2Line } from '@remixicon/react';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/HeaderPage',
|
||||
@@ -56,14 +63,23 @@ const tabs: HeaderTab[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems: HeaderMenuItem[] = [
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
href: '/settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
href: '/invite-new-members',
|
||||
},
|
||||
{
|
||||
label: 'Logout',
|
||||
value: 'logout',
|
||||
onClick: () => {
|
||||
alert('logout');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -123,18 +139,30 @@ export const WithTabs: Story = {
|
||||
decorators: [withRouter],
|
||||
};
|
||||
|
||||
export const WithMenuItems: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
menuItems,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomActions: Story = {
|
||||
decorators: [withRouter],
|
||||
render: () => (
|
||||
<HeaderPage
|
||||
{...Default.args}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
customActions={
|
||||
<>
|
||||
<Button>Custom action</Button>
|
||||
<MenuTrigger>
|
||||
<ButtonIcon variant="tertiary" icon={<RiMore2Line />} />
|
||||
<Menu placement="bottom end">
|
||||
{menuItems.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onAction={option.onClick}
|
||||
href={option.href}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -163,7 +191,6 @@ export const WithEverything: Story = {
|
||||
render: () => (
|
||||
<HeaderPage
|
||||
{...Default.args}
|
||||
menuItems={menuItems}
|
||||
tabs={tabs}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
breadcrumbs={[{ label: 'Home', href: '/' }]}
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
*/
|
||||
|
||||
import type { HeaderPageProps } from './types';
|
||||
import { Menu } from '../Menu';
|
||||
import { Text } from '../Text';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { RiArrowRightSLine, RiMore2Line } from '@remixicon/react';
|
||||
import { RiArrowRightSLine } from '@remixicon/react';
|
||||
import { Tabs, TabList, Tab } from '../Tabs';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { Container } from '../Container';
|
||||
@@ -31,7 +29,7 @@ import { Fragment } from 'react/jsx-runtime';
|
||||
* @public
|
||||
*/
|
||||
export const HeaderPage = (props: HeaderPageProps) => {
|
||||
const { title, menuItems, tabs, customActions, breadcrumbs } = props;
|
||||
const { title, tabs, customActions, breadcrumbs } = props;
|
||||
const { classNames } = useStyles('HeaderPage');
|
||||
|
||||
return (
|
||||
@@ -58,37 +56,7 @@ export const HeaderPage = (props: HeaderPageProps) => {
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={classNames.controls}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
{...props}
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(menuItem => (
|
||||
<Menu.Item
|
||||
key={menuItem.value}
|
||||
onClick={() => menuItem.onClick?.()}
|
||||
>
|
||||
{menuItem.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames.controls}>{customActions}</div>
|
||||
</div>
|
||||
{tabs && (
|
||||
<div className={classNames.tabsWrapper}>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
|
||||
import type { HeaderTab } from '../Header/types';
|
||||
|
||||
/**
|
||||
* Props for the main HeaderPage component.
|
||||
@@ -24,7 +24,6 @@ import type { HeaderTab, HeaderMenuItem } from '../Header/types';
|
||||
export interface HeaderPageProps {
|
||||
title?: string;
|
||||
customActions?: React.ReactNode;
|
||||
menuItems?: HeaderMenuItem[];
|
||||
tabs?: HeaderTab[];
|
||||
breadcrumbs?: HeaderPageBreadcrumb[];
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* 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,
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { MenuComboboxOption, MenuComboboxProps } from './types';
|
||||
import { Icon } from '../..';
|
||||
import { useId } from 'react-aria';
|
||||
|
||||
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="bui-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="bui-SubmenuComboboxItemCheckbox">
|
||||
{isSelected && <Icon aria-hidden="true" name="check" size={12} />}
|
||||
</div>
|
||||
<div className="bui-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('bui-MenuCombobox', className)}
|
||||
{...rest}
|
||||
>
|
||||
<input
|
||||
className="bui-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="bui-SubmenuComboboxItems"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="bui-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';
|
||||
@@ -15,14 +15,45 @@
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Menu } from './Menu';
|
||||
import { Text, Icon, Button, Flex } from '../../index';
|
||||
import {
|
||||
MenuTrigger,
|
||||
SubmenuTrigger,
|
||||
Menu,
|
||||
MenuListBox,
|
||||
MenuAutocomplete,
|
||||
MenuAutocompleteListbox,
|
||||
MenuItem,
|
||||
MenuListBoxItem,
|
||||
MenuSection,
|
||||
MenuSeparator,
|
||||
} from './index';
|
||||
import { Button, Flex, Text } from '../..';
|
||||
import {
|
||||
RiChat1Line,
|
||||
RiEdit2Line,
|
||||
RiFileCopyLine,
|
||||
RiCustomerService2Line,
|
||||
RiQuestionLine,
|
||||
RiSettingsLine,
|
||||
RiUserLine,
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react';
|
||||
import { useState } from 'react';
|
||||
import { Selection } from 'react-aria-components';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MenuItem as AriaMenuItem } from 'react-aria-components';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/Menu',
|
||||
component: Menu.Root,
|
||||
} satisfies Meta<typeof Menu.Root>;
|
||||
component: MenuTrigger,
|
||||
decorators: [
|
||||
Story => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MenuTrigger>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
@@ -40,192 +71,357 @@ const options = [
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: undefined },
|
||||
render: args => (
|
||||
<Menu.Root {...args}>
|
||||
<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.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger>
|
||||
<Button>Menu</Button>
|
||||
<Menu>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value}>{option.label}</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
export const Opened: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
open: true,
|
||||
},
|
||||
render: Default.render,
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value}>{option.label}</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const OpenOnHover: Story = {
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
openOnHover: true,
|
||||
},
|
||||
render: Default.render,
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem iconStart={<RiFileCopyLine />}>Copy</MenuItem>
|
||||
<MenuItem iconStart={<RiEdit2Line />}>Rename</MenuItem>
|
||||
<MenuItem iconStart={<RiChat1Line />}>Send feedback</MenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithScrolling: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu with Scrolling</Button>
|
||||
<Menu>
|
||||
{Array.from({ length: 50 }, (_, i) => (
|
||||
<MenuItem key={i}>
|
||||
Item {i + 1} - This is a long menu item to demonstrate scrolling
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithSections: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuSection title="My Account">
|
||||
<MenuItem iconStart={<RiUserLine />}>Profile</MenuItem>
|
||||
<MenuItem iconStart={<RiSettingsLine />}>Settings</MenuItem>
|
||||
</MenuSection>
|
||||
<MenuSection title="Support">
|
||||
<MenuItem iconStart={<RiQuestionLine />}>Help Center</MenuItem>
|
||||
<MenuItem iconStart={<RiCustomerService2Line />}>
|
||||
Contact Support
|
||||
</MenuItem>
|
||||
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
|
||||
</MenuSection>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithSeparators: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<MenuItem>Rename</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem>Share</MenuItem>
|
||||
<MenuItem>Move</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithColors: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<MenuItem>Rename</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem iconStart={<RiDeleteBinLine />} color="danger">
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLinks: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem href="/home">Internal link</MenuItem>
|
||||
<MenuItem href="https://www.google.com" target="_blank">
|
||||
External link
|
||||
</MenuItem>
|
||||
<MenuItem href="mailto:test@test.com">Email link</MenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLinksTest: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<AriaMenuItem href="/home">Internal link</AriaMenuItem>
|
||||
<AriaMenuItem href="https://www.google.com">External link</AriaMenuItem>
|
||||
<AriaMenuItem href="mailto:test@test.com">Email link</AriaMenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithListBox: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuListBox>
|
||||
<MenuListBoxItem>Item 1</MenuListBoxItem>
|
||||
<MenuListBoxItem>Item 2</MenuListBoxItem>
|
||||
<MenuListBoxItem>Item 3</MenuListBoxItem>
|
||||
</MenuListBox>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithListBoxControlled: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(new Set(['paul']));
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="start">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuListBox
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
>
|
||||
<MenuListBoxItem key="item1" id="john">
|
||||
John Lennon
|
||||
</MenuListBoxItem>
|
||||
<MenuListBoxItem key="item2" id="paul">
|
||||
Paul McCartney
|
||||
</MenuListBoxItem>
|
||||
<MenuListBoxItem key="item3" id="george">
|
||||
George Harrison
|
||||
</MenuListBoxItem>
|
||||
<MenuListBoxItem key="item4" id="ringo">
|
||||
Ringo Starr
|
||||
</MenuListBoxItem>
|
||||
</MenuListBox>
|
||||
</MenuTrigger>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAutocompleteMenu: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocomplete placeholder="Filter">
|
||||
<MenuItem>Create new file...</MenuItem>
|
||||
<MenuItem>Create new folder...</MenuItem>
|
||||
<MenuItem>Assign to...</MenuItem>
|
||||
<MenuItem>Assign to me</MenuItem>
|
||||
<MenuItem>Change status...</MenuItem>
|
||||
<MenuItem>Change priority...</MenuItem>
|
||||
<MenuItem>Add label...</MenuItem>
|
||||
<MenuItem>Remove label...</MenuItem>
|
||||
</MenuAutocomplete>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithAutocompleteListbox: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="start">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocompleteListbox
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuListBoxItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</MenuTrigger>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Submenu: Story = {
|
||||
args: { children: undefined },
|
||||
render: args => (
|
||||
<Menu.Root {...args}>
|
||||
<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>
|
||||
</Menu.Root>
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<MenuItem>Rename</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem>Share</MenuItem>
|
||||
<MenuItem>Move</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
|
||||
</Menu>
|
||||
</SubmenuTrigger>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
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 SubmenuAutocompleteMenu: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<MenuAutocomplete>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuAutocomplete>
|
||||
</SubmenuTrigger>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
|
||||
export const SubmenuComboboxMultiselect = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
export const SubmenuAutocompleteListbox: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value]),
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="start">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<MenuAutocompleteListbox
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuListBoxItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</SubmenuTrigger>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,182 +1,253 @@
|
||||
.bui-MenuPositioner {
|
||||
outline: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.bui-MenuPopup {
|
||||
.bui-MenuPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
border: 1px solid var(--bui-border);
|
||||
border-radius: var(--bui-radius-2);
|
||||
background: var(--bui-bg-surface-1);
|
||||
color: var(--bui-fg-primary);
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
transform-origin: var(--transform-origin);
|
||||
max-width: min(var(--available-width), 340px);
|
||||
max-height: min(var(--available-height), 500px);
|
||||
transition: transform 150ms, opacity 150ms;
|
||||
position: relative;
|
||||
padding-bottom: var(--bui-space-1);
|
||||
transition: transform 200ms, opacity 200ms;
|
||||
/* Let React Aria handle height constraints naturally */
|
||||
min-height: 0;
|
||||
/* Remove overflow from popover since ScrollArea will handle it */
|
||||
overflow: hidden;
|
||||
|
||||
&[data-starting-style],
|
||||
&[data-ending-style] {
|
||||
&[data-entering],
|
||||
&[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&[data-placement='top'] {
|
||||
--origin: translateY(8px);
|
||||
}
|
||||
|
||||
&[data-placement='bottom'] {
|
||||
--origin: translateY(-8px);
|
||||
}
|
||||
|
||||
&[data-placement='right'] {
|
||||
--origin: translateX(-8px);
|
||||
}
|
||||
|
||||
&[data-placement='left'] {
|
||||
--origin: translateX(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuContent {
|
||||
max-height: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
padding: var(--bui-space-1);
|
||||
min-width: 150px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Ensure ScrollArea works properly within MenuRA popover */
|
||||
.bui-MenuPopover .bui-ScrollAreaRoot {
|
||||
/* Take full height of popover */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
/* Ensure the root container has proper height constraints */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bui-MenuPopover .bui-ScrollAreaScrollbar {
|
||||
margin-inline: var(--bui-space-1_5);
|
||||
}
|
||||
|
||||
.bui-MenuItem {
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
color: var(--bui-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
justify-content: space-between;
|
||||
height: 2rem;
|
||||
padding-inline: var(--bui-space-2);
|
||||
border-radius: var(--bui-radius-2);
|
||||
outline: none;
|
||||
cursor: default;
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
gap: var(--bui-space-6);
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--bui-space-1);
|
||||
&[data-focused] {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
&[data-open] {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-color='danger'] {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-color='danger'][data-focused] {
|
||||
background: var(--bui-bg-danger);
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-has-submenu] {
|
||||
& > .bui-MenuItemArrow {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSubmenuTrigger {
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
.bui-MenuItemListBox {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--bui-space-2);
|
||||
color: var(--bui-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
height: 2rem;
|
||||
padding-inline: var(--bui-space-2);
|
||||
border-radius: var(--bui-radius-2);
|
||||
outline: none;
|
||||
cursor: default;
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
gap: var(--bui-space-6);
|
||||
|
||||
& .bui-Icon {
|
||||
color: var(--bui-fg-secondary);
|
||||
&:hover {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: var(--bui-space-1);
|
||||
}
|
||||
|
||||
&[data-popup-open],
|
||||
&[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
|
||||
.bui-Icon {
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSeparator {
|
||||
margin: 0.375rem 1rem;
|
||||
height: 1px;
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxSearch {
|
||||
padding-inline: var(--bui-space-3);
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
color: var(--bui-fg-primary);
|
||||
line-height: 140%;
|
||||
outline: none;
|
||||
font-size: var(--bui-font-size-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItems {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--bui-space-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxNoResults {
|
||||
padding-inline: var(--bui-space-3);
|
||||
padding-top: var(--bui-space-2);
|
||||
padding-bottom: var(--bui-space-4);
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItem {
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--bui-space-2);
|
||||
color: var(--bui-fg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--bui-radius-2);
|
||||
margin-inline: var(--bui-space-1);
|
||||
padding-inline: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--bui-gray-3);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItemCheckbox {
|
||||
.bui-MenuItemListBoxCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-primary);
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--bui-radius-2);
|
||||
border: 1px solid var(--bui-border);
|
||||
background: var(--bui-bg-surface-1);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SubmenuComboboxItemLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.bui-MenuItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemArrow {
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSection {
|
||||
&:first-child .bui-MenuSectionHeader {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSectionHeader {
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: var(--bui-space-3);
|
||||
padding-left: var(--bui-space-2);
|
||||
color: var(--bui-fg-primary);
|
||||
font-size: var(--bui-font-size-1);
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.05rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bui-MenuSeparator {
|
||||
height: 1px;
|
||||
background: var(--bui-border);
|
||||
margin-inline: var(--bui-space-1_5);
|
||||
margin-block: var(--bui-space-1);
|
||||
}
|
||||
|
||||
.bui-MenuSearchField {
|
||||
position: relative;
|
||||
font-family: var(--bui-font-regular);
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-empty] {
|
||||
.bui-MenuSearchFieldClear {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSearchFieldInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--bui-space-3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-family: var(--bui-font-regular);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
color: var(--bui-fg-primary);
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
cursor: inherit;
|
||||
outline: none;
|
||||
|
||||
&::-webkit-search-cancel-button,
|
||||
&::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuSearchFieldClear {
|
||||
position: absolute;
|
||||
right: var(--bui-space-2);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--bui-fg-secondary);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
& > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuEmptyState {
|
||||
padding: var(--bui-space-1);
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-family: var(--bui-font-regular);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
}
|
||||
|
||||
@@ -14,281 +14,267 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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';
|
||||
import {
|
||||
MenuTrigger as RAMenuTrigger,
|
||||
Popover as RAPopover,
|
||||
MenuItem as RAMenuItem,
|
||||
Menu as RAMenu,
|
||||
MenuSection as RAMenuSection,
|
||||
Header as RAMenuHeader,
|
||||
Separator as RAMenuSeparator,
|
||||
SubmenuTrigger as RAMenuSubmenuTrigger,
|
||||
Autocomplete as RAAutocomplete,
|
||||
SearchField as RASearchField,
|
||||
Input as RAInput,
|
||||
Button as RAButton,
|
||||
ListBox as RAListBox,
|
||||
ListBoxItem as RAListBoxItem,
|
||||
useFilter,
|
||||
RouterProvider,
|
||||
} from 'react-aria-components';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import type {
|
||||
MenuTriggerProps,
|
||||
SubmenuTriggerProps,
|
||||
MenuProps,
|
||||
MenuAutocompleteProps,
|
||||
MenuItemProps,
|
||||
MenuSectionProps,
|
||||
MenuSeparatorProps,
|
||||
MenuListBoxProps,
|
||||
MenuListBoxItemProps,
|
||||
MenuAutocompleteListBoxProps,
|
||||
} from './types';
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiCheckLine,
|
||||
RiCloseCircleLine,
|
||||
} from '@remixicon/react';
|
||||
import { isExternalLink } from '../../utils/isExternalLink';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
|
||||
const MenuTrigger = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const MenuEmptyState = () => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={clsx(classNames.trigger, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuTrigger.displayName = MenuPrimitive.Trigger.displayName;
|
||||
|
||||
const MenuBackdrop = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Backdrop>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Backdrop>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Backdrop
|
||||
ref={ref}
|
||||
className={clsx(classNames.backdrop, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuBackdrop.displayName = MenuPrimitive.Backdrop.displayName;
|
||||
|
||||
const MenuPositioner = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Positioner>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Positioner>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Positioner
|
||||
ref={ref}
|
||||
className={clsx(classNames.positioner, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuPositioner.displayName = MenuPrimitive.Positioner.displayName;
|
||||
|
||||
const MenuPopup = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Popup>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Popup>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Popup
|
||||
ref={ref}
|
||||
className={clsx(classNames.popup, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuPopup.displayName = MenuPrimitive.Popup.displayName;
|
||||
|
||||
const MenuArrow = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Arrow>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Arrow>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Arrow
|
||||
ref={ref}
|
||||
className={clsx(classNames.arrow, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuArrow.displayName = MenuPrimitive.Arrow.displayName;
|
||||
|
||||
const MenuItem = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={clsx(classNames.item, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuItem.displayName = MenuPrimitive.Item.displayName;
|
||||
|
||||
const MenuGroup = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Group
|
||||
ref={ref}
|
||||
className={clsx(classNames.group, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuGroup.displayName = MenuPrimitive.Group.displayName;
|
||||
|
||||
const MenuGroupLabel = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.GroupLabel>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.GroupLabel>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
ref={ref}
|
||||
className={clsx(classNames.groupLabel, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuGroupLabel.displayName = MenuPrimitive.GroupLabel.displayName;
|
||||
|
||||
const MenuRadioGroup = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.RadioGroup>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioGroup>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
ref={ref}
|
||||
className={clsx(classNames.radioGroup, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuRadioGroup.displayName = MenuPrimitive.RadioGroup.displayName;
|
||||
|
||||
const MenuRadioItem = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItem>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={clsx(classNames.radioItem, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuRadioItem.displayName = MenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenuRadioItemIndicator = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.RadioItemIndicator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItemIndicator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.RadioItemIndicator
|
||||
ref={ref}
|
||||
className={clsx(classNames.radioItemIndicator, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuRadioItemIndicator.displayName =
|
||||
MenuPrimitive.RadioItemIndicator.displayName;
|
||||
|
||||
const MenuCheckboxItem = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItem>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={clsx(classNames.checkboxItem, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuCheckboxItem.displayName = MenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenuCheckboxItemIndicator = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.CheckboxItemIndicator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItemIndicator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItemIndicator
|
||||
ref={ref}
|
||||
className={clsx(classNames.checkboxItemIndicator, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuCheckboxItemIndicator.displayName =
|
||||
MenuPrimitive.CheckboxItemIndicator.displayName;
|
||||
|
||||
const MenuSubmenuTrigger = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.SubmenuTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
ref={ref}
|
||||
className={clsx(classNames.submenuTrigger, className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{children}</div>
|
||||
<Icon
|
||||
aria-label="Submenu indicator icon"
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
/>
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
});
|
||||
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
|
||||
|
||||
const MenuSeparator = forwardRef<
|
||||
React.ElementRef<typeof MenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={clsx(classNames.separator, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
MenuSeparator.displayName = MenuPrimitive.Separator.displayName;
|
||||
return <div className={classNames.emptyState}>No results found.</div>;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const Menu: MenuComponent = {
|
||||
Root: MenuPrimitive.Root,
|
||||
Trigger: MenuTrigger,
|
||||
Portal: MenuPrimitive.Portal,
|
||||
Backdrop: MenuBackdrop,
|
||||
Positioner: MenuPositioner,
|
||||
Popup: MenuPopup,
|
||||
Arrow: MenuArrow,
|
||||
Item: MenuItem,
|
||||
Group: MenuGroup,
|
||||
GroupLabel: MenuGroupLabel,
|
||||
RadioGroup: MenuRadioGroup,
|
||||
RadioItem: MenuRadioItem,
|
||||
RadioItemIndicator: MenuRadioItemIndicator,
|
||||
CheckboxItem: MenuCheckboxItem,
|
||||
CheckboxItemIndicator: MenuCheckboxItemIndicator,
|
||||
SubmenuTrigger: MenuSubmenuTrigger,
|
||||
Separator: MenuSeparator,
|
||||
Combobox,
|
||||
export const MenuTrigger = (props: MenuTriggerProps) => {
|
||||
return <RAMenuTrigger {...props} />;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const SubmenuTrigger = (props: SubmenuTriggerProps) => {
|
||||
return <RAMenuSubmenuTrigger {...props} />;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const Menu = (props: MenuProps<object>) => {
|
||||
const { placement = 'bottom start', ...rest } = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
<ScrollArea.Root>
|
||||
<ScrollArea.Viewport>
|
||||
<RAMenu className={classNames.content} {...rest}>
|
||||
{props.children}
|
||||
</RAMenu>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuListBox = (props: MenuListBoxProps<object>) => {
|
||||
const {
|
||||
selectionMode = 'single',
|
||||
placement = 'bottom start',
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
<ScrollArea.Root>
|
||||
<ScrollArea.Viewport>
|
||||
<RAListBox
|
||||
className={classNames.content}
|
||||
selectionMode={selectionMode}
|
||||
{...rest}
|
||||
/>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
|
||||
const { placement = 'bottom start', ...rest } = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
<RAAutocomplete filter={contains}>
|
||||
<RASearchField className={classNames.searchField}>
|
||||
<RAInput
|
||||
className={classNames.searchFieldInput}
|
||||
aria-label="Search"
|
||||
placeholder={props.placeholder || 'Search...'}
|
||||
/>
|
||||
<RAButton className={classNames.searchFieldClear}>
|
||||
<RiCloseCircleLine />
|
||||
</RAButton>
|
||||
</RASearchField>
|
||||
<ScrollArea.Root>
|
||||
<ScrollArea.Viewport>
|
||||
<RAMenu
|
||||
className={classNames.content}
|
||||
renderEmptyState={() => <MenuEmptyState />}
|
||||
{...rest}
|
||||
/>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</RAAutocomplete>
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuAutocompleteListbox = (
|
||||
props: MenuAutocompleteListBoxProps<object>,
|
||||
) => {
|
||||
const {
|
||||
selectionMode = 'single',
|
||||
placement = 'bottom start',
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
<RAAutocomplete filter={contains}>
|
||||
<RASearchField className={classNames.searchField}>
|
||||
<RAInput
|
||||
className={classNames.searchFieldInput}
|
||||
aria-label="Search"
|
||||
placeholder={props.placeholder || 'Search...'}
|
||||
/>
|
||||
<RAButton className={classNames.searchFieldClear}>
|
||||
<RiCloseCircleLine />
|
||||
</RAButton>
|
||||
</RASearchField>
|
||||
<ScrollArea.Root>
|
||||
<ScrollArea.Viewport>
|
||||
<RAListBox
|
||||
className={classNames.content}
|
||||
renderEmptyState={() => <MenuEmptyState />}
|
||||
selectionMode={selectionMode}
|
||||
{...rest}
|
||||
/>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</RAAutocomplete>
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuItem = (props: MenuItemProps) => {
|
||||
const { iconStart, color = 'primary', children, href, ...rest } = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isLink = href !== undefined;
|
||||
const isExternal = isExternalLink(href);
|
||||
|
||||
const content = (
|
||||
<RAMenuItem
|
||||
className={classNames.item}
|
||||
data-color={color}
|
||||
href={href}
|
||||
textValue={typeof children === 'string' ? children : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<div className={classNames.itemContent}>
|
||||
{iconStart}
|
||||
{children}
|
||||
</div>
|
||||
<div className={classNames.itemArrow}>
|
||||
<RiArrowRightSLine />
|
||||
</div>
|
||||
</RAMenuItem>
|
||||
);
|
||||
|
||||
if (isLink && !isExternal) {
|
||||
return (
|
||||
<RouterProvider navigate={navigate} useHref={useHref}>
|
||||
{content}
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
|
||||
const { children, ...rest } = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<RAListBoxItem
|
||||
textValue={
|
||||
typeof props.children === 'string' ? props.children : undefined
|
||||
}
|
||||
className={classNames.itemListBox}
|
||||
{...rest}
|
||||
>
|
||||
<div className={classNames.itemContent}>
|
||||
<div className={classNames.itemListBoxCheck}>
|
||||
<RiCheckLine />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</RAListBoxItem>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuSection = (props: MenuSectionProps<object>) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return (
|
||||
<RAMenuSection className={classNames.section} {...props}>
|
||||
<RAMenuHeader className={classNames.sectionHeader}>
|
||||
{props.title}
|
||||
</RAMenuHeader>
|
||||
{props.children}
|
||||
</RAMenuSection>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuSeparator = (props: MenuSeparatorProps) => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
return <RAMenuSeparator className={classNames.separator} {...props} />;
|
||||
};
|
||||
|
||||
@@ -14,5 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './Menu';
|
||||
export * from './types';
|
||||
export {
|
||||
MenuTrigger,
|
||||
SubmenuTrigger,
|
||||
Menu,
|
||||
MenuListBox,
|
||||
MenuAutocomplete,
|
||||
MenuAutocompleteListbox,
|
||||
MenuItem,
|
||||
MenuListBoxItem,
|
||||
MenuSection,
|
||||
MenuSeparator,
|
||||
} from './Menu';
|
||||
export type {
|
||||
MenuTriggerProps,
|
||||
SubmenuTriggerProps,
|
||||
MenuProps,
|
||||
MenuListBoxProps,
|
||||
MenuAutocompleteProps,
|
||||
MenuAutocompleteListBoxProps,
|
||||
MenuItemProps,
|
||||
MenuListBoxItemProps,
|
||||
MenuSectionProps,
|
||||
MenuSeparatorProps,
|
||||
} from './types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
* Copyright 2024 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.
|
||||
@@ -14,49 +14,77 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
|
||||
import {
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
ComponentProps,
|
||||
} from 'react';
|
||||
import type {
|
||||
MenuTriggerProps as RAMenuTriggerProps,
|
||||
MenuItemProps as RAMenuItemProps,
|
||||
MenuProps as RAMenuProps,
|
||||
MenuSectionProps as RAMenuSectionProps,
|
||||
SeparatorProps as RAMenuSeparatorProps,
|
||||
SubmenuTriggerProps as RAMenuSubmenuTriggerProps,
|
||||
ListBoxProps as RAListBoxProps,
|
||||
ListBoxItemProps as RAListBoxItemProps,
|
||||
PopoverProps as RAPopoverProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
/** @public */
|
||||
export type MenuComboboxOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export interface MenuTriggerProps extends RAMenuTriggerProps {}
|
||||
|
||||
/** @public */
|
||||
export interface MenuComboboxProps extends ComponentProps<'div'> {
|
||||
options: MenuComboboxOption[];
|
||||
value?: string[];
|
||||
onValueChange?: (value: string[]) => void;
|
||||
multiselect?: boolean;
|
||||
closeParentOnEsc?: boolean;
|
||||
export interface SubmenuTriggerProps extends RAMenuSubmenuTriggerProps {}
|
||||
|
||||
/** @public */
|
||||
export interface MenuProps<T>
|
||||
extends RAMenuProps<T>,
|
||||
Omit<RAMenuProps<T>, 'children'> {
|
||||
placement?: RAPopoverProps['placement'];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type MenuComponent = {
|
||||
Root: typeof MenuPrimitive.Root;
|
||||
Trigger: typeof MenuPrimitive.Trigger;
|
||||
Portal: typeof MenuPrimitive.Portal;
|
||||
Backdrop: typeof MenuPrimitive.Backdrop;
|
||||
Positioner: typeof MenuPrimitive.Positioner;
|
||||
Popup: typeof MenuPrimitive.Popup;
|
||||
Arrow: typeof MenuPrimitive.Arrow;
|
||||
Item: typeof MenuPrimitive.Item;
|
||||
Group: typeof MenuPrimitive.Group;
|
||||
GroupLabel: typeof MenuPrimitive.GroupLabel;
|
||||
RadioGroup: typeof MenuPrimitive.RadioGroup;
|
||||
RadioItem: typeof MenuPrimitive.RadioItem;
|
||||
RadioItemIndicator: typeof MenuPrimitive.RadioItemIndicator;
|
||||
CheckboxItem: typeof MenuPrimitive.CheckboxItem;
|
||||
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
|
||||
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
|
||||
Separator: typeof MenuPrimitive.Separator;
|
||||
Combobox: ForwardRefExoticComponent<
|
||||
MenuComboboxProps & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
};
|
||||
export interface MenuListBoxProps<T>
|
||||
extends RAListBoxProps<T>,
|
||||
Omit<RAListBoxProps<T>, 'children'> {
|
||||
placement?: RAPopoverProps['placement'];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuAutocompleteProps<T>
|
||||
extends RAMenuProps<T>,
|
||||
Omit<RAMenuProps<T>, 'children'> {
|
||||
placeholder?: string;
|
||||
placement?: RAPopoverProps['placement'];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuAutocompleteListBoxProps<T>
|
||||
extends RAListBoxProps<T>,
|
||||
Omit<RAListBoxProps<T>, 'children'> {
|
||||
placeholder?: string;
|
||||
placement?: RAPopoverProps['placement'];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuItemProps
|
||||
extends RAMenuItemProps,
|
||||
Omit<RAMenuItemProps, 'children'> {
|
||||
iconStart?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
color?: 'primary' | 'danger';
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuListBoxItemProps
|
||||
extends RAListBoxItemProps,
|
||||
Omit<RAListBoxItemProps, 'children'> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuSectionProps<T>
|
||||
extends RAMenuSectionProps<T>,
|
||||
Omit<RAMenuSectionProps<T>, 'children'> {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MenuSeparatorProps extends RAMenuSeparatorProps {}
|
||||
|
||||
@@ -157,21 +157,21 @@ export const componentDefinitions = {
|
||||
},
|
||||
Menu: {
|
||||
classNames: {
|
||||
trigger: 'bui-MenuTrigger',
|
||||
backdrop: 'bui-MenuBackdrop',
|
||||
positioner: 'bui-MenuPositioner',
|
||||
popup: 'bui-MenuPopup',
|
||||
arrow: 'bui-MenuArrow',
|
||||
root: 'bui-Menu',
|
||||
popover: 'bui-MenuPopover',
|
||||
content: 'bui-MenuContent',
|
||||
section: 'bui-MenuSection',
|
||||
sectionHeader: 'bui-MenuSectionHeader',
|
||||
item: 'bui-MenuItem',
|
||||
group: 'bui-MenuGroup',
|
||||
groupLabel: 'bui-MenuGroupLabel',
|
||||
radioGroup: 'bui-MenuRadioGroup',
|
||||
radioItem: 'bui-MenuRadioItem',
|
||||
radioItemIndicator: 'bui-MenuRadioItemIndicator',
|
||||
checkboxItem: 'bui-MenuCheckboxItem',
|
||||
checkboxItemIndicator: 'bui-MenuCheckboxItemIndicator',
|
||||
submenuTrigger: 'bui-MenuSubmenuTrigger',
|
||||
itemListBox: 'bui-MenuItemListBox',
|
||||
itemListBoxCheck: 'bui-MenuItemListBoxCheck',
|
||||
itemContent: 'bui-MenuItemContent',
|
||||
itemArrow: 'bui-MenuItemArrow',
|
||||
separator: 'bui-MenuSeparator',
|
||||
searchField: 'bui-MenuSearchField',
|
||||
searchFieldInput: 'bui-MenuSearchFieldInput',
|
||||
searchFieldClear: 'bui-MenuSearchFieldClear',
|
||||
emptyState: 'bui-MenuEmptyState',
|
||||
},
|
||||
},
|
||||
Popover: {
|
||||
|
||||
Reference in New Issue
Block a user