import * as React from 'react' import { theme } from '@planview/pv-utilities' import { ButtonEmptyInverse, ListItem, Tooltip } from '@planview/pv-uikit' import { Cancel, Search } from '@planview/pv-icons' import { ToolbarItem, ToolbarItemCollapsedView, ToolbarItemView, } from '../wrapper' import { useMergeRefs } from '../utils/refs' import ResizeContext, { useMainNavigationContext } from '../utils/context' import type { DisplayOnType } from '../utils' import { DISPLAY_ON_TABLET_PORTRAIT } from '../utils' import { defineMessages, useIntl } from 'react-intl' import { SearchContainer, SearchInnerInput } from './styles' const messages = defineMessages({ search: { id: 'pvds.toolbar.navigation.search', defaultMessage: 'Search', description: 'Label for search input in navigation', }, clearSearch: { id: 'pvds.toolbar.navigation.clearSearch', defaultMessage: 'Clear search', description: 'Label for clear search button in navigation search input', }, }) export type SearchInputProps = { /** * force expanded mode (controlled mode) */ expanded?: boolean /** * call-back on expanded state change request (if controlled state will not change) */ onExpandedChange?: (nextState: boolean) => void /** * Target display of this item. Choose 'phone' to prevent collapsing. */ displayOn?: DisplayOnType /** value of input in controlled mode */ value?: string /** default value of input in uncontrolled mode */ defaultValue?: string /** Callback triggered after the value changes. */ onChange?: ( value: string, event: React.ChangeEvent ) => void /** * call-back when list item is activated in collapsed mode from more menu * (This can be useful in case you want to open search modal in phone resolution) */ onActivateMoreMenu?: () => void } & Omit, 'onChange'> /* Adapted from https://hustle.bizongo.in/simulate-react-on-change-on-controlled-components-baa336920e04 */ function setValueImperative(input: HTMLInputElement, value: string) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set nativeInputValueSetter?.call(input, value) const inputEvent = new InputEvent('input', { bubbles: true }) input.dispatchEvent(inputEvent) } const SearchInputImpl: React.ForwardRefRenderFunction< HTMLInputElement | null, SearchInputProps > = ( { value, defaultValue = '', expanded, displayOn = DISPLAY_ON_TABLET_PORTRAIT, onActivateMoreMenu, onExpandedChange, ...rest }, ref ) => { const { formatMessage } = useIntl() const { shouldDisplay, initialized } = React.useContext(ResizeContext) const shouldCollapse = shouldDisplay('desktop-hd') const { wrappedInNavigation } = useMainNavigationContext() const [stateValue, setStateValue] = React.useState(value || defaultValue) const [hasFocus, setHasFocus] = React.useState(false) const [expandedState, setExpandedState] = React.useState( expanded !== undefined ? expanded : null ) const showClear = !!stateValue.length const inputRef = React.useRef(null) const parentRef = React.useRef(null) const mergedRefs = useMergeRefs([ref, inputRef]) React.useEffect(() => { if (!initialized) { return } const inputExpanded = !shouldCollapse || !!stateValue.trim().length || hasFocus if ( inputExpanded !== expandedState || (expanded !== undefined && expanded !== expandedState) ) { setExpandedState(expanded === undefined ? inputExpanded : expanded) onExpandedChange?.(inputExpanded) } }, [ expanded, expandedState, hasFocus, initialized, onExpandedChange, shouldCollapse, stateValue, ]) React.useEffect(() => { if (value !== undefined) { setStateValue(value) } }, [value]) return expandedState !== null ? ( refOverride={inputRef}> {( ref, { toolbarItemProps: { focused: _focused, ...toolbarItemProps }, } ) => ( { inputRef.current?.focus() }} onFocus={() => { setHasFocus(true) }} onBlur={(e) => { if ( !parentRef.current?.contains( e.relatedTarget ) ) { setHasFocus(false) } }} > { rest.onKeyDown?.(ev) const pos = ev.currentTarget.selectionStart || 0 const len = ev.currentTarget.value.length if (ev.key === 'ArrowRight' && pos < len) { return } else if ( ev.key === 'ArrowLeft' && pos > 0 ) { return } toolbarItemProps.onKeyDown(ev) }} onChange={(e) => { if (value === undefined) { setStateValue(e.target.value) } rest.onChange?.(e.target.value, e) }} /> {showClear ? ( } tabIndex={-1} onClick={() => { if (inputRef.current) { setValueImperative( inputRef.current, '' ) inputRef.current?.focus() } }} aria-label={formatMessage( messages.clearSearch )} /> ) : null} )} } label={rest['aria-label'] || formatMessage(messages.search)} onActivate={onActivateMoreMenu} /> ) : null } /** * * [Design system spec](https://design.planview.com/components/navigation/navigation-bar#navigation-bar-search-) * * `import { SearchInput } from '@planview/pv-toolbar'` * * ### API * Props type extends `React.ComponentPropsWithoutRef<'input'>` * * ### Adaptive layout * The `SearchInput` component will be auto-expanded when in breakpoint `desktop-hd`. Below that breakpoint it will contract into a button and * expand on user interaction. The expanded state can be forced by using the `expanded` prop. * * Like other components in `pv-toolbar`, the `SearchInput` can collapse into a more menu. * When it should be removed from the bar and put into the more menu is determined by the `displayOn` property. * * * ### Accessibility * By using the `role="search"`, this component adds a [search landmark](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/search.html) which will help users using assistive technology jump quicker between the different regions on the page. * */ export const SearchInput = React.forwardRef(SearchInputImpl)