From 1aa0d364b85cec3e80bb9ede1b4fb1e0ad245d3b Mon Sep 17 00:00:00 2001 From: Alexander Swed Date: Sat, 5 Dec 2020 21:51:05 +0100 Subject: [PATCH 01/16] fix(ComboBox): initial structure --- src/lib/ComboBox/ComboBox.tsx | 85 ++++++++++++++ src/lib/ComboBox/Input.tsx | 42 +++++++ src/lib/ComboBox/Item.tsx | 40 +++++++ src/lib/ComboBox/List.tsx | 36 ++++++ src/lib/ComboBox/index.ts | 1 + src/lib/ComboBox/utils.ts | 15 +++ src/lib/ListItem/ListItem.tsx | 13 +-- src/lib/Menu/MenuItem.tsx | 5 +- src/lib/MenuList/Item.tsx | 15 ++- src/lib/Picker/Item.tsx | 14 ++- src/lib/Picker/Picker.tsx | 32 ++---- src/lib/Picker/Trigger.tsx | 3 +- src/lib/TextField/TextField.tsx | 180 ++++++++++++++++-------------- src/lib/index.ts | 1 + src/lib/utils/index.ts | 15 ++- src/pages/components/ComboBox.mdx | 67 +++++++++++ 16 files changed, 441 insertions(+), 123 deletions(-) create mode 100644 src/lib/ComboBox/ComboBox.tsx create mode 100644 src/lib/ComboBox/Input.tsx create mode 100644 src/lib/ComboBox/Item.tsx create mode 100644 src/lib/ComboBox/List.tsx create mode 100644 src/lib/ComboBox/index.ts create mode 100644 src/lib/ComboBox/utils.ts create mode 100644 src/pages/components/ComboBox.mdx diff --git a/src/lib/ComboBox/ComboBox.tsx b/src/lib/ComboBox/ComboBox.tsx new file mode 100644 index 00000000..8202e169 --- /dev/null +++ b/src/lib/ComboBox/ComboBox.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Popover from '../Popover'; +import { useAllHandlers, useId } from '../utils'; +import { OpenStateProvider } from '../utils/OpenStateProvider'; +import List from './List'; +import Item from './Item'; + +import { ComboBoxProvider } from './utils'; +import Input from './Input'; + +interface OptionType extends React.ReactElement, typeof Item> {} +type Props = Omit, 'onChange'> & { + value?: string; + onChange?: (newValue: string | undefined, textValue: string) => void; + children: OptionType[] | OptionType; +}; + +const ComboBox: React.FC & { + Item: typeof Item; +} = ({ children, id, value: propValue, onChange: propOnChange, ...textFieldProps }) => { + const selectedItem = (React.Children.toArray(children) as OptionType[]).find( + (child) => child.props.value === propValue + ); + const selectedLabel = selectedItem?.props?.label || ''; + const selectedValue = selectedItem?.props?.value; + + const [textValue, setTextValue] = useState(selectedLabel); + + useEffect(() => { + if (selectedLabel) { + setTextValue(selectedLabel); + } + }, [selectedLabel]); + + const updateText = useAllHandlers(setTextValue, (text) => propOnChange?.(selectedItem?.props?.value, text)); + + const textValueRef = useRef(textValue); + useEffect(() => { + textValueRef.current = textValue; + }, [textValue]); + + const triggerRef = useRef(null); + const triggerId = useId(); + + const contextValue = useMemo( + () => ({ + value: propValue, + triggerRef, + onChange: (newValue: string) => propOnChange?.(newValue, textValueRef.current), + }), + [propOnChange, propValue, textValueRef] + ); + + return ( + + + + + {children} + + + + ); +}; + +ComboBox.Item = Item; + +export default ComboBox; + +/** + * Duplicate state to be able to use the element uncontrolled + */ +function useValue(propValue: Props['value'], propOnChange: Props['onChange']) { + return [value, onChange] as const; +} + +function useTextValue({ children, onChange, value }: Partial) { + return [textValue, updateText] as const; +} diff --git a/src/lib/ComboBox/Input.tsx b/src/lib/ComboBox/Input.tsx new file mode 100644 index 00000000..a123e1c5 --- /dev/null +++ b/src/lib/ComboBox/Input.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { HiSelector } from 'react-icons/hi'; + +import TextField from '../TextField'; +import { useAllHandlers, useForkRef, useKeyboardHandles } from '../utils'; +import { useOpenState, useOpenStateControls } from '../utils/OpenStateProvider'; +import { useComboBox } from './utils'; + +interface Props extends Omit, 'icon'> {} + +const Input = React.forwardRef(({ id, hint, inputRef, ...props }, ref) => { + const { triggerRef } = useComboBox(); + const isOpen = useOpenState(); + const { open, close } = useOpenStateControls(); + + const handleKeyDown = useKeyboardHandles({ + ArrowDown: open, + ArrowUp: open, + Escape: close, + }); + + const onKeyDown = useAllHandlers(props.onKeyDown, handleKeyDown); + + const refs = useForkRef(inputRef, triggerRef); + + return ( + + ); +}); + +export default Input; diff --git a/src/lib/ComboBox/Item.tsx b/src/lib/ComboBox/Item.tsx new file mode 100644 index 00000000..c6f4902c --- /dev/null +++ b/src/lib/ComboBox/Item.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import { HiCheck } from 'react-icons/hi'; +import Icon from '../Icon'; +import ListItem from '../ListItem'; +import { styled } from '../stitches.config'; +import { useOpenStateControls } from '../utils/OpenStateProvider'; +import { useComboBox } from './utils'; + +const SelectedIcon = styled(Icon, {}); + +const Option = styled(ListItem, { + [` > ${SelectedIcon}`]: { + color: '$primaryStill', + }, +}); + +type Props = Omit, 'children'> & { + value: string; + label: string; +}; + +const Item = React.forwardRef(({ value, label, ...props }, ref) => { + const { value: dropdownValue, onChange } = useComboBox(); + const { close } = useOpenStateControls(); + const isSelected = dropdownValue === value; + + const onClick = useCallback(() => { + onChange?.(value); + close(); + }, [close, onChange, value]); + + return ( + + ); +}); + +export default Item; diff --git a/src/lib/ComboBox/List.tsx b/src/lib/ComboBox/List.tsx new file mode 100644 index 00000000..291f06a5 --- /dev/null +++ b/src/lib/ComboBox/List.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef } from 'react'; +import ListBox from '../ListBox'; +import { useComboBox } from './utils'; + +type Props = { + triggerId: string; +} & React.ComponentProps; + +const List: React.FC = ({ triggerId, children, ...props }) => { + const { value } = useComboBox(); + const ref = useRef(null); + + useEffect(() => { + // selected value is already focused + if (ref.current?.contains(document.activeElement)) { + return; + } + + // focus selected item + // const option = ref.current?.querySelector('[role="option"]') as HTMLLIElement; + + // if (option) { + // option?.focus?.(); + // } else { + // ref.current?.focus(); + // } + }, [value]); + + return ( + + {children} + + ); +}; + +export default List; diff --git a/src/lib/ComboBox/index.ts b/src/lib/ComboBox/index.ts new file mode 100644 index 00000000..0716bf84 --- /dev/null +++ b/src/lib/ComboBox/index.ts @@ -0,0 +1 @@ +export { default } from './ComboBox'; diff --git a/src/lib/ComboBox/utils.ts b/src/lib/ComboBox/utils.ts new file mode 100644 index 00000000..dafbd348 --- /dev/null +++ b/src/lib/ComboBox/utils.ts @@ -0,0 +1,15 @@ +import React, { createContext, useContext } from 'react'; + +type ComboBoxContext = { + triggerRef: React.RefObject; + value?: string; + onChange?: (newValue: string) => void; +}; + +const context = createContext({ + triggerRef: { current: null }, +} as ComboBoxContext); + +export const ComboBoxProvider = context.Provider; + +export const useComboBox = () => useContext(context); diff --git a/src/lib/ListItem/ListItem.tsx b/src/lib/ListItem/ListItem.tsx index 6fc0be7d..67bf2645 100644 --- a/src/lib/ListItem/ListItem.tsx +++ b/src/lib/ListItem/ListItem.tsx @@ -34,12 +34,6 @@ type Props = { disabled?: boolean } & StitchesProps; const ListItem = React.forwardRef( ({ flow = 'row', cross = 'center', space = '$2', disabled, as = 'li', ...props }, ref) => { - const onMouseEnter = useAllHandlers(props.onMouseEnter, (e) => { - e.currentTarget.focus({ - preventScroll: true, - }); - }); - const onKeyDown = useKeyboardHandles({ 'Enter': (e) => e.currentTarget.click?.(), ' ': (e) => e.currentTarget.click?.(), @@ -57,7 +51,6 @@ const ListItem = React.forwardRef( {...props} aria-disabled={disabled} ref={ref} - onMouseEnter={onMouseEnter} onKeyDown={handleKeyDown} /> ); @@ -67,3 +60,9 @@ const ListItem = React.forwardRef( ListItem.displayName = 'ListItem'; export default ListItem; + +export function focusOnMouseOver(e: React.MouseEvent) { + e.currentTarget.focus({ + preventScroll: true, + }); +} diff --git a/src/lib/Menu/MenuItem.tsx b/src/lib/Menu/MenuItem.tsx index 1e0d6486..492b8acf 100644 --- a/src/lib/Menu/MenuItem.tsx +++ b/src/lib/Menu/MenuItem.tsx @@ -4,6 +4,7 @@ import { useAllHandlers } from '../utils'; import { useMenu } from './utils'; import ListItem from '../ListItem'; import { useOpenStateControls } from '../utils/OpenStateProvider'; +import { focusOnMouseOver } from '../ListItem/ListItem'; type Props = React.ComponentProps & { action?: string }; @@ -11,12 +12,14 @@ const MenuItem = React.forwardRef(({ action, disabled, ... const { onAction } = useMenu(); const { close } = useOpenStateControls(); + const onMouseEnter = useAllHandlers(props.onMouseEnter, focusOnMouseOver); + const onClick = useAllHandlers(() => { action && onAction?.(action); close(); }, props.onClick); - return ; + return ; }); MenuItem.displayName = 'MenuItem'; diff --git a/src/lib/MenuList/Item.tsx b/src/lib/MenuList/Item.tsx index fff8e7dd..0001d397 100644 --- a/src/lib/MenuList/Item.tsx +++ b/src/lib/MenuList/Item.tsx @@ -1,6 +1,8 @@ import React from 'react'; import ListItem from '../ListItem'; +import { focusOnMouseOver } from '../ListItem/ListItem'; import { styled } from '../stitches.config'; +import { useAllHandlers } from '../utils'; const MenuItem = styled(ListItem, { 'position': 'relative', @@ -34,7 +36,18 @@ type Props = React.ComponentProps & { }; const Item = React.forwardRef(({ selected, ...props }, ref) => { - return ; + const onMouseEnter = useAllHandlers(props.onMouseEnter, focusOnMouseOver); + + return ( + + ); }); export default Item; diff --git a/src/lib/Picker/Item.tsx b/src/lib/Picker/Item.tsx index 06f3ba3f..7dec79ca 100644 --- a/src/lib/Picker/Item.tsx +++ b/src/lib/Picker/Item.tsx @@ -2,8 +2,9 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { HiCheck } from 'react-icons/hi'; import Icon from '../Icon'; import ListItem from '../ListItem'; +import { focusOnMouseOver } from '../ListItem/ListItem'; import { styled } from '../stitches.config'; -import { useForkRef } from '../utils'; +import { useAllHandlers, useForkRef } from '../utils'; import { useOpenStateControls } from '../utils/OpenStateProvider'; import { usePicker } from './utils'; @@ -37,10 +38,19 @@ const Item = React.forwardRef(({ value, label, ...props }, } }, [isSelected]); + const onMouseEnter = useAllHandlers(props.onMouseEnter, focusOnMouseOver); + const refs = useForkRef(innerRef, ref); return ( - diff --git a/src/lib/Picker/Picker.tsx b/src/lib/Picker/Picker.tsx index fe1779e3..de88a034 100644 --- a/src/lib/Picker/Picker.tsx +++ b/src/lib/Picker/Picker.tsx @@ -7,10 +7,11 @@ import Item from './Item'; import Trigger from './Trigger'; import { PickerProvider } from './utils'; +interface OptionType extends React.ReactElement, typeof Item> {} type Props = React.ComponentProps & { value?: string; onChange?: (newValue: string) => void; - children: React.ElementType[] | React.ElementType; + children: OptionType[] | OptionType; }; const Picker: React.FC & { @@ -46,34 +47,18 @@ const Picker: React.FC & { Picker.Item = Item; -Picker.propTypes = { - children: (props, propName, componentName) => { - if (props[propName]?.some?.((el: React.ReactNode) => !isOption(el))) { - return new Error( - `Invalid child supplied to ${componentName}. ${componentName} only accepts ${Item.displayName} as children` - ); - } - return null; - }, -}; - export default Picker; -function isOption(el: React.ReactNode): el is React.ReactElement> { - return React.isValidElement(el) && el.type === Item; -} - function useTitle({ children, value }: { children: Props['children']; value?: string }) { const [title, setTitle] = useState(''); useEffect(() => { if (!value) return; - let label = null; - React.Children.forEach(children, (el) => { - if (el.props.value === value) { - label = el.props.label; - } - }); + const selectedOption = (React.Children.toArray(children) as OptionType[]).find( + (option) => option.props.value === value + ); + const label = selectedOption?.props?.label; + if (label) { setTitle(label); } @@ -82,6 +67,9 @@ function useTitle({ children, value }: { children: Props['children']; value?: st return title; } +/** + * Duplicate state to be able to use the element uncontrolled + */ function useValue(propValue: Props['value'], propOnChange: Props['onChange']) { const [value, setValue] = useState(propValue); diff --git a/src/lib/Picker/Trigger.tsx b/src/lib/Picker/Trigger.tsx index cfbd06a9..37256853 100644 --- a/src/lib/Picker/Trigger.tsx +++ b/src/lib/Picker/Trigger.tsx @@ -73,7 +73,7 @@ const Trigger: React.FC = ({ children, ...props }) => { - const { triggerRef } = usePicker(); + const { triggerRef, value } = usePicker(); const isOpen = useOpenState(); const { toggle, open } = useOpenStateControls(); const ariaProps = useFormField({ id, hint, label }); @@ -115,6 +115,7 @@ const Trigger: React.FC = ({ onClick={handleClick} onKeyDown={onKeyDown} ref={triggerRef as any} + value={value} > {children || {placeholder}} diff --git a/src/lib/TextField/TextField.tsx b/src/lib/TextField/TextField.tsx index 10dc31c8..5044d8e0 100644 --- a/src/lib/TextField/TextField.tsx +++ b/src/lib/TextField/TextField.tsx @@ -7,6 +7,7 @@ import Label from '../Label'; import { FormField, HintBox, Hint, useFormField } from '../FormField/FormField'; import { InteractiveBox, validityVariant, IconWrapper, iconStyles } from './shared'; import { StitchesProps } from '@stitches/react'; +import { IconType } from 'react-icons/lib'; const Input = styled(InteractiveBox, { '::placeholder': { @@ -81,101 +82,114 @@ const inputMode: Record, InputProps['inputMode']> = { password: undefined, }; -const TextField: React.FC = ({ - label, - secondaryLabel, - hint, - main, - cross, - flow, - display, - space, - css, - style, - className, - type = 'text', - onChange, - validity, - value, - disabled, - variant = 'boxed', - id, - defaultValue, - ...props -}) => { - const ariaProps = useFormField({ id, hint }); - - const handleChange = useMemo(() => { - if (typeof onChange !== 'function') { - return undefined; - } +const TextField = React.forwardRef( + ( + { + label, + secondaryLabel, + hint, + main, + cross, + flow, + display, + space, + css, + style, + className, + type = 'text', + onChange, + validity, + value, + disabled, + variant = 'boxed', + id, + defaultValue, + icon, + inputRef, + ...props + }, + ref + ) => { + const ariaProps = useFormField({ id, hint }); - return (e: React.ChangeEvent) => { - switch (type) { - case 'number': - return (onChange as NumberChange)(e.target.valueAsNumber, e); - case 'date': - return (onChange as DateChange)(e.target.valueAsDate, e); - default: - return (onChange as StringChange)(e.target.value, e); + const handleChange = useMemo(() => { + if (typeof onChange !== 'function') { + return undefined; } - }; - }, [onChange, type]); - - const iconRight = icons[validity || type]; - - return ( - - {label && - ); -}; + + + ); + } +); + +TextField.displayName = 'TextField'; export default TextField; type InputProps = StitchesProps; -type Props = Omit & +type Props = Omit & FlexVariants & { label?: string; secondaryLabel?: string; hint?: string; validity?: 'valid' | 'invalid'; + inputRef?: React.Ref; + icon?: IconType; } & OnChange; type OnChange = diff --git a/src/lib/index.ts b/src/lib/index.ts index 2294b258..0e194bc6 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -3,6 +3,7 @@ import { StylesWithVariants } from './utils'; export { default as Box } from './Box'; export { default as Button } from './Button'; export { default as Checkbox } from './Checkbox'; +export { default as ComboBox } from './ComboBox'; export { default as Dialog } from './Dialog'; export { default as Flex } from './Flex'; export { default as Heading } from './Heading'; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1b02fd11..c8ed3ab6 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -43,18 +43,21 @@ export function joinNonEmpty(...strings: Array) { return strings.filter(Boolean).join(' '); } -export function useAllHandlers>( - ...handlers: (React.EventHandler | undefined)[] -): React.EventHandler { +export function useAllHandlers>( + ...handlers: ( + | (E extends React.SyntheticEvent ? React.EventHandler : (value: string) => void) + | undefined + )[] +): E extends React.SyntheticEvent ? React.EventHandler : (value: string) => void { const handlersRef = useRef(handlers); useEffect(() => { handlersRef.current = handlers; }, handlers); // eslint-disable-line react-hooks/exhaustive-deps - return useCallback((...args) => { - handlersRef.current.forEach((fn) => fn?.(...args)); - }, []); + return useCallback((event: any) => { + handlersRef.current.forEach((fn) => fn?.(event)); + }, []) as E extends React.SyntheticEvent ? React.EventHandler : (value: string) => void; } type KeyboardHandler = ((event: React.KeyboardEvent) => void) | undefined; diff --git a/src/pages/components/ComboBox.mdx b/src/pages/components/ComboBox.mdx new file mode 100644 index 00000000..ebaa9e0f --- /dev/null +++ b/src/pages/components/ComboBox.mdx @@ -0,0 +1,67 @@ +--- +name: ComboBox +--- + +import { Playground } from 'dokz'; +import { Box, Flex, ComboBox, Text } from '@fxtrot/ui'; + +# ComboBox + + + {() => { + const [value, setValue] = React.useState(''); + const [newItemText, setNewItemText] = React.useState(''); + const onChangeWithNewItem = (newValue, newText) => { + setValue(newValue); + setNewItemText(newText); + }; + return ( + + + + {['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'].map((house, index) => ( + + ))} + + +
{`Fake selected item ID: ${value}`}
+
{`Current input text: ${newItemText}`}
+
+
+
+ ); + }} +
+ +## Variants + +Supports same `variant`s as `TextField` component. + + + {() => { + const [value, setValue] = React.useState('12'); + return ( + + + 10 ? 'valid' : 'invalid'} + > + {Array(20) + .fill(null) + .map((el, i) => ( + + ))} + + + + ); + }} + From 4c69591e33fe519dd4b6717cde9fe1731c901529 Mon Sep 17 00:00:00 2001 From: Alexander Swed Date: Sun, 6 Dec 2020 20:57:45 +0100 Subject: [PATCH 02/16] chore(ComboBox): dumb implementation --- package.json | 3 - src/lib/ComboBox/ComboBox.tsx | 120 +++++++++++++++++------------- src/lib/ComboBox/Input.tsx | 36 ++++++--- src/lib/ComboBox/Item.tsx | 40 +++++++--- src/lib/ComboBox/List.tsx | 36 --------- src/lib/ComboBox/readme.md | 3 + src/lib/ComboBox/utils.ts | 47 ++++++++++-- src/lib/ListBox/ListBox.tsx | 16 ++-- src/lib/ListItem/ListItem.tsx | 8 ++ src/lib/Popover/Popover.tsx | 3 +- src/lib/stitches.config.ts | 13 +--- src/lib/theme/shadows.ts | 10 +++ src/lib/utils/index.ts | 13 +++- src/pages/components/ComboBox.mdx | 40 +++++++--- yarn.lock | 8 +- 15 files changed, 240 insertions(+), 156 deletions(-) delete mode 100644 src/lib/ComboBox/List.tsx create mode 100644 src/lib/ComboBox/readme.md create mode 100644 src/lib/theme/shadows.ts diff --git a/package.json b/package.json index d5428846..48bd0722 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,6 @@ "react-icons": "^4.1.0", "typescript": "^4.1.2" }, - "resolutions": { - "typescript": "^4.0.3" - }, "peerDependencies": { "react": "^16.8.0", "react-dom": "^16.8.0" diff --git a/src/lib/ComboBox/ComboBox.tsx b/src/lib/ComboBox/ComboBox.tsx index 8202e169..e9fb8862 100644 --- a/src/lib/ComboBox/ComboBox.tsx +++ b/src/lib/ComboBox/ComboBox.tsx @@ -1,68 +1,97 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useUIDSeed } from 'react-uid'; import Popover from '../Popover'; -import { useAllHandlers, useId } from '../utils'; +import { useAllHandlers } from '../utils'; import { OpenStateProvider } from '../utils/OpenStateProvider'; -import List from './List'; import Item from './Item'; +import { List as ListBox } from '../ListBox/ListBox'; -import { ComboBoxProvider } from './utils'; +import { ComboBoxProvider, ComboBoxContext, FocusControls } from './utils'; import Input from './Input'; interface OptionType extends React.ReactElement, typeof Item> {} type Props = Omit, 'onChange'> & { value?: string; - onChange?: (newValue: string | undefined, textValue: string) => void; + onChange?: (newValue: string | undefined | null) => void; + onInputChange?: (text: string) => void; children: OptionType[] | OptionType; }; const ComboBox: React.FC & { Item: typeof Item; -} = ({ children, id, value: propValue, onChange: propOnChange, ...textFieldProps }) => { - const selectedItem = (React.Children.toArray(children) as OptionType[]).find( - (child) => child.props.value === propValue - ); - const selectedLabel = selectedItem?.props?.label || ''; - const selectedValue = selectedItem?.props?.value; +} = ({ children, id, value: propValue, onChange: propOnChange, onInputChange, ...textFieldProps }) => { + const [textValue, setTextValue] = useState(''); + const [focusedItemId, setFocusedItemId] = useState(); + const [renderedItems, setRenderedItems] = useState({}); - const [textValue, setTextValue] = useState(selectedLabel); + const handleTextChange = useAllHandlers(setTextValue, onInputChange); - useEffect(() => { - if (selectedLabel) { - setTextValue(selectedLabel); - } - }, [selectedLabel]); + const inputRef = useRef(null); + const idSeed = useUIDSeed(); + const focusControls = useRef({} as FocusControls); - const updateText = useAllHandlers(setTextValue, (text) => propOnChange?.(selectedItem?.props?.value, text)); + useEffect(() => { + const newItems: ComboBoxContext['renderedItems'] = {}; + React.Children.forEach(children, (option: OptionType) => { + const { label, value } = option.props || {}; + const selected = value === propValue; + const id = renderedItems[value]?.id || idSeed('option'); + if (label.toLowerCase().includes(textValue.toLowerCase())) { + newItems[value] = { + focused: id === focusedItemId, + id, + value, + selected, + label, + }; + } + }); + setRenderedItems(newItems); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, focusedItemId, idSeed, propValue, textValue]); - const textValueRef = useRef(textValue); useEffect(() => { - textValueRef.current = textValue; - }, [textValue]); + if (propValue && renderedItems[propValue]) { + setTextValue(renderedItems[propValue].label); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propValue]); - const triggerRef = useRef(null); - const triggerId = useId(); + focusControls.current = { + focus: setFocusedItemId, + focusNext: () => { + const options = Object.values(renderedItems); + const i = options.findIndex((el) => el.focused); + const newIndex = (i + 1) % Object.values(renderedItems).length; + setFocusedItemId(options[newIndex].id); + }, + focusPrev: () => { + const options = Object.values(renderedItems); + const i = options.findIndex((el) => el.focused); + const newIndex = i > 0 ? i - 1 : Object.values(renderedItems).length - 1; + setFocusedItemId(options[newIndex].id); + }, + }; - const contextValue = useMemo( - () => ({ - value: propValue, - triggerRef, - onChange: (newValue: string) => propOnChange?.(newValue, textValueRef.current), - }), - [propOnChange, propValue, textValueRef] - ); + const contextValue: ComboBoxContext = { + inputRef, + selectedItemValue: propValue, + onChange: propOnChange, + focusedItemId, + idSeed, + textValue, + renderedItems, + focusControls, + }; return ( - - - {children} + + + + {children} + @@ -72,14 +101,3 @@ const ComboBox: React.FC & { ComboBox.Item = Item; export default ComboBox; - -/** - * Duplicate state to be able to use the element uncontrolled - */ -function useValue(propValue: Props['value'], propOnChange: Props['onChange']) { - return [value, onChange] as const; -} - -function useTextValue({ children, onChange, value }: Partial) { - return [textValue, updateText] as const; -} diff --git a/src/lib/ComboBox/Input.tsx b/src/lib/ComboBox/Input.tsx index a123e1c5..40872d39 100644 --- a/src/lib/ComboBox/Input.tsx +++ b/src/lib/ComboBox/Input.tsx @@ -1,27 +1,37 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { HiSelector } from 'react-icons/hi'; import TextField from '../TextField'; -import { useAllHandlers, useForkRef, useKeyboardHandles } from '../utils'; +import { useAllHandlers, useKeyboardHandles } from '../utils'; import { useOpenState, useOpenStateControls } from '../utils/OpenStateProvider'; import { useComboBox } from './utils'; -interface Props extends Omit, 'icon'> {} +interface Props extends Omit, 'icon' | 'type' | 'value'> { + value?: string; +} -const Input = React.forwardRef(({ id, hint, inputRef, ...props }, ref) => { - const { triggerRef } = useComboBox(); +const Input: React.FC = (props) => { + const { inputRef, focusedItemId, focusControls, selectedItemValue, renderedItems, idSeed } = useComboBox(); const isOpen = useOpenState(); const { open, close } = useOpenStateControls(); + const onChange = useAllHandlers(props.onChange as any, open); + const handleKeyDown = useKeyboardHandles({ - ArrowDown: open, - ArrowUp: open, - Escape: close, + 'ArrowDown': open, + 'ArrowUp': open, + 'Escape': close, + 'Tab.propagate': close, }); const onKeyDown = useAllHandlers(props.onKeyDown, handleKeyDown); - const refs = useForkRef(inputRef, triggerRef); + useEffect(() => { + if (isOpen && selectedItemValue) { + focusControls.current?.focus(renderedItems[selectedItemValue].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); return ( (({ id, hint, inputRef, ... aria-autocomplete="list" role="combobox" {...props} + aria-activedescendant={focusedItemId} + aria-controls={idSeed('listbox')} autoComplete="off" spellCheck="false" icon={HiSelector} onKeyDown={onKeyDown} - inputRef={refs} - ref={ref} + onChange={onChange} + inputRef={inputRef} /> ); -}); +}; export default Input; diff --git a/src/lib/ComboBox/Item.tsx b/src/lib/ComboBox/Item.tsx index c6f4902c..6296c0c0 100644 --- a/src/lib/ComboBox/Item.tsx +++ b/src/lib/ComboBox/Item.tsx @@ -14,25 +14,43 @@ const Option = styled(ListItem, { }, }); -type Props = Omit, 'children'> & { +interface Props extends Omit, 'children' | 'value' | 'label'> { value: string; label: string; -}; +} -const Item = React.forwardRef(({ value, label, ...props }, ref) => { - const { value: dropdownValue, onChange } = useComboBox(); +const Item = React.forwardRef(({ value, label, ...props }, propRef) => { + const { renderedItems, onChange, inputRef } = useComboBox(); const { close } = useOpenStateControls(); - const isSelected = dropdownValue === value; + const item = renderedItems[value]; - const onClick = useCallback(() => { - onChange?.(value); - close(); - }, [close, onChange, value]); + const onClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onChange?.(value); + close(); + inputRef.current?.focus(); + }, + [close, onChange, value, inputRef] + ); + + if (!item) { + return null; + } return ( - ); }); diff --git a/src/lib/ComboBox/List.tsx b/src/lib/ComboBox/List.tsx deleted file mode 100644 index 291f06a5..00000000 --- a/src/lib/ComboBox/List.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import ListBox from '../ListBox'; -import { useComboBox } from './utils'; - -type Props = { - triggerId: string; -} & React.ComponentProps; - -const List: React.FC = ({ triggerId, children, ...props }) => { - const { value } = useComboBox(); - const ref = useRef(null); - - useEffect(() => { - // selected value is already focused - if (ref.current?.contains(document.activeElement)) { - return; - } - - // focus selected item - // const option = ref.current?.querySelector('[role="option"]') as HTMLLIElement; - - // if (option) { - // option?.focus?.(); - // } else { - // ref.current?.focus(); - // } - }, [value]); - - return ( - - {children} - - ); -}; - -export default List; diff --git a/src/lib/ComboBox/readme.md b/src/lib/ComboBox/readme.md new file mode 100644 index 00000000..7b0cd9e6 --- /dev/null +++ b/src/lib/ComboBox/readme.md @@ -0,0 +1,3 @@ +# Confession + +I understand that the implementation is not pretty, performant or properly accessible. But I needed some component that satisfy my needs and didn't want to spend months on it. Hence, here it is. diff --git a/src/lib/ComboBox/utils.ts b/src/lib/ComboBox/utils.ts index dafbd348..a0c0e17a 100644 --- a/src/lib/ComboBox/utils.ts +++ b/src/lib/ComboBox/utils.ts @@ -1,14 +1,45 @@ import React, { createContext, useContext } from 'react'; +import { useUIDSeed } from 'react-uid'; -type ComboBoxContext = { - triggerRef: React.RefObject; - value?: string; - onChange?: (newValue: string) => void; -}; +export interface FocusControls { + focus: (newId: string) => void; + focusNext: () => void; + focusPrev: () => void; +} -const context = createContext({ - triggerRef: { current: null }, -} as ComboBoxContext); +export interface ComboBoxContext { + inputRef: React.RefObject; + textValue: string; + selectedItemValue?: string; + focusedItemId?: string; + onChange?: (newValue: string | undefined | null) => void; + idSeed: ReturnType; + renderedItems: Record< + string, + { + id: string; + value: string; + label: string; + selected: boolean; + focused: boolean; + } + >; + focusControls: React.RefObject; +} + +const context = createContext({ + inputRef: { current: null }, + idSeed: () => undefined as any, + textValue: '', + renderedItems: {}, + focusControls: { + current: { + focus: () => {}, + focusNext: () => {}, + focusPrev: () => {}, + }, + }, +}); export const ComboBoxProvider = context.Provider; diff --git a/src/lib/ListBox/ListBox.tsx b/src/lib/ListBox/ListBox.tsx index 3aa80a68..97c17063 100644 --- a/src/lib/ListBox/ListBox.tsx +++ b/src/lib/ListBox/ListBox.tsx @@ -3,12 +3,16 @@ import React from 'react'; import { styled } from '../stitches.config'; import { useKeyboardHandles } from '../utils'; -const List = styled('ul', { - m: 0, - p: '$1', - overflowY: 'auto', - maxHeight: '240px', - $outline: -1, +export const List = styled('ul', { + 'm': 0, + 'p': '$1', + 'overflowY': 'auto', + 'maxHeight': '240px', + '$outline': -1, + + '&:empty': { + display: 'none', + }, }); const ListInner: React.FC<{ wrap?: boolean }> = ({ wrap, ...props }) => { diff --git a/src/lib/ListItem/ListItem.tsx b/src/lib/ListItem/ListItem.tsx index 67bf2645..90f3f7e1 100644 --- a/src/lib/ListItem/ListItem.tsx +++ b/src/lib/ListItem/ListItem.tsx @@ -24,6 +24,14 @@ const Item = styled(FlexBox as FlexType<'li'>, { bc: '$surfaceActive', }, + 'variants': { + isFocused: { + true: { + bc: '$surfaceHover', + }, + }, + }, + [`& ${Text}`]: { fontSize: 'inherit', lineHeight: 'inherit', diff --git a/src/lib/Popover/Popover.tsx b/src/lib/Popover/Popover.tsx index bb51956d..af81a69f 100644 --- a/src/lib/Popover/Popover.tsx +++ b/src/lib/Popover/Popover.tsx @@ -14,9 +14,8 @@ const Popper = styled('div', { const PopperBox = styled(motion.div, { bc: '$surfaceStill', br: '$md', - border: '1px solid $borderLight', outline: 'none', - boxShadow: '$xl', + boxShadow: '$popper', }); const animations: Record = { diff --git a/src/lib/stitches.config.ts b/src/lib/stitches.config.ts index e4e617c5..0179cc5c 100644 --- a/src/lib/stitches.config.ts +++ b/src/lib/stitches.config.ts @@ -2,6 +2,7 @@ import { createStyled } from '@stitches/react'; import { attribute } from './FocusRing/focus-visible'; import colors from './theme/colors'; import { scales } from './theme/scales'; +import { shadows } from './theme/shadows'; import { isServer } from './utils'; export const theme = { @@ -55,17 +56,7 @@ export const theme = { $4: '400', $max: '9999', }, - shadows: { - $base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', - $xs: '0 0 0 1px rgba(0, 0, 0, 0.05)', - $sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', - $md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', - $lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', - $xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', - $2xl: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', - $inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', - $none: 'none', - }, + shadows: { ...shadows, $popper: `0 0 1px ${colors.$borderLight}, ${shadows.$xl}`, $none: 'none' }, }; type Theme = typeof theme; diff --git a/src/lib/theme/shadows.ts b/src/lib/theme/shadows.ts new file mode 100644 index 00000000..e97e1403 --- /dev/null +++ b/src/lib/theme/shadows.ts @@ -0,0 +1,10 @@ +export const shadows = { + $base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', + $xs: '0 0 0 1px rgba(0, 0, 0, 0.05)', + $sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + $md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + $lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + $xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', + $2xl: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', + $inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', +}; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index c8ed3ab6..2a799211 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -61,7 +61,9 @@ export function useAllHandlers>( } type KeyboardHandler = ((event: React.KeyboardEvent) => void) | undefined; -type KeyboardHandlers = { [Key in React.KeyboardEvent['key']]: KeyboardHandler }; +type KeyboardHandlers = { + [Key in React.KeyboardEvent['key'] | `${React.KeyboardEvent['key']}.propagate`]: KeyboardHandler; +}; export function useKeyboardHandles(handlers: KeyboardHandlers): KeyboardHandler { const handlersRef = useRef(handlers); @@ -71,9 +73,14 @@ export function useKeyboardHandles(handlers: KeyboardHandlers): KeyboardHandler }, [handlers]); return useCallback<(event: React.KeyboardEvent) => void>((event) => { - const handler = handlersRef.current[event.key]; - if (typeof handler === 'function') { + const handlers = handlersRef.current; + let handler = handlers[event.key]; + if (handler) { event.preventDefault(); + return handler(event); + } + handler = handlers[`${event.key}.propagate`]; + if (handler) { handler(event); } }, []); diff --git a/src/pages/components/ComboBox.mdx b/src/pages/components/ComboBox.mdx index ebaa9e0f..4b1a4757 100644 --- a/src/pages/components/ComboBox.mdx +++ b/src/pages/components/ComboBox.mdx @@ -7,14 +7,35 @@ import { Box, Flex, ComboBox, Text } from '@fxtrot/ui'; # ComboBox + + {() => { + const [value, setValue] = React.useState('1'); + return ( + + + + {['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'].map((house, index) => ( + + ))} + + +
{`Selected item index: ${value}`}
+
+
+
+ ); + }} +
+ +## Allowing creation of new elements + +You can allow the selection of elements that do not exist - creating them from the input value. For this, subscribe to the input text changes and store them locally. + + diff --git a/yarn.lock b/yarn.lock index 338ab3a9..3719ac9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10979,10 +10979,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^3.9.5, typescript@^4.0.3: - version "4.0.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" - integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== +typescript@^3.9.5: + version "3.9.7" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" + integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== typescript@^4.1.2: version "4.1.2" From 4e8e0c989cdd9f7e8c86d8048b8283ad312f5eca Mon Sep 17 00:00:00 2001 From: Alexander Swed Date: Sun, 6 Dec 2020 21:08:36 +0100 Subject: [PATCH 03/16] chore: fix focus --- src/lib/ComboBox/ComboBox.tsx | 11 +++++------ src/lib/ComboBox/Input.tsx | 34 +++++++++++++++++++++++++++++++--- src/lib/ComboBox/Item.tsx | 4 ++-- src/lib/ComboBox/utils.ts | 1 - 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/lib/ComboBox/ComboBox.tsx b/src/lib/ComboBox/ComboBox.tsx index e9fb8862..e814a327 100644 --- a/src/lib/ComboBox/ComboBox.tsx +++ b/src/lib/ComboBox/ComboBox.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { useUIDSeed } from 'react-uid'; +import { useUIDSeed, uid } from 'react-uid'; import Popover from '../Popover'; import { useAllHandlers } from '../utils'; import { OpenStateProvider } from '../utils/OpenStateProvider'; @@ -35,10 +35,9 @@ const ComboBox: React.FC & { React.Children.forEach(children, (option: OptionType) => { const { label, value } = option.props || {}; const selected = value === propValue; - const id = renderedItems[value]?.id || idSeed('option'); + const id = uid(value); if (label.toLowerCase().includes(textValue.toLowerCase())) { newItems[value] = { - focused: id === focusedItemId, id, value, selected, @@ -48,7 +47,7 @@ const ComboBox: React.FC & { }); setRenderedItems(newItems); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [children, focusedItemId, idSeed, propValue, textValue]); + }, [children, focusedItemId, propValue, textValue]); useEffect(() => { if (propValue && renderedItems[propValue]) { @@ -61,13 +60,13 @@ const ComboBox: React.FC & { focus: setFocusedItemId, focusNext: () => { const options = Object.values(renderedItems); - const i = options.findIndex((el) => el.focused); + const i = options.findIndex((el) => el.id === focusedItemId); const newIndex = (i + 1) % Object.values(renderedItems).length; setFocusedItemId(options[newIndex].id); }, focusPrev: () => { const options = Object.values(renderedItems); - const i = options.findIndex((el) => el.focused); + const i = options.findIndex((el) => el.id === focusedItemId); const newIndex = i > 0 ? i - 1 : Object.values(renderedItems).length - 1; setFocusedItemId(options[newIndex].id); }, diff --git a/src/lib/ComboBox/Input.tsx b/src/lib/ComboBox/Input.tsx index 40872d39..62c06672 100644 --- a/src/lib/ComboBox/Input.tsx +++ b/src/lib/ComboBox/Input.tsx @@ -11,17 +11,45 @@ interface Props extends Omit, 'icon' | 't } const Input: React.FC = (props) => { - const { inputRef, focusedItemId, focusControls, selectedItemValue, renderedItems, idSeed } = useComboBox(); + const { + inputRef, + focusedItemId, + focusControls, + selectedItemValue, + onChange: changeValue, + renderedItems, + idSeed, + } = useComboBox(); const isOpen = useOpenState(); const { open, close } = useOpenStateControls(); const onChange = useAllHandlers(props.onChange as any, open); + const onSelect = useAllHandlers(() => { + const newValue = Object.keys(renderedItems).find((key) => renderedItems[key].id === focusedItemId); + changeValue?.(newValue); + close(); + }); + const handleKeyDown = useKeyboardHandles({ - 'ArrowDown': open, - 'ArrowUp': open, + 'ArrowDown': () => { + if (isOpen) { + focusControls.current?.focusNext(); + } else { + open(); + } + }, + 'ArrowUp': () => { + if (isOpen) { + focusControls.current?.focusPrev(); + } else { + open(); + } + }, 'Escape': close, 'Tab.propagate': close, + 'Enter': onSelect, + 'Space': onSelect, }); const onKeyDown = useAllHandlers(props.onKeyDown, handleKeyDown); diff --git a/src/lib/ComboBox/Item.tsx b/src/lib/ComboBox/Item.tsx index 6296c0c0..3aa8e895 100644 --- a/src/lib/ComboBox/Item.tsx +++ b/src/lib/ComboBox/Item.tsx @@ -20,7 +20,7 @@ interface Props extends Omit, 'children' | } const Item = React.forwardRef(({ value, label, ...props }, propRef) => { - const { renderedItems, onChange, inputRef } = useComboBox(); + const { renderedItems, onChange, focusedItemId, inputRef } = useComboBox(); const { close } = useOpenStateControls(); const item = renderedItems[value]; @@ -42,7 +42,7 @@ const Item = React.forwardRef(({ value, label, ...props }, return (