Skip to content

Commit

Permalink
chore(web): implement selector component (#997)
Browse files Browse the repository at this point in the history
Co-authored-by: m-abe-dev <[email protected]>
Co-authored-by: airslice <[email protected]>
  • Loading branch information
3 people authored Jun 17, 2024
1 parent cf388ab commit 7c76a84
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 2 deletions.
8 changes: 6 additions & 2 deletions web/src/beta/lib/reearth-ui/components/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, MouseEvent } from "react";

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

Expand All @@ -14,7 +14,7 @@ export type ButtonProps = {
title?: string;
extendWidth?: boolean;
minWidth?: number;
onClick?: () => void;
onClick?: (e: MouseEvent<HTMLElement>) => void;
};

export const Button: FC<ButtonProps> = ({
Expand Down Expand Up @@ -104,4 +104,8 @@ const StyledButton = styled("button")<{
backgroundColor: appearance !== "simple" ? `${theme.bg[1]}` : "transparent",
boxShadow: "none",
},
["& svg"]: {
width: iconButton && size === "small" ? "12px" : "inherit",
height: iconButton && size === "small" ? "12px" : "inherit",
},
}));
67 changes: 67 additions & 0 deletions web/src/beta/lib/reearth-ui/components/Selector/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Meta, StoryObj } from "@storybook/react";

import { Selector, SelectorProps } from ".";

const meta: Meta<SelectorProps> = {
component: Selector,
};

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

const options = [
{
value: "item_1",
label: "item_1",
},
{
value: "item_2",
label: "item_2",
},
{
value: "item_3",
label: "item_3",
},
{
value: "item_4",
label: "item_4",
},
{
value: "item_5",
label: "item_5",
},
{
value: "item_6",
label: "item_6",
},
];

export const Default: Story = {
render: () => {
return <Selector options={options} />;
},
};

export const MultipleSelector: Story = {
render: () => {
return <Selector options={options} multiple={true} />;
},
};

export const NoOptions: Story = {
render: () => {
return <Selector options={[]} />;
},
};

export const Disabled: Story = {
render: () => {
return (
<div style={{ width: "100%", gap: "8px", display: "flex", flexDirection: "column" }}>
<Selector options={options} disabled />
<Selector options={options} value="item_1" disabled />
<Selector options={options} value={["item_1", "item_2"]} multiple disabled />
</div>
);
},
};
247 changes: 247 additions & 0 deletions web/src/beta/lib/reearth-ui/components/Selector/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { FC, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";

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

import { Button } from "../Button";
import { Icon } from "../Icon";
import { Popup } from "../Popup";
import { Typography } from "../Typography";

export type SelectorProps = {
multiple?: boolean;
value?: string | string[];
options: { value: string; label?: string }[];
disabled?: boolean;
placeholder?: string;
onChange?: (value: string | string[]) => void;
};

export const Selector: FC<SelectorProps> = ({
multiple,
value,
options,
placeholder = "Please select",
disabled,
onChange,
}) => {
const theme = useTheme();
const selectorRef = useRef<HTMLDivElement>(null);
const [selectedValue, setSelectedValue] = useState<string | string[] | undefined>(
value ?? (multiple ? [] : undefined),
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [selectorWidth, setSelectorWidth] = useState<number>();

const optionValues = useMemo(() => options, [options]);

useEffect(() => {
setSelectedValue(value ?? (multiple ? [] : undefined));
}, [value, multiple]);

useEffect(() => {
const selectorElement = selectorRef.current;
if (!selectorElement) return;
const resizeObserver = new ResizeObserver(entries => {
if (!entries || entries.length === 0) return;
const { width } = entries[0].contentRect;
setSelectorWidth(width);
});
resizeObserver.observe(selectorElement);
return () => {
resizeObserver.unobserve(selectorElement);
resizeObserver.disconnect();
};
}, []);

const isSelected = useCallback(
(value: string) => {
if (multiple) {
return Array.isArray(selectedValue) && selectedValue.includes(value);
}
return selectedValue === value;
},
[multiple, selectedValue],
);

const handleChange = (value: string) => {
if (multiple && Array.isArray(selectedValue)) {
if (selectedValue.includes(value)) {
const newSelectedArr = selectedValue.filter(val => val !== value);
setSelectedValue(newSelectedArr);
onChange?.(newSelectedArr);
} else {
const newSelectedArr = [...selectedValue, value];
setSelectedValue(newSelectedArr);
onChange?.(newSelectedArr);
}
} else {
if (value === selectedValue) setSelectedValue(undefined);
else setSelectedValue(value);
setIsOpen(!isOpen);
onChange?.(value);
}
};

const handleUnselect = useCallback(
(e: MouseEvent<HTMLElement>, value: string) => {
e.stopPropagation();
if (Array.isArray(selectedValue) && selectedValue.length) {
const newSelectedArr = selectedValue.filter(val => val !== value);
setSelectedValue(newSelectedArr);
onChange?.(newSelectedArr);
}
},
[selectedValue, onChange],
);

const renderTrigger = () => {
return (
<SelectInput isMultiple={multiple} isOpen={isOpen} disabled={disabled} width={selectorWidth}>
{!selectedValue?.length ? (
<Typography size="body" color={theme.content.weaker}>
{placeholder}
</Typography>
) : multiple ? (
<SelectedItems>
{(selectedValue as string[]).map(val => (
<SelectedItem key={val}>
<Typography
size="body"
color={disabled ? theme.content.weaker : theme.content.main}>
{val}
</Typography>
{!disabled && (
<Button
iconButton
icon="close"
appearance="simple"
size="small"
onClick={e => handleUnselect(e, val)}
/>
)}
</SelectedItem>
))}
</SelectedItems>
) : (
<Typography size="body" color={disabled ? theme.content.weaker : ""}>
{selectedValue}
</Typography>
)}
<Icon
icon={isOpen ? "caretUp" : "caretDown"}
color={disabled ? theme.content.weaker : theme.content.main}
/>
</SelectInput>
);
};

return (
<SelectorWrapper ref={selectorRef}>
<Popup
trigger={renderTrigger()}
open={isOpen}
onOpenChange={setIsOpen}
disabled={disabled}
placement="bottom-start">
<DropDownWrapper width={selectorWidth}>
{optionValues.length === 0 ? (
<DropDownItem>
<Typography size="body" color={theme.content.weaker}>
No Options yet
</Typography>
</DropDownItem>
) : (
optionValues.map((item: { value: string; label?: string }) => (
<DropDownItem
key={item.value}
isSelected={isSelected(item.value)}
onClick={() => handleChange(item.value)}>
<Typography size="body" color={theme.content.main}>
{item.label ?? item.value}
</Typography>
{isSelected(item.value) && multiple && (
<Icon icon="check" size="small" color={theme.content.main} />
)}
</DropDownItem>
))
)}
</DropDownWrapper>
</Popup>
</SelectorWrapper>
);
};

const SelectorWrapper = styled("div")(() => ({
width: "100%",
}));

const SelectInput = styled("div")<{
isMultiple?: boolean;
isOpen?: boolean;
disabled?: boolean;
width?: number;
}>(({ isMultiple, isOpen, disabled, width, theme }) => ({
boxSizing: "border-box",
backgroundColor: `${theme.bg[1]}`,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: `${theme.spacing.small}px`,
borderRadius: `${theme.radius.smallest}px`,
border: `1px solid ${!disabled && isOpen ? theme.select.strong : theme.outline.weak}`,
boxShadow: `${theme.shadow.input}`,
padding: `${theme.spacing.smallest}px ${
isMultiple ? theme.spacing.smallest : theme.spacing.small
}px`,
cursor: disabled ? "not-allowed" : "pointer",
width: width ? `${width}px` : "",
minHeight: "32px",
}));

const SelectedItems = styled("div")(({ theme }) => ({
flex: 1,
display: "flex",
alignItems: "center",
gap: `${theme.spacing.smallest}px`,
flexWrap: "wrap",
}));

const SelectedItem = styled("div")(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: `${theme.spacing.smallest}px`,
padding: `${theme.spacing.micro}px ${theme.spacing.smallest}px`,
backgroundColor: `${theme.bg[2]}`,
borderRadius: `${theme.radius.smallest}px`,
}));

const DropDownWrapper = styled("div")<{
width?: number;
}>(({ width, theme }) => ({
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
gap: `${theme.spacing.micro}px`,
padding: `${theme.spacing.micro}px`,
backgroundColor: `${theme.bg[1]}`,
boxShadow: `${theme.shadow.popup}`,
borderRadius: `${theme.radius.small}px`,
width: width ? `${width}px` : "",
border: `1px solid ${theme.outline.weaker}`,
}));

const DropDownItem = styled("div")<{
isSelected?: boolean;
}>(({ isSelected, theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${theme.spacing.small}px`,
backgroundColor: !isSelected ? `${theme.bg[1]}` : `${theme.select.weak} !important`,
padding: `${theme.spacing.micro}px ${theme.spacing.smallest}px`,
borderRadius: `${theme.radius.smallest}px`,
cursor: "pointer",
["&:hover"]: {
backgroundColor: `${theme.bg[2]}`,
},
}));
1 change: 1 addition & 0 deletions web/src/beta/lib/reearth-ui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export * from "./ColorInput";
export * from "./DatePicker";
export * from "./TimePicker";
export * from "./Breadcrumb";
export * from "./Selector";

0 comments on commit 7c76a84

Please sign in to comment.