-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #100 from sopt-makers/feat/select
feat(Select) : Input - Select, UserMention 컴포넌트 개발
- Loading branch information
Showing
7 changed files
with
528 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'] }, | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.