@hua-labs/hua-ux 0.1.0-alpha.0.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/README.md +839 -0
- package/dist/framework/a11y/components/LiveRegion.d.ts +64 -0
- package/dist/framework/a11y/components/LiveRegion.d.ts.map +1 -0
- package/dist/framework/a11y/components/LiveRegion.js +43 -0
- package/dist/framework/a11y/components/SkipToContent.d.ts +62 -0
- package/dist/framework/a11y/components/SkipToContent.d.ts.map +1 -0
- package/dist/framework/a11y/components/SkipToContent.js +60 -0
- package/dist/framework/a11y/hooks/useFocusManagement.d.ts +60 -0
- package/dist/framework/a11y/hooks/useFocusManagement.d.ts.map +1 -0
- package/dist/framework/a11y/hooks/useFocusManagement.js +71 -0
- package/dist/framework/a11y/hooks/useFocusTrap.d.ts +64 -0
- package/dist/framework/a11y/hooks/useFocusTrap.d.ts.map +1 -0
- package/dist/framework/a11y/hooks/useFocusTrap.js +185 -0
- package/dist/framework/a11y/hooks/useLiveRegion.d.ts +56 -0
- package/dist/framework/a11y/hooks/useLiveRegion.d.ts.map +1 -0
- package/dist/framework/a11y/hooks/useLiveRegion.js +60 -0
- package/dist/framework/a11y/index.d.ts +16 -0
- package/dist/framework/a11y/index.d.ts.map +1 -0
- package/dist/framework/a11y/index.js +11 -0
- package/dist/framework/branding/context.d.ts +52 -0
- package/dist/framework/branding/context.d.ts.map +1 -0
- package/dist/framework/branding/context.js +96 -0
- package/dist/framework/branding/css-vars.d.ts +34 -0
- package/dist/framework/branding/css-vars.d.ts.map +1 -0
- package/dist/framework/branding/css-vars.js +95 -0
- package/dist/framework/branding/tailwind-config.d.ts +38 -0
- package/dist/framework/branding/tailwind-config.d.ts.map +1 -0
- package/dist/framework/branding/tailwind-config.js +66 -0
- package/dist/framework/components/BrandedButton.d.ts +53 -0
- package/dist/framework/components/BrandedButton.d.ts.map +1 -0
- package/dist/framework/components/BrandedButton.js +40 -0
- package/dist/framework/components/BrandedCard.d.ts +52 -0
- package/dist/framework/components/BrandedCard.d.ts.map +1 -0
- package/dist/framework/components/BrandedCard.js +73 -0
- package/dist/framework/components/ErrorBoundary.d.ts +92 -0
- package/dist/framework/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/framework/components/ErrorBoundary.js +121 -0
- package/dist/framework/components/HuaUxLayout.d.ts +29 -0
- package/dist/framework/components/HuaUxLayout.d.ts.map +1 -0
- package/dist/framework/components/HuaUxLayout.js +32 -0
- package/dist/framework/components/HuaUxPage.d.ts +48 -0
- package/dist/framework/components/HuaUxPage.d.ts.map +1 -0
- package/dist/framework/components/HuaUxPage.js +105 -0
- package/dist/framework/components/Providers.d.ts +17 -0
- package/dist/framework/components/Providers.d.ts.map +1 -0
- package/dist/framework/components/Providers.js +72 -0
- package/dist/framework/components/WelcomePage.d.ts +44 -0
- package/dist/framework/components/WelcomePage.d.ts.map +1 -0
- package/dist/framework/components/WelcomePage.js +80 -0
- package/dist/framework/config/index.d.ts +182 -0
- package/dist/framework/config/index.d.ts.map +1 -0
- package/dist/framework/config/index.js +329 -0
- package/dist/framework/config/merge.d.ts +26 -0
- package/dist/framework/config/merge.d.ts.map +1 -0
- package/dist/framework/config/merge.js +160 -0
- package/dist/framework/config/schema.d.ts +25 -0
- package/dist/framework/config/schema.d.ts.map +1 -0
- package/dist/framework/config/schema.js +122 -0
- package/dist/framework/hooks/useMotion.d.ts +45 -0
- package/dist/framework/hooks/useMotion.d.ts.map +1 -0
- package/dist/framework/hooks/useMotion.js +40 -0
- package/dist/framework/index.d.ts +37 -0
- package/dist/framework/index.d.ts.map +1 -0
- package/dist/framework/index.js +42 -0
- package/dist/framework/license/errors.d.ts +15 -0
- package/dist/framework/license/errors.d.ts.map +1 -0
- package/dist/framework/license/errors.js +52 -0
- package/dist/framework/license/index.d.ts +70 -0
- package/dist/framework/license/index.d.ts.map +1 -0
- package/dist/framework/license/index.js +124 -0
- package/dist/framework/license/loader.d.ts +26 -0
- package/dist/framework/license/loader.d.ts.map +1 -0
- package/dist/framework/license/loader.js +137 -0
- package/dist/framework/license/types.d.ts +67 -0
- package/dist/framework/license/types.d.ts.map +1 -0
- package/dist/framework/license/types.js +18 -0
- package/dist/framework/loading/components/SkeletonGroup.d.ts +44 -0
- package/dist/framework/loading/components/SkeletonGroup.d.ts.map +1 -0
- package/dist/framework/loading/components/SkeletonGroup.js +34 -0
- package/dist/framework/loading/components/SuspenseWrapper.d.ts +58 -0
- package/dist/framework/loading/components/SuspenseWrapper.d.ts.map +1 -0
- package/dist/framework/loading/components/SuspenseWrapper.js +40 -0
- package/dist/framework/loading/hoc/withSuspense.d.ts +46 -0
- package/dist/framework/loading/hoc/withSuspense.d.ts.map +1 -0
- package/dist/framework/loading/hoc/withSuspense.js +54 -0
- package/dist/framework/loading/hooks/useDelayedLoading.d.ts +56 -0
- package/dist/framework/loading/hooks/useDelayedLoading.d.ts.map +1 -0
- package/dist/framework/loading/hooks/useDelayedLoading.js +97 -0
- package/dist/framework/loading/hooks/useLoadingState.d.ts +69 -0
- package/dist/framework/loading/hooks/useLoadingState.d.ts.map +1 -0
- package/dist/framework/loading/hooks/useLoadingState.js +59 -0
- package/dist/framework/loading/index.d.ts +16 -0
- package/dist/framework/loading/index.d.ts.map +1 -0
- package/dist/framework/loading/index.js +13 -0
- package/dist/framework/middleware/i18n.d.ts +90 -0
- package/dist/framework/middleware/i18n.d.ts.map +1 -0
- package/dist/framework/middleware/i18n.js +99 -0
- package/dist/framework/plugins/index.d.ts +8 -0
- package/dist/framework/plugins/index.d.ts.map +1 -0
- package/dist/framework/plugins/index.js +6 -0
- package/dist/framework/plugins/registry.d.ts +95 -0
- package/dist/framework/plugins/registry.d.ts.map +1 -0
- package/dist/framework/plugins/registry.js +160 -0
- package/dist/framework/plugins/types.d.ts +97 -0
- package/dist/framework/plugins/types.d.ts.map +1 -0
- package/dist/framework/plugins/types.js +6 -0
- package/dist/framework/seo/geo/examples.d.ts +87 -0
- package/dist/framework/seo/geo/examples.d.ts.map +1 -0
- package/dist/framework/seo/geo/examples.js +295 -0
- package/dist/framework/seo/geo/generateGEOMetadata.d.ts +107 -0
- package/dist/framework/seo/geo/generateGEOMetadata.d.ts.map +1 -0
- package/dist/framework/seo/geo/generateGEOMetadata.js +404 -0
- package/dist/framework/seo/geo/index.d.ts +19 -0
- package/dist/framework/seo/geo/index.d.ts.map +1 -0
- package/dist/framework/seo/geo/index.js +21 -0
- package/dist/framework/seo/geo/presets.d.ts +52 -0
- package/dist/framework/seo/geo/presets.d.ts.map +1 -0
- package/dist/framework/seo/geo/presets.js +47 -0
- package/dist/framework/seo/geo/structuredData.d.ts +187 -0
- package/dist/framework/seo/geo/structuredData.d.ts.map +1 -0
- package/dist/framework/seo/geo/structuredData.js +354 -0
- package/dist/framework/seo/geo/test-utils.d.ts +78 -0
- package/dist/framework/seo/geo/test-utils.d.ts.map +1 -0
- package/dist/framework/seo/geo/test-utils.js +139 -0
- package/dist/framework/seo/geo/types.d.ts +225 -0
- package/dist/framework/seo/geo/types.d.ts.map +1 -0
- package/dist/framework/seo/geo/types.js +51 -0
- package/dist/framework/types/index.d.ts +577 -0
- package/dist/framework/types/index.d.ts.map +1 -0
- package/dist/framework/types/index.js +6 -0
- package/dist/framework/utils/data-fetching.d.ts +45 -0
- package/dist/framework/utils/data-fetching.d.ts.map +1 -0
- package/dist/framework/utils/data-fetching.js +74 -0
- package/dist/framework/utils/file-structure.d.ts +29 -0
- package/dist/framework/utils/file-structure.d.ts.map +1 -0
- package/dist/framework/utils/file-structure.js +72 -0
- package/dist/framework/utils/metadata.d.ts +109 -0
- package/dist/framework/utils/metadata.d.ts.map +1 -0
- package/dist/framework/utils/metadata.js +105 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/presets/index.d.ts +8 -0
- package/dist/presets/index.d.ts.map +1 -0
- package/dist/presets/index.js +7 -0
- package/dist/presets/marketing.d.ts +41 -0
- package/dist/presets/marketing.d.ts.map +1 -0
- package/dist/presets/marketing.js +81 -0
- package/dist/presets/product.d.ts +41 -0
- package/dist/presets/product.d.ts.map +1 -0
- package/dist/presets/product.js +74 -0
- package/package.json +91 -0
- package/src/framework/README.md +329 -0
- package/src/framework/__tests__/branding/css-vars.test.ts +147 -0
- package/src/framework/__tests__/components/ErrorBoundary.test.tsx +146 -0
- package/src/framework/__tests__/config/defineConfig.test.ts +138 -0
- package/src/framework/__tests__/hooks/useMotion.test.ts +105 -0
- package/src/framework/__tests__/seo/geo/generateGEOMetadata.test.ts +207 -0
- package/src/framework/__tests__/seo/geo/structuredData.test.ts +262 -0
- package/src/framework/a11y/components/LiveRegion.tsx +89 -0
- package/src/framework/a11y/components/SkipToContent.tsx +103 -0
- package/src/framework/a11y/hooks/useFocusManagement.ts +125 -0
- package/src/framework/a11y/hooks/useFocusTrap.ts +239 -0
- package/src/framework/a11y/hooks/useLiveRegion.ts +95 -0
- package/src/framework/a11y/index.ts +17 -0
- package/src/framework/branding/context.tsx +135 -0
- package/src/framework/branding/css-vars.ts +110 -0
- package/src/framework/branding/tailwind-config.ts +90 -0
- package/src/framework/components/BrandedButton.tsx +94 -0
- package/src/framework/components/BrandedCard.tsx +87 -0
- package/src/framework/components/ErrorBoundary.tsx +215 -0
- package/src/framework/components/HuaUxLayout.tsx +36 -0
- package/src/framework/components/HuaUxPage.tsx +138 -0
- package/src/framework/components/Providers.tsx +98 -0
- package/src/framework/components/WelcomePage.tsx +207 -0
- package/src/framework/config/index.ts +349 -0
- package/src/framework/config/merge.ts +190 -0
- package/src/framework/config/schema.ts +140 -0
- package/src/framework/hooks/useMotion.ts +57 -0
- package/src/framework/index.ts +122 -0
- package/src/framework/license/errors.ts +63 -0
- package/src/framework/license/index.ts +137 -0
- package/src/framework/license/loader.ts +158 -0
- package/src/framework/license/types.ts +95 -0
- package/src/framework/loading/components/SkeletonGroup.tsx +70 -0
- package/src/framework/loading/components/SuspenseWrapper.tsx +88 -0
- package/src/framework/loading/hoc/withSuspense.tsx +96 -0
- package/src/framework/loading/hooks/useDelayedLoading.ts +127 -0
- package/src/framework/loading/hooks/useLoadingState.ts +103 -0
- package/src/framework/loading/index.ts +19 -0
- package/src/framework/middleware/i18n.ts +161 -0
- package/src/framework/middleware/index.ts +7 -0
- package/src/framework/plugins/index.ts +13 -0
- package/src/framework/plugins/registry.ts +186 -0
- package/src/framework/plugins/types.ts +106 -0
- package/src/framework/seo/geo/examples.tsx +415 -0
- package/src/framework/seo/geo/generateGEOMetadata.ts +441 -0
- package/src/framework/seo/geo/index.ts +61 -0
- package/src/framework/seo/geo/presets.ts +58 -0
- package/src/framework/seo/geo/structuredData.ts +422 -0
- package/src/framework/seo/geo/test-utils.ts +179 -0
- package/src/framework/seo/geo/types.ts +315 -0
- package/src/framework/types/index.ts +623 -0
- package/src/framework/utils/data-fetching.ts +95 -0
- package/src/framework/utils/file-structure.ts +88 -0
- package/src/framework/utils/metadata.ts +152 -0
- package/src/index.ts +31 -0
- package/src/presets/index.ts +8 -0
- package/src/presets/marketing.ts +88 -0
- package/src/presets/product.ts +81 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - useFocusTrap
|
|
3
|
+
*
|
|
4
|
+
* 모달, 드로어 등에서 포커스를 트랩하는 hook
|
|
5
|
+
* Traps focus within a container (e.g., modal, drawer)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useCallback, type RefObject } from 'react';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Focus Trap 옵션
|
|
14
|
+
*/
|
|
15
|
+
export interface FocusTrapOptions {
|
|
16
|
+
/**
|
|
17
|
+
* 포커스 트랩 활성화 여부
|
|
18
|
+
* Whether focus trap is active
|
|
19
|
+
*
|
|
20
|
+
* @default true
|
|
21
|
+
*/
|
|
22
|
+
isActive?: boolean;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Escape 키를 눌렀을 때 호출할 콜백
|
|
26
|
+
* Callback when Escape key is pressed
|
|
27
|
+
*/
|
|
28
|
+
onEscape?: () => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 포커스할 초기 요소 선택자
|
|
32
|
+
* Selector for initial element to focus
|
|
33
|
+
*/
|
|
34
|
+
initialFocus?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 포커스가 트랩 밖으로 나갔을 때 호출할 콜백
|
|
38
|
+
* Callback when focus leaves the trap
|
|
39
|
+
*/
|
|
40
|
+
onFocusOut?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Focus Trap Hook
|
|
45
|
+
*
|
|
46
|
+
* 모달, 드로어 등에서 포커스를 컨테이너 내부에 트랩합니다.
|
|
47
|
+
* Traps focus within a container (e.g., modal, drawer).
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* function Modal({ isOpen, onClose }) {
|
|
52
|
+
* const modalRef = useFocusTrap({
|
|
53
|
+
* isActive: isOpen,
|
|
54
|
+
* onEscape: onClose
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* if (!isOpen) return null;
|
|
58
|
+
*
|
|
59
|
+
* return (
|
|
60
|
+
* <div ref={modalRef} role="dialog" aria-modal="true">
|
|
61
|
+
* <button onClick={onClose}>Close</button>
|
|
62
|
+
* Modal content
|
|
63
|
+
* </div>
|
|
64
|
+
* );
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @param options - Focus Trap 옵션
|
|
69
|
+
* @returns 컨테이너에 연결할 ref
|
|
70
|
+
*/
|
|
71
|
+
export function useFocusTrap<T extends HTMLElement = HTMLElement>(
|
|
72
|
+
options: FocusTrapOptions = {}
|
|
73
|
+
): RefObject<T | null> {
|
|
74
|
+
const {
|
|
75
|
+
isActive = true,
|
|
76
|
+
onEscape,
|
|
77
|
+
initialFocus,
|
|
78
|
+
onFocusOut,
|
|
79
|
+
} = options;
|
|
80
|
+
|
|
81
|
+
const ref = useRef<T>(null);
|
|
82
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
83
|
+
const onEscapeRef = useRef(onEscape);
|
|
84
|
+
const onFocusOutRef = useRef(onFocusOut);
|
|
85
|
+
|
|
86
|
+
// 콜백 ref 업데이트
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onEscapeRef.current = onEscape;
|
|
89
|
+
onFocusOutRef.current = onFocusOut;
|
|
90
|
+
}, [onEscape, onFocusOut]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!isActive || !ref.current) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const container = ref.current;
|
|
98
|
+
|
|
99
|
+
// 현재 포커스된 요소 저장
|
|
100
|
+
previousActiveElement.current = document.activeElement as HTMLElement;
|
|
101
|
+
|
|
102
|
+
// 포커스 가능한 요소들 찾기
|
|
103
|
+
const getFocusableElements = (): HTMLElement[] => {
|
|
104
|
+
const focusableSelectors = [
|
|
105
|
+
'a[href]',
|
|
106
|
+
'button:not([disabled])',
|
|
107
|
+
'textarea:not([disabled])',
|
|
108
|
+
'input:not([disabled])',
|
|
109
|
+
'select:not([disabled])',
|
|
110
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
111
|
+
].join(', ');
|
|
112
|
+
|
|
113
|
+
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors))
|
|
114
|
+
.filter((el) => {
|
|
115
|
+
// 숨겨진 요소 제외
|
|
116
|
+
const style = window.getComputedStyle(el);
|
|
117
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// 초기 포커스 설정
|
|
122
|
+
const focusInitialElement = () => {
|
|
123
|
+
try {
|
|
124
|
+
if (initialFocus) {
|
|
125
|
+
const element = container.querySelector<HTMLElement>(initialFocus);
|
|
126
|
+
if (element) {
|
|
127
|
+
element.focus();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 첫 번째 포커스 가능한 요소에 포커스
|
|
133
|
+
const focusableElements = getFocusableElements();
|
|
134
|
+
if (focusableElements.length > 0) {
|
|
135
|
+
focusableElements[0].focus();
|
|
136
|
+
} else {
|
|
137
|
+
// 포커스 가능한 요소가 없으면 컨테이너에 포커스
|
|
138
|
+
container.setAttribute('tabindex', '-1');
|
|
139
|
+
container.focus();
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// focus() 실패 시 (예: 요소가 아직 렌더링되지 않음)
|
|
143
|
+
// 조용히 실패 (접근성 기능이므로 에러를 던지지 않음)
|
|
144
|
+
if (process.env.NODE_ENV === 'development') {
|
|
145
|
+
console.warn('[useFocusTrap] Failed to focus initial element:', error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 키보드 이벤트 핸들러
|
|
151
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
152
|
+
if (event.key === 'Escape' && onEscapeRef.current) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
onEscapeRef.current();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tab 키 처리
|
|
159
|
+
if (event.key === 'Tab') {
|
|
160
|
+
const focusableElements = getFocusableElements();
|
|
161
|
+
if (focusableElements.length === 0) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const firstElement = focusableElements[0];
|
|
167
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
168
|
+
const currentElement = document.activeElement as HTMLElement;
|
|
169
|
+
|
|
170
|
+
// Shift + Tab (역방향)
|
|
171
|
+
if (event.shiftKey) {
|
|
172
|
+
if (currentElement === firstElement) {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
try {
|
|
175
|
+
lastElement.focus();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// focus() 실패 시 조용히 처리
|
|
178
|
+
if (process.env.NODE_ENV === 'development') {
|
|
179
|
+
console.warn('[useFocusTrap] Failed to focus last element:', error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Tab (정방향)
|
|
185
|
+
if (currentElement === lastElement) {
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
try {
|
|
188
|
+
firstElement.focus();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// focus() 실패 시 조용히 처리
|
|
191
|
+
if (process.env.NODE_ENV === 'development') {
|
|
192
|
+
console.warn('[useFocusTrap] Failed to focus first element:', error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// 포커스 아웃 감지
|
|
201
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
202
|
+
if (!container.contains(event.target as Node)) {
|
|
203
|
+
onFocusOutRef.current?.();
|
|
204
|
+
// 포커스를 다시 컨테이너 내부로 이동
|
|
205
|
+
try {
|
|
206
|
+
const focusableElements = getFocusableElements();
|
|
207
|
+
if (focusableElements.length > 0) {
|
|
208
|
+
focusableElements[0].focus();
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// focus() 실패 시 조용히 처리
|
|
212
|
+
if (process.env.NODE_ENV === 'development') {
|
|
213
|
+
console.warn('[useFocusTrap] Failed to refocus element:', error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// 초기 포커스 설정
|
|
220
|
+
focusInitialElement();
|
|
221
|
+
|
|
222
|
+
// 이벤트 리스너 등록
|
|
223
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
224
|
+
document.addEventListener('focusin', handleFocusIn);
|
|
225
|
+
|
|
226
|
+
// 정리 함수
|
|
227
|
+
return () => {
|
|
228
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
229
|
+
document.removeEventListener('focusin', handleFocusIn);
|
|
230
|
+
|
|
231
|
+
// 이전 포커스 복원
|
|
232
|
+
if (previousActiveElement.current) {
|
|
233
|
+
previousActiveElement.current.focus();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}, [isActive, initialFocus]);
|
|
237
|
+
|
|
238
|
+
return ref;
|
|
239
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - useLiveRegion
|
|
3
|
+
*
|
|
4
|
+
* 프로그래밍 방식으로 Live Region을 사용하는 hook
|
|
5
|
+
* Hook for programmatically using Live Region
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { useCallback, useState } from 'react';
|
|
11
|
+
import { LiveRegion, type LiveRegionProps } from '../components/LiveRegion';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* useLiveRegion 반환 타입
|
|
15
|
+
*/
|
|
16
|
+
export interface UseLiveRegionReturn {
|
|
17
|
+
/**
|
|
18
|
+
* 메시지를 알림
|
|
19
|
+
* Announce a message
|
|
20
|
+
*/
|
|
21
|
+
announce: (message: string, politeness?: 'polite' | 'assertive') => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* LiveRegion 컴포넌트
|
|
25
|
+
* LiveRegion component to render
|
|
26
|
+
*/
|
|
27
|
+
LiveRegionComponent: React.JSX.Element;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 현재 메시지
|
|
31
|
+
* Current message
|
|
32
|
+
*/
|
|
33
|
+
message: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* useLiveRegion Hook
|
|
38
|
+
*
|
|
39
|
+
* 프로그래밍 방식으로 Live Region을 사용할 수 있습니다.
|
|
40
|
+
* Allows programmatic use of Live Region.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* function MyComponent() {
|
|
45
|
+
* const { announce, LiveRegionComponent } = useLiveRegion();
|
|
46
|
+
*
|
|
47
|
+
* const handleClick = () => {
|
|
48
|
+
* announce('버튼이 클릭되었습니다');
|
|
49
|
+
* };
|
|
50
|
+
*
|
|
51
|
+
* return (
|
|
52
|
+
* <div>
|
|
53
|
+
* <button onClick={handleClick}>Click me</button>
|
|
54
|
+
* {LiveRegionComponent}
|
|
55
|
+
* </div>
|
|
56
|
+
* );
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @param defaultPoliteness - 기본 politeness 레벨
|
|
61
|
+
* @returns Live Region 제어 함수와 컴포넌트
|
|
62
|
+
*/
|
|
63
|
+
export function useLiveRegion(
|
|
64
|
+
defaultPoliteness: 'polite' | 'assertive' = 'polite'
|
|
65
|
+
): UseLiveRegionReturn {
|
|
66
|
+
const [message, setMessage] = useState<string | undefined>(undefined);
|
|
67
|
+
const [politeness, setPoliteness] = useState<'polite' | 'assertive'>(defaultPoliteness);
|
|
68
|
+
|
|
69
|
+
const announce = useCallback(
|
|
70
|
+
(newMessage: string, newPoliteness?: 'polite' | 'assertive') => {
|
|
71
|
+
// 메시지를 초기화한 후 다시 설정하여 스크린 리더가 변경을 감지하도록 함
|
|
72
|
+
setMessage(undefined);
|
|
73
|
+
|
|
74
|
+
// 다음 틱에 메시지 설정
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
setMessage(newMessage);
|
|
77
|
+
if (newPoliteness) {
|
|
78
|
+
setPoliteness(newPoliteness);
|
|
79
|
+
}
|
|
80
|
+
}, 0);
|
|
81
|
+
},
|
|
82
|
+
[]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const LiveRegionComponent = React.createElement(LiveRegion, {
|
|
86
|
+
message,
|
|
87
|
+
politeness,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
announce,
|
|
92
|
+
LiveRegionComponent,
|
|
93
|
+
message,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - Accessibility (a11y)
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.1 준수를 위한 접근성 도구 모음
|
|
5
|
+
* Accessibility tools for WCAG 2.1 compliance
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { useFocusManagement } from './hooks/useFocusManagement';
|
|
9
|
+
export { useFocusTrap } from './hooks/useFocusTrap';
|
|
10
|
+
export { SkipToContent } from './components/SkipToContent';
|
|
11
|
+
export { LiveRegion } from './components/LiveRegion';
|
|
12
|
+
export { useLiveRegion } from './hooks/useLiveRegion';
|
|
13
|
+
|
|
14
|
+
export type { FocusManagementOptions } from './hooks/useFocusManagement';
|
|
15
|
+
export type { FocusTrapOptions } from './hooks/useFocusTrap';
|
|
16
|
+
export type { SkipToContentProps } from './components/SkipToContent';
|
|
17
|
+
export type { LiveRegionProps } from './components/LiveRegion';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - Branding Context
|
|
3
|
+
*
|
|
4
|
+
* 브랜딩 설정을 컴포넌트에서 사용하기 위한 Context
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { createContext, useContext } from 'react';
|
|
10
|
+
import type { HuaUxConfig } from '../types';
|
|
11
|
+
import { generateCSSVariables } from './css-vars';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Branding context value
|
|
15
|
+
*/
|
|
16
|
+
interface BrandingContextValue {
|
|
17
|
+
branding: NonNullable<HuaUxConfig['branding']> | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Branding context
|
|
22
|
+
*/
|
|
23
|
+
const BrandingContext = createContext<BrandingContextValue>({
|
|
24
|
+
branding: null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* BrandingProvider component
|
|
29
|
+
*
|
|
30
|
+
* 브랜딩 설정을 제공하고 CSS 변수를 자동으로 주입하는 Provider입니다.
|
|
31
|
+
* Provides branding configuration and automatically injects CSS variables.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <BrandingProvider branding={config.branding}>
|
|
36
|
+
* {children}
|
|
37
|
+
* </BrandingProvider>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function BrandingProvider({
|
|
41
|
+
branding,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
branding: NonNullable<HuaUxConfig['branding']> | null;
|
|
45
|
+
children: React.ReactNode;
|
|
46
|
+
}) {
|
|
47
|
+
// SSR 시 초기 CSS 변수 주입 (FOUC 방지)
|
|
48
|
+
// Inject initial CSS variables on SSR (prevent FOUC)
|
|
49
|
+
// Next.js의 suppressHydrationWarning을 사용하여 클라이언트 하이드레이션 시 경고 방지
|
|
50
|
+
// Use Next.js suppressHydrationWarning to prevent warnings during client hydration
|
|
51
|
+
const cssVarsString = React.useMemo(() => {
|
|
52
|
+
if (!branding) return '';
|
|
53
|
+
return generateCSSVariables(branding);
|
|
54
|
+
}, [branding]);
|
|
55
|
+
|
|
56
|
+
// 클라이언트 사이드에서 CSS 변수 동적 업데이트
|
|
57
|
+
// Dynamically update CSS variables on client side
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (!branding || !cssVarsString) return;
|
|
60
|
+
|
|
61
|
+
// style 태그 생성 또는 업데이트
|
|
62
|
+
// Create or update style tag
|
|
63
|
+
const styleId = 'hua-ux-branding-vars';
|
|
64
|
+
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
|
65
|
+
|
|
66
|
+
if (!styleElement) {
|
|
67
|
+
styleElement = document.createElement('style');
|
|
68
|
+
styleElement.id = styleId;
|
|
69
|
+
document.head.appendChild(styleElement);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
styleElement.textContent = cssVarsString;
|
|
73
|
+
|
|
74
|
+
// Cleanup
|
|
75
|
+
return () => {
|
|
76
|
+
const element = document.getElementById(styleId);
|
|
77
|
+
if (element) {
|
|
78
|
+
element.remove();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}, [branding, cssVarsString]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<BrandingContext.Provider value={{ branding }}>
|
|
85
|
+
{/* SSR 시 초기 CSS 변수 주입 (FOUC 방지) */}
|
|
86
|
+
{/* Inject initial CSS variables on SSR (prevent FOUC) */}
|
|
87
|
+
{cssVarsString && (
|
|
88
|
+
<style
|
|
89
|
+
id="hua-ux-branding-vars"
|
|
90
|
+
dangerouslySetInnerHTML={{ __html: cssVarsString }}
|
|
91
|
+
suppressHydrationWarning
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{children}
|
|
95
|
+
</BrandingContext.Provider>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* useBranding hook
|
|
101
|
+
*
|
|
102
|
+
* 브랜딩 설정을 가져오는 훅입니다.
|
|
103
|
+
*
|
|
104
|
+
* @returns Branding configuration or null
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```tsx
|
|
108
|
+
* function MyComponent() {
|
|
109
|
+
* const branding = useBranding();
|
|
110
|
+
* const primaryColor = branding?.colors?.primary || 'blue';
|
|
111
|
+
* return <div style={{ color: primaryColor }}>Hello</div>;
|
|
112
|
+
* }
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function useBranding(): NonNullable<HuaUxConfig['branding']> | null {
|
|
116
|
+
const { branding } = useContext(BrandingContext);
|
|
117
|
+
return branding;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get color from branding
|
|
122
|
+
*
|
|
123
|
+
* 브랜딩에서 색상을 가져옵니다. 없으면 기본값을 반환합니다.
|
|
124
|
+
*
|
|
125
|
+
* @param colorKey - Color key (primary, secondary, etc.)
|
|
126
|
+
* @param defaultValue - Default color if not found
|
|
127
|
+
* @returns Color value
|
|
128
|
+
*/
|
|
129
|
+
export function useBrandingColor(
|
|
130
|
+
colorKey: keyof NonNullable<NonNullable<HuaUxConfig['branding']>['colors']>,
|
|
131
|
+
defaultValue?: string
|
|
132
|
+
): string | undefined {
|
|
133
|
+
const branding = useBranding();
|
|
134
|
+
return branding?.colors?.[colorKey] || defaultValue;
|
|
135
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - CSS Variables Generator
|
|
3
|
+
*
|
|
4
|
+
* 브랜딩 설정을 CSS 변수로 자동 생성
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { HuaUxConfig } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate CSS variables from branding configuration
|
|
11
|
+
*
|
|
12
|
+
* 브랜딩 설정을 CSS 변수 문자열로 변환합니다.
|
|
13
|
+
*
|
|
14
|
+
* @param branding - Branding configuration
|
|
15
|
+
* @returns CSS variables string
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const css = generateCSSVariables({
|
|
20
|
+
* colors: { primary: '#3B82F6' },
|
|
21
|
+
* typography: { fontFamily: ['Inter', 'sans-serif'] },
|
|
22
|
+
* });
|
|
23
|
+
* // Returns: ":root {\n --color-primary: #3B82F6;\n --font-family: Inter, sans-serif;\n}"
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function generateCSSVariables(branding: NonNullable<HuaUxConfig['branding']>): string {
|
|
27
|
+
const vars: string[] = [];
|
|
28
|
+
|
|
29
|
+
// 색상 변수
|
|
30
|
+
if (branding.colors) {
|
|
31
|
+
Object.entries(branding.colors).forEach(([key, value]) => {
|
|
32
|
+
if (value) {
|
|
33
|
+
vars.push(` --color-${key}: ${value};`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 타이포그래피 변수
|
|
39
|
+
if (branding.typography) {
|
|
40
|
+
if (branding.typography.fontFamily) {
|
|
41
|
+
vars.push(` --font-family: ${branding.typography.fontFamily.join(', ')};`);
|
|
42
|
+
}
|
|
43
|
+
if (branding.typography.fontSize) {
|
|
44
|
+
Object.entries(branding.typography.fontSize).forEach(([key, value]) => {
|
|
45
|
+
if (value) {
|
|
46
|
+
vars.push(` --font-size-${key}: ${value};`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 커스텀 변수
|
|
53
|
+
if (branding.customVariables) {
|
|
54
|
+
Object.entries(branding.customVariables).forEach(([key, value]) => {
|
|
55
|
+
vars.push(` --${key}: ${value};`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (vars.length === 0) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `:root {\n${vars.join('\n')}\n}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate CSS variables as object
|
|
68
|
+
*
|
|
69
|
+
* 브랜딩 설정을 CSS 변수 객체로 변환합니다.
|
|
70
|
+
*
|
|
71
|
+
* @param branding - Branding configuration
|
|
72
|
+
* @returns CSS variables object
|
|
73
|
+
*/
|
|
74
|
+
export function generateCSSVariablesObject(
|
|
75
|
+
branding: NonNullable<HuaUxConfig['branding']>
|
|
76
|
+
): Record<string, string> {
|
|
77
|
+
const vars: Record<string, string> = {};
|
|
78
|
+
|
|
79
|
+
// 색상 변수
|
|
80
|
+
if (branding.colors) {
|
|
81
|
+
Object.entries(branding.colors).forEach(([key, value]) => {
|
|
82
|
+
if (value) {
|
|
83
|
+
vars[`--color-${key}`] = value;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 타이포그래피 변수
|
|
89
|
+
if (branding.typography) {
|
|
90
|
+
if (branding.typography.fontFamily) {
|
|
91
|
+
vars['--font-family'] = branding.typography.fontFamily.join(', ');
|
|
92
|
+
}
|
|
93
|
+
if (branding.typography.fontSize) {
|
|
94
|
+
Object.entries(branding.typography.fontSize).forEach(([key, value]) => {
|
|
95
|
+
if (value) {
|
|
96
|
+
vars[`--font-size-${key}`] = value;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 커스텀 변수
|
|
103
|
+
if (branding.customVariables) {
|
|
104
|
+
Object.entries(branding.customVariables).forEach(([key, value]) => {
|
|
105
|
+
vars[`--${key}`] = value;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return vars;
|
|
110
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hua-labs/hua-ux/framework - Tailwind Config Generator
|
|
3
|
+
*
|
|
4
|
+
* 브랜딩 설정을 Tailwind Config로 자동 생성
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { HuaUxConfig } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate Tailwind config from branding configuration
|
|
11
|
+
*
|
|
12
|
+
* 브랜딩 설정을 Tailwind Config 객체로 변환합니다.
|
|
13
|
+
*
|
|
14
|
+
* @param branding - Branding configuration
|
|
15
|
+
* @returns Tailwind config object
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const tailwindConfig = generateTailwindConfig({
|
|
20
|
+
* colors: { primary: '#3B82F6' },
|
|
21
|
+
* typography: { fontFamily: ['Inter', 'sans-serif'] },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Use in tailwind.config.js:
|
|
25
|
+
* module.exports = {
|
|
26
|
+
* ...tailwindConfig,
|
|
27
|
+
* // ... other config
|
|
28
|
+
* };
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function generateTailwindConfig(
|
|
32
|
+
branding: NonNullable<HuaUxConfig['branding']>
|
|
33
|
+
): {
|
|
34
|
+
theme: {
|
|
35
|
+
extend: {
|
|
36
|
+
colors?: Record<string, string>;
|
|
37
|
+
fontFamily?: Record<string, string[]>;
|
|
38
|
+
fontSize?: Record<string, string>;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
} {
|
|
42
|
+
const config: {
|
|
43
|
+
theme: {
|
|
44
|
+
extend: {
|
|
45
|
+
colors?: Record<string, string>;
|
|
46
|
+
fontFamily?: Record<string, string[]>;
|
|
47
|
+
fontSize?: Record<string, string>;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
} = {
|
|
51
|
+
theme: {
|
|
52
|
+
extend: {},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// 색상 설정
|
|
57
|
+
if (branding.colors) {
|
|
58
|
+
const colors: Record<string, string> = {};
|
|
59
|
+
Object.entries(branding.colors).forEach(([key, value]) => {
|
|
60
|
+
if (value) {
|
|
61
|
+
colors[key] = value;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
if (Object.keys(colors).length > 0) {
|
|
65
|
+
config.theme.extend.colors = colors;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 타이포그래피 설정
|
|
70
|
+
if (branding.typography) {
|
|
71
|
+
if (branding.typography.fontFamily) {
|
|
72
|
+
config.theme.extend.fontFamily = {
|
|
73
|
+
sans: branding.typography.fontFamily,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (branding.typography.fontSize) {
|
|
77
|
+
const fontSize: Record<string, string> = {};
|
|
78
|
+
Object.entries(branding.typography.fontSize).forEach(([key, value]) => {
|
|
79
|
+
if (value) {
|
|
80
|
+
fontSize[key] = value;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (Object.keys(fontSize).length > 0) {
|
|
84
|
+
config.theme.extend.fontSize = fontSize;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return config;
|
|
90
|
+
}
|