e4ffbb05a5
Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):
- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
файлов появится вместе с вложениями).
Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
type ButtonHTMLAttributes,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type ReactNode,
|
|
type RefObject,
|
|
} from 'react';
|
|
|
|
interface MenuProps {
|
|
open: boolean;
|
|
/** Доступное имя меню (озвучивается скринридером). */
|
|
label: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
/**
|
|
* Элемент-якорь: клик по нему не считается кликом «вне меню» (иначе меню
|
|
* закрывалось бы и тут же открывалось заново), ему же возвращается фокус.
|
|
*/
|
|
anchorRef?: RefObject<HTMLElement | null>;
|
|
className?: string;
|
|
}
|
|
|
|
const ITEM_SELECTOR =
|
|
'[role="menuitem"]:not([disabled]),[role="menuitemradio"]:not([disabled]),[role="menuitemcheckbox"]:not([disabled])';
|
|
|
|
/**
|
|
* Всплывающее меню (`role="menu"`): закрывается по Escape и клику вне,
|
|
* открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус,
|
|
* Tab закрывает меню и возвращает управление странице.
|
|
*
|
|
* Позиционирование задаёт родитель: панель абсолютная и растёт вверх
|
|
* (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя.
|
|
*/
|
|
export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) {
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
const onCloseRef = useRef(onClose);
|
|
useEffect(() => {
|
|
onCloseRef.current = onClose;
|
|
}, [onClose]);
|
|
|
|
// Фокус — на первом пункте: меню сразу доступно с клавиатуры.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const items = panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR);
|
|
items?.[0]?.focus();
|
|
}, [open]);
|
|
|
|
// Клик вне меню (и вне якоря) закрывает его.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const onPointerDown = (event: MouseEvent): void => {
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) {
|
|
return;
|
|
}
|
|
if (panelRef.current?.contains(target) === true) {
|
|
return;
|
|
}
|
|
if (anchorRef?.current?.contains(target) === true) {
|
|
return;
|
|
}
|
|
onCloseRef.current();
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('touchstart', onPointerDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('touchstart', onPointerDown);
|
|
};
|
|
}, [open, anchorRef]);
|
|
|
|
// Escape работает и когда фокус ушёл на якорь.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Escape') {
|
|
onCloseRef.current();
|
|
anchorRef?.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [open, anchorRef]);
|
|
|
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
if (event.key === 'Tab') {
|
|
// Меню — не часть tab-порядка: уводим фокус дальше по странице.
|
|
onClose();
|
|
return;
|
|
}
|
|
const items = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []);
|
|
if (items.length === 0) {
|
|
return;
|
|
}
|
|
const current = items.findIndex((item) => item === document.activeElement);
|
|
const move = (next: number): void => {
|
|
event.preventDefault();
|
|
const target = items[(next + items.length) % items.length];
|
|
target?.focus();
|
|
};
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
move(current + 1);
|
|
return;
|
|
case 'ArrowUp':
|
|
move(current <= 0 ? items.length - 1 : current - 1);
|
|
return;
|
|
case 'Home':
|
|
move(0);
|
|
return;
|
|
case 'End':
|
|
move(items.length - 1);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={panelRef}
|
|
role="menu"
|
|
aria-label={label}
|
|
onKeyDown={onKeyDown}
|
|
className={`gl-popover absolute bottom-full left-0 z-40 mb-2 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
/** Иконка слева (декоративная). */
|
|
icon?: ReactNode;
|
|
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
|
|
selected?: boolean;
|
|
danger?: boolean;
|
|
}
|
|
|
|
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
|
|
export function MenuItem({
|
|
icon,
|
|
selected = false,
|
|
danger = false,
|
|
className = '',
|
|
children,
|
|
...rest
|
|
}: MenuItemProps) {
|
|
const role = rest.role ?? 'menuitem';
|
|
return (
|
|
<button
|
|
{...rest}
|
|
type="button"
|
|
role={role}
|
|
tabIndex={rest.tabIndex ?? -1}
|
|
aria-checked={role === 'menuitemradio' ? selected : undefined}
|
|
className={`flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm transition-colors duration-[var(--duration-fast)] focus-visible:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
danger ? 'text-danger hover:bg-danger/10' : 'text-fg hover:bg-surface-3'
|
|
} ${className}`}
|
|
>
|
|
<span aria-hidden="true" className="w-4 shrink-0 text-center">
|
|
{selected ? '✓' : (icon ?? '')}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{children}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** Заголовок группы пунктов внутри меню. */
|
|
export function MenuGroupLabel({ children }: { children: ReactNode }) {
|
|
return (
|
|
<p role="presentation" className="px-2 py-1 text-xs font-semibold uppercase text-fg-muted">
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** Разделитель между группами пунктов. */
|
|
export function MenuSeparator() {
|
|
return <div role="separator" className="my-1 h-px bg-border/60" />;
|
|
}
|