diff --git a/.eslintrc b/.eslintrc index 103ffb8fa6..18ebd9749e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,7 +18,8 @@ "selector": "TSTypeReference>TSQualifiedName[left.name='React'][right.name='FC']", "message": "Don't use React.FC" } - ] + ], + "jsx-a11y/no-autofocus": 0 }, "overrides": [ { diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx index 302f8e73ee..986f51f781 100644 --- a/src/components/DropdownMenu/DropdownMenu.tsx +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Ellipsis} from '@gravity-ui/icons'; +import {useActionHandlers} from '../../hooks/useActionHandlers'; import {Button} from '../Button'; import type {ButtonProps} from '../Button'; import {Icon} from '../Icon'; @@ -27,6 +28,11 @@ import {toItemList} from './utils/toItemList'; import './DropdownMenu.scss'; +type SwitcherProps = { + onKeyDown: React.KeyboardEventHandler; + onClick: React.MouseEventHandler; +}; + export type DropdownMenuProps = { /** * Array of items. @@ -56,8 +62,13 @@ export type DropdownMenuProps = { disabled?: boolean; /** * Menu toggle control. + * @deprecated Use renderSwitcher instead */ switcher?: React.ReactNode; + /** + * Menu toggle control. + */ + renderSwitcher?: (props: SwitcherProps) => React.ReactNode; switcherWrapperClassName?: string; /** * Overrides the default switcher button props. @@ -94,6 +105,7 @@ const DropdownMenu = ({ data, disabled, switcher, + renderSwitcher, switcherWrapperClassName, defaultSwitcherProps, defaultSwitcherClassName, @@ -126,7 +138,7 @@ const DropdownMenu = ({ [items], ); - const handleSwitcherClick: React.MouseEventHandler = (event) => { + const handleSwitcherClick: React.MouseEventHandler = (event) => { if (disabled) { return; } @@ -135,26 +147,34 @@ const DropdownMenu = ({ togglePopup(); }; + const {onKeyDown: handleSwitcherKeyDown} = useActionHandlers(handleSwitcherClick); + return ( - {/*as this div has Button component as child, clicking on it one fires onClick of this div on bubbling*/} + {/* FIXME remove switcher prop and this wrapper */} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
- {switcher || ( - - )} + {renderSwitcher?.({ + onClick: handleSwitcherClick, + onKeyDown: handleSwitcherKeyDown, + }) || + switcher || ( + + )}
## Custom menu toggle -The menu toggle is configured with the `switcher` prop. It can be any React component (or any `React.ReactNode` in the TypeScript terms). By default, the menu toggle is a button with the ellipsis icon (**⋯**). +The menu toggle is configured with the `renderSwitcher` prop. It can be any function that returns a React component (or any `(props: SwitcherProps) => React.ReactNode` in the TypeScript terms, see [`SwitcherProps`](#switcherprops) below). By default, the menu toggle is a button with the ellipsis icon (**⋯**). ```jsx John Doe} + renderSwitcher={(props) => ( +
+ John Doe +
+ )} items={[ { action: () => console.log('Rename'), @@ -401,18 +401,18 @@ The example above is oversimplified to demonstrate the idea of the customizable Custom icons can be added to a `DropdownMenu` item by assigning the `iconStart` or `iconEnd` property. By default, `DropdownMenu` items go without icons. -The menu toggle icon can be changed with the `DropdownMenu`'s `switcher` prop. By default, the menu toggle is a button with the ellipsis icon (**⋯**). +The menu toggle icon can be changed with the `DropdownMenu`'s `renderSwitcher` prop. By default, the menu toggle is a button with the ellipsis icon (**⋯**). ```jsx + renderSwitcher={(props) => ( + - } + )} items={[ { iconStart: , @@ -508,7 +508,7 @@ LANDING_BLOCK--> | `icon` | Icon of the default `switcher`. | `React.ReactNode` | Ellipsis icon | | `size` | Applied both to the default `switcher` and the menu. | `'s' \| 'm' \| 'l' \| 'xl'` | `'m'` | | `disabled` | Setting this prop to `true` disables the `switcher` button and prevents the menu from opening. | `boolean` | | -| `switcher` | Menu toggle control. | `React.ReactNode` | | +| `renderSwitcher` | Render function for the menu toggle control. | `React.ReactNode` | | | `switcherWrapperClassName` | Value for the `className` prop of the `switcher`'s parent component. | `string` | | | `defaultSwitcherProps` | Default `switcher` props. | `ButtonProps` | | | `defaultSwitcherClassName` | Value for the `className` prop of the default `switcher`. | `string` | | @@ -542,3 +542,10 @@ This type describes individual dropdown menu items. | `popupProps` | Submenu popup props. | `string` | | | `path` | Path of indexes from the root to the current item. | `number[]` | | | `closeMenu` | Custom `closeMenu` callback. It can be called instead of closing the main menu and used to close submenus before the main menu. | `() => void` | | + +### SwitcherProps + +| Name | Description | Type | +| :---------- | :------------------------------------------------------------- | :----------: | +| `onClick` | Called when the switcher is clicked. | `() => void` | +| `onKeyDown` | Called when the switcher is focused and action key is pressed. | `() => void` | diff --git a/src/components/Label/Label.scss b/src/components/Label/Label.scss index 0c25875634..7b392be790 100644 --- a/src/components/Label/Label.scss +++ b/src/components/Label/Label.scss @@ -4,48 +4,14 @@ $block: '.#{variables.$ns}label'; $disabled: #{$block}_disabled; $transitionDuration: 0.15s; $transitionTimingFunction: ease-in-out; - -@mixin themeState($bgColor, $bgHoverColor, $textColor, $borderColor: none) { - color: #{$textColor}; - background-color: #{$bgColor}; - - @if $borderColor != none { - --border-size: 1px; - border: var(--border-size) solid #{$borderColor}; - } - - // hover on interactive label (excluding hover on addon) - &:not(#{$disabled})#{$block}_is-interactive:hover:not( - :has(#{$block}__addon_interactive:hover) - ) { - background-color: #{$bgHoverColor}; - } - - //fallback for old browsers - @supports not selector(:has(*)) { - &:not(#{$disabled})#{$block}_is-interactive:hover { - background-color: #{$bgHoverColor}; - } - } - - // hover on action button - &:not(#{$disabled}) #{$block}__addon_interactive { - --yc-button-background-color-hover: #{$bgHoverColor}; - - &:hover, - &:focus, - &:active { - color: #{$textColor}; - } - } -} +$hover-opacity: 0.7; @mixin sizeState($margin, $mainSize, $rAddon, $lAddon, $borderRadius) { - height: #{$mainSize}; - border-radius: #{$borderRadius}; + height: $mainSize; + border-radius: $borderRadius; & #{$block}__text { - line-height: #{$mainSize}; - margin: 0 #{$margin}; + line-height: $mainSize; + margin: 0 $margin; } & #{$block}__addon { @@ -55,11 +21,11 @@ $transitionTimingFunction: ease-in-out; } &#{$block}_has-right-addon #{$block}__text { - margin-right: #{$rAddon}; + margin-right: $rAddon; } &#{$block}_has-left-addon #{$block}__text { - margin-left: #{$lAddon}; + margin-left: $lAddon; } } @@ -87,7 +53,7 @@ $transitionTimingFunction: ease-in-out; &__value { display: flex; - opacity: 0.7; + opacity: $hover-opacity; } &__separator { @@ -140,70 +106,86 @@ $transitionTimingFunction: ease-in-out; } &_disabled { - opacity: 0.7; + opacity: $hover-opacity; pointer-events: none; } &_is-interactive { cursor: pointer; + outline: none; + } + + &_is-interactive:focus-visible { + outline: 2px solid var(--g-color-line-focus); } + --_-bg-color: none; + --_-bg-hover-color: none; + --_-text-color: none; + &_theme { &_normal { - @include themeState( - var(--g-color-base-misc-light), - var(--g-color-base-misc-light-hover), - var(--g-color-text-misc-heavy) - ); + --_-bg-color: var(--g-color-base-misc-light); + --_-bg-hover-color: var(--g-color-base-misc-light-hover); + --_-text-color: var(--g-color-text-misc-heavy); } &_success { - @include themeState( - var(--g-color-base-positive-light), - var(--g-color-base-positive-light-hover), - var(--g-color-text-positive-heavy) - ); + --_-bg-color: var(--g-color-base-positive-light); + --_-bg-hover-color: var(--g-color-base-positive-light-hover); + --_-text-color: var(--g-color-text-positive-heavy); } &_info { - @include themeState( - var(--g-color-base-info-light), - var(--g-color-base-info-light-hover), - var(--g-color-text-info-heavy) - ); + --_-bg-color: var(--g-color-base-info-light); + --_-bg-hover-color: var(--g-color-base-info-light-hover); + --_-text-color: var(--g-color-text-info-heavy); } &_warning { - @include themeState( - var(--g-color-base-warning-light), - var(--g-color-base-warning-light-hover), - var(--g-color-text-warning-heavy) - ); + --_-bg-color: var(--g-color-base-warning-light); + --_-bg-hover-color: var(--g-color-base-warning-light-hover); + --_-text-color: var(--g-color-text-warning-heavy); } &_danger { - @include themeState( - var(--g-color-base-danger-light), - var(--g-color-base-danger-light-hover), - var(--g-color-text-danger-heavy) - ); + --_-bg-color: var(--g-color-base-danger-light); + --_-bg-hover-color: var(--g-color-base-danger-light-hover); + --_-text-color: var(--g-color-text-danger-heavy); } &_unknown { - @include themeState( - var(--g-color-base-neutral-light), - var(--g-color-base-neutral-light-hover), - var(--g-color-text-complementary) - ); + --_-bg-color: var(--g-color-base-neutral-light); + --_-bg-hover-color: var(--g-color-base-neutral-light-hover); + --_-text-color: var(--g-color-text-complementary); } &_clear { - @include themeState( - transparent, - var(--g-color-base-simple-hover-solid), - var(--g-color-text-complementary), - var(--g-color-line-generic) - ); + --_-bg-color: transparent; + --_-bg-hover-color: var(--g-color-base-simple-hover-solid); + --_-text-color: var(--g-color-text-complementary); + + --border-size: 1px; + border: var(--border-size) solid var(--g-color-line-generic); + } + } + + color: var(--_-text-color); + background-color: var(--_-bg-color); + + // hover on interactive label (excluding hover on addon) + &_is-interactive:hover:not(:has(#{$block}__addon_interactive:hover)) { + background-color: var(--_-bg-hover-color); + } + + // hover on action button + &:not(#{$disabled}) #{$block}__addon_interactive { + --yc-button-background-color-hover: var(--_-bg-hover-color); + + &:hover, + &:focus, + &:active { + color: var(--_-text-color); } } } diff --git a/src/components/Label/Label.tsx b/src/components/Label/Label.tsx index d245460f25..08c5289380 100644 --- a/src/components/Label/Label.tsx +++ b/src/components/Label/Label.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Xmark} from '@gravity-ui/icons'; +import {useActionHandlers} from '../../hooks'; import {Button} from '../Button'; import type {ButtonProps, ButtonSize} from '../Button'; import {ClipboardIcon} from '../ClipboardIcon'; @@ -86,6 +87,8 @@ export const Label = React.forwardRef(function Label onClick, } = props; + const actionButtonRef = React.useRef(null); + const hasContent = Boolean(children !== '' && React.Children.count(children) > 0); const typeClose = type === 'close' && hasContent; @@ -93,7 +96,7 @@ export const Label = React.forwardRef(function Label const hasOnClick = Boolean(onClick); const hasCopy = Boolean(typeCopy && copyText); - const isInteractive = hasOnClick || hasCopy || interactive; + const isInteractive = (hasOnClick || hasCopy || interactive) && !disabled; const {copyIconSize, closeIconSize, buttonSize} = sizeMap[size]; const leftIcon = icon && ( @@ -122,12 +125,25 @@ export const Label = React.forwardRef(function Label } }; + const handleClick = (event: React.MouseEvent) => { + /** + * Triggered only if the handler was triggered on the element itself, and not on the actionButton + * It is necessary that keyboard navigation works correctly + */ + if (!actionButtonRef.current?.contains(event.target as Node)) { + onClick?.(event); + } + }; + + const {onKeyDown} = useActionHandlers(handleClick); + const renderLabel = (status?: CopyToClipboardStatus) => { let actionButton: React.ReactNode; if (typeCopy) { actionButton = ( - )} + {renderSwitcher?.({onClick: handleControlClick, onKeyDown: handleControlKeyDown}) || + switcher || ( + + )} ( }, [imgUrl]); return ( + // FIXME OnClick deprecated, will be deleted // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
(functio } return ( + // It is used to focus the control input if non-interaction element is provided. // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
{children} diff --git a/src/components/controls/TextInput/TextInputControl.tsx b/src/components/controls/TextInput/TextInputControl.tsx index 76943c1003..be14634183 100644 --- a/src/components/controls/TextInput/TextInputControl.tsx +++ b/src/components/controls/TextInput/TextInputControl.tsx @@ -44,8 +44,6 @@ export function TextInputControl(props: Props) { placeholder={placeholder} value={value} defaultValue={defaultValue} - // TextInput provides this functionality for its user. False by default - // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={autoFocus} autoComplete={autoComplete} onChange={onChange} diff --git a/src/hooks/useOutsideClick/__tests__/Demo.tsx b/src/hooks/useOutsideClick/__tests__/Demo.tsx index 6c09acf949..7f7f013071 100644 --- a/src/hooks/useOutsideClick/__tests__/Demo.tsx +++ b/src/hooks/useOutsideClick/__tests__/Demo.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ import React from 'react'; import {useOutsideClick} from '../useOutsideClick'; @@ -23,9 +22,9 @@ export const Demo = () => { return (

{status}

-
+
+
{'Outside'}
);