@@ -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", |
@@ -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' }; |
@@ -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)` |
@@ -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> | |||
); |
@@ -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')}; | |||
} | |||
`; |
@@ -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> | |||
); | |||
} |
@@ -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; | |||
} | |||
} | |||
`; |
@@ -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')}; | |||
} | |||
`; |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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'; |
@@ -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; | |||
} |
@@ -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", |
@@ -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" |