@tide-tds/components 0.3.0 → 0.4.0

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/Dialog.js ADDED
@@ -0,0 +1,255 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { IconButton } from "./IconButton.js";
5
+ import { OverlayPortal, useOverlayPresence, useOverlayTheme } from "./overlay.js";
6
+ import { backdropMotionClass, dialogMotionClass, dialogSurfaceClass } from "./overlay-style.js";
7
+ import { renderElement } from "./render.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { createContext, createElement, use, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import { XIcon } from "@tide-tds/icons";
11
+ import { mergeProps, mergeRefs, useDialog, useFocusManager, useModalOverlay, useOverlayTrigger, usePress } from "react-aria";
12
+ import { useOverlayTriggerState } from "react-stately";
13
+
14
+ // src/Dialog.tsx
15
+ const DialogContext = createContext(null);
16
+ function useDialogContext(part) {
17
+ const context = use(DialogContext);
18
+ if (!context) throw new Error(`${part}는 Dialog나 AlertDialog 안에 둔다.`);
19
+ return context;
20
+ }
21
+ const DialogPartsContext = createContext(null);
22
+ function useDialogPartsContext(part) {
23
+ const context = use(DialogPartsContext);
24
+ if (!context) throw new Error(`${part}는 DialogContent 안에 둔다.`);
25
+ return context;
26
+ }
27
+ /** Dialog·AlertDialog가 같이 쓰는 열림 상태. 공개 API가 아니다(AlertDialog.tsx가 쓴다). */
28
+ function DialogRoot({ open, defaultOpen, onOpenChange, alert, children }) {
29
+ const state = useOverlayTriggerState({
30
+ isOpen: open,
31
+ defaultOpen,
32
+ onOpenChange
33
+ });
34
+ const triggerRef = useRef(null);
35
+ const { triggerProps, overlayProps } = useOverlayTrigger({ type: "dialog" }, state, triggerRef);
36
+ const [triggerId, setTriggerId] = useState(null);
37
+ const value = useMemo(() => ({
38
+ state,
39
+ triggerRef,
40
+ triggerProps,
41
+ contentId: overlayProps.id,
42
+ triggerId,
43
+ setTriggerId,
44
+ alert
45
+ }), [
46
+ state,
47
+ triggerProps,
48
+ overlayProps.id,
49
+ triggerId,
50
+ alert
51
+ ]);
52
+ return /* @__PURE__ */ jsx(DialogContext, {
53
+ value,
54
+ children
55
+ });
56
+ }
57
+ /**
58
+ * 트리거와 내용을 잇는 열림 상태. DOM 요소를 만들지 않는다.
59
+ * 안에 `DialogTrigger`(없어도 된다. `open`으로 제어해 Menu 항목 등에서 열 수 있다)와 `DialogContent`를 둔다.
60
+ */
61
+ function Dialog(props) {
62
+ return /* @__PURE__ */ jsx(DialogRoot, {
63
+ ...props,
64
+ alert: false
65
+ });
66
+ }
67
+ /**
68
+ * Dialog를 여는 버튼. 누르면(마우스, 터치, Enter, Space) 연다. 열린 동안은 바깥이 inert라 다시 누를 수 없다.
69
+ * `aria-haspopup="dialog"`, `aria-expanded`, 열렸을 때 `aria-controls`와 `data-open`이 붙는다.
70
+ */
71
+ function DialogTrigger({ render, ref, id, className, ...props }) {
72
+ const { state, triggerRef, triggerProps, setTriggerId } = useDialogContext("DialogTrigger");
73
+ const { onPress, ...ariaProps } = triggerProps;
74
+ const { pressProps } = usePress({
75
+ onPress,
76
+ ref: triggerRef
77
+ });
78
+ const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
79
+ const generatedId = useId();
80
+ const resolvedId = id ?? generatedId;
81
+ useLayoutEffect(() => {
82
+ setTriggerId(resolvedId);
83
+ return () => setTriggerId(null);
84
+ }, [resolvedId, setTriggerId]);
85
+ const merged = {
86
+ ...mergeProps(props, ariaProps, pressProps),
87
+ id: resolvedId,
88
+ ref: mergedRef,
89
+ "data-open": state.isOpen ? "" : void 0
90
+ };
91
+ if (!render) return createElement("button", {
92
+ type: "button",
93
+ ...merged,
94
+ className
95
+ });
96
+ return renderElement({
97
+ defaultElement: "button",
98
+ render,
99
+ baseClassName: "",
100
+ props: {
101
+ ...merged,
102
+ className
103
+ }
104
+ });
105
+ }
106
+ const backdropClass = `fixed inset-0 isolate z-modal flex items-center justify-center bg-overlay p-4 ${backdropMotionClass}`;
107
+ const contentClass = `relative flex max-h-full w-full flex-col gap-4 overflow-y-auto p-6 text-body-md outline-none ${dialogSurfaceClass} ${dialogMotionClass}`;
108
+ const sizeClass = {
109
+ sm: "max-w-96",
110
+ md: "max-w-128",
111
+ lg: "max-w-160"
112
+ };
113
+ /**
114
+ * 화면 가운데 뜨는 모달 내용(`role="dialog"`, AlertDialog 안에서는 `role="alertdialog"`).
115
+ * 열릴 때만 `document.body`에 portal하고, 반투명 막(`bg-overlay`)과 함께 `z-modal`로 쌓는다.
116
+ * 등장은 opacity + scale 0.96 → 1(`normal`), 퇴장은 opacity + scale 0.98(`fast`)이다. 움직임 줄이기에서는 opacity만 바뀐다.
117
+ * Dialog에는 끝 위쪽에 닫기 버튼(IconButton)이 있고 바깥 막을 누르면 닫힌다. AlertDialog는 둘 다 없다(Esc는 닫는다).
118
+ */
119
+ function DialogContent(props) {
120
+ const context = useDialogContext("DialogContent");
121
+ const backdropRef = useRef(null);
122
+ if (!useOverlayPresence(context.state.isOpen, backdropRef)) return null;
123
+ return /* @__PURE__ */ jsx(OverlayPortal, {
124
+ isExiting: !context.state.isOpen,
125
+ children: /* @__PURE__ */ jsx(DialogContentInner, {
126
+ ...props,
127
+ context,
128
+ backdropRef
129
+ })
130
+ });
131
+ }
132
+ /** DOM에 있는 동안만 portal 안에서 렌더한다. React Aria 오버레이 hook은 요소가 있다고 보고 동작한다. */
133
+ function DialogContentInner({ context, backdropRef, size = "md", closeLabel = "닫기", ref, className, children, ...props }) {
134
+ const { state, triggerRef, contentId, triggerId, alert } = context;
135
+ const dialogRef = useRef(null);
136
+ const [titleId, setTitleId] = useState(null);
137
+ const [descriptionId, setDescriptionId] = useState(null);
138
+ useOverlayTheme(triggerRef, backdropRef);
139
+ const { modalProps, underlayProps } = useModalOverlay({ isDismissable: !alert }, state, dialogRef);
140
+ const labelledBy = props["aria-labelledby"] ?? (props["aria-label"] ? void 0 : titleId ?? triggerId ?? void 0);
141
+ const describedBy = props["aria-describedby"] ?? descriptionId ?? void 0;
142
+ const { dialogProps } = useDialog({
143
+ role: alert ? "alertdialog" : "dialog",
144
+ "aria-label": props["aria-label"],
145
+ "aria-labelledby": labelledBy,
146
+ "aria-describedby": describedBy
147
+ }, dialogRef);
148
+ const focusManager = useFocusManager();
149
+ useEffect(() => {
150
+ if (alert && document.activeElement === dialogRef.current) focusManager?.focusFirst({ tabbable: true });
151
+ }, []);
152
+ const mergedRef = useMemo(() => mergeRefs(ref, dialogRef), [ref]);
153
+ const parts = useMemo(() => ({
154
+ alert,
155
+ setTitleId,
156
+ setDescriptionId
157
+ }), [alert]);
158
+ const open = state.isOpen ? "" : void 0;
159
+ return /* @__PURE__ */ jsx("div", {
160
+ ...underlayProps,
161
+ ref: backdropRef,
162
+ "data-open": open,
163
+ className: backdropClass,
164
+ children: /* @__PURE__ */ jsxs("div", {
165
+ ...mergeProps(props, modalProps, dialogProps),
166
+ id: contentId,
167
+ ref: mergedRef,
168
+ "data-open": open,
169
+ className: cn(contentClass, sizeClass[size], className),
170
+ children: [/* @__PURE__ */ jsx(DialogPartsContext, {
171
+ value: parts,
172
+ children
173
+ }), !alert && /* @__PURE__ */ jsx(IconButton, {
174
+ "aria-label": closeLabel,
175
+ variant: "ghost",
176
+ color: "neutral",
177
+ size: "sm",
178
+ onClick: state.close,
179
+ className: "absolute end-5 top-5",
180
+ children: /* @__PURE__ */ jsx(XIcon, {})
181
+ })]
182
+ })
183
+ });
184
+ }
185
+ /** 부분 요소의 id를 정하고 열린 동안 dialog에 알린다. */
186
+ function usePartId(id, register) {
187
+ const generated = useId();
188
+ const resolved = id ?? generated;
189
+ useLayoutEffect(() => {
190
+ register(resolved);
191
+ return () => register(null);
192
+ }, [resolved, register]);
193
+ return resolved;
194
+ }
195
+ /**
196
+ * Dialog 제목(`<h2>`, `text-heading-sm`). 있으면 dialog의 `aria-labelledby`가 가리켜 이름이 된다.
197
+ * Dialog에서는 끝쪽 닫기 버튼과 겹치지 않게 끝 여백(36px)을 둔다.
198
+ */
199
+ function DialogTitle({ id, className, ...props }) {
200
+ const { alert, setTitleId } = useDialogPartsContext("DialogTitle");
201
+ const resolvedId = usePartId(id, setTitleId);
202
+ return /* @__PURE__ */ jsx("h2", {
203
+ ...props,
204
+ id: resolvedId,
205
+ className: cn("text-heading-sm text-default", !alert && "pe-9", className)
206
+ });
207
+ }
208
+ /**
209
+ * Dialog 설명(`<p>`, `text-muted`). 있으면 dialog의 `aria-describedby`가 가리킨다. AlertDialog에서는 알릴 내용을 여기에 쓴다.
210
+ * 목록·여러 문단처럼 구조가 있는 내용은 한 줄로 읽히므로 이 요소 밖에 둔다(APG Dialog).
211
+ */
212
+ function DialogDescription({ id, className, ...props }) {
213
+ const { setDescriptionId } = useDialogPartsContext("DialogDescription");
214
+ const resolvedId = usePartId(id, setDescriptionId);
215
+ return /* @__PURE__ */ jsx("p", {
216
+ ...props,
217
+ id: resolvedId,
218
+ className: cn("text-body-md text-muted", className)
219
+ });
220
+ }
221
+ /** 동작 버튼 줄. 끝쪽에 맞추고 버튼 사이는 8px이다. 앞에 덜 위험한 동작(취소)을, 끝에 주 동작을 둔다. */
222
+ function DialogFooter({ className, ...props }) {
223
+ return /* @__PURE__ */ jsx("div", {
224
+ ...props,
225
+ className: cn("mt-2 flex flex-wrap justify-end gap-2", className)
226
+ });
227
+ }
228
+ /**
229
+ * 누르면 Dialog를 닫는 버튼. `onClick`을 먼저 부르고, 그 안에서 `event.preventDefault()`를 부르면 닫지 않는다.
230
+ * 닫히면 포커스는 열기 전 요소로 돌아간다.
231
+ */
232
+ function DialogClose({ render, onClick, className, ...props }) {
233
+ const { state } = useDialogContext("DialogClose");
234
+ const handleClick = (event) => {
235
+ onClick?.(event);
236
+ if (!event.defaultPrevented) state.close();
237
+ };
238
+ const merged = {
239
+ ...props,
240
+ onClick: handleClick,
241
+ className
242
+ };
243
+ if (!render) return createElement("button", {
244
+ type: "button",
245
+ ...merged
246
+ });
247
+ return renderElement({
248
+ defaultElement: "button",
249
+ render,
250
+ baseClassName: "",
251
+ props: merged
252
+ });
253
+ }
254
+
255
+ export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogRoot, DialogTitle, DialogTrigger };
package/dist/Input.d.ts CHANGED
@@ -1,7 +1,5 @@
1
- import { Size } from "./types.js";
1
+ import { InputSize } from "./input-style.js";
2
2
  import { ComponentProps, ReactElement } from "react";
3
- /** Input의 size. `xs`(24px)는 입력하기에 작아서 받지 않는다. */
4
- type InputSize = Extract<Size, "sm" | "md" | "lg">;
5
3
  export interface InputProps extends Omit<ComponentProps<"input">, "size"> {
6
4
  /** 높이 `sm` 32px, `md` 40px, `lg` 48px. 같은 size의 Button과 높이가 같다. 기본값은 `md`다. */
7
5
  size?: InputSize;
@@ -15,5 +13,4 @@ export interface InputProps extends Omit<ComponentProps<"input">, "size"> {
15
13
  * 한 줄 텍스트를 입력하는 네이티브 `<input>`. `type`(`email`, `password`, `date` 등)을 그대로 받는다.
16
14
  * Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
17
15
  */
18
- export declare function Input({ size, invalid, id, disabled, required, className, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: InputProps): ReactElement;
19
- export {};
16
+ export declare function Input({ size, invalid, id, disabled, required, className, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }: InputProps): ReactElement;
package/dist/Input.js CHANGED
@@ -2,16 +2,10 @@
2
2
 
3
3
  import { cn } from "./cn.js";
4
4
  import { useFieldControl } from "./field-context.js";
5
- import { inputBaseClass } from "./input-style.js";
5
+ import { inputBaseClass, inputSizeClass } from "./input-style.js";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
 
8
8
  // src/Input.tsx
9
- /** 높이, 좌우 padding, 텍스트 스타일(spec size 표). radius는 모든 size에서 `md`다. */
10
- const sizeClass = {
11
- sm: "h-8 px-2.5 text-label-sm",
12
- md: "h-10 px-3 text-label-md",
13
- lg: "h-12 px-4 text-label-lg"
14
- };
15
9
  /**
16
10
  * 한 줄 텍스트를 입력하는 네이티브 `<input>`. `type`(`email`, `password`, `date` 등)을 그대로 받는다.
17
11
  * Field 안에 두면 label, 설명, 오류 메시지가 자동으로 연결된다.
@@ -28,7 +22,7 @@ function Input({ size = "md", invalid, id, disabled, required, className, "aria-
28
22
  return /* @__PURE__ */ jsx("input", {
29
23
  ...props,
30
24
  ...control,
31
- className: cn(sizeClass[size], inputBaseClass, className)
25
+ className: cn(inputSizeClass[size], inputBaseClass, className)
32
26
  });
33
27
  }
34
28
 
package/dist/Menu.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { CollectionItemProps, CollectionSectionProps } from "./list-collection.js";
2
+ import { OverlayPlacement } from "./overlay.js";
3
+ import { ComponentProps, ReactElement, ReactNode } from "react";
4
+ /** Menu 목록의 위치. 앞은 트리거의 어느 쪽, 뒤는 그 변에서 맞출 끝이다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
5
+ export type MenuPlacement = OverlayPlacement;
6
+ export interface MenuProps {
7
+ /** 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
8
+ open?: boolean;
9
+ /** 처음 열림 상태(비제어). 기본값은 `false`다. */
10
+ defaultOpen?: boolean;
11
+ /** 열림 상태가 바뀌려 할 때 새 값으로 부른다. 항목을 실행해 닫힐 때도 부른다. */
12
+ onOpenChange?: (open: boolean) => void;
13
+ /** `MenuTrigger`와 `MenuContent` */
14
+ children: ReactNode;
15
+ }
16
+ /**
17
+ * 트리거와 동작 목록을 잇는 열림 상태. DOM 요소를 만들지 않는다.
18
+ * 안에 `MenuTrigger` 하나와 `MenuContent` 하나를 둔다.
19
+ */
20
+ export declare function Menu({ open, defaultOpen, onOpenChange, children }: MenuProps): ReactElement;
21
+ export interface MenuTriggerProps extends ComponentProps<"button"> {
22
+ /**
23
+ * 다른 요소로 렌더한다. 예: `render={<Button variant="outline" />}`, `render={<IconButton aria-label="더보기" />}`.
24
+ * props, ref, className, 이벤트 핸들러를 그 요소에 병합한다. 기본은 class 없는 `<button type="button">`이다.
25
+ */
26
+ render?: ReactElement;
27
+ }
28
+ /**
29
+ * Menu를 여는 버튼. 마우스는 누르는 순간 열고, 키보드는 Enter·Space·↓로 열어 첫 항목에, ↑로 열어 마지막 항목에
30
+ * 위치한다. `aria-haspopup`, `aria-expanded`, 열렸을 때 `aria-controls`와 `data-open`이 붙는다.
31
+ */
32
+ export declare function MenuTrigger({ render, ref, id, className, ...props }: MenuTriggerProps): ReactElement;
33
+ export interface MenuContentProps extends Omit<ComponentProps<"div">, "id" | "role"> {
34
+ /** 트리거 기준 위치. 기본값은 `bottom start`(아래, 시작 끝 맞춤)다. 공간이 모자라면 반대쪽으로 뒤집힌다. */
35
+ placement?: MenuPlacement;
36
+ /** 트리거와의 간격(px). 기본값은 `4`다. */
37
+ offset?: number;
38
+ /** `MenuItem`과 `MenuSection`. 이 요소들을 바로 아래(또는 Fragment·배열 안)에 둔다. */
39
+ children?: ReactNode;
40
+ }
41
+ /**
42
+ * 트리거 옆에 뜨는 동작 목록(`role="menu"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
43
+ * 바로 나타나고, 닫히면 opacity만 `fast`(150ms)로 사라진다.
44
+ * 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
45
+ */
46
+ export declare function MenuContent(props: MenuContentProps): ReactElement | null;
47
+ export interface MenuItemProps extends Omit<ComponentProps<"div">, "role" | "children">, CollectionItemProps {
48
+ /** 항목 내용. 아이콘(장식용)과 글자를 함께 둘 수 있다. */
49
+ children?: ReactNode;
50
+ /**
51
+ * 항목을 실행할 때 부른다. 마우스·터치 누름뿐 아니라 Enter·Space도 click으로 실행한다(네이티브 버튼과 같다).
52
+ * 실행하면 메뉴가 닫힌다. `disabled`면 부르지 않는다.
53
+ */
54
+ onClick?: ComponentProps<"div">["onClick"];
55
+ }
56
+ /**
57
+ * Menu의 동작 항목(`role="menuitem"`). `MenuContent`나 `MenuSection` 바로 안에 둔다.
58
+ * 키보드 위치와 마우스를 올린 항목은 highlight(primary subtle 배경, primary 글자)로 바로 바뀐다.
59
+ * 상태는 `data-highlighted`(키보드 위치·hover), `data-disabled`(비활성)로 노출한다.
60
+ */
61
+ export declare function MenuItem(_props: MenuItemProps): null;
62
+ export interface MenuSectionProps extends CollectionSectionProps {
63
+ /** 묶음 감싸개(`<div role="presentation">`)에 붙는 class */
64
+ className?: string;
65
+ /** `MenuItem`. 바로 아래(또는 Fragment·배열 안)에 둔다. */
66
+ children?: ReactNode;
67
+ }
68
+ /**
69
+ * 항목 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
70
+ * 앞뒤 다른 항목·묶음과의 사이에 구분선(`role="separator"`)이 그려진다.
71
+ */
72
+ export declare function MenuSection(_props: MenuSectionProps): null;
package/dist/Menu.js ADDED
@@ -0,0 +1,226 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { asCollectionComponent, itemCollectionNode, resolveCollectionChildren, sectionCollectionNode } from "./list-collection.js";
5
+ import { listHeadingClass, listItemClass, listPopupClass, listSeparatorClass } from "./list-style.js";
6
+ import { OverlayPortal, useCloseOnTab, useOverlayPresence, useOverlayTheme, usePopoverOverlay } from "./overlay.js";
7
+ import { renderElement } from "./render.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { createContext, createElement, use, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import { mergeProps, mergeRefs, useMenu, useMenuItem, useMenuSection, useMenuTrigger, usePress } from "react-aria";
11
+ import { useMenuTriggerState, useTreeState } from "react-stately";
12
+
13
+ // src/Menu.tsx
14
+ const MenuContext = createContext(null);
15
+ function useMenuContext(part) {
16
+ const context = use(MenuContext);
17
+ if (!context) throw new Error(`${part}는 Menu 안에 둔다.`);
18
+ return context;
19
+ }
20
+ /**
21
+ * 트리거와 동작 목록을 잇는 열림 상태. DOM 요소를 만들지 않는다.
22
+ * 안에 `MenuTrigger` 하나와 `MenuContent` 하나를 둔다.
23
+ */
24
+ function Menu({ open, defaultOpen, onOpenChange, children }) {
25
+ const state = useMenuTriggerState({
26
+ isOpen: open,
27
+ defaultOpen,
28
+ onOpenChange
29
+ });
30
+ const triggerRef = useRef(null);
31
+ const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, triggerRef);
32
+ const generatedTriggerId = useId();
33
+ const [triggerId, setTriggerId] = useState(generatedTriggerId);
34
+ const value = useMemo(() => ({
35
+ state,
36
+ triggerRef,
37
+ menuTriggerProps,
38
+ menuProps,
39
+ triggerId,
40
+ setTriggerId
41
+ }), [
42
+ state,
43
+ menuTriggerProps,
44
+ menuProps,
45
+ triggerId
46
+ ]);
47
+ return /* @__PURE__ */ jsx(MenuContext, {
48
+ value,
49
+ children
50
+ });
51
+ }
52
+ /**
53
+ * Menu를 여는 버튼. 마우스는 누르는 순간 열고, 키보드는 Enter·Space·↓로 열어 첫 항목에, ↑로 열어 마지막 항목에
54
+ * 위치한다. `aria-haspopup`, `aria-expanded`, 열렸을 때 `aria-controls`와 `data-open`이 붙는다.
55
+ */
56
+ function MenuTrigger({ render, ref, id, className, ...props }) {
57
+ const { state, triggerRef, menuTriggerProps, triggerId, setTriggerId } = useMenuContext("MenuTrigger");
58
+ const { onPressStart, onPress, preventFocusOnPress, id: _ariaId, isDisabled: _isDisabled, ...ariaProps } = menuTriggerProps;
59
+ const { pressProps } = usePress({
60
+ onPressStart,
61
+ onPress,
62
+ preventFocusOnPress,
63
+ ref: triggerRef
64
+ });
65
+ const mergedRef = useMemo(() => mergeRefs(ref, triggerRef), [ref, triggerRef]);
66
+ const resolvedId = id ?? triggerId;
67
+ useLayoutEffect(() => {
68
+ if (resolvedId !== triggerId) setTriggerId(resolvedId);
69
+ }, [
70
+ resolvedId,
71
+ triggerId,
72
+ setTriggerId
73
+ ]);
74
+ const merged = {
75
+ ...mergeProps(props, ariaProps, pressProps),
76
+ id: resolvedId,
77
+ ref: mergedRef,
78
+ "data-open": state.isOpen ? "" : void 0
79
+ };
80
+ if (!render) return createElement("button", {
81
+ type: "button",
82
+ ...merged,
83
+ className
84
+ });
85
+ return renderElement({
86
+ defaultElement: "button",
87
+ render,
88
+ baseClassName: "",
89
+ props: {
90
+ ...merged,
91
+ className
92
+ }
93
+ });
94
+ }
95
+ /**
96
+ * 트리거 옆에 뜨는 동작 목록(`role="menu"`). 열릴 때만 `document.body`에 portal로 렌더하고 `z-popover`로 쌓는다.
97
+ * 바로 나타나고, 닫히면 opacity만 `fast`(150ms)로 사라진다.
98
+ * 열려 있으면 `data-open`이, 트리거 기준 방향이 `data-placement`(`top`/`bottom`/`left`/`right`)로 붙는다.
99
+ */
100
+ function MenuContent(props) {
101
+ const context = useMenuContext("MenuContent");
102
+ const popoverRef = useRef(null);
103
+ if (!useOverlayPresence(context.state.isOpen, popoverRef)) return null;
104
+ return /* @__PURE__ */ jsx(OverlayPortal, {
105
+ isExiting: !context.state.isOpen,
106
+ children: /* @__PURE__ */ jsx(MenuContentInner, {
107
+ ...props,
108
+ context,
109
+ popoverRef
110
+ })
111
+ });
112
+ }
113
+ /** DOM에 있는 동안만 portal 안에서 렌더한다. React Aria 오버레이·목록 hook은 요소가 있다고 보고 동작한다. */
114
+ function MenuContentInner({ context, popoverRef, placement = "bottom start", offset = 4, ref, className, children, ...props }) {
115
+ const { state, triggerRef, menuProps, triggerId } = context;
116
+ useOverlayTheme(triggerRef, popoverRef);
117
+ const { overlayProps } = usePopoverOverlay({
118
+ triggerRef,
119
+ popoverRef,
120
+ placement,
121
+ offset
122
+ }, state);
123
+ const items = useMemo(() => resolveCollectionChildren(children), [children]);
124
+ const tree = useTreeState({ children: items });
125
+ const labelledBy = props["aria-labelledby"] ?? (props["aria-label"] ? void 0 : triggerId);
126
+ const { menuProps: ariaMenuProps } = useMenu({
127
+ ...menuProps,
128
+ "aria-label": props["aria-label"],
129
+ "aria-labelledby": labelledBy
130
+ }, tree, popoverRef);
131
+ const mergedRef = useMemo(() => mergeRefs(ref, popoverRef), [ref, popoverRef]);
132
+ useCloseOnTab(popoverRef, state.close);
133
+ const { style: _style, ...rest } = mergeProps(props, overlayProps, ariaMenuProps);
134
+ const nodes = [...tree.collection];
135
+ return /* @__PURE__ */ jsx("div", {
136
+ ...rest,
137
+ ref: mergedRef,
138
+ "data-open": state.isOpen ? "" : void 0,
139
+ className: cn(listPopupClass, className),
140
+ style: {
141
+ ...overlayProps.style,
142
+ ...props.style
143
+ },
144
+ children: nodes.map((node, index) => {
145
+ const previous = nodes[index - 1];
146
+ const separator = previous !== void 0 && (node.type === "section" || previous.type === "section");
147
+ const element = node.type === "section" ? /* @__PURE__ */ jsx(MenuSectionView, {
148
+ node,
149
+ tree
150
+ }, node.key) : /* @__PURE__ */ jsx(MenuItemView, {
151
+ node,
152
+ tree
153
+ }, node.key);
154
+ if (!separator) return element;
155
+ return [/* @__PURE__ */ jsx("div", {
156
+ role: "separator",
157
+ className: listSeparatorClass
158
+ }, `${String(node.key)}-separator`), element];
159
+ })
160
+ });
161
+ }
162
+ /**
163
+ * Menu의 동작 항목(`role="menuitem"`). `MenuContent`나 `MenuSection` 바로 안에 둔다.
164
+ * 키보드 위치와 마우스를 올린 항목은 highlight(primary subtle 배경, primary 글자)로 바로 바뀐다.
165
+ * 상태는 `data-highlighted`(키보드 위치·hover), `data-disabled`(비활성)로 노출한다.
166
+ */
167
+ function MenuItem(_props) {
168
+ return null;
169
+ }
170
+ asCollectionComponent(MenuItem, itemCollectionNode);
171
+ /**
172
+ * 항목 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
173
+ * 앞뒤 다른 항목·묶음과의 사이에 구분선(`role="separator"`)이 그려진다.
174
+ */
175
+ function MenuSection(_props) {
176
+ return null;
177
+ }
178
+ asCollectionComponent(MenuSection, sectionCollectionNode);
179
+ /** 항목 하나를 그린다. node.props는 앱이 MenuItem에 준 props(와 컬렉션이 더한 isDisabled)다. */
180
+ function MenuItemView({ node, tree }) {
181
+ const itemRef = useRef(null);
182
+ const { ref, className, onClick, disabled: _disabled, textValue: _textValue, isDisabled: _isDisabled, children: _children, id, ...props } = node.props;
183
+ const { menuItemProps, isFocused, isDisabled } = useMenuItem({
184
+ key: node.key,
185
+ id,
186
+ onClick,
187
+ "aria-label": props["aria-label"]
188
+ }, tree, itemRef);
189
+ const mergedRef = useMemo(() => mergeRefs(ref, itemRef), [ref, itemRef]);
190
+ const merged = mergeProps(props, menuItemProps);
191
+ return /* @__PURE__ */ jsx("div", {
192
+ ...merged,
193
+ ref: mergedRef,
194
+ "aria-labelledby": props["aria-labelledby"],
195
+ "aria-describedby": props["aria-describedby"],
196
+ "data-highlighted": isFocused ? "" : void 0,
197
+ "data-disabled": isDisabled ? "" : void 0,
198
+ className: cn(listItemClass, className),
199
+ children: node.rendered
200
+ });
201
+ }
202
+ /** 묶음 하나를 그린다. 제목은 보조기술에서 숨기고(`presentation`) 묶음의 이름으로만 쓴다(useMenuSection). */
203
+ function MenuSectionView({ node, tree }) {
204
+ const { className } = node.props;
205
+ const { itemProps, headingProps, groupProps } = useMenuSection({
206
+ heading: node.rendered,
207
+ "aria-label": node["aria-label"]
208
+ });
209
+ return /* @__PURE__ */ jsxs("div", {
210
+ ...itemProps,
211
+ className,
212
+ children: [node.rendered != null && /* @__PURE__ */ jsx("div", {
213
+ ...headingProps,
214
+ className: "px-2 pt-1.5 pb-1 text-label-sm text-muted select-none",
215
+ children: node.rendered
216
+ }), /* @__PURE__ */ jsx("div", {
217
+ ...groupProps,
218
+ children: [...node.childNodes].map((child) => /* @__PURE__ */ jsx(MenuItemView, {
219
+ node: child,
220
+ tree
221
+ }, child.key))
222
+ })]
223
+ });
224
+ }
225
+
226
+ export { Menu, MenuContent, MenuItem, MenuSection, MenuTrigger };
@@ -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;