mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
chore(orbit): keyboard navigation across interactive modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,13 +13,43 @@ import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||
export default function OrbitAccountPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Reset + focus first item each time the picker re-opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelected(0);
|
||||
// Defer focus to the next tick so the DOM has actually mounted.
|
||||
queueMicrotask(() => itemRefs.current[0]?.focus());
|
||||
}, [isOpen]);
|
||||
|
||||
// Move DOM focus with the arrow-key selection so the browser's focus
|
||||
// ring follows, and the currently active button is readable to AT.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
itemRefs.current[selected]?.focus();
|
||||
}, [selected, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancel(); };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { cancel(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s + 1) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = accounts[selected];
|
||||
if (target) pick(target);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, cancel]);
|
||||
}, [isOpen, accounts, selected, pick, cancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -42,13 +72,15 @@ export default function OrbitAccountPicker() {
|
||||
<p className="orbit-account-picker__sub">
|
||||
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
|
||||
</p>
|
||||
<ul className="orbit-account-picker__list">
|
||||
{accounts.map(a => (
|
||||
<li key={a.id}>
|
||||
<ul className="orbit-account-picker__list" role="listbox">
|
||||
{accounts.map((a, i) => (
|
||||
<li key={a.id} role="option" aria-selected={i === selected}>
|
||||
<button
|
||||
ref={el => { itemRefs.current[i] = el; }}
|
||||
type="button"
|
||||
className="orbit-account-picker__item"
|
||||
className={`orbit-account-picker__item${i === selected ? ' is-active' : ''}`}
|
||||
onClick={() => pick(a)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
>
|
||||
<User size={14} />
|
||||
<span className="orbit-account-picker__user">{a.username}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
@@ -26,7 +27,29 @@ export default function OrbitExitModal() {
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
const isRemoved = phase === 'error' && errorMessage === 'removed';
|
||||
const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout';
|
||||
if (!isEnded && !isKicked && !isRemoved && !isHostTimeout) return null;
|
||||
const isOpen = isEnded || isKicked || isRemoved || isHostTimeout;
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
if (role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Modal is informational with a single action — Enter / Escape both fire OK.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = isKicked
|
||||
? t('orbit.exitKickedTitle')
|
||||
@@ -43,15 +66,6 @@ export default function OrbitExitModal() {
|
||||
? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
if (role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-exit-overlay"
|
||||
@@ -64,7 +78,7 @@ export default function OrbitExitModal() {
|
||||
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
||||
<p className="orbit-exit-modal__body">{body}</p>
|
||||
<div className="orbit-exit-modal__actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onOk}>{t('orbit.exitOk')}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onOk} autoFocus>{t('orbit.exitOk')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X, Sparkles, Users, Share2, LogIn, MousePointerClick,
|
||||
@@ -17,10 +17,34 @@ import SettingsSubSection from './SettingsSubSection';
|
||||
export default function OrbitHelpModal() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, close } = useHelpModalStore();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
|
||||
// Focus the first accordion summary so arrow keys work immediately.
|
||||
queueMicrotask(() => {
|
||||
const first = bodyRef.current?.querySelector<HTMLElement>('summary');
|
||||
first?.focus();
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const summaries = Array.from(
|
||||
bodyRef.current?.querySelectorAll<HTMLElement>('summary') ?? [],
|
||||
);
|
||||
if (summaries.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const idx = summaries.indexOf(current as HTMLElement);
|
||||
e.preventDefault();
|
||||
const next = e.key === 'ArrowDown'
|
||||
? summaries[(idx + 1 + summaries.length) % summaries.length]
|
||||
: summaries[(idx - 1 + summaries.length) % summaries.length];
|
||||
next?.focus();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, close]);
|
||||
@@ -48,7 +72,7 @@ export default function OrbitHelpModal() {
|
||||
</h3>
|
||||
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
|
||||
|
||||
<div className="orbit-help-modal__body">
|
||||
<div className="orbit-help-modal__body" ref={bodyRef}>
|
||||
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
|
||||
<p>{t('orbit.helpSec1Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -137,6 +137,12 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setHasCopied(false); }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (busy || !name.trim()) return;
|
||||
e.preventDefault();
|
||||
void onStart();
|
||||
}}
|
||||
placeholder={t('orbit.namePlaceholder')}
|
||||
maxLength={40}
|
||||
className="orbit-start-modal__input"
|
||||
|
||||
@@ -12021,7 +12021,8 @@ html[data-psy-native-hidden="true"] *::after {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.orbit-account-picker__item:hover {
|
||||
.orbit-account-picker__item:hover,
|
||||
.orbit-account-picker__item.is-active {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user