Skip to content

Commit

Permalink
Convert PrivacyDropdownMenu to Typescript and generalize it to Dropdo…
Browse files Browse the repository at this point in the history
…wnSelector component (mastodon#31338)
  • Loading branch information
ClearlyClaire authored Aug 8, 2024
1 parent a95fe93 commit 2edae5e
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 131 deletions.
185 changes: 185 additions & 0 deletions app/javascript/mastodon/components/dropdown_selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react';

import classNames from 'classnames';

import { supportsPassiveEvents } from 'detect-passive-events';

import InfoIcon from '@/material-icons/400-24px/info.svg?react';

import type { IconProp } from './icon';
import { Icon } from './icon';

const listenerOptions = supportsPassiveEvents
? { passive: true, capture: true }
: true;

interface SelectItem {
value: string;
icon?: string;
iconComponent?: IconProp;
text: string;
meta: string;
extra?: string;
}

interface Props {
value: string;
classNamePrefix: string;
style?: React.CSSProperties;
items: SelectItem[];
onChange: (value: string) => void;
onClose: () => void;
}

export const DropdownSelector: React.FC<Props> = ({
style,
items,
value,
classNamePrefix = 'privacy-dropdown',
onClose,
onChange,
}) => {
const nodeRef = useRef<HTMLUListElement>(null);
const focusedItemRef = useRef<HTMLLIElement>(null);
const [currentValue, setCurrentValue] = useState(value);

const handleDocumentClick = useCallback(
(e: MouseEvent | TouchEvent) => {
if (
nodeRef.current &&
e.target instanceof Node &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
},
[nodeRef, onClose],
);

const handleClick = useCallback(
(
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
) => {
const value = e.currentTarget.getAttribute('data-index');

e.preventDefault();

onClose();
if (value) onChange(value);
},
[onClose, onChange],
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLLIElement>) => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex((item) => item.value === value);

let element: Element | null | undefined = null;

switch (e.key) {
case 'Escape':
onClose();
break;
case ' ':
case 'Enter':
handleClick(e);
break;
case 'ArrowDown':
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
break;
case 'ArrowUp':
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
break;
case 'Tab':
if (e.shiftKey) {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
} else {
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
}
break;
case 'Home':
element = nodeRef.current?.firstElementChild;
break;
case 'End':
element = nodeRef.current?.lastElementChild;
break;
}

if (element && element instanceof HTMLElement) {
const selectedValue = element.getAttribute('data-index');
element.focus();
if (selectedValue) setCurrentValue(selectedValue);
e.preventDefault();
e.stopPropagation();
}
},
[nodeRef, items, onClose, handleClick, setCurrentValue],
);

useEffect(() => {
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItemRef.current?.focus({ preventScroll: true });

return () => {
document.removeEventListener('click', handleDocumentClick, {
capture: true,
});
document.removeEventListener(
'touchend',
handleDocumentClick,
listenerOptions,
);
};
}, [handleDocumentClick]);

return (
<ul style={style} role='listbox' ref={nodeRef}>
{items.map((item) => (
<li
role='option'
tabIndex={0}
key={item.value}
data-index={item.value}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={classNames(`${classNamePrefix}__option`, {
active: item.value === currentValue,
})}
aria-selected={item.value === currentValue}
ref={item.value === currentValue ? focusedItemRef : null}
>
{item.icon && item.iconComponent && (
<div className={`${classNamePrefix}__option__icon`}>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
)}

<div className={`${classNamePrefix}__option__content`}>
<strong>{item.text}</strong>
{item.meta}
</div>

{item.extra && (
<div
className={`${classNamePrefix}__option__additional`}
title={item.extra}
>
<Icon id='info-circle' icon={InfoIcon} />
</div>
)}
</li>
))}
</ul>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
import { Icon } from 'mastodon/components/icon';

import { PrivacyDropdownMenu } from './privacy_dropdown_menu';

const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
Expand Down Expand Up @@ -143,7 +142,7 @@ class PrivacyDropdown extends PureComponent {
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<PrivacyDropdownMenu
<DropdownSelector
items={this.options}
value={value}
onClose={this.handleClose}
Expand Down

This file was deleted.

0 comments on commit 2edae5e

Please sign in to comment.