Merge remote-tracking branch 'parent/main' into upstream-20250414
This commit is contained in:
commit
dba5f3b93f
208 changed files with 3210 additions and 2896 deletions
551
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
551
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
|
@ -0,0 +1,551 @@
|
|||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
cloneElement,
|
||||
Children,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
import {
|
||||
openDropdownMenu,
|
||||
closeDropdownMenu,
|
||||
} from 'mastodon/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { isUserTouching } from 'mastodon/is_mobile';
|
||||
import type {
|
||||
MenuItem,
|
||||
ActionMenuItem,
|
||||
ExternalLinkMenuItem,
|
||||
} from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
const isMenuItem = (item: unknown): item is MenuItem => {
|
||||
if (item === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof item === 'object' && 'text' in item;
|
||||
};
|
||||
|
||||
const isActionItem = (item: unknown): item is ActionMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'action' in item;
|
||||
};
|
||||
|
||||
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'href' in item;
|
||||
};
|
||||
|
||||
type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
},
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
|
||||
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
|
||||
|
||||
interface DropdownMenuProps<Item = MenuItem> {
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
scrollable?: boolean;
|
||||
onClose: () => void;
|
||||
openedViaKeyboard: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
export const DropdownMenu = <Item = MenuItem,>({
|
||||
items,
|
||||
loading,
|
||||
scrollable,
|
||||
onClose,
|
||||
openedViaKeyboard,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
if (
|
||||
e.target instanceof Node &&
|
||||
nodeRef.current &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!nodeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
|
||||
const index = document.activeElement
|
||||
? items.indexOf(document.activeElement)
|
||||
: -1;
|
||||
|
||||
let element: Element | undefined;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] ?? items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] ?? items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] ?? items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] ?? items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
onClose();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
}
|
||||
},
|
||||
[onClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleItemClick(e);
|
||||
}
|
||||
},
|
||||
[handleItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, dangerous } = option;
|
||||
|
||||
let element: React.ReactElement;
|
||||
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
} else if (isExternalLinkItem(option)) {
|
||||
element = (
|
||||
<a
|
||||
href={option.href}
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item', {
|
||||
'dropdown-menu__item--dangerous': dangerous,
|
||||
})}
|
||||
key={`${text}-${i}`}
|
||||
>
|
||||
{element}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItemMethod = renderItem ?? nativeRenderItem;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('dropdown-menu__container', {
|
||||
'dropdown-menu__container--loading': loading,
|
||||
})}
|
||||
ref={nodeRef}
|
||||
>
|
||||
{(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
|
||||
|
||||
{!loading && renderHeader && items && (
|
||||
<div className='dropdown-menu__container__header'>
|
||||
{renderHeader(items)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items && (
|
||||
<ul
|
||||
className={classNames('dropdown-menu__container__list', {
|
||||
'dropdown-menu__container__list--scrollable': scrollable,
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(option, i, {
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
}),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps<Item = MenuItem> {
|
||||
children?: React.ReactElement;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
active?: boolean;
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onOpen?: () => void;
|
||||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const Dropdown = <Item = MenuItem,>({
|
||||
children,
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
loading,
|
||||
title = 'Menu',
|
||||
disabled,
|
||||
scrollable,
|
||||
active,
|
||||
status,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onOpen,
|
||||
onItemClick,
|
||||
scrollKey,
|
||||
}: DropdownProps<Item>) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
|
||||
const openedViaKeyboard = useAppSelector(
|
||||
(state) => state.dropdownMenu.keyboard,
|
||||
);
|
||||
const [currentId] = useState(id++);
|
||||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
activeElement.current.focus({ preventScroll: true });
|
||||
activeElement.current = null;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
closeModal({
|
||||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(closeDropdownMenu({ id: currentId }));
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
handleClose();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
}
|
||||
},
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
onOpen?.();
|
||||
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
if (isUserTouching()) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
currentId,
|
||||
scrollKey,
|
||||
onOpen,
|
||||
handleItemClick,
|
||||
open,
|
||||
status,
|
||||
items,
|
||||
handleClose,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!open && document.activeElement instanceof HTMLElement) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentId === openDropdownId) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
}, [currentId, openDropdownId, handleClose]);
|
||||
|
||||
let button: React.ReactElement;
|
||||
|
||||
if (children) {
|
||||
button = cloneElement(Children.only(children), {
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: targetRef,
|
||||
});
|
||||
} else if (icon && iconComponent) {
|
||||
button = (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open || active}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={targetRef}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
flip
|
||||
target={targetRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
{...arrowProps}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
loading={loading}
|
||||
scrollable={scrollable}
|
||||
onClose={handleClose}
|
||||
openedViaKeyboard={openedViaKeyboard}
|
||||
renderItem={renderItem}
|
||||
renderHeader={renderHeader}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue