Merge pull request #31375 from backstage/bui-menu-virtualizer
BUI - Add virtualizer all Menu components
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Add new `virtualized`, `maxWidth` and `maxHeight` props to `Menu`, `MenuListBox`, `MenuAutocomplete` and `MenuAutocompleteListBox` to allow for virtalization of long lists inside menus.
|
||||
File diff suppressed because one or more lines are too long
@@ -70,6 +70,16 @@ export const menuPropDefs: Record<string, PropDef> = {
|
||||
type: 'enum',
|
||||
values: placementValues,
|
||||
},
|
||||
virtualized: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
maxWidth: {
|
||||
type: 'number',
|
||||
},
|
||||
maxHeight: {
|
||||
type: 'number',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
@@ -95,6 +105,16 @@ export const menuListBoxPropDefs: Record<string, PropDef> = {
|
||||
type: 'enum',
|
||||
values: placementValues,
|
||||
},
|
||||
virtualized: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
maxWidth: {
|
||||
type: 'number',
|
||||
},
|
||||
maxHeight: {
|
||||
type: 'number',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
@@ -104,6 +124,16 @@ export const menuAutocompletePropDefs: Record<string, PropDef> = {
|
||||
type: 'enum',
|
||||
values: placementValues,
|
||||
},
|
||||
virtualized: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
maxWidth: {
|
||||
type: 'number',
|
||||
},
|
||||
maxHeight: {
|
||||
type: 'number',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
@@ -113,6 +143,16 @@ export const menuAutocompleteListboxPropDefs: Record<string, PropDef> = {
|
||||
type: 'enum',
|
||||
values: placementValues,
|
||||
},
|
||||
virtualized: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
maxWidth: {
|
||||
type: 'number',
|
||||
},
|
||||
maxHeight: {
|
||||
type: 'number',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
|
||||
+238
-241
@@ -701,247 +701,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
transition: transform .2s, opacity .2s;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-entering], &[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[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 {
|
||||
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-6);
|
||||
outline: none;
|
||||
display: flex;
|
||||
|
||||
&[data-focused], &[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-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-6);
|
||||
outline: none;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemListBoxCheck {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 2rem;
|
||||
cursor: inherit;
|
||||
outline: none;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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-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 {
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
border: 1px solid var(--bui-border);
|
||||
@@ -11078,3 +10837,241 @@
|
||||
}
|
||||
|
||||
@layer legacy;
|
||||
|
||||
.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);
|
||||
outline: none;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
transition: transform .2s, opacity .2s;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-entering], &[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[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;
|
||||
min-width: 150px;
|
||||
padding-block: var(--bui-space-1);
|
||||
outline: none;
|
||||
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 {
|
||||
padding-inline: var(--bui-space-1);
|
||||
display: block;
|
||||
|
||||
&[data-focused] .bui-MenuItemWrapper, &[data-open] .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-color="danger"] .bui-MenuItemWrapper {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-color="danger"][data-focused] .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-danger);
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-has-submenu] {
|
||||
& > .bui-MenuItemWrapper > .bui-MenuItemArrow {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemWrapper {
|
||||
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-6);
|
||||
outline: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-MenuItemListBox {
|
||||
padding-inline: var(--bui-space-1);
|
||||
display: block;
|
||||
|
||||
&:hover .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemListBoxCheck {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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-3);
|
||||
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: 2rem;
|
||||
cursor: inherit;
|
||||
outline: none;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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-MenuEmptyState {
|
||||
padding-inline: var(--bui-space-3);
|
||||
padding-block: var(--bui-space-2);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -516,6 +516,7 @@ export const componentDefinitions: {
|
||||
readonly item: 'bui-MenuItem';
|
||||
readonly itemListBox: 'bui-MenuItemListBox';
|
||||
readonly itemListBoxCheck: 'bui-MenuItemListBoxCheck';
|
||||
readonly itemWrapper: 'bui-MenuItemWrapper';
|
||||
readonly itemContent: 'bui-MenuItemContent';
|
||||
readonly itemArrow: 'bui-MenuItemArrow';
|
||||
readonly separator: 'bui-MenuSeparator';
|
||||
@@ -1277,9 +1278,15 @@ export interface MenuAutocompleteListBoxProps<T>
|
||||
extends ListBoxProps<T>,
|
||||
Omit<ListBoxProps<T>, 'children'> {
|
||||
// (undocumented)
|
||||
maxHeight?: string;
|
||||
// (undocumented)
|
||||
maxWidth?: string;
|
||||
// (undocumented)
|
||||
placeholder?: string;
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
// (undocumented)
|
||||
virtualized?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1287,9 +1294,15 @@ export interface MenuAutocompleteProps<T>
|
||||
extends MenuProps_2<T>,
|
||||
Omit<MenuProps_2<T>, 'children'> {
|
||||
// (undocumented)
|
||||
maxHeight?: string;
|
||||
// (undocumented)
|
||||
maxWidth?: string;
|
||||
// (undocumented)
|
||||
placeholder?: string;
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
// (undocumented)
|
||||
virtualized?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1326,7 +1339,13 @@ export interface MenuListBoxProps<T>
|
||||
extends ListBoxProps<T>,
|
||||
Omit<ListBoxProps<T>, 'children'> {
|
||||
// (undocumented)
|
||||
maxHeight?: string;
|
||||
// (undocumented)
|
||||
maxWidth?: string;
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
// (undocumented)
|
||||
virtualized?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1334,7 +1353,13 @@ export interface MenuProps<T>
|
||||
extends MenuProps_2<T>,
|
||||
Omit<MenuProps_2<T>, 'children'> {
|
||||
// (undocumented)
|
||||
maxHeight?: string;
|
||||
// (undocumented)
|
||||
maxWidth?: string;
|
||||
// (undocumented)
|
||||
placement?: PopoverProps['placement'];
|
||||
// (undocumented)
|
||||
virtualized?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -19,15 +19,11 @@ import {
|
||||
MenuTrigger,
|
||||
SubmenuTrigger,
|
||||
Menu,
|
||||
MenuListBox,
|
||||
MenuAutocomplete,
|
||||
MenuAutocompleteListbox,
|
||||
MenuItem,
|
||||
MenuListBoxItem,
|
||||
MenuSection,
|
||||
MenuSeparator,
|
||||
} from './index';
|
||||
import { Button, Flex, Text } from '../..';
|
||||
import { Button } from '../..';
|
||||
import {
|
||||
RiChat1Line,
|
||||
RiEdit2Line,
|
||||
@@ -39,10 +35,8 @@ import {
|
||||
RiDeleteBinLine,
|
||||
RiShareBoxLine,
|
||||
} 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';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/Menu',
|
||||
@@ -206,88 +200,6 @@ export const PreviewLinks: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const PreviewAutocompleteMenu: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger>
|
||||
<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 PreviewAutocompleteListbox: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger>
|
||||
<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 PreviewAutocompleteListboxMultiple: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value, options[3].value]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocompleteListbox
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuListBoxItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</MenuTrigger>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Opened: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
@@ -320,24 +232,6 @@ export const WithIcons: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const WithScrolling: Story = {
|
||||
args: {
|
||||
...Preview.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: {
|
||||
...Preview.args,
|
||||
@@ -421,125 +315,6 @@ export const WithLinks: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLinksTest: Story = {
|
||||
args: {
|
||||
...Preview.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: {
|
||||
...Preview.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: {
|
||||
...Preview.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: {
|
||||
...Preview.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: {
|
||||
...Preview.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: {
|
||||
...Preview.args,
|
||||
@@ -552,7 +327,7 @@ export const Submenu: Story = {
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<Menu>
|
||||
<Menu placement="right top">
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<MenuItem>Rename</MenuItem>
|
||||
@@ -568,62 +343,72 @@ export const Submenu: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SubmenuAutocompleteMenu: Story = {
|
||||
args: {
|
||||
...Preview.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 SubmenuAutocompleteListbox: Story = {
|
||||
export const Virtualized: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value]),
|
||||
);
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu items={pokemon} virtualized>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualizedMaxHeight: Story = {
|
||||
args: {
|
||||
...Preview.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu items={pokemon} virtualized maxHeight="300px">
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,258 +14,253 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
.bui-MenuPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
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;
|
||||
.bui-MenuPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
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-entering],
|
||||
&[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[data-placement='top'] {
|
||||
--origin: translateY(8px);
|
||||
}
|
||||
|
||||
&[data-placement='bottom'] {
|
||||
--origin: translateY(-8px);
|
||||
}
|
||||
|
||||
&[data-placement='right'] {
|
||||
--origin: translateX(-8px);
|
||||
}
|
||||
|
||||
&[data-placement='left'] {
|
||||
--origin: translateX(8px);
|
||||
}
|
||||
&[data-entering],
|
||||
&[data-exiting] {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
&[data-placement='top'] {
|
||||
--origin: translateY(8px);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
&[data-placement='bottom'] {
|
||||
--origin: translateY(-8px);
|
||||
}
|
||||
|
||||
.bui-MenuPopover .bui-ScrollAreaScrollbar {
|
||||
margin-inline: var(--bui-space-1_5);
|
||||
&[data-placement='right'] {
|
||||
--origin: translateX(-8px);
|
||||
}
|
||||
|
||||
.bui-MenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
gap: var(--bui-space-6);
|
||||
|
||||
&[data-focused] {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[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-MenuItemListBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
gap: var(--bui-space-6);
|
||||
|
||||
&:hover {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemListBoxCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
&[data-placement='left'] {
|
||||
--origin: translateX(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuContent {
|
||||
max-height: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
min-width: 150px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
padding-block: var(--bui-space-1);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
padding-inline: var(--bui-space-1);
|
||||
display: block;
|
||||
|
||||
&[data-focused] .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-open] .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-color='danger'] .bui-MenuItemWrapper {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-color='danger'][data-focused] .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-danger);
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
&[data-has-submenu] {
|
||||
& > .bui-MenuItemWrapper > .bui-MenuItemArrow {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
gap: var(--bui-space-6);
|
||||
}
|
||||
|
||||
.bui-MenuItemListBox {
|
||||
padding-inline: var(--bui-space-1);
|
||||
display: block;
|
||||
|
||||
&:hover .bui-MenuItemWrapper {
|
||||
background: var(--bui-bg-surface-2);
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-selected] .bui-MenuItemListBoxCheck {
|
||||
& > svg {
|
||||
opacity: 1;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuItemListBoxCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
& > svg {
|
||||
opacity: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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-3);
|
||||
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-inline: var(--bui-space-3);
|
||||
padding-block: var(--bui-space-2);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
ListBoxItem as RAListBoxItem,
|
||||
useFilter,
|
||||
RouterProvider,
|
||||
Virtualizer,
|
||||
ListLayout,
|
||||
} from 'react-aria-components';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import type {
|
||||
MenuTriggerProps,
|
||||
@@ -54,6 +55,9 @@ import {
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
import { isExternalLink } from '../../utils/isExternalLink';
|
||||
|
||||
// The height will be used for virtualized menus. It should match the size set in CSS for each menu item.
|
||||
const rowHeight = 32;
|
||||
|
||||
const MenuEmptyState = () => {
|
||||
const { classNames } = useStyles('Menu');
|
||||
|
||||
@@ -72,23 +76,41 @@ export const SubmenuTrigger = (props: SubmenuTriggerProps) => {
|
||||
|
||||
/** @public */
|
||||
export const Menu = (props: MenuProps<object>) => {
|
||||
const { placement = 'bottom start', ...rest } = props;
|
||||
const {
|
||||
placement = 'bottom start',
|
||||
virtualized = false,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const navigate = useNavigate();
|
||||
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
|
||||
|
||||
const menuContent = (
|
||||
<RAMenu
|
||||
className={classNames.content}
|
||||
renderEmptyState={() => <MenuEmptyState />}
|
||||
style={{ width: newMaxWidth, maxHeight }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
<RouterProvider navigate={navigate} useHref={useHref}>
|
||||
<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>
|
||||
{virtualized ? (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
rowHeight,
|
||||
}}
|
||||
>
|
||||
{menuContent}
|
||||
</Virtualizer>
|
||||
) : (
|
||||
menuContent
|
||||
)}
|
||||
</RouterProvider>
|
||||
</RAPopover>
|
||||
);
|
||||
@@ -99,60 +121,92 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
|
||||
const {
|
||||
selectionMode = 'single',
|
||||
placement = 'bottom start',
|
||||
virtualized = false,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
|
||||
|
||||
const listBoxContent = (
|
||||
<RAListBox
|
||||
className={classNames.content}
|
||||
selectionMode={selectionMode}
|
||||
style={{ width: newMaxWidth, maxHeight }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
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>
|
||||
{virtualized ? (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
rowHeight,
|
||||
}}
|
||||
>
|
||||
{listBoxContent}
|
||||
</Virtualizer>
|
||||
) : (
|
||||
listBoxContent
|
||||
)}
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
|
||||
const { placement = 'bottom start', ...rest } = props;
|
||||
const {
|
||||
placement = 'bottom start',
|
||||
virtualized = false,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const menuContent = (
|
||||
<RAMenu
|
||||
className={classNames.content}
|
||||
renderEmptyState={() => <MenuEmptyState />}
|
||||
style={{ width: newMaxWidth, maxHeight }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
<RouterProvider navigate={navigate} useHref={useHref}>
|
||||
<RAAutocomplete filter={contains}>
|
||||
<RASearchField className={classNames.searchField}>
|
||||
<RAInput
|
||||
className={classNames.searchFieldInput}
|
||||
aria-label="Search"
|
||||
placeholder={props.placeholder || 'Search...'}
|
||||
/>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</RAAutocomplete>
|
||||
<RAButton className={classNames.searchFieldClear}>
|
||||
<RiCloseCircleLine />
|
||||
</RAButton>
|
||||
</RASearchField>
|
||||
{virtualized ? (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
rowHeight,
|
||||
}}
|
||||
>
|
||||
{menuContent}
|
||||
</Virtualizer>
|
||||
) : (
|
||||
menuContent
|
||||
)}
|
||||
</RAAutocomplete>
|
||||
</RouterProvider>
|
||||
</RAPopover>
|
||||
);
|
||||
};
|
||||
@@ -164,10 +218,24 @@ export const MenuAutocompleteListbox = (
|
||||
const {
|
||||
selectionMode = 'single',
|
||||
placement = 'bottom start',
|
||||
virtualized = false,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
...rest
|
||||
} = props;
|
||||
const { classNames } = useStyles('Menu');
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
|
||||
|
||||
const listBoxContent = (
|
||||
<RAListBox
|
||||
className={classNames.content}
|
||||
renderEmptyState={() => <MenuEmptyState />}
|
||||
selectionMode={selectionMode}
|
||||
style={{ width: newMaxWidth, maxHeight }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<RAPopover className={classNames.popover} placement={placement}>
|
||||
@@ -182,19 +250,18 @@ export const MenuAutocompleteListbox = (
|
||||
<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>
|
||||
{virtualized ? (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
rowHeight,
|
||||
}}
|
||||
>
|
||||
{listBoxContent}
|
||||
</Virtualizer>
|
||||
) : (
|
||||
listBoxContent
|
||||
)}
|
||||
</RAAutocomplete>
|
||||
</RAPopover>
|
||||
);
|
||||
@@ -208,24 +275,6 @@ export const MenuItem = (props: MenuItemProps) => {
|
||||
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 (
|
||||
<RAMenuItem
|
||||
@@ -235,6 +284,28 @@ export const MenuItem = (props: MenuItemProps) => {
|
||||
onAction={() => window.open(href, '_blank', 'noopener,noreferrer')}
|
||||
{...rest}
|
||||
>
|
||||
<div className={classNames.itemWrapper}>
|
||||
<div className={classNames.itemContent}>
|
||||
{iconStart}
|
||||
{children}
|
||||
</div>
|
||||
<div className={classNames.itemArrow}>
|
||||
<RiArrowRightSLine />
|
||||
</div>
|
||||
</div>
|
||||
</RAMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RAMenuItem
|
||||
className={classNames.item}
|
||||
data-color={color}
|
||||
href={href}
|
||||
textValue={typeof children === 'string' ? children : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<div className={classNames.itemWrapper}>
|
||||
<div className={classNames.itemContent}>
|
||||
{iconStart}
|
||||
{children}
|
||||
@@ -242,11 +313,9 @@ export const MenuItem = (props: MenuItemProps) => {
|
||||
<div className={classNames.itemArrow}>
|
||||
<RiArrowRightSLine />
|
||||
</div>
|
||||
</RAMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
</div>
|
||||
</RAMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
@@ -262,11 +331,13 @@ export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
|
||||
className={classNames.itemListBox}
|
||||
{...rest}
|
||||
>
|
||||
<div className={classNames.itemContent}>
|
||||
<div className={classNames.itemListBoxCheck}>
|
||||
<RiCheckLine />
|
||||
<div className={classNames.itemWrapper}>
|
||||
<div className={classNames.itemContent}>
|
||||
<div className={classNames.itemListBoxCheck}>
|
||||
<RiCheckLine />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</RAListBoxItem>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
MenuTrigger,
|
||||
SubmenuTrigger,
|
||||
Menu,
|
||||
MenuAutocomplete,
|
||||
MenuItem,
|
||||
} from './index';
|
||||
import { Button } from '../..';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/MenuAutocomplete',
|
||||
component: MenuTrigger,
|
||||
decorators: [
|
||||
Story => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MenuTrigger>;
|
||||
|
||||
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: null,
|
||||
},
|
||||
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 PreviewAutocompleteMenu: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger>
|
||||
<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 Virtualized: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocomplete
|
||||
items={pokemon}
|
||||
placeholder="Search Pokemon..."
|
||||
virtualized
|
||||
>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuAutocomplete>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualizedMaxHeight: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocomplete
|
||||
items={pokemon}
|
||||
placeholder="Search Pokemon..."
|
||||
virtualized
|
||||
maxHeight="300px"
|
||||
>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuAutocomplete>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Submenu: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<MenuAutocomplete placement="right top">
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuAutocomplete>
|
||||
</SubmenuTrigger>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
MenuTrigger,
|
||||
MenuAutocompleteListbox,
|
||||
MenuListBoxItem,
|
||||
Menu,
|
||||
MenuItem,
|
||||
SubmenuTrigger,
|
||||
} from './index';
|
||||
import { Button, Flex, Text } from '../..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Selection } from 'react-aria-components';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/MenuAutocompleteListBox',
|
||||
component: MenuTrigger,
|
||||
decorators: [
|
||||
Story => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MenuTrigger>;
|
||||
|
||||
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: null,
|
||||
},
|
||||
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 PreviewListbox: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger>
|
||||
<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 PreviewListboxMultiple: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set([options[2].value, options[3].value]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Text>Selected: {Array.from(selected).join(', ')}</Text>
|
||||
<MenuTrigger>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocompleteListbox
|
||||
selectionMode="multiple"
|
||||
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: {
|
||||
...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>
|
||||
<Menu>
|
||||
<MenuItem>Edit</MenuItem>
|
||||
<MenuItem>Duplicate</MenuItem>
|
||||
<SubmenuTrigger>
|
||||
<MenuItem>Submenu</MenuItem>
|
||||
<MenuAutocompleteListbox
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
placement="right top"
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuListBoxItem key={option.value} id={option.value}>
|
||||
{option.label}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</SubmenuTrigger>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Virtualized: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocompleteListbox
|
||||
items={pokemon}
|
||||
placeholder="Search Pokemon..."
|
||||
virtualized
|
||||
>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuListBoxItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualizedMaxHeight: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuAutocompleteListbox
|
||||
items={pokemon}
|
||||
placeholder="Search Pokemon..."
|
||||
virtualized
|
||||
maxHeight="300px"
|
||||
>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuListBoxItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuAutocompleteListbox>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { MenuTrigger, MenuListBox, MenuListBoxItem } from './index';
|
||||
import { Button, Flex, Text } from '../..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Selection } from 'react-aria-components';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/MenuListBox',
|
||||
component: MenuTrigger,
|
||||
decorators: [
|
||||
Story => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MenuTrigger>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
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 Controlled: 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 Virtualized: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuListBox items={pokemon} virtualized>
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuListBoxItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuListBox>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualizedMaxHeight: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: () => {
|
||||
const [pokemon, setPokemon] = useState<
|
||||
Array<{ name: string; url: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setPokemon(data.results);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching Pokemon:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuTrigger isOpen>
|
||||
<Button aria-label="Menu">Menu</Button>
|
||||
<MenuListBox items={pokemon} virtualized maxHeight="300px">
|
||||
{pokemon.map((p, index) => (
|
||||
<MenuListBoxItem key={index} id={p.name}>
|
||||
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
|
||||
</MenuListBoxItem>
|
||||
))}
|
||||
</MenuListBox>
|
||||
</MenuTrigger>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -37,6 +37,9 @@ export interface MenuProps<T>
|
||||
extends RAMenuProps<T>,
|
||||
Omit<RAMenuProps<T>, 'children'> {
|
||||
placement?: RAPopoverProps['placement'];
|
||||
virtualized?: boolean;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -44,6 +47,9 @@ export interface MenuListBoxProps<T>
|
||||
extends RAListBoxProps<T>,
|
||||
Omit<RAListBoxProps<T>, 'children'> {
|
||||
placement?: RAPopoverProps['placement'];
|
||||
virtualized?: boolean;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -52,6 +58,9 @@ export interface MenuAutocompleteProps<T>
|
||||
Omit<RAMenuProps<T>, 'children'> {
|
||||
placeholder?: string;
|
||||
placement?: RAPopoverProps['placement'];
|
||||
virtualized?: boolean;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -60,6 +69,9 @@ export interface MenuAutocompleteListBoxProps<T>
|
||||
Omit<RAListBoxProps<T>, 'children'> {
|
||||
placeholder?: string;
|
||||
placement?: RAPopoverProps['placement'];
|
||||
virtualized?: boolean;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -165,6 +165,7 @@ export const componentDefinitions = {
|
||||
item: 'bui-MenuItem',
|
||||
itemListBox: 'bui-MenuItemListBox',
|
||||
itemListBoxCheck: 'bui-MenuItemListBoxCheck',
|
||||
itemWrapper: 'bui-MenuItemWrapper',
|
||||
itemContent: 'bui-MenuItemContent',
|
||||
itemArrow: 'bui-MenuItemArrow',
|
||||
separator: 'bui-MenuSeparator',
|
||||
|
||||
Reference in New Issue
Block a user