@uniai-fe/uds-templates 0.12.0 → 0.12.2

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 CHANGED
@@ -39,6 +39,32 @@ import { Frame, Modal } from "@uniai-fe/uds-templates";
39
39
 
40
40
  `@uniai-fe/uds-templates/src/**`와 export map에 없는 category path는 public API가 아니다.
41
41
 
42
+ ### CCTV 전체화면
43
+
44
+ `useCctvFullscreen`은 서비스가 지정한 container를 전체화면으로 전환한다.
45
+ 화면 방향 API를 지원하면 전체화면 동안 `landscape`를 요청하며, 종료·대상 제거 시
46
+ 고정을 해제해 기본 방향 정책으로 돌아간다. 세로 방향 복귀를 강제하지 않는다.
47
+ 방향 API 미지원·요청 거부는 전체화면 시청을 차단하지 않는다.
48
+ 진입 요청 대기 중 대상이 교체·제거되면 늦게 열린 이전 대상의 전체화면을 종료하고
49
+ `false`를 반환한다. 다른 대상이 소유한 전체화면은 유지한다.
50
+
51
+ `useCctvFullscreen({ fallback: "viewport" })`는 표준 Fullscreen API 미지원 시
52
+ 페이지 안 전체창 확대를 허용한다. `isFullscreenSupported`와 `isFullscreen`은
53
+ 표준 API의 의미를 유지하며, 전체창 확대는 `isViewportExpanded`로 구분한다.
54
+ 옵션을 생략하면 기존 동작을 유지하고, 표준 API 요청이 거부된 경우에는 확대하지 않는다.
55
+
56
+ 서비스는 같은 `targetRef` container에 `isViewportExpanded`에 따른 viewport 고정,
57
+ 크기·안전 영역·반응형 스타일을 적용한다. 브라우저 UI를 숨기거나 기기 방향을 강제하지 않는다.
58
+ 진입·종료 action은 두 방식을 처리하므로 버튼의 활성 상태에는
59
+ `isFullscreen || isViewportExpanded`를 사용한다. 전체창 확대를 선택한 서비스는
60
+ 표준 API 지원 여부로 해당 버튼을 숨기지 않는다.
61
+
62
+ Hook은 전체창 확대 동안 배경 scroll과 대상 바깥의 focus를 제한한다. 확대 종료 버튼,
63
+ Escape, 대상 교체·제거 또는 unmount로 종료하면 기존 overflow·inert·tabindex와
64
+ 연결된 진입 control의 focus를 복원한다. 카메라 전환은 container를 유지하며, DOM을
65
+ 다른 부모로 이동하거나 영상을 다시 만들지 않는다. 같은 document에서는 한 Hook만
66
+ 전체창 확대를 소유한다.
67
+
42
68
  ## Styles
43
69
 
44
70
  CSS consumer는 dependency order대로 각 public entry를 로드한다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -70,14 +70,14 @@
70
70
  "@uniai-fe/react-hooks": "0.3.0",
71
71
  "@uniai-fe/eslint-config": "0.4.2",
72
72
  "@uniai-fe/tsconfig": "0.2.0",
73
- "@uniai-fe/next-devkit": "0.4.0",
74
- "@uniai-fe/util-api": "0.2.1",
75
73
  "@uniai-fe/uds-foundation": "0.6.0",
76
- "@uniai-fe/util-functions": "0.4.3",
77
74
  "@uniai-fe/uds-primitives": "0.12.5",
78
75
  "@uniai-fe/util-jotai": "0.3.0",
79
- "@uniai-fe/util-rtc": "0.2.1",
80
- "@uniai-fe/util-next": "0.5.0"
76
+ "@uniai-fe/util-api": "0.2.1",
77
+ "@uniai-fe/util-next": "0.5.0",
78
+ "@uniai-fe/next-devkit": "0.4.0",
79
+ "@uniai-fe/util-functions": "0.4.3",
80
+ "@uniai-fe/util-rtc": "0.2.1"
81
81
  },
82
82
  "scripts": {
83
83
  "check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
@@ -1,31 +1,50 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import type { UseCctvFullscreenReturn } from "../types";
4
+ import type {
5
+ UseCctvFullscreenParams,
6
+ UseCctvFullscreenReturn,
7
+ } from "../types";
8
+
9
+ // 같은 document의 viewer들이 배경 잠금과 focus 복원 책임을 동시에 갖지 않도록 제한한다.
10
+ const viewportOwners = new WeakMap<Document, HTMLElement>();
5
11
 
6
12
  /**
7
13
  * CCTV; service-owned viewer container fullscreen state
8
14
  * @hook
9
15
  * @desc 대상 element의 표준 Fullscreen API 지원 여부와 실제 document 상태를 동기화한다.
10
16
  * 요청 실패는 기존 viewer를 유지하며, 외부 종료 후에는 진입 전 focus를 복원한다.
17
+ * 지원 환경에서는 진입 후 가로 방향을 요청하고 종료·대상 제거 시 기본 방향 정책으로 돌려준다.
18
+ * 방향 요청 실패는 전체화면 성공에 영향을 주지 않으며 종료 후 세로 방향을 강제하지 않는다.
19
+ * fallback이 "viewport"이면 표준 API 미지원 시 전체창 확대 상태와 배경 scroll·focus를 관리한다.
20
+ * 실제 확대 layout은 서비스가 isViewportExpanded로 적용하며 영상 DOM을 이동하거나 다시 만들지 않는다.
21
+ * 표준 API 요청 거부는 전체창 확대로 전환하지 않는다.
22
+ * @param {UseCctvFullscreenParams} options 미지원 환경의 확대 정책
23
+ * @property {"viewport"} [options.fallback] "viewport"는 전체창 확대를 허용한다. 생략하면 표준 API만 사용한다.
11
24
  * @return {UseCctvFullscreenReturn} 대상 ref callback과 전체화면 상태·action
12
25
  */
13
- export function useCctvFullscreen(): UseCctvFullscreenReturn {
26
+ export function useCctvFullscreen({
27
+ fallback,
28
+ }: UseCctvFullscreenParams = {}): UseCctvFullscreenReturn {
14
29
  const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
15
30
  const [isFullscreen, setIsFullscreen] = useState(false);
31
+ const [isViewportExpanded, setIsViewportExpanded] = useState(false);
16
32
  const targetElementRef = useRef<HTMLElement | null>(null);
17
33
  const entryFocusRef = useRef<HTMLElement | null>(null);
18
34
  const wasFullscreenRef = useRef(false);
35
+ const unlockOrientationRef = useRef<(() => void) | null>(null);
36
+ const viewportSessionRef = useRef<{
37
+ target: HTMLElement;
38
+ release: () => void;
39
+ } | null>(null);
19
40
 
20
- /**
21
- * service가 선택한 전체화면 대상 element를 hook lifecycle에 연결한다.
22
- */
23
- const targetRef = useCallback((element: HTMLElement | null) => {
24
- targetElementRef.current = element;
25
- setTargetElement(element);
26
-
27
- if (element === null) {
28
- setIsFullscreen(false);
41
+ const releaseOrientation = useCallback(() => {
42
+ const unlock = unlockOrientationRef.current;
43
+ unlockOrientationRef.current = null;
44
+ try {
45
+ unlock?.();
46
+ } catch {
47
+ // 숨겨진 document 등에서 해제가 거부돼도 전체화면 종료와 focus 복원을 유지한다.
29
48
  }
30
49
  }, []);
31
50
 
@@ -37,10 +56,37 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
37
56
  entryFocusRef.current = null;
38
57
 
39
58
  if (entryFocus?.isConnected) {
40
- entryFocus.focus();
59
+ entryFocus.focus({ preventScroll: true });
41
60
  }
42
61
  }, []);
43
62
 
63
+ const exitViewportExpansion = useCallback(() => {
64
+ const session = viewportSessionRef.current;
65
+ if (!session) return false;
66
+ viewportSessionRef.current = null;
67
+ session.release();
68
+ setIsViewportExpanded(false);
69
+ restoreEntryFocus();
70
+ return true;
71
+ }, [restoreEntryFocus]);
72
+
73
+ /**
74
+ * service가 선택한 전체화면 대상 element를 hook lifecycle에 연결한다.
75
+ */
76
+ const targetRef = useCallback(
77
+ (element: HTMLElement | null) => {
78
+ if (targetElementRef.current !== element) {
79
+ releaseOrientation();
80
+ exitViewportExpansion();
81
+ }
82
+ targetElementRef.current = element;
83
+ setTargetElement(element);
84
+
85
+ if (element === null) setIsFullscreen(false);
86
+ },
87
+ [exitViewportExpansion, releaseOrientation],
88
+ );
89
+
44
90
  useEffect(() => {
45
91
  if (targetElement === null) return;
46
92
 
@@ -54,6 +100,7 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
54
100
  setIsFullscreen(nextIsFullscreen);
55
101
 
56
102
  if (!nextIsFullscreen && wasFullscreenRef.current) {
103
+ releaseOrientation();
57
104
  restoreEntryFocus();
58
105
  }
59
106
 
@@ -79,6 +126,10 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
79
126
  synchronizeFullscreenState,
80
127
  );
81
128
 
129
+ releaseOrientation();
130
+ if (viewportSessionRef.current?.target === targetElement) {
131
+ exitViewportExpansion();
132
+ }
82
133
  if (ownerDocument.fullscreenElement === targetElement) {
83
134
  void ownerDocument.exitFullscreen().catch(() => undefined);
84
135
  }
@@ -89,45 +140,182 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
89
140
 
90
141
  wasFullscreenRef.current = false;
91
142
  };
92
- }, [restoreEntryFocus, targetElement]);
143
+ }, [
144
+ exitViewportExpansion,
145
+ releaseOrientation,
146
+ restoreEntryFocus,
147
+ targetElement,
148
+ ]);
93
149
 
94
150
  /**
95
- * 현재 service target의 표준 전체화면 진입을 요청한다.
151
+ * 표준 API를 우선하며 미지원일 때만 선택한 전체창 확대를 시작한다.
96
152
  */
97
153
  const enterFullscreen = useCallback(async () => {
98
154
  const target = targetElementRef.current;
99
155
 
156
+ if (target === null) return false;
157
+
100
158
  if (
101
- target === null ||
102
- !target.ownerDocument.fullscreenEnabled ||
103
- typeof target.requestFullscreen !== "function"
159
+ target.ownerDocument.fullscreenElement === target ||
160
+ viewportSessionRef.current?.target === target
104
161
  ) {
105
- return false;
106
- }
107
-
108
- if (target.ownerDocument.fullscreenElement === target) {
109
162
  return true;
110
163
  }
111
164
 
112
- const activeElement = target.ownerDocument.activeElement;
165
+ const ownerDocument = target.ownerDocument;
166
+ if (viewportOwners.has(ownerDocument)) return false;
167
+ const isNativeSupported =
168
+ ownerDocument.fullscreenEnabled &&
169
+ typeof target.requestFullscreen === "function";
170
+ if (
171
+ !isNativeSupported &&
172
+ (fallback !== "viewport" ||
173
+ !target.isConnected ||
174
+ ownerDocument.fullscreenElement)
175
+ ) {
176
+ return false;
177
+ }
178
+ const activeElement = ownerDocument.activeElement;
179
+ const ElementConstructor = ownerDocument.defaultView?.HTMLElement;
113
180
  entryFocusRef.current =
114
- typeof HTMLElement !== "undefined" && activeElement instanceof HTMLElement
181
+ ElementConstructor && activeElement instanceof ElementConstructor
115
182
  ? activeElement
116
183
  : null;
117
184
 
185
+ if (!isNativeSupported) {
186
+ const restores: (() => void)[] = [];
187
+ viewportOwners.set(ownerDocument, target);
188
+
189
+ // 기존 modal의 잠금과 inline style은 해당 속성만 보관해 종료 시 복원한다.
190
+ for (const element of [
191
+ ownerDocument.documentElement,
192
+ ownerDocument.body,
193
+ ]) {
194
+ const overflow = element.style.overflow;
195
+ const priority = element.style.getPropertyPriority("overflow");
196
+ element.style.setProperty("overflow", "hidden");
197
+ restores.push(() =>
198
+ element.style.setProperty("overflow", overflow, priority),
199
+ );
200
+ }
201
+ for (
202
+ let branch: HTMLElement = target;
203
+ branch.parentElement;
204
+ branch = branch.parentElement
205
+ ) {
206
+ for (const sibling of branch.parentElement.children) {
207
+ if (
208
+ sibling === branch ||
209
+ !ElementConstructor ||
210
+ !(sibling instanceof ElementConstructor)
211
+ )
212
+ continue;
213
+ const inert = sibling.getAttribute("inert");
214
+ sibling.setAttribute("inert", "");
215
+ restores.push(() => {
216
+ if (inert === null) sibling.removeAttribute("inert");
217
+ else sibling.setAttribute("inert", inert);
218
+ });
219
+ }
220
+ }
221
+ const tabIndex = target.getAttribute("tabindex");
222
+ target.setAttribute("tabindex", "-1");
223
+ restores.push(() => {
224
+ if (tabIndex === null) target.removeAttribute("tabindex");
225
+ else target.setAttribute("tabindex", tabIndex);
226
+ });
227
+
228
+ const onFocus = () => {
229
+ if (!target.contains(ownerDocument.activeElement))
230
+ target.focus({ preventScroll: true });
231
+ };
232
+ const onKeyDown = (event: KeyboardEvent) => {
233
+ if (event.key === "Escape") {
234
+ event.preventDefault();
235
+ event.stopPropagation();
236
+ exitViewportExpansion();
237
+ } else if (event.key === "Tab") {
238
+ const controls = Array.from(
239
+ target.querySelectorAll<HTMLElement>(
240
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
241
+ ),
242
+ ).filter(
243
+ element =>
244
+ element.tabIndex >= 0 &&
245
+ element.getClientRects().length > 0 &&
246
+ !element.closest("[inert]"),
247
+ );
248
+ const first = controls[0];
249
+ const last = controls.at(-1);
250
+ if (
251
+ !first ||
252
+ ownerDocument.activeElement === target ||
253
+ (event.shiftKey
254
+ ? ownerDocument.activeElement === first
255
+ : ownerDocument.activeElement === last)
256
+ ) {
257
+ event.preventDefault();
258
+ (event.shiftKey ? (last ?? target) : (first ?? target)).focus({
259
+ preventScroll: true,
260
+ });
261
+ }
262
+ }
263
+ };
264
+ ownerDocument.addEventListener("keydown", onKeyDown, true);
265
+ ownerDocument.addEventListener("focusin", onFocus);
266
+ viewportSessionRef.current = {
267
+ target,
268
+ release: () => {
269
+ ownerDocument.removeEventListener("keydown", onKeyDown, true);
270
+ ownerDocument.removeEventListener("focusin", onFocus);
271
+ restores.reverse().forEach(restore => restore());
272
+ viewportOwners.delete(ownerDocument);
273
+ },
274
+ };
275
+ onFocus();
276
+ setIsViewportExpanded(true);
277
+ return true;
278
+ }
279
+
118
280
  try {
119
281
  await target.requestFullscreen();
282
+ // 대상 정리 뒤 완료된 요청은 이전 화면을 남기지 않되 새 대상의 전체화면은 보존한다.
283
+ if (targetElementRef.current !== target) {
284
+ if (ownerDocument.fullscreenElement === target) {
285
+ await ownerDocument.exitFullscreen().catch(() => undefined);
286
+ }
287
+ return false;
288
+ }
289
+ const orientation = target.ownerDocument.defaultView?.screen.orientation;
290
+ if (
291
+ target.ownerDocument.fullscreenElement === target &&
292
+ typeof orientation?.lock === "function" &&
293
+ typeof orientation.unlock === "function"
294
+ ) {
295
+ // 대기 중에도 종료가 pending lock을 취소할 수 있도록 먼저 소유권을 기록한다.
296
+ const unlock = () => orientation.unlock();
297
+ unlockOrientationRef.current = unlock;
298
+ try {
299
+ await orientation.lock("landscape");
300
+ } catch {
301
+ // 이전 요청의 거부가 이후 전체화면 세션의 해제 책임을 지우지 않도록 구분한다.
302
+ if (unlockOrientationRef.current === unlock) {
303
+ unlockOrientationRef.current = null;
304
+ }
305
+ }
306
+ }
120
307
  return true;
121
308
  } catch {
122
309
  entryFocusRef.current = null;
123
310
  return false;
124
311
  }
125
- }, []);
312
+ }, [exitViewportExpansion, fallback]);
126
313
 
127
314
  /**
128
315
  * 다른 element의 전체화면은 건드리지 않고 현재 target 소유 상태만 종료한다.
129
316
  */
130
317
  const exitFullscreen = useCallback(async () => {
318
+ if (exitViewportExpansion()) return true;
131
319
  const target = targetElementRef.current;
132
320
 
133
321
  if (
@@ -140,11 +328,12 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
140
328
 
141
329
  try {
142
330
  await target.ownerDocument.exitFullscreen();
331
+ releaseOrientation();
143
332
  return true;
144
333
  } catch {
145
334
  return false;
146
335
  }
147
- }, []);
336
+ }, [exitViewportExpansion, releaseOrientation]);
148
337
 
149
338
  /**
150
339
  * 현재 document 소유 상태에 따라 target의 진입 또는 종료 action을 선택한다.
@@ -152,7 +341,10 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
152
341
  const toggleFullscreen = useCallback(async () => {
153
342
  const target = targetElementRef.current;
154
343
 
155
- if (target?.ownerDocument.fullscreenElement === target) {
344
+ if (
345
+ viewportSessionRef.current ||
346
+ target?.ownerDocument.fullscreenElement === target
347
+ ) {
156
348
  return exitFullscreen();
157
349
  }
158
350
 
@@ -163,9 +355,10 @@ export function useCctvFullscreen(): UseCctvFullscreenReturn {
163
355
  targetRef,
164
356
  isFullscreenSupported:
165
357
  targetElement !== null &&
166
- targetElement.ownerDocument.fullscreenEnabled &&
358
+ targetElement.ownerDocument.fullscreenEnabled === true &&
167
359
  typeof targetElement.requestFullscreen === "function",
168
360
  isFullscreen,
361
+ isViewportExpanded,
169
362
  enterFullscreen,
170
363
  exitFullscreen,
171
364
  toggleFullscreen,
@@ -13,14 +13,26 @@ import type {
13
13
  import type { UseFormReturn } from "react-hook-form";
14
14
  import type { CctvBaseContext } from "./context";
15
15
 
16
+ /**
17
+ * CCTV; 전체화면 미지원 환경의 확대 정책
18
+ * @property {"viewport"} [fallback] "viewport"는 표준 API 미지원 시 서비스의 전체창 확대 스타일을 사용한다. 생략하면 표준 API만 사용한다.
19
+ */
20
+ export interface UseCctvFullscreenParams {
21
+ /**
22
+ * "viewport"는 미지원 환경의 전체창 확대를 허용한다. 생략하면 기존 viewer를 유지한다.
23
+ */
24
+ fallback?: "viewport";
25
+ }
26
+
16
27
  /**
17
28
  * CCTV; useCctvFullscreen return
18
29
  * @property {React.RefCallback<HTMLElement>} targetRef 전체화면 대상 viewer container ref callback
19
30
  * @property {boolean} isFullscreenSupported 대상과 브라우저의 표준 Fullscreen API 지원 여부
20
31
  * @property {boolean} isFullscreen 현재 대상이 실제 전체화면인지 여부
21
- * @property {() => Promise<boolean>} enterFullscreen 대상 전체화면 진입 action과 성공 여부
22
- * @property {() => Promise<boolean>} exitFullscreen 현재 대상 소유 전체화면 종료 action과 성공 여부
23
- * @property {() => Promise<boolean>} toggleFullscreen 현재 대상의 전체화면 진입·종료 action과 성공 여부
32
+ * @property {boolean} isViewportExpanded 표준 전체화면 대신 서비스의 전체창 확대 스타일을 적용할 상태
33
+ * @property {() => Promise<boolean>} enterFullscreen 대상 전체화면 또는 허용한 전체창 확대 진입 action과 성공 여부
34
+ * @property {() => Promise<boolean>} exitFullscreen 현재 대상 소유 전체화면·전체창 확대 종료 action과 성공 여부
35
+ * @property {() => Promise<boolean>} toggleFullscreen 현재 대상의 전체화면·전체창 확대 진입·종료 action과 성공 여부
24
36
  */
25
37
  export interface UseCctvFullscreenReturn {
26
38
  /**
@@ -36,17 +48,21 @@ export interface UseCctvFullscreenReturn {
36
48
  */
37
49
  isFullscreen: boolean;
38
50
  /**
39
- * 대상 전체화면 진입 action
51
+ * 서비스가 대상 container에 전체창 확대 스타일을 적용할 상태. 브라우저 전체화면 상태와 구분한다.
52
+ */
53
+ isViewportExpanded: boolean;
54
+ /**
55
+ * 대상 전체화면 또는 허용한 전체창 확대 진입 action
40
56
  * @return 진입 요청 성공 여부
41
57
  */
42
58
  enterFullscreen: () => Promise<boolean>;
43
59
  /**
44
- * 현재 대상 소유 전체화면 종료 action
60
+ * 현재 대상 소유 전체화면·전체창 확대 종료 action
45
61
  * @return 종료 요청 성공 여부
46
62
  */
47
63
  exitFullscreen: () => Promise<boolean>;
48
64
  /**
49
- * 현재 대상의 전체화면 진입·종료 action
65
+ * 현재 대상의 전체화면·전체창 확대 진입·종료 action
50
66
  * @return 요청 성공 여부
51
67
  */
52
68
  toggleFullscreen: () => Promise<boolean>;