Skip to content

Commit

Permalink
Merge pull request #100 from sopt-makers/feat/select
Browse files Browse the repository at this point in the history
feat(Select) : Input - Select, UserMention 컴포넌트 개발
  • Loading branch information
sohee-K authored Jun 22, 2024
2 parents 281f7ac + b9bf5d5 commit 544735a
Show file tree
Hide file tree
Showing 7 changed files with 528 additions and 9 deletions.
130 changes: 130 additions & 0 deletions apps/docs/src/stories/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Select } from "@sopt-makers/ui";
import { IconSend } from "@sopt-makers/icons";
import { fn } from '@storybook/test';

const selectOptions = [{
label: 'All',
value: '',
description: 'select all',
icon: <IconSend />,
}, {
label: 'Option 1',
value: 'option1',
description: 'Description 1',
icon: <IconSend />,
}, {
label: 'Option 2',
value: 'option2',
description: 'Description 2',
icon: <IconSend />,
}, {
label: 'Option 3',
value: 'option3',
description: 'Description 3',
icon: <IconSend />,
}, {
label: 'Option 4',
value: 'option4',
description: 'Description 4',
icon: <IconSend />,
}, {
label: 'Option 5',
value: 'option5',
description: 'Description 5',
icon: <IconSend />,
}];

const userOptions = [{
label: 'Person 1',
value: 1,
description: 'person 1 desc',
}, {
label: 'Person 2',
value: 2,
description: 'person 2 desc',
}, {
label: 'Person 3',
value: 3,
description: 'person 3 desc',
}, {
label: 'Person 4',
value: 4,
description: 'person 4 desc',
}, {
label: 'Person 5',
value: 5,
description: 'person 5 desc',
}, {
label: 'Person 6',
value: 6,
description: 'person 6 desc',
}];

const meta = {
title: "Components/Input/Select",
component: Select,
tags: ["autodocs"],
args: {
placeholder: 'Placeholder',
visibleOptions: 3,
onChange: fn(),
},
argTypes: {
defaultValue: { control: 'select', options: ['', 'option1', 'option2', 'option3', 'option4', 'option5'] },
placeholder: { control: 'text' },
visibleOptions: { control: 'number' },
}
} as Meta<typeof Select>;

export default meta;

export const Text: StoryObj = {
args: {
type: 'text',
options: selectOptions,
},
argTypes: {
type: { control: 'radio', options: ['text', 'textDesc', 'textIcon'] },
}
};

export const TextDesc: StoryObj = {
args: {
type: 'textDesc',
options: selectOptions,
},
argTypes: {
type: { control: 'radio', options: ['text', 'textDesc', 'textIcon'] },
}
};

export const TextIcon: StoryObj = {
args: {
type: 'textIcon',
options: selectOptions,
},
argTypes: {
type: { control: 'radio', options: ['text', 'textDesc', 'textIcon'] },
}
};

export const UserList: StoryObj = {
args: {
type: 'userList',
options: userOptions,
},
argTypes: {
type: { control: 'radio', options: ['userList', 'userListDesc'] },
}
};

export const UserListDesc: StoryObj = {
args: {
type: 'userListDesc',
options: userOptions,
},
argTypes: {
type: { control: 'radio', options: ['userList', 'userListDesc'] },
}
};
88 changes: 88 additions & 0 deletions apps/docs/src/stories/UserMention.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react";
import { UserMention } from "@sopt-makers/ui";
import { fn } from '@storybook/test';
import { useState } from "react";

interface UserOption<T> {
label: string;
value: T;
description?: string;
profileUrl?: string;
}

interface UserMentionProps<T> {
className?: string;
userOptions: UserOption<T>[];
type: 'default' | 'description';
visibleOptions?: number;
onChange: (value: T) => void;
}

const userOptions = [{
label: 'Person 1',
value: 1,
description: 'person 1 desc',
}, {
label: 'Person 2',
value: 2,
description: 'person 2 desc',
}, {
label: 'Person 3',
value: 3,
description: 'person 3 desc',
}, {
label: 'Person 4',
value: 4,
description: 'person 4 desc',
}, {
label: 'Person 5',
value: 5,
description: 'person 5 desc',
}, {
label: 'Person 6',
value: 6,
description: 'person 6 desc',
}];

const useUserMention = (props: UserMentionProps<number>) => {
const [open, setOpen] = useState(false);

const toggleOpen = () => { setOpen(true) }
const toggleClose = () => { setOpen(false) }

return <>
<button type="button" onClick={toggleOpen}>
Open UserMention
</button>
{open && <UserMention {...props} toggleClose={toggleClose} />}
</>
};

const meta = {
title: "Components/Input/UserMention",
component: useUserMention,
tags: ["autodocs"],
args: {
userOptions,
visibleOptions: 3,
onChange: fn(),
},
argTypes: {
type: { control: 'radio', options: ['default', 'description'] },
visibleOptions: { control: 'number' },
}
} as Meta<typeof UserMention>;

export default meta;

export const Default: StoryObj = {
args: {
type: 'default',
},
};

export const Description: StoryObj = {
args: {
type: 'description',
},
};
101 changes: 101 additions & 0 deletions packages/ui/Input/Select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { IconChevronDown, IconUser } from '@sopt-makers/icons';
import * as S from './style.css';

export interface Option<T> {
label: string;
value: T;
description?: string;
icon?: React.ReactNode;
profileUrl?: string;
}

interface SelectProps<T> {
className?: string;
placeholder?: string;
type: 'text' | 'textDesc' | 'textIcon' | 'userList' | 'userListDesc';
options: Option<T>[];
visibleOptions?: number;
defaultValue?: T;
onChange: (value: T) => void;
}

function Select<T extends string | number | boolean>(props: SelectProps<T>) {
const { className, placeholder, type, options, visibleOptions = 5, defaultValue, onChange } = props;

const optionsRef = useRef<HTMLUListElement>(null);

const [selected, setSelected] = useState<T | null>(defaultValue ?? null);
const [open, setOpen] = useState(false);

const handleToggleOpen = useCallback(() => {
setOpen(true);
}, []);

const handleToggleClose = useCallback(() => {
setOpen(false);
}, []);

const calcMaxHeight = useCallback(() => {
const getOptionHeight = () => {
switch (type) {
case 'text':
case 'textIcon':
return 42;
case 'textDesc':
case 'userListDesc':
return 62;
case 'userList':
return 48;
}
}
const gapHeight = 6;
const paddingHeight = 8;

return getOptionHeight() * visibleOptions + gapHeight * (visibleOptions - 1) + paddingHeight * 2;
}, [visibleOptions, type]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (optionsRef.current && !optionsRef.current.contains(event.target as Node)) {
handleToggleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);

return () => { document.removeEventListener('mousedown', handleClickOutside); };
}, [handleToggleClose]);

const handleOptionClick = (value: T) => {
setSelected(value);
onChange(value);
handleToggleClose();
}

const selectedLabel = selected ? options.find(option => option.value === selected)?.label : placeholder;

return <div className={`${S.selectWrap} ${className}`}>
<button className={S.select} onClick={handleToggleOpen} type="button">
<p className={!selected ? S.selectPlaceholder : ''}>{selectedLabel}</p>
<IconChevronDown style={{ width: 20, height: 20, transform: open ? 'rotate(-180deg)' : '', transition: 'all 0.5s' }} />
</button>

{open ? <ul className={S.optionList} ref={optionsRef} style={{ maxHeight: calcMaxHeight() }}>
{options.map(option =>
<li key={option.label}>
<button className={S.option} onClick={() => { handleOptionClick(option.value); }} type="button">
{type === 'textIcon' && option.icon}
{(type === 'userList' || type === 'userListDesc') && (option.profileUrl ? <img alt={option.label} className={S.optionProfileImg} src={option.profileUrl} /> : <div className={S.optionProfileEmpty}><IconUser /></div>)}

<div>
<p>{option.label}</p>
{(type === 'textDesc' || type === 'userListDesc') && <p className={S.optionDesc}>{option.description}</p>}
</div>
</button>
</li>
)}
</ul> : null}
</div>
}

export default Select;
66 changes: 66 additions & 0 deletions packages/ui/Input/UserMention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useRef, useEffect, useCallback } from 'react';
import { IconUser } from '@sopt-makers/icons';
import * as S from './style.css';

export interface UserOption<T> {
label: string;
value: T;
description?: string;
profileUrl?: string;
}

interface UserMentionProps<T> {
className?: string;
userOptions: UserOption<T>[];
type: 'default' | 'description';
visibleOptions?: number;
onChange: (value: T) => void;
toggleClose: () => void;
}

function UserMention<T extends string | number>(props: UserMentionProps<T>) {
const { className, userOptions, type, visibleOptions = 5, onChange, toggleClose } = props;

const userOptionsRef = useRef<HTMLUListElement>(null);

const calcMaxHeight = useCallback(() => {
const optionHeight = type === 'description' ? 62 : 48;
const gapHeight = 6;
const paddingHeight = 8;
return optionHeight * visibleOptions + gapHeight * (visibleOptions - 1) + paddingHeight * 2;
}, [visibleOptions, type]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (userOptionsRef.current && !userOptionsRef.current.contains(event.target as Node)) {
toggleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);

return () => { document.removeEventListener('mousedown', handleClickOutside); };
}, [toggleClose]);

const handleOptionClick = (value: T) => {
onChange(value);
toggleClose();
}

return <div className={`${S.selectWrap} ${className}`}>
<ul className={`${S.optionList} ${S.userMention}`} ref={userOptionsRef} style={{ maxHeight: calcMaxHeight() }}>
{userOptions.map(option =>
<li key={option.label}>
<button className={`${S.option} ${S.userMentionItem}`} onClick={() => { handleOptionClick(option.value); }} type="button">
{option.profileUrl ? <img alt={option.label} className={S.optionProfileImg} src={option.profileUrl} /> : <div className={S.optionProfileEmpty}><IconUser /></div>}
<div>
<p>{option.label}</p>
{type === 'description' && <p className={S.optionDesc}>{option.description}</p>}
</div>
</button>
</li>
)}
</ul>
</div>
}

export default UserMention;
Loading

0 comments on commit 544735a

Please sign in to comment.