From f5617aca1c4c95a38cc439da9230f168e604e724 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Sun, 2 Jul 2023 17:41:02 +0100 Subject: [PATCH] :recycle: moving more components out of common.tsx (#1257) Moving some more components out of `common.tsx` into their own files. There are no functional changes. This is a direct copy&paste into new files. --- .../desktop-client/src/components/common.tsx | 434 +----------------- .../src/components/common/AlignedText.tsx | 56 +++ .../src/components/common/CustomSelect.tsx | 52 +++ .../src/components/common/FormError.tsx | 16 + .../src/components/common/InitialFocus.tsx | 34 ++ .../components/common/InputWithContent.tsx | 72 +++ .../src/components/common/Menu.tsx | 181 ++++++++ .../src/components/common/Search.tsx | 43 ++ upcoming-release-notes/1257.md | 6 + 9 files changed, 471 insertions(+), 423 deletions(-) create mode 100644 packages/desktop-client/src/components/common/AlignedText.tsx create mode 100644 packages/desktop-client/src/components/common/CustomSelect.tsx create mode 100644 packages/desktop-client/src/components/common/FormError.tsx create mode 100644 packages/desktop-client/src/components/common/InitialFocus.tsx create mode 100644 packages/desktop-client/src/components/common/InputWithContent.tsx create mode 100644 packages/desktop-client/src/components/common/Menu.tsx create mode 100644 packages/desktop-client/src/components/common/Search.tsx create mode 100644 upcoming-release-notes/1257.md diff --git a/packages/desktop-client/src/components/common.tsx b/packages/desktop-client/src/components/common.tsx index 554b5c6bbe..d63d823319 100644 --- a/packages/desktop-client/src/components/common.tsx +++ b/packages/desktop-client/src/components/common.tsx @@ -1,52 +1,40 @@ import React, { useRef, - useEffect, useLayoutEffect, - useState, useCallback, - type ChangeEvent, type ComponentProps, - type ReactElement, type ReactNode, - type Ref, forwardRef, - createElement, - cloneElement, } from 'react'; import { NavLink, useMatch, useNavigate } from 'react-router-dom'; -import { - ListboxInput, - ListboxButton, - ListboxPopover, - ListboxList, - ListboxOption, -} from '@reach/listbox'; import { type CSSProperties, css } from 'glamor'; -import ExpandArrow from '../icons/v0/ExpandArrow'; import { styles, colors } from '../style'; import type { HTMLPropsWithStyle } from '../types/utils'; -import Block from './common/Block'; import Button from './common/Button'; -import Input, { defaultInputStyle } from './common/Input'; -import Text from './common/Text'; -import View from './common/View'; -export { default as Modal, ModalButtons } from './common/Modal'; +export { default as AlignedText } from './common/AlignedText'; export { default as Block } from './common/Block'; export { default as Button, ButtonWithLoading } from './common/Button'; export { default as Card } from './common/Card'; +export { default as CustomSelect } from './common/CustomSelect'; +export { default as FormError } from './common/FormError'; export { default as HoverTarget } from './common/HoverTarget'; +export { default as InitialFocus } from './common/InitialFocus'; export { default as InlineField } from './common/InlineField'; export { default as Input } from './common/Input'; +export { default as InputWithContent } from './common/InputWithContent'; export { default as Label } from './common/Label'; -export { default as View } from './common/View'; -export { default as Text } from './common/Text'; -export { default as TextOneLine } from './common/TextOneLine'; +export { default as Menu } from './common/Menu'; +export { default as Modal, ModalButtons } from './common/Modal'; +export { default as Search } from './common/Search'; export { default as Select } from './common/Select'; export { default as Stack } from './Stack'; +export { default as Text } from './common/Text'; +export { default as TextOneLine } from './common/TextOneLine'; +export { default as View } from './common/View'; type UseStableCallbackArg = (...args: unknown[]) => unknown; @@ -174,368 +162,6 @@ export function ButtonLink({ ); } -type InputWithContentProps = ComponentProps & { - leftContent: ReactNode; - rightContent: ReactNode; - inputStyle?: CSSProperties; - style?: CSSProperties; - getStyle?: (focused: boolean) => CSSProperties; -}; -export function InputWithContent({ - leftContent, - rightContent, - inputStyle, - style, - getStyle, - ...props -}: InputWithContentProps) { - let [focused, setFocused] = useState(false); - - return ( - - {leftContent} - { - setFocused(true); - props.onFocus && props.onFocus(e); - }} - onBlur={e => { - setFocused(false); - props.onBlur && props.onBlur(e); - }} - /> - {rightContent} - - ); -} - -type SearchProps = { - inputRef: Ref; - value: string; - onChange: (value: string) => unknown; - placeholder: string; - isInModal: boolean; - width?: number; -}; -export function Search({ - inputRef, - value, - onChange, - placeholder, - isInModal, - width = 350, -}: SearchProps) { - return ( - ) => onChange(e.target.value)} - style={{ - width, - borderColor: isInModal ? null : 'transparent', - backgroundColor: isInModal ? null : colors.n11, - ':focus': isInModal - ? null - : { - backgroundColor: 'white', - '::placeholder': { color: colors.n8 }, - }, - }} - /> - ); -} - -type CustomSelectProps = { - options: Array<[string, string]>; - value: string; - onChange?: (newValue: string) => void; - style?: CSSProperties; - disabledKeys?: string[]; -}; - -export function CustomSelect({ - options, - value, - onChange, - style, - disabledKeys = [], -}: CustomSelectProps) { - return ( - - } - /> - - - {options.map(([value, label]) => ( - - {label} - - ))} - - - - ); -} - -type KeybindingProps = { - keyName: ReactNode; -}; - -function Keybinding({ keyName }: KeybindingProps) { - return {keyName}; -} - -type MenuItem = { - type?: string | symbol; - name: string; - disabled?: boolean; - icon?; - iconSize?: number; - text: string; - key?: string; -}; - -type MenuProps = { - header?: ReactNode; - footer?: ReactNode; - items: Array; - onMenuSelect; -}; - -export function Menu({ - header, - footer, - items: allItems, - onMenuSelect, -}: MenuProps) { - let elRef = useRef(null); - let items = allItems.filter(x => x); - let [hoveredIndex, setHoveredIndex] = useState(null); - - useEffect(() => { - const el = elRef.current; - el.focus(); - - let onKeyDown = e => { - let filteredItems = items.filter( - item => item && item !== Menu.line && item.type !== Menu.label, - ); - let currentIndex = filteredItems.indexOf(items[hoveredIndex]); - - let transformIndex = idx => items.indexOf(filteredItems[idx]); - - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - setHoveredIndex( - hoveredIndex === null - ? 0 - : transformIndex(Math.max(currentIndex - 1, 0)), - ); - break; - case 'ArrowDown': - e.preventDefault(); - setHoveredIndex( - hoveredIndex === null - ? 0 - : transformIndex( - Math.min(currentIndex + 1, filteredItems.length - 1), - ), - ); - break; - case 'Enter': - e.preventDefault(); - const item = items[hoveredIndex]; - if (hoveredIndex !== null && item !== Menu.line) { - onMenuSelect && onMenuSelect(item.name); - } - break; - default: - } - }; - - el.addEventListener('keydown', onKeyDown); - - return () => { - el.removeEventListener('keydown', onKeyDown); - }; - }, [hoveredIndex]); - - return ( - - {header} - {items.map((item, idx) => { - if (item === Menu.line) { - return ( - - - - ); - } else if (item.type === Menu.label) { - return ( - - {item.name} - - ); - } - - let lastItem = items[idx - 1]; - - return ( - setHoveredIndex(idx)} - onMouseLeave={() => setHoveredIndex(null)} - onClick={e => - !item.disabled && onMenuSelect && onMenuSelect(item.name) - } - > - {/* Force it to line up evenly */} - - {item.icon && - createElement(item.icon, { - width: item.iconSize || 10, - height: item.iconSize || 10, - style: { marginRight: 7, width: 10 }, - })} - - {item.text} - - {item.key && } - - ); - })} - {footer} - - ); -} - -const MenuLine: unique symbol = Symbol('menu-line'); -Menu.line = MenuLine; -Menu.label = Symbol('menu-label'); - -type AlignedTextProps = ComponentProps & { - left; - right; - style?: CSSProperties; - leftStyle?: CSSProperties; - rightStyle?: CSSProperties; - truncate?: 'left' | 'right'; -}; -export function AlignedText({ - left, - right, - style, - leftStyle, - rightStyle, - truncate = 'left', - ...nativeProps -}: AlignedTextProps) { - const truncateStyle = { - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - }; - - return ( - - - {left} - - - {right} - - - ); -} - type PProps = HTMLPropsWithStyle & { isLast?: boolean; }; @@ -550,43 +176,5 @@ export function P({ style, isLast, children, ...props }: PProps) { ); } -type FormErrorProps = { - style?: CSSProperties; - children?: ReactNode; -}; - -export function FormError({ style, children }: FormErrorProps) { - return ( - {children} - ); -} - -type InitialFocusProps = { - children?: ReactElement | ((node: Ref) => ReactElement); -}; - -export function InitialFocus({ children }: InitialFocusProps) { - let node = useRef(null); - - useEffect(() => { - if (node.current && !global.IS_DESIGN_MODE) { - // This is needed to avoid a strange interaction with - // `ScopeTab`, which doesn't allow it to be focused at first for - // some reason. Need to look into it. - setTimeout(() => { - if (node.current) { - node.current.focus(); - node.current.setSelectionRange(0, 10000); - } - }, 0); - } - }, []); - - if (typeof children === 'function') { - return children(node); - } - return cloneElement(children, { inputRef: node }); -} - export * from './tooltips'; export { useTooltip } from './tooltips'; diff --git a/packages/desktop-client/src/components/common/AlignedText.tsx b/packages/desktop-client/src/components/common/AlignedText.tsx new file mode 100644 index 0000000000..1636bf17cc --- /dev/null +++ b/packages/desktop-client/src/components/common/AlignedText.tsx @@ -0,0 +1,56 @@ +import { type ComponentProps } from 'react'; + +import { type CSSProperties } from 'glamor'; + +import Block from './Block'; +import View from './View'; + +type AlignedTextProps = ComponentProps & { + left; + right; + style?: CSSProperties; + leftStyle?: CSSProperties; + rightStyle?: CSSProperties; + truncate?: 'left' | 'right'; +}; +export default function AlignedText({ + left, + right, + style, + leftStyle, + rightStyle, + truncate = 'left', + ...nativeProps +}: AlignedTextProps) { + const truncateStyle = { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }; + + return ( + + + {left} + + + {right} + + + ); +} diff --git a/packages/desktop-client/src/components/common/CustomSelect.tsx b/packages/desktop-client/src/components/common/CustomSelect.tsx new file mode 100644 index 0000000000..7114057fc5 --- /dev/null +++ b/packages/desktop-client/src/components/common/CustomSelect.tsx @@ -0,0 +1,52 @@ +import { + ListboxInput, + ListboxButton, + ListboxPopover, + ListboxList, + ListboxOption, +} from '@reach/listbox'; +import { type CSSProperties, css } from 'glamor'; + +import ExpandArrow from '../../icons/v0/ExpandArrow'; + +type CustomSelectProps = { + options: Array<[string, string]>; + value: string; + onChange?: (newValue: string) => void; + style?: CSSProperties; + disabledKeys?: string[]; +}; + +export default function CustomSelect({ + options, + value, + onChange, + style, + disabledKeys = [], +}: CustomSelectProps) { + return ( + + } + /> + + + {options.map(([value, label]) => ( + + {label} + + ))} + + + + ); +} diff --git a/packages/desktop-client/src/components/common/FormError.tsx b/packages/desktop-client/src/components/common/FormError.tsx new file mode 100644 index 0000000000..90854653fd --- /dev/null +++ b/packages/desktop-client/src/components/common/FormError.tsx @@ -0,0 +1,16 @@ +import { type ReactNode } from 'react'; + +import { type CSSProperties } from 'glamor'; + +import View from './View'; + +type FormErrorProps = { + style?: CSSProperties; + children?: ReactNode; +}; + +export default function FormError({ style, children }: FormErrorProps) { + return ( + {children} + ); +} diff --git a/packages/desktop-client/src/components/common/InitialFocus.tsx b/packages/desktop-client/src/components/common/InitialFocus.tsx new file mode 100644 index 0000000000..7c6ad0a651 --- /dev/null +++ b/packages/desktop-client/src/components/common/InitialFocus.tsx @@ -0,0 +1,34 @@ +import { + type ReactElement, + type Ref, + cloneElement, + useEffect, + useRef, +} from 'react'; + +type InitialFocusProps = { + children?: ReactElement | ((node: Ref) => ReactElement); +}; + +export default function InitialFocus({ children }: InitialFocusProps) { + let node = useRef(null); + + useEffect(() => { + if (node.current && !global.IS_DESIGN_MODE) { + // This is needed to avoid a strange interaction with + // `ScopeTab`, which doesn't allow it to be focused at first for + // some reason. Need to look into it. + setTimeout(() => { + if (node.current) { + node.current.focus(); + node.current.setSelectionRange(0, 10000); + } + }, 0); + } + }, []); + + if (typeof children === 'function') { + return children(node); + } + return cloneElement(children, { inputRef: node }); +} diff --git a/packages/desktop-client/src/components/common/InputWithContent.tsx b/packages/desktop-client/src/components/common/InputWithContent.tsx new file mode 100644 index 0000000000..6d907be0f4 --- /dev/null +++ b/packages/desktop-client/src/components/common/InputWithContent.tsx @@ -0,0 +1,72 @@ +import { type ComponentProps, type ReactNode, useState } from 'react'; + +import { type CSSProperties } from 'glamor'; + +import { colors } from '../../style'; + +import Input, { defaultInputStyle } from './Input'; +import View from './View'; + +type InputWithContentProps = ComponentProps & { + leftContent: ReactNode; + rightContent: ReactNode; + inputStyle?: CSSProperties; + style?: CSSProperties; + getStyle?: (focused: boolean) => CSSProperties; +}; +export default function InputWithContent({ + leftContent, + rightContent, + inputStyle, + style, + getStyle, + ...props +}: InputWithContentProps) { + let [focused, setFocused] = useState(false); + + return ( + + {leftContent} + { + setFocused(true); + props.onFocus && props.onFocus(e); + }} + onBlur={e => { + setFocused(false); + props.onBlur && props.onBlur(e); + }} + /> + {rightContent} + + ); +} diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx new file mode 100644 index 0000000000..8aa49d3e1a --- /dev/null +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -0,0 +1,181 @@ +import { + type ReactNode, + createElement, + useEffect, + useRef, + useState, +} from 'react'; + +import { colors } from '../../style'; + +import Text from './Text'; +import View from './View'; + +type KeybindingProps = { + keyName: ReactNode; +}; + +function Keybinding({ keyName }: KeybindingProps) { + return {keyName}; +} + +type MenuItem = { + type?: string | symbol; + name: string; + disabled?: boolean; + icon?; + iconSize?: number; + text: string; + key?: string; +}; + +type MenuProps = { + header?: ReactNode; + footer?: ReactNode; + items: Array; + onMenuSelect; +}; + +export default function Menu({ + header, + footer, + items: allItems, + onMenuSelect, +}: MenuProps) { + let elRef = useRef(null); + let items = allItems.filter(x => x); + let [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + const el = elRef.current; + el.focus(); + + let onKeyDown = e => { + let filteredItems = items.filter( + item => item && item !== Menu.line && item.type !== Menu.label, + ); + let currentIndex = filteredItems.indexOf(items[hoveredIndex]); + + let transformIndex = idx => items.indexOf(filteredItems[idx]); + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + setHoveredIndex( + hoveredIndex === null + ? 0 + : transformIndex(Math.max(currentIndex - 1, 0)), + ); + break; + case 'ArrowDown': + e.preventDefault(); + setHoveredIndex( + hoveredIndex === null + ? 0 + : transformIndex( + Math.min(currentIndex + 1, filteredItems.length - 1), + ), + ); + break; + case 'Enter': + e.preventDefault(); + const item = items[hoveredIndex]; + if (hoveredIndex !== null && item !== Menu.line) { + onMenuSelect && onMenuSelect(item.name); + } + break; + default: + } + }; + + el.addEventListener('keydown', onKeyDown); + + return () => { + el.removeEventListener('keydown', onKeyDown); + }; + }, [hoveredIndex]); + + return ( + + {header} + {items.map((item, idx) => { + if (item === Menu.line) { + return ( + + + + ); + } else if (item.type === Menu.label) { + return ( + + {item.name} + + ); + } + + let lastItem = items[idx - 1]; + + return ( + setHoveredIndex(idx)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={e => + !item.disabled && onMenuSelect && onMenuSelect(item.name) + } + > + {/* Force it to line up evenly */} + + {item.icon && + createElement(item.icon, { + width: item.iconSize || 10, + height: item.iconSize || 10, + style: { marginRight: 7, width: 10 }, + })} + + {item.text} + + {item.key && } + + ); + })} + {footer} + + ); +} + +const MenuLine: unique symbol = Symbol('menu-line'); +Menu.line = MenuLine; +Menu.label = Symbol('menu-label'); diff --git a/packages/desktop-client/src/components/common/Search.tsx b/packages/desktop-client/src/components/common/Search.tsx new file mode 100644 index 0000000000..c1b6bf8324 --- /dev/null +++ b/packages/desktop-client/src/components/common/Search.tsx @@ -0,0 +1,43 @@ +import { type ChangeEvent, type Ref } from 'react'; + +import { colors } from '../../style'; + +import Input from './Input'; + +type SearchProps = { + inputRef: Ref; + value: string; + onChange: (value: string) => unknown; + placeholder: string; + isInModal: boolean; + width?: number; +}; + +export default function Search({ + inputRef, + value, + onChange, + placeholder, + isInModal, + width = 350, +}: SearchProps) { + return ( + ) => onChange(e.target.value)} + style={{ + width, + borderColor: isInModal ? null : 'transparent', + backgroundColor: isInModal ? null : colors.n11, + ':focus': isInModal + ? null + : { + backgroundColor: 'white', + '::placeholder': { color: colors.n8 }, + }, + }} + /> + ); +} diff --git a/upcoming-release-notes/1257.md b/upcoming-release-notes/1257.md new file mode 100644 index 0000000000..e0a8d21643 --- /dev/null +++ b/upcoming-release-notes/1257.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Moving more components from `common.tsx` to separate files inside the `common` folder