diff --git a/packages/ui/src/components/search-dropdown/private/types.ts b/packages/ui/src/components/search-dropdown/private/types.ts new file mode 100644 index 00000000..6dcd3c46 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/private/types.ts @@ -0,0 +1,3 @@ +import { INVALID_ID } from '../../../utilities/private/constants.js' + +export type Id = typeof INVALID_ID | string diff --git a/packages/ui/src/components/search-dropdown/private/update-menu-element-layout.ts b/packages/ui/src/components/search-dropdown/private/update-menu-element-layout.ts new file mode 100644 index 00000000..aef33f71 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/private/update-menu-element-layout.ts @@ -0,0 +1,147 @@ +import { + INVALID_ID, + ITEM_ID_DATA_ATTRIBUTE_NAME, + VIEWPORT_MARGIN +} from '../../../utilities/private/constants.js' +import { Id } from './types.js' + +export function updateMenuElementLayout( + rootElement: HTMLDivElement, + menuElement: HTMLDivElement, + selectedId: Id +) { + const rootElementBoundingClientRect = rootElement.getBoundingClientRect() + const rootWidth = rootElement.offsetWidth + const rootHeight = rootElement.offsetHeight + const rootLeft = rootElementBoundingClientRect.left + const rootTop = rootElementBoundingClientRect.top + + menuElement.style.minWidth = `${rootWidth}px` + + const menuElementMaxWidth = window.innerWidth - 2 * VIEWPORT_MARGIN + menuElement.style.maxWidth = `${menuElementMaxWidth}px` + + const menuElementMaxHeight = window.innerHeight - 2 * VIEWPORT_MARGIN + menuElement.style.maxHeight = `${menuElementMaxHeight}px` + + const menuWidth = menuElement.offsetWidth + const menuHeight = menuElement.offsetHeight + const menuScrollHeight = menuElement.scrollHeight + const menuPaddingTop = parseInt( + window.getComputedStyle(menuElement).paddingTop, + 10 + ) + const labelElement = getSelectedLabelElement(menuElement, selectedId) + + const left = computeLeft({ + menuWidth, + rootLeft + }) + menuElement.style.left = `${left}px` + + const top = computeTop({ + menuHeight, + rootTop, + selectedTop: labelElement.offsetTop + }) + menuElement.style.top = `${top}px` + + const isScrollable = menuScrollHeight > menuHeight + if (!isScrollable) { + return + } + menuElement.scrollTop = computeScrollTop({ + menuHeight, + menuPaddingTop, + menuScrollHeight, + rootHeight, + rootTop, + selectedTop: labelElement.offsetTop + }) +} + +function getSelectedLabelElement( + menuElement: HTMLDivElement, + selectedId: Id +): HTMLLabelElement { + const inputElement = menuElement.querySelector( + selectedId === INVALID_ID + ? `[${ITEM_ID_DATA_ATTRIBUTE_NAME}]` + : `[${ITEM_ID_DATA_ATTRIBUTE_NAME}='${selectedId}']` + ) + if (inputElement === null) { + throw new Error('`inputElement` is `null`') + } + const labelElement = inputElement.parentElement + if (labelElement === null) { + throw new Error('`labelElement` is `null`') + } + return labelElement as HTMLLabelElement +} + +function computeLeft(options: { menuWidth: number; rootLeft: number }): number { + const { menuWidth, rootLeft } = options + if (rootLeft <= VIEWPORT_MARGIN) { + return VIEWPORT_MARGIN + } + const viewportWidth = window.innerWidth + if (rootLeft + menuWidth > viewportWidth - VIEWPORT_MARGIN) { + return viewportWidth - VIEWPORT_MARGIN - menuWidth + } + return rootLeft +} + +function computeTop(options: { + menuHeight: number + rootTop: number + selectedTop: number +}): number { + const { menuHeight, rootTop, selectedTop } = options + const viewportHeight = window.innerHeight + if ( + rootTop <= VIEWPORT_MARGIN || + menuHeight === viewportHeight - 2 * VIEWPORT_MARGIN + ) { + return VIEWPORT_MARGIN + } + // Position the selected element at `rootTop` + const top = rootTop - selectedTop + const minimumTop = VIEWPORT_MARGIN + const maximumTop = viewportHeight - VIEWPORT_MARGIN - menuHeight + return restrictToRange(top, minimumTop, maximumTop) +} + +function computeScrollTop(options: { + menuHeight: number + menuPaddingTop: number + menuScrollHeight: number + rootHeight: number + rootTop: number + selectedTop: number +}): number { + const { + menuHeight, + menuPaddingTop, + menuScrollHeight, + rootHeight, + rootTop, + selectedTop + } = options + const restrictedRootTop = restrictToRange( + rootTop, + VIEWPORT_MARGIN, + window.innerHeight - VIEWPORT_MARGIN - rootHeight + menuPaddingTop / 2 + ) + const scrollTop = selectedTop - (restrictedRootTop - VIEWPORT_MARGIN) + const minimumScrollTop = 0 + const maximumScrollTop = menuScrollHeight - menuHeight + return restrictToRange(scrollTop, minimumScrollTop, maximumScrollTop) +} + +function restrictToRange( + value: number, + minimum: number, + maximum: number +): number { + return Math.min(Math.max(value, minimum), maximum) +} diff --git a/packages/ui/src/components/search-dropdown/search-dropdown.module.css b/packages/ui/src/components/search-dropdown/search-dropdown.module.css new file mode 100644 index 00000000..d9c8e3ba --- /dev/null +++ b/packages/ui/src/components/search-dropdown/search-dropdown.module.css @@ -0,0 +1,147 @@ +.searchDropdown { + position: relative; + display: flex; + width: 100%; + flex-direction: column; + outline: none; +} + +.searchDropdown:focus { + outline: none; +} + +.disabled, +.disabled * { + cursor: not-allowed; +} + +.inputContainer { + position: relative; + display: flex; + width: 100%; + align-items: center; +} + +.input { + display: block; + width: 100%; + height: var(--space-32); + padding: var(--space-0) var(--space-32); + border-radius: var(--border-radius-6); + background-color: var(--figma-color-bg-secondary); + color: var(--figma-color-text); + cursor: text; +} +.disabled .input { + color: var(--figma-color-text-disabled); + cursor: not-allowed; +} +.input::placeholder { + color: var(--figma-color-text-tertiary); +} + +.searchIcon { + position: absolute; + top: var(--space-4); + left: var(--space-4); + color: var(--figma-color-icon-secondary); + pointer-events: none; /* so that clicking the icon focuses the textbox */ +} +.disabled .searchIcon { + color: var(--figma-color-icon-disabled); +} +.input:focus ~ .searchIcon { + color: var(--figma-color-icon); +} + +.icon { + position: absolute; + top: 50%; + left: var(--space-3); + color: var(--figma-color-icon-secondary); + pointer-events: none; + transform: translateY(-50%); +} + +.clearButton { + position: absolute; + top: var(--space-0); + right: var(--space-0); + width: var(--space-32); + height: var(--space-32); + padding: calc(var(--space-4) - var(--border-width-1)); + color: var(--figma-color-icon-secondary); +} +.searchTextbox:not(.disabled) .clearButton:hover, +.searchTextbox:not(.disabled) .clearButton:focus-visible { + color: var(--figma-color-icon); +} +.disabled .clearButton { + color: var(--figma-color-icon-disabled); +} + +.clearButtonBox { + display: block; + border: var(--border-width-1) solid transparent; + border-radius: var(--border-radius-4); +} +.searchTextbox:not(.disabled) .clearButton:focus-visible .clearButtonBox { + border-color: var(--figma-color-border-selected); +} +.clearButtonBox svg { + display: block; +} + +.chevronIcon { + position: absolute; + right: 0; + display: flex; + width: var(--space-32); + height: var(--space-32); + align-items: center; + justify-content: center; +} + +.valueDisplay { + display: none; +} + +/* Position the menu in the document body */ +:global(.menu-container) { + position: fixed; + z-index: var(--z-index-1); + width: 100%; + padding: var(--space-4) var(--space-4); +} + +.searchContainer { + position: sticky; + z-index: var(--z-index-3); + top: 0; + width: 100%; + padding: var(--space-8) var(--space-8); + border-bottom: var(--border-width-1) solid var(--figma-color-border); + background-color: var(--figma-color-bg); +} + +.searchInputWrapper { + position: relative; + display: flex; + width: 100%; + align-items: center; +} + +.searchInput { + width: 100%; + height: var(--space-32); + padding: var(--space-0) var(--space-8) var(--space-0) var(--space-32); + border: var(--border-width-1) solid var(--figma-color-border); + background-color: var(--figma-color-bg); + color: var(--figma-color-text); + font-size: var(--font-size-12); +} + +.searchInput:focus { + border-color: var(--figma-color-border-selected); + outline: 0; +} diff --git a/packages/ui/src/components/search-dropdown/search-dropdown.tsx b/packages/ui/src/components/search-dropdown/search-dropdown.tsx new file mode 100644 index 00000000..50a78c1e --- /dev/null +++ b/packages/ui/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,807 @@ +import { ComponentChildren, h, RefObject } from 'preact' +import { createPortal } from 'preact/compat' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' + +import menuStyles from '../../css/menu.module.css' +import { useMouseDownOutside } from '../../hooks/use-mouse-down-outside.js' +import { useScrollableMenu } from '../../hooks/use-scrollable-menu.js' +import { IconCheck16 } from '../../icons/icon-16/icon-check-16.js' +import { IconChevronDown16 } from '../../icons/icon-16/icon-chevron-down-16.js' +import { IconClose24 } from '../../icons/icon-24/icon-close-24.js' +import { IconSearch24 } from '../../icons/icon-24/icon-search-24.js' +import { Event, EventHandler } from '../../types/event-handler.js' +import { FocusableComponentProps } from '../../types/focusable-component-props.js' +import { createClassName } from '../../utilities/create-class-name.js' +import { createComponent } from '../../utilities/create-component.js' +import { getCurrentFromRef } from '../../utilities/get-current-from-ref.js' +import { noop } from '../../utilities/no-op.js' +import { + INVALID_ID, + ITEM_ID_DATA_ATTRIBUTE_NAME +} from '../../utilities/private/constants.js' +import { Id } from './private/types.js' +import { updateMenuElementLayout } from './private/update-menu-element-layout.js' +import styles from './search-dropdown.module.css' + +const EMPTY_STRING = '' + +export interface SearchDropdownOptionHeader { + header: string +} + +export type SearchDropdownOptionSeparator = '-' + +export interface SearchDropdownOptionValue { + disabled?: boolean + text?: string + value: string +} + +export type SearchDropdownOption = + | SearchDropdownOptionHeader + | SearchDropdownOptionSeparator + | SearchDropdownOptionValue + +export interface SearchDropdownProps + extends FocusableComponentProps { + clearOnEscapeKeyDown?: boolean + disabled?: boolean + icon?: ComponentChildren + onChange?: EventHandler.onChange + onFocus?: EventHandler.onFocus + onKeyDown?: EventHandler.onKeyDown + onMouseDown?: EventHandler.onMouseDown + onValueChange?: EventHandler.onValueChange + options: Array + placeholder?: string + propagateEscapeKeyDown?: boolean + spellCheck?: boolean + value: null | string + /** Controlled search input value */ + searchValue?: string + /** Called when the search input value changes */ + onSearchValueInput?: (newValue: string) => void + /** Props to apply to the input element */ + inputProps?: Record +} + +export const SearchDropdown = createComponent< + HTMLDivElement, + SearchDropdownProps +>(function ( + { + clearOnEscapeKeyDown = false, + disabled = false, + icon, + onChange = noop, + onFocus = noop, + onKeyDown = noop, + onMouseDown = noop, + onValueChange = noop, + onSearchValueInput = noop, + options, + placeholder, + propagateEscapeKeyDown = true, + spellCheck = false, + value, + searchValue: propSearchValue, + inputProps = {}, + ...rest + }, + ref +) { + // Allow controlled or uncontrolled search input: + const [internalSearchValue, setInternalSearchValue] = useState('') + const searchValue = + propSearchValue !== undefined ? propSearchValue : internalSearchValue + + // Track if the user is actively searching + const [isSearching, setIsSearching] = useState(false) + + // Get the current selected option's display text + const getSelectedOptionText = useCallback(() => { + if (value === null) return '' + const index = findOptionIndexByValue(options, value) + if (index === -1) return '' + return String(getDropdownOptionValue(options[index])) + }, [options, value]) + + // Determine what to show in the input + const displayValue = isSearching ? searchValue : getSelectedOptionText() + + const rootElementRef: RefObject = useRef(null) + const menuElementRef: RefObject = useRef(null) + const inputElementRef: RefObject = useRef(null) + const menuContainerRef: RefObject = useRef(null) + + const handleClearButtonClick = useCallback( + function (event: { stopPropagation: () => void }) { + event.stopPropagation() + + const inputElement = getCurrentFromRef(inputElementRef) + if (inputElement) { + // Clear everything + inputElement.value = EMPTY_STRING + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + onValueChange(null) + setIsSearching(false) + + const inputEvent = new window.Event('input', { + bubbles: true, + cancelable: true + }) + inputElement.dispatchEvent(inputEvent) + inputElement.focus() + } + }, + [onSearchValueInput, onValueChange, propSearchValue] + ) + + const [isMenuVisible, setIsMenuVisible] = useState(false) + const [filteredOptions, setFilteredOptions] = useState(options) + const [menuPosition, setMenuPosition] = useState({ + isAbove: false, + left: 0, + top: 0, + width: 0 + }) + + const index = findOptionIndexByValue(options, value) + if (value !== null && index === -1) { + throw new Error(`Invalid \`value\`: ${value}`) + } + const [selectedId, setSelectedId] = useState( + index === -1 ? INVALID_ID : `${index}` + ) + const children = + typeof options[index] === 'undefined' + ? '' + : getDropdownOptionValue(options[index]) + + const { handleScrollableMenuKeyDown, handleScrollableMenuItemMouseMove } = + useScrollableMenu({ + itemIdDataAttributeName: ITEM_ID_DATA_ATTRIBUTE_NAME, + menuElementRef, + selectedId, + setSelectedId + }) + + useEffect(() => { + // Filter options based on the search input + if (searchValue === '') { + setFilteredOptions(options) + } else { + const filtered = options.filter((option, index, array) => { + if (typeof option === 'string') { + // For separators, check if there are any matching options after it until the next header or separator + const nextHeaderIndex = array.findIndex( + (opt, i) => + i > index && (typeof opt === 'string' || 'header' in opt) + ) + const relevantOptions = array.slice( + index + 1, + nextHeaderIndex === -1 ? array.length : nextHeaderIndex + ) + return relevantOptions.some( + (opt) => + typeof opt !== 'string' && + !('header' in opt) && + (opt.text || opt.value) + .toLowerCase() + .includes(searchValue.toLowerCase()) + ) + } + if ('header' in option) { + // For headers, check if there are any matching options after it until the next header or separator + const nextHeaderIndex = array.findIndex( + (opt, i) => + i > index && (typeof opt === 'string' || 'header' in opt) + ) + const relevantOptions = array.slice( + index + 1, + nextHeaderIndex === -1 ? array.length : nextHeaderIndex + ) + return relevantOptions.some( + (opt) => + typeof opt !== 'string' && + !('header' in opt) && + (opt.text || opt.value) + .toLowerCase() + .includes(searchValue.toLowerCase()) + ) + } + // Filter by value or text if available + const optionText = option.text || option.value + return optionText.toLowerCase().includes(searchValue.toLowerCase()) + }) + setFilteredOptions(filtered) + } + }, [searchValue, options]) + + const triggerRootBlur = useCallback(function () { + getCurrentFromRef(rootElementRef).blur() + }, []) + + const triggerRootFocus = useCallback(function () { + getCurrentFromRef(rootElementRef).focus() + }, []) + + const updateMenuPosition = useCallback(function () { + const rootElement = getCurrentFromRef(rootElementRef) + const menuElement = getCurrentFromRef(menuElementRef) + if (rootElement && menuElement) { + const rect = rootElement.getBoundingClientRect() + const viewportHeight = window.innerHeight + + // Calculate the actual menu height rather than using a hardcoded value + const menuHeight = menuElement.offsetHeight || 0 + + // Calculate available space + const spaceBelow = viewportHeight - rect.bottom + + // Position above if we're close to the bottom of the viewport + if (spaceBelow < menuHeight && rect.top > menuHeight) { + setMenuPosition({ + isAbove: true, + left: rect.left, + // For "above" positioning, we set top to a value that will position the menu above + top: rect.top - menuHeight - 8, // Position above with 8px gap + width: rect.width + }) + } else { + // Position below (default) + setMenuPosition({ + isAbove: false, + left: rect.left, + top: rect.bottom + 4, // 4px gap below + width: rect.width + }) + } + } + }, []) + + const triggerMenuUpdateLayout = useCallback(function (selectedId: Id) { + const rootElement = getCurrentFromRef(rootElementRef) + const menuElement = getCurrentFromRef(menuElementRef) + updateMenuElementLayout(rootElement, menuElement, selectedId) + }, []) + + const triggerMenuHide = useCallback(function () { + setIsMenuVisible(false) + setSelectedId(INVALID_ID) + }, []) + + const triggerMenuShow = useCallback( + function () { + if (isMenuVisible === true) { + return + } + + // Show menu first + setIsMenuVisible(true) + + // Update position immediately and after a short delay to ensure smooth transition + requestAnimationFrame(() => { + updateMenuPosition() + // Update again after content is rendered + setTimeout(() => { + updateMenuPosition() + }, 50) + }) + + if (value === null) { + triggerMenuUpdateLayout(selectedId) + return + } + const index = findOptionIndexByValue(options, value) + if (index === -1) { + throw new Error(`Invalid \`value\`: ${value}`) + } + const newSelectedId = `${index}` + setSelectedId(newSelectedId) + triggerMenuUpdateLayout(newSelectedId) + }, + [ + isMenuVisible, + options, + selectedId, + triggerMenuUpdateLayout, + updateMenuPosition, + value + ] + ) + + const handleFocus = useCallback( + function (event: Event.onFocus) { + onFocus(event) + // Start with the current selected value in search + const currentText = getSelectedOptionText() + if (propSearchValue === undefined) { + setInternalSearchValue(currentText) + } + onSearchValueInput(currentText) + setIsSearching(true) + event.currentTarget.select() + + // Always show menu on focus + triggerMenuShow() + }, + [ + onFocus, + getSelectedOptionText, + onSearchValueInput, + propSearchValue, + triggerMenuShow + ] + ) + + // Update menu position when filtered options change + useEffect(() => { + if (isMenuVisible) { + // Use requestAnimationFrame for smoother updates + requestAnimationFrame(() => { + updateMenuPosition() + // Update again after content is rendered + setTimeout(() => { + updateMenuPosition() + }, 50) + }) + } + }, [isMenuVisible, filteredOptions, updateMenuPosition]) + + const handleInput = useCallback( + function (event: Event.onInput) { + const newValue = event.currentTarget.value + setIsSearching(true) + + if (propSearchValue === undefined) { + setInternalSearchValue(newValue) + } + onSearchValueInput(newValue) + + // If input is completely cleared, clear selection + if (newValue === EMPTY_STRING) { + onValueChange(null) + setSelectedId(INVALID_ID) + } + + // Show menu when typing + if (!isMenuVisible) { + triggerMenuShow() + } + }, + [ + isMenuVisible, + onSearchValueInput, + onValueChange, + propSearchValue, + triggerMenuShow + ] + ) + + const handleRootKeyDown = useCallback( + function (event: Event.onKeyDown) { + onKeyDown(event) + const key = event.key + + if (key === 'Escape') { + event.preventDefault() + if (clearOnEscapeKeyDown === true) { + event.stopPropagation() + // Clear both search and selection + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + onValueChange(null) + setIsSearching(false) + + const inputElement = getCurrentFromRef(inputElementRef) + if (inputElement) { + inputElement.value = EMPTY_STRING + inputElement.focus() + } + return + } + if (propagateEscapeKeyDown === false) { + event.stopPropagation() + } + if (isMenuVisible === true) { + triggerMenuHide() + return + } + triggerRootBlur() + return + } + + if (key === 'ArrowUp' || key === 'ArrowDown') { + event.preventDefault() + if (isMenuVisible === false) { + triggerMenuShow() + return + } + handleScrollableMenuKeyDown(event) + return + } + + if (key === 'Enter') { + event.preventDefault() + if (isMenuVisible === false) { + triggerMenuShow() + return + } + if (selectedId !== INVALID_ID) { + const selectedElement = getCurrentFromRef( + menuElementRef + ).querySelector( + `[${ITEM_ID_DATA_ATTRIBUTE_NAME}='${selectedId}']` + ) + if (selectedElement === null) { + throw new Error('`selectedElement` is `null`') + } + selectedElement.checked = true + const changeEvent = new window.Event('change', { + bubbles: true, + cancelable: true + }) + selectedElement.dispatchEvent(changeEvent) + } + triggerMenuHide() + return + } + + if (key === 'Tab') { + triggerMenuHide() + return + } + }, + [ + clearOnEscapeKeyDown, + handleScrollableMenuKeyDown, + isMenuVisible, + onKeyDown, + onSearchValueInput, + onValueChange, + propSearchValue, + propagateEscapeKeyDown, + selectedId, + triggerMenuHide, + triggerMenuShow, + triggerRootBlur + ] + ) + + const handleRootMouseDown = useCallback( + function (event: Event.onMouseDown) { + // `mousedown` events from `menuElement` are stopped from propagating to `rootElement` by `handleMenuMouseDown` + onMouseDown(event) + if (isMenuVisible === false) { + triggerMenuShow() + } + }, + [isMenuVisible, onMouseDown, triggerMenuShow] + ) + + const handleMenuMouseDown = useCallback(function ( + event: Event.onMouseDown + ) { + // Stop the `mousedown` event from propagating to the `rootElement` + event.stopPropagation() + }, []) + + const handleOptionChange = useCallback( + function (event: Event.onChange) { + onChange(event) + const id = event.currentTarget.getAttribute(ITEM_ID_DATA_ATTRIBUTE_NAME) + if (id === null) { + throw new Error('`id` is `null`') + } + const optionValue = filteredOptions[ + parseInt(id, 10) + ] as SearchDropdownOptionValue + const newValue = optionValue.value + onValueChange(newValue) + + // Clear the search state + setIsSearching(false) + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + + // Hide menu after selection + triggerMenuHide() + }, + [ + filteredOptions, + onChange, + onValueChange, + onSearchValueInput, + propSearchValue, + triggerMenuHide + ] + ) + + const handleSelectedOptionClick = useCallback( + function () { + triggerRootFocus() + triggerMenuHide() + }, + [triggerMenuHide, triggerRootFocus] + ) + + const handleMouseDownOutside = useCallback( + function () { + if (isMenuVisible === false) { + return + } + triggerMenuHide() + triggerRootBlur() + }, + [isMenuVisible, triggerRootBlur, triggerMenuHide] + ) + useMouseDownOutside({ + onMouseDownOutside: handleMouseDownOutside, + ref: rootElementRef + }) + + // Update menu position when the window resizes + useEffect(() => { + function handleWindowResize() { + if (isMenuVisible) { + updateMenuPosition() + } + } + + window.addEventListener('resize', handleWindowResize) + return () => { + window.removeEventListener('resize', handleWindowResize) + } + }, [isMenuVisible, updateMenuPosition]) + + useEffect( + function () { + function handleWindowScroll() { + if (isMenuVisible === false) { + return + } + updateMenuPosition() + } + window.addEventListener('scroll', handleWindowScroll) + return function () { + window.removeEventListener('scroll', handleWindowScroll) + } + }, + [isMenuVisible, updateMenuPosition] + ) + + // Effect to update menu position after it becomes visible + useEffect(() => { + if (isMenuVisible) { + // Update position once menu is rendered + setTimeout(() => { + updateMenuPosition() + }, 0) + } + }, [isMenuVisible, updateMenuPosition]) + + const refCallback = useCallback( + function (rootElement: null | HTMLDivElement) { + rootElementRef.current = rootElement + if (ref === null) { + return + } + if (typeof ref === 'function') { + ref(rootElement) + return + } + ref.current = rootElement + }, + [ref] + ) + + const inputRefCallback = useCallback(function ( + inputElement: null | HTMLInputElement + ) { + inputElementRef.current = inputElement + }, []) + + return ( +
+
+ {typeof icon === 'undefined' ? ( +
+ +
+ ) : ( +
{icon}
+ )} + + {/* Render the clear button if either a value is selected or the user is searching */} + {(searchValue !== EMPTY_STRING || value !== null) && !disabled ? ( + + ) : null} + {/* Only render the chevron if not searching and no value is selected */} + {searchValue === EMPTY_STRING && value === null && ( +
+ +
+ )} +
+
+ {value === null ? ( + placeholder ? ( + {placeholder} + ) : null + ) : ( + {children} + )} +
+ {isMenuVisible && + createPortal( +
+
+ {filteredOptions.map(function ( + option: SearchDropdownOption, + index: number + ) { + if (typeof option === 'string') { + return
+ } + if ('header' in option) { + return ( +

+ {option.header} +

+ ) + } + return ( + + ) + })} +
+
, + document.body + )} +
+ ) +}) + +function getDropdownOptionValue( + option: SearchDropdownOption +): ComponentChildren { + if (typeof option !== 'string') { + if ('text' in option) { + return option.text + } + if ('value' in option) { + return option.value + } + } + throw new Error('Invariant violation') +} + +// Returns the index of the option in `options` with the given `value`, else `-1` +function findOptionIndexByValue( + options: Array, + value: null | string +): number { + if (value === null) { + return -1 + } + let index = 0 + for (const option of options) { + if ( + typeof option !== 'string' && + 'value' in option && + option.value === value + ) { + return index + } + index += 1 + } + return -1 +} diff --git a/packages/ui/src/components/search-dropdown/stories/search-dropdown.stories.tsx b/packages/ui/src/components/search-dropdown/stories/search-dropdown.stories.tsx new file mode 100644 index 00000000..04fbb657 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/stories/search-dropdown.stories.tsx @@ -0,0 +1,373 @@ +/* eslint-disable no-console */ +import { h } from 'preact' +import { useState } from 'preact/hooks' + +import { useInitialFocus } from '../../../hooks/use-initial-focus/use-initial-focus.js' +import { IconFrame32 } from '../../../icons/icon-32/icon-frame-32.js' +import { SearchDropdown, SearchDropdownOption } from '../search-dropdown.js' + +export default { + tags: ['2'], + title: 'Components/Search Dropdown' +} + +export const Empty = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} + +export const Focused = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} + +export const Selected = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState('banana') + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} + +export const WithSections = function () { + const options = [ + { header: 'Fruits' }, + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + '-', + { header: 'Vegetables' }, + { text: 'Carrot', value: 'carrot' }, + { text: 'Celery', value: 'celery' }, + { text: 'Cucumber', value: 'cucumber' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} + +export const Disabled = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { disabled: true, text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { disabled: true, text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} + +export const WithIcon = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( + } + onSearchValueInput={handleSearchInput} + onValueChange={handleValueChange} + options={options} + placeholder="Choose a fruit…" + searchValue={searchValue} + value={value} + /> + ) +} + +export const WithClearOnEscape = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState('banana') + + function handleSearchInput(newValue: string) { + console.log(newValue) + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + console.log(newValue) + setValue(newValue) + } + + return ( +
+

+ Press Escape key to clear the selection +

+ +
+ ) +} + +export const CornerPositions = function () { + const options = [ + { text: 'Apple', value: 'apple' }, + { text: 'Banana', value: 'banana' }, + { text: 'Blueberry', value: 'blueberry' }, + { text: 'Cherry', value: 'cherry' }, + { text: 'Grape', value: 'grape' }, + { text: 'Orange', value: 'orange' }, + { text: 'Pear', value: 'pear' }, + { text: 'Strawberry', value: 'strawberry' } + ] + + const [searchValue, setSearchValue] = useState('') + const [value, setValue] = useState(null) + + function handleSearchInput(newValue: string) { + setSearchValue(newValue) + } + + function handleValueChange(newValue: string | null) { + setValue(newValue) + } + + const containerStyle = { + display: 'grid', + gap: '16px', + gridTemplateColumns: 'repeat(2, 1fr)', + height: '100vh', + padding: '16px' + } + + const dropdownStyle = { + width: '240px' + } + + return ( +
+ {/* Bottom Left */} +
+ +
+ + {/* Bottom Right */} +
+ +
+ + {/* Top Left */} +
+ +
+ + {/* Top Right */} +
+ +
+
+ ) +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ec5ace55..dc5452c4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -56,6 +56,14 @@ export { RangeSlider, RangeSliderProps } from './components/range-slider/range-slider.js' +export { + SearchDropdown, + SearchDropdownOption, + SearchDropdownOptionHeader, + SearchDropdownOptionSeparator, + SearchDropdownOptionValue, + SearchDropdownProps +} from './components/search-dropdown/search-dropdown.js' export { SearchTextbox, SearchTextboxProps