Add live region

This commit is contained in:
MartinBraquet
2026-03-01 03:12:39 +01:00
parent 1aad769d93
commit edaf119d9e
3 changed files with 153 additions and 20 deletions

View File

@@ -0,0 +1,89 @@
import {useState} from 'react'
import {useLiveRegion} from 'web/components/live-region'
function LikeButton({profileId: _profileId}: {profileId: string}) {
const [liked, setLiked] = useState(false)
const {announce} = useLiveRegion()
const handleLike = () => {
const newLiked = !liked
setLiked(newLiked)
if (newLiked) {
announce('Profile liked', 'polite')
} else {
announce('Like removed', 'polite')
}
}
return (
<button onClick={handleLike} aria-label={liked ? 'Remove like' : 'Like profile'}>
{liked ? '❤️' : '🤍'}
</button>
)
}
function MessageSentNotification() {
const {announce} = useLiveRegion()
const handleSend = () => {
announce('Message sent successfully', 'polite')
}
return <button onClick={handleSend}>Send</button>
}
function ErrorAlert({message}: {message: string}) {
const {announce} = useLiveRegion()
const handleRetry = () => {
announce('Retrying connection...', 'polite')
}
return (
<div role="alert">
<p>Error: {message}</p>
<button onClick={handleRetry}>Retry</button>
</div>
)
}
function FormSubmission() {
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const {announce} = useLiveRegion()
const handleSubmit = async () => {
setStatus('submitting')
announce('Submitting form, please wait', 'polite')
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
setStatus('success')
announce('Form submitted successfully', 'polite')
} catch {
setStatus('error')
announce('Form submission failed. Please try again.', 'assertive')
}
}
return (
<form onSubmit={handleSubmit}>
{status === 'success' && <p role="status">Success!</p>}
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
function LoadingIndicator({isLoading}: {isLoading: boolean}) {
const {announce} = useLiveRegion()
if (isLoading) {
announce('Loading more results', 'polite')
}
return isLoading ? <span aria-busy="true">Loading...</span> : null
}
export {ErrorAlert, FormSubmission, LikeButton, LoadingIndicator, MessageSentNotification}

View File

@@ -0,0 +1,38 @@
import {createContext, useCallback, useContext, useRef} from 'react'
interface LiveRegionContextValue {
announce: (message: string, priority?: 'polite' | 'assertive') => void
}
const LiveRegionContext = createContext<LiveRegionContextValue | null>(null)
export function useLiveRegion() {
const context = useContext(LiveRegionContext)
if (!context) {
return {announce: () => {}}
}
return context
}
export function LiveRegionProvider({children}: {children: React.ReactNode}) {
const politeRef = useRef<HTMLDivElement>(null)
const assertiveRef = useRef<HTMLDivElement>(null)
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const element = priority === 'assertive' ? assertiveRef.current : politeRef.current
if (element) {
element.textContent = ''
setTimeout(() => {
if (element) element.textContent = message
}, 50)
}
}, [])
return (
<LiveRegionContext.Provider value={{announce}}>
{children}
<div ref={politeRef} aria-live="polite" aria-atomic="true" className="sr-only" />
<div ref={assertiveRef} aria-live="assertive" aria-atomic="true" className="sr-only" />
</LiveRegionContext.Provider>
)
}

View File

@@ -16,6 +16,8 @@ import posthog from 'posthog-js'
import {PostHogProvider} from 'posthog-js/react'
import {useEffect, useState} from 'react'
import {AuthProvider, AuthUser} from 'web/components/auth-context'
import {ErrorBoundary} from 'web/components/error-boundary'
import {LiveRegionProvider} from 'web/components/live-region'
import {useFontPreferenceManager} from 'web/hooks/use-font-preference'
import {useHasLoaded} from 'web/hooks/use-has-loaded'
import {HiddenProfilesProvider} from 'web/hooks/use-hidden-profiles'
@@ -181,27 +183,31 @@ function MyApp(props: AppProps<PageProps>) {
/>
</Head>
<PostHogProvider client={posthog}>
<div
className={clsx(
'contents font-normal',
logoFont.variable,
// mainFont.variable
)}
>
<I18nContext.Provider value={{locale, setLocale}}>
<AuthProvider serverUser={pageProps.auth}>
<HiddenProfilesProvider>
<WebPush />
<AndroidPush />
<Component {...pageProps} />
</HiddenProfilesProvider>
</AuthProvider>
</I18nContext.Provider>
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}
<div id="headlessui-portal-root">
<div />
<LiveRegionProvider>
<div
className={clsx(
'contents font-normal',
logoFont.variable,
// mainFont.variable
)}
>
<I18nContext.Provider value={{locale, setLocale}}>
<ErrorBoundary>
<AuthProvider serverUser={pageProps.auth}>
<HiddenProfilesProvider>
<WebPush />
<AndroidPush />
<Component {...pageProps} />
</HiddenProfilesProvider>
</AuthProvider>
</ErrorBoundary>
</I18nContext.Provider>
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}
<div id="headlessui-portal-root">
<div />
</div>
</div>
</div>
</LiveRegionProvider>
</PostHogProvider>
</>
)