mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d448cffd7 | |||
| 180da8d433 | |||
| 8a30071f41 | |||
| 2002aa7b04 | |||
| 69dfda4b4b | |||
| a863dedc6a | |||
| 17e5c31ee2 | |||
| b16e4e9feb | |||
| b3f106baf1 | |||
| 79ad62c700 | |||
| 90e432ac68 | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a |
@@ -77,6 +77,9 @@ db.sqlite3
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Personal rules/config files
|
||||
rules.md
|
||||
|
||||
# Egress output
|
||||
docker/livekit/out
|
||||
|
||||
|
||||
+17
-2
@@ -7,10 +7,25 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve menu focus management #869
|
||||
- ♿️(frontend) add accessible back button in side panel #881
|
||||
- ♿️(frontend) improve participants toggle a11y label #880
|
||||
- ♿️(frontend) make carousel image decorative #871
|
||||
- ♿️(frontend) reactions are now vocalized and configurable #849
|
||||
- ♿️(frontend) improve background effect announcements #879
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒(backend) prevent automatic upgrade setuptools
|
||||
- ♿(frontend) improve contrast for selected options #863
|
||||
- ♿️(frontend) announce copy state in invite dialog #877
|
||||
- 📝(frontend) align close dialog label in rooms locale #878
|
||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
||||
|
||||
## [1.3.0] - 2026-01-13
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) add dutch and german languages
|
||||
@@ -27,7 +42,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
if (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
else:
|
||||
raise drf_exceptions.NotFound("User not found.") from e
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
"scope": "user.family@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -28,3 +28,37 @@ export default {
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
|
||||
## Side Panel Focus Pattern
|
||||
|
||||
We use a consistent focus management pattern for side panels:
|
||||
|
||||
- **Open**: focus the first actionable element inside the panel.
|
||||
- **Close**: restore focus to the button that opened the panel.
|
||||
|
||||
Implementation summary:
|
||||
|
||||
1. A provider stores a `panelRef` and a registry of trigger refs (`setTrigger/getTrigger`).
|
||||
2. Each trigger button registers itself with `setTrigger("key", el)`.
|
||||
3. Panel content uses `useRestoreFocus` with:
|
||||
- `resolveTrigger` → returns `getTrigger("key")`.
|
||||
- `onOpened` → finds the first actionable element inside `panelRef`.
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
// Trigger button
|
||||
;<ToggleButton ref={(el) => setTrigger('tools', el)} />
|
||||
|
||||
// Panel content
|
||||
useRestoreFocus(isOpen, {
|
||||
resolveTrigger: (activeEl) => getTrigger('tools') ?? activeEl,
|
||||
onOpened: () => {
|
||||
const first = panelRef.current?.querySelector(
|
||||
'[data-attr="tools-list"] button'
|
||||
)
|
||||
// Leading semicolon avoids ASI issues when a line starts with '('
|
||||
;(first as HTMLElement | null)?.focus({ preventScroll: true })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
|
||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
|
||||
@@ -10,9 +10,16 @@ import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
const { isAdminOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
const { roomId } = useParams()
|
||||
|
||||
@@ -38,6 +45,29 @@ export const Admin = () => {
|
||||
isScreenShareEnabled,
|
||||
} = usePublishSourcesManager()
|
||||
|
||||
// Restore focus to the element that opened the Admin panel
|
||||
useRestoreFocus(isAdminOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
return getTrigger('admin') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens (first Field switch)
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first switch in the moderation section
|
||||
const firstSwitch =
|
||||
panel.querySelector<HTMLElement>('[role="switch"]')
|
||||
if (firstSwitch) {
|
||||
firstSwitch.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiAdminLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -5,6 +6,7 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
|
||||
export const AdminToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -14,10 +16,17 @@ export const AdminToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
|
||||
|
||||
const { isAdminOpen, toggleAdmin } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
|
||||
const setAdminTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('admin', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
const hasAdminAccess = useIsAdminOrOwner()
|
||||
if (!hasAdminAccess) return
|
||||
if (!hasAdminAccess) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +41,7 @@ export const AdminToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isAdminOpen}
|
||||
ref={setAdminTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleAdmin()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
@@ -10,8 +10,23 @@ import { formatPinCode } from '../../utils/telephony'
|
||||
import { useTelephony } from '../hooks/useTelephony'
|
||||
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||
|
||||
const useFocusOnOpen = ({ ref, key }) => {
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = ref.current
|
||||
if (panel) {
|
||||
const firstButton = panel.querySelector<HTMLElement>(key)
|
||||
if (firstButton) {
|
||||
firstButton.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
const panelRef = useRef()
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
@@ -24,6 +39,8 @@ export const Info = () => {
|
||||
|
||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||
|
||||
useFocusOnOpen({ ref: panelRef, key: '[data-attr="copy-info-sidepanel"]' })
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -32,6 +49,7 @@ export const Info = () => {
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
ref={panelRef}
|
||||
>
|
||||
<VStack alignItems="start">
|
||||
<Text
|
||||
@@ -71,7 +89,7 @@ export const Info = () => {
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
aria-label={t('roomInformation.button.ariaLabel')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="copy-info-sidepannel"
|
||||
data-attr="copy-info-sidepanel"
|
||||
style={{
|
||||
marginLeft: '-8px',
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const ANIMATION_DURATION = 3000
|
||||
export const ANIMATION_DISTANCE = 300
|
||||
@@ -140,11 +143,53 @@ export function ReactionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||
reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||
const [announcement, setAnnouncement] = useState<string | null>(null)
|
||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
|
||||
const latestReaction =
|
||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!announceReactions) {
|
||||
setAnnouncement(null)
|
||||
return
|
||||
}
|
||||
if (!latestReaction) return
|
||||
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
||||
if (!isNewReaction) return
|
||||
|
||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||
const participantName = latestReaction.participant?.isLocal
|
||||
? t('you')
|
||||
: latestReaction.participant?.name?.trim() ||
|
||||
t('someone', { defaultValue: 'Someone' })
|
||||
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||
setLastAnnouncedId(latestReaction.id)
|
||||
|
||||
const timer = setTimeout(() => setAnnouncement(null), 1200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [latestReaction, lastAnnouncedId, announceReactions, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement ?? ''}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useEscapeKey } from '@/hooks/useEscapeKey'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -23,6 +26,8 @@ type StyledSidePanelProps = {
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
panelRef: React.RefObject<HTMLElement>
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
@@ -34,8 +39,11 @@ const StyledSidePanel = ({
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
panelRef,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
@@ -63,31 +71,34 @@ const StyledSidePanel = ({
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
@@ -129,8 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const SidePanelContent = () => {
|
||||
const {
|
||||
activePanelId,
|
||||
isParticipantsOpen,
|
||||
@@ -144,6 +154,23 @@ export const SidePanel = () => {
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const panelRef = useSidePanelRef()
|
||||
|
||||
useEscapeKey(
|
||||
() => {
|
||||
// Close subpanel + panel together for a consistent Escape behavior
|
||||
if (isSubPanelOpen) {
|
||||
layoutStore.activeSubPanelId = null
|
||||
layoutStore.activePanelId = null
|
||||
return
|
||||
}
|
||||
layoutStore.activePanelId = null
|
||||
},
|
||||
{
|
||||
isActive: isSidePanelOpen,
|
||||
capture: true,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
@@ -158,12 +185,16 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
panelRef={panelRef}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
|
||||
revisit if memory becomes a concern */}
|
||||
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
|
||||
<ParticipantsList />
|
||||
</Panel>
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Panel isOpen={isEffectsOpen} keepAlive={true}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
@@ -172,7 +203,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Panel isOpen={isAdminOpen} keepAlive={true}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
<Panel isOpen={isInfoOpen}>
|
||||
@@ -181,3 +212,7 @@ export const SidePanel = () => {
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidePanel = () => {
|
||||
return <SidePanelContent />
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -98,6 +100,8 @@ export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
@@ -106,10 +110,30 @@ export const Tools = () => {
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
return getTrigger('options') ?? activeEl
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
return getTrigger('tools') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first ToolButton in the tools list (transcript or screen recording button)
|
||||
const toolsList = panel.querySelector<HTMLElement>(
|
||||
'[data-attr="tools-list"]'
|
||||
)
|
||||
if (toolsList) {
|
||||
const firstToolButton = toolsList.querySelector<HTMLElement>(
|
||||
'button:first-of-type'
|
||||
)
|
||||
if (firstToolButton) {
|
||||
firstToolButton.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
@@ -141,6 +165,7 @@ export const Tools = () => {
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
data-attr="tools-list"
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
|
||||
+18
-2
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import Source = Track.Source
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
|
||||
|
||||
type AudioDevicesControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Microphone>,
|
||||
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
|
||||
...props
|
||||
}: AudioDevicesControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
|
||||
const popoverContentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||
@@ -55,6 +58,12 @@ export const AudioDevicesControl = ({
|
||||
|
||||
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
|
||||
|
||||
useFocusOnOpen(isMenuOpen, popoverContentRef, {
|
||||
selector:
|
||||
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]',
|
||||
delayMs: 250,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Popover
|
||||
variant="dark"
|
||||
withArrow={false}
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
@@ -92,6 +106,7 @@ export const AudioDevicesControl = ({
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
ref={popoverContentRef}
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
@@ -100,6 +115,7 @@ export const AudioDevicesControl = ({
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-attr="audio-input-select"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
|
||||
+22
-1
@@ -10,6 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
|
||||
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
import { BackgroundProcessorFactory } from '../../blur'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
@@ -17,10 +18,12 @@ import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
tooltip={t('effects')}
|
||||
aria-label={t('effects')}
|
||||
variant="primaryDark"
|
||||
ref={(el) => setTrigger('effects', el)}
|
||||
onPress={() => {
|
||||
if (!isEffectsOpen) toggleEffects()
|
||||
onPress()
|
||||
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
|
||||
...props
|
||||
}: VideoDeviceControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
|
||||
const popoverContentRef = React.useRef<HTMLDivElement>(null)
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
|
||||
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
||||
usePersistentUserChoices()
|
||||
@@ -99,6 +106,12 @@ export const VideoDeviceControl = ({
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useFocusOnOpen(isMenuOpen, popoverContentRef, {
|
||||
selector:
|
||||
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
|
||||
delayMs: 250,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Popover
|
||||
variant="dark"
|
||||
withArrow={false}
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
ref={(el) => setTrigger('cameraMenu', el)}
|
||||
variant={
|
||||
!canPublishTrack || !trackProps.enabled || cannotUseDevice
|
||||
? 'error2'
|
||||
@@ -136,6 +155,7 @@ export const VideoDeviceControl = ({
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
ref={popoverContentRef}
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-attr="video-input-select"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiInformationLine } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const InfoToggle = ({
|
||||
onPress,
|
||||
@@ -12,8 +14,18 @@ export const InfoToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
|
||||
|
||||
const { isInfoOpen, toggleInfo } = useSidePanel()
|
||||
// const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
|
||||
|
||||
const wip = useRef<HTMLElement | null>()
|
||||
|
||||
// const setInfoTriggerRef = useCallback(
|
||||
// (el: HTMLElement | null) => {
|
||||
// setTrigger('info', el)
|
||||
// },
|
||||
// [setTrigger]
|
||||
// )
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -27,8 +39,9 @@ export const InfoToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isInfoOpen}
|
||||
ref={wip}
|
||||
onPress={(e) => {
|
||||
toggleInfo()
|
||||
toggleInfo(wip)
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-info-${tooltipLabel}`}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const setOptionsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('options', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
@@ -14,6 +23,7 @@ export const OptionsButton = () => {
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
ref={setOptionsTriggerRef}
|
||||
>
|
||||
<RiMoreFill />
|
||||
</Button>
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
|
||||
<ToggleHeader
|
||||
isSelected={isOpen}
|
||||
aria-label={label}
|
||||
data-focus-target="list-header"
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
borderRadius: !isOpen ? '7px' : undefined,
|
||||
|
||||
+32
@@ -12,10 +12,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
|
||||
import { Participant } from 'livekit-client'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
const { isParticipantsOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||
@@ -48,6 +55,31 @@ export const ParticipantsList = () => {
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
// Restore focus to the element that opened the Participants panel
|
||||
useRestoreFocus(isParticipantsOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
return getTrigger('participants') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
// Use setTimeout + RAF to ensure DOM is fully rendered and transition completed
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first ToggleHeader (collapsable list header) in the participants panel
|
||||
const firstListHeader = panel.querySelector<HTMLElement>(
|
||||
'button[data-focus-target="list-header"]'
|
||||
)
|
||||
firstListHeader?.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
}, 100) // Wait for panel slide-in animation to complete
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
return (
|
||||
<Div overflowY="scroll">
|
||||
|
||||
+31
-15
@@ -1,10 +1,13 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
@@ -19,10 +22,19 @@ export const ParticipantsToggle = ({
|
||||
*/
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
const announcedCount =
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
const setParticipantsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('participants', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,21 +43,25 @@ export const ParticipantsToggle = ({
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||
count: announcedCount,
|
||||
})}.`}
|
||||
isSelected={isParticipantsOpen}
|
||||
ref={setParticipantsTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
</VisualOnlyTooltip>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
import { useEscapeKey } from '@/hooks/useEscapeKey'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export enum Emoji {
|
||||
@@ -36,6 +38,7 @@ export const ReactionsToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||
const instanceIdRef = useRef(0)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const room = useRoomContext()
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
@@ -101,6 +104,22 @@ export const ReactionsToggle = () => {
|
||||
}
|
||||
}, [isVisible, isRendered])
|
||||
|
||||
useEscapeKey(
|
||||
() => {
|
||||
// Mirror the trigger button behavior (Enter toggles open/close)
|
||||
triggerRef.current?.click()
|
||||
requestAnimationFrame(() => {
|
||||
triggerRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
},
|
||||
{
|
||||
isActive: isVisible,
|
||||
capture: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: true,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -113,6 +132,7 @@ export const ReactionsToggle = () => {
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
ref={triggerRef}
|
||||
onPress={() => setIsVisible(!isVisible)}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
@@ -145,7 +165,7 @@ export const ReactionsToggle = () => {
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiShapesLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const ToolsToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -13,7 +15,14 @@ export const ToolsToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
|
||||
|
||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||
const setToolsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('tools', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -28,6 +37,7 @@ export const ToolsToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isToolsOpen}
|
||||
ref={setToolsTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleTools()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -5,14 +5,48 @@ import { EffectsConfiguration } from './EffectsConfiguration'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const Effects = () => {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||
const { isEffectsOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useRestoreFocus(isEffectsOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return getTrigger('options') ?? activeEl
|
||||
}
|
||||
// For direct button clicks, use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first toggle button (blur light button)
|
||||
const firstButton = panel.querySelector<HTMLElement>(
|
||||
'[data-attr="toggle-blur-light"]'
|
||||
)
|
||||
if (firstButton) {
|
||||
firstButton.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
+47
-30
@@ -56,11 +56,11 @@ export const EffectsConfiguration = ({
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const [blurStatusMessage, setBlurStatusMessage] = useState('')
|
||||
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
)
|
||||
const blurAnnouncementId = useRef(0)
|
||||
const [effectStatusMessage, setEffectStatusMessage] = useState('')
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -89,27 +89,27 @@ export const EffectsConfiguration = ({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const announceBlurStatusMessage = (message: string) => {
|
||||
blurAnnouncementId.current += 1
|
||||
const currentId = blurAnnouncementId.current
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
// Clear the region first so screen readers drop queued announcements.
|
||||
setBlurStatusMessage('')
|
||||
setEffectStatusMessage('')
|
||||
|
||||
blurAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== blurAnnouncementId.current) return
|
||||
setBlurStatusMessage(message)
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
setEffectStatusMessage(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
@@ -118,25 +118,42 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const updateBlurStatusMessage = (
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (type !== ProcessorType.BLUR) return
|
||||
|
||||
let message = ''
|
||||
|
||||
if (wasSelectedBeforeToggle) {
|
||||
message = t('blur.status.none')
|
||||
} else if (options.blurRadius === BlurRadius.LIGHT) {
|
||||
message = t('blur.status.light')
|
||||
} else if (options.blurRadius === BlurRadius.NORMAL) {
|
||||
message = t('blur.status.strong')
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (message) {
|
||||
announceBlurStatusMessage(message)
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +217,7 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -407,7 +424,7 @@ export const EffectsConfiguration = ({
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{blurStatusMessage}
|
||||
{effectStatusMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef, ReactNode } from 'react'
|
||||
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
|
||||
|
||||
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
|
||||
participants: null,
|
||||
tools: null,
|
||||
info: null,
|
||||
admin: null,
|
||||
options: null,
|
||||
effects: null,
|
||||
cameraMenu: null,
|
||||
})
|
||||
|
||||
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
|
||||
triggersRef.current[key] = el
|
||||
}
|
||||
|
||||
const getTrigger = (key: SidePanelTriggerKey) => {
|
||||
return triggersRef.current[key] ?? null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type SidePanelTriggerKey =
|
||||
| 'participants'
|
||||
| 'tools'
|
||||
| 'info'
|
||||
| 'admin'
|
||||
| 'options'
|
||||
| 'effects'
|
||||
| 'cameraMenu'
|
||||
|
||||
export type SidePanelContextValue = {
|
||||
panelRef: React.RefObject<HTMLElement>
|
||||
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
|
||||
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
|
||||
}
|
||||
|
||||
export const SidePanelContext = createContext<SidePanelContextValue | null>(
|
||||
null
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { ref, useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
@@ -56,8 +57,13 @@ export const useSidePanel = () => {
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
const toggleInfo = (wip) => {
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
|
||||
if (!isInfoOpen) {
|
||||
layoutStore.genericRef = ref(wip)
|
||||
}
|
||||
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
@@ -71,6 +77,18 @@ export const useSidePanel = () => {
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutSnap.activePanelId) {
|
||||
console.log('$$prout closing')
|
||||
const trigger = layoutSnap?.genericRef?.current
|
||||
console.log(trigger)
|
||||
if (trigger) {
|
||||
trigger.focus({ preventScroll: true })
|
||||
trigger.setAttribute('data-restore-focus-visible', '')
|
||||
}
|
||||
}
|
||||
}, [layoutSnap.activePanelId, layoutSnap?.genericRef])
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
activeSubPanelId,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react'
|
||||
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||
|
||||
export const useSidePanelRef = () => {
|
||||
const context = useContext(SidePanelContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidePanelRef must be used within SidePanelProvider')
|
||||
}
|
||||
return context.panelRef
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext } from 'react'
|
||||
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||
|
||||
export const useSidePanelTriggers = () => {
|
||||
const context = useContext(SidePanelContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSidePanelTriggers must be used within SidePanelProvider'
|
||||
)
|
||||
}
|
||||
return {
|
||||
setTrigger: context.setTrigger,
|
||||
getTrigger: context.getTrigger,
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
@@ -188,75 +189,77 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<SidePanelProvider>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapper>
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapper>
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</SidePanelProvider>
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const getEmojiLabel = (
|
||||
emoji: string,
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
) => {
|
||||
const emojiLabels: Record<string, string> = {
|
||||
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
|
||||
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
|
||||
'clapping-hands': t('emojis.clapping-hands', {
|
||||
defaultValue: 'clapping hands',
|
||||
}),
|
||||
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
|
||||
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
|
||||
defaultValue: 'face with tears of joy',
|
||||
}),
|
||||
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
|
||||
defaultValue: 'surprised face',
|
||||
}),
|
||||
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
|
||||
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
|
||||
}
|
||||
return emojiLabels[emoji] ?? emoji
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -22,6 +23,7 @@ import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -112,6 +114,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
@@ -125,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Field, H } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||
<ul
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<li>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('accessibility.announceReactions.label')}
|
||||
isSelected={snap.announceReactions}
|
||||
onChange={(value) => {
|
||||
accessibilityStore.announceReactions = value
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessibilityTab
|
||||
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type UseEscapeKeyOptions = {
|
||||
isActive: boolean
|
||||
capture?: boolean
|
||||
preventDefault?: boolean
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
|
||||
export const useEscapeKey = (
|
||||
handler: () => void,
|
||||
{
|
||||
isActive,
|
||||
capture = false,
|
||||
preventDefault = false,
|
||||
stopPropagation = false,
|
||||
}: UseEscapeKeyOptions
|
||||
) => {
|
||||
const handleRef = useRef(handler)
|
||||
handleRef.current = handler
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (preventDefault) event.preventDefault()
|
||||
if (stopPropagation) event.stopPropagation()
|
||||
handleRef.current()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, capture)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, capture)
|
||||
}
|
||||
}, [capture, isActive, preventDefault, stopPropagation])
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type UseFocusOnOpenOptions = {
|
||||
selector: string
|
||||
delayMs?: number
|
||||
preventScroll?: boolean
|
||||
}
|
||||
|
||||
export const useFocusOnOpen = (
|
||||
isOpen: boolean,
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
{ selector, delayMs = 0, preventScroll = true }: UseFocusOnOpenOptions
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const timer = setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const first = containerRef.current?.querySelector<HTMLElement>(selector)
|
||||
first?.focus({ preventScroll })
|
||||
})
|
||||
}, delayMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [containerRef, delayMs, isOpen, preventScroll, selector])
|
||||
}
|
||||
@@ -26,6 +26,26 @@ export function useRestoreFocus(
|
||||
|
||||
const prevIsOpenRef = useRef(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Track last interaction type (like native :focus-visible behavior)
|
||||
const handleKeyDown = () => {
|
||||
lastInteractionRef.current = 'keyboard'
|
||||
}
|
||||
const handleMouseDown = () => {
|
||||
lastInteractionRef.current = 'mouse'
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevIsOpenRef.current
|
||||
@@ -41,7 +61,28 @@ export function useRestoreFocus(
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
const focus = () => trigger.focus({ preventScroll })
|
||||
const focus = () => {
|
||||
trigger.focus({ preventScroll })
|
||||
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
|
||||
if (lastInteractionRef.current === 'keyboard') {
|
||||
trigger.setAttribute('data-restore-focus-visible', '')
|
||||
// Remove focus ring only when the trigger loses focus
|
||||
const handleBlur = () => {
|
||||
if (document.contains(trigger)) {
|
||||
trigger.removeAttribute('data-restore-focus-visible')
|
||||
}
|
||||
}
|
||||
trigger.addEventListener('blur', handleBlur, { once: true })
|
||||
// Store cleanup for unmount case
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = () => {
|
||||
trigger.removeEventListener('blur', handleBlur)
|
||||
if (document.contains(trigger)) {
|
||||
trigger.removeAttribute('data-restore-focus-visible')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||
else focus()
|
||||
}
|
||||
@@ -50,6 +91,12 @@ export function useRestoreFocus(
|
||||
}
|
||||
|
||||
prevIsOpenRef.current = isOpen
|
||||
|
||||
// Cleanup: remove focus ring if component unmounts before focus changes
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
onClosed,
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
|
||||
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Gruppenanrufe ohne Einschränkungen",
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße.",
|
||||
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!",
|
||||
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Ihr Meeting ist bereit",
|
||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||
"closeDialog": "Dialogfenster schließen",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
@@ -192,7 +193,9 @@
|
||||
"leave": "Verlassen",
|
||||
"participants": {
|
||||
"open": "Alle ausblenden",
|
||||
"closed": "Alle anzeigen"
|
||||
"closed": "Alle anzeigen",
|
||||
"count_one": "{{count}} Teilnehmer",
|
||||
"count_other": "{{count}} Teilnehmer"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Weitere Tools ausblenden",
|
||||
@@ -206,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Reaktion senden",
|
||||
"send": "Reaktion {{emoji}} senden",
|
||||
"you": "Sie"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "Sie",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
"clapping-hands": "Klatschende Hände",
|
||||
"red-heart": "rotes Herz",
|
||||
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
|
||||
"face-with-open-mouth": "überraschter Gesichtsausdruck",
|
||||
"party-popper": "Party-Popper",
|
||||
"folded-hands": "gefaltete Hände"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +291,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Make the switch to simplicity. Try us now!",
|
||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
|
||||
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
|
||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Host group calls without limits",
|
||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
|
||||
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
|
||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transform your meetings with AI",
|
||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
|
||||
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
|
||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Your meeting is ready",
|
||||
"description": "Share this link with people you want to invite to the meeting.",
|
||||
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||
"closeDialog": "Close dialog",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
@@ -192,7 +193,9 @@
|
||||
"leave": "Leave",
|
||||
"participants": {
|
||||
"open": "Hide everyone",
|
||||
"closed": "See everyone"
|
||||
"closed": "See everyone",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Hide more tools",
|
||||
@@ -206,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Send reaction",
|
||||
"send": "Send reaction {{emoji}}",
|
||||
"you": "you"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "you",
|
||||
"emojis": {
|
||||
"thumbs-up": "thumbs up",
|
||||
"thumbs-down": "thumbs down",
|
||||
"clapping-hands": "clapping hands",
|
||||
"red-heart": "red heart",
|
||||
"face-with-tears-of-joy": "face with tears of joy",
|
||||
"face-with-open-mouth": "surprised face",
|
||||
"party-popper": "party popper",
|
||||
"folded-hands": "folded hands"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +291,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Backgrounds and Effects",
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Language"
|
||||
},
|
||||
"settingsButtonLabel": "Settings",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
|
||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
|
||||
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
|
||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Organisez des appels de groupe sans limite",
|
||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
|
||||
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
|
||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transformez vos réunions avec l'IA",
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
|
||||
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Votre réunion est prête",
|
||||
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
|
||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
|
||||
"closeDialog": "Fermer la fenêtre modale",
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
@@ -192,7 +193,9 @@
|
||||
"leave": "Quitter",
|
||||
"participants": {
|
||||
"open": "Masquer les participants",
|
||||
"closed": "Afficher les participants"
|
||||
"closed": "Afficher les participants",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Masquer les outils de réunion",
|
||||
@@ -206,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Envoyer une réaction",
|
||||
"send": "Envoyer la réaction {{emoji}}",
|
||||
"you": "vous"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "vous",
|
||||
"emojis": {
|
||||
"thumbs-up": "pouce levé",
|
||||
"thumbs-down": "pouce baissé",
|
||||
"clapping-hands": "applaudissements",
|
||||
"red-heart": "cœur",
|
||||
"face-with-tears-of-joy": "visage qui rit",
|
||||
"face-with-open-mouth": "visage étonné",
|
||||
"party-popper": "confettis",
|
||||
"folded-hands": "mains jointes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +291,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Panneau latéral",
|
||||
"backToTools": "Retour aux outils de réunion",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Arrière-plans et effets",
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Langue de l'application"
|
||||
},
|
||||
"settingsButtonLabel": "Paramètres",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Vocaliser les réactions"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Stap over op eenvoud. Probeer ons nu!",
|
||||
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
|
||||
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
|
||||
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Houdt groepsgesprekken zonder limieten",
|
||||
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
|
||||
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
|
||||
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transformeer uw vergaderingen met AI",
|
||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
|
||||
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
|
||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Uw vergadering is klaar",
|
||||
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||
"closeDialog": "Sluit het dialoogvenster",
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
"pinCode": "Code:"
|
||||
@@ -192,7 +193,9 @@
|
||||
"leave": "Vertrekken",
|
||||
"participants": {
|
||||
"open": "Verberg iedereen",
|
||||
"closed": "Toon iedereen"
|
||||
"closed": "Toon iedereen",
|
||||
"count_one": "{{count}} deelnemer",
|
||||
"count_other": "{{count}} deelnemers"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Meer tools verbergen",
|
||||
@@ -206,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Stuur reactie",
|
||||
"send": "Stuur reactie {{emoji}}",
|
||||
"you": "U"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "U",
|
||||
"emojis": {
|
||||
"thumbs-up": "duim omhoog",
|
||||
"thumbs-down": "duim omlaag",
|
||||
"clapping-hands": "applaudisserende handen",
|
||||
"red-heart": "rood hart",
|
||||
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
|
||||
"face-with-open-mouth": "verrast gezicht",
|
||||
"party-popper": "feestknaller",
|
||||
"folded-hands": "gevouwen handen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +291,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Zijbalk",
|
||||
"backToTools": "Terug naar vergadertools",
|
||||
"heading": {
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
|
||||
@@ -70,6 +70,9 @@ export const Popover = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
withArrow = true,
|
||||
isOpen,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...dialogProps
|
||||
}: {
|
||||
children: [
|
||||
@@ -80,10 +83,17 @@ export const Popover = ({
|
||||
]
|
||||
variant?: 'dark' | 'light'
|
||||
withArrow?: boolean
|
||||
isOpen?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
const [trigger, popoverContent] = children
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<DialogTrigger
|
||||
isOpen={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
{withArrow && (
|
||||
|
||||
@@ -112,7 +112,8 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
'&[data-selected]': {
|
||||
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
|
||||
boxShadow:
|
||||
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
backgroundColor: 'greyscale.100',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
import { deserializeToProxyMap } from '@/utils/valtio'
|
||||
|
||||
type AccessibilityState = {
|
||||
announceReactions: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: AccessibilityState = {
|
||||
announceReactions: false,
|
||||
}
|
||||
|
||||
function getAccessibilityState(): AccessibilityState {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
announceReactions:
|
||||
typeof parsed.announceReactions === 'boolean'
|
||||
? parsed.announceReactions
|
||||
: DEFAULT_STATE.announceReactions,
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy migration: if the setting was previously stored in notifications
|
||||
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
|
||||
if (legacy) {
|
||||
try {
|
||||
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
|
||||
if (typeof parsedLegacy?.announceReactions === 'boolean') {
|
||||
const migratedState: AccessibilityState = {
|
||||
...DEFAULT_STATE,
|
||||
...parsedLegacy,
|
||||
announceReactions: parsedLegacy.announceReactions,
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.ACCESSIBILITY,
|
||||
JSON.stringify(migratedState)
|
||||
)
|
||||
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
|
||||
} catch {
|
||||
// ignore persistence issues during migration
|
||||
}
|
||||
|
||||
return migratedState
|
||||
}
|
||||
} catch {
|
||||
// ignore legacy parsing issues
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_STATE
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[AccessibilityStore] Failed to parse stored settings:',
|
||||
error
|
||||
)
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const accessibilityStore = proxy<AccessibilityState>(
|
||||
getAccessibilityState()
|
||||
)
|
||||
|
||||
subscribe(accessibilityStore, () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.ACCESSIBILITY,
|
||||
JSON.stringify(accessibilityStore)
|
||||
)
|
||||
})
|
||||
@@ -10,6 +10,7 @@ type State = {
|
||||
showSubtitles: boolean
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
genericRef: HTMLElement | null
|
||||
}
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
|
||||
showSubtitles: false,
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
genericRef: null,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
// Reusable visually hidden style for screen-reader-only content
|
||||
export const srOnly = css({
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
})
|
||||
@@ -23,9 +23,20 @@ body,
|
||||
}
|
||||
|
||||
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
|
||||
[data-rac][data-restore-focus-visible]:not(label, .react-aria-Select),
|
||||
:is(a, button, input[type='text'], select, textarea):not(
|
||||
[data-rac]
|
||||
):focus-visible {
|
||||
):focus-visible,
|
||||
/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */
|
||||
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus,
|
||||
/* Show focus ring when restoring focus on react-aria buttons without overriding its focus state */
|
||||
[data-restore-focus-visible]:is(
|
||||
a,
|
||||
button,
|
||||
input[type='text'],
|
||||
select,
|
||||
textarea
|
||||
):focus {
|
||||
outline: 2px solid var(--colors-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
export const STORAGE_KEYS = {
|
||||
NOTIFICATIONS: 'app_notification_settings',
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user