@tide-tds/components 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Popover.d.ts +43 -0
- package/dist/Popover.js +147 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/overlay-style.js +35 -0
- package/dist/overlay.d.ts +7 -0
- package/dist/overlay.js +106 -0
- package/package.json +3 -3
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { OverlayPlacement } from "./overlay.js";
|
|
2
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
/** Popover 내용의 위치. 앞은 트리거의 어느 쪽, 뒤는 그 변에서 맞출 끝이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
4
|
+
export type PopoverPlacement = OverlayPlacement;
|
|
5
|
+
export interface PopoverProps {
|
|
6
|
+
/** 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/** 처음 열림 상태(비제어). 기본값은 `false`다. */
|
|
9
|
+
defaultOpen?: boolean;
|
|
10
|
+
/** 열림 상태가 바뀌려 할 때 새 값으로 부른다. */
|
|
11
|
+
onOpenChange?: (open: boolean) => void;
|
|
12
|
+
/** `PopoverTrigger`와 `PopoverContent` */
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
|
|
17
|
+
* 안에 `PopoverTrigger` 하나와 `PopoverContent` 하나를 둔다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function Popover({ open, defaultOpen, onOpenChange, children }: PopoverProps): ReactElement;
|
|
20
|
+
export interface PopoverTriggerProps extends ComponentProps<"button"> {
|
|
21
|
+
/**
|
|
22
|
+
* 다른 요소로 렌더한다. 예: `render={<Button variant="outline" />}`, `render={<IconButton aria-label="설정" />}`.
|
|
23
|
+
* props, ref, className, 이벤트 핸들러를 그 요소에 병합한다. 기본은 class 없는 `<button type="button">`이다.
|
|
24
|
+
*/
|
|
25
|
+
render?: ReactElement;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Popover를 여닫는 버튼. 누르면(마우스, 터치, Enter, Space) 열림 상태를 뒤집는다.
|
|
29
|
+
* `aria-expanded`, 열렸을 때 `aria-controls`, 열렸을 때 `data-open`이 붙는다.
|
|
30
|
+
*/
|
|
31
|
+
export declare function PopoverTrigger({ render, ref, id, className, ...props }: PopoverTriggerProps): ReactElement;
|
|
32
|
+
export interface PopoverContentProps extends Omit<ComponentProps<"div">, "id" | "role"> {
|
|
33
|
+
/** 트리거 기준 위치. 기본값은 `bottom`(아래, 가운데 맞춤)이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
|
|
34
|
+
placement?: PopoverPlacement;
|
|
35
|
+
/** 트리거와의 간격(px). 기본값은 `8`이다. */
|
|
36
|
+
offset?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 트리거 옆에 뜨는 내용(`role="dialog"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
|
|
40
|
+
* 등장은 opacity + blur 4px + 4px 이동(`normal`), 퇴장은 그 절반(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
|
|
41
|
+
* 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function PopoverContent(props: PopoverContentProps): ReactElement | null;
|
package/dist/Popover.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { OverlayPortal, useOverlayPresence, useOverlayTheme, usePopoverOverlay } from "./overlay.js";
|
|
5
|
+
import { overlaySurfaceClass, popoverMotionClass } from "./overlay-style.js";
|
|
6
|
+
import { renderElement } from "./render.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { createContext, createElement, use, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { DismissButton, mergeProps, mergeRefs, useDialog, useOverlayTrigger, usePress } from "react-aria";
|
|
10
|
+
import { useOverlayTriggerState } from "react-stately";
|
|
11
|
+
|
|
12
|
+
// src/Popover.tsx
|
|
13
|
+
const PopoverContext = createContext(null);
|
|
14
|
+
function usePopoverContext(part) {
|
|
15
|
+
const context = use(PopoverContext);
|
|
16
|
+
if (!context) throw new Error(`${part}는 Popover 안에 둔다.`);
|
|
17
|
+
return context;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
|
|
21
|
+
* 안에 `PopoverTrigger` 하나와 `PopoverContent` 하나를 둔다.
|
|
22
|
+
*/
|
|
23
|
+
function Popover({ open, defaultOpen, onOpenChange, children }) {
|
|
24
|
+
const state = useOverlayTriggerState({
|
|
25
|
+
isOpen: open,
|
|
26
|
+
defaultOpen,
|
|
27
|
+
onOpenChange
|
|
28
|
+
});
|
|
29
|
+
const triggerRef = useRef(null);
|
|
30
|
+
const { triggerProps, overlayProps } = useOverlayTrigger({ type: "dialog" }, state, triggerRef);
|
|
31
|
+
const generatedTriggerId = useId();
|
|
32
|
+
const [triggerId, setTriggerId] = useState(generatedTriggerId);
|
|
33
|
+
const value = useMemo(() => ({
|
|
34
|
+
state,
|
|
35
|
+
triggerRef,
|
|
36
|
+
triggerProps,
|
|
37
|
+
contentId: overlayProps.id,
|
|
38
|
+
triggerId,
|
|
39
|
+
setTriggerId
|
|
40
|
+
}), [
|
|
41
|
+
state,
|
|
42
|
+
triggerProps,
|
|
43
|
+
overlayProps.id,
|
|
44
|
+
triggerId
|
|
45
|
+
]);
|
|
46
|
+
return /* @__PURE__ */ jsx(PopoverContext, {
|
|
47
|
+
value,
|
|
48
|
+
children
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Popover를 여닫는 버튼. 누르면(마우스, 터치, Enter, Space) 열림 상태를 뒤집는다.
|
|
53
|
+
* `aria-expanded`, 열렸을 때 `aria-controls`, 열렸을 때 `data-open`이 붙는다.
|
|
54
|
+
*/
|
|
55
|
+
function PopoverTrigger({ render, ref, id, className, ...props }) {
|
|
56
|
+
const { state, triggerRef, triggerProps, triggerId, setTriggerId } = usePopoverContext("PopoverTrigger");
|
|
57
|
+
const { onPress, ...ariaProps } = triggerProps;
|
|
58
|
+
const { pressProps } = usePress({
|
|
59
|
+
onPress,
|
|
60
|
+
ref: triggerRef
|
|
61
|
+
});
|
|
62
|
+
const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
|
|
63
|
+
const resolvedId = id ?? triggerId;
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (resolvedId !== triggerId) setTriggerId(resolvedId);
|
|
66
|
+
}, [
|
|
67
|
+
resolvedId,
|
|
68
|
+
triggerId,
|
|
69
|
+
setTriggerId
|
|
70
|
+
]);
|
|
71
|
+
const merged = {
|
|
72
|
+
...mergeProps(props, ariaProps, pressProps),
|
|
73
|
+
id: resolvedId,
|
|
74
|
+
ref: mergedRef,
|
|
75
|
+
"data-open": state.isOpen ? "" : void 0
|
|
76
|
+
};
|
|
77
|
+
if (!render) return createElement("button", {
|
|
78
|
+
type: "button",
|
|
79
|
+
...merged,
|
|
80
|
+
className
|
|
81
|
+
});
|
|
82
|
+
return renderElement({
|
|
83
|
+
defaultElement: "button",
|
|
84
|
+
render,
|
|
85
|
+
baseClassName: "",
|
|
86
|
+
props: {
|
|
87
|
+
...merged,
|
|
88
|
+
className
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const contentClass = `isolate z-popover max-w-80 overflow-auto p-4 text-body-md outline-none ${overlaySurfaceClass} ${popoverMotionClass}`;
|
|
93
|
+
/**
|
|
94
|
+
* 트리거 옆에 뜨는 내용(`role="dialog"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
|
|
95
|
+
* 등장은 opacity + blur 4px + 4px 이동(`normal`), 퇴장은 그 절반(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
|
|
96
|
+
* 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
|
|
97
|
+
*/
|
|
98
|
+
function PopoverContent(props) {
|
|
99
|
+
const context = usePopoverContext("PopoverContent");
|
|
100
|
+
const popoverRef = useRef(null);
|
|
101
|
+
if (!useOverlayPresence(context.state.isOpen, popoverRef)) return null;
|
|
102
|
+
return /* @__PURE__ */ jsx(OverlayPortal, {
|
|
103
|
+
isExiting: !context.state.isOpen,
|
|
104
|
+
children: /* @__PURE__ */ jsx(PopoverContentInner, {
|
|
105
|
+
...props,
|
|
106
|
+
context,
|
|
107
|
+
popoverRef
|
|
108
|
+
})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** DOM에 있는 동안만 portal 안에서 렌더한다. React Aria 오버레이 hook은 요소가 있다고 보고 동작한다. */
|
|
112
|
+
function PopoverContentInner({ context, popoverRef, placement = "bottom", offset = 8, ref, className, children, ...props }) {
|
|
113
|
+
const { state, triggerRef, contentId, triggerId } = context;
|
|
114
|
+
useOverlayTheme(triggerRef, popoverRef);
|
|
115
|
+
const { overlayProps } = usePopoverOverlay({
|
|
116
|
+
triggerRef,
|
|
117
|
+
popoverRef,
|
|
118
|
+
placement,
|
|
119
|
+
offset
|
|
120
|
+
}, state);
|
|
121
|
+
const { dialogProps } = useDialog({
|
|
122
|
+
"aria-label": props["aria-label"],
|
|
123
|
+
"aria-labelledby": props["aria-labelledby"]
|
|
124
|
+
}, popoverRef);
|
|
125
|
+
const labelledBy = dialogProps["aria-labelledby"] ?? (props["aria-label"] ? void 0 : triggerId);
|
|
126
|
+
const mergedRef = useMemo(() => mergeRefs(ref, popoverRef), [ref, popoverRef]);
|
|
127
|
+
const { style: _style, ...rest } = mergeProps(props, overlayProps, dialogProps);
|
|
128
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
129
|
+
...rest,
|
|
130
|
+
id: contentId,
|
|
131
|
+
ref: mergedRef,
|
|
132
|
+
"aria-labelledby": labelledBy,
|
|
133
|
+
"data-open": state.isOpen ? "" : void 0,
|
|
134
|
+
className: cn(contentClass, className),
|
|
135
|
+
style: {
|
|
136
|
+
...overlayProps.style,
|
|
137
|
+
...props.style
|
|
138
|
+
},
|
|
139
|
+
children: [
|
|
140
|
+
/* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close }),
|
|
141
|
+
children,
|
|
142
|
+
/* @__PURE__ */ jsx(DismissButton, { onDismiss: state.close })
|
|
143
|
+
]
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { Popover, PopoverContent, PopoverTrigger };
|
package/dist/index.d.ts
CHANGED
|
@@ -23,5 +23,6 @@ import { Progress, ProgressProps } from "./Progress.js";
|
|
|
23
23
|
import { Table, TableBody, TableBodyProps, TableCell, TableCellProps, TableHead, TableHeadProps, TableHeaderCell, TableHeaderCellProps, TableProps, TableRow, TableRowProps } from "./Table.js";
|
|
24
24
|
import { Breadcrumb, BreadcrumbCurrent, BreadcrumbCurrentProps, BreadcrumbItem, BreadcrumbItemProps, BreadcrumbLink, BreadcrumbLinkProps, BreadcrumbProps } from "./Breadcrumb.js";
|
|
25
25
|
import { Pagination, PaginationEllipsis, PaginationEllipsisProps, PaginationItem, PaginationItemProps, PaginationLink, PaginationLinkProps, PaginationNext, PaginationNextProps, PaginationPrevious, PaginationPreviousProps, PaginationProps } from "./Pagination.js";
|
|
26
|
+
import { Popover, PopoverContent, PopoverContentProps, PopoverPlacement, PopoverProps, PopoverTrigger, PopoverTriggerProps } from "./Popover.js";
|
|
26
27
|
import { Color, ColorFamilyName, ColorFamilyRegistry, FillVariant, Role, RoleRegistry, Size } from "./types.js";
|
|
27
|
-
export { Alert, type AlertProps, Avatar, type AvatarProps, Badge, type BadgeProps, Breadcrumb, BreadcrumbCurrent, type BreadcrumbCurrentProps, BreadcrumbItem, type BreadcrumbItemProps, BreadcrumbLink, type BreadcrumbLinkProps, type BreadcrumbProps, Button, type ButtonProps, Card, type CardProps, Checkbox, CheckboxGroup, type CheckboxGroupProps, type CheckboxProps, type ChoiceOrientation, type Color, type ColorFamilyName, type ColorFamilyRegistry, Field, type FieldProps, type FillVariant, IconButton, type IconButtonProps, Input, type InputProps, Link, type LinkProps, Pagination, PaginationEllipsis, type PaginationEllipsisProps, PaginationItem, type PaginationItemProps, PaginationLink, type PaginationLinkProps, PaginationNext, type PaginationNextProps, PaginationPrevious, type PaginationPreviousProps, type PaginationProps, Progress, type ProgressProps, Radio, RadioGroup, type RadioGroupProps, type RadioProps, type Role, type RoleRegistry, Separator, type SeparatorProps, type Size, Skeleton, type SkeletonProps, Spinner, type SpinnerProps, Switch, type SwitchProps, Table, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableHead, type TableHeadProps, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, Textarea, type TextareaProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
|
|
28
|
+
export { Alert, type AlertProps, Avatar, type AvatarProps, Badge, type BadgeProps, Breadcrumb, BreadcrumbCurrent, type BreadcrumbCurrentProps, BreadcrumbItem, type BreadcrumbItemProps, BreadcrumbLink, type BreadcrumbLinkProps, type BreadcrumbProps, Button, type ButtonProps, Card, type CardProps, Checkbox, CheckboxGroup, type CheckboxGroupProps, type CheckboxProps, type ChoiceOrientation, type Color, type ColorFamilyName, type ColorFamilyRegistry, Field, type FieldProps, type FillVariant, IconButton, type IconButtonProps, Input, type InputProps, Link, type LinkProps, Pagination, PaginationEllipsis, type PaginationEllipsisProps, PaginationItem, type PaginationItemProps, PaginationLink, type PaginationLinkProps, PaginationNext, type PaginationNextProps, PaginationPrevious, type PaginationPreviousProps, type PaginationProps, Popover, PopoverContent, type PopoverContentProps, type PopoverPlacement, type PopoverProps, PopoverTrigger, type PopoverTriggerProps, Progress, type ProgressProps, Radio, RadioGroup, type RadioGroupProps, type RadioProps, type Role, type RoleRegistry, Separator, type SeparatorProps, type Size, Skeleton, type SkeletonProps, Spinner, type SpinnerProps, Switch, type SwitchProps, Table, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableHead, type TableHeadProps, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, Textarea, type TextareaProps, Typography, type TypographyProps, VisuallyHidden, type VisuallyHiddenProps, cn };
|
package/dist/index.js
CHANGED
|
@@ -23,5 +23,6 @@ import { Progress } from "./Progress.js";
|
|
|
23
23
|
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table.js";
|
|
24
24
|
import { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink } from "./Breadcrumb.js";
|
|
25
25
|
import { Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "./Pagination.js";
|
|
26
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover.js";
|
|
26
27
|
|
|
27
|
-
export { Alert, Avatar, Badge, Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, Button, Card, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, Progress, Radio, RadioGroup, Separator, Skeleton, Spinner, Switch, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Textarea, Typography, VisuallyHidden, cn };
|
|
28
|
+
export { Alert, Avatar, Badge, Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, Button, Card, Checkbox, CheckboxGroup, Field, IconButton, Input, Link, Pagination, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, Popover, PopoverContent, PopoverTrigger, Progress, Radio, RadioGroup, Separator, Skeleton, Spinner, Switch, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Textarea, Typography, VisuallyHidden, cn };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/overlay-style.ts
|
|
2
|
+
/**
|
|
3
|
+
* 오버레이 표면. raised 표면(`bg-raised`), `shadow-md`, radius `lg`, 경계 `border-raised`(light 투명 / dark gray-800).
|
|
4
|
+
* - 글자는 `text-default`다. `text-default × bg-raised` 4.5:1이 등록되어 있다.
|
|
5
|
+
* - `border-raised`는 장식용 경계라 대비 검사에서 빠진다. light의 투명 border는 forced-colors에서 CanvasText로 칠해져
|
|
6
|
+
* 그림자 없이도 경계가 남는다(spec Elevation, 사용자 이야기 37). forced-colors에서는 브라우저가 box-shadow를 지운다.
|
|
7
|
+
*/
|
|
8
|
+
const overlaySurfaceClass = "rounded-lg border border-raised bg-raised text-default shadow-md";
|
|
9
|
+
/**
|
|
10
|
+
* 「가끔 씀」 오버레이의 등장·퇴장(spec 「motion 적용」). 요소에 `data-open`(열림)과 `data-placement`(트리거 기준 방향,
|
|
11
|
+
* `top`·`bottom`·`left`·`right`)가 있어야 한다.
|
|
12
|
+
*
|
|
13
|
+
* - 등장: `@starting-style`(`starting:`)에서 opacity 0 + blur 4px(`blur-xs`) + 트리거 쪽으로 4px(`*-translate-*-1`)
|
|
14
|
+
* 떨어진 곳에서 시작해 `normal`(300ms)로 제자리에 온다.
|
|
15
|
+
* - 퇴장: `data-open`이 빠지면 같은 조합의 절반(blur 2px, 2px 이동)으로 `fast`(150ms)에 사라진다.
|
|
16
|
+
* duration은 도착 상태의 값을 쓰므로 기본 `duration-fast`에 열림만 `data-open:duration-normal`이다. easing은 둘 다 out이다.
|
|
17
|
+
* - 닫힌 상태는 `display: none`(`hidden`)이고 `transition-discrete`(`transition-behavior: allow-discrete`)로 display도
|
|
18
|
+
* 전환 대상에 넣어, 퇴장 전환이 끝날 때 사라진다. 이 둘을 지원하지 않는 브라우저에서는 바로 나타나고 사라진다.
|
|
19
|
+
* 요소를 DOM에서 빼는 시점은 `useOverlayPresence`가 전환이 끝나기를 기다려 정한다.
|
|
20
|
+
* - 퇴장 중에는 `pointer-events-none`으로 눌리지 않는다.
|
|
21
|
+
* - reduced-motion: 이동·blur와 그 전환 대상(filter, translate)은 `motion-safe:`일 때만 둔다. 움직임 줄이기에서는
|
|
22
|
+
* opacity(와 display)만 같은 시간으로 바뀐다.
|
|
23
|
+
* - `transition-all`은 쓰지 않는다. 전환 대상을 적는다.
|
|
24
|
+
*/
|
|
25
|
+
const popoverMotionClass = [
|
|
26
|
+
"transition-[opacity,display] transition-discrete duration-fast ease-out data-open:duration-normal motion-safe:transition-[opacity,filter,translate,display]",
|
|
27
|
+
"not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0 motion-safe:not-data-open:blur-[2px]",
|
|
28
|
+
"motion-safe:not-data-open:data-[placement=bottom]:-translate-y-0.5 motion-safe:not-data-open:data-[placement=top]:translate-y-0.5",
|
|
29
|
+
"motion-safe:not-data-open:data-[placement=right]:-translate-x-0.5 motion-safe:not-data-open:data-[placement=left]:translate-x-0.5",
|
|
30
|
+
"starting:opacity-0 motion-safe:starting:blur-xs",
|
|
31
|
+
"motion-safe:data-[placement=bottom]:starting:-translate-y-1 motion-safe:data-[placement=top]:starting:translate-y-1",
|
|
32
|
+
"motion-safe:data-[placement=right]:starting:-translate-x-1 motion-safe:data-[placement=left]:starting:translate-x-1"
|
|
33
|
+
].join(" ");
|
|
34
|
+
|
|
35
|
+
export { overlaySurfaceClass, popoverMotionClass };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import "react";
|
|
2
|
+
import "react-stately";
|
|
3
|
+
/**
|
|
4
|
+
* 트리거 기준 위치. 앞은 트리거의 어느 쪽에 놓을지(주축), 뒤는 그 변에서 어디에 맞출지(교차축)다.
|
|
5
|
+
* 공간이 모자라면 반대쪽으로 뒤집는다(React Aria `shouldFlip` 기본값).
|
|
6
|
+
*/
|
|
7
|
+
export type OverlayPlacement = "top" | "top start" | "top end" | "bottom" | "bottom start" | "bottom end" | "left" | "left top" | "left bottom" | "right" | "right top" | "right bottom";
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { useLayoutEffect, useState } from "react";
|
|
5
|
+
import { Overlay, usePopover } from "react-aria";
|
|
6
|
+
|
|
7
|
+
// src/overlay.tsx
|
|
8
|
+
const isAxis = (value) => value === "top" || value === "bottom" || value === "left" || value === "right";
|
|
9
|
+
/**
|
|
10
|
+
* 열림 상태가 꺼져도 퇴장 전환이 끝날 때까지 오버레이를 DOM에 남긴다. DOM에 둘지(`true`)를 돌려준다.
|
|
11
|
+
*
|
|
12
|
+
* 닫힌 뒤 요소의 `getAnimations()`(CSS transition 포함)가 모두 끝나면 뺀다. 전환이 없으면(지원하지 않는 브라우저,
|
|
13
|
+
* 전환이 없는 오버레이) 바로 뺀다. 퇴장 중에 다시 열리면 빼지 않는다. React Aria hooks에는 퇴장을 기다리는 hook이
|
|
14
|
+
* 없어서(react-aria-components의 `isExiting`은 Component 층에만 있다) 이 hook이 그 일을 한다.
|
|
15
|
+
*/
|
|
16
|
+
function useOverlayPresence(isOpen, ref) {
|
|
17
|
+
const [present, setPresent] = useState(isOpen);
|
|
18
|
+
if (isOpen && !present) setPresent(true);
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
if (isOpen || !present) return;
|
|
21
|
+
const element = ref.current;
|
|
22
|
+
if (!element || typeof element.getAnimations !== "function") {
|
|
23
|
+
setPresent(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let cancelled = false;
|
|
27
|
+
const waitForExit = () => {
|
|
28
|
+
const animations = element.getAnimations();
|
|
29
|
+
if (animations.length === 0) {
|
|
30
|
+
setPresent(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
|
|
34
|
+
if (!cancelled) waitForExit();
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
waitForExit();
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, [
|
|
42
|
+
isOpen,
|
|
43
|
+
present,
|
|
44
|
+
ref
|
|
45
|
+
]);
|
|
46
|
+
return isOpen || present;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* React Aria가 위치 style에 넣는 `zIndex: 100000`을 빼고 주축 방향을 `data-placement`로 붙인다.
|
|
50
|
+
* 쌓임은 쌓임 단계 class(`z-popover` 등)로만 정한다(spec 「오버레이」).
|
|
51
|
+
*
|
|
52
|
+
* `placement`가 아직 없으면(처음 렌더) 요청한 placement의 주축을 쓴다. 등장 전환의 시작 방향이 첫 스타일 계산에서
|
|
53
|
+
* 정해지기 때문이다. 공간이 없어 뒤집힌 경우에만 첫 프레임의 시작 방향이 요청한 쪽이 된다(4px).
|
|
54
|
+
*/
|
|
55
|
+
function overlayPositionProps(props, placement, requested) {
|
|
56
|
+
const { zIndex: _zIndex, ...style } = props.style ?? {};
|
|
57
|
+
const fallback = requested.split(" ")[0];
|
|
58
|
+
return {
|
|
59
|
+
...props,
|
|
60
|
+
style,
|
|
61
|
+
"data-placement": isAxis(placement) ? placement : fallback
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 트리거에 붙는 오버레이(Popover, Menu, Select·Combobox 목록)의 위치·닫기 동작. React Aria `usePopover`를 감싼다.
|
|
66
|
+
* - 닫기: Esc, 바깥 누름, 포커스가 바깥으로 나감(`usePopover` 기본). 위에 쌓인 오버레이부터 닫힌다.
|
|
67
|
+
* - 모달: 기본은 바깥을 `inert`로 막고 스크롤을 잠근다. `nonModal`이면 둘 다 하지 않는다.
|
|
68
|
+
* - 위치: 공간이 모자라면 반대쪽으로 뒤집고, 주축 길이는 `max-height`로 줄여 트리거를 덮지 않는다(WCAG 2.4.11, Comments 참고).
|
|
69
|
+
* 돌려받은 `overlayProps`(style 포함)를 오버레이 요소에 펼친다. 쌓임 단계 class는 부르는 쪽이 붙인다.
|
|
70
|
+
*/
|
|
71
|
+
function usePopoverOverlay({ triggerRef, popoverRef, placement, offset, nonModal = false }, state) {
|
|
72
|
+
const { popoverProps, placement: actual } = usePopover({
|
|
73
|
+
triggerRef,
|
|
74
|
+
popoverRef,
|
|
75
|
+
placement,
|
|
76
|
+
offset,
|
|
77
|
+
isNonModal: nonModal
|
|
78
|
+
}, state);
|
|
79
|
+
return { overlayProps: overlayPositionProps(popoverProps, actual, placement) };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 트리거가 놓인 Theme(`data-theme`)을 portal로 나간 오버레이에 옮긴다. Theme을 `<html>`이 아닌 일부 영역에
|
|
83
|
+
* 붙인 앱에서도 오버레이가 같은 색이 된다. 오버레이에 이미 `data-theme`이 있으면 두고, 트리거 위에 Theme이 없으면
|
|
84
|
+
* 붙이지 않는다(`:root` 기본값을 따른다). 위치를 계산하기 전(첫 스타일 계산 전)에 붙도록 위치 hook보다 먼저 부른다.
|
|
85
|
+
*/
|
|
86
|
+
function useOverlayTheme(triggerRef, overlayRef) {
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
const overlay = overlayRef.current;
|
|
89
|
+
if (!overlay || overlay.hasAttribute("data-theme")) return;
|
|
90
|
+
const theme = triggerRef.current?.closest("[data-theme]")?.getAttribute("data-theme");
|
|
91
|
+
if (theme) overlay.setAttribute("data-theme", theme);
|
|
92
|
+
}, [triggerRef, overlayRef]);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 오버레이를 `document.body`에 portal한다(React Aria `Overlay`). top-layer(`<dialog>`, popover API)는 쓰지 않는다.
|
|
96
|
+
* 안을 `FocusScope`(`restoreFocus`)로 감싸서, DOM에서 빠질 때 포커스가 열기 전 요소(보통 트리거)로 돌아간다.
|
|
97
|
+
* 역할 hook이 포커스 가두기를 요청하면(`useDialog` 등) Tab이 안에서 돈다. 서버 렌더에서는 아무것도 그리지 않는다.
|
|
98
|
+
*/
|
|
99
|
+
function OverlayPortal({ isExiting, children }) {
|
|
100
|
+
return /* @__PURE__ */ jsx(Overlay, {
|
|
101
|
+
isExiting,
|
|
102
|
+
children
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { OverlayPortal, overlayPositionProps, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tide-tds/components",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Tide 디자인 시스템의 React Component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"./styles.css": "./dist/styles.css"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@tide-tds/foundation": "0.3.
|
|
27
|
-
"@tide-tds/icons": "0.3.
|
|
26
|
+
"@tide-tds/foundation": "0.3.1",
|
|
27
|
+
"@tide-tds/icons": "0.3.1",
|
|
28
28
|
"clsx": "^2.1.1",
|
|
29
29
|
"react-aria": "^3.53.1",
|
|
30
30
|
"react-stately": "^3.51.0",
|