diff --git a/packages/ui/.loki/reference/chrome_minimal_Components_CheckableSelectField_Custom_Search.png b/packages/ui/.loki/reference/chrome_minimal_Components_CheckableSelectField_Custom_Search.png new file mode 100644 index 000000000..876476ead Binary files /dev/null and b/packages/ui/.loki/reference/chrome_minimal_Components_CheckableSelectField_Custom_Search.png differ diff --git a/packages/ui/.loki/reference/chrome_minimal_Components_Overlay_Without_Close_Text.png b/packages/ui/.loki/reference/chrome_minimal_Components_Overlay_Without_Close_Text.png index 9f92206e7..6d8beb974 100644 Binary files a/packages/ui/.loki/reference/chrome_minimal_Components_Overlay_Without_Close_Text.png and b/packages/ui/.loki/reference/chrome_minimal_Components_Overlay_Without_Close_Text.png differ diff --git a/packages/ui/__stories__/CheckableSelectField.stories.tsx b/packages/ui/__stories__/CheckableSelectField.stories.tsx index e36640b7d..ba770084d 100644 --- a/packages/ui/__stories__/CheckableSelectField.stories.tsx +++ b/packages/ui/__stories__/CheckableSelectField.stories.tsx @@ -141,3 +141,18 @@ WithoutOptions.args = { options: [], defaultOpen: true, } + +export const CustomSearch = Template.bind({}) +CustomSearch.args = { + value: [], + options, + filterOptionsLabel: 'Custom negative search', + defaultOpen: true, + filterOptions: (candidate, input) => { + if (!input) { + return true + } + + return !candidate.label.toLowerCase().includes(input) + }, +} diff --git a/packages/ui/__tests__/Select/CheckableSelectField.test.tsx b/packages/ui/__tests__/Select/CheckableSelectField.test.tsx index 33cac8722..4bb996788 100644 --- a/packages/ui/__tests__/Select/CheckableSelectField.test.tsx +++ b/packages/ui/__tests__/Select/CheckableSelectField.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useUID } from 'react-uid' -import { mountAndCheckA11Y } from '@hazelcast/test-helpers' +import { mountAndCheckA11Y, simulateChange } from '@hazelcast/test-helpers' import { act } from 'react-dom/test-utils' import { CheckableSelectField } from '../../src/Select/CheckableSelectField' @@ -306,4 +306,49 @@ describe('CheckableSelectField', () => { expect(wrapper.findDataTest('test-no-options-message').exists()).toBeTruthy() expect(wrapper.findDataTest('test-no-options-message').text()).toBe('There are no options') }) + + it('Custom search', async () => { + let id = 0 + const onChange = jest.fn() + const filterOptions = jest.fn() + useUIDMock.mockImplementation(() => { + id += 1 + return id.toString() + }) + const wrapper = await mountAndCheckA11Y( + , + ) + + const searchInput = wrapper.findDataTestFirst('test-search').find('input') + + expect(searchInput).toBeTruthy() + act(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + simulateChange(searchInput, 'Luke') + }) + + expect(filterOptions).toHaveBeenCalledTimes(0) + + act(() => { + wrapper.findDataTestFirst('test-toggle-custom-search').find('input').simulate('change') + }) + + act(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + simulateChange(searchInput, 'Luke2') + }) + + expect(filterOptions).toHaveBeenCalled() + }) }) diff --git a/packages/ui/src/Checkbox.tsx b/packages/ui/src/Checkbox.tsx index 387b851da..14c30163b 100644 --- a/packages/ui/src/Checkbox.tsx +++ b/packages/ui/src/Checkbox.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FC, FocusEvent, ReactElement, ReactText, useRef, MouseEvent } from 'react' +import React, { ChangeEvent, FocusEvent, ReactElement, ReactText, useRef, MouseEvent, forwardRef } from 'react' import { Check, Minus } from 'react-feather' import { DataTestProp } from '@hazelcast/helpers' import { useUID } from 'react-uid' @@ -28,6 +28,7 @@ export type CheckboxExtraProps = { required?: boolean className?: string classNameLabel?: string + 'aria-label'?: string classNameCheckmark?: string } @@ -43,7 +44,7 @@ type CheckboxProps = CheckboxCoreProps & CheckboxExtraProps & DataTestProp * - It's important to realise that we don't set checkbox value, but state (on/off). That is the main difference from other input types. * - They can have a special indeterminate state, that represents a '3rd' value, usually used in tree structures, etc. */ -export const Checkbox: FC = (props) => { +export const Checkbox = forwardRef((props, ref) => { const { checked, name, @@ -60,14 +61,15 @@ export const Checkbox: FC = (props) => { disabled = false, required, 'data-test': dataTest, + 'aria-label': ariaLabel, onClick, } = props const inputRef = useRef(null) const id = useUID() return ( -
- {/* +
+ {/* We can only style forward elements based on input state (with ~ or +), has() is not supported yet. That's why we need to explicitly pass error/checked/disabled classes to the wrapper element. */} @@ -94,6 +96,7 @@ export const Checkbox: FC = (props) => { ref={inputRef} id={id} name={name} + aria-label={ariaLabel} checked={!!checked} onChange={onChange} onBlur={onBlur} @@ -115,4 +118,6 @@ export const Checkbox: FC = (props) => {
) -} +}) + +Checkbox.displayName = 'Checkbox' diff --git a/packages/ui/src/Select/CheckableSelectField.module.scss b/packages/ui/src/Select/CheckableSelectField.module.scss index ab4b81472..e187adb17 100644 --- a/packages/ui/src/Select/CheckableSelectField.module.scss +++ b/packages/ui/src/Select/CheckableSelectField.module.scss @@ -35,7 +35,14 @@ } .search { + display: flex; + align-items: center; + gap: c.$grid * 2; margin-bottom: c.$grid * 2; + + &Field { + flex-grow: 1; + } } .option { diff --git a/packages/ui/src/Select/CheckableSelectField.tsx b/packages/ui/src/Select/CheckableSelectField.tsx index dd27501f3..2fd307aa0 100644 --- a/packages/ui/src/Select/CheckableSelectField.tsx +++ b/packages/ui/src/Select/CheckableSelectField.tsx @@ -8,10 +8,12 @@ import { Link } from '../Link' import { SelectFieldOption } from './helpers' import { TextField } from '../TextField' import { HelpProps } from '../Help' +import { Tooltip } from '../Tooltip' +import { Checkbox } from '../Checkbox' import { useOpenCloseState } from '../hooks' +import { TruncatedText } from '../TruncatedText' import styles from './CheckableSelectField.module.scss' -import { TruncatedText } from '../TruncatedText' export type CheckableSelectFieldCoreStaticProps = { name: string @@ -40,6 +42,8 @@ export type CheckableSelectFieldExtraProps = { noOptionsMessage?: string defaultOpen?: boolean id?: string + filterOptionsLabel?: string + filterOptions?: (candidate: SelectFieldOption, input: string) => boolean } export type CheckableSelectProps = CheckableSelectFieldCoreStaticProps & CheckableSelectFieldExtraProps @@ -77,9 +81,12 @@ export const CheckableSelectField = (props: noneSelectedLabel = 'None selected', noOptionsMessage = 'No options', id: rootId, + filterOptions, + filterOptionsLabel, } = props const id = useUID() const { isOpen, toggle, close } = useOpenCloseState(defaultOpen) + const { isOpen: isCustomSearchEnabled, toggle: toggleCustomSearch } = useOpenCloseState(false) const [searchValue, setSearchValue] = useState('') const [forceUpdateToken, setForceUpdateToken] = useState(1) const [anchorElement, setAnchorElement] = useState(null) @@ -97,8 +104,14 @@ export const CheckableSelectField = (props: const filteredOptions = useMemo(() => { const value = searchValue.toLowerCase() - return options.filter(({ label }) => label.toLowerCase().includes(value)) - }, [options, searchValue]) + return options.filter((option) => { + if (isCustomSearchEnabled && filterOptions) { + return filterOptions(option, value) + } + + return option.label.toLowerCase().includes(value) + }) + }, [options, searchValue, isCustomSearchEnabled, filterOptions]) const getValueLabel = () => { if (placeholderMode === 'permanent') { @@ -150,18 +163,34 @@ export const CheckableSelectField = (props: onUpdateLayout={() => setForceUpdateToken((token) => token + 1)} >
- setSearchValue(e.target.value)} - value={searchValue} - label="" - disabled={disabled} - placeholder={placeholder} - /> +
+ setSearchValue(e.target.value)} + value={searchValue} + label="" + disabled={disabled} + placeholder={placeholder} + /> + {filterOptions && ( + + {(tooltipRef) => ( + + )} + + )} +
{filteredOptions.length > 0 ? ( filteredOptions.map((option) => { diff --git a/packages/ui/src/Tooltip.tsx b/packages/ui/src/Tooltip.tsx index 3dca0254d..1a8ece82e 100644 --- a/packages/ui/src/Tooltip.tsx +++ b/packages/ui/src/Tooltip.tsx @@ -25,8 +25,6 @@ import { getPortalContainer, PortalContainer } from './utils/portal' import styles from './Tooltip.module.scss' -const tooltipZIndex = 20 - const TooltipContext = createContext<{ hide: () => void clearHideTimeout: () => void @@ -47,14 +45,15 @@ export type TooltipProps = { hoverAbleTooltip?: boolean children: ( ref: React.Dispatch>, - onMouseEnter?: MouseEventHandler, - onMouseLeave?: MouseEventHandler, + onMouseEnter?: MouseEventHandler, + onMouseLeave?: MouseEventHandler, ) => ReactNode popperRef?: MutableRefObject updateToken?: ReactText | boolean tooltipContainer?: PortalContainer wordBreak?: CSSProperties['wordBreak'] disabled?: boolean + zIndex?: number } /** @@ -88,6 +87,7 @@ export const Tooltip: FC = ({ tooltipContainer = 'body', hoverAbleTooltip = true, disabled = false, + zIndex = 20, }) => { const [isShown, setShown] = useState(false) const timeoutRef = useRef(null) @@ -225,7 +225,7 @@ export const Tooltip: FC = ({ className={cn(styles.overlay, { [styles.hidden]: !isTooltipVisible, })} - style={{ ...popper.styles.popper, ...{ zIndex: context ? tooltipZIndex + 1 : tooltipZIndex }, wordBreak }} + style={{ ...popper.styles.popper, ...{ zIndex: context ? zIndex + 1 : zIndex }, wordBreak }} data-test="tooltip-overlay" aria-hidden {...popper.attributes.popper}