-
Notifications
You must be signed in to change notification settings - Fork 11
feat(AlertDialog): add new AlertDialog composites
#725
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
base: main
Are you sure you want to change the base?
Changes from 79 commits
0896d48
00de410
d918c3e
8b247e4
5b361ae
f60ff1d
0047235
8e7efeb
a95fb75
7729185
c674ee3
94af759
dc119dd
d5f29ef
c53fe07
ee1b78a
9c85d19
525240f
1258eb9
fec56fc
48d53d5
7a7e618
a716b09
94844fd
a309585
9a8e0e5
f29bcf7
193e20f
8bbc9b1
bc16436
36d8651
22edd97
3c8edf7
83e4a76
91c48c3
8776117
4bef67b
04e9e77
1989a30
af65f87
55fb0a4
a89a31b
e0ad52a
5cf01fc
73079bf
202d52b
2589af6
0d1dbbe
7172c1b
da37aa6
0be04f5
6843654
42b39e1
3d04478
6f0d3f4
0840709
65c2c28
0adef44
23a7dcf
c9b7803
008b144
74a8557
bec0c26
68aed96
f93b17d
cb40e98
a0f966e
e5a82c7
ba70f9f
8f31bfa
80594d1
231da20
1a2b044
c77214a
aa73303
6cccd96
601cbce
4103db2
6689099
08301f0
9c99818
58b75e3
dd11e22
88722c4
54265e4
9fcd46a
840c429
61f47db
0ee313b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@vapor-ui/composites': patch | ||
| --- | ||
|
|
||
| add composite `AlertDialog` component | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react-vite'; | ||
| import { Button } from '@vapor-ui/core'; | ||
|
|
||
| import { AlertDialog } from '.'; | ||
|
|
||
| export default { | ||
| title: 'Composites/AlertDialog', | ||
| component: AlertDialog.Root, | ||
| argTypes: { | ||
| type: { control: 'inline-radio', options: ['critical', 'confirm'] }, | ||
| title: { control: 'text' }, | ||
| description: { control: 'text' }, | ||
| }, | ||
| } satisfies Meta<typeof AlertDialog.Root>; | ||
|
|
||
| type Story = StoryObj<typeof AlertDialog.Root>; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| type: 'critical', | ||
| title: '이 항목을 삭제할까요?', | ||
| description: '삭제하면 되돌릴 수 없습니다. 연결된 기록도 함께 사라집니다.', | ||
| }, | ||
| render: ({ | ||
| action = <AlertDialog.Action>Remove</AlertDialog.Action>, | ||
| cancel = <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>, | ||
| ...args | ||
| }) => { | ||
| return ( | ||
| <AlertDialog.Root | ||
| trigger={<Button>트리거</Button>} | ||
| cancel={cancel} | ||
| action={action} | ||
| {...args} | ||
| /> | ||
| ); | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,307 @@ | ||
| import { createContext, useCallback, useContext, useMemo, useRef } from 'react'; | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| import { AlertDialog as AlertDialogPrimitives, Button, VStack } from '@vapor-ui/core'; | ||
|
|
||
| import type { SlotProps } from '~/utils/create-slots'; | ||
| import { createSlots } from '~/utils/create-slots'; | ||
|
|
||
| export interface AlertDialogContext { | ||
| close: () => void; | ||
| type: AlertDialogRoot.Props['type']; | ||
| } | ||
|
|
||
| export const AlertDialogContext = createContext<AlertDialogContext | undefined>(undefined); | ||
|
|
||
| export function useAlertDialogContext() { | ||
| const context = useContext<AlertDialogContext | undefined>(AlertDialogContext); | ||
| if (context === undefined) { | ||
| throw new Error( | ||
| 'AlertDialogContext is missing. AlertDialog parts must be placed within <AlertDialog.Root>.', | ||
| ); | ||
| } | ||
| return context; | ||
| } | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| const slots = createSlots({ | ||
| title: AlertDialogPrimitives.Title, | ||
| trigger: AlertDialogPrimitives.Trigger, | ||
| description: AlertDialogPrimitives.Description, | ||
| cancel: Button, | ||
| action: Button, | ||
| }); | ||
|
|
||
| export const AlertDialogRoot = ({ | ||
| // functions | ||
| open, | ||
| onOpenChange, | ||
| defaultOpen, | ||
| actionsRef: actionsRefProp, | ||
| container, | ||
| keepMounted, | ||
|
|
||
| // variants | ||
| type, | ||
|
|
||
| // slots | ||
| trigger, | ||
|
|
||
| title, | ||
| description, | ||
| cancel, | ||
| action, | ||
| children, | ||
| }: AlertDialogRoot.Props) => { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const actionsRef = useRef<AlertDialogPrimitives.Root.Actions>(null); | ||
| const mergedRef = actionsRefProp ?? actionsRef; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기 변수명을 수정해 보면 어떨까요?? base-ui 내부에서 mergeProps, mergedRef와 같이 두 개 이상의 값을 합쳐주는 merge 로직이 있습니다. 이에 학습이 mergeXXX는 두 값을 합쳐서 하나의 props에 전달하는 컨벤션으로 익숙해져 있습니다. 다만 여기는 외부에서 들어온 값이 없으면 내부 값을 할당한다는 의미가 강해서 동일한 이름에 서로 다른 동작을 하고 있는 것 같습니다.! 이 부분 resolvedActionsRef와 같은 네이밍은 어떠신지요??
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋습니다~! 다른 속성들과 컨벤션을 맞추면 좋을 것 같아서 actionsRef라는 이름을 소비할 수 있도록, 기본 ref를 fallbackActionsRef로 만들어뒀습니다! const fallbackActionsRef = useRef<AlertDialogPrimitives.Root.Actions>(null);
const actionsRef = actionsRefProp ?? fallbackActionsRef; |
||
| const close = useCallback(() => mergedRef.current?.close(), [mergedRef]); | ||
|
|
||
| const context = useMemo<AlertDialogContext>(() => ({ close, type }), [close, type]); | ||
|
|
||
| return ( | ||
| <AlertDialogContext.Provider value={context}> | ||
| <AlertDialogPrimitives.Root | ||
| open={open} | ||
| onOpenChange={onOpenChange} | ||
| defaultOpen={defaultOpen} | ||
| actionsRef={mergedRef} | ||
| > | ||
| <slots.trigger render={trigger} /> | ||
|
|
||
| <AlertDialogPrimitives.PortalPrimitive | ||
| container={container} | ||
| keepMounted={keepMounted} | ||
| > | ||
| <AlertDialogPrimitives.OverlayPrimitive /> | ||
| <AlertDialogPrimitives.PopupPrimitive> | ||
| <Header title={title} description={description} /> | ||
| <Body>{children}</Body> | ||
| <Footer action={action} cancel={cancel} /> | ||
| </AlertDialogPrimitives.PopupPrimitive> | ||
| </AlertDialogPrimitives.PortalPrimitive> | ||
| </AlertDialogPrimitives.Root> | ||
| </AlertDialogContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| type Slots = SlotProps<typeof slots, 'title'>; | ||
| type RootProps = AlertDialogPrimitives.Root.Props; | ||
| type PortalProps = AlertDialogPrimitives.PortalPrimitive.Props; | ||
|
|
||
| export interface AlertDialogProps { | ||
| /** | ||
| * 다이얼로그 열림 상태(제어). 사용자가 결정을 내려야 하는 시점을 외부 상태로 동기화할 때 사용한다. | ||
| * 상태의 변경을 추적할 필요가 없다면 defaultOpen을 사용한다. | ||
| */ | ||
| open?: RootProps['open']; | ||
|
|
||
| /** | ||
| * 마운트 시 초기 열림 여부(비제어). | ||
| * @default false | ||
| */ | ||
| defaultOpen?: RootProps['defaultOpen']; | ||
|
|
||
| /** | ||
| * 열림 상태 변경 콜백. 트리거·오버레이·ESC 등 모든 닫힘 경로에서 호출된다. | ||
| * @example | ||
| * <AlertDialog.Root onOpenChange={(open) => setOpen(open)} /> | ||
| */ | ||
| onOpenChange?: RootProps['onOpenChange']; | ||
|
|
||
| /** | ||
| * 다이얼로그를 조작하기 위한 ref를 지정한다. | ||
| * @example | ||
| * const actionsRef = useRef<AlertDialog.Actions>(null); | ||
| * const handleSave = async (event: MouseEvent<HTMLButtonElement>) => { | ||
| * event.preventDefault(); | ||
| * | ||
| * await save(); | ||
| * actionsRef.current?.close(); | ||
| * }; | ||
| * <AlertDialog.Root actionsRef={actionsRef} action={<AlertDialog.Action onClick={handleSave} />} /> | ||
| */ | ||
| actionsRef?: RootProps['actionsRef']; | ||
|
|
||
| /** | ||
| * Portal 대상 컨테이너. SSR·shadow DOM·특정 스택 컨텍스트에서 오버레이 위치 제어가 필요할 때만 지정한다. | ||
| * @default document.body | ||
| */ | ||
| container?: PortalProps['container']; | ||
|
|
||
| /** | ||
| * 닫힘 시에도 DOM에 유지할지 여부. 애니메이션/폼 상태 보존이 필요한 경우 true. | ||
| * @default false | ||
| */ | ||
| keepMounted?: PortalProps['keepMounted']; | ||
|
|
||
| /** | ||
| * 다이얼로그의 타입을 결정한다. | ||
| * @default "critical" | ||
| */ | ||
| type: 'critical' | 'confirm'; | ||
|
|
||
| /** | ||
| * 다이얼로그의 목적을 한 문장으로 전달한다. | ||
| */ | ||
| title: Slots['title']; | ||
|
|
||
| /** | ||
| * 결정에 필요한 부가 설명. | ||
| */ | ||
| description: Slots['description']; | ||
|
|
||
| /** | ||
| * 다이얼로그를 여는 진입 요소. | ||
| * @example | ||
| * <AlertDialog.Root trigger={<Button>열기</Button>} /> | ||
| */ | ||
| trigger?: Slots['trigger']; | ||
|
|
||
| /** | ||
| * 다이얼로그의 보조 액션 요소. | ||
| * @example | ||
| * <AlertDialog.Root cancel={<AlertDialog.Cancel>취소</Dialog.Cancel>} /> | ||
| */ | ||
| cancel: Slots['cancel']; | ||
|
|
||
| /** | ||
| * 다이얼로그의 주요 액션 요소. | ||
| * @example | ||
| * <AlertDialog.Root action={<AlertDialog.Action>취소</Dialog.Action>} /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사용자가 jsdocs를 볼 때 action 버튼의 의도를 혼동해서 쓸 수 있을 듯 합니다. 취소 대신 stories 예시처럼 삭제로 추가하는 것이 어떨까요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 부분도 반영해두었습니다~! |
||
| */ | ||
| action: Slots['action']; | ||
|
|
||
| /** | ||
| * 다이얼로그의 본문에 해당한다. | ||
| */ | ||
| children?: ReactNode; | ||
| } | ||
|
|
||
| export namespace AlertDialogRoot { | ||
| export type ChangeEventDetails = AlertDialogPrimitives.Root.ChangeEventDetails; | ||
| export type Actions = AlertDialogPrimitives.Root.Actions; | ||
| export type Props = AlertDialogProps; | ||
| } | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| interface HeaderProps extends Pick<AlertDialogRoot.Props, 'title' | 'description'> {} | ||
|
|
||
| const Header = ({ title, description }: HeaderProps) => { | ||
| return ( | ||
| <AlertDialogPrimitives.Header render={<VStack />} $css={{ gap: '$200' }}> | ||
| <VStack $css={{ gap: '$075' }}> | ||
| <slots.title render={title} /> | ||
| <slots.description render={description} /> | ||
| </VStack> | ||
| </AlertDialogPrimitives.Header> | ||
| ); | ||
| }; | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| interface BodyProps extends Pick<AlertDialogRoot.Props, 'children'> {} | ||
|
|
||
| const Body = ({ children }: BodyProps) => { | ||
| if (!children) return null; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| return <AlertDialogPrimitives.Body>{children}</AlertDialogPrimitives.Body>; | ||
| }; | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| interface FooterProps extends Pick<AlertDialogRoot.Props, 'action' | 'cancel'> {} | ||
|
|
||
| const Footer = ({ action, cancel }: FooterProps) => { | ||
| return ( | ||
| <AlertDialogPrimitives.Footer | ||
| $css={{ | ||
| display: 'grid', | ||
| gap: '$100', | ||
| gridTemplateAreas: '"cancel action"', | ||
| }} | ||
| > | ||
| <slots.cancel render={cancel} $css={{ gridArea: 'cancel' }} /> | ||
| <slots.action render={action} $css={{ gridArea: 'action' }} /> | ||
| </AlertDialogPrimitives.Footer> | ||
| ); | ||
| }; | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| export const AlertDialogAction = ({ | ||
| closeOnClick = true, | ||
| onClick, | ||
| children, | ||
| ...props | ||
| }: AlertDialogAction.Props) => { | ||
| const { close, type } = useAlertDialogContext(); | ||
| const handleClick = (event: Parameters<NonNullable<Button.Props['onClick']>>[0]) => { | ||
| onClick?.(event); | ||
|
|
||
| if (event.defaultPrevented) return; | ||
| if (!closeOnClick) return; | ||
| close(); | ||
| }; | ||
|
|
||
| return ( | ||
| <Button | ||
| size="lg" | ||
| colorPalette={type === 'critical' ? 'danger' : 'primary'} | ||
| onClick={handleClick} | ||
| {...props} | ||
| > | ||
| {children} | ||
| </Button> | ||
| ); | ||
| }; | ||
|
|
||
| interface AlertDialogActionProps extends Omit<Button.Props, 'size' | 'colorPalette' | 'variant'> { | ||
| /** | ||
| * 클릭 시 다이얼로그를 자동으로 닫을지 여부. | ||
| * @default true | ||
| */ | ||
| closeOnClick?: boolean; | ||
| } | ||
|
|
||
| export namespace AlertDialogAction { | ||
| export type Props = AlertDialogActionProps; | ||
| } | ||
|
|
||
| /* -----------------------------------------------------------------------------------------------*/ | ||
|
|
||
| export const AlertDialogCancel = ({ | ||
| closeOnClick = true, | ||
| onClick, | ||
| children, | ||
| ...props | ||
| }: AlertDialogCancel.Props) => { | ||
| const { close } = useAlertDialogContext(); | ||
| const handleClick = (event: Parameters<NonNullable<Button.Props['onClick']>>[0]) => { | ||
| onClick?.(event); | ||
|
|
||
| if (event.defaultPrevented) return; | ||
| if (!closeOnClick) return; | ||
| close(); | ||
| }; | ||
|
|
||
| return ( | ||
| <Button size="lg" colorPalette="secondary" onClick={handleClick} {...props}> | ||
| {children} | ||
| </Button> | ||
| ); | ||
| }; | ||
|
|
||
| interface AlertDialogCancelProps extends Omit<Button.Props, 'size' | 'colorPalette' | 'variant'> { | ||
| /** | ||
| * 클릭 시 다이얼로그를 자동으로 닫을지 여부. | ||
| * @default true | ||
| */ | ||
| closeOnClick?: boolean; | ||
| } | ||
|
|
||
| export namespace AlertDialogCancel { | ||
| export type Props = AlertDialogCancelProps; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export { | ||
| AlertDialogRoot as Root, | ||
| AlertDialogAction as Action, | ||
| AlertDialogCancel as Cancel, | ||
| } from './alert-dialog'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * as AlertDialog from './index.parts'; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from './components/alert-dialog'; | ||
| export * from './components/dialog'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
core component alert dialog header component part가 추가된 것 같습니다.! 이 부분도 추가하면 어떨까요? 그리고 composite도 alert component가 추가되었으니 minor로 가면 어떨까요?
파일을 그럼 분리해서 두 개 추가해야 할 듯 합니다.!