♻️(frontend) introduce an Icon primitive

Encapsulate icon and symbol rendering in a dedicated component that applies
aria-hidden and disables translation attributes.

This prevents browsers from translating icon names and breaking the UI, and
ensures screen readers do not announce decorative icons.

This is a first draft and can be extended with additional variants later.
This commit is contained in:
lebaudantoine
2026-01-08 12:23:25 +01:00
committed by aleb_the_flash
parent d7f1b7b94c
commit c47e830b40
12 changed files with 105 additions and 66 deletions

View File

@@ -1,3 +1,5 @@
import '@fontsource/material-icons-outlined'
import '@fontsource-variable/material-symbols-outlined'
import '@livekit/components-styles'
import '@/styles/index.css'
import { Suspense } from 'react'

View File

@@ -1,7 +1,7 @@
import { css, cx } from '@/styled-system/css'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { Button, Icon, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -141,31 +141,23 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
</RACButton>
)}
<Button

View File

@@ -1,4 +1,4 @@
import { H, Text } from '@/primitives'
import { H, Text, Icon } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols" aria-hidden={true}>
login
</span>
<Icon type="symbols" name="login" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>

View File

@@ -1,4 +1,5 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <span className="material-symbols">speech_to_text</span>
return <Icon type="symbols" name="speech_to_text" />
}
return <span className="material-symbols">screen_record</span>
return <Icon type="symbols" name="screen_record" />
}

View File

@@ -1,4 +1,4 @@
import { Button, H, Text } from '@/primitives'
import { Button, Icon, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols">person_raised_hand</span>
<Icon type="symbols" name="person_raised_hand" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>

View File

@@ -1,5 +1,6 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -45,7 +46,7 @@ export const RowWrapper = ({
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<span className="material-icons">{iconName}</span>
<Icon name={iconName} />
</div>
<div
className={css({

View File

@@ -1,4 +1,4 @@
import { A, Div, Text } from '@/primitives'
import { A, Div, Icon, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
@@ -87,7 +87,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<span className="material-symbols">chevron_forward</span>
<Icon type="symbols" name="chevron_forward" />
</div>
</RACButton>
)
@@ -163,7 +163,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<span className="material-symbols">speech_to_text</span>}
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -171,7 +171,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<span className="material-symbols">mode_standby</span>}
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}

View File

@@ -2,6 +2,7 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -106,7 +107,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<span className="material-symbols">speech_to_text</span>
<Icon type="symbols" name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>

View File

@@ -0,0 +1,71 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { ComponentPropsWithoutRef } from 'react'
const iconRecipe = cva({
base: {
fontWeight: 'normal',
fontStyle: 'normal',
display: 'inline-block',
lineHeight: 1,
textTransform: 'none',
letterSpacing: 'normal',
wordWrap: 'normal',
whiteSpace: 'nowrap',
direction: 'ltr',
},
variants: {
type: {
icons: {
fontFamily: 'Material Icons Outlined',
webkitFontSmoothing: 'antialiased',
mozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontFeatureSettings: '"liga"',
},
symbols: {
fontFamily: 'Material Symbols Outlined Variable',
},
},
size: {
sm: {
fontSize: '18px',
},
md: {
fontSize: '24px',
},
lg: {
fontSize: '32px',
},
xl: {
fontSize: '40px',
},
},
},
defaultVariants: {
type: 'icons',
size: 'md',
},
})
export type IconRecipeProps = RecipeVariantProps<typeof iconRecipe>
export type IconProps = IconRecipeProps &
ComponentPropsWithoutRef<'span'> & {
name: string
}
export const Icon = ({ name, ...props }: IconProps) => {
const [variantProps, componentProps] = iconRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<span
translate="no"
aria-hidden="true"
className={[iconRecipe(variantProps), className].join(' ')}
{...remainingComponentProps}
>
{name}
</span>
)
}

View File

@@ -30,3 +30,4 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
export { Icon } from './Icon'

View File

@@ -1,27 +0,0 @@
@import '@fontsource/material-icons-outlined';
.material-icons,
.material-symbols {
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.material-icons {
font-family: 'Material Icons Outlined';
}
.material-symbols {
font-family: 'Material Symbols Outlined Variable';
}

View File

@@ -1,5 +1,4 @@
@import './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities;
html,
body,