mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 15:16:40 -05:00
Add new Menu component (#40184)
This commit is contained in:
@@ -1,117 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
MoonIcon,
|
||||
NumberCircleOneIcon,
|
||||
NumberCircleTwoIcon,
|
||||
SunIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
|
||||
import { Button } from '../button/redesign';
|
||||
import { ToggleField } from '../form_fields/redesign';
|
||||
|
||||
import type { DropdownProps } from './redesign';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownItemButton,
|
||||
DropdownPopover,
|
||||
} from './redesign';
|
||||
|
||||
const meta = {
|
||||
title: 'Redesign/Dropdown',
|
||||
args: {
|
||||
elevation: 1,
|
||||
},
|
||||
argTypes: {
|
||||
elevation: {
|
||||
control: 'inline-radio',
|
||||
options: [1, 2],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<Omit<DropdownProps<'div'>, 'children'>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const handleMenuItemClick = action('menu item click');
|
||||
|
||||
export const Simple: Story = {
|
||||
render(args) {
|
||||
return (
|
||||
<Dropdown {...args}>
|
||||
<DropdownItemButton
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
First item
|
||||
</DropdownItemButton>
|
||||
<DropdownItemButton
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
Second item
|
||||
</DropdownItemButton>
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Popover: Story = {
|
||||
render(args) {
|
||||
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
|
||||
const [open, { onToggle, onFalse }] = useToggle();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Button ref={setRef} onClick={onToggle}>
|
||||
Click to show dropdown
|
||||
</Button>
|
||||
|
||||
<DropdownPopover
|
||||
reference={ref}
|
||||
isOpen={open}
|
||||
onClose={onFalse}
|
||||
{...args}
|
||||
>
|
||||
<DropdownItemButton
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
First item
|
||||
</DropdownItemButton>
|
||||
<DropdownItemButton
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
Second item
|
||||
</DropdownItemButton>
|
||||
</DropdownPopover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Controls: Story = {
|
||||
render(args) {
|
||||
const [sun, { onToggle }] = useToggle();
|
||||
return (
|
||||
<Dropdown {...args}>
|
||||
<DropdownItemButton onClick={handleMenuItemClick}>
|
||||
First item
|
||||
</DropdownItemButton>
|
||||
|
||||
<hr />
|
||||
|
||||
<DropdownItem leadingIcon={sun ? SunIcon : MoonIcon}>
|
||||
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import type React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Merge } from 'type-fest';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
import type { IconProp } from '../icon';
|
||||
import type { PopoverProps } from '../popover';
|
||||
import { Popover } from '../popover';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const menuItemClass = classes.menuItem;
|
||||
|
||||
export type DropdownProps<As extends React.ElementType> = Merge<
|
||||
{
|
||||
as?: As;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
elevation?: 1 | 2;
|
||||
maxWidth?: number | string;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
React.ComponentProps<As>
|
||||
>;
|
||||
|
||||
export const DropdownPopover = <As extends React.ElementType>({
|
||||
isOpen,
|
||||
onClose,
|
||||
reference,
|
||||
popoverElement,
|
||||
container,
|
||||
placement,
|
||||
offset = 4,
|
||||
flip,
|
||||
strategy,
|
||||
matchReferenceWidth,
|
||||
closeOnClickOutside,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DropdownProps<As> & Omit<PopoverProps, 'children'>) => {
|
||||
const popoverProps = {
|
||||
isOpen,
|
||||
onClose,
|
||||
reference,
|
||||
popoverElement,
|
||||
container,
|
||||
placement,
|
||||
offset,
|
||||
flip,
|
||||
strategy,
|
||||
matchReferenceWidth,
|
||||
closeOnClickOutside,
|
||||
};
|
||||
return (
|
||||
<Popover {...popoverProps}>
|
||||
{({ props: popoverChildProps }) => (
|
||||
<Dropdown
|
||||
{...props}
|
||||
{...popoverChildProps}
|
||||
className={classNames(
|
||||
className,
|
||||
props.maxWidth && classes.popoverMenu,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const Dropdown = <As extends React.ElementType>({
|
||||
as: asComp,
|
||||
children,
|
||||
className,
|
||||
elevation = 1,
|
||||
maxWidth,
|
||||
style,
|
||||
...props
|
||||
}: DropdownProps<As>) => {
|
||||
const Component = asComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={classNames(className, classes.menu)}
|
||||
data-elevation={elevation}
|
||||
style={{
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
type DropdownItemProps<As extends React.ElementType> = Merge<
|
||||
{
|
||||
as?: As;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
leadingIcon?: IconProp;
|
||||
trailingIcon?: IconProp;
|
||||
iconClassName?: string;
|
||||
},
|
||||
React.ComponentPropsWithoutRef<As>
|
||||
>;
|
||||
|
||||
export const DropdownItem = <As extends React.ElementType>({
|
||||
active,
|
||||
disabled,
|
||||
as: AsComp,
|
||||
children,
|
||||
className,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
iconClassName,
|
||||
...props
|
||||
}: DropdownItemProps<As>) => {
|
||||
const Component = AsComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={classNames(
|
||||
className,
|
||||
classes.menuItem,
|
||||
active && classes.menuItemActive,
|
||||
disabled && classes.menuItemDisabled,
|
||||
)}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={leadingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{trailingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={trailingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownItemButton: React.FC<
|
||||
Omit<DropdownItemProps<'button'>, 'as'>
|
||||
> = ({ children, className, ...props }) => {
|
||||
return (
|
||||
<DropdownItem
|
||||
type='button'
|
||||
{...props}
|
||||
as='button'
|
||||
className={classNames(className, classes.menuItemButton)}
|
||||
>
|
||||
{children}
|
||||
</DropdownItem>
|
||||
);
|
||||
};
|
||||
94
app/javascript/mastodon/components/menu/card.tsx
Normal file
94
app/javascript/mastodon/components/menu/card.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Merge } from 'type-fest';
|
||||
|
||||
import { Popover } from '../popover';
|
||||
import type { PopoverProps } from '../popover';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export type MenuCardProps<As extends React.ElementType> = Merge<
|
||||
{
|
||||
as?: As;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
elevation?: 1 | 2;
|
||||
maxWidth?: number | string;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
React.ComponentProps<As>
|
||||
>;
|
||||
|
||||
export const MenuCard = <As extends React.ElementType>({
|
||||
as: asComp,
|
||||
children,
|
||||
className,
|
||||
elevation = 1,
|
||||
maxWidth,
|
||||
style,
|
||||
...props
|
||||
}: MenuCardProps<As>) => {
|
||||
const Component = asComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={classNames(className, classes.menuCard)}
|
||||
data-elevation={elevation}
|
||||
style={{
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export type PopoverMenuCardProps<As extends React.ElementType> =
|
||||
MenuCardProps<As> & Omit<PopoverProps, 'children'>;
|
||||
|
||||
export const PopoverMenuCard = <As extends React.ElementType>({
|
||||
isOpen,
|
||||
onClose,
|
||||
reference,
|
||||
popoverElement,
|
||||
container,
|
||||
placement,
|
||||
offset = 4,
|
||||
flip,
|
||||
strategy,
|
||||
matchReferenceWidth,
|
||||
closeOnClickOutside,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PopoverMenuCardProps<As>) => {
|
||||
return (
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
reference={reference}
|
||||
popoverElement={popoverElement}
|
||||
container={container}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
flip={flip}
|
||||
strategy={strategy}
|
||||
matchReferenceWidth={matchReferenceWidth}
|
||||
closeOnClickOutside={closeOnClickOutside}
|
||||
>
|
||||
{({ props: popoverChildProps }) => (
|
||||
<MenuCard
|
||||
{...popoverChildProps}
|
||||
{...props}
|
||||
className={classNames(
|
||||
className,
|
||||
props.maxWidth && classes.popoverMenuCard,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</MenuCard>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
336
app/javascript/mastodon/components/menu/index.tsx
Normal file
336
app/javascript/mastodon/components/menu/index.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useCallback,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Merge } from 'type-fest';
|
||||
|
||||
import { Button } from '../button/redesign';
|
||||
import { Icon } from '../icon';
|
||||
import type { IconProp } from '../icon';
|
||||
|
||||
import { PopoverMenuCard } from './card';
|
||||
import type { PopoverMenuCardProps } from './card';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const menuItemClass = classes.menuItem;
|
||||
|
||||
interface PopoverState {
|
||||
isMenuOpen: boolean;
|
||||
toggleMenu: () => void;
|
||||
openMenu: () => void;
|
||||
closeMenu: () => void;
|
||||
popover: HTMLDivElement | null;
|
||||
reference: HTMLButtonElement | null;
|
||||
}
|
||||
|
||||
interface MenuButtonContextProps {
|
||||
ref: (button: HTMLButtonElement | null) => void;
|
||||
id: string;
|
||||
'aria-haspopup': 'menu';
|
||||
'aria-expanded': boolean;
|
||||
'aria-controls'?: string;
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
interface MenuListContextProps {
|
||||
ref: (button: HTMLDivElement | null) => void;
|
||||
role: 'menu';
|
||||
tabIndex: -1;
|
||||
id: string;
|
||||
'aria-labelledby': string;
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
popover: PopoverState;
|
||||
menuButtonProps: MenuButtonContextProps;
|
||||
menuListProps: MenuListContextProps;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuState | null>(null);
|
||||
|
||||
export function useMenuContext(): MenuState {
|
||||
const context = use(MenuContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMenu must be used within a <Menu> component');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function getAllMenuItems(menuListElement: HTMLDivElement) {
|
||||
return Array.from(
|
||||
menuListElement.querySelectorAll<HTMLElement>(
|
||||
':scope [data-menu-item]:not([disabled], [aria-disabled])',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const id = useId();
|
||||
const buttonId = `${id}-button`;
|
||||
const listId = `${id}-list`;
|
||||
const [buttonElement, setButtonElement] = useState<HTMLButtonElement | null>(
|
||||
null,
|
||||
);
|
||||
const [listElement, setListElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const mountListElement = useCallback((element: HTMLDivElement | null) => {
|
||||
setListElement(element);
|
||||
if (element) {
|
||||
const menuItems = getAllMenuItems(element);
|
||||
const elementToFocus = menuItems[0] ?? element;
|
||||
elementToFocus.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false);
|
||||
buttonElement?.focus();
|
||||
}, [buttonElement]);
|
||||
|
||||
const toggleMenu = isMenuOpen ? closeMenu : openMenu;
|
||||
|
||||
const handleMenuNavigation = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (!listElement) return;
|
||||
|
||||
const menuItems = getAllMenuItems(listElement);
|
||||
if (menuItems.length === 0) return;
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const currentIndex = menuItems.indexOf(activeElement);
|
||||
|
||||
switch (event.code) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
if (isMenuOpen) {
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? 0 : (currentIndex + 1) % menuItems.length;
|
||||
menuItems[nextIndex]?.focus();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const prevIndex =
|
||||
currentIndex === -1
|
||||
? menuItems.length - 1
|
||||
: (currentIndex - 1 + menuItems.length) % menuItems.length;
|
||||
menuItems[prevIndex]?.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Home': {
|
||||
event.preventDefault();
|
||||
menuItems[0]?.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'End': {
|
||||
event.preventDefault();
|
||||
menuItems[menuItems.length - 1]?.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Escape': {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeMenu, isMenuOpen, listElement, openMenu],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
const popover: PopoverState = {
|
||||
isMenuOpen,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
toggleMenu,
|
||||
reference: buttonElement,
|
||||
popover: listElement,
|
||||
};
|
||||
|
||||
const menuButtonProps: MenuButtonContextProps = {
|
||||
id: buttonId,
|
||||
ref: setButtonElement,
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': isMenuOpen,
|
||||
'aria-controls': listElement ? listId : undefined,
|
||||
onClick: toggleMenu,
|
||||
onKeyDown: handleMenuNavigation,
|
||||
};
|
||||
|
||||
const menuListProps: MenuListContextProps = {
|
||||
id: listId,
|
||||
ref: mountListElement,
|
||||
'aria-labelledby': buttonId,
|
||||
role: 'menu',
|
||||
tabIndex: -1,
|
||||
onKeyDown: handleMenuNavigation,
|
||||
};
|
||||
|
||||
return {
|
||||
popover,
|
||||
menuButtonProps,
|
||||
menuListProps,
|
||||
};
|
||||
}, [
|
||||
isMenuOpen,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
toggleMenu,
|
||||
buttonElement,
|
||||
listElement,
|
||||
mountListElement,
|
||||
buttonId,
|
||||
listId,
|
||||
handleMenuNavigation,
|
||||
]);
|
||||
|
||||
return <MenuContext value={contextValue}>{children}</MenuContext>;
|
||||
};
|
||||
|
||||
export type MenuButtonProps<As extends React.ElementType> = Merge<
|
||||
React.ComponentProps<As>,
|
||||
{
|
||||
as?: As;
|
||||
}
|
||||
>;
|
||||
|
||||
export const MenuButton = <As extends React.ElementType>({
|
||||
as: asComp,
|
||||
children,
|
||||
...props
|
||||
}: MenuButtonProps<As>) => {
|
||||
const Component = asComp ?? Button;
|
||||
const { menuButtonProps } = useMenuContext();
|
||||
return (
|
||||
<Component {...props} {...menuButtonProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export type MenuListProps<As extends React.ElementType> = Omit<
|
||||
PopoverMenuCardProps<As>,
|
||||
'isOpen' | 'onClose' | 'reference' | 'popoverElement'
|
||||
>;
|
||||
|
||||
export const MenuList = <As extends React.ElementType>({
|
||||
children,
|
||||
...props
|
||||
}: MenuListProps<As>) => {
|
||||
const { popover, menuListProps } = useMenuContext();
|
||||
|
||||
return (
|
||||
<PopoverMenuCard
|
||||
isOpen={popover.isMenuOpen}
|
||||
onClose={popover.closeMenu}
|
||||
reference={popover.reference}
|
||||
popoverElement={popover.popover}
|
||||
container={null}
|
||||
{...props}
|
||||
{...menuListProps}
|
||||
>
|
||||
{children}
|
||||
</PopoverMenuCard>
|
||||
);
|
||||
};
|
||||
|
||||
type MenuItemProps<As extends React.ElementType> = Merge<
|
||||
{
|
||||
as?: As;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
leadingIcon?: IconProp;
|
||||
trailingIcon?: IconProp;
|
||||
iconClassName?: string;
|
||||
},
|
||||
React.ComponentPropsWithoutRef<As>
|
||||
>;
|
||||
|
||||
export const MenuItemBase = <As extends React.ElementType>({
|
||||
active,
|
||||
disabled,
|
||||
as: AsComp,
|
||||
children,
|
||||
className,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
iconClassName,
|
||||
...props
|
||||
}: MenuItemProps<As>) => {
|
||||
const Component = AsComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
data-menu-item
|
||||
className={classNames(
|
||||
className,
|
||||
classes.menuItem,
|
||||
active && classes.menuItemActive,
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={leadingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{trailingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={trailingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<MenuItemBase
|
||||
as='button'
|
||||
type='button'
|
||||
role='menuitem'
|
||||
{...props}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
100
app/javascript/mastodon/components/menu/menu.stories.tsx
Normal file
100
app/javascript/mastodon/components/menu/menu.stories.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
MoonIcon,
|
||||
NumberCircleOneIcon,
|
||||
NumberCircleTwoIcon,
|
||||
SunIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
|
||||
import { ToggleField } from '../form_fields/redesign';
|
||||
|
||||
import { Menu, MenuButton, MenuList, MenuItemBase, MenuItem } from '.';
|
||||
import type { MenuCardProps } from './card';
|
||||
import { MenuCard } from './card';
|
||||
|
||||
const meta = {
|
||||
title: 'Redesign/Menu',
|
||||
args: {
|
||||
elevation: 1,
|
||||
},
|
||||
argTypes: {
|
||||
elevation: {
|
||||
control: 'inline-radio',
|
||||
options: [1, 2],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<Omit<MenuCardProps<'div'>, 'children'>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const handleMenuItemClick = action('menu item click');
|
||||
|
||||
export const Simple: Story = {
|
||||
render(args) {
|
||||
return (
|
||||
<MenuCard {...args}>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
First item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
Second item
|
||||
</MenuItem>
|
||||
</MenuCard>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Popover: Story = {
|
||||
render(args) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Menu>
|
||||
<MenuButton>Click to show dropdown</MenuButton>
|
||||
|
||||
<MenuList {...args}>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
First item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
Second item
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Controls: Story = {
|
||||
render(args) {
|
||||
const [sun, { onToggle }] = useToggle();
|
||||
return (
|
||||
<MenuCard {...args}>
|
||||
<MenuItem onClick={handleMenuItemClick}>First item</MenuItem>
|
||||
|
||||
<hr />
|
||||
|
||||
<MenuItemBase leadingIcon={sun ? SunIcon : MoonIcon}>
|
||||
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
|
||||
</MenuItemBase>
|
||||
</MenuCard>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.menu {
|
||||
.menuCard {
|
||||
@include mixins.elevation-1;
|
||||
|
||||
display: flex;
|
||||
@@ -9,6 +9,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
overflow: hidden;
|
||||
z-index: calc(infinity);
|
||||
|
||||
&[data-elevation='2'] {
|
||||
@include mixins.elevation-2;
|
||||
@@ -22,6 +23,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.popoverMenuCard {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
@@ -37,17 +42,24 @@
|
||||
background 200ms,
|
||||
color 200ms;
|
||||
|
||||
&:where(button) {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
&:hover:not(.menuItemDisabled) {
|
||||
&:hover:not([aria-disabled='true']) {
|
||||
background-color: var(--color-bg-highlight);
|
||||
}
|
||||
|
||||
&:active:not(.menuItemDisabled),
|
||||
&:active:not([aria-disabled='true']),
|
||||
.menuItemActive {
|
||||
background-color: var(--color-bg-inverted);
|
||||
color: var(--color-text-inverted);
|
||||
@@ -58,20 +70,13 @@
|
||||
}
|
||||
|
||||
&:has(:disabled),
|
||||
.menuItemDisabled {
|
||||
[aria-disabled='true'] {
|
||||
--cursor: not-allowed;
|
||||
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemButton {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
@@ -84,7 +89,3 @@
|
||||
border: none;
|
||||
width: calc(100% - (2 * var(--space-sm)));
|
||||
}
|
||||
|
||||
.popoverMenu {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -190,18 +190,22 @@ export const Popover: React.FC<PopoverProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const props: PopoverChildProps = {
|
||||
style: floatingStyles,
|
||||
'data-popover-placement': computedPlacement,
|
||||
'data-popover-reference-hidden': middlewareData.hide?.referenceHidden,
|
||||
'data-popover-escaped': middlewareData.hide?.escaped,
|
||||
};
|
||||
if (!popoverElement) {
|
||||
props.ref = refs.setFloating;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal container={container}>
|
||||
{children({
|
||||
placement: computedPlacement,
|
||||
update,
|
||||
props: {
|
||||
ref: popoverElement ? undefined : refs.setFloating,
|
||||
style: floatingStyles,
|
||||
'data-popover-placement': computedPlacement,
|
||||
'data-popover-reference-hidden': middlewareData.hide?.referenceHidden,
|
||||
'data-popover-escaped': middlewareData.hide?.escaped,
|
||||
},
|
||||
props,
|
||||
})}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { emojiUse } from '@/mastodon/actions/emojis';
|
||||
import { changeSetting } from '@/mastodon/actions/settings';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { CircularProgress } from '@/mastodon/components/circular_progress';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown/redesign';
|
||||
import { MenuCard } from '@/mastodon/components/menu/card';
|
||||
import type { PopoverChildProps } from '@/mastodon/components/popover';
|
||||
import { Popover } from '@/mastodon/components/popover';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
@@ -161,7 +161,7 @@ const ComposeEmojiDropdown: React.FC<
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<MenuCard
|
||||
{...props}
|
||||
className={classNames(
|
||||
'dropdown-animation',
|
||||
@@ -216,7 +216,7 @@ const ComposeEmojiDropdown: React.FC<
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
</Dropdown>
|
||||
</MenuCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TranslateIcon } from '@phosphor-icons/react';
|
||||
|
||||
import { changeComposeLanguage } from '@/mastodon/actions/compose';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { DropdownPopover } from '@/mastodon/components/dropdown/redesign';
|
||||
import { PopoverMenuCard } from '@/mastodon/components/menu/card';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { LanguageDropdownMenu } from '../components/language_dropdown';
|
||||
@@ -55,7 +55,7 @@ export const LanguageButton: React.FC = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<DropdownPopover
|
||||
<PopoverMenuCard
|
||||
isOpen={open}
|
||||
onClose={handleClose}
|
||||
offset={4}
|
||||
@@ -65,7 +65,7 @@ export const LanguageButton: React.FC = () => {
|
||||
maxWidth={280}
|
||||
>
|
||||
<LanguageDropdown onClose={handleClose} />
|
||||
</DropdownPopover>
|
||||
</PopoverMenuCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import type React from 'react';
|
||||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { CircularProgress } from '@/mastodon/components/circular_progress';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItemButton,
|
||||
DropdownPopover,
|
||||
} from '@/mastodon/components/dropdown/redesign';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
} from '@/mastodon/components/menu';
|
||||
import { MenuCard } from '@/mastodon/components/menu/card';
|
||||
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { isRedesignEnabled } from '@/mastodon/utils/environment';
|
||||
@@ -32,9 +33,6 @@ const ComposeLazyForm = lazy(() =>
|
||||
);
|
||||
|
||||
export const ComposeRedesignButton: React.FC = () => {
|
||||
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
|
||||
const [menuOpen, { onFalse: onMenuClose, onToggle: onMenuToggle }] =
|
||||
useToggle();
|
||||
const displayState = useAppSelector((state) => state.composer.displayState);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -46,10 +44,9 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
} = event;
|
||||
if (name === 'post' || name === 'message') {
|
||||
dispatch(openNewComposer({ type: name }));
|
||||
onMenuClose();
|
||||
}
|
||||
},
|
||||
[dispatch, onMenuClose],
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!isRedesignEnabled()) {
|
||||
@@ -58,9 +55,9 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
|
||||
if (displayState === 'minimized') {
|
||||
return (
|
||||
<Dropdown className={classes.composerMinimized} elevation={2}>
|
||||
<MenuCard className={classes.composerMinimized} elevation={2}>
|
||||
<ComposeFormHeader />
|
||||
</Dropdown>
|
||||
</MenuCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +70,11 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={PenNibIcon}
|
||||
color='neutral'
|
||||
ref={setRef}
|
||||
onClick={onMenuToggle}
|
||||
className={classes.button}
|
||||
size='lg'
|
||||
>
|
||||
@@ -86,24 +82,18 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
id='compose.new'
|
||||
defaultMessage='Write a new post or messsage'
|
||||
/>
|
||||
</IconButton>
|
||||
</MenuButton>
|
||||
|
||||
<DropdownPopover
|
||||
isOpen={menuOpen}
|
||||
maxWidth={180}
|
||||
reference={ref}
|
||||
onClose={onMenuClose}
|
||||
placement='top-end'
|
||||
>
|
||||
<DropdownItemButton
|
||||
<MenuList maxWidth={180} placement='top-end'>
|
||||
<MenuItem
|
||||
name='post'
|
||||
onClick={handleComposerOpen}
|
||||
leadingIcon={NewspaperIcon}
|
||||
>
|
||||
<FormattedMessage id='compose.new.post' defaultMessage='Post' />
|
||||
</DropdownItemButton>
|
||||
</MenuItem>
|
||||
|
||||
<DropdownItemButton
|
||||
<MenuItem
|
||||
name='message'
|
||||
onClick={handleComposerOpen}
|
||||
leadingIcon={ChatCircleIcon}
|
||||
@@ -113,8 +103,8 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
defaultMessage='Message'
|
||||
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
|
||||
/>
|
||||
</DropdownItemButton>
|
||||
</DropdownPopover>
|
||||
</>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,10 +12,7 @@ import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
|
||||
import { Blurhash } from '@/mastodon/components/blurhash';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import {
|
||||
DropdownItemButton,
|
||||
DropdownPopover,
|
||||
} from '@/mastodon/components/dropdown/redesign';
|
||||
import { MenuItem, MenuList } from '@/mastodon/components/menu';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@@ -103,7 +100,7 @@ export const ComposeUpload: React.FC<{
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<DropdownPopover
|
||||
<MenuList
|
||||
isOpen={open}
|
||||
onClose={onFalse}
|
||||
reference={target}
|
||||
@@ -111,7 +108,7 @@ export const ComposeUpload: React.FC<{
|
||||
offset={4}
|
||||
maxWidth={170}
|
||||
>
|
||||
<DropdownItemButton onClick={handleEdit}>
|
||||
<MenuItem onClick={handleEdit}>
|
||||
{attachment.description ? (
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.edit_alt'
|
||||
@@ -123,20 +120,20 @@ export const ComposeUpload: React.FC<{
|
||||
defaultMessage='Add alt text'
|
||||
/>
|
||||
)}
|
||||
</DropdownItemButton>
|
||||
</MenuItem>
|
||||
|
||||
{!single && (
|
||||
<DropdownItemButton onClick={handleRearrange}>
|
||||
<MenuItem onClick={handleRearrange}>
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.rearrange'
|
||||
defaultMessage='Rearrange…'
|
||||
/>
|
||||
</DropdownItemButton>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
<DropdownItemButton
|
||||
<MenuItem
|
||||
className={classes.mediaMenuDelete}
|
||||
onClick={handleDelete}
|
||||
leadingIcon={TrashIcon}
|
||||
@@ -145,8 +142,8 @@ export const ComposeUpload: React.FC<{
|
||||
id='compose.upload.menu.delete'
|
||||
defaultMessage='Remove image'
|
||||
/>
|
||||
</DropdownItemButton>
|
||||
</DropdownPopover>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
|
||||
{attachment.description && (
|
||||
<span className={classes.mediaAlt}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -16,20 +16,19 @@ import {
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { Button } from '@/mastodon/components/button/redesign';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownItemButton,
|
||||
} from '@/mastodon/components/dropdown/redesign';
|
||||
import { Fieldset } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
ToggleField,
|
||||
RadioButtonField,
|
||||
} from '@/mastodon/components/form_fields/redesign';
|
||||
import type { IconProp } from '@/mastodon/components/icon';
|
||||
import { Popover } from '@/mastodon/components/popover';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import {
|
||||
Menu,
|
||||
MenuList,
|
||||
MenuButton,
|
||||
MenuItemBase,
|
||||
MenuItem,
|
||||
} from '@/mastodon/components/menu';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { selectComposeMentions, selectComposePrivacy } from './selectors';
|
||||
@@ -38,8 +37,6 @@ import classes from './styles.module.scss';
|
||||
export const ComposeVisibility: React.FC = () => {
|
||||
const privacy = useAppSelector(selectComposePrivacy);
|
||||
const mentions = useAppSelector(selectComposeMentions);
|
||||
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
|
||||
const [showMenu, { onToggle, onFalse }] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,42 +45,31 @@ export const ComposeVisibility: React.FC = () => {
|
||||
defaultMessage='To:'
|
||||
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
|
||||
/>
|
||||
<Menu>
|
||||
<MenuButton size='sm'>
|
||||
{privacy !== 'private' && (
|
||||
<FormattedMessage
|
||||
id='privacy.public.short'
|
||||
defaultMessage='Public'
|
||||
/>
|
||||
)}
|
||||
{privacy === 'private' && (
|
||||
<FormattedMessage
|
||||
id='compose.post.privacy.followers'
|
||||
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
|
||||
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
|
||||
values={{ count: mentions.size }}
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onToggle}
|
||||
ref={setTrigger}
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
{privacy !== 'private' && (
|
||||
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
|
||||
)}
|
||||
{privacy === 'private' && (
|
||||
<FormattedMessage
|
||||
id='compose.post.privacy.followers'
|
||||
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
|
||||
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
|
||||
values={{ count: mentions.size }}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
isOpen={showMenu}
|
||||
onClose={onFalse}
|
||||
reference={trigger}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
{({ props }) => <ComposeVisibilityMenu {...props} />}
|
||||
</Popover>
|
||||
<ComposeVisibilityMenu />
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
|
||||
wrapperProps,
|
||||
) => {
|
||||
const ComposeVisibilityMenu: React.FC = () => {
|
||||
const privacy = useAppSelector(selectComposePrivacy);
|
||||
const defaultPrivacy = useAppSelector(
|
||||
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
||||
@@ -150,7 +136,7 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Dropdown {...wrapperProps} maxWidth={280}>
|
||||
<MenuList placement='bottom-start' offset={4} maxWidth={280}>
|
||||
<Fieldset
|
||||
name='visibility'
|
||||
legend={
|
||||
@@ -250,17 +236,14 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
|
||||
|
||||
<hr />
|
||||
|
||||
<DropdownItemButton
|
||||
leadingIcon={ChatCircleIcon}
|
||||
onClick={handleSwitchToMessage}
|
||||
>
|
||||
<MenuItem leadingIcon={ChatCircleIcon} onClick={handleSwitchToMessage}>
|
||||
<FormattedMessage
|
||||
id='compose.post.to_message'
|
||||
defaultMessage='Compose a message instead'
|
||||
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
|
||||
/>
|
||||
</DropdownItemButton>
|
||||
</Dropdown>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -275,7 +258,7 @@ const DropdownRadioCheckField: React.FC<
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<DropdownItem onClick={onWrapperClick} disabled={disabled}>
|
||||
<MenuItemBase onClick={onWrapperClick} disabled={disabled}>
|
||||
<RadioButtonField
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -284,7 +267,7 @@ const DropdownRadioCheckField: React.FC<
|
||||
disabled={disabled}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
/>
|
||||
</DropdownItem>
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -297,7 +280,7 @@ const DropdownToggleField: React.FC<
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
<MenuItemBase
|
||||
onClick={onWrapperClick}
|
||||
leadingIcon={icon}
|
||||
disabled={disabled}
|
||||
@@ -310,7 +293,7 @@ const DropdownToggleField: React.FC<
|
||||
label={children}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
/>
|
||||
</DropdownItem>
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
|
||||
interface UseListFocusArgs {
|
||||
/** Array of IDs. */
|
||||
ids: string[];
|
||||
/** The initially selected ID. */
|
||||
initialId?: string;
|
||||
/** Callback for when an item is selected. */
|
||||
onSelectId?: (id: string) => void;
|
||||
/** Callback to determine if a given ID is disabled. */
|
||||
getIsIdDisabled?: (id: string) => boolean;
|
||||
/** Callback when an item is clicked on. */
|
||||
onClickId?: (id: string, event: React.MouseEvent) => void;
|
||||
/** Callback when an item is focused. */
|
||||
onFocusId?: (id: string, event: React.FocusEvent) => void;
|
||||
/** Callback when a key is pressed when an item is focused. */
|
||||
onKeyDownId?: (id: string, event: React.KeyboardEvent) => void;
|
||||
/** Callback when the mouse enters an item space. */
|
||||
onMouseEnterId?: (id: string, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
type ItemComponentProps = {
|
||||
'data-id': string;
|
||||
'data-highlighted': boolean;
|
||||
} & Required<
|
||||
Pick<
|
||||
React.HTMLAttributes<Element>,
|
||||
| 'tabIndex'
|
||||
| 'aria-disabled'
|
||||
| 'aria-selected'
|
||||
| 'onClick'
|
||||
| 'onFocus'
|
||||
| 'onKeyDown'
|
||||
| 'onMouseEnter'
|
||||
>
|
||||
>;
|
||||
|
||||
export function useListFocus({
|
||||
ids,
|
||||
initialId: selectedInitialId,
|
||||
getIsIdDisabled,
|
||||
onMouseEnterId,
|
||||
onFocusId,
|
||||
onKeyDownId,
|
||||
onClickId,
|
||||
onSelectId,
|
||||
}: UseListFocusArgs) {
|
||||
const baseId = useId(); // The baseId is a unique ID prefix to avoid needing a wrapper ref.
|
||||
const [selectedId, setSelectedId] = useState(selectedInitialId ?? null);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
(id: string) => {
|
||||
if (getIsIdDisabled) {
|
||||
return getIsIdDisabled(id);
|
||||
}
|
||||
const element = idToElement(id, baseId);
|
||||
if (element?.ariaDisabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[getIsIdDisabled, baseId],
|
||||
);
|
||||
|
||||
// Calculate the initial ID as either the selected ID or the first non-disabled ID.
|
||||
const initialId = useMemo(() => {
|
||||
if (
|
||||
selectedInitialId &&
|
||||
ids.includes(selectedInitialId) &&
|
||||
!isDisabled(selectedInitialId)
|
||||
) {
|
||||
return selectedInitialId;
|
||||
}
|
||||
return ids.find((id) => !isDisabled(id)) ?? null;
|
||||
}, [ids, isDisabled, selectedInitialId]);
|
||||
|
||||
const [rawHighlightedId, setHighlightedId] = useState(initialId);
|
||||
|
||||
// Get the valid highlighted ID.
|
||||
const highlightedId = useMemo(() => {
|
||||
if (
|
||||
rawHighlightedId !== null &&
|
||||
ids.includes(rawHighlightedId) &&
|
||||
!isDisabled(rawHighlightedId)
|
||||
) {
|
||||
return rawHighlightedId;
|
||||
}
|
||||
return initialId;
|
||||
}, [ids, initialId, isDisabled, rawHighlightedId]);
|
||||
|
||||
// Set the correct highlight, triggering focus on the element.
|
||||
const onHighlight = useCallback(
|
||||
(id?: string | null, focus = true) => {
|
||||
if (!id) {
|
||||
setHighlightedId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDisabled(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHighlightedId(id);
|
||||
const element = document.querySelector(
|
||||
`[data-id="${safeId(id, baseId)}"]`,
|
||||
);
|
||||
if (element instanceof HTMLElement && focus) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
[baseId, isDisabled],
|
||||
);
|
||||
|
||||
// Select the item if it's not disabled.
|
||||
const onSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (isDisabled(id)) {
|
||||
return;
|
||||
}
|
||||
onSelectId?.(id);
|
||||
setSelectedId(id);
|
||||
setHighlightedId(id);
|
||||
},
|
||||
[isDisabled, onSelectId],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts.
|
||||
const onKeyDown = useCallback(
|
||||
(id: string, event: React.KeyboardEvent) => {
|
||||
onKeyDownId?.(id, event);
|
||||
if (isDisabled(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = ids.findIndex((indexId) => indexId === id);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getValidIdInDirection = (
|
||||
direction: 'prev' | 'next',
|
||||
full = false,
|
||||
) => {
|
||||
if (full) {
|
||||
return (
|
||||
ids[direction === 'next' ? 'findLast' : 'find'](
|
||||
(id) => !isDisabled(id),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const delta = direction === 'next' ? 1 : -1;
|
||||
for (let offset = 1; offset <= ids.length; offset += 1) {
|
||||
// Use a modulo to wrap the ids.
|
||||
const index =
|
||||
(currentIndex + offset * delta + ids.length) % ids.length;
|
||||
const indexId = ids[index];
|
||||
if (indexId && !isDisabled(indexId)) {
|
||||
return indexId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let foundKey = true;
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
onSelect(id);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
onHighlight(getValidIdInDirection('next'));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
onHighlight(getValidIdInDirection('prev'));
|
||||
break;
|
||||
case 'Tab':
|
||||
onHighlight(
|
||||
event.shiftKey
|
||||
? getValidIdInDirection('prev')
|
||||
: getValidIdInDirection('next'),
|
||||
);
|
||||
break;
|
||||
case 'Home':
|
||||
onHighlight(getValidIdInDirection('prev', true));
|
||||
break;
|
||||
case 'End':
|
||||
onHighlight(getValidIdInDirection('next', true));
|
||||
break;
|
||||
default:
|
||||
foundKey = false;
|
||||
}
|
||||
|
||||
if (foundKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[ids, isDisabled, onHighlight, onKeyDownId, onSelect],
|
||||
);
|
||||
|
||||
// Callback to get props for a given item.
|
||||
const getItemProps = useCallback(
|
||||
(id: string): ItemComponentProps => {
|
||||
const isHighlighted = id === highlightedId;
|
||||
const isSelected = id === selectedId;
|
||||
return {
|
||||
'data-id': safeId(id, baseId),
|
||||
'data-highlighted': isHighlighted,
|
||||
'aria-disabled': isDisabled(id),
|
||||
'aria-selected': isSelected,
|
||||
// Only allow focus if the item is highlighted.
|
||||
tabIndex: isHighlighted ? 0 : -1,
|
||||
onClick: (event) => {
|
||||
onClickId?.(id, event);
|
||||
onSelect(id);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
onFocusId?.(id, event);
|
||||
if (highlightedId !== id) {
|
||||
setHighlightedId(id);
|
||||
}
|
||||
},
|
||||
onKeyDown: (event) => {
|
||||
onKeyDown(id, event);
|
||||
},
|
||||
onMouseEnter: (event) => {
|
||||
onMouseEnterId?.(id, event);
|
||||
if (highlightedId !== id) {
|
||||
setHighlightedId(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
[
|
||||
baseId,
|
||||
highlightedId,
|
||||
isDisabled,
|
||||
onClickId,
|
||||
onFocusId,
|
||||
onKeyDown,
|
||||
onMouseEnterId,
|
||||
onSelect,
|
||||
selectedId,
|
||||
],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
// Has a stable referenced map of id to props in case getItemProps is causing unneeded re-renders.
|
||||
idProps: ids.reduce<Record<string, ItemComponentProps>>((map, id) => {
|
||||
map[id] = getItemProps(id);
|
||||
return map;
|
||||
}, {}),
|
||||
getItemProps,
|
||||
selectedId,
|
||||
onSelect,
|
||||
highlightedId,
|
||||
onHighlight,
|
||||
}),
|
||||
[getItemProps, highlightedId, ids, onHighlight, onSelect, selectedId],
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
id: string | number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function useListItemsFocus({
|
||||
items,
|
||||
...rest
|
||||
}: Omit<UseListFocusArgs, 'ids' | 'getIsIdDisabled'> & {
|
||||
items: ListItem[];
|
||||
}) {
|
||||
const ids = useMemo(() => items.map(({ id }) => id.toString()), [items]);
|
||||
const getIsIdDisabled = useCallback(
|
||||
(id: string) => !!items.find(({ id: itemId }) => id === itemId)?.disabled,
|
||||
[items],
|
||||
);
|
||||
return useListFocus({
|
||||
ids,
|
||||
getIsIdDisabled,
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
function safeId(id: string, baseId: string) {
|
||||
return CSS.escape(`${baseId}-${id}`);
|
||||
}
|
||||
|
||||
function idToElement(id: string, baseId: string) {
|
||||
return document.querySelector(`[data-id="${safeId(id, baseId)}"]`);
|
||||
}
|
||||
Reference in New Issue
Block a user