Merge branch 'hoverable-menu' into 'develop'

Add new hoverable menu for Chat Messages (and rebuild DropdownMenu component with Popper)

See merge request soapbox-pub/soapbox!2274
environments/review-develop-3zknud/deployments/2607
Chewbacca 2023-02-13 17:32:02 +00:00
commit 0b9fe2cfca
34 zmienionych plików z 728 dodań i 799 usunięć

Wyświetl plik

@ -1,13 +1,8 @@
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) =>
({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard });
const closeDropdownMenu = (id: number) =>
({ type: DROPDOWN_MENU_CLOSE, id });
const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN });
const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE });
export {
DROPDOWN_MENU_OPEN,

Wyświetl plik

@ -1,4 +1,3 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
@ -6,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui';
import { Input, Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';

Wyświetl plik

@ -1,9 +1,9 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../features/compose/components/autosuggest-account';

Wyświetl plik

@ -1,420 +0,0 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React from 'react';
import { spring } from 'react-motion';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { Counter, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion';
import type { Status } from 'soapbox/types/entities';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,
active?: boolean,
}
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
style: {},
placement: 'bottom',
};
state = {
mounted: false,
};
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
}
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
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: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.props.onClose();
e.stopPropagation();
if (to) {
e.preventDefault();
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
if (e.button === 1 && typeof middleClick === 'function') {
e.preventDefault();
middleClick(e);
}
};
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
};
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return (
<li className={clsx('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
<a
href={href || to || '#'}
role='button'
tabIndex={0}
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a>
</li>
);
}
render() {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
const RouterDropdownMenu = withRouter(DropdownMenu);
export interface IDropdown extends RouteComponentProps {
icon?: string,
src?: string,
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
title?: string,
disabled?: boolean,
status?: Status,
isUserTouching?: () => boolean,
isModalOpen?: boolean,
onOpen?: (
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) => void,
onClose?: (id: number) => void,
dropdownPlacement?: string,
openDropdownId?: number | null,
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
}
interface IDropdownState {
id: number,
open: boolean,
}
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
title: 'Menu',
};
state = {
id: id++,
open: false,
};
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
e.stopPropagation();
if (onShiftClick && e.shiftKey) {
e.preventDefault();
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
this.handleClose();
} else if (onOpen) {
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
};
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleMouseDown(e);
break;
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.handleClose();
e.preventDefault();
e.stopPropagation();
if (typeof action === 'function') {
action(e);
} else if (to) {
this.props.history?.push(to);
}
};
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
const open = this.state.id === openDropdownId;
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': open,
})}
title={title}
src={src}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
)}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

Wyświetl plik

@ -0,0 +1,109 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { Counter, Icon } from '../ui';
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
active?: boolean
count?: number
destructive?: boolean
href?: string
icon?: string
meta?: string
middleClick?(event: React.MouseEvent): void
target?: React.HTMLAttributeAnchorTarget
text: string
to?: string
}
interface IDropdownMenuItem {
index: number
item: MenuItem | null
onClick?(): void
}
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
const history = useHistory();
const itemRef = useRef<HTMLAnchorElement>(null);
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
event.stopPropagation();
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);
} else if (typeof item.action === 'function') {
event.preventDefault();
item.action(event);
}
};
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
if (!item) return;
if (onClick) onClick();
if (event.button === 1 && item.middleClick) {
item.middleClick(event);
}
};
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleClick(event);
}
};
useEffect(() => {
const firstItem = index === 0;
if (itemRef.current && firstItem) {
itemRef.current.focus();
}
}, [itemRef.current, index]);
if (item === null) {
return <li className='my-1 mx-2 h-[2px] bg-gray-100 dark:bg-gray-800' />;
}
return (
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
<a
href={item.href || item.to || '#'}
role='button'
tabIndex={0}
ref={itemRef}
data-index={index}
onClick={handleClick}
onAuxClick={handleAuxClick}
onKeyPress={handleItemKeyPress}
target={item.target}
title={item.text}
className={
clsx({
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
'text-danger-600 dark:text-danger-400': item.destructive,
})
}
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={item.count} />
</span>
) : null}
</a>
</li>
);
};
export default DropdownMenuItem;

Wyświetl plik

@ -0,0 +1,300 @@
import { offset, Placement, useFloating } from '@floating-ui/react';
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { closeDropdownMenu, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { useAppDispatch } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { IconButton, Portal } from '../ui';
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
import type { Status } from 'soapbox/types/entities';
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu {
children?: React.ReactElement
disabled?: boolean
items: Menu
onClose?: () => void
onOpen?: () => void
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
placement?: Placement
src?: string
status?: Status
title?: string
}
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const DropdownMenu = (props: IDropdownMenu) => {
const {
children,
disabled,
items,
onClose,
onOpen,
onShiftClick,
placement = 'top',
src = require('@tabler/icons/dots.svg'),
title = 'Menu',
...filteredProps
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const [isOpen, setIsOpen] = useState<boolean>(false);
const activeElement = useRef<Element | null>(null);
const target = useRef<Element>(null);
const isOnMobile = isUserTouching();
const { x, y, strategy, refs } = useFloating<HTMLButtonElement>({
placement,
middleware: [offset(12)],
});
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
if (onShiftClick && event.shiftKey) {
event.preventDefault();
onShiftClick(event);
return;
}
if (isOpen) {
handleClose();
} else {
handleOpen();
}
};
/**
* On mobile screens, let's replace the Popper dropdown with a Modal.
*/
const handleOpen = () => {
if (isOnMobile) {
dispatch(
openModal('ACTIONS', {
status: filteredProps.status,
actions: items,
onClick: handleItemClick,
}),
);
} else {
dispatch(openDropdownMenu());
setIsOpen(true);
}
if (onOpen) {
onOpen();
}
};
const handleClose = () => {
if (activeElement.current && activeElement.current === target.current) {
(activeElement.current as any).focus();
activeElement.current = null;
}
if (isOnMobile) {
dispatch(closeModal('ACTIONS'));
} else {
dispatch(closeDropdownMenu());
setIsOpen(false);
}
if (onClose) {
onClose();
}
};
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!isOpen) {
activeElement.current = document.activeElement;
}
};
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
handleMouseDown(event);
break;
}
};
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
event.stopPropagation();
event.preventDefault();
handleClick(event);
break;
}
};
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
const i = Number(event.currentTarget.getAttribute('data-index'));
const item = items[i];
if (!item) return;
const { action, to } = item;
handleClose();
if (typeof action === 'function') {
action(event);
} else if (to) {
history.push(to);
}
};
const handleDocumentClick = (event: Event) => {
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
handleClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!refs.floating.current) return;
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
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':
handleClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchend', handleDocumentClick);
};
}, [refs.floating.current]);
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: refs.setReference,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': isOpen,
})}
title={title}
src={src}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={refs.setReference}
/>
)}
{isOpen ? (
<Portal>
<div
data-testid='dropdown-menu'
ref={refs.setFloating}
className={
clsx('relative z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
'opacity-0 pointer-events-none': !isOpen,
})
}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
>
<ul>
{items.map((item, idx) => (
<DropdownMenuItem
key={idx}
item={item}
index={idx}
onClick={handleClose}
/>
))}
</ul>
{/* Arrow */}
<div
className={
clsx({
'absolute w-0 h-0 border-0 border-solid border-transparent': true,
'border-t-white dark:border-t-gray-900 -bottom-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-[5px] border-x-[5px] border-b-0': placement === 'top',
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom',
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[92.5%] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom-end',
})
}
/>
</div>
</Portal>
) : null}
</>
);
};
export default DropdownMenu;

Wyświetl plik

@ -0,0 +1,3 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

Wyświetl plik

@ -2,15 +2,13 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
import DropdownMenu, { Menu } from './dropdown-menu';
import SidebarNavigationLink from './sidebar-navigation-link';
import type { Menu } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
@ -185,7 +183,7 @@ const SidebarNavigation = () => {
)}
{menu.length > 0 && (
<DropdownMenu items={menu}>
<DropdownMenu items={menu} placement='top'>
<SidebarNavigationLink
icon={require('@tabler/icons/dots-circle-horizontal.svg')}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}

Wyświetl plik

@ -14,10 +14,10 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
@ -617,13 +617,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
{(features.quotePosts && me) ? (
<DropdownMenuContainer
<DropdownMenu
items={reblogMenu}
disabled={!publicStatus}
onShiftClick={handleReblogClick}
>
{reblogButton}
</DropdownMenuContainer>
</DropdownMenu>
) : (
reblogButton
)}
@ -662,12 +662,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
)}
<DropdownMenuContainer items={menu} status={status}>
<DropdownMenu items={menu} status={status}>
<StatusActionButton
title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/dots.svg')}
/>
</DropdownMenuContainer>
</DropdownMenu>
</HStack>
</HStack>
);

Wyświetl plik

@ -4,10 +4,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatus } from 'soapbox/actions/statuses';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import DropdownMenu from '../dropdown-menu';
import { Button, HStack, Text } from '../ui';
import type { Status as StatusEntity } from 'soapbox/types/entities';

Wyświetl plik

@ -2,7 +2,7 @@ import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';

Wyświetl plik

@ -80,7 +80,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
{
name: 'offset',
options: {
offset: [-10, 0],
offset: [-10, 12],
},
},
],

Wyświetl plik

@ -38,6 +38,7 @@ export {
} from './menu/menu';
export { default as Modal } from './modal/modal';
export { default as PhoneInput } from './phone-input/phone-input';
export { default as Portal } from './portal/portal';
export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select';

Wyświetl plik

@ -0,0 +1,31 @@
import React, { useLayoutEffect, useState } from 'react';
import ReactDOM from 'react-dom';
interface IPortal {
children: React.ReactNode
}
/**
* Portal
*/
const Portal: React.FC<IPortal> = ({ children }) => {
const [isRendered, setIsRendered] = useState<boolean>(false);
useLayoutEffect(() => {
setIsRendered(true);
}, []);
if (!isRendered) {
return null;
}
return (
ReactDOM.createPortal(
children,
document.getElementById('soapbox') as HTMLDivElement,
)
);
};
export default Portal;

Wyświetl plik

@ -1,7 +1,8 @@
import { Portal } from '@reach/portal';
import { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react';
import Portal from '../portal/portal';
import './tooltip.css';
interface ITooltip {

Wyświetl plik

@ -1,46 +0,0 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown-menu';
import { openModal, closeModal } from '../actions/modals';
import DropdownMenu from '../components/dropdown-menu';
import { isUserTouching } from '../is-mobile';
import type { Dispatch } from 'redux';
import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown-menu';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
isModalOpen: Boolean(state.modals.size && state.modals.last()!.modalType === 'ACTIONS'),
dropdownPlacement: state.dropdown_menu.placement,
openDropdownId: state.dropdown_menu.openId,
openedViaKeyboard: state.dropdown_menu.keyboard,
});
const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial<IDropdown>) => ({
onOpen(
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) {
dispatch(isUserTouching() ? openModal('ACTIONS', {
status,
actions: items,
onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
if (filteredProps.onOpen) {
filteredProps.onOpen(id, onItemClick, dropdownPlacement, keyboard);
}
},
onClose(id: number) {
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
if (filteredProps.onClose) {
filteredProps.onClose(id);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

Wyświetl plik

@ -5,7 +5,7 @@ import { AxiosError } from 'axios';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
@ -16,9 +16,9 @@ import { initReport } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings';
import Badge from 'soapbox/components/badge';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import StillImage from 'soapbox/components/still-image';
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { Avatar, HStack, IconButton } from 'soapbox/components/ui';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
@ -31,8 +31,6 @@ import { Account } from 'soapbox/types/entities';
import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
import { MASTODON, parseVersion } from 'soapbox/utils/features';
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
@ -275,7 +273,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
};
const makeMenu = () => {
const menu: MenuType = [];
const menu: Menu = [];
if (!account) {
return [];
@ -645,39 +643,15 @@ const Header: React.FC<IHeader> = ({ account }) => {
{renderShareButton()}
{menu.length > 0 && (
<Menu>
<MenuButton
as={IconButton}
<DropdownMenu items={menu} placement='bottom-end'>
<IconButton
src={require('@tabler/icons/dots.svg')}
theme='outlined'
className='px-2'
iconClassName='w-4 h-4'
children={null}
/>
<MenuList className='w-56'>
{menu.map((menuItem, idx) => {
if (typeof menuItem?.text === 'undefined') {
return <MenuDivider key={idx} />;
} else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
return (
<Comp key={idx} {...itemProps} className='group'>
<HStack space={3} alignItems='center'>
{menuItem.icon && (
<SvgIcon src={menuItem.icon} className='h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
)}
<div className='truncate'>{menuItem.text}</div>
</HStack>
</Comp>
);
}
})}
</MenuList>
</Menu>
</DropdownMenu>
)}
<ActionButton account={account} />

Wyświetl plik

@ -2,10 +2,10 @@ import React from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { deleteStatusModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusContent from 'soapbox/components/status-content';
import StatusMedia from 'soapbox/components/status-media';
import { HStack, Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch } from 'soapbox/hooks';
import type { AdminReport, Status } from 'soapbox/types/entities';

Wyświetl plik

@ -4,9 +4,9 @@ import { Link } from 'react-router-dom';
import { closeReports } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import toast from 'soapbox/toast';

Wyświetl plik

@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { IChat, useChatActions } from 'soapbox/queries/chats';
@ -115,14 +115,14 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
<HStack alignItems='center' space={2}>
{features.chatsDelete && (
<div className='hidden text-gray-600 hover:text-gray-100 group-hover:block'>
<DropdownMenuContainer items={menu}>
<DropdownMenu items={menu}>
<IconButton
src={require('@tabler/icons/dots.svg')}
title='Settings'
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='w-4 h-4'
/>
</DropdownMenuContainer>
</DropdownMenu>
</div>
)}

Wyświetl plik

@ -7,8 +7,8 @@ import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { initReport } from 'soapbox/actions/reports';
import { HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji/emoji';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
@ -189,181 +189,199 @@ const ChatMessage = (props: IChatMessage) => {
}, [chatMessage, chat]);
return (
<div className='px-4 py-2'>
<div key={chatMessage.id} className='group' data-testid='chat-message'>
<Stack
space={1.5}
<div
className={
clsx({
'group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40': true,
'bg-gray-200/40 dark:bg-gray-800/40': isMenuOpen || isReactionSelectorOpen,
})
}
data-testid='chat-message'
>
<div
className={
clsx({
'p-1 flex items-center space-x-0.5 z-10 absolute opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
'top-2 right-2': !isMyMessage,
'top-2 left-2': isMyMessage,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})
}
>
{!features.chatEmojiReactions ? (
<ChatMessageReactionWrapper
onOpen={setIsReactionSelectorOpen}
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
>
<button
title={intl.formatMessage(messages.more)}
className={clsx({
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
})}
data-testid='chat-message-menu'
>
<Icon
src={require('@tabler/icons/mood-smile.svg')}
className='h-4 w-4'
/>
</button>
</ChatMessageReactionWrapper>
) : null}
{menu.length > 0 && (
<DropdownMenu
items={menu}
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<button
title={intl.formatMessage(messages.more)}
className={clsx({
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
})}
data-testid='chat-message-menu'
>
<Icon
src={require('@tabler/icons/dots.svg')}
className='h-4 w-4'
/>
</button>
</DropdownMenu>
)}
</div>
<Stack
space={1.5}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'opacity-50': chatMessage.pending,
})}
>
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'flex-1': !!chatMessage.media_attachments.size,
'order-3': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
{maybeRenderMedia(chatMessage)}
{content && (
<HStack alignItems='bottom' className='max-w-full'>
<div
title={getFormattedTimestamp(chatMessage)}
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
ref={setBubbleRef}
tabIndex={0}
>
<Text
size='sm'
theme='inherit'
className='break-word-nested'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</HStack>
)}
</Stack>
</HStack>
{(features.chatEmojiReactions && chatMessage.emoji_reactions) ? (
<div
className={clsx({
'space-y-1': true,
'ml-auto': isMyMessage,
'mr-auto': !isMyMessage,
})}
>
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
<HStack
key={idx}
className={
clsx({
'flex items-center gap-1': true,
'flex-row-reverse': isMyMessage,
})
}
>
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
<ChatMessageReaction
key={idx}
emojiReaction={emojiReaction}
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
/>
))}
</HStack>
))}
</div>
) : null}
<HStack
alignItems='center'
space={2}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
<div
className={clsx({
'opacity-50': chatMessage.pending,
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
{menu.length > 0 && (
<DropdownMenuContainer
items={menu}
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<IconButton
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
className={clsx({
'opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
'mr-2 order-2': isMyMessage,
'ml-2 order-2': !isMyMessage,
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})}
data-testid='chat-message-menu'
iconClassName='w-4 h-4'
/>
</DropdownMenuContainer>
)}
<span className='flex items-center space-x-1.5'>
<Text theme='muted' size='xs'>
{intl.formatTime(chatMessage.created_at)}
</Text>
{features.chatEmojiReactions ? (
<ChatMessageReactionWrapper
onOpen={setIsReactionSelectorOpen}
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
>
<IconButton
src={require('@tabler/icons/mood-smile.svg')}
title={intl.formatMessage(messages.more)}
className={clsx({
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
'mr-2 order-1': isMyMessage,
'ml-2 order-3': !isMyMessage,
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})}
iconClassName='w-5 h-5'
/>
</ChatMessageReactionWrapper>
) : null}
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'flex-1': !!chatMessage.media_attachments.size,
'order-3': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
{maybeRenderMedia(chatMessage)}
{content && (
<HStack alignItems='bottom' className='max-w-full'>
<div
title={getFormattedTimestamp(chatMessage)}
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
ref={setBubbleRef}
tabIndex={0}
>
<Text
size='sm'
theme='inherit'
className='break-word-nested'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</HStack>
)}
</Stack>
</HStack>
{(features.chatEmojiReactions && chatMessage.emoji_reactions) ? (
<div
className={clsx({
'space-y-1': true,
'ml-auto': isMyMessage,
'mr-auto': !isMyMessage,
})}
>
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
<HStack
key={idx}
className={
clsx({
'flex items-center gap-1': true,
'flex-row-reverse': isMyMessage,
})
}
>
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
<ChatMessageReaction
key={idx}
emojiReaction={emojiReaction}
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
/>
))}
</HStack>
))}
</div>
) : null}
<HStack
alignItems='center'
space={2}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<div
className={clsx({
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
<span className='flex items-center space-x-1.5'>
<Text theme='muted' size='xs'>
{intl.formatTime(chatMessage.created_at)}
</Text>
{(isMyMessage && features.chatsReadReceipts) ? (
<>
{isRead ? (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
) : (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
)}
</>
) : null}
</span>
</div>
</HStack>
</Stack>
</div>
{(isMyMessage && features.chatsReadReceipts) ? (
<>
{isRead ? (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
) : (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
)}
</>
) : null}
</span>
</div>
</HStack>
</Stack>
</div>
);
};

Wyświetl plik

@ -396,7 +396,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return <MenuDivider key={idx} />;
} else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
return (
<Comp key={idx} {...itemProps} className='group'>

Wyświetl plik

@ -180,7 +180,7 @@ const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId,
return <MenuDivider key={idx} />;
} else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
return (
<Comp key={idx} {...itemProps} className='group'>

Wyświetl plik

@ -5,9 +5,9 @@ import { v4 as uuidv4 } from 'uuid';
import { updateSoapboxConfig } from 'soapbox/actions/admin';
import { getHost } from 'soapbox/actions/instance';
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
@ -194,7 +194,7 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
</List>
<FormActions>
<DropdownMenuContainer
<DropdownMenu
items={[{
text: intl.formatMessage(messages.restore),
action: restoreDefaultTheme,

Wyświetl plik

@ -4,8 +4,8 @@ import React from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import { Widget } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import InstanceRestrictions from 'soapbox/features/federation-restrictions/components/instance-restrictions';
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { makeGetRemoteInstance } from 'soapbox/selectors';

Wyświetl plik

@ -25,7 +25,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { icon = null, text, meta = null, active = false, href = '#', isLogout, destructive } = action;
const { icon = null, text, meta = null, active = false, href = '#', destructive } = action;
const Comp = href === '#' ? 'button' : 'a';
const compProps = href === '#' ? { onClick: onClick } : { href: href, rel: 'noopener' };
@ -37,7 +37,6 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
space={2.5}
data-index={i}
className={clsx('w-full', { active, destructive })}
data-method={isLogout ? 'delete' : null}
element={Comp}
>
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}

Wyświetl plik

@ -355,7 +355,7 @@ const UI: React.FC<IUI> = ({ children }) => {
const features = useFeatures();
const vapidKey = useAppSelector(state => getVapidKey(state));
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.openId !== null);
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen);
const accessToken = useAppSelector(state => getAccessToken(state));
const streamingUrl = instance.urls.get('streaming_api');
const standalone = useAppSelector(isStandalone);

Wyświetl plik

@ -51,21 +51,23 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
};
return (
<Provider store={props.store}>
<MemoryRouter {...routerProps}>
<StatProvider>
<QueryClientProvider client={queryClient}>
<ChatProvider>
<IntlProvider locale={props.locale}>
{children}
<div id='soapbox'>
<Provider store={props.store}>
<MemoryRouter {...routerProps}>
<StatProvider>
<QueryClientProvider client={queryClient}>
<ChatProvider>
<IntlProvider locale={props.locale}>
{children}
<Toaster />
</IntlProvider>
</ChatProvider>
</QueryClientProvider>
</StatProvider>
</MemoryRouter>
</Provider>
<Toaster />
</IntlProvider>
</ChatProvider>
</QueryClientProvider>
</StatProvider>
</MemoryRouter>
</Provider>
</div>
);
};

Wyświetl plik

@ -3,9 +3,7 @@ import reducer from '../dropdown-menu';
describe('dropdown_menu reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {} as any).toJS()).toEqual({
openId: null,
placement: null,
keyboard: false,
isOpen: false,
});
});
});

Wyświetl plik

@ -6,12 +6,9 @@ import {
} from '../actions/dropdown-menu';
import type { AnyAction } from 'redux';
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
const ReducerRecord = ImmutableRecord({
openId: null as number | null,
placement: null as any as DropdownPlacement,
keyboard: false,
isOpen: false,
});
type State = ReturnType<typeof ReducerRecord>;
@ -19,9 +16,9 @@ type State = ReturnType<typeof ReducerRecord>;
export default function dropdownMenu(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case DROPDOWN_MENU_OPEN:
return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard });
return state.set('isOpen', true);
case DROPDOWN_MENU_CLOSE:
return state.openId === action.id ? state.set('openId', null) : state;
return state.set('isOpen', false);
default:
return state;
}

Wyświetl plik

@ -11,7 +11,6 @@
// COMPONENTS
@import 'components/buttons';
@import 'components/dropdown-menu';
@import 'components/modal';
@import 'components/compose-form';
@import 'components/status';

Wyświetl plik

@ -1,69 +0,0 @@
.dropdown-menu {
@apply absolute bg-white dark:bg-gray-900 z-[1001] rounded-md shadow-lg py-1 w-56 dark:ring-2 dark:ring-primary-700 focus:outline-none;
&.left { transform-origin: 100% 50%; }
&.top { transform-origin: 50% 100%; }
&.bottom { transform-origin: 50% 0; }
&.right { transform-origin: 0 50%; }
&__arrow {
@apply absolute w-0 h-0;
border: 0 solid transparent;
&.left {
@apply border-l-white dark:border-l-gray-900;
right: -5px;
margin-top: -5px;
border-width: 5px 0 5px 5px;
}
&.top {
@apply border-t-white dark:border-t-gray-900;
bottom: -5px;
margin-left: -5px;
border-width: 5px 5px 0;
}
&.bottom {
@apply border-b-white dark:border-b-gray-900;
top: -5px;
margin-left: -5px;
border-width: 0 5px 5px;
}
&.right {
@apply border-r-white dark:border-r-gray-900;
left: -5px;
margin-top: -5px;
border-width: 5px 5px 5px 0;
}
}
ul {
overflow: hidden;
}
&__item {
@apply focus-within:ring-primary-500 focus-within:ring-2;
a {
@apply flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 cursor-pointer;
> .svg-icon:first-child {
@apply h-5 w-5 mr-2.5 transition-none;
svg {
@apply stroke-[1.5px] transition-none;
}
}
}
&.destructive a {
@apply text-danger-600 dark:text-danger-400;
}
}
&__separator {
@apply block my-2 h-[1px] bg-gray-100 dark:bg-gray-800;
}
}

Wyświetl plik

@ -46,6 +46,7 @@
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@babel/runtime": "^7.20.13",
"@floating-ui/react": "^0.19.1",
"@fontsource/inter": "^4.5.1",
"@fontsource/roboto-mono": "^4.5.8",
"@gamestdio/websocket": "^0.3.2",
@ -56,7 +57,6 @@
"@reach/combobox": "^0.18.0",
"@reach/menu-button": "^0.18.0",
"@reach/popover": "^0.18.0",
"@reach/portal": "^0.18.0",
"@reach/rect": "^0.18.0",
"@reach/tabs": "^0.18.0",
"@reach/tooltip": "^0.18.0",

Wyświetl plik

@ -1722,6 +1722,34 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@floating-ui/core@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.0.tgz#ae7ae7923d41f3d84cb2fd88740a89436610bbec"
integrity sha512-GHUXPEhMEmTpnpIfesFA2KAoMJPb1SPQw964tToQwt+BbGXdhqTCWT1rOb0VURGylsxsYxiGMnseJ3IlclVpVA==
"@floating-ui/dom@^1.1.1":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.0.tgz#a60212069cc58961c478037c30eba4b191c75316"
integrity sha512-QXzg57o1cjLz3cGETzKXjI3kx1xyS49DW9l7kV2jw2c8Yftd434t2hllX0sVGn2Q8MtcW/4pNm8bfE1/4n6mng==
dependencies:
"@floating-ui/core" "^1.2.0"
"@floating-ui/react-dom@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.2.2.tgz#ed256992fd44fcfcddc96da68b4b92f123d61871"
integrity sha512-DbmFBLwFrZhtXgCI2ra7wXYT8L2BN4/4AMQKyu05qzsVji51tXOfF36VE2gpMB6nhJGHa85PdEg75FB4+vnLFQ==
dependencies:
"@floating-ui/dom" "^1.1.1"
"@floating-ui/react@^0.19.1":
version "0.19.1"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.1.tgz#bcaeaf3856dfeea388816f7e66750cab26208376"
integrity sha512-h7hr53rLp+VVvWvbu0dOBvGsLeeZwn1DTLIllIaLYjGWw20YhAgEqegHU+nc7BJ30ttxq4Sq6hqARm0ne6chXQ==
dependencies:
"@floating-ui/react-dom" "^1.2.2"
aria-hidden "^1.1.3"
tabbable "^6.0.1"
"@fontsource/inter@^4.5.1":
version "4.5.1"
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-4.5.1.tgz#058d8a02354f3c78e369d452c15d33557ec1b705"
@ -2686,7 +2714,7 @@
"@reach/utils" "0.18.0"
tabbable "^5.3.3"
"@reach/portal@0.18.0", "@reach/portal@^0.18.0":
"@reach/portal@0.18.0":
version "0.18.0"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.18.0.tgz#dd466f5110689d14a0e7491b3aa8a449e8cefb40"
integrity sha512-TImozRapd576ofRk30Le2L3lRTFXF1p47B182wnp5eMTdZa74JX138BtNGEPJFOyrMaVmguVF8SSwZ6a0fon1Q==
@ -5381,6 +5409,13 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.3:
version "1.2.2"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.2.tgz#8c4f7cc88d73ca42114106fdf6f47e68d31475b8"
integrity sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==
dependencies:
tslib "^2.0.0"
aria-query@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
@ -16517,6 +16552,11 @@ tabbable@^5.3.3:
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
tabbable@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.1.tgz#427a09b13c83ae41eed3e88abb76a4af28bde1a6"
integrity sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA==
table@^6.8.1:
version "6.8.1"
resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"