@uniai-fe/uds-primitives 0.12.14 → 0.13.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.
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { forwardRef, useImperativeHandle } from "react";
5
+ import { Tooltip } from "../../tooltip";
6
+ import type { FabMenuItemProps, FabMenuListProps } from "../types";
7
+ import { FabDefault } from "./Default";
8
+ import { useFabContext } from "./Root";
9
+
10
+ /**
11
+ * Fab Component; 닫힌 상태에서도 원점을 유지하는 액션 목록
12
+ * @component
13
+ * @property {ReactNode} [props.children] 같은 Root에 속한 Menu.Item 조합. DOM의 첫 항목이 Trigger에 가장 가깝다.
14
+ * @property {string} [props.aria-label] 메뉴의 별도 접근 가능한 이름. 생략하거나 빈 문자열이면 Trigger의 이름을 aria-labelledby로 연결한다.
15
+ * @property {string} [props.className] 최상위 목록 div에 fab-menu-list 클래스와 병합할 추가 클래스.
16
+ * @property {CSSProperties} [props.style] 최상위 목록 div의 inline style. 닫힌 목록의 배치 공간을 제거하지 않는다.
17
+ * @property {React.MouseEventHandler<HTMLDivElement>} [props.onClick] 액션의 실행·닫힘 처리 뒤 목록으로 버블링되는 native 클릭 이벤트.
18
+ * @property {React.KeyboardEventHandler<HTMLDivElement>} [props.onKeyDown] Root 탐색으로 버블링되는 native 키 이벤트. preventDefault하면 Root의 해당 기본 처리를 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
19
+ * @property {React.Ref<HTMLDivElement>} [ref] 실제 목록 div에 전달하는 object/callback ref.
20
+ * @see FabMenuListProps
21
+ * @see FabRoot
22
+ * @desc Root가 ID·role=menu·orientation·aria-labelledby와 닫힘을 연결한다. 닫히면 visibility로 배치 공간을 유지하고 aria-hidden·inert로 접근성·focus 탐색에서 제외한다. 이 속성과 hidden은 public 입력으로 받지 않으며 나머지 native div 속성·이벤트는 목록에 전달한다. Root 밖에서 사용하면 오류를 발생시킨다.
23
+ */
24
+ const FabMenuList = forwardRef<HTMLDivElement, FabMenuListProps>(
25
+ ({ className, ...props }, ref) => {
26
+ const { open, direction, menuId, triggerId, menuRef } =
27
+ useFabContext("Fab.Menu.List");
28
+ // DOM ref를 공유하고 소비자 callback ref의 React cleanup 계약은 React에 맡긴다.
29
+ useImperativeHandle(ref, () => menuRef.current!, [menuRef]);
30
+ return (
31
+ <div
32
+ {...props}
33
+ ref={menuRef}
34
+ id={menuId}
35
+ className={clsx("fab-menu-list", className)}
36
+ role="menu"
37
+ aria-labelledby={props["aria-label"] ? undefined : triggerId}
38
+ aria-orientation={
39
+ direction === "up" || direction === "down" ? "vertical" : "horizontal"
40
+ }
41
+ data-open={open}
42
+ aria-hidden={!open || undefined}
43
+ inert={!open}
44
+ />
45
+ );
46
+ },
47
+ );
48
+
49
+ FabMenuList.displayName = "FabMenuList";
50
+
51
+ /**
52
+ * Fab Component; 액션 버튼과 이름 툴팁
53
+ * @component
54
+ * @property {ReactNode} props.icon 필수 장식용 콘텐츠. 내부는 aria-hidden으로 표시한다.
55
+ * @property {"small" | "medium" | "large"} props.size 필수 크기. 버튼 높이는 각각 40/48/56px, 아이콘 영역은 16/20/24px다.
56
+ * @property {"solid" | "outlined"} props.fill 필수 채움 방식. outlined의 기본 면은 불투명 흰색이다.
57
+ * @property {"primary" | "secondary" | "tertiary" | "green" | "yellow" | "orange" | "teal" | "pink" | "red" | "darkGray" | "lightGray"} props.priority 필수 색상. 두 fill 모두에서 같은 목록을 지원한다.
58
+ * @property {string} props.aria-label 필수 행동 이름이자 Tooltip 문구. persistentTooltips는 Root에서 지정한다.
59
+ * @property {"top" | "right" | "bottom" | "left"} [props.tooltipSide] 툴팁 방향. up/down 메뉴는 left, left/right 메뉴는 top이 기본이다.
60
+ * @property {"button" | "submit" | "reset"} [props.type="button"] native 버튼 동작. 폼 제출·초기화는 소비자가 명시한다.
61
+ * @property {boolean} [props.disabled=false] native 실행을 차단하고 Root의 방향키 탐색에서 제외한다.
62
+ * @property {React.MouseEventHandler<HTMLButtonElement>} [props.onClick] 소비자의 액션을 먼저 호출한 뒤 닫고 Trigger로 focus를 복귀한다. preventDefault하면 자동 닫기·focus 복귀를 생략한다.
63
+ * @property {React.FocusEventHandler<HTMLButtonElement>} [props.onFocus] native 액션 button의 focus 이벤트. Root의 열림 제어는 Root 입력으로 설정한다.
64
+ * @property {React.KeyboardEventHandler<HTMLButtonElement>} [props.onKeyDown] Root 탐색으로 버블링되는 native 키 이벤트. preventDefault하면 Root의 해당 기본 처리를 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
65
+ * @property {string} [props.id] 액션 button의 DOM ID. 같은 화면에서 고유하게 지정한다.
66
+ * @property {string} [props.className] wrapper·Tooltip이 아닌 액션 button에 fab 클래스와 병합할 추가 클래스.
67
+ * @property {CSSProperties} [props.style] 액션 button의 inline style.
68
+ * @property {React.Ref<HTMLButtonElement>} [ref] wrapper가 아닌 실제 액션 button에 전달하는 object/callback ref.
69
+ * @see FabMenuItemProps
70
+ * @see FabRoot
71
+ * @desc FabDefaultProps에서 role·tabIndex를 제외하고 tooltipSide를 더한 계약으로 Button과 기존 Tooltip을 조립한다. children을 받지 않는 아이콘 버튼이다. 같은 Root의 Menu.List 안에서 사용하며 role=menuitem·tabIndex=-1은 모듈이 소유한다. 나머지 native button 속성·이벤트는 액션 button에 전달한다. Tooltip의 document capture Escape도 전체 닫힘·Trigger focus 복귀를 요청하며 Tooltip 자체 dismiss도 유지한다. Root가 닫히면 Tooltip 문구의 portal도 제거하고 버튼과 ref는 유지한다. Root 밖에서 사용하면 오류를 발생시킨다.
72
+ */
73
+ const FabMenuItem = forwardRef<HTMLButtonElement, FabMenuItemProps>(
74
+ ({ tooltipSide, onClick, ...props }, ref) => {
75
+ const { open, direction, persistentTooltips, onClose } =
76
+ useFabContext("Fab.Menu.Item");
77
+ return (
78
+ <div className="fab-menu-item" role="none">
79
+ <Tooltip.Root open={persistentTooltips ? open : undefined}>
80
+ <Tooltip.Trigger asChild>
81
+ <FabDefault
82
+ {...props}
83
+ ref={ref}
84
+ role="menuitem"
85
+ tabIndex={-1}
86
+ onClick={event => {
87
+ onClick?.(event);
88
+ if (!event.defaultPrevented) onClose(true);
89
+ }}
90
+ />
91
+ </Tooltip.Trigger>
92
+ {/* 닫힌 목록의 Tooltip portal만 제거하고 버튼·ref·Tooltip 상태는 유지한다. */}
93
+ {open && (
94
+ <Tooltip.Message
95
+ // Tooltip의 document capture Escape는 전체 닫힘·focus 복귀를 먼저 처리한다.
96
+ // 이벤트를 취소하지 않아 비제어형 Tooltip의 기본 dismiss도 실행한다.
97
+ onEscapeKeyDown={() => onClose(true)}
98
+ side={
99
+ tooltipSide ??
100
+ (direction === "up" || direction === "down" ? "left" : "top")
101
+ }
102
+ >
103
+ {props["aria-label"]}
104
+ </Tooltip.Message>
105
+ )}
106
+ </Tooltip.Root>
107
+ </div>
108
+ );
109
+ },
110
+ );
111
+
112
+ FabMenuItem.displayName = "FabMenuItem";
113
+
114
+ export { FabMenuList, FabMenuItem };
@@ -0,0 +1,263 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import {
5
+ createContext,
6
+ forwardRef,
7
+ useContext,
8
+ useId,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import type {
14
+ FocusEventHandler,
15
+ KeyboardEventHandler,
16
+ MouseEventHandler,
17
+ } from "react";
18
+ import type { FabContextValue, FabRootProps } from "../types";
19
+
20
+ /**
21
+ * Fab Context; Root와 조립 부품의 상태·ARIA·interaction 연결
22
+ * @see FabContextValue
23
+ * @see useFabContext
24
+ * @desc FabRoot가 Provider로 열림 상태·물리 방향·Tooltip 정책·ID·DOM ref·공통 handler를 제공한다. Trigger와 Menu 부품은 useFabContext로 가장 가까운 Root의 값을 읽는다. 기본값 null은 Provider 부재를 나타내며 Hook이 Root 밖의 부품 사용을 오류로 알린다.
25
+ */
26
+ export const FabContext = createContext<FabContextValue | null>(null);
27
+
28
+ /**
29
+ * Fab Constants; 물리 펼침 방향별 메뉴 탐색 키
30
+ * @property {readonly ["ArrowUp", "ArrowDown"]} up 위쪽 메뉴의 다음·이전 탐색 키.
31
+ * @property {readonly ["ArrowDown", "ArrowUp"]} down 아래쪽 메뉴의 다음·이전 탐색 키.
32
+ * @property {readonly ["ArrowLeft", "ArrowRight"]} left 왼쪽 메뉴의 다음·이전 탐색 키.
33
+ * @property {readonly ["ArrowRight", "ArrowLeft"]} right 오른쪽 메뉴의 다음·이전 탐색 키.
34
+ * @desc 열린 메뉴의 물리 펼침 방향별로 [다음 액션으로 이동하는 키, 이전 액션 또는 Trigger로 돌아가는 키]를 제공한다. RTL에서도 방향을 반전하지 않는다. 닫힌 Trigger는 이 순서와 관계없이 모든 방향키로 첫 액션에 진입한다. TypeScript에서 각 배열은 readonly tuple이다.
35
+ */
36
+ export const ARROW_KEYS = {
37
+ up: ["ArrowUp", "ArrowDown"],
38
+ down: ["ArrowDown", "ArrowUp"],
39
+ left: ["ArrowLeft", "ArrowRight"],
40
+ right: ["ArrowRight", "ArrowLeft"],
41
+ } as const;
42
+
43
+ /**
44
+ * Fab Hook; 조립 부품의 공통 상태 조회
45
+ * @hook
46
+ * @param {string} component 호출 부품 이름.
47
+ * @desc Root의 열림 상태·ref·interaction을 읽는다. Root 밖의 부품 사용은 오류로 알린다.
48
+ */
49
+ export function useFabContext(component: string): FabContextValue {
50
+ const context = useContext(FabContext);
51
+ if (!context) throw new Error(`${component} must be used within Fab.Root.`);
52
+ return context;
53
+ }
54
+
55
+ /**
56
+ * Fab Component; 공통 상태와 액션 탐색
57
+ * @component
58
+ * @property {ReactNode} props.children 필수 조합. Trigger 하나와 Menu.List 하나를 두고 List 안에 Menu.Item을 배치한다.
59
+ * @property {"up" | "down" | "left" | "right"} [props.direction="up"] 액션의 물리 펼침 방향. RTL에서도 좌우를 반전하지 않는다.
60
+ * @property {boolean} [props.persistentTooltips=false] 열린 메뉴의 행동 이름을 hover 여부와 관계없이 계속 표시한다.
61
+ * @property {boolean} [props.open] 제어형 열림 상태. 지정하면 onOpenChange 요청을 소비자가 반영해야 한다.
62
+ * @property {boolean} [props.defaultOpen=false] 비제어형 초기 열림 상태. open이 지정되면 사용하지 않는다.
63
+ * @property {(open: boolean) => void} [props.onOpenChange] hover·focus·토글·키보드·선택·외부 focus 이탈의 열림 변경 요청. 현재 상태와 같은 요청은 통지하지 않는다.
64
+ * @property {React.FocusEventHandler<HTMLDivElement>} [props.onBlur] 기본 처리보다 먼저 호출한다. preventDefault하면 외부 focus 이탈에 따른 닫힘을 생략한다.
65
+ * @property {React.KeyboardEventHandler<HTMLDivElement>} [props.onKeyDown] 기본 메뉴 탐색보다 먼저 호출한다. preventDefault하면 이 경로의 탐색·닫힘을 생략하지만 Tooltip의 document capture Escape 경로는 취소하지 못한다.
66
+ * @property {React.PointerEventHandler<HTMLDivElement>} [props.onPointerEnter] 기본 처리보다 먼저 호출한다. 닫힌 상태의 포인터 진입 대상은 Trigger다. preventDefault하면 mouse hover 열기를 생략하며 disabled Trigger는 hover로 열지 않는다.
67
+ * @property {React.PointerEventHandler<HTMLDivElement>} [props.onPointerLeave] 기본 처리보다 먼저 호출한다. preventDefault하면 pointer 이탈 닫힘을 생략한다. 내부 focus가 있으면 기본 닫힘도 생략한다.
68
+ * @property {React.MouseEventHandler<HTMLDivElement>} [props.onClick] 부품의 실행·토글 처리 뒤 Root로 버블링되는 native 클릭 이벤트.
69
+ * @property {string} [props.className] 최상위 div에 fab-root 클래스와 병합할 추가 클래스. 내부 요소는 nested selector로 조정한다.
70
+ * @property {CSSProperties} [props.style] 최상위 div의 inline style. 화면 고정 위치는 소비자가 정한다.
71
+ * @property {React.Ref<HTMLDivElement>} [ref] 최상위 Root div에 전달하는 object/callback ref.
72
+ * @see FabRootProps
73
+ * @desc 부품에 상태·ID·ARIA·ref와 공통 interaction을 제공한다. 닫힌 Trigger의 모든 방향키는 첫 액션을 열고, 열린 메뉴에서는 펼침 방향 키가 다음 액션으로 이동하며 반대 키는 Trigger에서 멈춘다. Home/End는 첫/마지막 액션으로 이동한다. 첫 액션은 Trigger에 가장 가깝고 disabled 항목은 탐색에서 제외한다. Escape·선택은 닫고 Trigger로 복귀하며 외부 Tab은 focus를 빼앗지 않는다. 닫힌 목록도 배치 영역을 유지한다. FabRootProps가 상속한 나머지 native div 속성은 Root에 전달하며 viewport 배치·서비스 실행은 소비자가 맡는다.
74
+ * @example
75
+ * <Fab.Root direction="up">
76
+ * <Fab.Trigger icon={<span>+</span>} aria-label="관련 작업"
77
+ * size="large" fill="solid" priority="primary" />
78
+ * <Fab.Menu.List>
79
+ * <Fab.Menu.Item icon={<span>⧉</span>} aria-label="복사"
80
+ * size="small" fill="outlined" priority="primary" />
81
+ * </Fab.Menu.List>
82
+ * </Fab.Root>
83
+ */
84
+ const FabRoot = forwardRef<HTMLDivElement, FabRootProps>(
85
+ (
86
+ {
87
+ children,
88
+ direction = "up",
89
+ persistentTooltips = false,
90
+ open: controlledOpen,
91
+ defaultOpen = false,
92
+ onOpenChange: onOpenChangeProp,
93
+ className,
94
+ onBlur,
95
+ onKeyDown,
96
+ onPointerEnter,
97
+ onPointerLeave,
98
+ ...rest
99
+ },
100
+ ref,
101
+ ) => {
102
+ const id = useId();
103
+ const [uncontrolledOpen, setUncontrolledOpen] =
104
+ useState<boolean>(defaultOpen);
105
+ const open: boolean = controlledOpen ?? uncontrolledOpen;
106
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
107
+ const menuRef = useRef<HTMLDivElement | null>(null);
108
+ const pendingFocusRef = useRef<HTMLButtonElement | null>(null);
109
+ const hoverOpenedRef = useRef<boolean>(false);
110
+
111
+ // 닫힌 목록의 focus 요청은 소비자가 열림을 반영한 commit 뒤에 실행한다.
112
+ useLayoutEffect(() => {
113
+ if (open) {
114
+ pendingFocusRef.current?.focus();
115
+ pendingFocusRef.current = null;
116
+ } else {
117
+ // 제어형 소비자가 외부에서 닫아도 이전 hover 진입을 다음 토글에 남기지 않는다.
118
+ hoverOpenedRef.current = false;
119
+ }
120
+ }, [open]);
121
+
122
+ const onOpenChange: (next: boolean) => void = next => {
123
+ if (next === open) return;
124
+ if (controlledOpen === undefined) setUncontrolledOpen(next);
125
+ onOpenChangeProp?.(next);
126
+ };
127
+
128
+ const onClose: (restoreFocus: boolean) => void = restoreFocus => {
129
+ pendingFocusRef.current = null;
130
+ hoverOpenedRef.current = false;
131
+ onOpenChange(false);
132
+ if (restoreFocus) triggerRef.current?.focus();
133
+ };
134
+
135
+ const onTriggerClick: MouseEventHandler<HTMLButtonElement> = event => {
136
+ // Hover로 먼저 열린 첫 포인터 클릭은 진입을 유지하고 이후 클릭은 다시 토글한다.
137
+ const keepOpen = event.detail > 0 && hoverOpenedRef.current;
138
+ hoverOpenedRef.current = false;
139
+ onOpenChange(keepOpen || !open);
140
+ };
141
+
142
+ const onTriggerFocus: FocusEventHandler<HTMLButtonElement> = event => {
143
+ // Escape·선택 뒤의 내부 focus 복귀는 메뉴를 다시 열지 않는다.
144
+ if (
145
+ event.relatedTarget &&
146
+ menuRef.current?.contains(event.relatedTarget)
147
+ ) {
148
+ return;
149
+ }
150
+ // Pointer는 hover/click이 담당하고 키보드 진입은 focus로 연다.
151
+ if (event.currentTarget.matches(":focus-visible")) onOpenChange(true);
152
+ };
153
+
154
+ const onNavigate: KeyboardEventHandler<HTMLDivElement> = event => {
155
+ if (event.key === "Escape") {
156
+ event.preventDefault();
157
+ onClose(true);
158
+ return;
159
+ }
160
+ const actions = Array.from(
161
+ menuRef.current?.querySelectorAll<HTMLButtonElement>(
162
+ 'button[role="menuitem"]:not(:disabled)',
163
+ ) ?? [],
164
+ );
165
+ const current = actions.indexOf(event.target as HTMLButtonElement);
166
+ const [forwardKey, backwardKey] = ARROW_KEYS[direction];
167
+ const forward = event.key === forwardKey;
168
+ const backward = event.key === backwardKey;
169
+ if (!(
170
+ (current === -1 && event.key.startsWith("Arrow")) ||
171
+ forward ||
172
+ backward ||
173
+ event.key === "Home" ||
174
+ event.key === "End"
175
+ ))
176
+ return;
177
+ event.preventDefault();
178
+ hoverOpenedRef.current = false;
179
+ // 열린 Trigger는 반대 방향 탐색의 끝이며 닫힌 Trigger는 모든 방향키로 진입한다.
180
+ if (open && current === -1 && backward) return;
181
+ // DOM 순서의 첫 액션이 Trigger에 가장 가깝다.
182
+ const next =
183
+ event.key === "End"
184
+ ? actions.length - 1
185
+ : event.key === "Home" || current === -1
186
+ ? 0
187
+ : forward
188
+ ? Math.min(current + 1, actions.length - 1)
189
+ : current - 1;
190
+ const target = next < 0 ? triggerRef.current : actions[next];
191
+ if (open) target?.focus();
192
+ else {
193
+ pendingFocusRef.current = target ?? null;
194
+ onOpenChange(true);
195
+ }
196
+ };
197
+
198
+ return (
199
+ <FabContext.Provider
200
+ value={{
201
+ open,
202
+ direction,
203
+ persistentTooltips,
204
+ triggerId: `${id}-trigger`,
205
+ menuId: `${id}-menu`,
206
+ triggerRef,
207
+ menuRef,
208
+ onClose,
209
+ onTriggerClick,
210
+ onTriggerFocus,
211
+ }}
212
+ >
213
+ <div
214
+ {...rest}
215
+ ref={ref}
216
+ className={clsx("fab-root", className)}
217
+ data-direction={direction}
218
+ data-open={open}
219
+ onBlur={event => {
220
+ onBlur?.(event);
221
+ if (
222
+ !event.defaultPrevented &&
223
+ !event.currentTarget.contains(event.relatedTarget)
224
+ )
225
+ onClose(false);
226
+ }}
227
+ onKeyDown={event => {
228
+ onKeyDown?.(event);
229
+ if (!event.defaultPrevented) onNavigate(event);
230
+ }}
231
+ onPointerEnter={event => {
232
+ onPointerEnter?.(event);
233
+ if (
234
+ !event.defaultPrevented &&
235
+ event.pointerType === "mouse" &&
236
+ !triggerRef.current?.disabled
237
+ ) {
238
+ if (!open) hoverOpenedRef.current = true;
239
+ onOpenChange(true);
240
+ }
241
+ }}
242
+ onPointerLeave={event => {
243
+ onPointerLeave?.(event);
244
+ // 키보드 탐색 중인 목록은 포인터가 벗어나도 유지한다.
245
+ if (
246
+ !event.defaultPrevented &&
247
+ !event.currentTarget.contains(
248
+ event.currentTarget.ownerDocument.activeElement,
249
+ )
250
+ )
251
+ onClose(false);
252
+ }}
253
+ >
254
+ {children}
255
+ </div>
256
+ </FabContext.Provider>
257
+ );
258
+ },
259
+ );
260
+
261
+ FabRoot.displayName = "FabRoot";
262
+
263
+ export { FabRoot };
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import type { FabTemplateProps } from "../types";
5
+ import { FabRoot } from "./Root";
6
+ import { FabTrigger } from "./Trigger";
7
+ import { FabMenuItem, FabMenuList } from "./Menu";
8
+
9
+ /**
10
+ * Fab Component; 바로 사용하는 기본 액션 조합
11
+ * @component
12
+ * @property {Omit<FabTriggerProps, "className">} props.triggerProps 필수 Trigger 입력. 아래 필수 필드와 native button 입력을 전달한다.
13
+ * @property {ReactNode} props.triggerProps.icon 필수 장식용 콘텐츠. 열리면 45도 회전한다.
14
+ * @property {string} props.triggerProps.aria-label 필수 Trigger 행동 이름이자 기본 메뉴 이름.
15
+ * @property {"small" | "medium" | "large"} props.triggerProps.size 필수 Trigger 높이 40/48/56px. 아이콘 영역은 16/20/24px다.
16
+ * @property {"solid" | "outlined"} props.triggerProps.fill 필수 Trigger 채움 방식. outlined의 기본 면은 불투명 흰색이다.
17
+ * @property {"primary" | "secondary" | "tertiary" | "green" | "yellow" | "orange" | "teal" | "pink" | "red" | "darkGray" | "lightGray"} props.triggerProps.priority 두 fill 모두에서 사용하는 필수 Trigger 색상.
18
+ * @property {boolean} [props.triggerProps.disabled=false] Trigger의 native 실행·Tab 진입과 Root의 mouse hover 열기를 차단한다.
19
+ * @property {React.MouseEventHandler<HTMLButtonElement>} [props.triggerProps.onClick] 기본 클릭 처리 전에 호출한다. preventDefault하면 처리를 생략한다. hover로 열린 첫 pointer 클릭은 열림을 유지하고 이후 클릭과 Enter·Space는 토글한다.
20
+ * @property {React.FocusEventHandler<HTMLButtonElement>} [props.triggerProps.onFocus] focus 처리 전에 호출한다. preventDefault하면 해당 focus의 열기를 생략한다.
21
+ * @property {React.KeyboardEventHandler<HTMLButtonElement>} [props.triggerProps.onKeyDown] Root 탐색으로 버블링되는 native 키 이벤트. preventDefault하면 Root의 해당 처리를 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
22
+ * @property {CSSProperties} [props.triggerProps.style] Trigger button의 inline style.
23
+ * @property {"button" | "submit" | "reset"} [props.triggerProps.type="button"] Trigger의 native 버튼 동작.
24
+ * @property {FabTemplateItem[]} props.items 필수 액션 목록. 빈 배열도 허용하며 배열 순서의 첫 항목이 Trigger에 가장 가깝다.
25
+ * @property {string} props.items[].id 필수 액션 DOM ID이자 React key. 같은 화면에서 고유하게 지정한다.
26
+ * @property {ReactNode} props.items[].icon 필수 장식용 액션 콘텐츠.
27
+ * @property {string} props.items[].aria-label 필수 행동 이름이자 Tooltip 문구.
28
+ * @property {"small" | "medium" | "large"} props.items[].size 필수 액션 높이 40/48/56px. 아이콘 영역은 16/20/24px다.
29
+ * @property {"solid" | "outlined"} props.items[].fill 필수 액션 채움 방식. outlined의 기본 면은 불투명 흰색이다.
30
+ * @property {"primary" | "secondary" | "tertiary" | "green" | "yellow" | "orange" | "teal" | "pink" | "red" | "darkGray" | "lightGray"} props.items[].priority 두 fill 모두에서 사용하는 필수 액션 색상.
31
+ * @property {"top" | "right" | "bottom" | "left"} [props.items[].tooltipSide] 툴팁 방향. up/down 메뉴는 left, left/right 메뉴는 top이 기본이다.
32
+ * @property {boolean} [props.items[].disabled=false] 해당 액션의 실행과 방향키 탐색을 차단한다.
33
+ * @property {React.MouseEventHandler<HTMLButtonElement>} [props.items[].onClick] 소비자 액션을 먼저 실행한 뒤 닫고 Trigger로 focus를 복귀한다. preventDefault하면 자동 처리를 생략한다.
34
+ * @property {React.FocusEventHandler<HTMLButtonElement>} [props.items[].onFocus] 해당 native 액션 button의 focus 이벤트.
35
+ * @property {React.KeyboardEventHandler<HTMLButtonElement>} [props.items[].onKeyDown] Root 탐색으로 버블링되는 native 키 이벤트. preventDefault하면 Root의 해당 처리를 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
36
+ * @property {CSSProperties} [props.items[].style] 해당 액션 button의 inline style.
37
+ * @property {"button" | "submit" | "reset"} [props.items[].type="button"] 액션의 native 버튼 동작.
38
+ * @property {"up" | "down" | "left" | "right"} [props.direction="up"] 액션의 물리 펼침 방향. RTL에서도 반전하지 않는다.
39
+ * @property {boolean} [props.persistentTooltips=false] 열린 액션의 Tooltip을 계속 표시한다.
40
+ * @property {boolean} [props.open] 제어형 열림 상태. onOpenChange 요청을 소비자가 반영해야 한다.
41
+ * @property {boolean} [props.defaultOpen=false] 비제어형 초기 열림 상태. open이 지정되면 무시한다.
42
+ * @property {(open: boolean) => void} [props.onOpenChange] 현재 상태와 다른 열림 변경 요청을 전달한다.
43
+ * @property {React.FocusEventHandler<HTMLDivElement>} [props.onBlur] Root의 외부 focus 이탈 닫힘 전에 호출한다. preventDefault하면 해당 기본 닫힘을 생략한다.
44
+ * @property {React.KeyboardEventHandler<HTMLDivElement>} [props.onKeyDown] Root의 탐색 전에 호출한다. preventDefault하면 이 경로의 기본 동작을 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
45
+ * @property {React.PointerEventHandler<HTMLDivElement>} [props.onPointerEnter] Root의 mouse hover 열기 전에 호출한다. 닫힌 상태의 포인터 진입 대상은 Trigger다. preventDefault하면 해당 기본 열기를 생략한다.
46
+ * @property {React.PointerEventHandler<HTMLDivElement>} [props.onPointerLeave] Root의 pointer 이탈 닫힘 전에 호출한다. preventDefault하면 해당 기본 닫힘을 생략한다. 내부 focus가 있으면 기본 닫힘도 생략한다.
47
+ * @property {React.MouseEventHandler<HTMLDivElement>} [props.onClick] 부품의 실행·토글 처리 뒤 Root로 버블링되는 native 클릭 이벤트.
48
+ * @property {string} [props.className] 최상위 Root div 하나에만 적용하는 추가 클래스. 내부 스타일은 nested selector로 조정한다.
49
+ * @property {CSSProperties} [props.style] 최상위 Root div 하나의 inline style.
50
+ * @property {React.Ref<HTMLDivElement>} [ref] 최상위 Root div의 ref. Trigger·액션의 ref 입력은 제공하지 않는다.
51
+ * @see FabTemplateProps
52
+ * @see FabTrigger
53
+ * @see FabMenuItem
54
+ * @desc 공개 Root·Trigger·Menu.List·Menu.Item과 같은 구현을 조립한다. children과 내부 버튼의 className을 받지 않으며 런타임에 전달된 내부 className도 무시한다. 내부 스타일은 Root className의 nested selector 또는 data attribute로 조정한다. Trigger는 id·aria-haspopup·aria-expanded·aria-controls를, 각 액션은 role·tabIndex를 모듈이 소유하며 children·ref는 nested 입력에 포함하지 않는다. 액션마다 나머지 native button 입력을 전달하고 나머지 native div 입력은 Root에 전달한다. 열림·탐색·닫힘·원점 유지·focus 복귀는 모듈이, 실행 결과와 viewport 배치는 소비자가 맡는다.
55
+ * @example
56
+ * <Fab.Template
57
+ * triggerProps={{ icon: <span>+</span>, "aria-label": "관련 작업",
58
+ * size: "large", fill: "solid", priority: "primary" }}
59
+ * items={[{ id: "copy-action", icon: <span>⧉</span>, "aria-label": "복사",
60
+ * size: "small", fill: "outlined", priority: "primary" }]}
61
+ * />
62
+ */
63
+ const FabTemplate = forwardRef<HTMLDivElement, FabTemplateProps>(
64
+ ({ triggerProps, items, ...rootProps }, ref) => (
65
+ <FabRoot {...rootProps} ref={ref}>
66
+ {/* Template의 내부 클래스는 소비자가 전달한 객체의 추가 필드로도 덮어쓰지 않는다. */}
67
+ <FabTrigger {...triggerProps} className={undefined} />
68
+ <FabMenuList>
69
+ {items.map(item => (
70
+ <FabMenuItem key={item.id} {...item} className={undefined} />
71
+ ))}
72
+ </FabMenuList>
73
+ </FabRoot>
74
+ ),
75
+ );
76
+
77
+ FabTemplate.displayName = "FabTemplate";
78
+
79
+ export { FabTemplate };
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { forwardRef, useImperativeHandle } from "react";
5
+ import type { FabTriggerProps } from "../types";
6
+ import { FabDefault } from "./Default";
7
+ import { useFabContext } from "./Root";
8
+
9
+ /**
10
+ * Fab Component; 공통 버튼에 연결한 메뉴 Trigger
11
+ * @component
12
+ * @property {ReactNode} props.icon 필수 장식용 콘텐츠. 열리면 콘텐츠 영역이 45도 회전하며 reduced motion에서는 전환을 생략한다.
13
+ * @property {"small" | "medium" | "large"} props.size 필수 크기. 버튼 높이는 각각 40/48/56px, 아이콘 영역은 16/20/24px다.
14
+ * @property {"solid" | "outlined"} props.fill 필수 채움 방식. outlined의 기본 면은 불투명 흰색이다.
15
+ * @property {"primary" | "secondary" | "tertiary" | "green" | "yellow" | "orange" | "teal" | "pink" | "red" | "darkGray" | "lightGray"} props.priority 필수 색상. 두 fill 모두에서 같은 목록을 지원한다.
16
+ * @property {string} props.aria-label 필수 행동 이름. Menu.List가 이름을 따로 지정하지 않으면 메뉴 이름으로도 사용한다.
17
+ * @property {"button" | "submit" | "reset"} [props.type="button"] native 버튼 동작. 메뉴 열기에 폼 제출·초기화가 필요하면 소비자가 명시한다.
18
+ * @property {boolean} [props.disabled=false] native 실행·Tab 진입을 차단하고 Root의 mouse hover 열기도 차단한다.
19
+ * @property {React.MouseEventHandler<HTMLButtonElement>} [props.onClick] 기본 처리 전에 호출한다. preventDefault하면 해당 클릭의 열림 처리를 생략한다. hover로 먼저 열린 첫 포인터 클릭은 열림을 유지하고 이후 클릭·Enter·Space는 토글한다.
20
+ * @property {React.FocusEventHandler<HTMLButtonElement>} [props.onFocus] focus 처리 전에 호출한다. preventDefault하면 해당 focus의 열기를 생략한다. 선택·Escape 뒤 내부 focus 복귀는 다시 열지 않는다.
21
+ * @property {React.KeyboardEventHandler<HTMLButtonElement>} [props.onKeyDown] Root 탐색으로 버블링되는 native 키 이벤트. preventDefault하면 Root의 해당 기본 처리를 생략하지만 Tooltip의 document capture Escape는 취소하지 못한다.
22
+ * @property {string} [props.className] 최상위 button에 fab·fab-trigger 클래스와 병합할 추가 클래스.
23
+ * @property {CSSProperties} [props.style] 최상위 button의 inline style.
24
+ * @property {React.Ref<HTMLButtonElement>} [ref] 실제 Trigger button에 전달하는 object/callback ref.
25
+ * @see FabTriggerProps
26
+ * @see FabRoot
27
+ * @desc 같은 Root에 속한 액션 목록을 여는 Button이다. FabTriggerProps는 FabDefaultProps에서 id·aria-haspopup·aria-expanded·aria-controls를 제외하며 Root가 이 속성을 소유한다. Button과 마찬가지로 children을 받지 않는 아이콘 전용 버튼이고 나머지 native button 속성·이벤트는 전달한다. Root 밖에서 사용하면 오류를 발생시킨다.
28
+ */
29
+ const FabTrigger = forwardRef<HTMLButtonElement, FabTriggerProps>(
30
+ ({ className, onClick, onFocus, ...props }, ref) => {
31
+ const {
32
+ open,
33
+ triggerId,
34
+ menuId,
35
+ triggerRef,
36
+ onTriggerClick,
37
+ onTriggerFocus,
38
+ } = useFabContext("Fab.Trigger");
39
+ // DOM ref를 공유하고 소비자 callback ref의 React cleanup 계약은 React에 맡긴다.
40
+ useImperativeHandle(ref, () => triggerRef.current!, [triggerRef]);
41
+ return (
42
+ <FabDefault
43
+ {...props}
44
+ ref={triggerRef}
45
+ id={triggerId}
46
+ className={clsx("fab-trigger", className)}
47
+ aria-haspopup="menu"
48
+ aria-expanded={open}
49
+ aria-controls={menuId}
50
+ onClick={event => {
51
+ onClick?.(event);
52
+ if (!event.defaultPrevented) onTriggerClick(event);
53
+ }}
54
+ onFocus={event => {
55
+ onFocus?.(event);
56
+ if (!event.defaultPrevented) onTriggerFocus(event);
57
+ }}
58
+ />
59
+ );
60
+ },
61
+ );
62
+
63
+ FabTrigger.displayName = "FabTrigger";
64
+
65
+ export { FabTrigger };
@@ -0,0 +1,7 @@
1
+ export { FabDefault } from "./Default";
2
+ export { FabExtended } from "./Extended";
3
+ export { FabLabel } from "./Label";
4
+ export { FabRoot, FabContext, ARROW_KEYS, useFabContext } from "./Root";
5
+ export { FabTrigger } from "./Trigger";
6
+ export { FabMenuList, FabMenuItem } from "./Menu";
7
+ export { FabTemplate } from "./Template";
@@ -0,0 +1,80 @@
1
+ .fab-root,
2
+ .fab-menu-list {
3
+ display: flex;
4
+ align-items: center;
5
+ gap: 24px;
6
+ }
7
+
8
+ .fab-menu-list[data-open="false"] {
9
+ // 전역 hidden reset의 display 제거를 피하고 닫힌 목록의 배치 공간을 유지한다.
10
+ // 접근성·focus 제외는 같은 상태의 aria-hidden·inert가 담당한다.
11
+ visibility: hidden;
12
+ }
13
+
14
+ .fab-root {
15
+ // 물리 좌우 방향과 방향키는 문서의 RTL 여부에 따라 반전되지 않는다.
16
+ direction: ltr;
17
+
18
+ &[data-open="false"] {
19
+ // 원점을 위한 예약 공간은 포인터 대상에서 제외하고 실제 Trigger로만 진입한다.
20
+ pointer-events: none;
21
+
22
+ .fab-trigger {
23
+ pointer-events: auto;
24
+ }
25
+ }
26
+
27
+ &[data-direction="up"],
28
+ &[data-direction="up"] .fab-menu-list {
29
+ flex-direction: column-reverse;
30
+ }
31
+
32
+ &[data-direction="down"],
33
+ &[data-direction="down"] .fab-menu-list {
34
+ flex-direction: column;
35
+ }
36
+
37
+ &[data-direction="left"],
38
+ &[data-direction="left"] .fab-menu-list {
39
+ flex-direction: row-reverse;
40
+ }
41
+
42
+ &[data-direction="right"],
43
+ &[data-direction="right"] .fab-menu-list {
44
+ flex-direction: row;
45
+ }
46
+
47
+ &[data-open="true"] .fab-trigger .fab-content {
48
+ transform: rotate(45deg);
49
+ }
50
+ }
51
+
52
+ .fab-trigger .fab-content {
53
+ transition: transform 150ms ease;
54
+ }
55
+
56
+ .fab-root[data-open="true"] .fab-menu-item {
57
+ animation: fab-menu-enter 150ms ease-out;
58
+ }
59
+
60
+ @keyframes fab-menu-enter {
61
+ from {
62
+ opacity: 0;
63
+ transform: scale(0.75);
64
+ }
65
+
66
+ to {
67
+ opacity: 1;
68
+ transform: scale(1);
69
+ }
70
+ }
71
+
72
+ @media (prefers-reduced-motion: reduce) {
73
+ .fab-trigger .fab-content {
74
+ transition: none;
75
+ }
76
+
77
+ .fab-root[data-open="true"] .fab-menu-item {
78
+ animation: none;
79
+ }
80
+ }