mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
♻️ 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.
This commit is contained in:
committed by
GitHub
parent
bdaa78b919
commit
f5617aca1c
@@ -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<typeof Input> & {
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
defaultInputStyle,
|
||||
{
|
||||
padding: 0,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
focused && {
|
||||
border: '1px solid ' + colors.b5,
|
||||
boxShadow: '0 1px 1px ' + colors.b7,
|
||||
},
|
||||
style,
|
||||
getStyle && getStyle(focused),
|
||||
]}
|
||||
>
|
||||
{leftContent}
|
||||
<Input
|
||||
{...props}
|
||||
style={[
|
||||
inputStyle,
|
||||
{
|
||||
flex: 1,
|
||||
'&, &:focus, &:hover': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
props.onFocus && props.onFocus(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
props.onBlur && props.onBlur(e);
|
||||
}}
|
||||
/>
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchProps = {
|
||||
inputRef: Ref<HTMLInputElement>;
|
||||
value: string;
|
||||
onChange: (value: string) => unknown;
|
||||
placeholder: string;
|
||||
isInModal: boolean;
|
||||
width?: number;
|
||||
};
|
||||
export function Search({
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
isInModal,
|
||||
width = 350,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => 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 (
|
||||
<ListboxInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ lineHeight: '1em' }}
|
||||
>
|
||||
<ListboxButton
|
||||
{...css([{ borderWidth: 0, padding: 5, borderRadius: 4 }, style])}
|
||||
arrow={<ExpandArrow style={{ width: 7, height: 7, paddingTop: 3 }} />}
|
||||
/>
|
||||
<ListboxPopover style={{ zIndex: 10000, outline: 0, borderRadius: 4 }}>
|
||||
<ListboxList>
|
||||
{options.map(([value, label]) => (
|
||||
<ListboxOption
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={disabledKeys.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
);
|
||||
}
|
||||
|
||||
type KeybindingProps = {
|
||||
keyName: ReactNode;
|
||||
};
|
||||
|
||||
function Keybinding({ keyName }: KeybindingProps) {
|
||||
return <Text style={{ fontSize: 10, color: colors.n6 }}>{keyName}</Text>;
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
type?: string | symbol;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
icon?;
|
||||
iconSize?: number;
|
||||
text: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type MenuProps = {
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
items: Array<MenuItem | typeof Menu.line>;
|
||||
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 (
|
||||
<View
|
||||
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden' }}
|
||||
tabIndex={1}
|
||||
innerRef={elRef}
|
||||
>
|
||||
{header}
|
||||
{items.map((item, idx) => {
|
||||
if (item === Menu.line) {
|
||||
return (
|
||||
<View key={idx} style={{ margin: '3px 0px' }}>
|
||||
<View style={{ borderTop: '1px solid ' + colors.n10 }} />
|
||||
</View>
|
||||
);
|
||||
} else if (item.type === Menu.label) {
|
||||
return (
|
||||
<Text
|
||||
key={item.name}
|
||||
style={{
|
||||
color: colors.n6,
|
||||
fontSize: 11,
|
||||
lineHeight: '1em',
|
||||
textTransform: 'uppercase',
|
||||
margin: '3px 9px',
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let lastItem = items[idx - 1];
|
||||
|
||||
return (
|
||||
<View
|
||||
role="button"
|
||||
key={item.name}
|
||||
style={[
|
||||
{
|
||||
cursor: 'default',
|
||||
padding: '9px 10px',
|
||||
marginTop:
|
||||
idx === 0 ||
|
||||
lastItem === Menu.line ||
|
||||
lastItem.type === Menu.label
|
||||
? 0
|
||||
: -3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
item.disabled && { color: colors.n7 },
|
||||
!item.disabled &&
|
||||
hoveredIndex === idx && { backgroundColor: colors.n10 },
|
||||
]}
|
||||
onMouseEnter={() => setHoveredIndex(idx)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={e =>
|
||||
!item.disabled && onMenuSelect && onMenuSelect(item.name)
|
||||
}
|
||||
>
|
||||
{/* Force it to line up evenly */}
|
||||
<Text style={{ lineHeight: 0 }}>
|
||||
{item.icon &&
|
||||
createElement(item.icon, {
|
||||
width: item.iconSize || 10,
|
||||
height: item.iconSize || 10,
|
||||
style: { marginRight: 7, width: 10 },
|
||||
})}
|
||||
</Text>
|
||||
<Text>{item.text}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{item.key && <Keybinding keyName={item.key} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{footer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const MenuLine: unique symbol = Symbol('menu-line');
|
||||
Menu.line = MenuLine;
|
||||
Menu.label = Symbol('menu-label');
|
||||
|
||||
type AlignedTextProps = ComponentProps<typeof View> & {
|
||||
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 (
|
||||
<View
|
||||
style={[{ flexDirection: 'row', alignItems: 'center' }, style]}
|
||||
{...nativeProps}
|
||||
>
|
||||
<Block
|
||||
style={[
|
||||
{ marginRight: 10 },
|
||||
truncate === 'left' && truncateStyle,
|
||||
leftStyle,
|
||||
]}
|
||||
>
|
||||
{left}
|
||||
</Block>
|
||||
<Block
|
||||
style={[
|
||||
{ flex: 1, textAlign: 'right' },
|
||||
truncate === 'right' && truncateStyle,
|
||||
rightStyle,
|
||||
]}
|
||||
>
|
||||
{right}
|
||||
</Block>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type PProps = HTMLPropsWithStyle<HTMLDivElement> & {
|
||||
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 (
|
||||
<View style={[{ color: 'red', fontSize: 13 }, style]}>{children}</View>
|
||||
);
|
||||
}
|
||||
|
||||
type InitialFocusProps = {
|
||||
children?: ReactElement | ((node: Ref<HTMLInputElement>) => 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';
|
||||
|
||||
@@ -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<typeof View> & {
|
||||
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 (
|
||||
<View
|
||||
style={[{ flexDirection: 'row', alignItems: 'center' }, style]}
|
||||
{...nativeProps}
|
||||
>
|
||||
<Block
|
||||
style={[
|
||||
{ marginRight: 10 },
|
||||
truncate === 'left' && truncateStyle,
|
||||
leftStyle,
|
||||
]}
|
||||
>
|
||||
{left}
|
||||
</Block>
|
||||
<Block
|
||||
style={[
|
||||
{ flex: 1, textAlign: 'right' },
|
||||
truncate === 'right' && truncateStyle,
|
||||
rightStyle,
|
||||
]}
|
||||
>
|
||||
{right}
|
||||
</Block>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ListboxInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ lineHeight: '1em' }}
|
||||
>
|
||||
<ListboxButton
|
||||
{...css([{ borderWidth: 0, padding: 5, borderRadius: 4 }, style])}
|
||||
arrow={<ExpandArrow style={{ width: 7, height: 7, paddingTop: 3 }} />}
|
||||
/>
|
||||
<ListboxPopover style={{ zIndex: 10000, outline: 0, borderRadius: 4 }}>
|
||||
<ListboxList>
|
||||
{options.map(([value, label]) => (
|
||||
<ListboxOption
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={disabledKeys.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
);
|
||||
}
|
||||
16
packages/desktop-client/src/components/common/FormError.tsx
Normal file
16
packages/desktop-client/src/components/common/FormError.tsx
Normal file
@@ -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 (
|
||||
<View style={[{ color: 'red', fontSize: 13 }, style]}>{children}</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
cloneElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps = {
|
||||
children?: ReactElement | ((node: Ref<HTMLInputElement>) => 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 });
|
||||
}
|
||||
@@ -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<typeof Input> & {
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
defaultInputStyle,
|
||||
{
|
||||
padding: 0,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
focused && {
|
||||
border: '1px solid ' + colors.b5,
|
||||
boxShadow: '0 1px 1px ' + colors.b7,
|
||||
},
|
||||
style,
|
||||
getStyle && getStyle(focused),
|
||||
]}
|
||||
>
|
||||
{leftContent}
|
||||
<Input
|
||||
{...props}
|
||||
style={[
|
||||
inputStyle,
|
||||
{
|
||||
flex: 1,
|
||||
'&, &:focus, &:hover': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
props.onFocus && props.onFocus(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
props.onBlur && props.onBlur(e);
|
||||
}}
|
||||
/>
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
181
packages/desktop-client/src/components/common/Menu.tsx
Normal file
181
packages/desktop-client/src/components/common/Menu.tsx
Normal file
@@ -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 <Text style={{ fontSize: 10, color: colors.n6 }}>{keyName}</Text>;
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
type?: string | symbol;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
icon?;
|
||||
iconSize?: number;
|
||||
text: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type MenuProps = {
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
items: Array<MenuItem | typeof Menu.line>;
|
||||
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 (
|
||||
<View
|
||||
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden' }}
|
||||
tabIndex={1}
|
||||
innerRef={elRef}
|
||||
>
|
||||
{header}
|
||||
{items.map((item, idx) => {
|
||||
if (item === Menu.line) {
|
||||
return (
|
||||
<View key={idx} style={{ margin: '3px 0px' }}>
|
||||
<View style={{ borderTop: '1px solid ' + colors.n10 }} />
|
||||
</View>
|
||||
);
|
||||
} else if (item.type === Menu.label) {
|
||||
return (
|
||||
<Text
|
||||
key={item.name}
|
||||
style={{
|
||||
color: colors.n6,
|
||||
fontSize: 11,
|
||||
lineHeight: '1em',
|
||||
textTransform: 'uppercase',
|
||||
margin: '3px 9px',
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let lastItem = items[idx - 1];
|
||||
|
||||
return (
|
||||
<View
|
||||
role="button"
|
||||
key={item.name}
|
||||
style={[
|
||||
{
|
||||
cursor: 'default',
|
||||
padding: '9px 10px',
|
||||
marginTop:
|
||||
idx === 0 ||
|
||||
lastItem === Menu.line ||
|
||||
lastItem.type === Menu.label
|
||||
? 0
|
||||
: -3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
item.disabled && { color: colors.n7 },
|
||||
!item.disabled &&
|
||||
hoveredIndex === idx && { backgroundColor: colors.n10 },
|
||||
]}
|
||||
onMouseEnter={() => setHoveredIndex(idx)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={e =>
|
||||
!item.disabled && onMenuSelect && onMenuSelect(item.name)
|
||||
}
|
||||
>
|
||||
{/* Force it to line up evenly */}
|
||||
<Text style={{ lineHeight: 0 }}>
|
||||
{item.icon &&
|
||||
createElement(item.icon, {
|
||||
width: item.iconSize || 10,
|
||||
height: item.iconSize || 10,
|
||||
style: { marginRight: 7, width: 10 },
|
||||
})}
|
||||
</Text>
|
||||
<Text>{item.text}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{item.key && <Keybinding keyName={item.key} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{footer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const MenuLine: unique symbol = Symbol('menu-line');
|
||||
Menu.line = MenuLine;
|
||||
Menu.label = Symbol('menu-label');
|
||||
43
packages/desktop-client/src/components/common/Search.tsx
Normal file
43
packages/desktop-client/src/components/common/Search.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type ChangeEvent, type Ref } from 'react';
|
||||
|
||||
import { colors } from '../../style';
|
||||
|
||||
import Input from './Input';
|
||||
|
||||
type SearchProps = {
|
||||
inputRef: Ref<HTMLInputElement>;
|
||||
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 (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
||||
style={{
|
||||
width,
|
||||
borderColor: isInModal ? null : 'transparent',
|
||||
backgroundColor: isInModal ? null : colors.n11,
|
||||
':focus': isInModal
|
||||
? null
|
||||
: {
|
||||
backgroundColor: 'white',
|
||||
'::placeholder': { color: colors.n8 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
upcoming-release-notes/1257.md
Normal file
6
upcoming-release-notes/1257.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Moving more components from `common.tsx` to separate files inside the `common` folder
|
||||
Reference in New Issue
Block a user