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:
@@ -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);
|
||||
|
||||
+19
-4
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
+8
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user