Przeglądaj źródła

SONAR-19296 Add SearchSelectDropdown component to ui lib

tags/10.1.0.73491
stanislavh 1 rok temu
rodzic
commit
073183e7f1

+ 2
- 0
server/sonar-web/design-system/package.json Wyświetl plik

@@ -25,6 +25,7 @@
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",
"@types/react": "17.0.39",
"@types/react-highlight-words": "0.16.4",
"@typescript-eslint/parser": "5.57.0",
"@vitejs/plugin-react": "3.1.0",
"autoprefixer": "10.4.14",
@@ -59,6 +60,7 @@
"react-day-picker": "8.6.0",
"react-dom": "17.0.2",
"react-helmet-async": "1.3.0",
"react-highlight-words": "0.20.0",
"react-intl": "6.2.5",
"react-router-dom": "6.10.0",
"react-select": "5.7.2",

+ 2
- 9
server/sonar-web/design-system/src/components/DeferredSpinner.tsx Wyświetl plik

@@ -20,10 +20,9 @@
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import tw, { theme } from 'twin.macro';
import tw from 'twin.macro';
import { translate } from '../helpers/l10n';
import { themeColor } from '../helpers/theme';
import { InputSearchWrapper } from './InputSearch';

interface Props {
children?: React.ReactNode;
@@ -105,7 +104,7 @@ const spinAnimation = keyframes`
}
`;

const Spinner = styled.div`
export const Spinner = styled.div`
border: 2px solid transparent;
background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box,
linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box;
@@ -118,12 +117,6 @@ const Spinner = styled.div`
${tw`sw-inline-block`};
${tw`sw-box-border`};
${tw`sw-rounded-pill`}

${InputSearchWrapper} & {
top: calc((2.25rem - ${theme('spacing.4')}) / 2);
${tw`sw-left-3`};
${tw`sw-absolute`};
}
`;

Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' };

+ 31
- 21
server/sonar-web/design-system/src/components/InputSearch.tsx Wyświetl plik

@@ -21,14 +21,14 @@
import styled from '@emotion/styled';
import classNames from 'classnames';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react';
import tw, { theme } from 'twin.macro';
import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants';
import { Key } from '../helpers/keyboard';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { isDefined } from '../helpers/types';
import { InputSizeKeys } from '../types/theme';
import { DeferredSpinner } from './DeferredSpinner';
import { DeferredSpinner, Spinner } from './DeferredSpinner';
import { InteractiveIcon } from './InteractiveIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SearchIcon } from './icons/SearchIcon';
@@ -47,8 +47,8 @@ interface Props {
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
placeholder: string;
searchInputAriaLabel: string;
placeholder?: string;
searchInputAriaLabel?: string;
size?: InputSizeKeys;
tooShortText?: string;
value?: string;
@@ -75,7 +75,8 @@ export function InputSearch({
tooShortText,
searchInputAriaLabel,
clearIconAriaLabel,
}: Props) {
children,
}: PropsWithChildren<Props>) {
const input = useRef<null | HTMLElement>(null);
const [value, setValue] = useState(parentValue ?? '');
const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
@@ -139,22 +140,24 @@ export function InputSearch({
title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
>
<StyledInputWrapper className="sw-flex sw-items-center">
<input
aria-label={searchInputAriaLabel}
autoComplete="off"
className={inputClassName}
maxLength={maxLength}
onBlur={onBlur}
onChange={handleInputChange}
onFocus={onFocus}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
ref={ref}
role="searchbox"
type="search"
value={value}
/>
<DeferredSpinner loading={loading !== undefined ? loading : false}>
{children ?? (
<input
aria-label={searchInputAriaLabel}
autoComplete="off"
className={inputClassName}
maxLength={maxLength}
onBlur={onBlur}
onChange={handleInputChange}
onFocus={onFocus}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
ref={ref}
role="searchbox"
type="search"
value={value}
/>
)}
<DeferredSpinner className="sw-z-normal" loading={loading ?? false}>
<StyledSearchIcon />
</DeferredSpinner>
{value && (
@@ -184,6 +187,12 @@ export const InputSearchWrapper = styled.div`
${tw`sw-whitespace-nowrap`}
${tw`sw-align-middle`}
${tw`sw-h-control`}

${Spinner} {
top: calc((2.25rem - ${theme('spacing.4')}) / 2);
${tw`sw-left-3`};
${tw`sw-absolute`};
}
`;

export const StyledInputWrapper = styled.div`
@@ -229,6 +238,7 @@ const StyledSearchIcon = styled(SearchIcon)`

${tw`sw-left-3`}
${tw`sw-absolute`}
${tw`sw-z-normal`}
`;

export const StyledInteractiveIcon = styled(InteractiveIcon)`

+ 4
- 3
server/sonar-web/design-system/src/components/InputSelect.tsx Wyświetl plik

@@ -31,6 +31,7 @@ import ReactSelect, {
import { INPUT_SIZES } from '../helpers';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { InputSizeKeys } from '../types/theme';
import { SearchHighlighter } from './SearchHighlighter';
import { ChevronDownIcon } from './icons';

export interface LabelValueSelectOption<V> {
@@ -43,14 +44,14 @@ interface StyleExtensionProps {
size?: InputSizeKeys;
}

type SelectProps<
export type SelectProps<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;

function IconOption<
export function IconOption<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
@@ -64,7 +65,7 @@ function IconOption<
<components.Option {...props}>
<div className="sw-flex sw-items-center sw-gap-1">
{Icon}
{label}
<SearchHighlighter>{label}</SearchHighlighter>
</div>
</components.Option>
);

+ 58
- 0
server/sonar-web/design-system/src/components/SearchHighlighter.tsx Wyświetl plik

@@ -0,0 +1,58 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import { deburr } from 'lodash';
import { createContext, useContext } from 'react';
import Highlighter from 'react-highlight-words';
import { themeColor, themeContrast } from '../helpers/theme';

export const SearchHighlighterContext = createContext<string | undefined>(undefined);
SearchHighlighterContext.displayName = 'SearchHighlighterContext';

interface Props {
children?: string;
term?: string;
}

export function SearchHighlighter({ children = '', term }: Props) {
const query = useContext(SearchHighlighterContext);

const searchTerm = term ?? query;
if (searchTerm) {
return (
<StyledHighlighter
autoEscape={true}
sanitize={deburr}
searchWords={[searchTerm]}
textToHighlight={children}
/>
);
}
return <>{children}</>;
}

const StyledHighlighter = styled(Highlighter)`
mark {
color: ${themeContrast('searchHighlight')};
font-weight: inherit;
background: ${themeColor('searchHighlight')};
}
`;

+ 126
- 0
server/sonar-web/design-system/src/components/SearchSelect.tsx Wyświetl plik

@@ -0,0 +1,126 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { omit } from 'lodash';
import React, { RefObject } from 'react';
import { GroupBase, InputProps, components } from 'react-select';
import AsyncSelect, { AsyncProps } from 'react-select/async';
import Select from 'react-select/dist/declarations/src/Select';
import { INPUT_SIZES } from '../helpers';
import { Key } from '../helpers/keyboard';
import { translate } from '../helpers/l10n';
import { InputSearch } from './InputSearch';
import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';

type SearchSelectProps<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = SelectProps<V, Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;

export function SearchSelect<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
size = 'full',
selectRef,
...props
}: SearchSelectProps<V, Option, IsMulti, Group> & {
selectRef?: RefObject<Select<Option, IsMulti, Group>>;
}) {
const styles = selectStyle<V, Option, IsMulti, Group>({ size });
return (
<AsyncSelect<Option, IsMulti, Group>
{...omit(props, 'className', 'large')}
className={classNames('react-select', props.className)}
classNamePrefix="react-select"
classNames={{
control: ({ isDisabled }) =>
classNames(
'sw-border-0 sw-rounded-2 sw-outline-none sw-shadow-none',
isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
),
indicatorsContainer: () => 'sw-hidden',
input: () => `sw-flex sw-w-full sw-p-0 sw-m-0`,
valueContainer: () => `sw-px-3 sw-pb-1 sw-mb-1 sw-pt-4`,
placeholder: () => 'sw-hidden',
...props.classNames,
}}
components={{
Input: SearchSelectInput,
...props.components,
}}
ref={selectRef}
styles={{
...styles,
menu: (base, props) => ({
...styles.menu?.(base, props),
width: `calc(${INPUT_SIZES[size]} - 2px)`,
}),
}}
/>
);
}

export function SearchSelectInput<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: InputProps<Option, IsMulti, Group>) {
const {
selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
} = props;

const onChange = (v: string, prevValue = '') => {
props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;

if (event.key === Key.Escape && target.value !== '') {
event.stopPropagation();
onChange('');
}
};

return (
<InputSearch
clearIconAriaLabel={clearIconLabel ?? translate('clear')}
loading={isLoading && inputValue.length >= (minLength ?? 0)}
minLength={minLength}
onChange={onChange}
size="full"
tooShortText={tooShortText}
value={inputValue}
>
<components.Input
{...props}
onKeyDown={handleKeyDown}
placeholder={placeholder as string}
style={{}}
/>
</InputSearch>
);
}

+ 179
- 0
server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx Wyświetl plik

@@ -0,0 +1,179 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
import { debounce } from 'lodash';
import React from 'react';
import {
ActionMeta,
GroupBase,
InputActionMeta,
OnChangeValue,
OptionsOrGroups,
} from 'react-select';
import { AsyncProps } from 'react-select/async';
import Select from 'react-select/dist/declarations/src/Select';
import tw from 'twin.macro';
import { DEBOUNCE_DELAY, themeBorder } from '../helpers';
import { DropdownToggler } from './DropdownToggler';
import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
import { SearchHighlighterContext } from './SearchHighlighter';
import { SearchSelect } from './SearchSelect';
import { SearchSelectDropdownControl } from './SearchSelectDropdownControl';

declare module 'react-select/dist/declarations/src/Select' {
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> {
clearIconLabel?: string;
minLength?: number;
tooShortText?: string;
}
}

export interface SearchSelectDropdownProps<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> extends SelectProps<V, Option, IsMulti, Group>,
AsyncProps<Option, IsMulti, Group> {
controlLabel?: React.ReactNode | string;
isDiscreet?: boolean;
}

export function SearchSelectDropdown<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
const { isDiscreet, value, loadOptions, controlLabel, isDisabled, minLength, ...rest } = props;
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');

const ref = React.useRef<Select<Option, IsMulti, Group>>(null);

const toggleDropdown = React.useCallback(
(value?: boolean) => {
setOpen(value === undefined ? !open : value);
},
[open]
);

const handleChange = React.useCallback(
(newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => {
toggleDropdown(false);
props.onChange?.(newValue, actionMeta);
},
[toggleDropdown, props.onChange]
);

const handleLoadOptions = React.useCallback(
(query: string, callback: (options: OptionsOrGroups<Option, Group>) => void) => {
return query.length >= (minLength ?? 0) ? loadOptions?.(query, callback) : undefined;
},
[minLength, loadOptions]
);
const debouncedLoadOptions = React.useRef(debounce(handleLoadOptions, DEBOUNCE_DELAY));

const handleInputChange = React.useCallback(
(newValue: string, actionMeta: InputActionMeta) => {
const value = actionMeta.action === 'menu-close' ? actionMeta.prevInputValue : newValue;
setInputValue(value);
props.onInputChange?.(value, actionMeta);
},
[props.onInputChange]
);

React.useEffect(() => {
if (open) {
ref.current?.inputRef?.select();
} else {
setInputValue('');
}
}, [open]);

return (
<DropdownToggler
allowResizing={true}
className="sw-overflow-visible sw-border-none"
onRequestClose={() => {
toggleDropdown(false);
}}
open={open}
overlay={
<SearchHighlighterContext.Provider value={inputValue}>
<StyledSearchSelectWrapper>
<SearchSelect
cacheOptions={true}
{...rest}
components={{
SingleValue: () => null,
Option: IconOption,
...rest.components,
}}
inputValue={inputValue}
loadOptions={debouncedLoadOptions.current}
menuIsOpen={true}
minLength={minLength}
onChange={handleChange}
onInputChange={handleInputChange}
selectRef={ref}
/>
</StyledSearchSelectWrapper>
</SearchHighlighterContext.Provider>
}
>
<SearchSelectDropdownControl
disabled={isDisabled}
isDiscreet={isDiscreet}
label={controlLabel}
onClick={() => {
toggleDropdown(true);
}}
/>
</DropdownToggler>
);
}

const StyledSearchSelectWrapper = styled.div`
${tw`sw-w-full`};
${tw`sw-rounded-2`};

.react-select {
border: ${themeBorder('default', 'inputDisabledBorder')};
${tw`sw-rounded-2`};
}

.react-select__menu {
${tw`sw-m-0`};
${tw`sw-relative`};
${tw`sw-shadow-none`};
${tw`sw-rounded-2`};
}

.react-select__menu-notice--loading {
${tw`sw-hidden`}
}

.react-select__input-container {
&::after {
content: '' !important;
}
}
`;

+ 126
- 0
server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx Wyświetl plik

@@ -0,0 +1,126 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import classNames from 'classnames';
import tw from 'twin.macro';
import { INPUT_SIZES, themeBorder, themeColor, themeContrast } from '../helpers';
import { Key } from '../helpers/keyboard';
import { InputSizeKeys } from '../types/theme';
import { ChevronDownIcon } from './icons';

interface SearchSelectDropdownControlProps {
disabled?: boolean;
isDiscreet?: boolean;
label?: React.ReactNode | string;
onClick: VoidFunction;
size?: InputSizeKeys;
}

export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
const { disabled, label, isDiscreet, onClick, size = 'full' } = props;
return (
<StyledControl
className={classNames({ 'is-discreet': isDiscreet })}
onClick={() => {
if (!disabled) {
onClick();
}
}}
onKeyDown={(event) => {
if (event.key === Key.Enter || event.key === Key.ArrowDown) {
onClick();
}
}}
role="combobox"
tabIndex={disabled ? -1 : 0}
>
<InputValue
className={classNames('js-search-input-value', {
'is-disabled': disabled,
'is-placeholder': !label,
})}
style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
>
{label}
<ChevronDownIcon />
</InputValue>
</StyledControl>
);
}

const StyledControl = styled.div`
color: ${themeContrast('inputBackground')};
background: ${themeColor('inputBackground')};
border: ${themeBorder('default', 'inputBorder')};

${tw`sw-flex sw-justify-between sw-items-center`};
${tw`sw-rounded-2`};
${tw`sw-box-border`};
${tw`sw-px-3 sw-py-2`};
${tw`sw-body-sm`};
${tw`sw-w-full sw-h-control`};
${tw`sw-leading-4`};
${tw`sw-cursor-pointer`};

&.is-discreet {
${tw`sw-border-none`};
${tw`sw-p-0`};
${tw`sw-w-auto sw-h-auto`};

background: inherit;
}

&:hover {
border: ${themeBorder('default', 'inputFocus')};

&.is-discreet {
${tw`sw-border-none`};
color: ${themeColor('discreetButtonHover')};
}
}

&:focus,
&:focus-visible,
&:focus-within {
border: ${themeBorder('default', 'inputFocus')};
outline: ${themeBorder('focus', 'inputFocus')};

&.is-discreet {
${tw`sw-rounded-1 sw-border-none`};
outline: ${themeBorder('focus', 'discreetFocusBorder')};
}
}
`;

const InputValue = styled.span`
width: var(--inputSize);
color: ${themeContrast('inputBackground')};

${tw`sw-truncate`};

&.is-placeholder {
color: ${themeColor('inputPlaceholder')};
}

&.is-disabled {
color: ${themeContrast('inputDisabled')};
}
`;

+ 97
- 0
server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx Wyświetl plik

@@ -0,0 +1,97 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../helpers/testUtils';
import { FCProps } from '../../types/misc';
import { LabelValueSelectOption } from '../InputSelect';
import { SearchSelectDropdown } from '../SearchSelectDropdown';

const defaultOptions = [
{ label: 'label1', value: 'value1' },
{ label: 'different', value: 'diff1' },
];

const loadOptions = (
query: string,
cb: (options: Array<LabelValueSelectOption<string>>) => void
) => {
cb(defaultOptions.filter((o) => o.label.includes(query)));
};

it('should render select input and be able to search and select an option', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
renderSearchSelectDropdown({ onChange });
expect(screen.getByText('not assigned')).toBeInTheDocument();
await user.click(screen.getByRole('combobox'));
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('different')).toBeInTheDocument();
await user.type(screen.getByRole('combobox', { name: 'label' }), 'label');
expect(await screen.findByText('label')).toBeInTheDocument();
expect(screen.queryByText('different')).not.toBeInTheDocument();
await user.click(screen.getByText('label'));
expect(onChange).toHaveBeenLastCalledWith(defaultOptions[0], {
action: 'select-option',
name: undefined,
option: undefined,
});
});

it('should handle key navigation', async () => {
const user = userEvent.setup();
renderSearchSelectDropdown();
await user.tab();
await user.keyboard('{Enter}');
await user.type(screen.getByRole('combobox', { name: 'label' }), 'label');
expect(await screen.findByText('label')).toBeInTheDocument();
expect(screen.queryByText('different')).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(await screen.findByText('different')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByText('different')).not.toBeInTheDocument();
await user.tab({ shift: true });
await user.keyboard('{ArrowDown}');
expect(await screen.findByText('label1')).toBeInTheDocument();
});

it('behaves correctly in disabled state', async () => {
const user = userEvent.setup();
renderSearchSelectDropdown({ isDisabled: true });
await user.click(screen.getByRole('combobox'));
expect(screen.queryByText('label1')).not.toBeInTheDocument();
await user.tab();
await user.keyboard('{Enter}');
expect(screen.queryByText('label1')).not.toBeInTheDocument();
});

function renderSearchSelectDropdown(props: Partial<FCProps<typeof SearchSelectDropdown>> = {}) {
return render(
<SearchSelectDropdown
aria-label="label"
controlLabel="not assigned"
defaultOptions={defaultOptions}
isDiscreet={true}
loadOptions={loadOptions}
placeholder="search for things"
{...props}
/>
);
}

+ 2
- 0
server/sonar-web/design-system/src/components/index.ts Wyświetl plik

@@ -53,6 +53,8 @@ export * from './MetricsRatingBadge';
export * from './NavBarTabs';
export * from './NewCodeLegend';
export { QualityGateIndicator } from './QualityGateIndicator';
export * from './SearchSelect';
export * from './SearchSelectDropdown';
export * from './SelectionCard';
export * from './Separator';
export * from './SizeIndicator';

+ 6
- 0
server/sonar-web/design-system/src/helpers/keyboard.ts Wyświetl plik

@@ -52,3 +52,9 @@ export function isInput(event: KeyboardEvent): boolean {
const { tagName } = event.target as HTMLElement;
return INPUT_TAGS.includes(tagName);
}

export function isTextarea(
event: KeyboardEvent
): event is KeyboardEvent & { target: HTMLTextAreaElement } {
return event.target instanceof HTMLTextAreaElement;
}

+ 2
- 0
server/sonar-web/package.json Wyświetl plik

@@ -31,6 +31,7 @@
"react-dom": "17.0.2",
"react-draggable": "4.4.5",
"react-helmet-async": "1.3.0",
"react-highlight-words": "0.20.0",
"react-intl": "6.2.5",
"react-modal": "3.16.1",
"react-router-dom": "6.10.0",
@@ -64,6 +65,7 @@
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@types/react-helmet": "6.1.6",
"@types/react-highlight-words": "0.16.4",
"@types/react-modal": "3.13.1",
"@types/react-virtualized": "9.21.21",
"@types/valid-url": "1.0.4",

+ 50
- 10
server/sonar-web/yarn.lock Wyświetl plik

@@ -4068,6 +4068,15 @@ __metadata:
languageName: node
linkType: hard

"@types/react-highlight-words@npm:0.16.4":
version: 0.16.4
resolution: "@types/react-highlight-words@npm:0.16.4"
dependencies:
"@types/react": "*"
checksum: 8b7c1b2b63b8f6d58e181613ba5392755b042bf87c74e65caad7bfc4585566944e9aacda6ee3b1cad222aea6667716c8be8643b39b72ae7b4aaadde063a62d25
languageName: node
linkType: hard

"@types/react-modal@npm:3.13.1":
version: 3.13.1
resolution: "@types/react-modal@npm:3.13.1"
@@ -4519,6 +4528,7 @@ __metadata:
"@types/react": 17.0.39
"@types/react-dom": 17.0.11
"@types/react-helmet": 6.1.6
"@types/react-highlight-words": 0.16.4
"@types/react-modal": 3.13.1
"@types/react-virtualized": 9.21.21
"@types/valid-url": 1.0.4
@@ -4574,6 +4584,7 @@ __metadata:
react-dom: 17.0.2
react-draggable: 4.4.5
react-helmet-async: 1.3.0
react-highlight-words: 0.20.0
react-intl: 6.2.5
react-modal: 3.16.1
react-router-dom: 6.10.0
@@ -6126,6 +6137,7 @@ __metadata:
"@testing-library/react": 12.1.5
"@testing-library/user-event": 14.4.3
"@types/react": 17.0.39
"@types/react-highlight-words": 0.16.4
"@typescript-eslint/parser": 5.57.0
"@vitejs/plugin-react": 3.1.0
autoprefixer: 10.4.14
@@ -6159,6 +6171,7 @@ __metadata:
react-day-picker: 8.6.0
react-dom: 17.0.2
react-helmet-async: 1.3.0
react-highlight-words: 0.20.0
react-intl: 6.2.5
react-router-dom: 6.10.0
react-select: 5.7.2
@@ -7818,6 +7831,13 @@ __metadata:
languageName: node
linkType: hard

"highlight-words-core@npm:^1.2.0":
version: 1.2.2
resolution: "highlight-words-core@npm:1.2.2"
checksum: 737758a8a572c82919552b031df300016164b7d0db6a819d24bc6c7ca2279d3cd6d03497728930d6402423c7a3fc2f42c628a9b01b025c704a0b56a635377511
languageName: node
linkType: hard

"history@npm:5.3.0":
version: 5.3.0
resolution: "history@npm:5.3.0"
@@ -9652,6 +9672,13 @@ __metadata:
languageName: node
linkType: hard

"memoize-one@npm:^4.0.0":
version: 4.0.3
resolution: "memoize-one@npm:4.0.3"
checksum: addd18c046542f57440ba70bf8ebd48663d17626cade681f777522ef70900a87ec72c5041bed8ece4f6d40a2cb58803bae388b50a4b740d64f36bcda20c147b7
languageName: node
linkType: hard

"memoize-one@npm:^6.0.0":
version: 6.0.0
resolution: "memoize-one@npm:6.0.0"
@@ -10644,25 +10671,25 @@ __metadata:
languageName: node
linkType: hard

"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
version: 15.7.2
resolution: "prop-types@npm:15.7.2"
"prop-types@npm:^15.5.8, prop-types@npm:^15.7.0, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
loose-envify: ^1.4.0
object-assign: ^4.1.1
react-is: ^16.8.1
checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430
react-is: ^16.13.1
checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
languageName: node
linkType: hard

"prop-types@npm:^15.7.0, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
version: 15.7.2
resolution: "prop-types@npm:15.7.2"
dependencies:
loose-envify: ^1.4.0
object-assign: ^4.1.1
react-is: ^16.13.1
checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
react-is: ^16.8.1
checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430
languageName: node
linkType: hard

@@ -10800,6 +10827,19 @@ __metadata:
languageName: node
linkType: hard

"react-highlight-words@npm:0.20.0":
version: 0.20.0
resolution: "react-highlight-words@npm:0.20.0"
dependencies:
highlight-words-core: ^1.2.0
memoize-one: ^4.0.0
prop-types: ^15.5.8
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0
checksum: 6794b6fe409ee81390e342ccdb951696e06354d8591b4cac050a6d64dbc77dfc7bb636fee0aabcfda841e57778aa5108fe351e7c1dc27b28abedd36aec8141e7
languageName: node
linkType: hard

"react-intl@npm:6.2.5":
version: 6.2.5
resolution: "react-intl@npm:6.2.5"

Ładowanie…
Anuluj
Zapisz