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(web): add spacing input field and number input #636

Merged
merged 17 commits into from
Sep 1, 2023
11 changes: 11 additions & 0 deletions web/src/beta/components/fields/PropertyFields/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import TextInput from "@reearth/beta/components/fields/TextInput";
import { type Item } from "@reearth/services/api/propertyApi/utils";

import ColorField from "../ColorField";
import SpacingInput, { SpacingValues } from "../SpacingInput";
import ToggleField from "../ToggleField";

import useHooks from "./hooks";
Expand Down Expand Up @@ -42,6 +43,16 @@ const PropertyFields: React.FC<Props> = ({ propertyId, item }) => {
onChange={handlePropertyValueUpdate(item.schemaGroup, propertyId, sf.id, sf.type)}
/>
)
) : sf.type === "spacing" ? (
<SpacingInput
key={sf.id}
name={sf.name}
value={(value as SpacingValues) ?? ""}
description={sf.description}
min={sf.min}
max={sf.max}
onChange={handlePropertyValueUpdate(item.schemaGroup, propertyId, sf.id, sf.type)}
/>
) : sf.type == "bool" ? (
<ToggleField
key={sf.id}
Expand Down
27 changes: 27 additions & 0 deletions web/src/beta/components/fields/SpacingInput/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Meta, Story } from "@storybook/react";

import SpacingInput from "./index";

export default {
component: SpacingInput,
} as Meta;

const Template: Story = args => <SpacingInput {...args} />;

export const Default = Template.bind({});
Default.args = {
name: "Padding",
description: "Adjust the padding values",
};

export const WithValues = Template.bind({});
WithValues.args = {
name: "Padding",
description: "Adjust the padding values",
value: {
top: "10",
left: "20",
right: "30",
bottom: "40",
},
};
87 changes: 87 additions & 0 deletions web/src/beta/components/fields/SpacingInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useMemo, useState } from "react";

import { styled } from "@reearth/services/theme";

import Property from "..";
import NumberInput from "../common/NumberInput";

export type SpacingValues = {
top: number;
left: number;
right: number;
bottom: number;
};

type Props = {
name?: string;
description?: string;
value?: SpacingValues;
min?: number;
max?: number;
onChange?: (values: SpacingValues) => void;
};
type Position = keyof SpacingValues;

const SpacingInput: React.FC<Props> = ({ name, description, value, min, max, onChange }) => {
const [spacingValues, setSpacingValues] = useState<SpacingValues>(
value || { top: 0, left: 0, right: 0, bottom: 0 },
);

const memoizedSpacingValues = useMemo(() => {
return ["top", "left", "right", "bottom"].map(position => {
return getSpacingPosition(spacingValues, position as Position);
});
}, [spacingValues]);

const handleInputChange = (position: Position, newValue?: number) => {
const updatedValues = { ...spacingValues, [position]: newValue };
setSpacingValues(updatedValues);
onChange?.(updatedValues);
};

return (
<Property name={name} description={description}>
<StyledRectangle>
{["top", "left", "right", "bottom"].map((position, index) => (
<SpacingField
value={memoizedSpacingValues[index]}
suffix="px"
key={position}
position={position}
min={min}
max={max}
onChange={newValue => handleInputChange(position as Position, newValue)}
/>
))}
</StyledRectangle>
</Property>
);
};

export default SpacingInput;

const StyledRectangle = styled.div`
display: flex;
width: 100%;
height: 94px;
border: 1px dashed ${({ theme }) => theme.outline.weak};
box-sizing: border-box;
position: relative;
`;

const SpacingField = styled(NumberInput)<{ position: string }>`
width: 40px;
position: absolute;
${({ position }) =>
position === "top"
? "top: 0; left: 50%; transform: translateX(-50%);"
: position === "left"
? "left: 0; top: 50%; transform: translateY(-50%);"
: position === "right"
? "right: 0; top: 50%; transform: translateY(-50%);"
: "bottom: 0; left: 50%; transform: translateX(-50%);"};
`;

function getSpacingPosition(spacingValue: SpacingValues, position: Position): number {
return spacingValue[position];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Meta, StoryObj } from "@storybook/react";

import NumberInput, { Props } from "./index";

const meta: Meta<Props> = {
component: NumberInput,
};

export default meta;
type Story = StoryObj<typeof NumberInput>;

export const Default: Story = {
args: {
value: 42,
inputDescription: "Value",
suffix: "px",
},
};

export const Disabled: Story = {
args: {
value: 15,
inputDescription: "Disabled",
suffix: "px",
disabled: true,
},
};

export const Range: Story = {
args: {
value: 50,
inputDescription: "Range",
suffix: "px",
min: 4,
max: 100,
},
};

export const NoValue: Story = {
args: {
inputDescription: "No Value",
suffix: "px",
},
};

export const WithMinValue: Story = {
args: {
value: 5,
inputDescription: "With Min Value",
suffix: "px",
min: 0,
},
};

export const WithMaxValue: Story = {
args: {
value: 95,
inputDescription: "With Max Value",
suffix: "px",
max: 100,
},
};

export const Editable: Story = {
args: {
value: 25,
inputDescription: "Editable",
suffix: "px",
},
};
176 changes: 176 additions & 0 deletions web/src/beta/components/fields/common/NumberInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React, { useState, useCallback, useRef, useEffect } from "react";

import Text from "@reearth/beta/components/Text";
import { useT } from "@reearth/services/i18n";
import { useNotification } from "@reearth/services/state";
import { styled, useTheme } from "@reearth/services/theme";
import { metricsSizes } from "@reearth/services/theme/reearthTheme/common/metrics";

export type Props = {
className?: string;
suffix?: string;
min?: number;
max?: number;
disabled?: boolean;
inputDescription?: string;
value?: number;
onChange?: (value?: number | undefined) => void;
};

const NumberInput: React.FC<Props> = ({
className,
value,
inputDescription,
suffix,
onChange,
min,
max,
disabled = false,
}) => {
const [innerValue, setInnerValue] = useState<number | undefined>(value);
const [, setNotification] = useNotification();
const inputRef = useRef<HTMLInputElement | null>(null);

const isEditing = useRef(false);
const t = useT();

const theme = useTheme();

useEffect(() => {
setInnerValue(value);
}, [value]);

useEffect(() => {
// Calculate and set the minimum width for the input field
if (inputRef.current) {
const minWidth = Math.max(metricsSizes.xs, inputRef.current.value.length * 10);
inputRef.current.style.width = `${minWidth}px`;
}
}, []);

const handleValueChange = useCallback(
(newValue: number | undefined) => {
if (!onChange || !isEditing.current) {
return;
}

if (newValue === undefined) {
setInnerValue(undefined);
onChange(undefined);
} else if (typeof max === "number" && isFinite(max) && newValue > max) {
setNotification({ type: "warning", text: t("You have passed the maximum value.") });
setInnerValue(undefined);
onChange(undefined);
} else if (typeof min === "number" && isFinite(min) && newValue < min) {
setNotification({ type: "warning", text: t("You have passed the minimum value.") });
setInnerValue(undefined);
onChange(undefined);
} else if (!isNaN(newValue)) {
setInnerValue(newValue);
onChange(newValue);
}
},
[onChange, max, min, setNotification, t],
);

const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.currentTarget.value;
setInnerValue(parseFloat(newValue));
const minWidth = Math.max(metricsSizes.xs, newValue.length * 10);
e.currentTarget.style.width = `${minWidth}px`;
}, []);

const handleKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleValueChange(parseFloat(e.currentTarget.value));
}
},
[handleValueChange],
);

const handleFocus = useCallback(() => {
isEditing.current = true;
}, []);

const handleBlur = useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.currentTarget.value);
handleValueChange(newValue);
isEditing.current = false;
},
[handleValueChange],
);

return (
<Wrapper>
<InputWrapper inactive={!!disabled} className={className}>
<StyledInput
type="number"
value={innerValue}
disabled={disabled}
onChange={handleChange}
onKeyPress={handleKeyPress}
onFocus={handleFocus}
onBlur={handleBlur}
min={min}
ref={inputRef}
max={max}
step={"any"}
/>
{suffix && (
<Text size="footnote" color={theme.content.weak} otherProperties={{ userSelect: "none" }}>
{suffix}
</Text>
)}
</InputWrapper>
{inputDescription && (
<Text size="footnote" color={theme.content.weak}>
{inputDescription}
</Text>
)}
</Wrapper>
);
};

const Wrapper = styled.div`
width: 100%;
text-align: center;
`;

const InputWrapper = styled.div<{ inactive: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
background: ${({ theme }) => theme.bg[1]};
border: 1px solid ${({ theme }) => theme.outline.weak};
border-radius: 4px;
padding: ${metricsSizes.xs}px ${metricsSizes.s}px;
gap: 12px;
width: auto;
min-width: min-content;
max-width: 64px;
color: ${({ inactive, theme }) => (inactive ? theme.content.weak : theme.content.main)};
&:focus-within {
border-color: ${({ theme }) => theme.select.main};
}
box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25) inset;
`;

const StyledInput = styled.input`
display: block;
border: none;
background: ${({ theme }) => theme.bg[1]};
outline: none;
color: inherit;
width: 100%;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
&[type="number"] {
-moz-appearance: textfield;
}
`;

export default NumberInput;
2 changes: 1 addition & 1 deletion web/src/beta/components/fields/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default Property;
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
`;

const Description = styled(Text)`
Expand Down
Loading
Loading