From 1f6c2b89fdcbf5820ea3abca9402b81281aa6e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:57:23 +0200 Subject: [PATCH] Accessibility guardrails and component hardening for twenty-ui (#21848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on twenty-ui's existing runtime axe gate by adding a static enforcement layer and fixing accessibility gaps in shared components. Color contrast is intentionally out of scope (still deferred via `A11Y_DEFER_COLOR_CONTRAST`). ## What changed - **Static guardrails:** enabled oxlint's `jsx-a11y` plugin (keyboard-operability rules at `error`), and added a custom `twenty/no-storybook-a11y-disable` rule that blocks `a11y: { test: 'off' | 'todo' }` so the axe gate can't be silently disabled again. - **Focus visibility:** wired the existing `focus-ring` mixin into all buttons for real `:focus-visible` rings (was `outline: none`). - **Decorative icons:** `aria-hidden` on icons inside labeled buttons (added to `IconComponentProps` + render sites). - **Inputs:** accessible-name support on `SearchInput` and `Checkbox`. - **Interactive components:** `Tag` renders a real ` + ); + } + + return ( + + {tagContent} ); }; diff --git a/packages/twenty-ui/src/icon/components/Icon.tsx b/packages/twenty-ui/src/icon/components/Icon.tsx index a4869faca7..7507fe2b44 100644 --- a/packages/twenty-ui/src/icon/components/Icon.tsx +++ b/packages/twenty-ui/src/icon/components/Icon.tsx @@ -12,6 +12,7 @@ export const Icon = ({ size, stroke, color, + 'aria-hidden': ariaHidden, }: IconProps) => { const { getIcon } = useIcons(); @@ -24,6 +25,7 @@ export const Icon = ({ size={size} stroke={stroke} color={color} + aria-hidden={ariaHidden} /> ); }; diff --git a/packages/twenty-ui/src/icon/types/IconComponent.ts b/packages/twenty-ui/src/icon/types/IconComponent.ts index 1e38c862b0..e706cbbc67 100644 --- a/packages/twenty-ui/src/icon/types/IconComponent.ts +++ b/packages/twenty-ui/src/icon/types/IconComponent.ts @@ -6,6 +6,7 @@ export type IconComponentProps = { size?: number | string; stroke?: number | string; color?: string; + 'aria-hidden'?: boolean; }; export type IconComponent = FunctionComponent; diff --git a/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.module.scss b/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.module.scss index 4f4d52abd2..2769c8d47e 100644 --- a/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.module.scss +++ b/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.module.scss @@ -37,6 +37,8 @@ outline: none; } + @include focus-ring; + &[data-disabled] { cursor: not-allowed; } diff --git a/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.tsx b/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.tsx index 2653c7a2e1..ba7df8d57f 100644 --- a/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.tsx +++ b/packages/twenty-ui/src/input/AnimatedButton/AnimatedButton.tsx @@ -397,7 +397,7 @@ export const AnimatedButton = ({ animate={animate} transition={transition} > - + )} {animatedSvg && ( diff --git a/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.module.scss b/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.module.scss index d2e16f65d7..16d06cfb6e 100644 --- a/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.module.scss +++ b/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.module.scss @@ -27,6 +27,8 @@ outline: none; } + @include focus-ring; + &:active { background: var(--t-background-transparent-medium); } diff --git a/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.tsx b/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.tsx index ec590c47c5..22fdcde6ac 100644 --- a/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.tsx +++ b/packages/twenty-ui/src/input/AnimatedLightIconButton/AnimatedLightIconButton.tsx @@ -63,6 +63,7 @@ export const AnimatedLightIconButton = ({ {Icon && ( )} diff --git a/packages/twenty-ui/src/input/Button/Button.module.scss b/packages/twenty-ui/src/input/Button/Button.module.scss index aced1cab6d..2414608894 100644 --- a/packages/twenty-ui/src/input/Button/Button.module.scss +++ b/packages/twenty-ui/src/input/Button/Button.module.scss @@ -62,6 +62,8 @@ $gray-scale-light-gray1: color(display-p3 1 1 1); outline: none; } + @include focus-ring; + &[data-disabled] { cursor: not-allowed; } diff --git a/packages/twenty-ui/src/input/Button/internal/ButtonIcon.tsx b/packages/twenty-ui/src/input/Button/internal/ButtonIcon.tsx index e5fdc5f09a..f5a815dd3a 100644 --- a/packages/twenty-ui/src/input/Button/internal/ButtonIcon.tsx +++ b/packages/twenty-ui/src/input/Button/internal/ButtonIcon.tsx @@ -23,7 +23,7 @@ export const ButtonIcon = ({ )} {Icon && (
- +
)} diff --git a/packages/twenty-ui/src/input/Checkbox/Checkbox.tsx b/packages/twenty-ui/src/input/Checkbox/Checkbox.tsx index 95e8c9646f..a605459926 100644 --- a/packages/twenty-ui/src/input/Checkbox/Checkbox.tsx +++ b/packages/twenty-ui/src/input/Checkbox/Checkbox.tsx @@ -39,7 +39,9 @@ type CheckboxProps = { className?: string; disabled?: boolean; accent?: CheckboxAccent; + id?: string; 'aria-label'?: string; + 'aria-labelledby'?: string; }; export const Checkbox = ({ @@ -54,7 +56,9 @@ export const Checkbox = ({ className, disabled = false, accent = CheckboxAccent.Blue, + id, 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, }: CheckboxProps) => { const [isInternalChecked, setIsInternalChecked] = React.useState(false); @@ -68,8 +72,10 @@ export const Checkbox = ({ checked={isInternalChecked} indeterminate={indeterminate} disabled={disabled} + id={id} name="styled-checkbox" aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} data-testid="input-checkbox" onCheckedChange={(value, eventDetails) => { onChange?.( @@ -90,7 +96,11 @@ export const Checkbox = ({ > - {indeterminate ? : } + {indeterminate ? ( + + ) : ( + + )} diff --git a/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx b/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx index 2c2fa84101..1011d08656 100644 --- a/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx +++ b/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx @@ -172,6 +172,7 @@ export const CodeEditor = ({ ) : ( + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
@@ -62,7 +68,14 @@ export const ColorSchemeCard = ({ if (variant === 'System') { return (
-
+
- {Icon && } + {Icon && } {title} ); diff --git a/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.module.scss b/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.module.scss index db0c12e6e3..46968ff258 100644 --- a/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.module.scss +++ b/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.module.scss @@ -83,6 +83,10 @@ outline: none; } +.button { + @include focus-ring; +} + // data-focus is gated by !disabled in the component, like the legacy // call-site passing focus={focus && !disabled}. .button[data-focus] { diff --git a/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.tsx b/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.tsx index 4fdc082d07..9d73057f1d 100644 --- a/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.tsx +++ b/packages/twenty-ui/src/input/FloatingIconButton/FloatingIconButton.tsx @@ -55,7 +55,7 @@ export const FloatingIconButton = ({ data-is-active={isActive || undefined} onClick={onClick} > - {Icon && } + {Icon && } ); }; diff --git a/packages/twenty-ui/src/input/IconButton/IconButton.module.scss b/packages/twenty-ui/src/input/IconButton/IconButton.module.scss index 8fab7c1f26..bfd60e2194 100644 --- a/packages/twenty-ui/src/input/IconButton/IconButton.module.scss +++ b/packages/twenty-ui/src/input/IconButton/IconButton.module.scss @@ -44,6 +44,8 @@ outline: none; } + @include focus-ring; + &[data-disabled] { cursor: not-allowed; } diff --git a/packages/twenty-ui/src/input/IconButton/IconButton.tsx b/packages/twenty-ui/src/input/IconButton/IconButton.tsx index a0d3005799..d52ebfc542 100644 --- a/packages/twenty-ui/src/input/IconButton/IconButton.tsx +++ b/packages/twenty-ui/src/input/IconButton/IconButton.tsx @@ -58,7 +58,7 @@ export const IconButton = ({ // to the DOM as an inert attribute. Keep forwarding it for DOM parity. {...{ to }} > - {Icon && } + {Icon && } ); }; diff --git a/packages/twenty-ui/src/input/InsideButton/InsideButton.tsx b/packages/twenty-ui/src/input/InsideButton/InsideButton.tsx index 1c2595debf..3e34c86743 100644 --- a/packages/twenty-ui/src/input/InsideButton/InsideButton.tsx +++ b/packages/twenty-ui/src/input/InsideButton/InsideButton.tsx @@ -31,7 +31,7 @@ export const InsideButton = ({ disabled={disabled} data-disabled={disabled || undefined} > - {Icon && } + {Icon && } ); }; diff --git a/packages/twenty-ui/src/input/LightButton/LightButton.module.scss b/packages/twenty-ui/src/input/LightButton/LightButton.module.scss index 603cb741bb..a7d347acdd 100644 --- a/packages/twenty-ui/src/input/LightButton/LightButton.module.scss +++ b/packages/twenty-ui/src/input/LightButton/LightButton.module.scss @@ -34,6 +34,8 @@ outline: none; } + @include focus-ring; + &:active { background: var( --light-button-active-bg, diff --git a/packages/twenty-ui/src/input/LightButton/LightButton.tsx b/packages/twenty-ui/src/input/LightButton/LightButton.tsx index 54b67a3fd5..b087312a86 100644 --- a/packages/twenty-ui/src/input/LightButton/LightButton.tsx +++ b/packages/twenty-ui/src/input/LightButton/LightButton.tsx @@ -44,7 +44,7 @@ export const LightButton = ({ data-disabled={disabled || undefined} data-focus={(focus && !disabled) || undefined} > - {!!Icon && } + {!!Icon && } {title} ); diff --git a/packages/twenty-ui/src/input/LightIconButton/LightIconButton.module.scss b/packages/twenty-ui/src/input/LightIconButton/LightIconButton.module.scss index b2c4b7c963..ceac7dc44f 100644 --- a/packages/twenty-ui/src/input/LightIconButton/LightIconButton.module.scss +++ b/packages/twenty-ui/src/input/LightIconButton/LightIconButton.module.scss @@ -34,6 +34,8 @@ outline: none; } + @include focus-ring; + &:active { background: var( --light-icon-button-active-bg, diff --git a/packages/twenty-ui/src/input/LightIconButton/LightIconButton.tsx b/packages/twenty-ui/src/input/LightIconButton/LightIconButton.tsx index 02c793d045..72895b9842 100644 --- a/packages/twenty-ui/src/input/LightIconButton/LightIconButton.tsx +++ b/packages/twenty-ui/src/input/LightIconButton/LightIconButton.tsx @@ -53,6 +53,7 @@ export const LightIconButton = ({ {Icon && ( )} diff --git a/packages/twenty-ui/src/input/MainButton/MainButton.module.scss b/packages/twenty-ui/src/input/MainButton/MainButton.module.scss index d81831a339..3d722a0fe6 100644 --- a/packages/twenty-ui/src/input/MainButton/MainButton.module.scss +++ b/packages/twenty-ui/src/input/MainButton/MainButton.module.scss @@ -31,6 +31,8 @@ &[data-disabled] { cursor: not-allowed; } + + @include focus-ring; } .button[data-variant='primary'] { diff --git a/packages/twenty-ui/src/input/MainButton/MainButton.tsx b/packages/twenty-ui/src/input/MainButton/MainButton.tsx index f564e97112..95deb913a0 100644 --- a/packages/twenty-ui/src/input/MainButton/MainButton.tsx +++ b/packages/twenty-ui/src/input/MainButton/MainButton.tsx @@ -51,7 +51,7 @@ export const MainButton = ({ : undefined } > - {Icon && } + {Icon && } {title} ); diff --git a/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.module.scss b/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.module.scss index d238375e8a..84fe8297d8 100644 --- a/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.module.scss +++ b/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.module.scss @@ -22,6 +22,8 @@ color: var(--t-font-color-tertiary); cursor: default; } + + @include focus-ring; } .small { diff --git a/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.tsx b/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.tsx index 14263a7401..7dbec2e706 100644 --- a/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.tsx +++ b/packages/twenty-ui/src/input/RoundedIconButton/RoundedIconButton.tsx @@ -30,7 +30,7 @@ export const RoundedIconButton = ({ aria-label={ariaLabel} onClick={onClick} > - + ); }; diff --git a/packages/twenty-ui/src/input/SearchInput/SearchInput.tsx b/packages/twenty-ui/src/input/SearchInput/SearchInput.tsx index 951b8aa11d..098ca227c2 100644 --- a/packages/twenty-ui/src/input/SearchInput/SearchInput.tsx +++ b/packages/twenty-ui/src/input/SearchInput/SearchInput.tsx @@ -1,6 +1,6 @@ import { Input } from '@base-ui/react/input'; import { clsx } from 'clsx'; -import { type ReactNode, useContext, useState } from 'react'; +import { type ReactNode, useContext, useId, useState } from 'react'; import { IconFilter, IconSearch } from '@ui/icon'; import { IconButton } from '@ui/input/IconButton/IconButton'; @@ -16,6 +16,10 @@ export type SearchInputProps = { autoFocus?: boolean; disabled?: boolean; className?: string; + id?: string; + filterButtonAriaLabel?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; }; export const SearchInput = ({ @@ -26,11 +30,23 @@ export const SearchInput = ({ autoFocus, disabled, className, + id, + filterButtonAriaLabel = 'Filter', + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, }: SearchInputProps) => { const { theme } = useContext(ThemeContext); const [isFocused, setIsFocused] = useState(false); + const generatedId = useId(); + const inputId = id ?? generatedId; - const filterButton = ; + const filterButton = ( + + ); return (
@@ -38,10 +54,12 @@ export const SearchInput = ({
onChange(newValue)} @@ -50,6 +68,8 @@ export const SearchInput = ({ placeholder={placeholder} autoFocus={autoFocus} disabled={disabled} + aria-label={ariaLabelledby ? undefined : (ariaLabel ?? placeholder)} + aria-labelledby={ariaLabelledby} />
{filterDropdown && filterDropdown(filterButton)} diff --git a/packages/twenty-ui/src/input/TabButton/parts/TabContent.tsx b/packages/twenty-ui/src/input/TabButton/parts/TabContent.tsx index 699510e52f..758c2a8cb4 100644 --- a/packages/twenty-ui/src/input/TabButton/parts/TabContent.tsx +++ b/packages/twenty-ui/src/input/TabButton/parts/TabContent.tsx @@ -39,10 +39,14 @@ export const TabContent = ({ return ( - {LeftIcon && } + {LeftIcon && ( + + )} {logo && } {title} - {RightIcon && } + {RightIcon && ( + + )} {pill && (typeof pill === 'string' ? : pill)} ); diff --git a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx index 8c2e7f6bc8..3e92d6484a 100644 --- a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx @@ -1,6 +1,8 @@ +import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown'; import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow'; import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting'; import { clsx } from 'clsx'; +import { isDefined } from '@ui/utilities/utils/isDefined'; import styles from './JsonNodeValue.module.scss'; @@ -13,6 +15,8 @@ export const JsonNodeValue = ({ }) => { const { onNodeValueClick } = useJsonTreeContextOrThrow(); + const isInteractive = isDefined(onNodeValueClick); + const handleClick = () => { onNodeValueClick?.(valueAsString); }; @@ -24,7 +28,10 @@ export const JsonNodeValue = ({ highlighting === 'blue' && styles.blue, highlighting === 'red' && styles.red, )} - onClick={handleClick} + role={isInteractive ? 'button' : undefined} + tabIndex={isInteractive ? 0 : undefined} + onClick={isInteractive ? handleClick : undefined} + onKeyDown={handleClickableElementKeyDown} > {valueAsString} diff --git a/packages/twenty-ui/src/navigation/MenuItemSuggestion/MenuItemSuggestion.tsx b/packages/twenty-ui/src/navigation/MenuItemSuggestion/MenuItemSuggestion.tsx index d27258f87c..b700350acd 100644 --- a/packages/twenty-ui/src/navigation/MenuItemSuggestion/MenuItemSuggestion.tsx +++ b/packages/twenty-ui/src/navigation/MenuItemSuggestion/MenuItemSuggestion.tsx @@ -1,9 +1,11 @@ import { clsx } from 'clsx'; import { type MouseEvent } from 'react'; +import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown'; import { type IconComponent } from '@ui/icon'; import { MenuItemLeftContent } from '@ui/navigation/MenuItem/parts/MenuItemLeftContent'; import { StyledMenuItemLeftContent } from '@ui/navigation/MenuItem/parts/StyledMenuItemBase'; +import { isDefined } from '@ui/utilities/utils/isDefined'; import styles from './MenuItemSuggestion.module.scss'; @@ -40,7 +42,10 @@ export const MenuItemSuggestion = ({
  • void }[]; + items: { + name: string; + label: string; + Icon: IconComponent; + onClick: () => void; + }[]; }; export const NavigationBar = ({ @@ -15,12 +20,13 @@ export const NavigationBar = ({ }: NavigationBarProps) => { return (
    - {items.map(({ Icon, name, onClick }) => ( + {items.map(({ Icon, name, label, onClick }) => ( ))}
    diff --git a/packages/twenty-ui/src/navigation/NavigationBar/__stories__/NavigationBar.stories.tsx b/packages/twenty-ui/src/navigation/NavigationBar/__stories__/NavigationBar.stories.tsx index 5a27da03b7..5bd03bee8e 100644 --- a/packages/twenty-ui/src/navigation/NavigationBar/__stories__/NavigationBar.stories.tsx +++ b/packages/twenty-ui/src/navigation/NavigationBar/__stories__/NavigationBar.stories.tsx @@ -17,9 +17,14 @@ export const Default: Story = { args: { activeItemName: 'Home', items: [ - { name: 'Home', Icon: IconHome, onClick: () => {} }, - { name: 'Search', Icon: IconSearch, onClick: () => {} }, - { name: 'Settings', Icon: IconSettings, onClick: () => {} }, + { name: 'Home', label: 'Home', Icon: IconHome, onClick: () => {} }, + { name: 'Search', label: 'Search', Icon: IconSearch, onClick: () => {} }, + { + name: 'Settings', + label: 'Settings', + Icon: IconSettings, + onClick: () => {}, + }, ], }, }; diff --git a/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.module.scss b/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.module.scss index 86af7e1dfa..69fd450c30 100644 --- a/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.module.scss +++ b/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.module.scss @@ -1,10 +1,16 @@ .iconButton { align-items: center; + appearance: none; + background: none; + border: none; border-radius: var(--t-spacing-1); + color: inherit; cursor: pointer; display: flex; + font: inherit; height: var(--t-spacing-10); justify-content: center; + padding: 0; transition: background-color duration(fast) ease; width: var(--t-spacing-10); @@ -15,4 +21,6 @@ &:hover { background-color: var(--t-background-transparent-light); } + + @include focus-ring; } diff --git a/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.tsx b/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.tsx index 9aa0be43c2..047d9ba214 100644 --- a/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.tsx +++ b/packages/twenty-ui/src/navigation/NavigationBarItem/NavigationBarItem.tsx @@ -9,25 +9,31 @@ type NavigationBarItemProps = { Icon: IconComponent; isActive: boolean; onClick: () => void; + ariaLabel: string; }; export const NavigationBarItem = ({ Icon, isActive, onClick, + ariaLabel, }: NavigationBarItemProps) => { const { theme } = useContext(ThemeContext); return ( -
    -
    + ); }; diff --git a/packages/twenty-ui/src/surfaces/OverflowingTextWithTooltip/OverflowingTextWithTooltip.tsx b/packages/twenty-ui/src/surfaces/OverflowingTextWithTooltip/OverflowingTextWithTooltip.tsx index e995613f2d..136ae9707b 100644 --- a/packages/twenty-ui/src/surfaces/OverflowingTextWithTooltip/OverflowingTextWithTooltip.tsx +++ b/packages/twenty-ui/src/surfaces/OverflowingTextWithTooltip/OverflowingTextWithTooltip.tsx @@ -113,6 +113,7 @@ export const OverflowingTextWithTooltip = ({ (isTitleOverflowing || alwaysShowTooltip) && isDefined(tooltipText) && createPortal( + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events