diff --git a/.changeset/fruity-aliens-crash.md b/.changeset/fruity-aliens-crash.md new file mode 100644 index 00000000..9e5e4347 --- /dev/null +++ b/.changeset/fruity-aliens-crash.md @@ -0,0 +1,5 @@ +--- +"@devup-ui/components": patch +--- + +Fix select component container diff --git a/packages/components/package.json b/packages/components/package.json index 6c8f5ee0..6e4e1852 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -22,7 +22,8 @@ "lint": "eslint", "build": "tsc && vite build", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "package": "pnpm pack --pack-destination \"../../dist\"" }, "publishConfig": { "access": "public" diff --git a/packages/components/src/__tests__/index.browser.test.ts b/packages/components/src/__tests__/index.browser.test.ts index bb4d3eb7..8ffaca2b 100644 --- a/packages/components/src/__tests__/index.browser.test.ts +++ b/packages/components/src/__tests__/index.browser.test.ts @@ -14,8 +14,9 @@ describe('export', () => { StepperDecreaseButton: expect.any(Function), StepperIncreaseButton: expect.any(Function), StepperInput: expect.any(Function), - useSelect: expect.any(Function), useStepper: expect.any(Function), + SelectContext: expect.any(Object), + useSelect: expect.any(Function), }) }) }) diff --git a/packages/components/src/components/Select/Select.stories.tsx b/packages/components/src/components/Select/Select.stories.tsx index fd8dfa1a..b912030a 100644 --- a/packages/components/src/components/Select/Select.stories.tsx +++ b/packages/components/src/components/Select/Select.stories.tsx @@ -43,14 +43,19 @@ export const ControlledCheckboxStory: Story = { render: () => , } +export const SelectWithOptionsStory: Story = { + args: {}, + render: () => , +} + export default meta function DefaultComponent( props: Omit, 'children'>, ) { return ( - {}}> - Select + {}}> + Select2 Option 1 @@ -69,13 +74,7 @@ function DefaultComponent( - + Option 6 Option 7 @@ -105,7 +104,7 @@ function ControlledCheckbox() { } return ( - + Select {value} Option 1 @@ -113,11 +112,7 @@ function ControlledCheckbox() { Option 3 Option 4 - + @@ -151,7 +146,7 @@ function ControlledRadio() { setSubValue(value) } return ( - + Select {value} Option 1 @@ -159,7 +154,7 @@ function ControlledRadio() { Option 3 Option 4 - + @@ -200,3 +195,25 @@ function ControlledRadio() { ) } + +function SelectWithOptions() { + return ( + <> + { + console.info('Option 3') + }, + }, + ]} + > + title + + > + ) +} diff --git a/packages/components/src/components/Select/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Select/__tests__/__snapshots__/index.browser.test.tsx.snap index bd4dae2f..af8b0ff9 100644 --- a/packages/components/src/components/Select/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Select/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -3,7 +3,120 @@ exports[`Select > should render 1`] = ` + + + + Select + + + + + +`; + +exports[`Select > should render with overflow screen 1`] = ` + + + + + + Select + + + + + + Option 1 + + + Option 2 + + + + Option 3 + + + Option 4 + + + + + Option 5 + + + + + + + + + +`; + +exports[`Select > should render with x and y properties 1`] = ` + + { it('should call onValueChange function when it is provided', () => { const onValueChange = vi.fn() const { container } = render( - + {children} , ) @@ -371,4 +371,78 @@ describe('Select', () => { const svg = container.querySelector('svg') expect(svg).not.toBeInTheDocument() }) + + it('should render with options properties', () => { + const { container } = render( + + Select + , + ) + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option1 = container.querySelector('[data-value="Option 1"]') + expect(option1).toBeInTheDocument() + }) + + it('should call onChange function when it is provided to SelectOption', () => { + const onValueChange = vi.fn() + const { container } = render( + + Select + , + ) + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toBeInTheDocument() + fireEvent.click(option2!) + expect(onValueChange).toHaveBeenCalledWith('Option 2') + }) + + it('should render with x and y properties', () => { + const { container } = render( + + Select + + Option 1 + Option 2 + + , + ) + expect(container).toMatchSnapshot() + }) + + it('should render with overflow screen', () => { + const { container, rerender } = render({children}) + + // open selectContainer + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + + const selectContainer = container.querySelector( + '[aria-label="Select container"]', + )! as HTMLDivElement + + // happy-dom defualt viewport 1024x768 + // offsetHeight > 768px + vi.spyOn(selectContainer, 'offsetHeight', 'get').mockReturnValue(800) + // offsetWidth > 1024px + vi.spyOn(selectContainer, 'offsetWidth', 'get').mockReturnValue(1100) + + // rerender + rerender({children}) + + expect(container).toMatchSnapshot() + }) }) diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index b2e125d1..d1391900 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -5,42 +5,23 @@ import clsx from 'clsx' import { Children, ComponentProps, - createContext, JSX, JSXElementConstructor, ReactElement, - useContext, useEffect, useRef, useState, } from 'react' +import { SelectContext, useSelect } from '../../contexts/useSelect' +import { SelectType, SelectValue } from '../../types/select' import { Button } from '../Button' import { IconCheck } from './IconCheck' -type SelectType = 'default' | 'radio' | 'checkbox' -type SelectValue = T extends 'radio' ? string : string[] - -const SelectContext = createContext<{ - open: boolean - setOpen: (open: boolean) => void - value: SelectValue - setValue: (value: string) => void - type: SelectType -} | null>(null) - -export const useSelect = () => { - const context = useContext(SelectContext) - if (!context) { - throw new Error('useSelect must be used within a Select') - } - return context -} - -interface SelectProps extends ComponentProps<'div'> { +interface SelectProps extends Omit, 'onChange'> { defaultValue?: SelectValue value?: SelectValue - onValueChange?: (value: string) => void + onChange?: (value: string) => void defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void @@ -58,6 +39,16 @@ interface SelectProps extends ComponentProps<'div'> { primaryBg?: string } typography?: keyof DevupThemeTypography + options?: { + label?: string + disabled?: boolean + onClick?: ( + value: string | undefined, + e?: React.MouseEvent, + ) => void + showCheck?: boolean + value: string + }[] } export function Select({ @@ -65,12 +56,13 @@ export function Select({ children, defaultValue, value: valueProp, - onValueChange, + onChange, defaultOpen, open: openProp, onOpenChange, colors, typography, + options, ...props }: SelectProps) { const ref = useRef(null) @@ -94,7 +86,7 @@ export function Select({ } const handleValueChange = (nextValue: string) => { - onValueChange?.(nextValue) + onChange?.(nextValue) if (type === 'default') return if (type === 'radio') { @@ -116,12 +108,13 @@ export function Select({ value: valueProp ?? value, setValue: handleValueChange, type, + ref, }} > - {children} + {options ? ( + <> + {children} + + {options?.map((option) => ( + + {option.label ?? option.value} + + ))} + + > + ) : ( + children + )} ) @@ -199,32 +205,69 @@ export function SelectTrigger({ interface SelectContainerProps extends ComponentProps<'div'> { showConfirmButton?: boolean confirmButtonText?: string + x?: number + y?: number } export function SelectContainer({ children, showConfirmButton, confirmButtonText = '완료', + x = 0, + y = 0, ...props }: SelectContainerProps) { - const { open, setOpen, type } = useSelect() + const { open, setOpen, type, ref } = useSelect() if (!open) return null return ( { + if (!ref.current || !el) return + const combobox = ref.current + + // 요소가 움직일 때마다(스크롤, 리사이즈 등) 위치를 갱신하도록 이벤트를 등록합니다. + const updatePosition = () => { + const { + height, + x: comboboxX, + y: comboboxY, + } = combobox.getBoundingClientRect() + + if (el.offsetHeight + comboboxY + y > window.innerHeight) + el.style.bottom = `${window.innerHeight - comboboxY + 10}px` + else el.style.top = `${comboboxY + height + 10 + y}px` + + if (el.offsetWidth + comboboxX + x > window.innerWidth) + el.style.left = `${comboboxX - el.offsetWidth + combobox.offsetWidth + x}px` + else el.style.left = `${comboboxX + x}px` + } + + // 최초 위치 설정 + updatePosition() + + // 스크롤, 리사이즈, DOM 변경 등 요소 위치가 변할 수 있는 이벤트에 리스너 등록 + window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition) + + // 컴포넌트 언마운트 시 이벤트 해제 + return () => { + window.removeEventListener('scroll', updatePosition, true) + window.removeEventListener('resize', updatePosition) + } + }} aria-label="Select container" bg="var(--inputBg, light-dark(#FFF,#2E2E2E))" border="1px solid var(--border, light-dark(#E4E4E4,#434343))" borderRadius="8px" bottom="-4px" boxShadow="0 2px 2px 0 var(--base10, light-dark(#0000001A,#FFFFFF1A))" + boxSize="fit-content" gap="6px" - h="fit-content" + minW="232px" p="10px" - pos="absolute" + pos="fixed" styleOrder={1} - transform="translateY(100%)" userSelect="none" - w="232px" zIndex={1} {...props} > @@ -255,12 +298,12 @@ export function SelectContainer({ } interface SelectOptionProps extends Omit, 'onClick'> { + value?: string + disabled?: boolean onClick?: ( value: string | undefined, e?: React.MouseEvent, ) => void - disabled?: boolean - value?: string showCheck?: boolean } diff --git a/packages/components/src/contexts/useSelect.ts b/packages/components/src/contexts/useSelect.ts new file mode 100644 index 00000000..da05b8aa --- /dev/null +++ b/packages/components/src/contexts/useSelect.ts @@ -0,0 +1,22 @@ +'use client' +import { createContext, useContext } from 'react' + +type SelectType = 'default' | 'radio' | 'checkbox' +type SelectValue = T extends 'radio' ? string : string[] + +export const SelectContext = createContext<{ + open: boolean + setOpen: (open: boolean) => void + value: SelectValue + setValue: (value: string) => void + type: SelectType + ref: React.RefObject +} | null>(null) + +export const useSelect = () => { + const context = useContext(SelectContext) + if (!context) { + throw new Error('useSelect must be used within a Select') + } + return context +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8242c5ac..e796a670 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -6,7 +6,6 @@ export { SelectDivider, SelectOption, SelectTrigger, - useSelect, } from './components/Select' export { Stepper, @@ -16,3 +15,5 @@ export { StepperInput, useStepper, } from './components/Stepper' +export { SelectContext, useSelect } from './contexts/useSelect' +export type { SelectType, SelectValue } from './types/select' diff --git a/packages/components/src/types/select.ts b/packages/components/src/types/select.ts new file mode 100644 index 00000000..e0dc6722 --- /dev/null +++ b/packages/components/src/types/select.ts @@ -0,0 +1,4 @@ +export type SelectType = 'default' | 'radio' | 'checkbox' +export type SelectValue = T extends 'radio' + ? string + : string[]