Accessibility guardrails and component hardening for twenty-ui (#21848)

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 `<button>` when
clickable; the non-semantic clickable `div`s (`Avatar`, `Status`,
`ColorSchemeCard`, `NavigationBarItem`, etc.) are now keyboard-operable
via a shared `handleClickableElementKeyDown` helper, role and accessible
name.

## Notes for reviewers
- Two `oxlint-disable` lines remain on genuine non-interactive capture
wrappers (`CodeEditor`, `OverflowingTextWithTooltip`).
- 8 lint warnings remain by design: conditional-interactivity
`no-static-element-interactions` and legitimate `autoFocus` on
`SearchInput`.
- `NavigationBarItem` gained a required `ariaLabel`; its only consumer
(`MobileNavigationBar`) is updated with translated labels.

## Follow-ups (separate PRs)
- Enforced accessible names on icon-only buttons
(`IconButton`/`LightIconButton`) — breaking, ~128 call sites.
- `aria-activedescendant` wiring for the dropdown/listbox keyboard
layer.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21848?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-19 15:57:23 +02:00
committed by GitHub
parent 3473410d64
commit 1f6c2b89fd
50 changed files with 407 additions and 51 deletions
@@ -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);
@@ -16,10 +16,25 @@ const meta: Meta<typeof NavigationBar> = {
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,
},
],
},
};
@@ -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,
@@ -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',
},
],
});
@@ -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',
});
}
},
}),
});
+28 -2
View File
@@ -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"
}
}
@@ -0,0 +1,10 @@
import { type KeyboardEvent } from 'react';
export const handleClickableElementKeyDown = (
event: KeyboardEvent<HTMLElement>,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.currentTarget.click();
}
};
@@ -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 ? (
@@ -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 (
<div
className={styles.wrapper}
data-clickable={isClickable || undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
aria-label={isClickable ? accessibleLabel : undefined}
onClick={onClick}
onKeyDown={handleClickableElementKeyDown}
>
<div
className={styles.iconWithBackgroundContainer}
@@ -69,6 +79,7 @@ export const AvatarOrIcon = ({
color={theme.font.color.inverted}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
aria-hidden
/>
</div>
</div>
@@ -79,12 +90,17 @@ export const AvatarOrIcon = ({
<div
className={styles.wrapper}
data-clickable={isClickable || undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
aria-label={isClickable ? accessibleLabel : undefined}
onClick={onClick}
onKeyDown={handleClickableElementKeyDown}
>
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={IconColor || 'currentColor'}
aria-hidden
/>
</div>
);
@@ -63,6 +63,7 @@ export const ClickableIcon: Story = {
args: {
Icon: IconBuildingSkyscraper,
isIconInverted: true,
placeholder: 'Company',
onClick: () => alert('Icon AvatarOrIcon clicked'),
},
};
@@ -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<typeof Pill> = {
@@ -16,6 +17,5 @@ export default meta;
type Story = StoryObj<typeof Pill>;
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 },
};
@@ -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 = ({
<h3
className={clsx(styles.status, styles[weight], className)}
onClick={onClick}
tabIndex={isDefined(onClick) ? 0 : undefined}
onKeyDown={handleClickableElementKeyDown}
data-loader-visible={isLoaderVisible || undefined}
style={
{
@@ -19,6 +19,13 @@
min-width: none;
}
.interactive {
appearance: none;
cursor: pointer;
font-family: inherit;
text-align: inherit;
}
.weightMedium {
font-weight: var(--t-font-weight-medium);
}
@@ -1,5 +1,6 @@
declare const classNames: {
readonly tag: 'tag';
readonly interactive: 'interactive';
readonly weightMedium: 'weightMedium';
readonly variantOutline: 'variantOutline';
readonly variantBorder: 'variantBorder';
+43 -20
View File
@@ -49,28 +49,17 @@ export const Tag = ({
: (themeCssVariables.tag.text[color] ??
themeCssVariables.font.color.secondary);
return (
<span
className={clsx(
styles.tag,
weight === 'medium' && styles.weightMedium,
variant === 'outline' && styles.variantOutline,
variant === 'border' && styles.variantBorder,
preventShrink && styles.preventShrink,
preventPadding && styles.preventPadding,
className,
)}
onClick={onClick}
style={
{
'--tag-background': tagBackground,
'--tag-text': tagText,
} as React.CSSProperties
}
>
const isInteractive = isDefined(onClick);
const tagContent = (
<>
{isDefined(Icon) ? (
<div className={styles.iconContainer}>
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
aria-hidden
/>
</div>
) : (
<></>
@@ -82,6 +71,40 @@ export const Tag = ({
<OverflowingTextWithTooltip text={text} />
</span>
)}
</>
);
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 (
<button
type="button"
className={clsx(sharedClassName, styles.interactive)}
onClick={onClick}
style={sharedStyle}
>
{tagContent}
</button>
);
}
return (
<span className={sharedClassName} style={sharedStyle}>
{tagContent}
</span>
);
};
@@ -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}
/>
);
};
@@ -6,6 +6,7 @@ export type IconComponentProps = {
size?: number | string;
stroke?: number | string;
color?: string;
'aria-hidden'?: boolean;
};
export type IconComponent = FunctionComponent<IconComponentProps>;
@@ -37,6 +37,8 @@
outline: none;
}
@include focus-ring;
&[data-disabled] {
cursor: not-allowed;
}
@@ -397,7 +397,7 @@ export const AnimatedButton = ({
animate={animate}
transition={transition}
>
<Icon size={theme.icon.size.sm} />
<Icon size={theme.icon.size.sm} aria-hidden />
</motion.div>
)}
{animatedSvg && (
@@ -27,6 +27,8 @@
outline: none;
}
@include focus-ring;
&:active {
background: var(--t-background-transparent-medium);
}
@@ -63,6 +63,7 @@ export const AnimatedLightIconButton = ({
{Icon && (
<Icon
size={size === 'medium' ? theme.icon.size.md : theme.icon.size.sm}
aria-hidden={!!ariaLabel}
/>
)}
</motion.div>
@@ -62,6 +62,8 @@ $gray-scale-light-gray1: color(display-p3 1 1 1);
outline: none;
}
@include focus-ring;
&[data-disabled] {
cursor: not-allowed;
}
@@ -23,7 +23,7 @@ export const ButtonIcon = ({
)}
{Icon && (
<div className={styles.icon} data-loading={isLoading || undefined}>
<Icon size={theme.icon.size.sm} />
<Icon size={theme.icon.size.sm} aria-hidden />
</div>
)}
</div>
@@ -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<boolean>(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 = ({
>
<span className={styles.box}>
<CheckboxPrimitive.Indicator className={styles.indicator}>
{indeterminate ? <IconMinus /> : <IconCheck />}
{indeterminate ? (
<IconMinus aria-hidden />
) : (
<IconCheck aria-hidden />
)}
</CheckboxPrimitive.Indicator>
</span>
</CheckboxPrimitive.Root>
@@ -172,6 +172,7 @@ export const CodeEditor = ({
<Loader />
</div>
) : (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions
<div className={styles.container} onKeyDown={handleKeyDown}>
<input
type="hidden"
@@ -3,10 +3,12 @@ import React from 'react';
import { clsx } from 'clsx';
import { type AnimationControls } from 'framer-motion';
import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown';
import { Checkmark } from '@ui/data-display/Checkmark/Checkmark';
import { type ColorScheme } from '@ui/input/types/ColorScheme';
import { GRAY_SCALE_DARK } from '@ui/theme/constants/GrayScaleDark';
import { GRAY_SCALE_LIGHT } from '@ui/theme/constants/GrayScaleLight';
import { isDefined } from '@ui/utilities/utils/isDefined';
import styles from './ColorSchemeCard.module.scss';
@@ -40,7 +42,11 @@ const ColorSchemeSegment = ({
...style,
} as React.CSSProperties
}
role={isDefined(onClick) ? 'button' : undefined}
tabIndex={isDefined(onClick) ? 0 : undefined}
aria-label={isDefined(onClick) ? variant : undefined}
onClick={onClick}
onKeyDown={handleClickableElementKeyDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@@ -62,7 +68,14 @@ export const ColorSchemeCard = ({
if (variant === 'System') {
return (
<div className={styles.container}>
<div className={styles.mixedColorSchemeSegment} onClick={onClick}>
<div
className={styles.mixedColorSchemeSegment}
role={isDefined(onClick) ? 'button' : undefined}
tabIndex={isDefined(onClick) ? 0 : undefined}
aria-label={isDefined(onClick) ? variant : undefined}
onClick={onClick}
onKeyDown={handleClickableElementKeyDown}
>
<ColorSchemeSegment
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
variant="Light"
@@ -40,6 +40,8 @@
outline: none;
}
@include focus-ring;
&[data-disabled] {
cursor: not-allowed;
}
@@ -57,7 +57,7 @@ export const FloatingButton = ({
data-focus={(focus && !disabled) || undefined}
to={to}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{Icon && <Icon size={theme.icon.size.sm} aria-hidden />}
{title}
</ButtonComponent>
);
@@ -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] {
@@ -55,7 +55,7 @@ export const FloatingIconButton = ({
data-is-active={isActive || undefined}
onClick={onClick}
>
{Icon && <Icon size={theme.icon.size.md} />}
{Icon && <Icon size={theme.icon.size.md} aria-hidden={!!ariaLabel} />}
</button>
);
};
@@ -44,6 +44,8 @@
outline: none;
}
@include focus-ring;
&[data-disabled] {
cursor: not-allowed;
}
@@ -58,7 +58,7 @@ export const IconButton = ({
// to the DOM as an inert attribute. Keep forwarding it for DOM parity.
{...{ to }}
>
{Icon && <Icon size={theme.icon.size.md} />}
{Icon && <Icon size={theme.icon.size.md} aria-hidden={!!ariaLabel} />}
</button>
);
};
@@ -31,7 +31,7 @@ export const InsideButton = ({
disabled={disabled}
data-disabled={disabled || undefined}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{Icon && <Icon size={theme.icon.size.sm} aria-hidden={!!ariaLabel} />}
</button>
);
};
@@ -34,6 +34,8 @@
outline: none;
}
@include focus-ring;
&:active {
background: var(
--light-button-active-bg,
@@ -44,7 +44,7 @@ export const LightButton = ({
data-disabled={disabled || undefined}
data-focus={(focus && !disabled) || undefined}
>
{!!Icon && <Icon size={theme.icon.size.md} />}
{!!Icon && <Icon size={theme.icon.size.md} aria-hidden />}
{title}
</button>
);
@@ -34,6 +34,8 @@
outline: none;
}
@include focus-ring;
&:active {
background: var(
--light-icon-button-active-bg,
@@ -53,6 +53,7 @@ export const LightIconButton = ({
{Icon && (
<Icon
size={size === 'medium' ? theme.icon.size.md : theme.icon.size.sm}
aria-hidden={!!ariaLabel}
/>
)}
</button>
@@ -31,6 +31,8 @@
&[data-disabled] {
cursor: not-allowed;
}
@include focus-ring;
}
.button[data-variant='primary'] {
@@ -51,7 +51,7 @@ export const MainButton = ({
: undefined
}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{Icon && <Icon size={theme.icon.size.sm} aria-hidden />}
{title}
</button>
);
@@ -22,6 +22,8 @@
color: var(--t-font-color-tertiary);
cursor: default;
}
@include focus-ring;
}
.small {
@@ -30,7 +30,7 @@ export const RoundedIconButton = ({
aria-label={ariaLabel}
onClick={onClick}
>
<Icon size={theme.icon.size.md} />
<Icon size={theme.icon.size.md} aria-hidden={!!ariaLabel} />
</button>
);
};
@@ -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 = <IconButton Icon={IconFilter} variant="secondary" />;
const filterButton = (
<IconButton
Icon={IconFilter}
variant="secondary"
ariaLabel={filterButtonAriaLabel}
/>
);
return (
<div className={clsx(styles.wrapper, className)}>
@@ -38,10 +54,12 @@ export const SearchInput = ({
<div
className={styles.iconContainer}
data-focused={isFocused || undefined}
aria-hidden
>
<IconSearch size={theme.icon.size.md} />
</div>
<Input
id={inputId}
className={styles.input}
value={value}
onValueChange={(newValue) => onChange(newValue)}
@@ -50,6 +68,8 @@ export const SearchInput = ({
placeholder={placeholder}
autoFocus={autoFocus}
disabled={disabled}
aria-label={ariaLabelledby ? undefined : (ariaLabel ?? placeholder)}
aria-labelledby={ariaLabelledby}
/>
</div>
{filterDropdown && filterDropdown(filterButton)}
@@ -39,10 +39,14 @@ export const TabContent = ({
return (
<StyledTabHover contentSize={contentSize} className={className}>
{LeftIcon && <LeftIcon color={iconColor} size={theme.icon.size.md} />}
{LeftIcon && (
<LeftIcon color={iconColor} size={theme.icon.size.md} aria-hidden />
)}
{logo && <Avatar avatarUrl={logo} size="md" placeholder={title} />}
{title}
{RightIcon && <RightIcon color={iconColor} size={theme.icon.size.md} />}
{RightIcon && (
<RightIcon color={iconColor} size={theme.icon.size.md} aria-hidden />
)}
{pill && (typeof pill === 'string' ? <Pill label={pill} /> : pill)}
</StyledTabHover>
);
@@ -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}
</span>
@@ -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 = ({
<li
className={clsx(styles.suggestionMenuItem, className)}
data-selected={selected || undefined}
role={isDefined(onClick) ? 'button' : undefined}
tabIndex={isDefined(onClick) ? 0 : undefined}
onClick={handleMenuItemClick}
onKeyDown={handleClickableElementKeyDown}
>
<StyledMenuItemLeftContent>
<MenuItemLeftContent
@@ -6,7 +6,12 @@ import styles from './NavigationBar.module.scss';
type NavigationBarProps = {
activeItemName: string;
items: { name: string; Icon: IconComponent; onClick: () => void }[];
items: {
name: string;
label: string;
Icon: IconComponent;
onClick: () => void;
}[];
};
export const NavigationBar = ({
@@ -15,12 +20,13 @@ export const NavigationBar = ({
}: NavigationBarProps) => {
return (
<div className={styles.container}>
{items.map(({ Icon, name, onClick }) => (
{items.map(({ Icon, name, label, onClick }) => (
<NavigationBarItem
key={name}
Icon={Icon}
isActive={activeItemName === name}
onClick={onClick}
ariaLabel={label}
/>
))}
</div>
@@ -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: () => {},
},
],
},
};
@@ -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;
}
@@ -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 (
<div
<button
type="button"
className={styles.iconButton}
data-active={isActive ? '' : undefined}
aria-label={ariaLabel}
aria-pressed={isActive}
onClick={onClick}
>
<Icon
color={themeCssVariables.grayScale.gray10}
size={theme.icon.size.lg}
aria-hidden
/>
</div>
</button>
);
};
@@ -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
<div onClick={handleTooltipClick}>
<AppTooltip
anchorSelect={`#${textElementId}`}