screen reader and keyboard usability pass
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user