From defbd29bb4a37262b72c05397c1b5aef04c36638 Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Sun, 30 Mar 2025 09:15:32 +0000 Subject: [PATCH 01/27] fix(search-dropdown): rename CSS file to resolve import error - Renamed search-dropdown.modules.css to search-dropdown.module.css - Updated import path in search-dropdown.tsx accordingly --- .../search-dropdown/private/types.ts | 3 + .../private/update-menu-element-layout.ts | 147 +++++ .../search-dropdown.module.css | 123 ++++ .../search-dropdown/search-dropdown.tsx | 575 ++++++++++++++++++ .../stories/search-dropdown.stories.tsx | 237 ++++++++ 5 files changed, 1085 insertions(+) create mode 100644 packages/ui/src/components/search-dropdown/private/types.ts create mode 100644 packages/ui/src/components/search-dropdown/private/update-menu-element-layout.ts create mode 100644 packages/ui/src/components/search-dropdown/search-dropdown.module.css create mode 100644 packages/ui/src/components/search-dropdown/search-dropdown.tsx create mode 100644 packages/ui/src/components/search-dropdown/stories/search-dropdown.stories.tsx 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 000000000..6dcd3c462 --- /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 000000000..aef33f71c --- /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 000000000..71d93a6e3 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/search-dropdown.module.css @@ -0,0 +1,123 @@ +.searchDropdown { + position: relative; + z-index: var(--z-index-1); + display: inline-block; + width: 100%; + height: var(--space-36); + padding: var(--space-8) var(--space-12); + border: var(--border-width-1) solid var(--figma-color-border); + border-radius: var(--border-radius-6); + background-color: var(--figma-color-bg); + cursor: default; +} + +.searchDropdown:focus { + border-color: var(--figma-color-border-selected); +} + +.disabled, +.disabled * { + cursor: not-allowed; +} + +.value { + overflow: hidden; + color: var(--figma-color-text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.placeholder { + color: var(--figma-color-text-tertiary); +} + +.disabled .value { + opacity: var(--opacity-30); +} + +.menu { + display: flex; + width: auto; + max-height: 80vh; + flex-direction: column; + padding: 0; +} + +.searchContainer { + position: sticky; + z-index: var(--z-index-2); + 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); + border-radius: var(--border-radius-6); + 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; +} + +.searchIcon { + position: absolute; + left: var(--space-8); + width: var(--space-16); + height: var(--space-16); + color: var(--figma-color-icon); + pointer-events: none; +} + +.clearButton { + position: absolute; + right: var(--space-8); + display: flex; + width: var(--space-24); + height: var(--space-24); + align-items: center; + justify-content: center; + padding: 0; + border: 0; + margin: 0; + background: none; +} + +.clearButtonBox { + display: flex; + width: var(--space-16); + height: var(--space-16); + align-items: center; + justify-content: center; + border-radius: var(--border-radius-2); + color: var(--figma-color-icon); +} + +.clearButton:hover .clearButtonBox { + background-color: var(--figma-color-bg-hover); +} + +.clearButton:active .clearButtonBox { + background-color: var(--figma-color-bg-pressed); + color: var(--figma-color-icon); +} + +.clearButton:focus .clearButtonBox { + 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 000000000..c18d956d2 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,575 @@ +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 +} + +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, + ...rest + }, + ref +) { + // Allow controlled or uncontrolled search input: + const [internalSearchValue, setInternalSearchValue] = useState('') + const searchValue = + propSearchValue !== undefined ? propSearchValue : internalSearchValue + + const rootElementRef: RefObject = useRef(null) + const menuElementRef: RefObject = useRef(null) + const inputElementRef: RefObject = useRef(null) + + const [isMenuVisible, setIsMenuVisible] = useState(false) + const [filteredOptions, setFilteredOptions] = useState(options) + + 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) => { + if (typeof option === 'string') { + return true // Keep separators + } + if ('header' in option) { + return true // Keep headers + } + // 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 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 the menu and update the `selectedId` on focus + setIsMenuVisible(true) + 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, value] + ) + + const handleClearButtonClick = useCallback( + function () { + const inputElement = getCurrentFromRef(inputElementRef) + inputElement.value = EMPTY_STRING + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + const inputEvent = new window.Event('input', { + bubbles: true, + cancelable: true + }) + inputElement.dispatchEvent(inputEvent) + inputElement.focus() + }, + [onSearchValueInput, propSearchValue] + ) + + const handleFocus = useCallback( + function (event: Event.onFocus) { + onFocus(event) + event.currentTarget.select() + }, + [onFocus] + ) + + const handleInput = useCallback( + function (event: Event.onInput) { + const newValue = event.currentTarget.value + if (propSearchValue === undefined) { + setInternalSearchValue(newValue) + } + onSearchValueInput(newValue) + }, + [onSearchValueInput, propSearchValue] + ) + + const handleRootKeyDown = useCallback( + function (event: Event.onKeyDown) { + onKeyDown(event) + const key = event.key + + if (key === 'Escape') { + event.preventDefault() + if (clearOnEscapeKeyDown === true && searchValue !== EMPTY_STRING) { + event.stopPropagation() // Clear the value without bubbling up the `Escape` key press + handleClearButtonClick() + 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, + handleClearButtonClick, + handleScrollableMenuKeyDown, + isMenuVisible, + onKeyDown, + propagateEscapeKeyDown, + searchValue, + 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 input + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + // Select `root`, then hide the menu + triggerRootFocus() + triggerMenuHide() + }, + [ + filteredOptions, + onChange, + onValueChange, + onSearchValueInput, + propSearchValue, + triggerMenuHide, + triggerRootFocus + ] + ) + + 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 + }) + + useEffect( + function () { + function handleWindowScroll() { + if (isMenuVisible === false) { + return + } + triggerRootFocus() + triggerMenuHide() + } + window.addEventListener('scroll', handleWindowScroll) + return function () { + window.removeEventListener('scroll', handleWindowScroll) + } + }, + [isMenuVisible, triggerMenuHide, triggerRootFocus] + ) + + 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' ? null : ( +
{icon}
+ )} +
+ +
+ + {searchValue === EMPTY_STRING || disabled === true ? null : ( + + )} +
+ +
+
+
+ {value === null ? ( +
+ {placeholder} +
+ ) : ( +
{children}
+ )} +
+ {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 000000000..f1c8b26f7 --- /dev/null +++ b/packages/ui/src/components/search-dropdown/stories/search-dropdown.stories.tsx @@ -0,0 +1,237 @@ +/* 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 { SearchDropdown, SearchDropdownOption } from '../search-dropdown.js' + +export default { + tags: ['2'], + title: 'Components/SearchDropdown' +} + +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) { + 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) { + 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) { + 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) { + 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) { + 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) { + console.log(newValue) + setValue(newValue) + } + + return ( + + ) +} From 9c53a3332944901db31c8ba8cac6682357f99533 Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Sun, 30 Mar 2025 09:35:30 +0000 Subject: [PATCH 02/27] feat(search-dropdown): add SearchDropdown components to index export --- packages/ui/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ec5ace550..dc5452c40 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 From 084a8ff9541d1f8b03b138bce7b7eed5f13b3071 Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Sun, 30 Mar 2025 10:10:36 +0000 Subject: [PATCH 03/27] fix eslint --- .../search-dropdown.module.css | 191 +++++++++---- .../search-dropdown/search-dropdown.tsx | 257 +++++++++++------- 2 files changed, 297 insertions(+), 151 deletions(-) diff --git a/packages/ui/src/components/search-dropdown/search-dropdown.module.css b/packages/ui/src/components/search-dropdown/search-dropdown.module.css index 71d93a6e3..8e0b0da1a 100644 --- a/packages/ui/src/components/search-dropdown/search-dropdown.module.css +++ b/packages/ui/src/components/search-dropdown/search-dropdown.module.css @@ -20,74 +20,60 @@ cursor: not-allowed; } -.value { - overflow: hidden; - color: var(--figma-color-text); - text-overflow: ellipsis; - white-space: nowrap; -} - -.placeholder { - color: var(--figma-color-text-tertiary); -} - -.disabled .value { - opacity: var(--opacity-30); -} - -.menu { - display: flex; - width: auto; - max-height: 80vh; - flex-direction: column; - padding: 0; -} - -.searchContainer { - position: sticky; - z-index: var(--z-index-2); - 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 { +.inputContainer { position: relative; display: flex; width: 100%; align-items: center; } -.searchInput { +.input { 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); - border-radius: var(--border-radius-6); - background-color: var(--figma-color-bg); + height: var(--space-20); + padding: var(--space-0) var(--space-28) var(--space-0) var(--space-24); + border: none; + background: none; color: var(--figma-color-text); font-size: var(--font-size-12); } -.searchInput:focus { - border-color: var(--figma-color-border-selected); - outline: 0; +.input:focus { + outline: none; +} + +.input:disabled { + color: var(--figma-color-text-disabled); + cursor: not-allowed; +} + +.icon { + position: absolute; + left: 0; + display: flex; + width: var(--space-24); + height: var(--space-24); + align-items: center; + justify-content: center; + color: var(--figma-color-icon); + pointer-events: none; } .searchIcon { position: absolute; - left: var(--space-8); - width: var(--space-16); - height: var(--space-16); + left: 0; + display: flex; + width: var(--space-24); + height: var(--space-24); + align-items: center; + justify-content: center; color: var(--figma-color-icon); pointer-events: none; } .clearButton { position: absolute; - right: var(--space-8); + z-index: var(--z-index-2); + right: var(--space-24); display: flex; width: var(--space-24); height: var(--space-24); @@ -97,16 +83,11 @@ border: 0; margin: 0; background: none; + cursor: pointer; } .clearButtonBox { - display: flex; - width: var(--space-16); - height: var(--space-16); - align-items: center; - justify-content: center; - border-radius: var(--border-radius-2); - color: var(--figma-color-icon); + cursor: pointer; } .clearButton:hover .clearButtonBox { @@ -121,3 +102,105 @@ .clearButton:focus .clearButtonBox { outline: 0; } + +.chevronIcon { + position: absolute; + right: 0; + display: flex; + width: var(--space-24); + height: var(--space-24); + align-items: center; + justify-content: center; + color: var(--figma-color-icon); + pointer-events: none; +} + +.valueDisplay { + display: none; +} + +/* Position the menu in the document body */ +:global(.menu-container) { + position: fixed; + z-index: var(--z-index-5); +} + +.menu { + display: flex; + width: 100%; + max-height: 80vh; + flex-direction: column; + padding: 0; + border: var(--border-width-1) solid var(--figma-color-border); + border-radius: var(--border-radius-6); + margin-top: var(--space-4); + background-color: var(--figma-color-bg); + box-shadow: var(--shadow-1); + color: var(--figma-color-text); +} + +.menu :global(.optionValue) { + display: flex; + min-height: var(--space-32); + align-items: center; + padding: var(--space-8) var(--space-12); + color: var(--figma-color-text); +} + +.menu :global(.optionValue:hover) { + background-color: var(--figma-color-bg-hover); +} + +.menu :global(.optionValueSelected) { + background-color: var(--figma-color-bg-selected); +} + +.menu :global(.optionValueDisabled) { + color: var(--figma-color-text-disabled); + cursor: not-allowed; +} + +.menu :global(.optionHeader) { + padding: var(--space-8) var(--space-12); + color: var(--figma-color-text-secondary); + font-weight: var(--font-weight-bold); +} + +.menu :global(.optionSeparator) { + border: none; + border-top: var(--border-width-1) solid var(--figma-color-border); + margin: var(--space-2) 0; +} + +.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); + border-radius: var(--border-radius-6); + 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 index c18d956d2..a6e73a8ae 100644 --- a/packages/ui/src/components/search-dropdown/search-dropdown.tsx +++ b/packages/ui/src/components/search-dropdown/search-dropdown.tsx @@ -95,9 +95,15 @@ export const SearchDropdown = createComponent< const rootElementRef: RefObject = useRef(null) const menuElementRef: RefObject = useRef(null) const inputElementRef: RefObject = useRef(null) + const menuContainerRef: RefObject = useRef(null) const [isMenuVisible, setIsMenuVisible] = useState(false) const [filteredOptions, setFilteredOptions] = useState(options) + const [menuPosition, setMenuPosition] = useState({ + left: 0, + top: 0, + width: 0 + }) const index = findOptionIndexByValue(options, value) if (value !== null && index === -1) { @@ -147,6 +153,18 @@ export const SearchDropdown = createComponent< getCurrentFromRef(rootElementRef).focus() }, []) + const updateMenuPosition = useCallback(function () { + const rootElement = getCurrentFromRef(rootElementRef) + if (rootElement) { + const rect = rootElement.getBoundingClientRect() + setMenuPosition({ + left: rect.left, + top: rect.bottom, + width: rect.width + }) + } + }, []) + const triggerMenuUpdateLayout = useCallback(function (selectedId: Id) { const rootElement = getCurrentFromRef(rootElementRef) const menuElement = getCurrentFromRef(menuElementRef) @@ -163,6 +181,9 @@ export const SearchDropdown = createComponent< if (isMenuVisible === true) { return } + // Update the menu position + updateMenuPosition() + // Show the menu and update the `selectedId` on focus setIsMenuVisible(true) if (value === null) { @@ -177,23 +198,35 @@ export const SearchDropdown = createComponent< setSelectedId(newSelectedId) triggerMenuUpdateLayout(newSelectedId) }, - [isMenuVisible, options, selectedId, triggerMenuUpdateLayout, value] + [ + isMenuVisible, + options, + selectedId, + triggerMenuUpdateLayout, + updateMenuPosition, + value + ] ) const handleClearButtonClick = useCallback( - function () { + function (event: { stopPropagation: () => void }) { + // Stop propagation to prevent dropdown from showing + event.stopPropagation() + const inputElement = getCurrentFromRef(inputElementRef) - inputElement.value = EMPTY_STRING - if (propSearchValue === undefined) { - setInternalSearchValue(EMPTY_STRING) + if (inputElement) { + inputElement.value = EMPTY_STRING + if (propSearchValue === undefined) { + setInternalSearchValue(EMPTY_STRING) + } + onSearchValueInput(EMPTY_STRING) + const inputEvent = new window.Event('input', { + bubbles: true, + cancelable: true + }) + inputElement.dispatchEvent(inputEvent) + inputElement.focus() } - onSearchValueInput(EMPTY_STRING) - const inputEvent = new window.Event('input', { - bubbles: true, - cancelable: true - }) - inputElement.dispatchEvent(inputEvent) - inputElement.focus() }, [onSearchValueInput, propSearchValue] ) @@ -213,8 +246,13 @@ export const SearchDropdown = createComponent< setInternalSearchValue(newValue) } onSearchValueInput(newValue) + + // Show menu when typing + if (!isMenuVisible) { + triggerMenuShow() + } }, - [onSearchValueInput, propSearchValue] + [isMenuVisible, onSearchValueInput, propSearchValue, triggerMenuShow] ) const handleRootKeyDown = useCallback( @@ -226,7 +264,7 @@ export const SearchDropdown = createComponent< event.preventDefault() if (clearOnEscapeKeyDown === true && searchValue !== EMPTY_STRING) { event.stopPropagation() // Clear the value without bubbling up the `Escape` key press - handleClearButtonClick() + handleClearButtonClick(event) return } if (propagateEscapeKeyDown === false) { @@ -326,11 +364,13 @@ export const SearchDropdown = createComponent< ] as SearchDropdownOptionValue const newValue = optionValue.value onValueChange(newValue) + // Clear the search input if (propSearchValue === undefined) { setInternalSearchValue(EMPTY_STRING) } onSearchValueInput(EMPTY_STRING) + // Select `root`, then hide the menu triggerRootFocus() triggerMenuHide() @@ -369,21 +409,34 @@ export const SearchDropdown = createComponent< 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 } - triggerRootFocus() - triggerMenuHide() + updateMenuPosition() } window.addEventListener('scroll', handleWindowScroll) return function () { window.removeEventListener('scroll', handleWindowScroll) } }, - [isMenuVisible, triggerMenuHide, triggerRootFocus] + [isMenuVisible, updateMenuPosition] ) const refCallback = useCallback( @@ -413,7 +466,6 @@ export const SearchDropdown = createComponent< ref={refCallback} class={createClassName([ styles.searchDropdown, - typeof icon !== 'undefined' ? styles.hasIcon : null, disabled === true ? styles.disabled : null ])} onKeyDown={disabled === true ? undefined : handleRootKeyDown} @@ -421,12 +473,13 @@ export const SearchDropdown = createComponent< tabIndex={0} >
- {typeof icon === 'undefined' ? null : ( + {typeof icon === 'undefined' ? ( +
+ +
+ ) : (
{icon}
)} -
- -
@@ -456,84 +510,93 @@ export const SearchDropdown = createComponent<
{value === null ? ( -
- {placeholder} -
+ placeholder ? ( + {placeholder} + ) : null ) : ( -
{children}
+ {children} )}
- {createPortal( -
- {filteredOptions.map(function ( - option: SearchDropdownOption, - index: number - ) { - if (typeof option === 'string') { - return
- } - if ('header' in option) { - return ( -

- {option.header} -

- ) - } - return ( - - ) - })} -
, - document.body - )} + {isMenuVisible && + createPortal( +
+
+ {filteredOptions.map(function ( + option: SearchDropdownOption, + index: number + ) { + if (typeof option === 'string') { + return
+ } + if ('header' in option) { + return ( +

+ {option.header} +

+ ) + } + return ( + + ) + })} +
+
, + document.body + )}
) }) From cf8d9cff6ee1ecff6921f14f31e8c0ca2d2d94fe Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Sun, 30 Mar 2025 10:44:14 +0000 Subject: [PATCH 04/27] fix(search-dropdown): update clear button positioning and enhance value change handling - Adjusted the clear button's CSS positioning to align it to the right. - Modified the onValueChange prop to accept null, allowing for clearer state management. - Updated the clear button rendering logic to display based on search value and selected value. - Improved option rendering to handle disabled states correctly. --- .../search-dropdown.module.css | 2 +- .../search-dropdown/search-dropdown.tsx | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/search-dropdown/search-dropdown.module.css b/packages/ui/src/components/search-dropdown/search-dropdown.module.css index 8e0b0da1a..724ec1914 100644 --- a/packages/ui/src/components/search-dropdown/search-dropdown.module.css +++ b/packages/ui/src/components/search-dropdown/search-dropdown.module.css @@ -73,7 +73,7 @@ .clearButton { position: absolute; z-index: var(--z-index-2); - right: var(--space-24); + right: 0; display: flex; width: var(--space-24); height: var(--space-24); diff --git a/packages/ui/src/components/search-dropdown/search-dropdown.tsx b/packages/ui/src/components/search-dropdown/search-dropdown.tsx index a6e73a8ae..0a529a8ca 100644 --- a/packages/ui/src/components/search-dropdown/search-dropdown.tsx +++ b/packages/ui/src/components/search-dropdown/search-dropdown.tsx @@ -51,7 +51,7 @@ export interface SearchDropdownProps onFocus?: EventHandler.onFocus onKeyDown?: EventHandler.onKeyDown onMouseDown?: EventHandler.onMouseDown - onValueChange?: EventHandler.onValueChange + onValueChange?: EventHandler.onValueChange options: Array placeholder?: string propagateEscapeKeyDown?: boolean @@ -210,9 +210,7 @@ export const SearchDropdown = createComponent< const handleClearButtonClick = useCallback( function (event: { stopPropagation: () => void }) { - // Stop propagation to prevent dropdown from showing event.stopPropagation() - const inputElement = getCurrentFromRef(inputElementRef) if (inputElement) { inputElement.value = EMPTY_STRING @@ -220,6 +218,7 @@ export const SearchDropdown = createComponent< setInternalSearchValue(EMPTY_STRING) } onSearchValueInput(EMPTY_STRING) + onValueChange(null) // Clear the selection const inputEvent = new window.Event('input', { bubbles: true, cancelable: true @@ -228,7 +227,7 @@ export const SearchDropdown = createComponent< inputElement.focus() } }, - [onSearchValueInput, propSearchValue] + [onSearchValueInput, onValueChange, propSearchValue] ) const handleFocus = useCallback( @@ -492,7 +491,8 @@ export const SearchDropdown = createComponent< type="text" value={searchValue} /> - {searchValue === EMPTY_STRING || disabled === true ? null : ( + {/* 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 ? ( @@ -541,6 +544,7 @@ export const SearchDropdown = createComponent< option: SearchDropdownOption, index: number ) { + // Keep separators and headers if (typeof option === 'string') { return
} @@ -551,15 +555,17 @@ export const SearchDropdown = createComponent< ) } + // If the option is disabled, skip rendering it in figma light mode & FigJam + if (option.disabled === true) { + return null + } return (