@tide-tds/components 0.3.1 → 0.5.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/Accordion.d.ts +66 -0
- package/dist/Accordion.js +127 -0
- package/dist/AlertDialog.d.ts +8 -0
- package/dist/AlertDialog.js +18 -0
- package/dist/Combobox.d.ts +59 -0
- package/dist/Combobox.js +206 -0
- package/dist/Dialog.d.ts +70 -0
- package/dist/Dialog.js +255 -0
- package/dist/Input.d.ts +2 -5
- package/dist/Input.js +2 -8
- package/dist/Menu.d.ts +72 -0
- package/dist/Menu.js +226 -0
- package/dist/Select.d.ts +62 -0
- package/dist/Select.js +169 -0
- package/dist/Tabs.d.ts +66 -0
- package/dist/Tabs.js +159 -0
- package/dist/Tag.d.ts +48 -0
- package/dist/Tag.js +102 -0
- package/dist/Toast.d.ts +71 -0
- package/dist/Toast.js +190 -0
- package/dist/Tooltip.d.ts +53 -0
- package/dist/Tooltip.js +129 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +11 -1
- package/dist/input-style.d.ts +3 -0
- package/dist/input-style.js +7 -1
- package/dist/list-collection.d.ts +23 -0
- package/dist/list-collection.js +99 -0
- package/dist/list-style.js +45 -0
- package/dist/listbox.d.ts +15 -0
- package/dist/listbox.js +90 -0
- package/dist/overlay-style.js +50 -1
- package/dist/overlay.js +27 -3
- package/package.json +3 -3
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 {
|
|
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(
|
|
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 };
|
package/dist/Select.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { InputSize } from "./input-style.js";
|
|
2
|
+
import { ListBoxOptionProps, ListBoxSectionProps } from "./listbox.js";
|
|
3
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
export interface SelectProps extends Omit<ComponentProps<"button">, "value" | "defaultValue" | "onChange" | "type" | "size" | "children" | "name" | "form"> {
|
|
5
|
+
/** 고른 값(제어). 고른 옵션의 `value`다. `null`이면 아무것도 고르지 않은 상태다. 주면 `onValueChange`로 바꾼다. */
|
|
6
|
+
value?: string | null;
|
|
7
|
+
/** 처음 고른 값(비제어). 주지 않으면 아무것도 고르지 않은 상태로 시작한다. 폼을 reset하면 이 값으로 돌아간다. */
|
|
8
|
+
defaultValue?: string;
|
|
9
|
+
/** 사용자가 다른 옵션을 고르면 그 옵션의 `value`로 부른다. */
|
|
10
|
+
onValueChange?: (value: string) => void;
|
|
11
|
+
/** 목록 열림 상태(제어). 주면 `onOpenChange`로 바꾼다. */
|
|
12
|
+
open?: boolean;
|
|
13
|
+
/** 처음 목록 열림 상태(비제어). 기본값은 `false`다. */
|
|
14
|
+
defaultOpen?: boolean;
|
|
15
|
+
/** 목록 열림 상태가 바뀌려 할 때 새 값으로 부른다. 옵션을 골라 닫힐 때도 부른다. */
|
|
16
|
+
onOpenChange?: (open: boolean) => void;
|
|
17
|
+
/** 폼 제출 이름. 숨은 `<select>`에 붙어 고른 값이 이 이름으로 제출된다(트리거 버튼에는 붙지 않는다). */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** 숨은 `<select>`가 속할 `<form>`의 id. 폼 밖에 둘 때 쓴다. */
|
|
20
|
+
form?: string;
|
|
21
|
+
/** 브라우저 자동 완성 힌트. 숨은 `<select>`에 붙는다. */
|
|
22
|
+
autoComplete?: string;
|
|
23
|
+
/** 아무것도 고르지 않았을 때 트리거에 보이는 글자. 기본값은 `"선택하세요"`다. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** 높이 `sm` 32px, `md` 40px, `lg` 48px. 같은 size의 Input·Button과 높이가 같다. 기본값은 `md`다. */
|
|
26
|
+
size?: InputSize;
|
|
27
|
+
/**
|
|
28
|
+
* 오류 상태. 트리거에 `aria-invalid="true"`와 `data-invalid`가 붙고 경계가 danger 색이 된다.
|
|
29
|
+
* Field 안에서는 Field의 값을 따르고, 여기에 준 값이 이긴다.
|
|
30
|
+
*/
|
|
31
|
+
invalid?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* 필수. 고르지 않고 폼을 제출하면 제출이 막히고 트리거로 포커스가 오며 오류 상태가 된다(브라우저 오류 말풍선은
|
|
34
|
+
* 없다). 메시지는 Field의 `error`로 앱이 보여 준다. Field 안에서는 Field의 값을 따른다.
|
|
35
|
+
*/
|
|
36
|
+
required?: boolean;
|
|
37
|
+
/** `SelectItem`과 `SelectSection`. 이 요소들을 바로 아래(또는 Fragment·배열 안)에 둔다. */
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 값 하나를 고르는 입력. 트리거를 누르거나(마우스는 누르는 순간) Enter·Space·↓·↑로 목록을 열고, 화살표·Home/End·
|
|
42
|
+
* 영문 글자로 옵션을 옮겨 Enter·Space·누름으로 고른다. 닫힌 채로 글자를 치면 바로 그 옵션을 고른다.
|
|
43
|
+
* 고르면 닫히고 포커스는 트리거로 돌아온다. Esc·바깥 누름·Tab으로도 닫힌다.
|
|
44
|
+
* Field 안에 두면 label·설명·오류가 자동으로 연결되고, `name`을 주면 고른 값이 폼에 제출된다.
|
|
45
|
+
* 상태는 `data-open`(트리거·목록), `data-invalid`(트리거), 네이티브 `:disabled`(트리거),
|
|
46
|
+
* `data-highlighted`·`data-selected`·`data-disabled`(옵션)로 노출한다.
|
|
47
|
+
*/
|
|
48
|
+
export declare function Select({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, name, form, autoComplete, placeholder, size, invalid, disabled, required, id, ref, className, children, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }: SelectProps): ReactElement;
|
|
49
|
+
export interface SelectItemProps extends ListBoxOptionProps {}
|
|
50
|
+
/**
|
|
51
|
+
* Select의 옵션(`role="option"`). `Select`나 `SelectSection` 바로 안에 둔다. `value`가 고른 값이고 폼에 제출된다.
|
|
52
|
+
* 내용(아이콘과 글자)은 고르면 트리거에도 보인다. 키보드 위치와 마우스를 올린 옵션은 highlight(primary subtle 배경,
|
|
53
|
+
* primary 글자), 고른 옵션은 neutral subtle-active 배경과 끝의 체크 아이콘이다.
|
|
54
|
+
* 상태는 `data-highlighted`, `data-selected`, `data-disabled`로 노출한다.
|
|
55
|
+
*/
|
|
56
|
+
export declare function SelectItem(_props: SelectItemProps): null;
|
|
57
|
+
export interface SelectSectionProps extends ListBoxSectionProps {}
|
|
58
|
+
/**
|
|
59
|
+
* 옵션 묶음(`role="group"`). `heading`이 화면에 보이는 제목이자 묶음의 이름이고, 없으면 `aria-label`이 이름이다.
|
|
60
|
+
* 앞뒤 다른 옵션·묶음과의 사이에 구분선이 그려진다.
|
|
61
|
+
*/
|
|
62
|
+
export declare function SelectSection(_props: SelectSectionProps): null;
|