Refactor <Dropdown> into TypeScript (#34357)

Co-authored-by: Echo <ChaosExAnima@users.noreply.github.com>
This commit is contained in:
Eugen Rochko 2025-04-08 21:22:19 +02:00 committed by GitHub
parent b7c3235349
commit 22d33244ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 846 additions and 654 deletions

View File

@ -1,11 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
export const openDropdownMenu = createAction<{
id: string;
id: number;
keyboard: boolean;
scrollKey: string;
scrollKey?: string;
}>('dropdownMenu/open');
export const closeDropdownMenu = createAction<{ id: string }>(
export const closeDropdownMenu = createAction<{ id: number }>(
'dropdownMenu/close',
);

View File

@ -17,12 +17,12 @@ import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -124,11 +124,10 @@ export const Account: React.FC<{
buttons = (
<>
<DropdownMenu
<Dropdown
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>

View File

@ -1,343 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent, cloneElement, Children } from 'react';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;
class DropdownMenu extends PureComponent {
static propTypes = {
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
e.preventDefault();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
handleKeyDown = e => {
const items = Array.from(this.node.querySelectorAll('a, button'));
const index = items.indexOf(document.activeElement);
let element = null;
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':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick = e => {
const { onItemClick } = this.props;
onItemClick(e);
};
renderItem = (option, i) => {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
);
};
render () {
const { items, scrollable, renderHeader, loading } = this.props;
let renderItem = this.props.renderItem || this.renderItem;
return (
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{loading && (
<CircularProgress size={30} strokeWidth={3.5} />
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
);
}
}
class Dropdown extends PureComponent {
static propTypes = {
children: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
size: PropTypes.number,
title: PropTypes.string,
disabled: PropTypes.bool,
scrollable: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func,
...WithRouterPropTypes
};
static defaultProps = {
title: 'Menu',
};
state = {
id: id++,
};
handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
} else {
this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
}
};
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}
this.props.onClose(this.state.id);
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick = e => {
const { onItemClick } = this.props;
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
this.handleClose();
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (item && typeof item.action === 'function') {
e.preventDefault();
item.action();
} else if (item && item.to) {
e.preventDefault();
this.props.history.push(item.to);
}
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target?.buttonRef?.current ?? this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
close = () => {
this.handleClose();
};
render () {
const {
icon,
iconComponent,
items,
size,
title,
disabled,
loading,
scrollable,
openDropdownId,
openedViaKeyboard,
children,
renderItem,
renderHeader,
} = this.props;
const open = this.state.id === openDropdownId;
const button = children ? cloneElement(Children.only(children), {
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
}) : (
<IconButton
icon={!open ? icon : 'close'}
iconComponent={iconComponent}
title={title}
active={open}
disabled={disabled}
size={size}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
);
return (
<>
{button}
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ 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={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View File

@ -0,0 +1,532 @@
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 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: (e: React.MouseEvent | React.KeyboardEvent) => void;
}
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 handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onItemClick(e);
}
},
[onItemClick],
);
const handleClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
onItemClick(e);
},
[onItemClick],
);
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={handleClick}
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={handleClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</a>
);
} else {
element = (
<Link
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleClick}
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: handleClick,
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;
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
onItemClick?: (arg0: Item, arg1: number) => void;
}
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,
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 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: onItemClick,
},
}),
);
} else {
dispatch(
openDropdownMenu({
id: currentId,
keyboard: type !== 'click',
scrollKey,
}),
);
}
}
},
[
dispatch,
currentId,
scrollKey,
onOpen,
onItemClick,
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],
);
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],
);
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}
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={handleItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
};

View File

@ -1,32 +0,0 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu';
/**
*
* @param {import('mastodon/store').RootState} state
* @param {*} props
*/
const mapStateToProps = (state, { statusId }) => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
items: state.getIn(['history', statusId, 'items']),
loading: state.getIn(['history', statusId, 'loading']),
});
const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu({ id, keyboard }));
},
onClose (id) {
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View File

@ -1,77 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import DropdownMenu from './containers/dropdown_menu_container';
const mapDispatchToProps = (dispatch, { statusId }) => ({
onItemClick (index) {
dispatch(openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}));
},
});
class EditedTimestamp extends PureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
onItemClick: PropTypes.func.isRequired,
};
handleItemClick = (item, i) => {
const { onItemClick } = this.props;
onItemClick(i);
};
renderHeader = items => {
return (
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
);
};
renderItem = (item, index, { onClick, onKeyPress }) => {
const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
const formattedName = <InlineAccount accountId={item.get('account')} />;
const label = item.get('original') ? (
<FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
) : (
<FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
);
return (
<li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
<button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
</li>
);
};
render () {
const { timestamp, statusId } = this.props;
return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
</button>
</DropdownMenu>
);
}
}
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));

View File

@ -0,0 +1,140 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { fetchHistory } from 'mastodon/actions/history';
import { openModal } from 'mastodon/actions/modal';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
type HistoryItem = ImmutableMap<string, unknown>;
export const EditedTimestamp: React.FC<{
statusId: string;
timestamp: string;
}> = ({ statusId, timestamp }) => {
const dispatch = useAppDispatch();
const items = useAppSelector(
(state) =>
(
state.history.getIn([statusId, 'items']) as
| ImmutableList<unknown>
| undefined
)?.toArray() as HistoryItem[],
);
const loading = useAppSelector(
(state) => state.history.getIn([statusId, 'loading']) as boolean,
);
const handleOpen = useCallback(() => {
dispatch(fetchHistory(statusId));
}, [dispatch, statusId]);
const handleItemClick = useCallback(
(_item: HistoryItem, i: number) => {
dispatch(
openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index: i, statusId },
}),
);
},
[dispatch, statusId],
);
const renderHeader = useCallback((items: HistoryItem[]) => {
return (
<FormattedMessage
id='status.edited_x_times'
defaultMessage='Edited {count, plural, one {# time} other {# times}}'
values={{ count: items.length - 1 }}
/>
);
}, []);
const renderItem = useCallback(
(
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
const formattedDate = (
<RelativeTimestamp
timestamp={item.get('created_at') as string}
short={false}
/>
);
const formattedName = (
<InlineAccount accountId={item.get('account') as string} />
);
const label = (item.get('original') as boolean) ? (
<FormattedMessage
id='status.history.created'
defaultMessage='{name} created {date}'
values={{ name: formattedName, date: formattedDate }}
/>
) : (
<FormattedMessage
id='status.history.edited'
defaultMessage='{name} edited {date}'
values={{ name: formattedName, date: formattedDate }}
/>
);
return (
<li
className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string}
>
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
{label}
</button>
</li>
);
},
[],
);
return (
<Dropdown<HistoryItem>
items={items}
loading={loading}
renderItem={renderItem}
scrollable
renderHeader={renderHeader}
onOpen={handleOpen}
onItemClick={handleItemClick}
>
<button className='dropdown-menu__text-button'>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'
values={{
date: (
<FormattedDateWrapper
className='animated-number'
value={timestamp}
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
),
}}
/>
</button>
</Dropdown>
);
};

View File

@ -1,4 +1,4 @@
import { PureComponent, createRef } from 'react';
import { useState, useEffect, useCallback, forwardRef } from 'react';
import classNames from 'classnames';
@ -15,99 +15,108 @@ interface Props {
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
active: boolean;
active?: boolean;
expanded?: boolean;
style?: React.CSSProperties;
activeStyle?: React.CSSProperties;
disabled: boolean;
disabled?: boolean;
inverted?: boolean;
animate: boolean;
overlay: boolean;
tabIndex: number;
animate?: boolean;
overlay?: boolean;
tabIndex?: number;
counter?: number;
href?: string;
ariaHidden: boolean;
ariaHidden?: boolean;
}
interface States {
activate: boolean;
deactivate: boolean;
}
export class IconButton extends PureComponent<Props, States> {
buttonRef = createRef<HTMLButtonElement>();
static defaultProps = {
active: false,
disabled: false,
animate: false,
overlay: false,
tabIndex: 0,
ariaHidden: false,
};
state = {
activate: false,
deactivate: false,
};
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (!nextProps.animate) return;
if (this.props.active && !nextProps.active) {
this.setState({ activate: false, deactivate: true });
} else if (!this.props.active && nextProps.active) {
this.setState({ activate: true, deactivate: false });
}
}
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!this.props.disabled && this.props.onClick != null) {
this.props.onClick(e);
}
};
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
};
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
};
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
};
render() {
const style = {
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
const {
active,
export const IconButton = forwardRef<HTMLButtonElement, Props>(
(
{
className,
disabled,
expanded,
icon,
iconComponent,
inverted,
overlay,
tabIndex,
title,
counter,
href,
ariaHidden,
} = this.props;
style,
activeStyle,
onClick,
onKeyDown,
onKeyPress,
onMouseDown,
active = false,
disabled = false,
animate = false,
overlay = false,
tabIndex = 0,
ariaHidden = false,
},
buttonRef,
) => {
const [activate, setActivate] = useState(false);
const [deactivate, setDeactivate] = useState(false);
const { activate, deactivate } = this.state;
useEffect(() => {
if (!animate) {
return;
}
if (activate && !active) {
setActivate(false);
setDeactivate(true);
} else if (!activate && active) {
setActivate(true);
setDeactivate(false);
}
}, [setActivate, setDeactivate, animate, active, activate]);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
e.preventDefault();
if (!disabled) {
onClick?.(e);
}
},
[disabled, onClick],
);
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyPress?.(e);
}
},
[disabled, onKeyPress],
);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onMouseDown?.(e);
}
},
[disabled, onMouseDown],
);
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyDown?.(e);
}
},
[disabled, onKeyDown],
);
const buttonStyle = {
...style,
...(active ? activeStyle : {}),
};
const classes = classNames(className, 'icon-button', {
active,
@ -146,18 +155,19 @@ export class IconButton extends PureComponent<Props, States> {
aria-hidden={ariaHidden}
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
style={buttonStyle}
tabIndex={tabIndex}
disabled={disabled}
ref={this.buttonRef}
ref={buttonRef}
>
{contents}
</button>
);
}
}
},
);
IconButton.displayName = 'IconButton';

View File

@ -25,7 +25,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../initial_state';
import { IconButton } from './icon_button';
@ -390,7 +390,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer
<Dropdown
scrollKey={scrollKey}
status={status}
items={menu}

View File

@ -1,50 +0,0 @@
import { connect } from 'react-redux';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modal';
import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile';
/**
* @param {import('mastodon/store').RootState} state
*/
const mapStateToProps = state => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
}
dispatch(isUserTouching() ? openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: onItemClick,
},
}) : openDropdownMenu({ id, keyboard, scrollKey }));
},
onClose(id) {
dispatch(closeModal({
modalType: 'ACTIONS',
ignoreFocus: false,
}));
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View File

@ -37,12 +37,12 @@ import {
FollowingCounter,
StatusesCounter,
} from 'mastodon/components/counters';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
@ -50,7 +50,7 @@ import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import {
PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION,
@ -406,7 +406,7 @@ export const AccountHeader: React.FC<{
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => {
const arr: DropdownMenu = [];
const arr: MenuItem[] = [];
if (!account) {
return arr;
@ -806,13 +806,11 @@ export const AccountHeader: React.FC<{
<div className='account__header__tabs__buttons'>
{!hidden && bellBtn}
{!hidden && shareBtn}
<DropdownMenuContainer
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
{!hidden && actionBtn}
</div>

View File

@ -2,64 +2,82 @@ import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
follow_requests: {
id: 'navigation_bar.follow_requests',
defaultMessage: 'Follow requests',
},
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
followed_tags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
domain_blocks: {
id: 'navigation_bar.domain_blocks',
defaultMessage: 'Blocked domains',
},
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
});
export const ActionBar = () => {
const dispatch = useDispatch();
export const ActionBar: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const menu = useMemo(() => {
const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
};
return ([
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
return [
{
text: intl.formatMessage(messages.edit_profile),
href: '/settings/profile',
},
{
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
},
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
null,
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
{
text: intl.formatMessage(messages.follow_requests),
to: '/follow_requests',
},
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
{ text: intl.formatMessage(messages.lists), to: '/lists' },
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
{
text: intl.formatMessage(messages.followed_tags),
to: '/followed_tags',
},
null,
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
{
text: intl.formatMessage(messages.domain_blocks),
to: '/domain_blocks',
},
{ text: intl.formatMessage(messages.filters), href: '/filters' },
null,
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
]);
];
}, [intl, dispatch]);
return (
<DropdownMenuContainer
items={menu}
icon='bars'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
);
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
};

View File

@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
@ -205,7 +205,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
<Dropdown
scrollKey={scrollKey}
status={lastStatus}
items={menu}

View File

@ -12,8 +12,8 @@ import {
} from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { useIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store';
@ -153,13 +153,11 @@ export const HashtagHeader: React.FC<{
<div className='hashtag-header__header__buttons'>
{menu.length > 0 && (
<DropdownMenu
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
)}

View File

@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -60,12 +60,11 @@ const ListItem: React.FC<{
<span>{title}</span>
</Link>
<DropdownMenuContainer
<Dropdown
scrollKey='lists'
items={menu}
icons='ellipsis-h'
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>

View File

@ -17,7 +17,7 @@ import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { CheckBox } from 'mastodon/components/check_box';
import { IconButton } from 'mastodon/components/icon_button';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers';

View File

@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls';
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
<div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
</div>
<DropdownMenuContainer
<Dropdown
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
</span>
<Icon id='down' icon={ArrowDropDownIcon} />
</button>
</DropdownMenuContainer>
</Dropdown>
<div className='column-header__select-row__mode-button'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? (

View File

@ -24,7 +24,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { IconButton } from '../../../components/icon_button';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../../../initial_state';
const messages = defineMessages({
@ -317,7 +317,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
<Dropdown icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div>
</div>
);

View File

@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import type { StatusLike } from 'mastodon/components/hashtag_bar';

View File

@ -3,16 +3,18 @@ interface BaseMenuItem {
dangerous?: boolean;
}
interface ActionMenuItem extends BaseMenuItem {
export interface ActionMenuItem extends BaseMenuItem {
action: () => void;
}
interface LinkMenuItem extends BaseMenuItem {
export interface LinkMenuItem extends BaseMenuItem {
to: string;
}
interface ExternalLinkMenuItem extends BaseMenuItem {
export interface ExternalLinkMenuItem extends BaseMenuItem {
href: string;
target?: string;
method?: 'post' | 'put' | 'delete';
}
export type MenuItem =
@ -20,5 +22,3 @@ export type MenuItem =
| LinkMenuItem
| ExternalLinkMenuItem
| null;
export type DropdownMenu = MenuItem[];

View File

@ -3,15 +3,15 @@ import { createReducer } from '@reduxjs/toolkit';
import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu';
interface DropdownMenuState {
openId: string | null;
openId: number | null;
keyboard: boolean;
scrollKey: string | null;
scrollKey: string | undefined;
}
const initialState: DropdownMenuState = {
openId: null,
keyboard: false,
scrollKey: null,
scrollKey: undefined,
};
export const dropdownMenuReducer = createReducer(initialState, (builder) => {
@ -27,7 +27,7 @@ export const dropdownMenuReducer = createReducer(initialState, (builder) => {
.addCase(closeDropdownMenu, (state, { payload: { id } }) => {
if (state.openId === id) {
state.openId = null;
state.scrollKey = null;
state.scrollKey = undefined;
}
});
});