diff --git a/src/components/RangeSelect/RangeSelect.stories.tsx b/src/components/RangeSelect/RangeSelect.stories.tsx new file mode 100644 index 0000000..d910077 --- /dev/null +++ b/src/components/RangeSelect/RangeSelect.stories.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { Meta, StoryFn } from '@storybook/react-webpack5'; +import { RangeSelect } from './RangeSelect'; + +export default { + title: 'Components/RangeSelect', + component: RangeSelect, + argTypes: { + placeholder: { control: { type: 'text' } }, + isDisabled: { control: { type: 'boolean' } }, + isInvalid: { control: { type: 'boolean' } }, + isFullWidth: { control: { type: 'boolean' } }, + onChange: { action: 'Range Changed' }, + onClose: { action: 'Dropdown Closed' }, + color: { + control: { type: 'text' }, + }, + backgroundColor: { + control: { type: 'text' }, + }, + borderColor: { + control: { type: 'text' }, + }, + size: { + options: ['xs', 'sm', 'md', 'lg'], + defaultValue: 'md', + control: { type: 'radio' }, + }, + variant: { + options: ['filled', 'unstyled', 'flushed', 'outline'], + defaultValue: 'outline', + control: { type: 'radio' }, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + placeholder: 'Select a range', + rangeFromLabel: 'From', + rangeToLabel: 'To', +}; + +export const WithValidation: StoryFn = (args) => { + const [value, setValue] = useState(['', '']); + const [fromError, setFromError] = useState(''); + const [toError, setToError] = useState(''); + + const handleChange = (newValue: string[]) => { + setValue(newValue); + + // Validation logic + const rangeMin = 1; + const rangeMax = 100; + const fromStr = newValue[0] ?? ''; + const toStr = newValue[1] ?? ''; + const fromNum = fromStr === '' ? undefined : Number(fromStr); + const toNum = toStr === '' ? undefined : Number(toStr); + + let nextFromError = ''; + let nextToError = ''; + + if (fromNum !== undefined && !Number.isNaN(fromNum)) { + if (fromNum < rangeMin) nextFromError = `Value must be at least ${rangeMin}`; + else if (fromNum > rangeMax) nextFromError = `Value must not exceed ${rangeMax}`; + } + + if (toNum !== undefined && !Number.isNaN(toNum)) { + if (toNum < rangeMin) nextToError = `Value must be at least ${rangeMin}`; + else if (toNum > rangeMax) nextToError = `Value must not exceed ${rangeMax}`; + } + + if ( + nextFromError === '' && + nextToError === '' && + fromNum !== undefined && + toNum !== undefined && + !Number.isNaN(fromNum) && + !Number.isNaN(toNum) && + fromNum > toNum + ) { + nextFromError = 'The minimum value cannot be greater than the maximum value'; + } + + setFromError(nextFromError); + setToError(nextToError); + }; + + return ( + + ); +}; + +WithValidation.args = { + ...Default.args, + placeholder: 'Select a range (1-100)', +}; diff --git a/src/components/RangeSelect/RangeSelect.test.tsx b/src/components/RangeSelect/RangeSelect.test.tsx new file mode 100644 index 0000000..8df5712 --- /dev/null +++ b/src/components/RangeSelect/RangeSelect.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { act } from 'react'; +import { fireEvent, render, screen, waitFor } from '../../test/testUtils'; +import { RangeSelect } from '.'; + +describe('The RangeSelect component', () => { + it('renders with placeholder text', () => { + render(); + expect(screen.getByText('Select a range')).toBeInTheDocument(); + }); + + it('opens dropdown and shows From/To labels when clicked', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await act(async () => { + fireEvent.click(combobox); + }); + + await waitFor(() => { + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + }); + }); + + it('handles range input changes', async () => { + const handleChange = jest.fn(); + + render(); + + const toggleButton = screen.getByRole('combobox'); + + await act(async () => { + fireEvent.click(toggleButton); + }); + + const inputFields = screen.getAllByRole('spinbutton'); + + fireEvent.change(inputFields[0], { + target: { value: '1' }, + }); + fireEvent.change(inputFields[1], { + target: { value: '100' }, + }); + + expect(handleChange).toHaveBeenCalledWith(['1', '100']); + }); + + it('displays error messages when error props are provided', async () => { + render( + + ); + + const toggleButton = screen.getByRole('combobox'); + await act(async () => { + fireEvent.click(toggleButton); + }); + + expect(screen.getByText('Value must be at least 5')).toBeInTheDocument(); + expect(screen.getByText('Value must be at most 10')).toBeInTheDocument(); + }); + + it('displays the range in the trigger when values are set', () => { + render(); + expect(screen.getByText('10 – 50')).toBeInTheDocument(); + }); + + it('displays partial range when only one value is set', () => { + render(); + expect(screen.getByText('10 – ...')).toBeInTheDocument(); + }); + + it('calls onClose when dropdown is closed', async () => { + const onCloseMock = jest.fn(); + render(); + + const toggleButton = screen.getByRole('combobox'); + + await act(async () => { + fireEvent.click(toggleButton); + }); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + await act(async () => { + fireEvent.click(toggleButton); + }); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/RangeSelect/RangeSelect.tsx b/src/components/RangeSelect/RangeSelect.tsx new file mode 100644 index 0000000..c2312da --- /dev/null +++ b/src/components/RangeSelect/RangeSelect.tsx @@ -0,0 +1,246 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Box, Input, Text, Flex, Button, Popover, useToken } from '@chakra-ui/react'; +import { CaretDownIcon, CaretUpIcon, WarningOctagonIcon } from '@phosphor-icons/react'; +import { selectRecipe } from '../../constants/componentCustomizations'; + +export interface RangeSelectProps + extends Omit, 'onChange' | 'color'> { + borderColor?: string; + backgroundColor?: string; + color?: string; + clearText?: string; + placeholder?: string; + isDisabled?: boolean; + isInvalid?: boolean; + isFullWidth?: boolean; + rangeFromLabel?: string; + rangeToLabel?: string; + rangeFromError?: string; + rangeToError?: string; + value?: string[]; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + dropdownWidth?: '3xs' | '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + variant?: 'filled' | 'unstyled' | 'flushed' | 'outline'; + onChange?: (value: string[]) => void; + onClose?: () => void; +} + +export const RangeSelect: React.FC = ({ + color = 'black', + placeholder = 'Select a range', + clearText = 'Clear', + isDisabled = false, + isInvalid = false, + isFullWidth = true, + rangeFromLabel = 'From', + rangeToLabel = 'To', + rangeFromError, + rangeToError, + size = 'md', + dropdownWidth, + variant = 'outline', + borderColor = selectRecipe.variants?.visual[variant]?.borderColor, + backgroundColor = selectRecipe.variants?.visual[variant]?.backgroundColor, + value, + onChange, + onClose, + ...rest +}) => { + const [isOpen, setIsOpen] = useState(false); + const [internalSelectedOptions, setInternalSelectedOptions] = useState(value ?? []); + const [menuWidth, setMenuWidth] = useState('auto'); + const menuButtonRef = useRef(null); + const [red500] = useToken('colors', ['red.500']); + const [space3] = useToken('space', ['3']); + + // Sync internal state when value prop changes from outside + const prevValueRef = useRef(value); + useEffect(() => { + if (value !== undefined && value !== prevValueRef.current) { + setInternalSelectedOptions(value); + prevValueRef.current = value; + } + }, [value]); + + const selectedOptions = internalSelectedOptions; + + useEffect(() => { + if (!menuButtonRef.current) return; + const observer = new ResizeObserver(() => { + if (menuButtonRef.current) { + setMenuWidth(`${menuButtonRef.current.offsetWidth}px`); + } + }); + observer.observe(menuButtonRef.current); + return () => observer.disconnect(); + }, []); + + const handleRangeChange = useCallback( + (index: number, inputValue: string) => { + const newValue = [...selectedOptions]; + while (newValue.length < 2) newValue.push(''); + newValue[index] = inputValue; + + setInternalSelectedOptions(newValue); + if (onChange) { + onChange(newValue); + } + }, + [selectedOptions, onChange] + ); + + const onClear = useCallback(() => { + setInternalSelectedOptions([]); + if (onChange) { + onChange([]); + } + }, [onChange]); + + const displayText = useMemo(() => { + if (selectedOptions[0] || selectedOptions[1]) { + return `${selectedOptions[0] || '...'} – ${selectedOptions[1] || '...'}`; + } + return placeholder; + }, [selectedOptions, placeholder]); + + return ( + + { + setIsOpen(details.open); + if (!details.open && onClose) { + onClose(); + } + }} + positioning={{ placement: 'bottom-start', flip: false, sameWidth: false }} + > + {/* Trigger */} + + + + {displayText} + + + {isOpen ? : } + + + + + + + + + + + + + + {rangeFromLabel} + + handleRangeChange(0, e.target.value)} + borderRadius="md" + borderColor={rangeFromError ? red500 : 'gray.200'} + borderWidth={rangeFromError ? '2px' : '1px'} + _focus={{ + borderWidth: '2px', + borderColor: rangeFromError ? red500 : 'black', + }} + /> + {rangeFromError && ( + + + + {rangeFromError} + + + )} + + + + {rangeToLabel} + + handleRangeChange(1, e.target.value)} + borderRadius="md" + borderColor={rangeToError ? red500 : 'gray.200'} + borderWidth={rangeToError ? '2px' : '1px'} + _focus={{ + borderWidth: '2px', + borderColor: rangeToError ? red500 : 'black', + }} + /> + {rangeToError && ( + + + + {rangeToError} + + + )} + + + + + + + + ); +}; diff --git a/src/components/RangeSelect/index.ts b/src/components/RangeSelect/index.ts new file mode 100644 index 0000000..2995b20 --- /dev/null +++ b/src/components/RangeSelect/index.ts @@ -0,0 +1 @@ +export { RangeSelect, type RangeSelectProps } from './RangeSelect'; diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index edf59d4..7b7b885 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -74,16 +74,23 @@ export const BoemlySelect: React.FC = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - const [internalSelectedOptions, setInternalSelectedOptions] = useState([]); + const [internalSelectedOptions, setInternalSelectedOptions] = useState(value ?? []); const [menuWidth, setMenuWidth] = useState('auto'); const inputRef = useRef(null); const menuButtonRef = useRef(null); const [primary500] = useToken('colors', ['primary.500']); const [space3] = useToken('space', ['3']); - // Use controlled value if provided, otherwise use internal state - const isControlled = value !== undefined; - const selectedOptions = (isControlled ? value : internalSelectedOptions) ?? []; + // Sync internal state when the value prop changes from the parent + const prevValueRef = useRef(value); + useEffect(() => { + if (value !== undefined && JSON.stringify(value) !== JSON.stringify(prevValueRef.current)) { + setInternalSelectedOptions(value); + prevValueRef.current = value; + } + }, [value]); + + const selectedOptions = internalSelectedOptions; const filteredOptions = useMemo(() => { if (isSearchable && searchTerm) { @@ -155,17 +162,12 @@ export const BoemlySelect: React.FC = ({ return newSelectedOptions; }; - // Only update internal state if not controlled - if (!isControlled) { - setInternalSelectedOptions((prev) => updateSelection(prev)); - } else { - // For controlled mode, just call onChange and let parent handle state - updateSelection(selectedOptions); - } + const newSelectedOptions = updateSelection(internalSelectedOptions); + setInternalSelectedOptions(newSelectedOptions); setSearchTerm(''); // Clear search term after selection }, - [isMultiple, onChange, preventDeselection, isControlled, selectedOptions] + [isMultiple, onChange, preventDeselection, internalSelectedOptions] ); const onSelectAll = useCallback(() => { @@ -173,22 +175,18 @@ export const BoemlySelect: React.FC = ({ .filter((option) => !option.disabled) .map((option) => option.value); - if (!isControlled) { - setInternalSelectedOptions(enabledFilteredOptions); - } + setInternalSelectedOptions(enabledFilteredOptions); if (onChange) { onChange(enabledFilteredOptions); } - }, [filteredOptions, onChange, isControlled]); + }, [filteredOptions, onChange]); const onClearAll = useCallback(() => { - if (!isControlled) { - setInternalSelectedOptions([]); - } + setInternalSelectedOptions([]); if (onChange) { onChange([]); } - }, [onChange, isControlled]); + }, [onChange]); // max height for the menu options, to show that there are more items to scroll const dynamicMaxHeight = useMemo(() => { diff --git a/src/index.tsx b/src/index.tsx index 73f35da..b28383b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -40,6 +40,7 @@ export { BoemlyThemeProvider } from './components/BoemlyThemeProvider'; export { Wrapper } from './components/Wrapper'; export { ConfirmAction } from './components/ConfirmAction'; export { Select, type BoemlySelectProps } from './components/Select'; +export { RangeSelect, type RangeSelectProps } from './components/RangeSelect'; export { SubmissionConfirm } from './components/SubmissionConfirm'; export {