diff --git a/.yarn/cache/@radix-ui-primitive-npm-1.1.1-758e8c9172-d7e8191775.zip b/.yarn/cache/@radix-ui-primitive-npm-1.1.1-758e8c9172-d7e8191775.zip new file mode 100644 index 000000000..7bfd44d43 Binary files /dev/null and b/.yarn/cache/@radix-ui-primitive-npm-1.1.1-758e8c9172-d7e8191775.zip differ diff --git a/.yarn/cache/@radix-ui-react-dismissable-layer-npm-1.1.3-2114a37d20-9905ff3d8d.zip b/.yarn/cache/@radix-ui-react-dismissable-layer-npm-1.1.3-2114a37d20-9905ff3d8d.zip new file mode 100644 index 000000000..5a5a3edf5 Binary files /dev/null and b/.yarn/cache/@radix-ui-react-dismissable-layer-npm-1.1.3-2114a37d20-9905ff3d8d.zip differ diff --git a/.yarn/cache/@radix-ui-react-focus-scope-npm-1.1.1-eaf894ac65-128508e7e3.zip b/.yarn/cache/@radix-ui-react-focus-scope-npm-1.1.1-eaf894ac65-128508e7e3.zip new file mode 100644 index 000000000..61f6a115e Binary files /dev/null and b/.yarn/cache/@radix-ui-react-focus-scope-npm-1.1.1-eaf894ac65-128508e7e3.zip differ diff --git a/.yarn/cache/@radix-ui-react-primitive-npm-2.0.1-a63c88e534-ed6829b8ff.zip b/.yarn/cache/@radix-ui-react-primitive-npm-2.0.1-a63c88e534-ed6829b8ff.zip new file mode 100644 index 000000000..ced2008b4 Binary files /dev/null and b/.yarn/cache/@radix-ui-react-primitive-npm-2.0.1-a63c88e534-ed6829b8ff.zip differ diff --git a/.yarn/cache/@stackflow-compat-await-push-npm-1.1.13-5582c8a4fe-ca498d6553.zip b/.yarn/cache/@stackflow-compat-await-push-npm-1.1.13-5582c8a4fe-ca498d6553.zip new file mode 100644 index 000000000..de18538b9 Binary files /dev/null and b/.yarn/cache/@stackflow-compat-await-push-npm-1.1.13-5582c8a4fe-ca498d6553.zip differ diff --git a/docs/components/example/alert-dialog-danger.tsx b/docs/components/example/alert-dialog-danger.tsx index 218478597..32b0dce96 100644 --- a/docs/components/example/alert-dialog-danger.tsx +++ b/docs/components/example/alert-dialog-danger.tsx @@ -1,19 +1,47 @@ "use client"; import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog, AlertDialogAction } from "seed-design/ui/alert-dialog"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from "seed-design/ui/alert-dialog"; +import { Column, Columns } from "@seed-design/react"; -const AlertDialogDangerActivity = () => { +const AlertDialogDanger = () => { return ( - - - 취소 - - - 확인 - - + // You can set z-index dialog with "--layer-index" custom property. useful for stackflow integration. + + + 열기 + + + + 제목 + 파괴적, 비가역적 작업을 경고합니다. + + + + + + 취소 + + + + + 확인 + + + + + + ); }; -export default AlertDialogDangerActivity; +export default AlertDialogDanger; diff --git a/docs/components/example/alert-dialog-default-activity.tsx b/docs/components/example/alert-dialog-default-activity.tsx deleted file mode 100644 index f909fb589..000000000 --- a/docs/components/example/alert-dialog-default-activity.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import * as React from "react"; - -import { AppScreen } from "@stackflow/plugin-basic-ui"; -import { type ActivityComponentType, useStepFlow, useStack } from "@stackflow/react/future"; - -import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog as UIAlertDialog } from "seed-design/ui/alert-dialog"; - -declare module "@stackflow/config" { - interface Register { - AlertDialogDefault: { - alert: boolean; - }; - } -} - -const AlertDialogDefaultActivity: ActivityComponentType<"AlertDialogDefault"> = ({ params }) => { - const { alert } = params; - const stack = useStack(); - const { pushStep, popStep } = useStepFlow("AlertDialogDefault"); - - const appBarLeft = () =>
Left
; - const appBarRight = () =>
Right
; - - const onInteractOutside = () => { - popStep(); - }; - - const onButtonClick = () => { - pushStep({ - alert: true, - }); - }; - - const mainActivitySteps = stack.activities[0].steps; - - return ( - -
- Open - {alert && ( - - )} -
- -
- - Steps - - {mainActivitySteps.map((step) => ( -
- ))} -
- -
- popStep}>Back -
- - ); -}; - -export default AlertDialogDefaultActivity; - -AlertDialogDefaultActivity.displayName = "AlertDialogDefaultActivity"; diff --git a/docs/components/example/alert-dialog-neutral.tsx b/docs/components/example/alert-dialog-neutral.tsx index 56aa79337..ebf7b0295 100644 --- a/docs/components/example/alert-dialog-neutral.tsx +++ b/docs/components/example/alert-dialog-neutral.tsx @@ -1,19 +1,47 @@ "use client"; import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog, AlertDialogAction } from "seed-design/ui/alert-dialog"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from "seed-design/ui/alert-dialog"; +import { Column, Columns } from "@seed-design/react"; -const AlertDialogNeutralActivity = () => { +const AlertDialogNeutral = () => { return ( - - - 취소 - - - 확인 - - + // You can set z-index dialog with "--layer-index" custom property. useful for stackflow integration. + + + 열기 + + + + 제목 + 중립적인 선택지를 제공합니다. + + + + + + 취소 + + + + + 확인 + + + + + + ); }; -export default AlertDialogNeutralActivity; +export default AlertDialogNeutral; diff --git a/docs/components/example/alert-dialog-nonpreferred.tsx b/docs/components/example/alert-dialog-nonpreferred.tsx deleted file mode 100644 index 45b394f71..000000000 --- a/docs/components/example/alert-dialog-nonpreferred.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog } from "seed-design/ui/alert-dialog"; -import { Flex } from "seed-design/ui/layout"; - -const AlertDialogNonpreferredActivity = () => { - return ( - - - 확인 - - - - ); -}; - -export default AlertDialogNonpreferredActivity; diff --git a/docs/components/example/alert-dialog-preview.tsx b/docs/components/example/alert-dialog-preview.tsx index 39b626842..a7c935b86 100644 --- a/docs/components/example/alert-dialog-preview.tsx +++ b/docs/components/example/alert-dialog-preview.tsx @@ -1,19 +1,47 @@ "use client"; +import { Column, Columns } from "@seed-design/react"; import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog, AlertDialogAction } from "seed-design/ui/alert-dialog"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from "seed-design/ui/alert-dialog"; -const AlertDialogPreviewActivity = () => { +const AlertDialogSingle = () => { return ( - - - 취소 - - - 확인 - - + // You can set z-index dialog with "--layer-index" custom property. useful for stackflow integration. + + + 열기 + + + + 주의 + 이 작업은 되돌릴 수 없습니다. + + + + + + 취소 + + + + + 확인 + + + + + + ); }; -export default AlertDialogPreviewActivity; +export default AlertDialogSingle; diff --git a/docs/components/example/alert-dialog-single.tsx b/docs/components/example/alert-dialog-single.tsx index 5f022f203..30ff191e1 100644 --- a/docs/components/example/alert-dialog-single.tsx +++ b/docs/components/example/alert-dialog-single.tsx @@ -1,17 +1,37 @@ "use client"; -import { Flex } from "seed-design/ui/layout"; import { ActionButton } from "seed-design/ui/action-button"; -import { AlertDialog } from "seed-design/ui/alert-dialog"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from "seed-design/ui/alert-dialog"; -const AlertDialogSingleActivity = () => { +const AlertDialogSingle = () => { + // You can set z-index dialog with "--layer-index" custom property. useful for stackflow integration. return ( - - - 확인 - - + + + 열기 + + + + 제목 + 단일 선택지를 제공합니다. + + + + 확인 + + + + ); }; -export default AlertDialogSingleActivity; +export default AlertDialogSingle; diff --git a/docs/components/example/alert-dialog-stackflow.tsx b/docs/components/example/alert-dialog-stackflow.tsx new file mode 100644 index 000000000..8bd3f8d00 --- /dev/null +++ b/docs/components/example/alert-dialog-stackflow.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useActivity } from "@stackflow/react"; +import { useFlow } from "@stackflow/react/future"; +import { ActionButton } from "seed-design/ui/action-button"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, +} from "seed-design/ui/alert-dialog"; + +const AlertDialogStackflow = () => { + const activity = useActivity(); + const { pop } = useFlow(); + + return ( + !open && pop()}> + + + 제목 + Stackflow + + + + 확인 + + + + + ); +}; + +export default AlertDialogStackflow; diff --git a/docs/content/docs/react/components/alert-dialog.mdx b/docs/content/docs/react/components/alert-dialog.mdx new file mode 100644 index 000000000..e867e77ce --- /dev/null +++ b/docs/content/docs/react/components/alert-dialog.mdx @@ -0,0 +1,31 @@ +--- +title: Alert Dialog +--- + + + +## 설치 + + + +## Props + + + +## 예제 + +### Single Action + + + +### Neutral Secondary Action + + + +### Danger Action + + + +### Stackflow + + diff --git a/docs/content/docs/react/components/stackflow/alert-dialog.mdx b/docs/content/docs/react/components/stackflow/alert-dialog.mdx deleted file mode 100644 index cc11a16be..000000000 --- a/docs/content/docs/react/components/stackflow/alert-dialog.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Alert Dialog ---- - - - -## 설치 - - - -## Props - - - -## 예제 - -### Single Action - - - -### Neutral Secondary Action - - - -### Non-Preferred Secondary Action - - - -### Danger Action - - \ No newline at end of file diff --git a/docs/registry/ui/alert-dialog.tsx b/docs/registry/ui/alert-dialog.tsx index 64f098c76..3befd5599 100644 --- a/docs/registry/ui/alert-dialog.tsx +++ b/docs/registry/ui/alert-dialog.tsx @@ -2,37 +2,70 @@ import "@seed-design/stylesheet/dialog.css"; -import { Dialog } from "@seed-design/stackflow"; +import { Dialog } from "@seed-design/react"; import { forwardRef } from "react"; -export interface AlertDialogProps extends Dialog.RootProps { - title: string; - description: string; -} +export interface AlertDialogRootProps extends Dialog.RootProps {} /** - * @see https://v3.seed-design.io/docs/react/components/stackflow/alert-dialog + * @see https://v3.seed-design.io/docs/react/components/alert-dialog */ -export const AlertDialog = forwardRef( - ({ title, description, children, ...otherProps }, ref) => { - // FIXME: Footer 안의 action 배열을 다룰 쓸만한 인터페이스가 생각이 안남. 인터페이스 다시 생각할 것. - return ( - - - - - {title} - {description} - - {children} - - - ); - }, -); - -AlertDialog.displayName = "AlertDialog"; - -export type AlertDialogActionProps = Dialog.ActionProps; +export const AlertDialogRoot = ({ + children, + ...otherProps +}: AlertDialogRootProps) => { + return ( + + {children} + + ); +}; +AlertDialogRoot.displayName = "AlertDialogRoot"; + +export interface AlertDialogContentProps extends Dialog.ContentProps { + layerIndex?: number; +} + +export const AlertDialogContent = forwardRef< + HTMLDivElement, + AlertDialogContentProps +>(({ children, layerIndex, ...otherProps }, ref) => { + return ( + + + + {children} + + + ); +}); + +export interface AlertDialogTriggerProps extends Dialog.TriggerProps {} + +export const AlertDialogTrigger = Dialog.Trigger; + +export interface AlertDialogHeaderProps extends Dialog.HeaderProps {} + +export const AlertDialogHeader = Dialog.Header; + +export interface AlertDialogTitleProps extends Dialog.TitleProps {} + +export const AlertDialogTitle = Dialog.Title; + +export interface AlertDialogDescriptionProps extends Dialog.DescriptionProps {} + +export const AlertDialogDescription = Dialog.Description; + +export interface AlertDialogFooterProps extends Dialog.FooterProps {} + +export const AlertDialogFooter = Dialog.Footer; + +export interface AlertDialogActionProps extends Dialog.ActionProps {} export const AlertDialogAction = Dialog.Action; diff --git a/docs/registry/util/mergeRefs.ts b/docs/registry/util/mergeRefs.ts deleted file mode 100644 index e3d220be5..000000000 --- a/docs/registry/util/mergeRefs.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as React from "react"; - -export function mergeRefs( - ...refs: React.ForwardedRef[] -): React.ForwardedRef { - if (refs.length === 1) { - return refs[0]; - } - - return (value: T | null) => { - for (const ref of refs) { - if (typeof ref === "function") { - ref(value); - } else if (ref != null) { - ref.current = value; - } - } - }; -} diff --git a/docs/registry/util/types.ts b/docs/registry/util/types.ts deleted file mode 100644 index 7e0e3c9f9..000000000 --- a/docs/registry/util/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Assign = Omit & U; diff --git a/docs/registry/util/visuallyHidden.ts b/docs/registry/util/visuallyHidden.ts deleted file mode 100644 index a31846088..000000000 --- a/docs/registry/util/visuallyHidden.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CSSProperties } from "react"; - -export const visuallyHidden: CSSProperties = { - border: 0, - clip: "rect(0 0 0 0)", - height: "1px", - margin: "-1px", - overflow: "hidden", - padding: 0, - position: "absolute", - whiteSpace: "nowrap", - width: "1px", -}; diff --git a/examples/stackflow-spa/package.json b/examples/stackflow-spa/package.json index 1469f71ff..1ef06e878 100644 --- a/examples/stackflow-spa/package.json +++ b/examples/stackflow-spa/package.json @@ -10,6 +10,7 @@ "dependencies": { "@daangn/react-monochrome-icon": "^0.0.13", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-use-callback-ref": "^1.1.0", "@seed-design/react": "0.0.0", "@seed-design/react-popover": "0.0.0-alpha-20241030023710", "@seed-design/react-tabs": "0.0.0-alpha-20241209060641", @@ -18,6 +19,7 @@ "@seed-design/stackflow": "0.0.0", "@seed-design/stylesheet": "3.0.0-alpha-20241212122822", "@seed-design/vars": "0.0.0", + "@stackflow/compat-await-push": "^1.1.13", "@stackflow/core": "^1.1.0", "@stackflow/plugin-basic-ui": "^1.11.1", "@stackflow/plugin-history-sync": "^1.7.0", diff --git a/examples/stackflow-spa/src/activities/ActivityAlertDialog.tsx b/examples/stackflow-spa/src/activities/ActivityAlertDialog.tsx index 4d0776fd4..6656dfcb6 100644 --- a/examples/stackflow-spa/src/activities/ActivityAlertDialog.tsx +++ b/examples/stackflow-spa/src/activities/ActivityAlertDialog.tsx @@ -1,35 +1,54 @@ -import type { ActivityComponentType } from "@stackflow/react"; +import { useActivity, type ActivityComponentType } from "@stackflow/react"; -import { AlertDialog, AlertDialogAction } from "../design-system/stackflow/AlertDialog"; import { ActionButton } from "../design-system/ui/action-button"; +import { + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, +} from "../design-system/ui/alert-dialog"; import { useFlow } from "../stackflow"; +import { Stack } from "@seed-design/react"; +import { send } from "@stackflow/compat-await-push"; const ActivityAlertDialog: ActivityComponentType = () => { - const { pop } = useFlow(); + const activity = useActivity(); + const { pop, push } = useFlow(); + + const handleClose = (open: boolean) => { + if (!open) { + pop(); + send({ + activityId: activity.id, + data: { + message: "hello", + }, + }); + } + }; return ( - - - { - pop(); - }} - variant="neutralWeak" - > - 취소 - - - - { - pop(); - }} - variant="neutralSolid" - > - 확인 - - - + + + + 제목 + 다람쥐 헌 쳇바퀴에 타고파 + + + + + 확인 + + push("ActivityActionChip", {})}> + Push + + + + + ); }; diff --git a/examples/stackflow-spa/src/activities/ActivityHome.tsx b/examples/stackflow-spa/src/activities/ActivityHome.tsx index cfc4fb412..c611c4f52 100644 --- a/examples/stackflow-spa/src/activities/ActivityHome.tsx +++ b/examples/stackflow-spa/src/activities/ActivityHome.tsx @@ -2,22 +2,41 @@ import type { ActivityComponentType } from "@stackflow/react"; import { AppScreen } from "@stackflow/plugin-basic-ui"; -import { List, ListItem } from "../components/List"; -import { useFlow } from "../stackflow"; import { useSnackbarAdapter } from "@seed-design/react"; +import { receive } from "@stackflow/compat-await-push"; +import { useRef } from "react"; +import { List, ListItem } from "../components/List"; +import { ActionButton } from "../design-system/ui/action-button"; +import { + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from "../design-system/ui/alert-dialog"; import { Snackbar } from "../design-system/ui/snackbar"; +import { useStepDialog } from "../design-system/util/use-step-dialog"; +import { useFlow } from "../stackflow"; const ActivityHome: ActivityComponentType = () => { const { push } = useFlow(); + const { dialogProps } = useStepDialog(); + const ref = useRef(null); const snackbarAdapter = useSnackbarAdapter(); return (
push("ActivityActionButton", {})} title="ActionButton" /> @@ -26,7 +45,29 @@ const ActivityHome: ActivityComponentType = () => { push("ActivityHelpBubble", {})} title="HelpBubble" /> push("ActivityLayerBar", {})} title="LayerBar" /> push("ActivityTransparentBar", {})} title="TransparentBar" /> - push("ActivityAlertDialog", {})} title="AlertDialog" /> + + + + + + + 제목 + + Lorem ipsum dolor sit amet consectetur adipisicing elit. + + + + push("ActivityActionChip", {})}>확인 + + + + { + const result = await receive(push("ActivityAlertDialog", {})); + console.log(result.message); + }} + title="AlertDialog (activity)" + /> push("ActivityBottomSheet", {})} title="BottomSheet" /> push("ActivityActionSheet", {})} title="ActionSheet" /> { + return ( + + {children} + + ); +}; +AlertDialogRoot.displayName = "AlertDialogRoot"; + +export interface AlertDialogContentProps extends Dialog.ContentProps { + layerIndex?: number; +} + +export const AlertDialogContent = forwardRef( + ({ children, layerIndex, ...otherProps }, ref) => { + return ( + + + + {children} + + + ); + }, +); + +export interface AlertDialogTriggerProps extends Dialog.TriggerProps {} + +export const AlertDialogTrigger = Dialog.Trigger; + +export interface AlertDialogHeaderProps extends Dialog.HeaderProps {} + +export const AlertDialogHeader = Dialog.Header; + +export interface AlertDialogTitleProps extends Dialog.TitleProps {} + +export const AlertDialogTitle = Dialog.Title; + +export interface AlertDialogDescriptionProps extends Dialog.DescriptionProps {} + +export const AlertDialogDescription = Dialog.Description; + +export interface AlertDialogFooterProps extends Dialog.FooterProps {} + +export const AlertDialogFooter = Dialog.Footer; + +export interface AlertDialogActionProps extends Dialog.ActionProps {} + +export const AlertDialogAction = Dialog.Action; diff --git a/examples/stackflow-spa/src/design-system/util/mergeRefs.ts b/examples/stackflow-spa/src/design-system/util/mergeRefs.ts deleted file mode 100644 index e3d220be5..000000000 --- a/examples/stackflow-spa/src/design-system/util/mergeRefs.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as React from "react"; - -export function mergeRefs( - ...refs: React.ForwardedRef[] -): React.ForwardedRef { - if (refs.length === 1) { - return refs[0]; - } - - return (value: T | null) => { - for (const ref of refs) { - if (typeof ref === "function") { - ref(value); - } else if (ref != null) { - ref.current = value; - } - } - }; -} diff --git a/examples/stackflow-spa/src/design-system/util/types.ts b/examples/stackflow-spa/src/design-system/util/types.ts deleted file mode 100644 index 7e0e3c9f9..000000000 --- a/examples/stackflow-spa/src/design-system/util/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Assign = Omit & U; diff --git a/examples/stackflow-spa/src/design-system/util/use-step-dialog.tsx b/examples/stackflow-spa/src/design-system/util/use-step-dialog.tsx new file mode 100644 index 000000000..be1cbe865 --- /dev/null +++ b/examples/stackflow-spa/src/design-system/util/use-step-dialog.tsx @@ -0,0 +1,58 @@ +import { useCallbackRef } from "@radix-ui/react-use-callback-ref"; +import { useActivity, useActivityParams } from "@stackflow/react"; +import { useStepFlow } from "@stackflow/react/future"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; + +export interface UseStepDialogProps { + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function useStepDialog(props: UseStepDialogProps = {}) { + const [open, setOpen] = useState(props.defaultOpen ?? false); + + const id = useId(); + const activity = useActivity(); + const { pushStep, popStep } = useStepFlow(activity.name as any); + const params = useActivityParams>(); + const isDialogPersist = params[id] === "dialog"; + + useEffect(() => { + if (!isDialogPersist) { + setOpen(false); + } + }, [isDialogPersist]); + + const onOpenChange = useCallbackRef(props.onOpenChange); + const handleOpenChange = useCallback( + (open: boolean) => { + setOpen(open); + onOpenChange?.(open); + if (open) { + if (!isDialogPersist) { + pushStep({ + ...params, + [id]: "dialog", + }); + } + } else { + if (isDialogPersist) { + popStep(); + } + } + }, + [pushStep, popStep, onOpenChange, isDialogPersist, params, id], + ); + + return useMemo( + () => ({ + open, + setOpen: handleOpenChange, + dialogProps: { + open, + onOpenChange: handleOpenChange, + }, + }), + [open, handleOpenChange], + ); +} diff --git a/examples/stackflow-spa/src/design-system/util/visuallyHidden.ts b/examples/stackflow-spa/src/design-system/util/visuallyHidden.ts deleted file mode 100644 index a31846088..000000000 --- a/examples/stackflow-spa/src/design-system/util/visuallyHidden.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CSSProperties } from "react"; - -export const visuallyHidden: CSSProperties = { - border: 0, - clip: "rect(0 0 0 0)", - height: "1px", - margin: "-1px", - overflow: "hidden", - padding: 0, - position: "absolute", - whiteSpace: "nowrap", - width: "1px", -}; diff --git a/packages/qvism-preset/src/recipes/dialog.ts b/packages/qvism-preset/src/recipes/dialog.ts index 090fe7e9b..f8ed777fe 100644 --- a/packages/qvism-preset/src/recipes/dialog.ts +++ b/packages/qvism-preset/src/recipes/dialog.ts @@ -1,36 +1,48 @@ import { dialog as vars } from "@seed-design/vars/component"; -import { defineRecipe } from "../utils/define-recipe"; import { enterAnimation, exitAnimation } from "../utils/animation"; -import { pseudo } from "../utils/pseudo"; +import { defineRecipe } from "../utils/define-recipe"; +import { not, open, pseudo } from "../utils/pseudo"; const dialog = defineRecipe({ name: "dialog", - slots: ["backdrop", "container", "content", "header", "footer", "action", "title", "description"], + slots: [ + "positioner", + "backdrop", + "content", + "header", + "footer", + "action", + "title", + "description", + ], base: { - backdrop: { + positioner: { position: "fixed", + display: "flex", + justifyContent: "center", + alignItems: "center", inset: 0, - background: vars.base.enabled.backdrop.color, + overscrollBehaviorY: "none", - [pseudo(":is([data-transition-state='exit-active'],[data-transition-state='exit-done'])")]: - exitAnimation({ - timingFunction: vars.base.enabled.backdrop.exitTimingFunction, - duration: vars.base.enabled.backdrop.exitDuration, - opacity: vars.base.enabled.backdrop.exitOpacity, - }), - [pseudo(":is([data-transition-state='enter-active'],[data-transition-state='enter-done'])")]: - enterAnimation({ - timingFunction: vars.base.enabled.backdrop.enterTimingFunction, - duration: vars.base.enabled.backdrop.enterDuration, - opacity: vars.base.enabled.backdrop.enterOpacity, - }), + "--dialog-z-index": "2", + zIndex: "calc(var(--dialog-z-index) + var(--layer-index, 0))", }, - container: { + backdrop: { position: "fixed", - display: "flex", - justifyContent: "center", - alignItems: "center", inset: 0, + background: vars.base.enabled.backdrop.color, + zIndex: "calc(var(--dialog-z-index) + var(--layer-index, 0))", + + [pseudo(open)]: enterAnimation({ + timingFunction: vars.base.enabled.backdrop.enterTimingFunction, + duration: vars.base.enabled.backdrop.enterDuration, + opacity: vars.base.enabled.backdrop.enterOpacity, + }), + [pseudo(not(open))]: exitAnimation({ + timingFunction: vars.base.enabled.backdrop.exitTimingFunction, + duration: vars.base.enabled.backdrop.exitDuration, + opacity: vars.base.enabled.backdrop.exitOpacity, + }), }, content: { position: "relative", @@ -39,6 +51,7 @@ const dialog = defineRecipe({ flexDirection: "column", boxSizing: "border-box", wordBreak: "break-all", + zIndex: "calc(var(--dialog-z-index) + var(--layer-index, 0))", background: vars.base.enabled.content.color, maxWidth: vars.base.enabled.content.maxWidth, @@ -46,19 +59,17 @@ const dialog = defineRecipe({ padding: `${vars.base.enabled.content.paddingY} ${vars.base.enabled.content.paddingX}`, borderRadius: vars.base.enabled.content.cornerRadius, - [pseudo(":is([data-transition-state='exit-active'],[data-transition-state='exit-done'])")]: - exitAnimation({ - timingFunction: vars.base.enabled.content.exitTimingFunction, - duration: vars.base.enabled.content.exitDuration, - opacity: vars.base.enabled.content.exitOpacity, - }), - [pseudo(":is([data-transition-state='enter-active'],[data-transition-state='enter-done'])")]: - enterAnimation({ - timingFunction: vars.base.enabled.content.enterTimingFunction, - duration: vars.base.enabled.content.enterDuration, - opacity: vars.base.enabled.content.enterOpacity, - scale: vars.base.enabled.content.enterScale, - }), + [pseudo(open)]: enterAnimation({ + timingFunction: vars.base.enabled.content.enterTimingFunction, + duration: vars.base.enabled.content.enterDuration, + opacity: vars.base.enabled.content.enterOpacity, + scale: vars.base.enabled.content.enterScale, + }), + [pseudo(not(open))]: exitAnimation({ + timingFunction: vars.base.enabled.content.exitTimingFunction, + duration: vars.base.enabled.content.exitDuration, + opacity: vars.base.enabled.content.exitOpacity, + }), }, header: { display: "flex", @@ -85,35 +96,14 @@ const dialog = defineRecipe({ }, footer: { display: "flex", - flexWrap: "wrap", - justifyContent: "space-between", + flexDirection: "column", alignItems: "stretch", paddingTop: vars.base.enabled.footer.paddingTop, - gap: vars.base.enabled.footer.gap, }, - action: { - width: "initial", - minWidth: `calc(50% - ${vars.base.enabled.footer.gap} / 2)`, - }, - }, - variants: { - footerLayout: { - horizontal: { - footer: { - flexDirection: "row-reverse", - }, - }, - vertical: { - footer: { - flexDirection: "column", - }, - }, - }, - }, - defaultVariants: { - footerLayout: "horizontal", }, + variants: {}, + defaultVariants: {}, }); export default dialog; diff --git a/packages/react-headless/dialog/.gitignore b/packages/react-headless/dialog/.gitignore new file mode 100644 index 000000000..32a94bfe8 --- /dev/null +++ b/packages/react-headless/dialog/.gitignore @@ -0,0 +1,2 @@ +/lib/ +/dist/ diff --git a/packages/react-headless/dialog/package.json b/packages/react-headless/dialog/package.json new file mode 100644 index 000000000..c94519afd --- /dev/null +++ b/packages/react-headless/dialog/package.json @@ -0,0 +1,51 @@ +{ + "name": "@seed-design/react-dialog", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/daangn/seed-design.git", + "directory": "packages/react-headless/dialog" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.mjs" + }, + "./package.json": "./package.json" + }, + "main": "./lib/index.js", + "files": [ + "lib", + "src" + ], + "scripts": { + "prepack": "yarn build", + "clean": "rm -rf lib", + "build": "nanobundle build" + }, + "dependencies": { + "@radix-ui/react-dismissable-layer": "^1.1.3", + "@radix-ui/react-focus-scope": "^1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "^1.1.0", + "@seed-design/dom-utils": "0.0.0-alpha-20241030023710" + }, + "devDependencies": { + "nanobundle": "^1.6.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "ultra": { + "concurrent": [ + "dev", + "build" + ] + } +} diff --git a/packages/react-headless/dialog/src/Dialog.namespace.ts b/packages/react-headless/dialog/src/Dialog.namespace.ts new file mode 100644 index 000000000..a74b1607d --- /dev/null +++ b/packages/react-headless/dialog/src/Dialog.namespace.ts @@ -0,0 +1,18 @@ +export { + DialogBackdrop as Backdrop, + DialogCloseButton as CloseButton, + DialogContent as Content, + DialogDescription as Description, + DialogPositioner as Positioner, + DialogRoot as Root, + DialogTitle as Title, + DialogTrigger as Trigger, + type DialogBackdropProps as BackdropProps, + type DialogCloseButtonProps as CloseButtonProps, + type DialogContentProps as ContentProps, + type DialogDescriptionProps as DescriptionProps, + type DialogPositionerProps as PositionerProps, + type DialogRootProps as RootProps, + type DialogTitleProps as TitleProps, + type DialogTriggerProps as TriggerProps, +} from "./Dialog"; diff --git a/packages/react-headless/dialog/src/Dialog.tsx b/packages/react-headless/dialog/src/Dialog.tsx new file mode 100644 index 000000000..2be510526 --- /dev/null +++ b/packages/react-headless/dialog/src/Dialog.tsx @@ -0,0 +1,112 @@ +import { DismissableLayer } from "@radix-ui/react-dismissable-layer"; +import { FocusScope } from "@radix-ui/react-focus-scope"; +import { mergeProps } from "@seed-design/dom-utils"; +import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive"; +import type * as React from "react"; +import { forwardRef } from "react"; +import { Presence } from "./private/Presence"; +import { useDialog, type UseDialogProps } from "./useDialog"; +import { DialogProvider, useDialogContext } from "./useDialogContext"; + +export interface DialogRootProps extends UseDialogProps { + children: React.ReactNode; +} + +export const DialogRoot = (props: DialogRootProps) => { + const { children, ...otherProps } = props; + const api = useDialog(otherProps); + return {children}; +}; + +export interface DialogTriggerProps + extends PrimitiveProps, + React.HTMLAttributes {} + +export const DialogTrigger = forwardRef((props, ref) => { + const api = useDialogContext(); + return ; +}); +DialogTrigger.displayName = "DialogTrigger"; + +export interface DialogPositionerProps + extends PrimitiveProps, + React.HTMLAttributes {} + +export const DialogPositioner = forwardRef((props, ref) => { + const api = useDialogContext(); + return ; +}); + +export interface DialogBackdropProps extends PrimitiveProps, React.HTMLAttributes {} + +// We might need scroll lock here; not needed yet in stackflow based webview. +export const DialogBackdrop = forwardRef((props, ref) => { + const api = useDialogContext(); + return ( + + + + ); +}); +DialogBackdrop.displayName = "DialogBackdrop"; + +export interface DialogContentProps extends PrimitiveProps, React.HTMLAttributes {} + +// TODO: implement DismissableLayer in useDialog instead of radix-ui +export const DialogContent = forwardRef((props, ref) => { + const api = useDialogContext(); + + return ( + + + { + if (!api.closeOnEscape) { + e.preventDefault(); + } + }} + onInteractOutside={(e) => { + if (!api.closeOnInteractOutside) { + e.preventDefault(); + } + }} + onDismiss={() => api.setOpen(false)} + {...mergeProps(api.contentProps, props)} + /> + + + ); +}); +DialogContent.displayName = "DialogContent"; + +export interface DialogTitleProps + extends PrimitiveProps, + React.HTMLAttributes {} + +export const DialogTitle = forwardRef((props, ref) => { + const api = useDialogContext(); + return ; +}); + +export interface DialogDescriptionProps + extends PrimitiveProps, + React.HTMLAttributes {} + +export const DialogDescription = forwardRef( + (props, ref) => { + const api = useDialogContext(); + return ; + }, +); + +export interface DialogCloseButtonProps + extends PrimitiveProps, + React.ButtonHTMLAttributes {} + +export const DialogCloseButton = forwardRef( + (props, ref) => { + const api = useDialogContext(); + return ; + }, +); diff --git a/packages/react-headless/dialog/src/index.ts b/packages/react-headless/dialog/src/index.ts new file mode 100644 index 000000000..eeccac225 --- /dev/null +++ b/packages/react-headless/dialog/src/index.ts @@ -0,0 +1,22 @@ +export { + DialogBackdrop, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogPositioner, + DialogRoot, + DialogTitle, + DialogTrigger, + type DialogBackdropProps, + type DialogCloseButtonProps, + type DialogContentProps, + type DialogDescriptionProps, + type DialogPositionerProps, + type DialogRootProps, + type DialogTitleProps, + type DialogTriggerProps, +} from "./Dialog"; + +export { useDialogContext, type UseDialogContext } from "./useDialogContext"; + +export * as Dialog from "./Dialog.namespace"; diff --git a/packages/react-headless/dialog/src/private/Presence.tsx b/packages/react-headless/dialog/src/private/Presence.tsx new file mode 100644 index 000000000..75ac654e5 --- /dev/null +++ b/packages/react-headless/dialog/src/private/Presence.tsx @@ -0,0 +1,35 @@ +import { composeRefs } from "@radix-ui/react-compose-refs"; +import { Primitive } from "@seed-design/react-primitive"; +import { useRef } from "react"; +import { usePresence } from "./usePresence"; + +export interface PresenceProps { + present: boolean; + unmountOnExit: boolean; + lazyMount: boolean; + children: React.ReactNode; +} + +export const Presence = (props: PresenceProps) => { + const { isPresent, ref } = usePresence(props.present); + const wasEverPresent = useRef(false); + + if (isPresent) { + wasEverPresent.current = true; + } + + const unmounted = + (!isPresent && !wasEverPresent.current && props.lazyMount) || + (props.unmountOnExit && !isPresent && wasEverPresent.current); + + if (unmounted) { + return null; + } + + return ( + + {props.children} + + ); +}; +Presence.displayName = "Presence"; diff --git a/packages/react-headless/dialog/src/private/usePresence.tsx b/packages/react-headless/dialog/src/private/usePresence.tsx new file mode 100644 index 000000000..2244b4e15 --- /dev/null +++ b/packages/react-headless/dialog/src/private/usePresence.tsx @@ -0,0 +1,159 @@ +// This code includes portions derived from radix-ui/primitives (https://github.com/radix-ui/primitives) +// Used under the MIT License: https://opensource.org/licenses/MIT + +import { useLayoutEffect } from "@radix-ui/react-use-layout-effect"; +import * as React from "react"; + +export type UsePresenceReturn = ReturnType; + +export function usePresence(present: boolean) { + const [node, setNode] = React.useState(); + const stylesRef = React.useRef({} as any); + const prevPresentRef = React.useRef(present); + const prevAnimationNameRef = React.useRef("none"); + const initialState = present ? "mounted" : "unmounted"; + const [state, send] = useStateMachine(initialState, { + mounted: { + UNMOUNT: "unmounted", + ANIMATION_OUT: "unmountSuspended", + }, + unmountSuspended: { + MOUNT: "mounted", + ANIMATION_END: "unmounted", + }, + unmounted: { + MOUNT: "mounted", + }, + }); + + React.useEffect(() => { + const currentAnimationName = getAnimationName(stylesRef.current); + prevAnimationNameRef.current = state === "mounted" ? currentAnimationName : "none"; + }, [state]); + + useLayoutEffect(() => { + const styles = stylesRef.current; + const wasPresent = prevPresentRef.current; + const hasPresentChanged = wasPresent !== present; + + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.current; + const currentAnimationName = getAnimationName(styles); + + if (present) { + send("MOUNT"); + } else if (currentAnimationName === "none" || styles?.display === "none") { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly + send("UNMOUNT"); + } else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName; + + if (wasPresent && isAnimating) { + send("ANIMATION_OUT"); + } else { + send("UNMOUNT"); + } + } + + prevPresentRef.current = present; + } + }, [present, send]); + + useLayoutEffect(() => { + if (node) { + let timeoutId: number; + const ownerWindow = node.ownerDocument.defaultView ?? window; + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(stylesRef.current); + const isCurrentAnimation = currentAnimationName.includes(event.animationName); + if (event.target === node && isCurrentAnimation) { + // With React 18 concurrency this update is applied a frame after the + // animation ends, creating a flash of visible content. By setting the + // animation fill mode to "forwards", we force the node to keep the + // styles of the last keyframe, removing the flash. + // + // Previously we flushed the update via ReactDom.flushSync, but with + // exit animations this resulted in the node being removed from the + // DOM before the synthetic animationEnd event was dispatched, meaning + // user-provided event handlers would not be called. + // https://github.com/radix-ui/primitives/pull/1849 + send("ANIMATION_END"); + if (!prevPresentRef.current) { + const currentFillMode = node.style.animationFillMode; + node.style.animationFillMode = "forwards"; + // Reset the style after the node had time to unmount (for cases + // where the component chooses not to unmount). Doing this any + // sooner than `setTimeout` (e.g. with `requestAnimationFrame`) + // still causes a flash. + timeoutId = ownerWindow.setTimeout(() => { + if (node.style.animationFillMode === "forwards") { + node.style.animationFillMode = currentFillMode; + } + }); + } + } + }; + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === node) { + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.current = getAnimationName(stylesRef.current); + } + }; + node.addEventListener("animationstart", handleAnimationStart); + node.addEventListener("animationcancel", handleAnimationEnd); + node.addEventListener("animationend", handleAnimationEnd); + return () => { + ownerWindow.clearTimeout(timeoutId); + node.removeEventListener("animationstart", handleAnimationStart); + node.removeEventListener("animationcancel", handleAnimationEnd); + node.removeEventListener("animationend", handleAnimationEnd); + }; + } else { + // Transition to the unmounted state if the node is removed prematurely. + // We avoid doing so during cleanup as the node may change but still exist. + send("ANIMATION_END"); + } + }, [node, send]); + + return { + isPresent: ["mounted", "unmountSuspended"].includes(state), + ref: React.useCallback((node: HTMLElement) => { + if (node) stylesRef.current = getComputedStyle(node); + setNode(node); + }, []), + }; +} + +/* -----------------------------------------------------------------------------------------------*/ + +function getAnimationName(styles?: CSSStyleDeclaration) { + return styles?.animationName || "none"; +} + +type Machine = { [k: string]: { [k: string]: S } }; +type MachineState = keyof T; +type MachineEvent = keyof UnionToIntersection; + +// 🤯 https://fettblog.eu/typescript-union-to-intersection/ +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends (x: infer R) => any + ? R + : never; + +function useStateMachine(initialState: MachineState, machine: M & Machine>) { + return React.useReducer((state: MachineState, event: MachineEvent): MachineState => { + const nextState = (machine[state] as any)[event]; + return nextState ?? state; + }, initialState); +} diff --git a/packages/react-headless/dialog/src/private/usePresenceContext.tsx b/packages/react-headless/dialog/src/private/usePresenceContext.tsx new file mode 100644 index 000000000..c599261f8 --- /dev/null +++ b/packages/react-headless/dialog/src/private/usePresenceContext.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import type { UsePresenceReturn } from "./usePresence"; + +export interface UsePresenceContext extends UsePresenceReturn {} + +const PresenceContext = createContext(null); + +export const PresenceProvider = PresenceContext.Provider; + +export function usePresenceContext({ + strict = true, +}: { strict?: T } = {}): T extends false ? UsePresenceContext | null : UsePresenceContext { + const context = useContext(PresenceContext); + if (!context && strict) { + throw new Error("usePresenceContext must be used within a Presence"); + } + + return context as UsePresenceContext; +} diff --git a/packages/react-headless/dialog/src/useDialog.ts b/packages/react-headless/dialog/src/useDialog.ts new file mode 100644 index 000000000..18ce1a5bd --- /dev/null +++ b/packages/react-headless/dialog/src/useDialog.ts @@ -0,0 +1,135 @@ +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { buttonProps, dataAttr, elementProps } from "@seed-design/dom-utils"; +import { useId, useMemo } from "react"; + +export interface UseDialogStateProps { + open?: boolean; + + defaultOpen?: boolean; + + onOpenChange?: (open: boolean) => void; +} + +function useDialogState(props: UseDialogStateProps) { + const [open = false, onOpenChange] = useControllableState({ + prop: props.open, + defaultProp: props.defaultOpen, + onChange: props.onOpenChange, + }); + + return useMemo(() => ({ open, onOpenChange }), [open, onOpenChange]); +} + +export interface UseDialogProps extends UseDialogStateProps { + /** + * The role of the dialog. + * @default "dialog" + */ + role?: "dialog" | "alertdialog"; + + /** + * Whether to close the dialog when the outside is clicked + * @default true + */ + closeOnInteractOutside?: boolean; + + /** + * Whether to close the dialog when the escape key is pressed + * @default true + */ + closeOnEscape?: boolean; + + /** + * Whether to enable lazy mounting + * @default false + */ + lazyMount?: boolean; + /** + * Whether to unmount on exit. + * @default false + */ + unmountOnExit?: boolean; +} + +export type UseDialogReturn = ReturnType; + +export function useDialog(props: UseDialogProps = {}) { + const { open, onOpenChange } = useDialogState(props); + + const id = useId(); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + + const stateProps = useMemo( + () => + elementProps({ + "data-open": dataAttr(open), + "data-hidden": dataAttr(!open), + }), + [open], + ); + + return useMemo( + () => ({ + open, + setOpen: onOpenChange, + closeOnInteractOutside: props.closeOnInteractOutside ?? true, + closeOnEscape: props.closeOnEscape ?? true, + lazyMount: props.lazyMount ?? false, + unmountOnExit: props.unmountOnExit ?? false, + stateProps, + triggerProps: buttonProps({ + "aria-haspopup": "dialog", + "aria-expanded": open, + ...stateProps, + onClick: (e) => { + if (e.defaultPrevented) return; + onOpenChange(true); + }, + }), + positionerProps: elementProps({ + ...stateProps, + style: { + pointerEvents: open ? undefined : "none", + }, + }), + backdropProps: elementProps({ + ...stateProps, + }), + contentProps: elementProps({ + ...stateProps, + role: props.role ?? "dialog", + "aria-modal": true, + "aria-labelledby": titleId, + "aria-describedby": descriptionId, + }), + titleProps: elementProps({ + id: titleId, + ...stateProps, + }), + descriptionProps: elementProps({ + id: descriptionId, + ...stateProps, + }), + closeButtonProps: buttonProps({ + ...stateProps, + onClick: (e) => { + if (e.defaultPrevented) return; + onOpenChange(false); + }, + }), + }), + [ + open, + onOpenChange, + stateProps, + titleId, + descriptionId, + props.role, + props.closeOnInteractOutside, + props.closeOnEscape, + props.lazyMount, + props.unmountOnExit, + ], + ); +} diff --git a/packages/react-headless/dialog/src/useDialogContext.tsx b/packages/react-headless/dialog/src/useDialogContext.tsx new file mode 100644 index 000000000..3ab9ba327 --- /dev/null +++ b/packages/react-headless/dialog/src/useDialogContext.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import type { UseDialogReturn } from "./useDialog"; + +export interface UseDialogContext extends UseDialogReturn {} + +const DialogContext = createContext(null); + +export const DialogProvider = DialogContext.Provider; + +export function useDialogContext({ + strict = true, +}: { strict?: T } = {}): T extends false ? UseDialogContext | null : UseDialogContext { + const context = useContext(DialogContext); + if (!context && strict) { + throw new Error("useDialogContext must be used within a Dialog"); + } + + return context as UseDialogContext; +} diff --git a/packages/react-headless/dialog/tsconfig.json b/packages/react-headless/dialog/tsconfig.json new file mode 100644 index 000000000..59fbc0a08 --- /dev/null +++ b/packages/react-headless/dialog/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "Bundler", + "verbatimModuleSyntax": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "rootDir": "src", + "outDir": "lib", + "jsx": "react-jsx" + } +} diff --git a/packages/react-headless/primitive/src/index.tsx b/packages/react-headless/primitive/src/index.tsx index aa6743f3f..41ca8a219 100644 --- a/packages/react-headless/primitive/src/index.tsx +++ b/packages/react-headless/primitive/src/index.tsx @@ -35,6 +35,7 @@ export const Primitive = { textarea: createPrimitive("textarea"), a: createPrimitive("a"), p: createPrimitive("p"), + h2: createPrimitive("h2"), svg: createPrimitive("svg"), circle: createPrimitive("circle"), }; diff --git a/packages/react/package.json b/packages/react/package.json index c80e78ded..fb262372a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,7 @@ "@seed-design/dom-utils": "0.0.0-alpha-20241030023710", "@seed-design/react-avatar": "0.0.0-alpha-20241030023710", "@seed-design/react-checkbox": "0.0.0-alpha-20241030023710", + "@seed-design/react-dialog": "0.0.0", "@seed-design/react-popover": "0.0.0-alpha-20241030023710", "@seed-design/react-primitive": "0.0.0", "@seed-design/react-progress": "0.0.0", diff --git a/packages/react/src/components/Dialog/Dialog.namespace.ts b/packages/react/src/components/Dialog/Dialog.namespace.ts new file mode 100644 index 000000000..890d0fa62 --- /dev/null +++ b/packages/react/src/components/Dialog/Dialog.namespace.ts @@ -0,0 +1,22 @@ +export { + DialogBackdrop as Backdrop, + DialogPositioner as Positioner, + DialogContent as Content, + DialogDescription as Description, + DialogFooter as Footer, + DialogHeader as Header, + DialogRoot as Root, + DialogTitle as Title, + DialogTrigger as Trigger, + DialogAction as Action, + type DialogBackdropProps as BackdropProps, + type DialogPositionerProps as PositionerProps, + type DialogContentProps as ContentProps, + type DialogDescriptionProps as DescriptionProps, + type DialogFooterProps as FooterProps, + type DialogHeaderProps as HeaderProps, + type DialogRootProps as RootProps, + type DialogTitleProps as TitleProps, + type DialogTriggerProps as TriggerProps, + type DialogActionProps as ActionProps, +} from "./Dialog"; diff --git a/packages/react/src/components/Dialog/Dialog.tsx b/packages/react/src/components/Dialog/Dialog.tsx new file mode 100644 index 000000000..f7f616f2d --- /dev/null +++ b/packages/react/src/components/Dialog/Dialog.tsx @@ -0,0 +1,94 @@ +import { Dialog as DialogPrimitive, useDialogContext } from "@seed-design/react-dialog"; +import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive"; +import { dialog, type DialogVariantProps } from "@seed-design/recipe/dialog"; +import { forwardRef } from "react"; +import { createStyleContext } from "../../utils/createStyleContext"; +import { createWithStateProps } from "../../utils/createWithStateProps"; + +const { withRootProvider, withContext } = createStyleContext(dialog); +const withStateProps = createWithStateProps([useDialogContext]); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogRootProps extends DialogVariantProps, DialogPrimitive.RootProps {} + +export const DialogRoot = withRootProvider(DialogPrimitive.Root, { + defaultProps: { + lazyMount: true, + unmountOnExit: true, + }, +}); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogTriggerProps extends DialogPrimitive.TriggerProps {} + +export const DialogTrigger = DialogPrimitive.Trigger; + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogPositionerProps extends DialogPrimitive.PositionerProps {} + +export const DialogPositioner = withContext( + DialogPrimitive.Positioner, + "positioner", +); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogBackdropProps extends DialogPrimitive.BackdropProps {} + +export const DialogBackdrop = withContext( + DialogPrimitive.Backdrop, + "backdrop", +); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogContentProps extends DialogPrimitive.ContentProps {} + +export const DialogContent = withContext( + DialogPrimitive.Content, + "content", +); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogHeaderProps extends PrimitiveProps, React.HTMLAttributes {} + +export const DialogHeader = withContext(Primitive.div, "header"); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogTitleProps extends DialogPrimitive.TitleProps {} + +export const DialogTitle = withContext( + withStateProps(Primitive.span), + "title", +); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogDescriptionProps extends DialogPrimitive.DescriptionProps {} + +export const DialogDescription = withContext( + withStateProps(Primitive.div), + "description", +); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogFooterProps extends PrimitiveProps, React.HTMLAttributes {} + +export const DialogFooter = withContext(Primitive.div, "footer"); + +//////////////////////////////////////////////////////////////////////////////////// + +export interface DialogActionProps + extends PrimitiveProps, + React.HTMLAttributes {} + +export const DialogAction = forwardRef((props, ref) => { + const api = useDialogContext(); + return api.setOpen(false)} />; +}); diff --git a/packages/react/src/components/Dialog/index.ts b/packages/react/src/components/Dialog/index.ts new file mode 100644 index 000000000..066fdee84 --- /dev/null +++ b/packages/react/src/components/Dialog/index.ts @@ -0,0 +1,24 @@ +export { + DialogBackdrop, + DialogPositioner, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogRoot, + DialogTitle, + DialogTrigger, + DialogAction, + type DialogBackdropProps, + type DialogPositionerProps, + type DialogContentProps, + type DialogDescriptionProps, + type DialogFooterProps, + type DialogHeaderProps, + type DialogRootProps, + type DialogTitleProps, + type DialogTriggerProps, + type DialogActionProps, +} from "./Dialog"; + +export * as Dialog from "./Dialog.namespace"; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 587227552..036e6ba95 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,6 +7,7 @@ export * from "./Callout"; export * from "./Checkbox"; export * from "./Columns"; export * from "./ControlChip"; +export * from "./Dialog"; export * from "./ExtendedFab"; export * from "./Fab"; export * from "./Flex"; diff --git a/packages/recipe/lib/dialog.d.ts b/packages/recipe/lib/dialog.d.ts index b7f74fca0..86bf520a2 100644 --- a/packages/recipe/lib/dialog.d.ts +++ b/packages/recipe/lib/dialog.d.ts @@ -1,8 +1,5 @@ declare interface DialogVariant { - /** - * @default horizontal - */ - footerLayout: "horizontal" | "vertical"; + } declare type DialogVariantMap = { @@ -11,7 +8,7 @@ declare type DialogVariantMap = { export declare type DialogVariantProps = Partial; -export declare type DialogSlotName = "backdrop" | "container" | "content" | "header" | "footer" | "action" | "title" | "description"; +export declare type DialogSlotName = "positioner" | "backdrop" | "content" | "header" | "footer" | "action" | "title" | "description"; export declare const dialogVariantMap: DialogVariantMap; diff --git a/packages/recipe/lib/dialog.mjs b/packages/recipe/lib/dialog.mjs index bc720d01c..9df45d6a2 100644 --- a/packages/recipe/lib/dialog.mjs +++ b/packages/recipe/lib/dialog.mjs @@ -4,12 +4,12 @@ import { splitVariantProps } from "./splitVariantProps.mjs"; const dialogSlotNames = [ [ - "backdrop", - "dialog__backdrop" + "positioner", + "dialog__positioner" ], [ - "container", - "dialog__container" + "backdrop", + "dialog__backdrop" ], [ "content", @@ -37,18 +37,11 @@ const dialogSlotNames = [ ] ]; -const defaultVariant = { - "footerLayout": "horizontal" -}; +const defaultVariant = {}; const compoundVariants = []; -export const dialogVariantMap = { - "footerLayout": [ - "horizontal", - "vertical" - ] -}; +export const dialogVariantMap = {}; export const dialogVariantKeys = Object.keys(dialogVariantMap); diff --git a/packages/stackflow/src/Dialog.tsx b/packages/stackflow/src/Dialog.tsx index 572378b9c..3af61f563 100644 --- a/packages/stackflow/src/Dialog.tsx +++ b/packages/stackflow/src/Dialog.tsx @@ -143,7 +143,7 @@ export const DialogRoot = forwardRef((props, re ref={ref} role={role} data-stackflow-component-name="Dialog" - className={clsx(classNames.container, className)} + className={clsx(classNames.positioner, className)} {...otherProps} > {children} diff --git a/packages/stylesheet/dialog.css b/packages/stylesheet/dialog.css index 537b7d902..45d764686 100644 --- a/packages/stylesheet/dialog.css +++ b/packages/stylesheet/dialog.css @@ -1,19 +1,20 @@ +.dialog__positioner { + position: fixed; + display: flex; + justify-content: center; + align-items: center; + inset: 0; + overscroll-behavior-y: none; + --dialog-z-index: 2; + z-index: calc(var(--dialog-z-index) + var(--layer-index, 0)); +} .dialog__backdrop { position: fixed; inset: 0; background: var(--seed-v3-color-bg-overlay); + z-index: calc(var(--dialog-z-index) + var(--layer-index, 0)); } -.dialog__backdrop:is([data-transition-state='exit-active'],[data-transition-state='exit-done']) { - animation: seed-exit; - animation-timing-function: var(--seed-v3-timing-function-exit); - animation-duration: var(--seed-v3-duration-s2); - animation-fill-mode: forwards; - --seed-exit-translate-x: 0; - --seed-exit-translate-y: 0; - --seed-exit-opacity: 0; - --seed-exit-scale: 1; -} -.dialog__backdrop:is([data-transition-state='enter-active'],[data-transition-state='enter-done']) { +.dialog__backdrop:is([data-state="open"], [data-open]) { animation: seed-enter; animation-timing-function: var(--seed-v3-timing-function-enter); animation-duration: var(--seed-v3-duration-s2); @@ -22,12 +23,15 @@ --seed-enter-opacity: 0; --seed-enter-scale: 1; } -.dialog__container { - position: fixed; - display: flex; - justify-content: center; - align-items: center; - inset: 0; +.dialog__backdrop:not(:is([data-state="open"], [data-open])) { + animation: seed-exit; + animation-timing-function: var(--seed-v3-timing-function-exit); + animation-duration: var(--seed-v3-duration-s2); + animation-fill-mode: forwards; + --seed-exit-translate-x: 0; + --seed-exit-translate-y: 0; + --seed-exit-opacity: 0; + --seed-exit-scale: 1; } .dialog__content { position: relative; @@ -36,13 +40,23 @@ flex-direction: column; box-sizing: border-box; word-break: break-all; + z-index: calc(var(--dialog-z-index) + var(--layer-index, 0)); background: var(--seed-v3-color-bg-layer-default); max-width: 272px; margin: auto var(--seed-v3-dimension-s8); padding: var(--seed-v3-dimension-s5) var(--seed-v3-dimension-s5); border-radius: var(--seed-v3-radius-s5); } -.dialog__content:is([data-transition-state='exit-active'],[data-transition-state='exit-done']) { +.dialog__content:is([data-state="open"], [data-open]) { + animation: seed-enter; + animation-timing-function: var(--seed-v3-timing-function-enter-expressive); + animation-duration: var(--seed-v3-duration-s4); + --seed-enter-translate-x: 0; + --seed-enter-translate-y: 0; + --seed-enter-opacity: 0; + --seed-enter-scale: 1.3; +} +.dialog__content:not(:is([data-state="open"], [data-open])) { animation: seed-exit; animation-timing-function: var(--seed-v3-timing-function-exit); animation-duration: var(--seed-v3-duration-s2); @@ -52,15 +66,6 @@ --seed-exit-opacity: 0; --seed-exit-scale: 1; } -.dialog__content:is([data-transition-state='enter-active'],[data-transition-state='enter-done']) { - animation: seed-enter; - animation-timing-function: var(--seed-v3-timing-function-enter-expressive); - animation-duration: var(--seed-v3-duration-s4); - --seed-enter-translate-x: 0; - --seed-enter-translate-y: 0; - --seed-enter-opacity: 0; - --seed-enter-scale: 1.3; -} .dialog__header { display: flex; flex-direction: column; @@ -83,19 +88,7 @@ } .dialog__footer { display: flex; - flex-wrap: wrap; - justify-content: space-between; + flex-direction: column; align-items: stretch; padding-top: var(--seed-v3-dimension-s4); - gap: var(--seed-v3-dimension-s2); -} -.dialog__action { - width: initial; - min-width: calc(50% - var(--seed-v3-dimension-s2) / 2); -} -.dialog__footer--footerLayout_horizontal { - flex-direction: row-reverse; -} -.dialog__footer--footerLayout_vertical { - flex-direction: column; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d84813880..9b38c54de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6006,6 +6006,13 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/primitive@npm:1.1.1" + checksum: 10/d7e819177590108b74139809d52ec043c0962ae3513e947998be575fb13639c5c1c091896ddcf1d6a22a777d44ade59d22c2019ce9099607fc62a5de09c59707 + languageName: node + linkType: hard + "@radix-ui/react-accordion@npm:^1.2.1": version: 1.2.1 resolution: "@radix-ui/react-accordion@npm:1.2.1" @@ -6220,6 +6227,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dismissable-layer@npm:^1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.3" + dependencies: + "@radix-ui/primitive": "npm:1.1.1" + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.1" + "@radix-ui/react-use-callback-ref": "npm:1.1.0" + "@radix-ui/react-use-escape-keydown": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/9905ff3d8d630223fd40bf31cdd8027b6e750cecd31aa04c2a5912e6e628f72973e58032bb944a5f4685dd888256a306a1c296a6e18648187974455a9660d95f + languageName: node + linkType: hard + "@radix-ui/react-focus-guards@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-focus-guards@npm:1.1.1" @@ -6254,6 +6284,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:^1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-focus-scope@npm:1.1.1" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.1" + "@radix-ui/react-use-callback-ref": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/128508e7e34a47fd44d51bdb3d66a35a337c54b64125548d4a98bb377ee89b2fd8f96e0a075368d393c6664abba1e5a2f167734a6adbb170c41da0aa7a06d05f + languageName: node + linkType: hard + "@radix-ui/react-id@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-id@npm:1.1.0" @@ -6421,6 +6472,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.0.1": + version: 2.0.1 + resolution: "@radix-ui/react-primitive@npm:2.0.1" + dependencies: + "@radix-ui/react-slot": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/ed6829b8ff4117cde2c02b14325ff78b7902fe9e8324b9fdbfd11646c5bb703f38711d8da5029ffc873384496481b7d398d0e3c17f7cc287b52fb92fbaf67da2 + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-roving-focus@npm:1.1.0" @@ -6490,7 +6560,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:^1.1.1": +"@radix-ui/react-slot@npm:1.1.1, @radix-ui/react-slot@npm:^1.1.1": version: 1.1.1 resolution: "@radix-ui/react-slot@npm:1.1.1" dependencies: @@ -7743,6 +7813,22 @@ __metadata: languageName: unknown linkType: soft +"@seed-design/react-dialog@npm:0.0.0, @seed-design/react-dialog@workspace:packages/react-headless/dialog": + version: 0.0.0-use.local + resolution: "@seed-design/react-dialog@workspace:packages/react-headless/dialog" + dependencies: + "@radix-ui/react-dismissable-layer": "npm:^1.1.3" + "@radix-ui/react-focus-scope": "npm:^1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + "@radix-ui/react-use-layout-effect": "npm:^1.1.0" + "@seed-design/dom-utils": "npm:0.0.0-alpha-20241030023710" + nanobundle: "npm:^1.6.0" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + languageName: unknown + linkType: soft + "@seed-design/react-icon@npm:^0.7.3": version: 0.7.3 resolution: "@seed-design/react-icon@npm:0.7.3" @@ -7921,6 +8007,7 @@ __metadata: "@seed-design/dom-utils": "npm:0.0.0-alpha-20241030023710" "@seed-design/react-avatar": "npm:0.0.0-alpha-20241030023710" "@seed-design/react-checkbox": "npm:0.0.0-alpha-20241030023710" + "@seed-design/react-dialog": "npm:0.0.0" "@seed-design/react-popover": "npm:0.0.0-alpha-20241030023710" "@seed-design/react-primitive": "npm:0.0.0" "@seed-design/react-progress": "npm:0.0.0" @@ -7993,6 +8080,7 @@ __metadata: dependencies: "@daangn/react-monochrome-icon": "npm:^0.0.13" "@radix-ui/react-slot": "npm:^1.1.1" + "@radix-ui/react-use-callback-ref": "npm:^1.1.0" "@seed-design/cli": "npm:0.0.0-alpha-20241204134404" "@seed-design/react": "npm:0.0.0" "@seed-design/react-popover": "npm:0.0.0-alpha-20241030023710" @@ -8002,6 +8090,7 @@ __metadata: "@seed-design/stackflow": "npm:0.0.0" "@seed-design/stylesheet": "npm:3.0.0-alpha-20241212122822" "@seed-design/vars": "npm:0.0.0" + "@stackflow/compat-await-push": "npm:^1.1.13" "@stackflow/core": "npm:^1.1.0" "@stackflow/plugin-basic-ui": "npm:^1.11.1" "@stackflow/plugin-history-sync": "npm:^1.7.0" @@ -8336,6 +8425,18 @@ __metadata: languageName: node linkType: hard +"@stackflow/compat-await-push@npm:^1.1.13": + version: 1.1.13 + resolution: "@stackflow/compat-await-push@npm:1.1.13" + peerDependencies: + "@stackflow/core": ^1.1.0-canary.0 + "@stackflow/react": ^1.3.2-canary.0 + "@types/react": ">=16.8.0" + react: ">=16.8.0" + checksum: 10/ca498d65533f88ce88b70b3f58806397b4c9fcea2857ce085ae3e506cbb9083ab2cb7951deac5ca92f78fbeddc8cc48180b3bf9d27ef175cda88f37b65c7e05b + languageName: node + linkType: hard + "@stackflow/config@npm:^1.2.0": version: 1.2.0 resolution: "@stackflow/config@npm:1.2.0"