Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve molecule label input #3279

Merged
merged 2 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 168 additions & 79 deletions src/component/elements/EditableColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { CSSProperties, ChangeEvent, KeyboardEvent } from 'react';
import { Button } from '@blueprintjs/core';
import styled from '@emotion/styled';
import type { CSSProperties, KeyboardEvent } from 'react';
import {
forwardRef,
useCallback,
Expand All @@ -7,8 +9,41 @@ import {
useState,
} from 'react';

import type { InputProps } from './Input.js';
import Input from './Input.js';
import { Input2 } from './Input2.js';
import { NumberInput2 } from './NumberInput2.js';

interface OverflowProps {
textOverflowEllipses: boolean;
}

const Text = styled.span<OverflowProps>`
display: table-cell;
vertical-align: middle;
width: 100%;
height: 100%;
${({ textOverflowEllipses }) =>
textOverflowEllipses &&
`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`}
`;
const Container = styled.span<OverflowProps>`
display: table;
width: 100%;
min-height: 22px;
height: 100%;
${({ textOverflowEllipses }) =>
textOverflowEllipses &&
`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: inline-flex;
align-items:end;
`}
`;

function extractNumber(val: string | number, type: string) {
if (type === 'number' && typeof val !== 'number') {
Expand All @@ -18,37 +53,45 @@ function extractNumber(val: string | number, type: string) {
return val;
}

interface EditableColumnProps
extends Omit<InputProps, 'style' | 'value' | 'type'> {
function handleMousedown(event) {
event.stopPropagation();
}

const style: CSSProperties = { minWidth: 60 };
const className = 'editable-column';

interface BaseEditableColumnProps {
type: 'number' | 'text';
value: number | string;
validate?: (value?: string | number) => boolean;
}

interface EditableColumnProps extends BaseEditableColumnProps {
onSave?: (element: KeyboardEvent<HTMLInputElement>) => void;
onEditStart?: (element: boolean) => void;
type?: 'number' | 'text';
editStatus?: boolean;
value: string | number;
style?: CSSProperties;
validate?: (value?: string | number) => boolean;
textOverFlowEllipses?: boolean;
textOverflowEllipses?: boolean;
clickType?: 'single' | 'double';
}

const EditableColumn = forwardRef(function EditableColumn(
export const EditableColumn = forwardRef(function EditableColumn(
props: EditableColumnProps,
ref: any,
) {
const {
onSave = () => null,
onSave,
value,
type = 'text',
type,
style,
onEditStart = () => null,
onEditStart,
editStatus = false,
validate = () => true,
textOverFlowEllipses = false,
...InputProps
validate,
textOverflowEllipses = false,
clickType = 'single',
} = props;

const [enabled, enableEdit] = useState<boolean | undefined>();
const [isValid, setValid] = useState<boolean>(true);
const [val, setVal] = useState(extractNumber(value, type));
useEffect(() => {
enableEdit(editStatus);
}, [editStatus]);
Expand All @@ -71,85 +114,131 @@ const EditableColumn = forwardRef(function EditableColumn(

function startEditHandler() {
globalThis.addEventListener('mousedown', mouseClickCallback);
onEditStart(true);
onEditStart?.(true);
enableEdit(true);
}

function saveHandler(event: KeyboardEvent<HTMLInputElement>) {
const valid = validate(val);
setValid(valid);
// when press Enter or Tab
if (valid && ['Enter', 'Tab'].includes(event.key)) {
onSave(event);
enableEdit(false);
globalThis.removeEventListener('mousedown', mouseClickCallback);
}
// close edit mode if press Enter, Tab or Escape
if (['Escape'].includes(event.key)) {
enableEdit(false);
globalThis.removeEventListener('mousedown', mouseClickCallback);
}
function onConfirm(event: KeyboardEvent<HTMLInputElement>) {
onSave?.(event);
enableEdit(false);
globalThis.removeEventListener('mousedown', mouseClickCallback);
}

function onCancel() {
enableEdit(false);
globalThis.removeEventListener('mousedown', mouseClickCallback);
}

function handleChange(e: ChangeEvent<HTMLInputElement>) {
setVal(e.target.value);
let clickHandler = {};

if (clickType === 'single' && !enabled) {
clickHandler = { onClick: startEditHandler };
}

if (clickType === 'double' && !enabled) {
clickHandler = { onDoubleClick: startEditHandler };
}

return (
<div
style={{
display: 'table',
width: '100%',
height: '100%',
...(textOverFlowEllipses
? { whiteSpace: 'nowrap', overflow: 'hidden', display: 'inline-flex' }
: {}),
...style,
}}
<Container
style={style}
textOverflowEllipses={textOverflowEllipses}
className="editable-column-input"
onDoubleClick={startEditHandler}
{...clickHandler}
>
{!enabled && (
<span
style={{
display: 'table-cell',
verticalAlign: 'middle',
width: '100%',
...(textOverFlowEllipses
? { textOverflow: 'ellipsis', overflow: 'hidden' }
: {}),
}}
>
{value} &nbsp;
</span>
<Text textOverflowEllipses={textOverflowEllipses}>
{value ?? '&nbsp;'}
</Text>
)}
{enabled && (
<div style={{ display: 'table-cell', verticalAlign: 'middle' }}>
<Input
style={{
inputWrapper: {
...(!isValid && { borderColor: 'red' }),
width: '100%',
},
input: {
padding: '5px',
width: '100%',
minWidth: '60px',
},
}}
autoSelect
className="editable-column"
value={val}
<EditFiled
value={value}
type={type}
onChange={handleChange}
onKeyDown={saveHandler}
onMouseDown={(e) => e.stopPropagation()}
{...InputProps}
onConfirm={onConfirm}
onCancel={onCancel}
validate={validate}
/>
</div>
)}
</div>
</Container>
);
});

export default EditableColumn;
interface EditFiledProps extends BaseEditableColumnProps {
onConfirm: (event: KeyboardEvent<HTMLInputElement>) => void;
onCancel: (event?: KeyboardEvent<HTMLInputElement>) => void;
}

function EditFiled(props: EditFiledProps) {
const { value: externalValue, type, onConfirm, onCancel, validate } = props;

const [isValid, setValid] = useState<boolean>(true);
const [value, setVal] = useState(extractNumber(externalValue, type));

function handleKeydown(event: KeyboardEvent<HTMLInputElement>) {
const valid = typeof validate === 'function' ? validate(value) : true;
setValid(valid);
// when press Enter or Tab
if (valid && ['Enter', 'Tab'].includes(event.key)) {
onConfirm(event);
}
// close edit mode if press Enter, Tab or Escape
if (['Escape'].includes(event.key)) {
onCancel(event);
}
}

function handleChange(value: string | number) {
setVal(value);
}

const intent = !isValid ? 'danger' : 'none';

const rightElement = (
<Button
minimal
icon="cross"
onMouseDown={handleMousedown}
onClick={() => onCancel()}
/>
);

if (type === 'number') {
return (
<NumberInput2
intent={intent}
style={style}
autoSelect
className={className}
value={value}
onValueChange={(valueAsNumber, valueString) =>
handleChange(valueString ?? Number(valueString))
}
onKeyDown={handleKeydown}
onMouseDown={handleMousedown}
small
fill
buttonPosition="none"
stepSize={1}
rightElement={rightElement}
/>
);
}

return (
<Input2
intent={intent}
style={style}
autoSelect
className={className}
value={value as string}
onChange={(value) => handleChange(value)}
onKeyDown={handleKeydown}
onMouseDown={handleMousedown}
small
rightElement={rightElement}
/>
);
}
27 changes: 23 additions & 4 deletions src/component/elements/NumberInput2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,40 @@ import {
useState,
isValidElement,
forwardRef,
useRef,
} from 'react';
import type { ForwardedRef } from 'react';

import useCombinedRefs from '../hooks/useCombinedRefs.js';

interface ValueProps
extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'style'>,
Pick<NumericInputProps, 'onValueChange' | 'value'> {
checkValue?: (element?: number) => boolean;
debounceTime?: number;
autoSelect?: boolean;
}
interface UseInputProps extends Omit<ValueProps, 'name'> {
ref: ForwardedRef<HTMLInputElement>;
}
type UseInputProps = Omit<ValueProps, 'name'>;
export interface NumberInput2Props
extends Omit<HTMLInputProps & NumericInputProps, 'value' | 'onValueChange'>,
ValueProps {
format?: () => (element: string) => number | string;
autoSelect?: boolean;
}

function useNumberInput(props: UseInputProps) {
const {
value: externalValue,
debounceTime,
onValueChange,
ref,
autoSelect,
checkValue,
} = props;
const [internalValue, setValue] = useState<number | string>();
const localRef = useRef<HTMLInputElement>();
const innerRef = useCombinedRefs([ref, localRef]);
const value = debounceTime ? internalValue : externalValue;
const [isDebounced, setDebouncedStatus] = useState<boolean>(false);

Expand All @@ -57,6 +67,12 @@ function useNumberInput(props: UseInputProps) {
}
}, [debounceTime, externalValue]);

useEffect(() => {
if (autoSelect) {
innerRef?.current?.select();
}
}, [autoSelect, innerRef]);

function handleValueChange(
valueAsNumber: number,
valueAsString: string,
Expand All @@ -79,6 +95,7 @@ function useNumberInput(props: UseInputProps) {
debounceOnValueChange,
isDebounced,
value,
innerRef,
};
}

Expand Down Expand Up @@ -110,9 +127,11 @@ function InnerNumberInput(props: NumberInput2Props, ref) {
...otherInputProps
} = props;

const { handleValueChange, isDebounced, value } = useNumberInput({
const { handleValueChange, isDebounced, value, innerRef } = useNumberInput({
value: externalValue,
debounceTime,
autoSelect,
ref,
onValueChange,
checkValue,
});
Expand All @@ -124,7 +143,7 @@ function InnerNumberInput(props: NumberInput2Props, ref) {
<NumericInput
leftIcon={icon}
inputClassName={classes}
inputRef={ref}
inputRef={innerRef}
onValueChange={handleValueChange}
value={value}
selectAllOnFocus={autoSelect}
Expand Down
Loading
Loading