screen reader and keyboard usability pass

This commit is contained in:
2026-08-23 17:51:46 +03:00
parent db54882933
commit 093b27f3f4
33 changed files with 638 additions and 640 deletions
+23 -95
View File
@@ -5,7 +5,6 @@ import { IconSunFilled, IconMoonFilled, IconDeviceDesktop, IconEyeFilled, IconTe
IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight,
IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled,
IconAccessibleFilled } from '@tabler/icons-react'
import * as narrator from '../narrator'
import { motion, AnimatePresence } from 'motion/react'
import { useStore } from '../store'
import * as api from '../api'
@@ -91,95 +90,23 @@ function Toggle({ checked, onChange, label, hint, disabled, disabledHint }: { ch
function NarratorSettingsBlock() {
const { t } = useTranslation()
const narratorEnabled = useStore(s => s.narratorEnabled)
const narratorSettings = useStore(s => s.narratorSettings)
const setNarratorEnabled = useStore(s => s.setNarratorEnabled)
const setNarratorSettings = useStore(s => s.setNarratorSettings)
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [pendingRate, setPendingRate] = useState(narratorSettings.rate)
const [pendingPitch, setPendingPitch] = useState(narratorSettings.pitch)
useEffect(() => {
if (!narrator.isSupported()) return
const loadVoices = () => setVoices(narrator.getVoices())
loadVoices()
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices
}
}, [])
if (!narrator.isSupported()) return (
<p className="settings-hint">{t('settings.narrator.notSupported')}</p>
)
return (
<div className="settings-narrator-controls">
<Toggle checked={narratorEnabled} onChange={v => setNarratorEnabled(v)}
label={t('settings.narrator.narratorMode')} />
<AnimatePresence>
{narratorEnabled && (
<motion.div className="settings-narrator-detail"
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
style={{ overflow: 'hidden' }}>
<div className="settings-narrator-grid">
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.speechRate', { rate: pendingRate.toFixed(1) })}</label>
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingRate}
onChange={e => setPendingRate(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ rate: pendingRate })}
onTouchEnd={() => setNarratorSettings({ rate: pendingRate })} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.pitch', { pitch: pendingPitch.toFixed(1) })}</label>
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingPitch}
onChange={e => setPendingPitch(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ pitch: pendingPitch })}
onTouchEnd={() => setNarratorSettings({ pitch: pendingPitch })} />
</div>
</div>
{voices.length > 0 && (
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.voice')}</label>
<Select value={narratorSettings.voice}
onChange={v => setNarratorSettings({ voice: String(v) })}
options={[
{ value: '', label: t('settings.narrator.defaultVoice') },
...voices.map(v => ({ value: v.name, label: `${v.name} (${v.lang})` }))
]} />
</div>
)}
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
<Select value={narratorSettings.verbosity}
onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
options={[
{ value: 'brief', label: t('settings.narrator.brief') },
{ value: 'normal', label: t('settings.narrator.normal') },
{ value: 'verbose', label: t('settings.narrator.verbose') },
]} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.output')}</label>
<Select value={narratorSettings.mode}
onChange={v => { const s = String(v); if (s === 'voice' || s === 'screenreader' || s === 'both') setNarratorSettings({ mode: s }) }}
options={[
{ value: 'voice', label: t('settings.narrator.outputVoice') },
{ value: 'screenreader', label: t('settings.narrator.outputScreenReader') },
{ value: 'both', label: t('settings.narrator.outputBoth') },
]} />
</div>
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
label={t('settings.narrator.soundEffects')} />
</motion.div>
)}
</AnimatePresence>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
<Select value={narratorSettings.verbosity}
onChange={v => { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }}
options={[
{ value: 'brief', label: t('settings.narrator.brief') },
{ value: 'normal', label: t('settings.narrator.normal') },
{ value: 'verbose', label: t('settings.narrator.verbose') },
]} />
</div>
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
label={t('settings.narrator.soundEffects')} />
</div>
)
}
@@ -198,6 +125,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const [newBlockedDomain, setNewBlockedDomain] = useState('')
const [tab, setTab] = useState<Tab>('profile')
const [migrateDomain, setMigrateDomain] = useState('')
const [importMsg, setImportMsg] = useState('')
const [migrating, setMigrating] = useState(false)
const [migrateMsg, setMigrateMsg] = useState('')
const [displayName, setDisplayName] = useState('')
@@ -303,7 +231,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label">{t('settings.display.theme')}</label>
<div className="settings-row">
{(['system', 'light', 'dark'] as const).map(th => (
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} onClick={() => update({ theme: th })}>
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} aria-pressed={settings.theme === th} onClick={() => update({ theme: th })}>
{th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> :
th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
<><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>}
@@ -316,7 +244,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label>
<div className="settings-row">
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`} aria-pressed={inputMode === m}
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
</button>
@@ -399,7 +327,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
<label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label>
<div className="settings-grid-3">
{LIFETIME_KEYS.map(opt => (
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`}
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`} aria-pressed={settings.cookieLifetime === opt.value}
onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button>
))}
</div>
@@ -420,7 +348,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
) : (
<p className="settings-hint">{t('settings.session.noPasskeys')}</p>
)}
{passkeyError && <p className="settings-hint" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
{passkeyError && <p className="settings-hint" role="alert" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
{supportsPasskey && (
<button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}>
<IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')}
@@ -518,7 +446,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
{settings.blockedInstances.length > 0 && (
<div className="privacy-blocked-list">
{settings.blockedInstances.map(d => (
<span key={d} className="privacy-blocked-tag">{d}<button onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} /></button></span>
<span key={d} className="privacy-blocked-tag">{d}<button aria-label="Remove" onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} aria-hidden="true" /></button></span>
))}
</div>
)}
@@ -557,14 +485,14 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const res = await api.importData(data)
let msg = t('onboarding.imported', { count: res.imported })
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
alert(msg)
window.location.reload()
} catch (e) { alert(e instanceof Error ? e.message : t('onboarding.importFailed')) }
setImportMsg(msg)
} catch (e) { setImportMsg(e instanceof Error ? e.message : t('onboarding.importFailed')) }
}; input.click()
}}>
{t('settings.account.import')}
</button>
</div>
{importMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{importMsg}</p>}
</div>
<div className="settings-section">
@@ -597,7 +525,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
}}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button>
</div>
{claimStatus && (
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`}>
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`} role="status">
{claimStatus.msg}
</p>
)}
@@ -637,7 +565,7 @@ export default function SettingsPanel({ onClose }: { onClose: () => void }) {
.finally(() => setMigrating(false))
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
</div>
{migrateMsg && <p className="settings-hint" style={{ marginTop: 8 }}>{migrateMsg}</p>}
{migrateMsg && <p className="settings-hint" role="status" style={{ marginTop: 8 }}>{migrateMsg}</p>}
</>)}
</div>