diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..5b8534f --- /dev/null +++ b/global.d.ts @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom'; + +declare global { + namespace Vi { + interface Assertion { + toBeInTheDocument(): void; + } + } +} diff --git a/package.json b/package.json index 968a4de..197ac52 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^26.0.19", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/packageExports.js b/packageExports.js index 0434f6b..2c3f4aa 100644 --- a/packageExports.js +++ b/packageExports.js @@ -5,6 +5,7 @@ export const packageExports = [ 'index', 'animation/index', + 'Autocomplete/index', 'Avatar/index', 'Box/index', 'breakpoints/index', @@ -14,6 +15,8 @@ export const packageExports = [ 'Card/index', 'Checkbox/index', 'ClickOutside/index', + 'Combobox/index', + 'DropdownList/index', 'IconButton/index', 'Image/index', 'Input/index', @@ -31,6 +34,7 @@ export const packageExports = [ 'Slider/index', 'SidebarNav/index', 'Switch/index', + 'Table/index', 'Tabs/index', 'TextArea/index', 'theme/index', diff --git a/src/Autocomplete/Autocomplete.spec.tsx b/src/Autocomplete/Autocomplete.spec.tsx new file mode 100644 index 0000000..acc1c8b --- /dev/null +++ b/src/Autocomplete/Autocomplete.spec.tsx @@ -0,0 +1,269 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, beforeEach, vi } from 'vitest'; +import { Autocomplete } from './Autocomplete'; +import { Input } from '../Input'; +import { PabloThemeProvider } from '../theme/PabloThemeProvider'; +import '../../testUtils/mockResizeObserver'; + +const mockOnChange = vi.fn(); + +const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); +}; + +const mockItems = [ + { + value: 'apple', + label: 'Apple', + key: 'apple', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, + { + value: 'banana', + label: 'Banana', + key: 'banana', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, + { + value: 'cherry', + key: 'cherry', + label: 'Cherry', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, +]; + +beforeEach(() => { + mockOnChange.mockClear(); +}); + +test('renders Autocomplete with input child', () => { + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('shows items when showOnEmpty is true', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('Cherry')).toBeInTheDocument(); +}); + +test('filters items based on filterTerm', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.queryByText('Banana')).not.toBeInTheDocument(); + expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); +}); + +test('calls onChange when item is selected', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const appleItem = screen.getByText('Apple'); + await user.click(appleItem); + + expect(mockOnChange).toHaveBeenCalledWith('apple'); +}); + +test('respects maxItems prop', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); +}); + +test('uses custom itemFilter when provided', async () => { + const user = userEvent.setup(); + const customFilter = vi.fn((value: string) => value.endsWith('a')); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(customFilter).toHaveBeenCalled(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); +}); + +test('uses custom toOutput function', async () => { + const user = userEvent.setup(); + const customToOutput = vi.fn((value: string) => value.toUpperCase()); + + const itemsWithCustomOutput = mockItems.map((item) => ({ + ...item, + toOutput: customToOutput, + })); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const appleItem = screen.getByText('Apple'); + await user.click(appleItem); + + expect(mockOnChange).toHaveBeenCalledWith('APPLE'); +}); + +test('handles empty items array', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); +}); + +test('clones children with onBlur handler', () => { + const originalOnBlur = vi.fn(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + fireEvent.blur(input); + expect(originalOnBlur).toHaveBeenCalled(); +}); + +test('do not blur input when clicking on autocomplete items', async () => { + const originalOnBlur = vi.fn(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + await userEvent.setup().click(input); + act(() => { + input.focus(); + }); + expect(input).toHaveFocus(); + const appleItem = screen.getByText('Apple'); + act(() => { + appleItem.click(); + }); + expect(originalOnBlur).not.toHaveBeenCalled(); + expect(input).toHaveFocus(); +}); + +test('handles wrapItems prop', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); +}); + +test('uses custom renderItem function when provided', async () => { + const user = userEvent.setup(); + const customRenderItem = vi.fn(({ label }) => `Custom: ${label}`); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(customRenderItem).toHaveBeenCalled(); + expect(screen.getByText('Custom: Apple')).toBeInTheDocument(); + expect(screen.getByText('Custom: Banana')).toBeInTheDocument(); + expect(screen.getByText('Custom: Cherry')).toBeInTheDocument(); +}); diff --git a/src/Autocomplete/Autocomplete.stories.tsx b/src/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000..301a444 --- /dev/null +++ b/src/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react'; + +import { Autocomplete } from './Autocomplete'; +import { Input } from '../Input'; +import { Box } from '../Box'; +import { Body } from '../Typography'; +import { DropdownListItem } from '../DropdownList/types'; + +export default { + title: 'Autocomplete', +}; + +const filterFn = (item: Game, searchTerm: string) => { + return ( + (item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.system.toLowerCase().includes(searchTerm.toLowerCase())) && + searchTerm.toLowerCase() !== item.name.toLowerCase() + ); +}; + +const render = ({ value }) => { + return ( + + {value.name} + + {value.system} + + + ); +}; + +const ControlledInput = ({ child, filter, ...props }) => { + const [value, setValue] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + if (value) { + setSearchTerm(value.name); + } else { + setSearchTerm(''); + } + }, [value]); + return ( + true)} + items={items} + onChange={setValue} + > + {child({ ...props, item: value, setItem: setValue, searchTerm, setSearchTerm })} + + ); +}; + +interface Game { + name: string; + system: string; +} + +const items: DropdownListItem[] = [ + { + key: 'final-fantasy-vi', + value: { + name: 'Final Fantasy VI', + system: 'SNES', + }, + }, + { + key: 'final-fight', + value: { + name: 'Final Fight', + system: 'SNES', + }, + }, + { + key: 'super-mario-bros-3', + value: { + name: 'Super Mario Bros. 3', + system: 'NES', + }, + }, + { + key: 'the-legend-of-zelda-ocarina-of-time', + value: { + name: 'The Legend of Zelda: Ocarina of Time', + system: 'N64', + }, + }, + { + key: 'the-legend-of-zelda-a-link-to-the-past', + value: { + name: 'The Legend of Zelda: A Link to the Past', + system: 'SNES', + }, + }, + { + key: 'portal-2', + value: { + name: 'Portal 2', + system: 'PC', + }, + }, + { + key: 'metal-gear-solid', + value: { + name: 'Metal Gear Solid', + system: 'PS1', + }, + }, + { + key: 'tony-hawk-pro-skater-2', + value: { + name: 'Tony Hawk Pro Skater 2', + system: 'multi system', + }, + }, +]; + +const baseStory = (args) => ; + +export const SimpleAutocomplete = baseStory.bind(null); +SimpleAutocomplete.args = { + filter: filterFn, + child: ({ searchTerm, setSearchTerm }) => { + return ; + }, +}; diff --git a/src/Autocomplete/Autocomplete.tsx b/src/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000..c06e16f --- /dev/null +++ b/src/Autocomplete/Autocomplete.tsx @@ -0,0 +1,67 @@ +import React, { cloneElement, ComponentElement, useEffect, useState } from 'react'; +import { useBlur } from '../utils/useBlur'; +import { + DropdownListFilterFn, + DropdownListItem, + DropdownListItemRenderFn, +} from '../DropdownList/types'; +import { DropdownList } from '../DropdownList/DropdownList'; +import { hijackCbBefore } from '../utils/hijackCb'; + +interface AutocompleteProps { + children: ComponentElement; + items: DropdownListItem[]; + itemFilter?: DropdownListFilterFn; + onChange: (value: O) => void; + toOutput?: (item: V) => O; + showOnEmpty?: boolean; + filterTerm?: string; + maxItems?: number; + renderItem?: DropdownListItemRenderFn; + wrapItems?: boolean; +} + +const defaultFilter: DropdownListFilterFn = (value, filterTerm) => { + const stringifiedValue = value?.toString?.(); + return ( + stringifiedValue?.toLowerCase().includes(filterTerm?.toLowerCase()) && + !filterTerm?.includes(stringifiedValue) + ); +}; + +const Autocomplete = ({ + children, + itemFilter = defaultFilter, + ...props +}: AutocompleteProps) => { + const [childElement] = useState(null); + const inputElement = childElement?.querySelector('input, textarea') as HTMLInputElement | null; + + const handleBlur = useBlur((target) => { + if (target && target.closest('[data-pbl-type=autocomplete-item]')) { + // Do not close if the blur was triggered by clicking on an autocomplete item + return; + } + }); + + useEffect(() => { + if (inputElement) { + inputElement.addEventListener('blur', handleBlur); + return () => { + inputElement.removeEventListener('blur', handleBlur); + }; + } + }, [inputElement, handleBlur]); + + const clonedChildren = cloneElement(children, { + onBlur: hijackCbBefore(children.props.onBlur, handleBlur), + }); + + return ( + + {clonedChildren} + + ); +}; + +export { Autocomplete }; diff --git a/src/Autocomplete/index.ts b/src/Autocomplete/index.ts new file mode 100644 index 0000000..a796c54 --- /dev/null +++ b/src/Autocomplete/index.ts @@ -0,0 +1 @@ +export * from './Autocomplete'; diff --git a/src/ClickOutside/ClickOutside.tsx b/src/ClickOutside/ClickOutside.tsx index d802023..e1d8f67 100644 --- a/src/ClickOutside/ClickOutside.tsx +++ b/src/ClickOutside/ClickOutside.tsx @@ -3,7 +3,7 @@ import { useReRenderForwardRef } from '../utils/useForwardRef'; export interface ClickOutsideProps { children: ReactElement; - onClickOutside: () => void; + onClickOutside: (e: MouseEvent) => void; } export const ClickOutside = forwardRef( @@ -26,7 +26,7 @@ export const ClickOutside = forwardRef( } while (targetEl); // This is a click outside. - onClickOutside(); + onClickOutside(e); }; // Wait a tick, otherwise an opening click event will be directly triggering outside click as well diff --git a/src/Combobox/Combobox.spec.tsx b/src/Combobox/Combobox.spec.tsx new file mode 100644 index 0000000..2203ae4 --- /dev/null +++ b/src/Combobox/Combobox.spec.tsx @@ -0,0 +1,172 @@ +import React, { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, beforeEach, vi } from 'vitest'; +import { Combobox } from './Combobox'; +import '../../testUtils/mockResizeObserver'; +import { PabloThemeProvider } from '../theme/PabloThemeProvider'; + +const mockOnChange = vi.fn(); +let user: ReturnType; +const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); +}; + +beforeEach(() => { + user = userEvent.setup(); + mockOnChange.mockClear(); +}); + +test('renders Combobox with input field', () => { + renderWithTheme( + + Option 1 + Option 2 + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('renders Combobox items as children', async () => { + renderWithTheme( + + Option 1 + Option 2 + + ); + + // Items should be available for selection + expect(screen.getByTestId('pbl-input')).toBeInTheDocument(); + + await user.click(screen.getByTestId('pbl-input')); + + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); +}); + +test('calls onChange when input value changes', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + Option 1 + Option 2 + + ); + + const input = screen.getByRole('textbox'); + await user.type(input, 'opt'); + + expect(mockOnChange).toHaveBeenCalled(); +}); + +test('filter options based on input', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + Football + Basketball + + ); + + await user.click(screen.getByTestId('pbl-input')); + expect(screen.getByText('Football')).toBeInTheDocument(); + expect(screen.queryByText('Basketball')).not.toBeInTheDocument(); +}); + +test('forwards ref to input element', () => { + const ref = createRef(); + + renderWithTheme( + + Option 1 + Option 2 + + ); + + expect(ref.current).toBeInTheDocument(); +}); + +test('passes input props correctly', () => { + renderWithTheme( + + Option 1 + Option 2 + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('test'); + expect(input).toHaveAttribute('placeholder', 'Search options...'); + expect(input).toBeDisabled(); +}); + +test('handles object values with custom toValue function', async () => { + const toValue = (item: { id: string; name: string }) => item.name; + + renderWithTheme( + + Apple + Banana + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + await user.click(screen.getByTestId('pbl-input')); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + await user.click(screen.getByText('Apple')); + expect(mockOnChange).toHaveBeenCalledWith('Apple'); +}); + +test('supports custom filter function', () => { + const customFilter = vi.fn((item: string, value: string) => + item.toLowerCase().includes(value.toLowerCase()) + ); + + renderWithTheme( + + Apple + Banana + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('respects maxItems prop', () => { + renderWithTheme( + + Option 1 + Option 2 + Option 3 + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('handles showOnEmpty prop', () => { + renderWithTheme( + + Option 1 + Option 2 + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('handles empty children array', () => { + renderWithTheme(); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); diff --git a/src/Combobox/Combobox.stories.tsx b/src/Combobox/Combobox.stories.tsx new file mode 100644 index 0000000..4c5f32d --- /dev/null +++ b/src/Combobox/Combobox.stories.tsx @@ -0,0 +1,235 @@ +import React, { useState } from 'react'; + +import { Search } from 'react-feather'; +import { css } from '@emotion/react'; +import { Box, Flex } from '../Box'; +import { IconButton } from '../IconButton'; +import { Body, Typography } from '../Typography'; +import { Combobox, ComboboxProps } from './Combobox'; + +export default { + title: 'Combobox', +}; + +const filter = (item, value) => { + return ( + (item.value.toLowerCase().includes(value.toLowerCase()) || + item.system.toLowerCase().includes(value.toLowerCase())) && + value.toLowerCase() !== item.value.toLowerCase() + ); +}; + +interface Game { + value: string; + system: string; + filter?: (item: Game, value: string) => boolean; + toValue?: (item: Game) => string; +} + +const ControlledInput = ({ + value: valueInitial = '', + ...props +}: Omit & { value?: string }) => { + const [value, setValue] = useState(valueInitial); + return ( + item.value} + showOnEmpty + value={value} + mb={4} + {...props} + onChange={setValue} + /> + ); +}; + +const items: Game[] = [ + { + value: 'Final Fantasy VI', + system: 'SNES', + }, + { + value: 'Final Fight', + system: 'SNES', + }, + { + value: 'Super Mario Bros. 3', + system: 'NES', + filter: (item, value) => { + return filter(item, value) || value.toLowerCase().includes('luigi'); + }, + }, + { + value: 'The Legend of Zelda: Ocarina of Time', + system: 'N64', + toValue: () => 'Zelda 5', + }, + { + value: 'The Legend of Zelda: A Link to the Past', + system: 'SNES', + toValue: () => 'Zelda 3', + }, + { + value: 'Portal 2', + system: 'PC', + }, + { + value: 'Metal Gear Solid', + system: 'PS1', + }, + { + value: 'Tony Hawk Pro Skater 2', + system: 'multi system', + }, +]; + +const Label = ({ item }) => ( + + {item.value} + + {item.system} + + +); + +const baseStory = ({ children, ...args }) => ( + + {children ?? + items.map((item) => ( + + + ))} + +); + +export const FullWidth = baseStory.bind(null); +FullWidth.args = { fullWidth: true }; + +export const EmptyChildren = baseStory.bind(null); +EmptyChildren.args = { fullWidth: true, children: [] }; + +export const FixedWidth = baseStory.bind(null); +FixedWidth.args = { width: 800 }; + +export const Inline = baseStory.bind(null); +Inline.args = {}; + +export const WithLabel = baseStory.bind(null); +WithLabel.args = { + label: <>name, +}; + +export const WithInfoText = baseStory.bind(null); +WithInfoText.args = { + infoText: ( + <> + This is something important! + + ), +}; + +export const WithLabelAndInfoText = baseStory.bind(null); +WithLabelAndInfoText.args = { + label: <>name, + infoText: ( + <> + This is something important! + + ), +}; + +export const Error = baseStory.bind(null); +Error.args = { + error: 'Something terrible happened!', + infoText: ( + <> + This is something important! + + ), +}; + +export const MultipleInputs = () => ( + + + + + + + + + + + +); + +export const MultipleInputsWithFlex = () => ( + + + + + + } + /> + +); + +export const WithEndComponent = baseStory.bind(null); +WithEndComponent.args = { + label: <>search, + fullWidth: false, + width: 600, + end: ( + + + + ), +}; + +export const WithStartComponent = baseStory.bind(null); +WithStartComponent.args = { + fullWidth: false, + width: 600, + start: ( + + search: + + ), +}; + +export const Outline = baseStory.bind(null); +Outline.args = { variant: 'outline' }; + +export const WithCustomStyles = baseStory.bind(null); +WithCustomStyles.args = { + label: <>Some longer name, + infoText: ( + <> + This is something important! + + ), + customStyles: { + label: css` + transform: translateY(-36px) rotate(6deg); + /* transform-origin: 0 0; */ + text-align: right; + `, + infoText: css` + transform: rotate(-3deg); + transform-origin: 0 0; + `, + wrapper: css` + transform: rotate(-5deg); + transform-origin: 0 0; + background-color: blue; + `, + field: css` + color: red; + `, + }, +}; diff --git a/src/Combobox/Combobox.tsx b/src/Combobox/Combobox.tsx new file mode 100644 index 0000000..8052168 --- /dev/null +++ b/src/Combobox/Combobox.tsx @@ -0,0 +1,67 @@ +import React, { forwardRef, useMemo, Children, ComponentType, Ref } from 'react'; +import { ComboboxItem, ComboboxItemProps } from './ComboboxItem'; +import { setRef } from '../utils/setRef'; +import { Autocomplete } from '../Autocomplete'; +import { Input, InputProps } from '../Input'; +import { DropdownListItem } from '../DropdownList'; + +export interface ComboboxProps extends InputProps { + children?: React.ReactElement>[]; + value: string; + ref?: Ref; + showOnEmpty?: boolean; + maxItems?: number; + filter?: (item: T, value: string) => boolean; + toValue?: (item: T) => string; +} + +type ComboboxToValueFn = (item: T) => string; + +type ComboboxComponent = ComponentType> & { + Item: typeof ComboboxItem; +}; + +const defaultToValue: ComboboxToValueFn = (item) => item.toString(); +const Combobox = forwardRef( + ({ children, filter, toValue = defaultToValue, onChange, value, ...props }, ref) => { + const setInputRef = (node) => { + setRef(ref, node); + }; + + const items: DropdownListItem[] = useMemo( + () => + children + ? Children.map(children, (child) => { + const toOutput = child?.props.toValue || toValue; + const outputValue = toOutput(child?.props.value); + return { + value: child?.props.value, + key: child.key || outputValue, + render: () => child.props.children, + filter: child?.props.filter || filter, + toOutput, + toString: toOutput, + }; + }) + : [], + [children, filter, toValue] + ); + + return ( + + + + ); + } +) as unknown as ComboboxComponent; + +Combobox.Item = ComboboxItem; + +export { Combobox }; diff --git a/src/Combobox/ComboboxItem.tsx b/src/Combobox/ComboboxItem.tsx new file mode 100644 index 0000000..3292536 --- /dev/null +++ b/src/Combobox/ComboboxItem.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { getComponentStyle } from '../styleHelpers'; +import styled from '@emotion/styled'; + +interface ComboboxItemProps { + children: React.ReactNode; + value: T; + onSelect?: (value: T) => void; + filter?: (item: T, value: string) => boolean; + toValue?: (item: T) => string; + selected?: boolean; +} + +interface ComboboxItemWrapperProps { + selected?: boolean; +} + +const ComboboxItemWrapper = styled.div` + font-family: ${getComponentStyle('input.fontFamily')}; + font-size: ${getComponentStyle('input.fontSize')}; + padding: 0.25em; + cursor: pointer; + background-color: ${({ theme, selected }) => (selected ? theme.colors.brand.lightest : null)}; + border-radius: 0.5em; +`; + +const ComboboxItem = (props: ComboboxItemProps) => { + return ( + { + e.preventDefault(); + props.onSelect!(props.value); + }} + > + {props.children || props.value.toString()} + + ); +}; + +export { ComboboxItem, ComboboxItemProps }; diff --git a/src/Combobox/index.ts b/src/Combobox/index.ts new file mode 100644 index 0000000..b7b3392 --- /dev/null +++ b/src/Combobox/index.ts @@ -0,0 +1,2 @@ +export * from './Combobox'; +export * from './ComboboxItem'; diff --git a/src/DropdownList/DropDownListBox.tsx b/src/DropdownList/DropDownListBox.tsx new file mode 100644 index 0000000..dd83915 --- /dev/null +++ b/src/DropdownList/DropDownListBox.tsx @@ -0,0 +1,67 @@ +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { LayoutBoxProps } from '../Box'; +import { componentPrimitive, getPrimitiveStyle } from '../styleHelpers'; + +interface WrapperProps extends LayoutBoxProps { + availableHeight?: number; +} + +const Wrapper = componentPrimitive(['dropdownList', 'container'], { + tag: 'ul', +})` + margin: 0; + list-style: none; + background-color: ${getPrimitiveStyle('backgroundColor')}; + border-radius: ${getPrimitiveStyle('borderRadius')}; + box-shadow: ${getPrimitiveStyle('boxShadow')}; + border-width: ${getPrimitiveStyle('borderWidth')}; + border-style: solid; + border-color: ${getPrimitiveStyle('borderColor')}; + padding: ${getPrimitiveStyle('padding')}; + overflow-x: hidden; + overflow-y: auto; + height: 100%; + box-sizing: border-box; + z-index: 1; +`; + +interface DropdownListBoxProps extends LayoutBoxProps { + children: React.ReactNode; + anchor: HTMLElement | null; +} + +const DropdownListBox = forwardRef((props, ref) => { + const [availableHeight, setAvailableHeight] = useState(window.innerHeight); + const width = useRef(0); + + const updateSize = useCallback(() => { + const anchorRect = props.anchor?.getBoundingClientRect(); + if (anchorRect) { + const newAvailableHeight = Math.min(window.innerHeight - anchorRect.bottom - 10, 300); + setAvailableHeight(newAvailableHeight); + width.current = anchorRect.width; + } + }, [props.anchor]); + + useEffect(() => { + updateSize(); + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + }, [updateSize]); + + return ( + + {props.children} + + ); +}); + +export { DropdownListBox, DropdownListBoxProps }; diff --git a/src/DropdownList/DropdownList.spec.tsx b/src/DropdownList/DropdownList.spec.tsx new file mode 100644 index 0000000..23eb61a --- /dev/null +++ b/src/DropdownList/DropdownList.spec.tsx @@ -0,0 +1,389 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, beforeEach, vi } from 'vitest'; +import { DropdownList } from './DropdownList'; +import { Input } from '../Input'; +import { PabloThemeProvider } from '../theme/PabloThemeProvider'; +import '../../testUtils/mockResizeObserver'; + +const mockOnChange = vi.fn(); +const mockOnOpen = vi.fn(); +const mockOnClose = vi.fn(); +const mockOnOpenStateChange = vi.fn(); +const mockScrollIntoView = vi.fn(); +window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + +const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); +}; + +const mockItems = [ + { + value: 'apple', + key: 'apple', + label: 'Apple', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, + { + value: 'banana', + key: 'banana', + label: 'Banana', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, + { + value: 'cherry', + key: 'cherry', + label: 'Cherry', + toOutput: (value: string) => value, + toString: (value: string) => value, + }, +]; + +beforeEach(() => { + mockOnChange.mockClear(); + mockOnOpen.mockClear(); + mockOnClose.mockClear(); + mockOnOpenStateChange.mockClear(); + mockScrollIntoView.mockClear(); +}); + +test('renders DropdownList with child element', () => { + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); +}); + +test('opens dropdown when child element is clicked', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('Cherry')).toBeInTheDocument(); +}); + +test('calls onChange when item is selected', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const appleItem = screen.getByText('Apple'); + await user.click(appleItem); + + expect(mockOnChange).toHaveBeenCalledWith('apple'); +}); + +test('filters items based on filterTerm', async () => { + const user = userEvent.setup(); + + renderWithTheme( + value.toLowerCase().includes(term.toLowerCase())} + > + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.queryByText('Banana')).not.toBeInTheDocument(); + expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); +}); + +test('respects maxItems prop', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); +}); + +test('calls onOpen and onClose callbacks', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + + // Open dropdown + await user.click(input); + expect(mockOnOpen).toHaveBeenCalled(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + // Close dropdown by clicking outside + await user.click(document.body); + expect(mockOnClose).toHaveBeenCalled(); +}); + +test('calls onOpenStateChange callback', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + + await user.click(input); + expect(mockOnOpenStateChange).toHaveBeenCalledWith(true); + + await user.click(document.body); + expect(mockOnOpenStateChange).toHaveBeenCalledWith(false); +}); + +test('does not close on select when closeOnSelect is false', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const appleItem = screen.getByText('Apple'); + await user.click(appleItem); + + expect(mockOnChange).toHaveBeenCalledWith('apple'); + expect(screen.getByText('Apple')).toBeInTheDocument(); // Still visible +}); + +test('uses custom toOutput function', async () => { + const user = userEvent.setup(); + const customToOutput = (value: string) => value.toUpperCase(); + + const itemsWithCustomOutput = mockItems.map((item) => ({ + ...item, + toOutput: customToOutput, + })); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const appleItem = screen.getByText('Apple'); + await user.click(appleItem); + + expect(mockOnChange).toHaveBeenCalledWith('APPLE'); +}); + +test('handles keyboard navigation', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + // Test arrow down navigation + expect(mockScrollIntoView).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(mockScrollIntoView).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(mockScrollIntoView).toHaveBeenCalledTimes(4); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnChange).toHaveBeenCalledWith('banana'); +}); + +test('uses custom renderItem function', async () => { + const user = userEvent.setup(); + const customRenderItem = vi.fn((item) => `Custom: ${item.label}`); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(customRenderItem).toHaveBeenCalled(); + expect(screen.getByText('Custom: Apple')).toBeInTheDocument(); +}); + +test('handles custom itemFilter function', async () => { + const user = userEvent.setup(); + const customFilter = vi.fn((value: string) => value.endsWith('a')); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(customFilter).toHaveBeenCalled(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); +}); + +test('handles empty items array', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); +}); + +test('shows dropdown based on showOnEmpty and filterTerm', async () => { + const user = userEvent.setup(); + + // Test without showOnEmpty and empty filterTerm + const { rerender } = renderWithTheme( + + + + ); + + let input = screen.getByRole('textbox'); + await user.click(input); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + + // Test with filterTerm + rerender( + + + + + + ); + + input = screen.getByRole('textbox'); + await user.click(input); + expect(screen.getByText('Apple')).toBeInTheDocument(); +}); + +test('handles wrapItems prop', async () => { + const user = userEvent.setup(); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); +}); + +test('uses custom anchorElement', async () => { + const user = userEvent.setup(); + const anchorElement = document.createElement('div'); + document.body.appendChild(anchorElement); + + renderWithTheme( + + + + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + expect(screen.getByText('Apple')).toBeInTheDocument(); + + // Cleanup + document.body.removeChild(anchorElement); +}); diff --git a/src/DropdownList/DropdownList.stories.tsx b/src/DropdownList/DropdownList.stories.tsx new file mode 100644 index 0000000..b21e800 --- /dev/null +++ b/src/DropdownList/DropdownList.stories.tsx @@ -0,0 +1,216 @@ +import React, { useRef, useState } from 'react'; + +import { DropdownList } from './DropdownList'; +import { DropdownListItem, DropdownListItemRenderFn } from './types'; +import { Box, Flex } from '../Box'; +import { Body } from '../Typography'; +import styled from '@emotion/styled'; +import { padding } from '../Box/interpolations/spacing'; +import { color } from '../Box/interpolations/color'; +import { Input } from '../Input'; +import { Button } from '../Button'; + +export default { + title: 'DropdownList', +}; + +const filterFn = (item: Game, searchTerm: string) => { + return ( + (item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.system.toLowerCase().includes(searchTerm.toLowerCase())) && + searchTerm.toLowerCase() !== item.name.toLowerCase() + ); +}; + +const render = ({ value }) => { + return ( + + {value.name} + + {value.system} + + + ); +}; + +const Wrapper = styled(Flex)` + ${padding.all(4)}; + ${color.bgColor('gray.200')}; + min-width: 400px; + cursor: pointer; +`; + +const Component = ({ filter, items, ...props }) => { + const [value, setValue] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [dropdownOpen, setDropdownOpen] = useState(false); + const inputRef = useRef(null); + + const handleOpenStateChange = (open) => { + setDropdownOpen(open); + if (open) { + setTimeout(() => { + inputRef.current?.focus(); + }); + } else { + inputRef.current?.blur(); + setSearchTerm(''); + } + }; + console.log('searchTerm', searchTerm); + + return ( + + + + {(!filter || (filter && !dropdownOpen)) && {value?.name}} + {filter && dropdownOpen && ( + + )} + + { + e.stopPropagation(); + setValue(null); + }} + > + + clear + + + + + ); +}; + +interface Game { + name: string; + system: string; +} + +const defaultItems: DropdownListItem[] = [ + { + key: 'final-fantasy-vi', + value: { + name: 'Final Fantasy VI', + system: 'SNES', + }, + }, + { + key: 'final-fight', + value: { + name: 'Final Fight', + system: 'SNES', + }, + }, + { + key: 'super-mario-bros-3', + value: { + name: 'Super Mario Bros. 3', + system: 'NES', + }, + }, + { + key: 'the-legend-of-zelda-ocarina-of-time', + value: { + name: 'The Legend of Zelda: Ocarina of Time', + system: 'N64', + }, + }, + { + key: 'the-legend-of-zelda-a-link-to-the-past', + value: { + name: 'The Legend of Zelda: A Link to the Past', + system: 'SNES', + }, + }, + { + key: 'portal-2', + value: { + name: 'Portal 2', + system: 'PC', + }, + }, + { + key: 'metal-gear-solid', + value: { + name: 'Metal Gear Solid', + system: 'PS1', + }, + }, + { + key: 'tony-hawk-pro-skater-2', + value: { + name: 'Tony Hawk Pro Skater 2', + system: 'multi system', + }, + }, +]; + +const baseStory = (args) => ; + +export const SimpleDropdownList = baseStory.bind(null); +SimpleDropdownList.args = {}; + +export const WithFilter = baseStory.bind(null); +WithFilter.args = { filter: filterFn }; + +export const NonClosingDropdownList = baseStory.bind(null); +NonClosingDropdownList.args = { + closeOnSelect: false, +}; + +const customRenderer: DropdownListItemRenderFn = ({ value }) => ( + {value.name} +); + +export const WithCustomRender = baseStory.bind(null); +WithCustomRender.args = { + renderItem: customRenderer, +}; + +const CustomWrapper = styled(Flex)<{ selected?: boolean }>` + ${padding.all(2)}; + transition: background-color 0.2s ease-in-out; + justify-content: space-between; + align-items: center; + ${(props) => props.selected && `text-decoration: underline;`} +`; + +const customUnwrappedRenderer: DropdownListItemRenderFn = ({ value, selected, onSelect }) => ( + + + {value.name} ({value.system}) + + + +); + +export const WithCustomUnwrappedRender = baseStory.bind(null); +WithCustomUnwrappedRender.args = { + renderItem: customUnwrappedRenderer, + wrapItems: false, +}; + +export const WithItemCustomRender = baseStory.bind(null); +WithItemCustomRender.args = { + items: [ + ...defaultItems, + { + key: 'custom-item', + value: { name: 'Custom Item', system: 'Custom System' }, + render: customRenderer, + }, + ], +}; diff --git a/src/DropdownList/DropdownList.tsx b/src/DropdownList/DropdownList.tsx new file mode 100644 index 0000000..31cd2d1 --- /dev/null +++ b/src/DropdownList/DropdownList.tsx @@ -0,0 +1,162 @@ +import React, { cloneElement, ComponentElement, useEffect, useMemo, useRef, useState } from 'react'; +import { Popover } from '../Popover'; +import { DropdownListFilterFn, DropdownListItem, DropdownListItemRenderFn } from './types'; +import { useKeyboardNavigation } from '../utils/useKeyboardNavigation'; +import { DropdownListItemBox } from '../DropdownList/DropdownListItemBox'; +import { DropdownListBox } from '../DropdownList/DropDownListBox'; + +interface DropdownListProps { + children: ComponentElement; + items: DropdownListItem[]; + itemFilter?: DropdownListFilterFn; + anchorElement?: HTMLElement | null; + onChange: (value: O) => void; + toOutput?: (item: V) => O; + onOpenStateChange?: (open: boolean) => void; + onOpen?: () => void; + onClose?: () => void; + showOnEmpty?: boolean; + closeOnSelect?: boolean; + filterTerm?: string; + maxItems?: number; + renderItem?: DropdownListItemRenderFn; + wrapItems?: boolean; +} + +const defaultFilter: DropdownListFilterFn = () => true; + +const DropdownList = ({ + children, + items, + filterTerm, + itemFilter = defaultFilter, + renderItem, + wrapItems = true, + toOutput, + anchorElement, + onChange, + showOnEmpty, + onOpenStateChange = () => {}, + onOpen = () => {}, + onClose = () => {}, + closeOnSelect = true, + maxItems, +}: DropdownListProps) => { + const [open, setOpenState] = useState(false); + const [childElement, setChildElement] = useState(null); + const wrapperRef = useRef(null); + const setOpen = (newValue: boolean) => { + setOpenState(newValue); + if (!newValue) { + onClose(); + } else { + onOpen(); + } + onOpenStateChange(newValue); + }; + + const handleChange = (item: DropdownListItem) => { + const outputFn = item.toOutput || toOutput; + const value = outputFn ? outputFn(item.value) : (item.value as unknown as O); + onChange(value); + if (closeOnSelect) { + setOpen(false); + } + }; + + const filteredItems = useMemo( + () => + items.filter((item) => { + if (filterTerm === undefined || filterTerm === '') { + return true; + } + const filter = item.filter || itemFilter; + return filter(item.value, filterTerm); + }), + [filterTerm, itemFilter, items] + ); + + const { selectedIndex } = useKeyboardNavigation(filteredItems, handleChange, open); + + // Scroll to selected element when selectedIndex changes + useEffect(() => { + if (selectedIndex >= 0) { + const itemElement = wrapperRef.current?.querySelector( + `[data-pbl-type="dropdownList-item"]:nth-child(${selectedIndex + 1})` + ) as HTMLElement | null; + + if (itemElement) { + itemElement.scrollIntoView({ block: 'nearest' }); + } + } + }, [selectedIndex]); + + const cappedItems = maxItems ? filteredItems.slice(0, maxItems) : filteredItems; + const renderedItems = cappedItems.map((item, index) => { + const isSelected = selectedIndex === index; + const renderFn = item.render || renderItem; + + return ( + { + handleChange(item); + if (closeOnSelect) { + setOpen(false); + } + }} + render={renderFn} + selected={isSelected} + wrap={wrapItems} + /> + ); + }); + + const showPopupBasedOnValue = showOnEmpty || (filterTerm?.length || 0) > 0; + const showPopup = showPopupBasedOnValue && renderedItems?.length > 0; + const content = showPopup ? ( + + {renderedItems} + + ) : null; + + const clonedChildren = cloneElement(children, { + ref: (el) => { + if (el) { + setChildElement(el); + } + }, + onClick: () => { + setOpen(true); + }, + 'aria-haspopup': 'listbox', + 'aria-expanded': open ? 'true' : undefined, + }); + + const usedAnchor = anchorElement || childElement; + + return ( + { + if (e.target === usedAnchor) { + e.preventDefault(); + e.stopPropagation(); + return; + } + setOpen(false); + }} + anchorElement={usedAnchor} + offset={-8} + arrow={null} + delay={0} + open={open} + content={content} + > + {clonedChildren} + + ); +}; + +export { DropdownList }; diff --git a/src/DropdownList/DropdownListItemBox.tsx b/src/DropdownList/DropdownListItemBox.tsx new file mode 100644 index 0000000..1809c89 --- /dev/null +++ b/src/DropdownList/DropdownListItemBox.tsx @@ -0,0 +1,93 @@ +import React, { cloneElement, isValidElement } from 'react'; +import { componentPrimitive, getComponentStyle, getPrimitiveStyle } from '../styleHelpers'; +import { Body } from '../Typography'; +import { DropdownListItem, DropdownListItemRenderFn } from './types'; + +interface DropdownListItemBoxProps { + render?: DropdownListItemRenderFn; + item: DropdownListItem; + wrap: boolean; + onSelect?: (item: V) => void; + toString?: (item: V) => string; + selected?: boolean; +} + +interface WrapperProps { + selected?: boolean; +} + +const getElementProps = (selected) => ({ + 'data-pbl-type': 'dropdownList-item', + 'data-selected': selected && 'true', + 'aria-selected': selected && 'true', + role: 'option', +}); + +const Wrapper = componentPrimitive(['dropdownList', 'item'], { + tag: 'li', +})` + padding: ${getPrimitiveStyle('padding')}; + cursor: pointer; + border-radius: ${getPrimitiveStyle('borderRadius')}; + + &[data-selected='true'] { + background-color: ${getComponentStyle(['dropdownList', 'item', 'selected', 'backgroundColor'])}; + } +`; + +const getRenderItem = ( + { item, onSelect, toString, selected, render }: DropdownListItemBoxProps, + shouldWrap: boolean +): React.ReactNode => { + if (render) { + const renderedItem = render({ + value: item.value, + label: item.label, + onSelect: () => onSelect?.(item.value), + selected, + }); + + if (typeof renderedItem === 'string') { + return {renderedItem}; + } + + if (isValidElement(renderedItem)) { + if (!shouldWrap) { + return cloneElement(renderedItem, getElementProps(selected) as any); + } + return renderedItem; + } + } + + if (item.label) { + if (isValidElement(item.label)) { + return item.label; + } + return {item.label}; + } + + const stringifiedItem = toString ? toString(item.value) : item.toString?.(item.value); + return {stringifiedItem}; +}; + +const DropdownListItemBox = (props: DropdownListItemBoxProps) => { + const shouldWrap = props.item.wrap || props.wrap; + const renderItem = getRenderItem(props, shouldWrap); + if (!shouldWrap) { + return renderItem; + } + return ( + { + e.preventDefault(); + props.onSelect!(props.item.value); + }} + > + {renderItem} + + ); +}; + +export { DropdownListItemBox, DropdownListItemBoxProps }; diff --git a/src/DropdownList/index.ts b/src/DropdownList/index.ts new file mode 100644 index 0000000..3b83d4c --- /dev/null +++ b/src/DropdownList/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownList'; +export * from './types'; diff --git a/src/DropdownList/styles.ts b/src/DropdownList/styles.ts new file mode 100644 index 0000000..61510a6 --- /dev/null +++ b/src/DropdownList/styles.ts @@ -0,0 +1,42 @@ +import { getSpacing } from '../styleHelpers/getSpacing'; +import { Style } from '../theme/types'; +import { BaseStyles } from '../types'; +import { themeVars } from '../theme/themeVars'; + +export type DropdownListStyleProperties = 'simple'; + +export interface DropdownListStyles extends BaseStyles { + container: { + backgroundColor: Style; + borderRadius: Style; + boxShadow: Style; + borderWidth: Style; + borderColor: Style; + padding: Style; + }; + item: { + padding: Style; + borderRadius: Style; + selected: { + backgroundColor: Style; + }; + }; +} + +export const dropdownListStyles: DropdownListStyles = { + container: { + backgroundColor: themeVars.colors.common.white, + borderRadius: '0.5rem', + boxShadow: '', + borderColor: themeVars.colors.gray[100], + borderWidth: '1px', + padding: getSpacing(0.5), + }, + item: { + padding: getSpacing(0.75), + borderRadius: '0.25rem', + selected: { + backgroundColor: themeVars.colors.brand.lightest, + }, + }, +}; diff --git a/src/DropdownList/types.ts b/src/DropdownList/types.ts new file mode 100644 index 0000000..8a58c24 --- /dev/null +++ b/src/DropdownList/types.ts @@ -0,0 +1,25 @@ +type DropdownListFilterFn = (value: V, filterTerm: string) => boolean; + +interface DropdownListItemRenderFnOptions { + value: V; + label?: string | React.ReactNode; + onSelect: () => void; + selected?: boolean; +} + +type DropdownListItemRenderFn = ( + options: DropdownListItemRenderFnOptions +) => React.ReactNode | React.ReactNode; + +interface DropdownListItem { + value: V; + label?: string; + render?: DropdownListItemRenderFn; + wrap?: boolean; + key?: string | number; + toOutput?: (value: V) => O; + toString?: (value: V) => string; + filter?: (value: V, term: string) => boolean; +} + +export type { DropdownListItem, DropdownListItemRenderFn, DropdownListFilterFn }; diff --git a/src/Input/Input.tsx b/src/Input/Input.tsx index 2df8058..c56c09b 100644 --- a/src/Input/Input.tsx +++ b/src/Input/Input.tsx @@ -15,6 +15,10 @@ export interface InputProps extends LayoutBoxProps { label?: React.ReactNode; variant?: InputVariant; infoText?: React.ReactNode; + placeholder?: string; + disabled?: boolean; + onBlur?: (e: React.FocusEvent) => void; + onFocus?: (e: React.FocusEvent) => void; fullWidth?: boolean; end?: React.ReactNode; onChange: (newValue: string, e: React.FormEvent) => void; diff --git a/src/Popover/Popover.tsx b/src/Popover/Popover.tsx index 37e3a8a..d389062 100644 --- a/src/Popover/Popover.tsx +++ b/src/Popover/Popover.tsx @@ -20,8 +20,10 @@ export interface PopoverProps { onClick?: () => void; onMouseEnter?: () => void; onMouseLeave?: () => void; - onClickOutside?: () => void; - arrow?: ReactElement; + anchorElement?: HTMLElement | null; + onClickOutside?: (e: MouseEvent) => void; + arrow?: ReactElement | null; + style?: React.CSSProperties; open: boolean; animation?: ComponentType>; animationProps?: AnimationSetupProps & A; @@ -34,14 +36,20 @@ const PopoverWrapper = styled.div` z-index: 1100; `; +const ContentWrapper = styled.div` + display: contents; +`; + const DefaultArrow = styled.div``; export const Popover = forwardRef( ( { + style, children, content, placement, + anchorElement, open, delay = 0, offset = 0, @@ -58,7 +66,8 @@ export const Popover = forwardRef( ) => { const [innerOpen, setInnerOpen] = useDelayedBooleanState(open, delay, animationProps.duration); const [referenceElement, setReferenceElement] = useReRenderForwardRef( - children.ref as Ref + children.ref as Ref, + anchorElement ); const [popperElement, setPopperElement] = useState(null); @@ -87,8 +96,16 @@ export const Popover = forwardRef( }, [ref, popperElement]); const handleClickOutside = useMemo( - () => (open ? onClickOutside : () => {}), - [open, onClickOutside] + () => + open + ? (e) => { + if (referenceElement?.contains(e.target)) { + return; + } + onClickOutside(e); + } + : () => {}, + [open, referenceElement, onClickOutside] ); useNanopop({ @@ -104,6 +121,7 @@ export const Popover = forwardRef( () => cloneElement(children, { ref: setReferenceElement, + innerRef: setReferenceElement, onClick: (...args) => { onClick?.(); children.props.onClick?.(...args); @@ -115,6 +133,7 @@ export const Popover = forwardRef( const clonedArrow = useMemo( () => + arrow && cloneElement(arrow, { ref: setArrowElement, positionMatch, @@ -127,10 +146,10 @@ export const Popover = forwardRef( {clonedElement} {innerOpen && ( - + -
{content}
+ {content}
{clonedArrow} diff --git a/src/Popover/useNanopop.ts b/src/Popover/useNanopop.ts index 609a790..82b6b5d 100644 --- a/src/Popover/useNanopop.ts +++ b/src/Popover/useNanopop.ts @@ -29,14 +29,26 @@ const useNanopop = ({ const container = targetWindow.document.documentElement?.getBoundingClientRect(); - const newPlacement = reposition(referenceElement, popperElement, { - margin, - position, - container, - arrow: arrowElement || undefined, - }); - - onChange(newPlacement); + setTimeout(() => { + const newPlacement = reposition(referenceElement, popperElement, { + margin, + position, + container, + positionFlipOrder: { + top: 'tb', + bottom: 'bt', + left: 'lr', + right: 'rl', + }, + variantFlipOrder: { + start: 's', + middle: 'm', + end: 'e', + }, + arrow: arrowElement || undefined, + }); + onChange(newPlacement); + }, 5); }, [onChange, referenceElement, popperElement, targetWindow, margin, position, arrowElement]); useLayoutEffect(() => { diff --git a/src/Typography/Typography.stories.tsx b/src/Typography/Typography.stories.tsx index b62a126..050fae9 100644 --- a/src/Typography/Typography.stories.tsx +++ b/src/Typography/Typography.stories.tsx @@ -104,7 +104,14 @@ export const Override = () => { h2: { fontFamily: 'Times New Roman', fontWeight: 'bold' }, h3: { fontFamily: 'Times New Roman', fontWeight: 'bold' }, h4: { fontFamily: 'Times New Roman', fontWeight: 'bold' }, - body: { fontFamily: 'Times New Roman', fontWeight: 'bold' }, + body: { + fontFamily: 'Times New Roman', + fontWeight: 'bold', + variants: { + bold: { fontWeight: 'normal', fontSize: '3rem' }, + small: { fontSize: '0.875rem' }, + }, + }, button: { fontFamily: 'Times New Roman', fontWeight: 'bold' }, }, }; @@ -123,6 +130,12 @@ export const Override = () => { Body + + Body Bold + + + Body Small + Button diff --git a/src/animation/InOutAnimation.tsx b/src/animation/InOutAnimation.tsx index f729f63..fbfc6e7 100644 --- a/src/animation/InOutAnimation.tsx +++ b/src/animation/InOutAnimation.tsx @@ -45,6 +45,7 @@ export type InOutAnimationProps = AnimationAdditional export const InnerInOutAnimation = styled.div>` transition: all ${(props) => props.duration}ms ${(props) => props.easing}; + display: contents; ${(props) => props.baseStyles} ${({ status, preEnterStyles, enteredStyles, exitedStyles, exitingStyles, enteringStyles }) => { switch (status) { diff --git a/src/index.ts b/src/index.ts index c5f9b76..b7de5e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ // ../packageExports.js export * from './animation'; +export * from './Autocomplete'; export * from './Avatar'; export * from './Box'; export * from './breakpoints'; @@ -11,6 +12,8 @@ export * from './ButtonBase'; export * from './Card'; export * from './Checkbox'; export * from './ClickOutside'; +export * from './Combobox'; +export * from './DropdownList'; export * from './IconButton'; export * from './Image'; export * from './Input'; diff --git a/src/shared/BaseInput.tsx b/src/shared/BaseInput.tsx index a32c46f..e3f1e94 100644 --- a/src/shared/BaseInput.tsx +++ b/src/shared/BaseInput.tsx @@ -32,12 +32,14 @@ export interface BaseInputProps infoText?: React.ReactNode; fullWidth?: boolean; innerRef?: React.ForwardedRef; + inputWrapperRef?: React.ForwardedRef; inputRef?: React.Ref; start?: React.ReactNode; end?: React.ReactNode; onChange?: (newValue: string, e: React.FormEvent) => void; - onFocus?: () => void; - onBlur?: () => void; + onFocus?: (e: React.FocusEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onBlur?: (e: React.FocusEvent) => void; } export type InputVariant = 'filled' | 'outline'; @@ -112,6 +114,7 @@ export function BaseInput

, E extends HTMLElement>( id: idProp, width, innerRef, + inputWrapperRef, inputRef, fullWidth = false, adornmentGap = 0, @@ -144,6 +147,7 @@ export function BaseInput

, E extends HTMLElement>( )} (ref: Ref, item: T) { +export function setRef(ref: Ref, item: T, onSet?: (item: T) => void) { + if (onSet) { + onSet(item); + } if (typeof ref === 'function') { ref(item); } else if (ref) { diff --git a/src/utils/useBlur.ts b/src/utils/useBlur.ts new file mode 100644 index 0000000..546bf3a --- /dev/null +++ b/src/utils/useBlur.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +const useBlur = (handler: (target: HTMLElement | null) => void) => { + const clickedElement = useRef(null); + const handleMouseDown = (e: MouseEvent) => { + clickedElement.current = e.target as HTMLElement; + }; + useEffect(() => { + document.addEventListener('mousedown', handleMouseDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + }; + }, []); + + return () => { + const target = clickedElement.current; + clickedElement.current = null; + + handler(target); + }; +}; + +export { useBlur }; diff --git a/src/utils/useForwardRef.ts b/src/utils/useForwardRef.ts index d543c66..7ab8b12 100644 --- a/src/utils/useForwardRef.ts +++ b/src/utils/useForwardRef.ts @@ -1,4 +1,13 @@ -import { MutableRefObject, Ref, RefObject, useCallback, useMemo, useRef, useState } from 'react'; +import { + MutableRefObject, + Ref, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { setRef } from './setRef'; export function useForwardRef(outsideRef: Ref): [RefObject, (newValue: T) => void] { @@ -14,15 +23,26 @@ export function useForwardRef(outsideRef: Ref): [RefObject, (newValue: return useMemo(() => [innerRef, setInnerRef], [innerRef, setInnerRef]); } -export function useReRenderForwardRef(outsideRef: Ref): [T | null, (newValue: T) => void] { - const [innerRef, setInnerRefValue] = useState(null); +export function useReRenderForwardRef( + outsideRef: Ref, + override?: T +): [T | null, (newValue: T) => void] { + const [innerRef, setInnerRefValue] = useState(override || null); const setInnerRef = useCallback( (value) => { - setInnerRefValue(value); - setRef(outsideRef, value); + if (override === undefined || override === null) { + setInnerRefValue(value); + setRef(outsideRef, value); + } }, - [outsideRef] + [outsideRef, override] ); + useEffect(() => { + if (override !== undefined && override !== null) { + setInnerRefValue(override || null); + } + }, [override, setInnerRef]); + return useMemo(() => [innerRef, setInnerRef], [innerRef, setInnerRef]); } diff --git a/src/utils/useKeyboardNavigation.ts b/src/utils/useKeyboardNavigation.ts new file mode 100644 index 0000000..1d66ef7 --- /dev/null +++ b/src/utils/useKeyboardNavigation.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useKeyboardNavigation = ( + items: T[], + onSelect: (item: T) => void, + active: boolean = true +) => { + const [selectedIndex, setSelectedIndex] = useState(-1); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => { + return prev <= 0 ? items.length - 1 : prev - 1; + }); + break; + case 'Enter': + e.preventDefault(); + e.stopPropagation(); + if (selectedIndex >= 0) { + const selectedItem = items[selectedIndex]; + if (selectedItem) { + onSelect(selectedItem); + } + } + break; + default: + setSelectedIndex(-1); + } + }, + [items, onSelect, selectedIndex] + ); + + // Reset index when items change + useEffect(() => { + setSelectedIndex(-1); + }, [items]); + + useEffect(() => { + if (active) { + document.addEventListener('keydown', handleKeyDown, true); + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + } + setSelectedIndex(-1); + return () => {}; + }, [handleKeyDown, active]); + + return { + selectedIndex, + }; +}; + +export { useKeyboardNavigation }; diff --git a/tsconfig.json b/tsconfig.json index 0a3fbbf..844bfcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "strictNullChecks": true, "skipLibCheck": true, "esModuleInterop": true, - "noUnusedLocals": true + "noUnusedLocals": true, + "types": ["vitest/globals", "@testing-library/jest-dom/vitest"], }, "include": ["src", "testUtils", "emotion.d.ts"], "exclude": ["node_modules", "build", "scripts", "acceptance-tests", "webpack", "jest"] diff --git a/vite.config.js b/vite.config.js index a44ce48..1603124 100644 --- a/vite.config.js +++ b/vite.config.js @@ -23,7 +23,7 @@ const config = defineConfig({ test: { environment: 'jsdom', globals: true, - setupFiles: './vitestSetup.js', // assuming the test folder is in the root of our project + setupFiles: './vitestSetup.ts', // assuming the test folder is in the root of our project }, }) diff --git a/vitestSetup.js b/vitestSetup.ts similarity index 100% rename from vitestSetup.js rename to vitestSetup.ts diff --git a/yarn.lock b/yarn.lock index ca41b83..a730cdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1566,6 +1566,11 @@ dependencies: "@babel/runtime" "^7.12.5" +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + "@tootallnate/quickjs-emscripten@^0.23.0": version "0.23.0" resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"