diff --git a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx index 5ec008dd38..0312a71264 100644 --- a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx @@ -1,3 +1,5 @@ +import { useLingui } from '@lingui/react/macro'; + import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; @@ -27,6 +29,7 @@ import { PermissionFlagType } from '~/generated-metadata/graphql'; type NavigationBarItemName = 'main' | 'search' | 'newAiChat'; export const MobileNavigationBar = () => { + const { t } = useLingui(); const navigate = useNavigate(); const { defaultHomePagePath } = useDefaultHomePagePath(); const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); @@ -56,11 +59,13 @@ export const MobileNavigationBar = () => { const items: { name: NavigationBarItemName; + label: string; Icon: IconComponent; onClick: () => void; }[] = [ { name: 'main', + label: t`Main navigation`, Icon: IconList, onClick: () => { closeSidePanelMenu(); @@ -80,6 +85,7 @@ export const MobileNavigationBar = () => { }, { name: 'search', + label: t`Search`, Icon: IconSearch, onClick: () => { setIsNavigationDrawerExpanded(false); @@ -102,6 +108,7 @@ export const MobileNavigationBar = () => { ? [ { name: 'newAiChat' as const, + label: t`New AI chat`, Icon: IconMessageCirclePlus, onClick: () => { setIsNavigationDrawerExpanded(false); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx index 61dad56f0f..c607438a3f 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx @@ -16,10 +16,25 @@ const meta: Meta = { args: { activeItemName: 'main', items: [ - { name: 'main', Icon: IconList, onClick: () => undefined }, - { name: 'search', Icon: IconSearch, onClick: () => undefined }, - { name: 'tasks', Icon: IconCheckbox, onClick: () => undefined }, - { name: 'settings', Icon: IconSettings, onClick: () => undefined }, + { name: 'main', label: 'Main', Icon: IconList, onClick: () => undefined }, + { + name: 'search', + label: 'Search', + Icon: IconSearch, + onClick: () => undefined, + }, + { + name: 'tasks', + label: 'Tasks', + Icon: IconCheckbox, + onClick: () => undefined, + }, + { + name: 'settings', + label: 'Settings', + Icon: IconSettings, + onClick: () => undefined, + }, ], }, }; diff --git a/packages/twenty-oxlint-rules/oxlint-plugin.ts b/packages/twenty-oxlint-rules/oxlint-plugin.ts index 062c5d0477..e634f8cece 100644 --- a/packages/twenty-oxlint-rules/oxlint-plugin.ts +++ b/packages/twenty-oxlint-rules/oxlint-plugin.ts @@ -56,6 +56,10 @@ import { rule as noStateUseref, RULE_NAME as noStateUserefName, } from './rules/no-state-useref'; +import { + rule as noStorybookA11yDisable, + RULE_NAME as noStorybookA11yDisableName, +} from './rules/no-storybook-a11y-disable'; import { rule as preferWorkspaceScopedRepository, RULE_NAME as preferWorkspaceScopedRepositoryName, @@ -95,6 +99,7 @@ export default definePlugin({ [noJotaiStoreInSelectorName]: noJotaiStoreInSelector, [noNavigatePreferLinkName]: noNavigatePreferLink, [noStateUserefName]: noStateUseref, + [noStorybookA11yDisableName]: noStorybookA11yDisable, [preferWorkspaceScopedRepositoryName]: preferWorkspaceScopedRepository, [restApiMethodsShouldBeGuardedName]: restApiMethodsShouldBeGuarded, [sortCssPropertiesAlphabeticallyName]: sortCssPropertiesAlphabetically, diff --git a/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.spec.ts b/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.spec.ts new file mode 100644 index 0000000000..51b9cb8627 --- /dev/null +++ b/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.spec.ts @@ -0,0 +1,43 @@ +import { RuleTester } from 'oxlint/plugins-dev'; + +import { rule, RULE_NAME } from './no-storybook-a11y-disable'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: "const meta = { parameters: { a11y: { test: 'error' } } };", + filename: 'Component.stories.tsx', + }, + { + code: 'const meta = { parameters: { a11y: A11Y_DEFER_COLOR_CONTRAST } };', + filename: 'Component.stories.tsx', + }, + { + code: "const config = { test: 'todo' };", + filename: 'Component.stories.tsx', + }, + { + code: "const meta = { args: { a11y: { test: 'off' } } };", + filename: 'Component.stories.tsx', + }, + ], + invalid: [ + { + code: "const meta = { parameters: { a11y: { test: 'todo' } } };", + errors: [{ messageId: 'noA11yDisable' }], + filename: 'Component.stories.tsx', + }, + { + code: "const meta = { parameters: { a11y: { test: 'off' } } };", + errors: [{ messageId: 'noA11yDisable' }], + filename: 'Component.stories.tsx', + }, + { + code: "export const Default = { parameters: { a11y: { test: 'off' } } };", + errors: [{ messageId: 'noA11yDisable' }], + filename: 'Component.stories.ts', + }, + ], +}); diff --git a/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.ts b/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.ts new file mode 100644 index 0000000000..968663215a --- /dev/null +++ b/packages/twenty-oxlint-rules/rules/no-storybook-a11y-disable.ts @@ -0,0 +1,74 @@ +import { defineRule } from '@oxlint/plugins'; + +export const RULE_NAME = 'no-storybook-a11y-disable'; + +const DISABLING_TEST_VALUES = ['off', 'todo']; + +const getPropertyKeyName = (node: any): string | undefined => { + if (node.type !== 'Property') { + return undefined; + } + + if (node.key.type === 'Identifier') { + return node.key.name; + } + + if (node.key.type === 'Literal' && typeof node.key.value === 'string') { + return node.key.value; + } + + return undefined; +}; + +const isDisablingTestProperty = (node: any): boolean => + node.type === 'Property' && + getPropertyKeyName(node) === 'test' && + node.value.type === 'Literal' && + DISABLING_TEST_VALUES.includes(node.value.value); + +export const rule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + "Disallow disabling the Storybook accessibility (axe) gate with a11y: { test: 'off' | 'todo' }.", + }, + messages: { + noA11yDisable: + "Do not disable the accessibility gate with test: 'off' or test: 'todo'. Fix the axe violations, or defer only color-contrast via A11Y_DEFER_COLOR_CONTRAST.", + }, + schema: [], + }, + create: (context) => ({ + Property: (node: any) => { + if (getPropertyKeyName(node) !== 'parameters') { + return; + } + + if (node.value.type !== 'ObjectExpression') { + return; + } + + const a11yProperty = node.value.properties.find( + (property: any) => + getPropertyKeyName(property) === 'a11y' && + property.value.type === 'ObjectExpression', + ); + + if (a11yProperty === undefined) { + return; + } + + const disablingProperty = a11yProperty.value.properties.find( + isDisablingTestProperty, + ); + + if (disablingProperty !== undefined) { + context.report({ + node: disablingProperty, + messageId: 'noA11yDisable', + }); + } + }, + }), +}); diff --git a/packages/twenty-ui/.oxlintrc.json b/packages/twenty-ui/.oxlintrc.json index 4fc53020dc..8bcfc4d061 100644 --- a/packages/twenty-ui/.oxlintrc.json +++ b/packages/twenty-ui/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "import", "unicorn"], + "plugins": ["react", "typescript", "import", "unicorn", "jsx-a11y"], "jsPlugins": ["../twenty-oxlint-rules/dist/oxlint-plugin.mjs"], "categories": { "correctness": "off" @@ -72,6 +72,32 @@ } ] } - ] + ], + "twenty/no-storybook-a11y-disable": "error", + + "jsx-a11y/alt-text": "error", + "jsx-a11y/anchor-has-content": "error", + "jsx-a11y/anchor-is-valid": "error", + "jsx-a11y/aria-activedescendant-has-tabindex": "error", + "jsx-a11y/aria-props": "error", + "jsx-a11y/aria-proptypes": "error", + "jsx-a11y/aria-role": "error", + "jsx-a11y/aria-unsupported-elements": "error", + "jsx-a11y/heading-has-content": "error", + "jsx-a11y/iframe-has-title": "error", + "jsx-a11y/img-redundant-alt": "error", + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/no-access-key": "error", + "jsx-a11y/no-aria-hidden-on-focusable": "error", + "jsx-a11y/no-noninteractive-tabindex": "error", + "jsx-a11y/no-redundant-roles": "error", + "jsx-a11y/role-has-required-aria-props": "error", + "jsx-a11y/role-supports-aria-props": "error", + "jsx-a11y/tabindex-no-positive": "error", + "jsx-a11y/click-events-have-key-events": "error", + "jsx-a11y/mouse-events-have-key-events": "error", + + "jsx-a11y/no-static-element-interactions": "warn", + "jsx-a11y/no-autofocus": "warn" } } diff --git a/packages/twenty-ui/src/accessibility/utils/handleClickableElementKeyDown.ts b/packages/twenty-ui/src/accessibility/utils/handleClickableElementKeyDown.ts new file mode 100644 index 0000000000..5dfbf8f4c8 --- /dev/null +++ b/packages/twenty-ui/src/accessibility/utils/handleClickableElementKeyDown.ts @@ -0,0 +1,10 @@ +import { type KeyboardEvent } from 'react'; + +export const handleClickableElementKeyDown = ( + event: KeyboardEvent, +) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.currentTarget.click(); + } +}; diff --git a/packages/twenty-ui/src/data-display/Avatar/Avatar.tsx b/packages/twenty-ui/src/data-display/Avatar/Avatar.tsx index 8163ba60a5..944e03beb7 100644 --- a/packages/twenty-ui/src/data-display/Avatar/Avatar.tsx +++ b/packages/twenty-ui/src/data-display/Avatar/Avatar.tsx @@ -3,6 +3,7 @@ import { clsx } from 'clsx'; import { useAtom } from 'jotai'; import { useContext } from 'react'; +import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown'; import { invalidAvatarUrlsAtomV2 } from '@ui/data-display/Avatar/states/invalidAvatarUrlsAtomV2'; import { type AvatarSize } from '@ui/data-display/Avatar/types/AvatarSize'; import { type AvatarType } from '@ui/data-display/Avatar/types/AvatarType'; @@ -123,7 +124,17 @@ export const Avatar = ({ className={clsx(styles.root, styles[size], className)} data-type={type ?? undefined} data-clickable={!isUndefined(onClick) ? true : undefined} + role={!isUndefined(onClick) ? 'button' : undefined} + tabIndex={!isUndefined(onClick) ? 0 : undefined} + aria-label={ + !isUndefined(onClick) + ? isNonEmptyString(placeholder) + ? placeholder + : 'Avatar' + : undefined + } onClick={onClick} + onKeyDown={handleClickableElementKeyDown} style={avatarStyle} > {Icon ? ( diff --git a/packages/twenty-ui/src/data-display/AvatarOrIcon/AvatarOrIcon.tsx b/packages/twenty-ui/src/data-display/AvatarOrIcon/AvatarOrIcon.tsx index 1c597edda2..c7fdd14e8e 100644 --- a/packages/twenty-ui/src/data-display/AvatarOrIcon/AvatarOrIcon.tsx +++ b/packages/twenty-ui/src/data-display/AvatarOrIcon/AvatarOrIcon.tsx @@ -1,3 +1,6 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown'; import { Avatar } from '@ui/data-display/Avatar/Avatar'; import { type AvatarType } from '@ui/data-display/Avatar/types/AvatarType'; import { type IconComponent } from '@ui/icon/types/IconComponent'; @@ -47,13 +50,20 @@ export const AvatarOrIcon = ({ } const isClickable = isDefined(onClick); + const accessibleLabel = isNonEmptyString(placeholder) + ? placeholder + : 'Avatar'; if (isIconInverted || isDefined(IconBackgroundColor)) { return (
@@ -79,12 +90,17 @@ export const AvatarOrIcon = ({
); diff --git a/packages/twenty-ui/src/data-display/AvatarOrIcon/__stories__/AvatarOrIcon.stories.tsx b/packages/twenty-ui/src/data-display/AvatarOrIcon/__stories__/AvatarOrIcon.stories.tsx index 82ae5d92de..cc98012282 100644 --- a/packages/twenty-ui/src/data-display/AvatarOrIcon/__stories__/AvatarOrIcon.stories.tsx +++ b/packages/twenty-ui/src/data-display/AvatarOrIcon/__stories__/AvatarOrIcon.stories.tsx @@ -63,6 +63,7 @@ export const ClickableIcon: Story = { args: { Icon: IconBuildingSkyscraper, isIconInverted: true, + placeholder: 'Company', onClick: () => alert('Icon AvatarOrIcon clicked'), }, }; diff --git a/packages/twenty-ui/src/data-display/Pill/__stories__/Pill.stories.ts b/packages/twenty-ui/src/data-display/Pill/__stories__/Pill.stories.ts index a7e518dca7..cac5bd9e0e 100644 --- a/packages/twenty-ui/src/data-display/Pill/__stories__/Pill.stories.ts +++ b/packages/twenty-ui/src/data-display/Pill/__stories__/Pill.stories.ts @@ -1,6 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; import { Pill } from '@ui/data-display/Pill/Pill'; +import { A11Y_DEFER_COLOR_CONTRAST } from '@ui/testing'; import { ComponentDecorator } from '../../../testing/decorators/ComponentDecorator'; const meta: Meta = { @@ -16,6 +17,5 @@ export default meta; type Story = StoryObj; export const Default: Story = { - // TODO(a11y): violations inherited from deprecated story; fix during a11y pass - parameters: { a11y: { test: 'todo' } }, + parameters: { a11y: A11Y_DEFER_COLOR_CONTRAST }, }; diff --git a/packages/twenty-ui/src/data-display/Status/Status.tsx b/packages/twenty-ui/src/data-display/Status/Status.tsx index 7790cd04e8..899e746630 100644 --- a/packages/twenty-ui/src/data-display/Status/Status.tsx +++ b/packages/twenty-ui/src/data-display/Status/Status.tsx @@ -1,9 +1,11 @@ import { clsx } from 'clsx'; +import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown'; import { Loader } from '@ui/feedback/Loader/Loader'; import { type ThemeColor } from '@ui/theme'; import { themeCssVariables } from '@ui/theme-constants'; import { parseThemeColor } from '@ui/utilities'; +import { isDefined } from '@ui/utilities/utils/isDefined'; import styles from './Status.module.scss'; @@ -30,6 +32,8 @@ export const Status = ({

+ const isInteractive = isDefined(onClick); + + const tagContent = ( + <> {isDefined(Icon) ? (
- +
) : ( <> @@ -82,6 +71,40 @@ export const Tag = ({ )} + + ); + + const sharedStyle = { + '--tag-background': tagBackground, + '--tag-text': tagText, + } as React.CSSProperties; + + const sharedClassName = clsx( + styles.tag, + weight === 'medium' && styles.weightMedium, + variant === 'outline' && styles.variantOutline, + variant === 'border' && styles.variantBorder, + preventShrink && styles.preventShrink, + preventPadding && styles.preventPadding, + className, + ); + + if (isInteractive) { + return ( + + ); + } + + 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