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