mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 04:28:18 -05:00
Add MenuItemRadio and MenuItemCheckbox components (#40215)
This commit is contained in:
@@ -73,8 +73,7 @@ input.input {
|
||||
}
|
||||
}
|
||||
|
||||
// Use double class to override original styles.
|
||||
.toggle.toggle {
|
||||
.toggle > span {
|
||||
background: var(--color-text-tertiary);
|
||||
|
||||
&::before {
|
||||
@@ -87,9 +86,11 @@ input.input {
|
||||
--padding: 2px;
|
||||
--diameter: 14px;
|
||||
|
||||
width: 32px;
|
||||
}
|
||||
> span {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
input:checked + .toggle.toggleSmall::before {
|
||||
transform: translateX(var(--diameter));
|
||||
> input:checked + span::before {
|
||||
transform: translateX(var(--diameter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
|
||||
.input:checked + .toggle::before {
|
||||
transform: translateX(calc(var(--diameter) - (var(--padding) * 2)));
|
||||
|
||||
:global([dir='rtl']) & {
|
||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
||||
}
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
@@ -70,7 +74,3 @@
|
||||
.input:disabled + .toggle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .input:checked + .toggle::before {
|
||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ ToggleField.displayName = 'ToggleField';
|
||||
export const Toggle = forwardRef<HTMLInputElement, Props>(
|
||||
({ className, size, ...otherProps }, ref) => (
|
||||
<span
|
||||
className={classes.wrapper}
|
||||
className={classNames(classes.wrapper, className)}
|
||||
style={{ '--diameter': size ? `${size}px` : undefined } as CSSProperties}
|
||||
>
|
||||
<input
|
||||
@@ -47,7 +47,7 @@ export const Toggle = forwardRef<HTMLInputElement, Props>(
|
||||
className={classes.input}
|
||||
ref={ref}
|
||||
/>
|
||||
<span className={classNames(classes.toggle, className)} hidden />
|
||||
<span className={classes.toggle} hidden />
|
||||
</span>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MenuCard = <As extends React.ElementType>({
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={classNames(className, classes.menuCard)}
|
||||
className={classNames(className, classes.card)}
|
||||
data-elevation={elevation}
|
||||
style={{
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
@@ -83,7 +83,7 @@ export const PopoverMenuCard = <As extends React.ElementType>({
|
||||
{...props}
|
||||
className={classNames(
|
||||
className,
|
||||
props.maxWidth && classes.popoverMenuCard,
|
||||
props.maxWidth && classes.popoverCard,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,19 +8,24 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Merge } from 'type-fest';
|
||||
|
||||
import { Button } from '../button/redesign';
|
||||
import { Icon } from '../icon';
|
||||
import type { IconProp } from '../icon';
|
||||
|
||||
import { PopoverMenuCard } from './card';
|
||||
import type { PopoverMenuCardProps } from './card';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const menuItemClass = classes.menuItem;
|
||||
export const menuItemClass = classes.item;
|
||||
|
||||
export {
|
||||
MenuItemDivider,
|
||||
MenuItemGroup,
|
||||
MenuItemBase,
|
||||
MenuItem,
|
||||
MenuItemRadio,
|
||||
MenuItemCheckbox,
|
||||
} from './items';
|
||||
|
||||
interface PopoverState {
|
||||
isMenuOpen: boolean;
|
||||
@@ -71,7 +76,7 @@ export function useMenuContext(): MenuState {
|
||||
function getAllMenuItems(menuListElement: HTMLDivElement) {
|
||||
return Array.from(
|
||||
menuListElement.querySelectorAll<HTMLElement>(
|
||||
':scope [data-menu-item]:not([disabled], [aria-disabled])',
|
||||
':scope [data-menu-item]:not([disabled])',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -258,79 +263,3 @@ export const MenuList = <As extends React.ElementType>({
|
||||
</PopoverMenuCard>
|
||||
);
|
||||
};
|
||||
|
||||
type MenuItemProps<As extends React.ElementType> = Merge<
|
||||
{
|
||||
as?: As;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
leadingIcon?: IconProp;
|
||||
trailingIcon?: IconProp;
|
||||
iconClassName?: string;
|
||||
},
|
||||
React.ComponentPropsWithoutRef<As>
|
||||
>;
|
||||
|
||||
export const MenuItemBase = <As extends React.ElementType>({
|
||||
active,
|
||||
disabled,
|
||||
as: AsComp,
|
||||
children,
|
||||
className,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
iconClassName,
|
||||
...props
|
||||
}: MenuItemProps<As>) => {
|
||||
const Component = AsComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
data-menu-item
|
||||
className={classNames(
|
||||
className,
|
||||
classes.menuItem,
|
||||
active && classes.menuItemActive,
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={leadingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{trailingIcon && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={trailingIcon}
|
||||
className={classNames(iconClassName, classes.menuItemIcon)}
|
||||
/>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<MenuItemBase
|
||||
as='button'
|
||||
type='button'
|
||||
role='menuitem'
|
||||
{...props}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
163
app/javascript/mastodon/components/menu/items.tsx
Normal file
163
app/javascript/mastodon/components/menu/items.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useId } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CheckIcon } from '@phosphor-icons/react';
|
||||
|
||||
import { Toggle } from '../form_fields/redesign';
|
||||
import { Icon } from '../icon';
|
||||
import type { IconProp } from '../icon';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
interface MenuItemGroupProps extends React.ComponentProps<'div'> {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MenuItemGroup: React.FC<MenuItemGroupProps> = ({
|
||||
label,
|
||||
children,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
|
||||
return (
|
||||
<div aria-labelledby={uniqueId} role='group'>
|
||||
<div id={uniqueId} className={classes.itemGroupLabel}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MenuItemDivider: React.FC = () => {
|
||||
return <hr className={classes.itemDivider} />;
|
||||
};
|
||||
|
||||
type MenuItemProps<As extends React.ElementType> =
|
||||
React.ComponentPropsWithoutRef<As> & {
|
||||
as?: As;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: IconProp | 'reserve-space';
|
||||
trailingContent?: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export const MenuItemBase = <As extends React.ElementType>({
|
||||
active,
|
||||
disabled,
|
||||
as: AsComp,
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
trailingContent,
|
||||
iconClassName,
|
||||
...props
|
||||
}: MenuItemProps<As>) => {
|
||||
const Component = AsComp ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
data-menu-item
|
||||
className={classNames(
|
||||
className,
|
||||
classes.item,
|
||||
active && classes.itemActive,
|
||||
)}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{icon && icon !== 'reserve-space' && (
|
||||
<Icon
|
||||
id='menu'
|
||||
icon={icon}
|
||||
className={classNames(iconClassName, classes.itemIcon)}
|
||||
/>
|
||||
)}
|
||||
{icon === 'reserve-space' && <div className={classes.itemIcon} />}
|
||||
|
||||
{children}
|
||||
|
||||
<span className={classes.itemTrailingContent}>{trailingContent}</span>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<MenuItemBase as='button' type='button' role='menuitem' {...props}>
|
||||
{children}
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
interface MenuItemRadioProps extends Omit<
|
||||
MenuItemProps<'button'>,
|
||||
'as' | 'onChange' | 'icon'
|
||||
> {
|
||||
checked: boolean;
|
||||
value: string;
|
||||
onChange: (change: { value: string }) => void;
|
||||
}
|
||||
|
||||
export const MenuItemRadio: React.FC<MenuItemRadioProps> = ({
|
||||
children,
|
||||
checked,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const handleChange = useCallback(() => {
|
||||
onChange({ value });
|
||||
}, [value, onChange]);
|
||||
|
||||
return (
|
||||
<MenuItemBase
|
||||
as='button'
|
||||
{...props}
|
||||
role='menuitemradio'
|
||||
aria-checked={checked}
|
||||
onClick={handleChange}
|
||||
icon={checked ? CheckIcon : 'reserve-space'}
|
||||
>
|
||||
{children}
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
interface MenuItemCheckboxProps extends Omit<MenuItemRadioProps, 'onChange'> {
|
||||
icon?: IconProp;
|
||||
onChange: (change: { value: string; checked: boolean }) => void;
|
||||
}
|
||||
|
||||
export const MenuItemCheckbox: React.FC<MenuItemCheckboxProps> = ({
|
||||
children,
|
||||
checked,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const handleChange = useCallback(() => {
|
||||
onChange({ value, checked: !checked });
|
||||
}, [onChange, value, checked]);
|
||||
|
||||
return (
|
||||
<MenuItemBase
|
||||
as='button'
|
||||
{...props}
|
||||
role='menuitemcheckbox'
|
||||
aria-checked={checked}
|
||||
onClick={handleChange}
|
||||
trailingContent={
|
||||
<Toggle aria-hidden='true' tabIndex={-1} size='sm' checked={checked} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
MoonIcon,
|
||||
NumberCircleOneIcon,
|
||||
@@ -9,9 +11,16 @@ import { action } from 'storybook/actions';
|
||||
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
|
||||
import { ToggleField } from '../form_fields/redesign';
|
||||
|
||||
import { Menu, MenuButton, MenuList, MenuItemBase, MenuItem } from '.';
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuItemDivider,
|
||||
MenuItemCheckbox,
|
||||
MenuItemGroup,
|
||||
MenuItemRadio,
|
||||
} from '.';
|
||||
import type { MenuCardProps } from './card';
|
||||
import { MenuCard } from './card';
|
||||
|
||||
@@ -38,16 +47,10 @@ export const Simple: Story = {
|
||||
render(args) {
|
||||
return (
|
||||
<MenuCard {...args}>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
<MenuItem icon={NumberCircleOneIcon} onClick={handleMenuItemClick}>
|
||||
First item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
<MenuItem icon={NumberCircleTwoIcon} onClick={handleMenuItemClick}>
|
||||
Second item
|
||||
</MenuItem>
|
||||
</MenuCard>
|
||||
@@ -63,16 +66,10 @@ export const Popover: Story = {
|
||||
<MenuButton>Click to show dropdown</MenuButton>
|
||||
|
||||
<MenuList {...args}>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleOneIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
<MenuItem icon={NumberCircleOneIcon} onClick={handleMenuItemClick}>
|
||||
First item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
leadingIcon={NumberCircleTwoIcon}
|
||||
onClick={handleMenuItemClick}
|
||||
>
|
||||
<MenuItem icon={NumberCircleTwoIcon} onClick={handleMenuItemClick}>
|
||||
Second item
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
@@ -82,18 +79,53 @@ export const Popover: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Controls: Story = {
|
||||
export const Complex: Story = {
|
||||
render(args) {
|
||||
const [sun, { onToggle }] = useToggle();
|
||||
const [precip, setPrecip] = useState('none');
|
||||
|
||||
const handlePrecipChange = useCallback(({ value }: { value: string }) => {
|
||||
setPrecip(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuCard {...args}>
|
||||
<MenuItem onClick={handleMenuItemClick}>First item</MenuItem>
|
||||
|
||||
<hr />
|
||||
<MenuItemDivider />
|
||||
|
||||
<MenuItemBase leadingIcon={sun ? SunIcon : MoonIcon}>
|
||||
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
|
||||
</MenuItemBase>
|
||||
<MenuItemCheckbox
|
||||
icon={sun ? SunIcon : MoonIcon}
|
||||
checked={sun}
|
||||
onChange={onToggle}
|
||||
value='daytime'
|
||||
>
|
||||
Daytime toggle
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemDivider />
|
||||
<MenuItemGroup label='Precipitation'>
|
||||
<MenuItemRadio
|
||||
value='none'
|
||||
checked={precip === 'none'}
|
||||
onChange={handlePrecipChange}
|
||||
>
|
||||
None
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
value='rain'
|
||||
checked={precip === 'rain'}
|
||||
onChange={handlePrecipChange}
|
||||
>
|
||||
Rain
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
value='snow'
|
||||
checked={precip === 'snow'}
|
||||
onChange={handlePrecipChange}
|
||||
>
|
||||
Snow
|
||||
</MenuItemRadio>
|
||||
</MenuItemGroup>
|
||||
</MenuCard>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.menuCard {
|
||||
.card {
|
||||
@include mixins.elevation-1;
|
||||
|
||||
display: flex;
|
||||
@@ -14,20 +14,27 @@
|
||||
&[data-elevation='2'] {
|
||||
@include mixins.elevation-2;
|
||||
}
|
||||
|
||||
> hr {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-primary);
|
||||
margin: var(--space-2xs) var(--space-sm);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.popoverMenuCard {
|
||||
.popoverCard {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
.itemGroupLabel {
|
||||
@include mixins.type-label-sm;
|
||||
|
||||
color: var(--color-text-tertiary);
|
||||
padding: var(--space-xs) var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.itemDivider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-primary);
|
||||
margin: var(--space-2xs) var(--space-sm);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
--cursor: pointer;
|
||||
@@ -47,20 +54,20 @@
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
&:hover:not([aria-disabled='true']) {
|
||||
background-color: var(--color-bg-highlight);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:active:not([aria-disabled='true']),
|
||||
.menuItemActive {
|
||||
.itemActive {
|
||||
background-color: var(--color-bg-inverted);
|
||||
color: var(--color-text-inverted);
|
||||
|
||||
@@ -70,22 +77,18 @@
|
||||
}
|
||||
|
||||
&:has(:disabled),
|
||||
[aria-disabled='true'] {
|
||||
&[aria-disabled='true'] {
|
||||
--cursor: not-allowed;
|
||||
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
.itemIcon {
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
}
|
||||
|
||||
.menu > hr {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-primary);
|
||||
margin: var(--space-2xs) var(--space-sm);
|
||||
border: none;
|
||||
width: calc(100% - (2 * var(--space-sm)));
|
||||
.itemTrailingContent {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
@@ -62,14 +62,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownItemControl {
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
--fade-size: var(--space-xl);
|
||||
|
||||
@@ -149,25 +141,6 @@
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.visibilityFieldset {
|
||||
display: block;
|
||||
|
||||
[data-label] {
|
||||
@include mixins.type-label-sm;
|
||||
|
||||
color: var(--color-text-tertiary);
|
||||
padding: var(--space-xs) var(--space-sm) 0;
|
||||
}
|
||||
|
||||
[data-fields] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[role='status'] {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.languageMenu {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
|
||||
|
||||
@@ -85,18 +85,14 @@ export const ComposeRedesignButton: React.FC = () => {
|
||||
</MenuButton>
|
||||
|
||||
<MenuList maxWidth={180} placement='top-end'>
|
||||
<MenuItem
|
||||
name='post'
|
||||
onClick={handleComposerOpen}
|
||||
leadingIcon={NewspaperIcon}
|
||||
>
|
||||
<MenuItem name='post' onClick={handleComposerOpen} icon={NewspaperIcon}>
|
||||
<FormattedMessage id='compose.new.post' defaultMessage='Post' />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
name='message'
|
||||
onClick={handleComposerOpen}
|
||||
leadingIcon={ChatCircleIcon}
|
||||
icon={ChatCircleIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.new.message'
|
||||
|
||||
@@ -12,7 +12,11 @@ import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
|
||||
import { Blurhash } from '@/mastodon/components/blurhash';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { MenuItem, MenuList } from '@/mastodon/components/menu';
|
||||
import {
|
||||
MenuItem,
|
||||
MenuItemDivider,
|
||||
MenuList,
|
||||
} from '@/mastodon/components/menu';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@@ -126,17 +130,17 @@ export const ComposeUpload: React.FC<{
|
||||
<MenuItem onClick={handleRearrange}>
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.rearrange'
|
||||
defaultMessage='Rearrange…'
|
||||
defaultMessage='Rearrange…'
|
||||
/>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
<MenuItemDivider />
|
||||
|
||||
<MenuItem
|
||||
className={classes.mediaMenuDelete}
|
||||
onClick={handleDelete}
|
||||
leadingIcon={TrashIcon}
|
||||
icon={TrashIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.delete'
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import {
|
||||
ChatCircleIcon,
|
||||
CheckIcon,
|
||||
MagnifyingGlassIcon,
|
||||
QuotesIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
@@ -16,23 +15,19 @@ import {
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { Fieldset } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
ToggleField,
|
||||
RadioButtonField,
|
||||
} from '@/mastodon/components/form_fields/redesign';
|
||||
import type { IconProp } from '@/mastodon/components/icon';
|
||||
import {
|
||||
Menu,
|
||||
MenuList,
|
||||
MenuButton,
|
||||
MenuItemBase,
|
||||
MenuItemDivider,
|
||||
MenuItemGroup,
|
||||
MenuItem,
|
||||
MenuItemRadio,
|
||||
MenuItemCheckbox,
|
||||
} from '@/mastodon/components/menu';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { selectComposeMentions, selectComposePrivacy } from './selectors';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const ComposeVisibility: React.FC = () => {
|
||||
const privacy = useAppSelector(selectComposePrivacy);
|
||||
@@ -83,53 +78,47 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
const quotePolicy = currentQuotePolicy ?? defaultQuotePolicy;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handlePrivacyChange: React.ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
if (value === 'private' && privacy !== 'private') {
|
||||
dispatch(changeComposeVisibility(value));
|
||||
} else if (value === 'public' && privacy === 'private') {
|
||||
dispatch(
|
||||
changeComposeVisibility(
|
||||
defaultPrivacy === 'unlisted' ? 'unlisted' : 'public',
|
||||
),
|
||||
);
|
||||
} else if (value === 'unlisted' && privacy !== 'private') {
|
||||
dispatch(
|
||||
changeComposeVisibility(
|
||||
privacy === 'public' ? 'unlisted' : 'public',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[defaultPrivacy, dispatch, privacy],
|
||||
);
|
||||
const handleQuotePolicyChange: React.ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
const { value, checked } = event.target;
|
||||
let newQuotePolicy: ApiQuotePolicy = 'nobody';
|
||||
switch (value) {
|
||||
case 'public':
|
||||
newQuotePolicy = 'public';
|
||||
break;
|
||||
case 'followers':
|
||||
newQuotePolicy = 'followers';
|
||||
break;
|
||||
case 'others':
|
||||
// If it's not checked, then it's nobody.
|
||||
if (checked) {
|
||||
// Only use the default if it's not nobody, as then it'll never be enabled.
|
||||
newQuotePolicy =
|
||||
defaultQuotePolicy !== 'nobody' ? defaultQuotePolicy : 'public';
|
||||
}
|
||||
break;
|
||||
}
|
||||
dispatch(setComposeQuotePolicy(newQuotePolicy));
|
||||
},
|
||||
[defaultQuotePolicy, dispatch],
|
||||
);
|
||||
const handlePrivacyChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
if (value === 'private' && privacy !== 'private') {
|
||||
dispatch(changeComposeVisibility(value));
|
||||
} else if (value === 'public' && privacy === 'private') {
|
||||
dispatch(
|
||||
changeComposeVisibility(
|
||||
defaultPrivacy === 'unlisted' ? 'unlisted' : 'public',
|
||||
),
|
||||
);
|
||||
} else if (value === 'unlisted' && privacy !== 'private') {
|
||||
dispatch(
|
||||
changeComposeVisibility(privacy === 'public' ? 'unlisted' : 'public'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[defaultPrivacy, dispatch, privacy],
|
||||
);
|
||||
const handleQuotePolicyChange = useCallback(
|
||||
({ value, checked }: { value: string; checked?: boolean }) => {
|
||||
let newQuotePolicy: ApiQuotePolicy = 'nobody';
|
||||
switch (value) {
|
||||
case 'public':
|
||||
newQuotePolicy = 'public';
|
||||
break;
|
||||
case 'followers':
|
||||
newQuotePolicy = 'followers';
|
||||
break;
|
||||
case 'others':
|
||||
// If it's not checked, then it's nobody.
|
||||
if (checked) {
|
||||
// Only use the default if it's not nobody, as then it'll never be enabled.
|
||||
newQuotePolicy =
|
||||
defaultQuotePolicy !== 'nobody' ? defaultQuotePolicy : 'public';
|
||||
}
|
||||
break;
|
||||
}
|
||||
dispatch(setComposeQuotePolicy(newQuotePolicy));
|
||||
},
|
||||
[defaultQuotePolicy, dispatch],
|
||||
);
|
||||
const handleSwitchToMessage: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(() => {
|
||||
dispatch(changeComposeVisibility('direct'));
|
||||
@@ -137,26 +126,24 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
|
||||
return (
|
||||
<MenuList placement='bottom-start' offset={4} maxWidth={280}>
|
||||
<Fieldset
|
||||
name='visibility'
|
||||
legend={
|
||||
<MenuItemGroup
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='compose.visibility.title'
|
||||
defaultMessage='Visibility'
|
||||
/>
|
||||
}
|
||||
className={classes.visibilityFieldset}
|
||||
>
|
||||
<DropdownRadioCheckField
|
||||
<MenuItemRadio
|
||||
name='visibility'
|
||||
value='public'
|
||||
checked={privacy === 'public' || privacy === 'unlisted'}
|
||||
onChange={handlePrivacyChange}
|
||||
>
|
||||
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
|
||||
</DropdownRadioCheckField>
|
||||
</MenuItemRadio>
|
||||
|
||||
<DropdownRadioCheckField
|
||||
<MenuItemRadio
|
||||
name='visibility'
|
||||
value='private'
|
||||
checked={privacy === 'private'}
|
||||
@@ -166,49 +153,47 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
id='privacy.private.short'
|
||||
defaultMessage='Followers'
|
||||
/>
|
||||
</DropdownRadioCheckField>
|
||||
</Fieldset>
|
||||
</MenuItemRadio>
|
||||
|
||||
<hr />
|
||||
<MenuItemDivider />
|
||||
|
||||
<DropdownToggleField
|
||||
value='unlisted'
|
||||
disabled={privacy === 'private'}
|
||||
checked={privacy === 'public'}
|
||||
onChange={handlePrivacyChange}
|
||||
icon={MagnifyingGlassIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.discoverable'
|
||||
defaultMessage='Discoverable in public feeds & search results'
|
||||
/>
|
||||
</DropdownToggleField>
|
||||
<MenuItemCheckbox
|
||||
value='unlisted'
|
||||
disabled={privacy === 'private'}
|
||||
checked={privacy === 'public'}
|
||||
onChange={handlePrivacyChange}
|
||||
icon={MagnifyingGlassIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.discoverable'
|
||||
defaultMessage='Discoverable in public feeds & search results'
|
||||
/>
|
||||
</MenuItemCheckbox>
|
||||
|
||||
<DropdownToggleField
|
||||
value='others'
|
||||
disabled={privacy === 'private'}
|
||||
checked={quotePolicy !== 'nobody' && privacy !== 'private'}
|
||||
onChange={handleQuotePolicyChange}
|
||||
icon={QuotesIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.quotable'
|
||||
defaultMessage='Allow others to quote'
|
||||
/>
|
||||
</DropdownToggleField>
|
||||
<MenuItemCheckbox
|
||||
value='others'
|
||||
disabled={privacy === 'private'}
|
||||
checked={quotePolicy !== 'nobody' && privacy !== 'private'}
|
||||
onChange={handleQuotePolicyChange}
|
||||
icon={QuotesIcon}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.quotable'
|
||||
defaultMessage='Allow others to quote'
|
||||
/>
|
||||
</MenuItemCheckbox>
|
||||
</MenuItemGroup>
|
||||
|
||||
{quotePolicy !== 'nobody' && privacy !== 'private' && (
|
||||
<Fieldset
|
||||
name='quote_policy'
|
||||
legend={
|
||||
<MenuItemGroup
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='compose.visibility.quote_policy'
|
||||
defaultMessage='Who can quote'
|
||||
/>
|
||||
}
|
||||
className={classes.visibilityFieldset}
|
||||
>
|
||||
<DropdownRadioCheckField
|
||||
<MenuItemRadio
|
||||
name='quote_policy'
|
||||
value='public'
|
||||
checked={quotePolicy === 'public'}
|
||||
@@ -218,9 +203,9 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
id='compose.visibility.quote_policy.anyone'
|
||||
defaultMessage='Anyone'
|
||||
/>
|
||||
</DropdownRadioCheckField>
|
||||
</MenuItemRadio>
|
||||
|
||||
<DropdownRadioCheckField
|
||||
<MenuItemRadio
|
||||
name='quote_policy'
|
||||
value='followers'
|
||||
checked={quotePolicy === 'followers'}
|
||||
@@ -230,13 +215,13 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
id='compose.visibility.quote_policy.followers'
|
||||
defaultMessage='Followers'
|
||||
/>
|
||||
</DropdownRadioCheckField>
|
||||
</Fieldset>
|
||||
</MenuItemRadio>
|
||||
</MenuItemGroup>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
<MenuItemDivider />
|
||||
|
||||
<MenuItem leadingIcon={ChatCircleIcon} onClick={handleSwitchToMessage}>
|
||||
<MenuItem icon={ChatCircleIcon} onClick={handleSwitchToMessage}>
|
||||
<FormattedMessage
|
||||
id='compose.post.to_message'
|
||||
defaultMessage='Compose a message instead'
|
||||
@@ -246,68 +231,3 @@ const ComposeVisibilityMenu: React.FC = () => {
|
||||
</MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownRadioCheckField: React.FC<
|
||||
Omit<
|
||||
React.ComponentProps<typeof RadioButtonField>,
|
||||
'label' | 'icon' | 'iconClassName'
|
||||
> & {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
> = ({ children, onClick, disabled, ...props }) => {
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<MenuItemBase onClick={onWrapperClick} disabled={disabled}>
|
||||
<RadioButtonField
|
||||
{...props}
|
||||
ref={ref}
|
||||
label={children}
|
||||
icon={CheckIcon}
|
||||
disabled={disabled}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
/>
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownToggleField: React.FC<
|
||||
Omit<React.ComponentProps<typeof ToggleField>, 'label'> & {
|
||||
children: React.ReactNode;
|
||||
icon?: IconProp;
|
||||
}
|
||||
> = ({ children, icon, disabled, ...props }) => {
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<MenuItemBase
|
||||
onClick={onWrapperClick}
|
||||
leadingIcon={icon}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ToggleField
|
||||
size='sm'
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
label={children}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
/>
|
||||
</MenuItemBase>
|
||||
);
|
||||
};
|
||||
|
||||
function useDropdownControl() {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const onWrapperClick: React.MouseEventHandler = useCallback((event) => {
|
||||
const { target } = event;
|
||||
if (
|
||||
target instanceof HTMLLabelElement ||
|
||||
target instanceof HTMLInputElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ref.current?.click();
|
||||
}, []);
|
||||
return { ref, onWrapperClick };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user