@uniai-fe/uds-templates 0.12.2 → 0.12.4

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
@@ -45,13 +45,19 @@ import { Frame, Modal } from "@uniai-fe/uds-templates";
45
45
  화면 방향 API를 지원하면 전체화면 동안 `landscape`를 요청하며, 종료·대상 제거 시
46
46
  고정을 해제해 기본 방향 정책으로 돌아간다. 세로 방향 복귀를 강제하지 않는다.
47
47
  방향 API 미지원·요청 거부는 전체화면 시청을 차단하지 않는다.
48
- 진입 요청 대기 중 대상이 교체·제거되면 늦게 열린 이전 대상의 전체화면을 종료하고
49
- `false`를 반환한다. 다른 대상이 소유한 전체화면은 유지한다.
50
-
51
- `useCctvFullscreen({ fallback: "viewport" })`는 표준 Fullscreen API 미지원 시
52
- 페이지 안 전체창 확대를 허용한다. `isFullscreenSupported`와 `isFullscreen`은
48
+ 표준 API 요청 성공 여부를 반환하며, 진입 요청 대기 중 대상이 교체·제거되면
49
+ 해당 대상의 가로 방향 고정을 추가로 요청하지 않는다.
50
+
51
+ `useCctvFullscreen({ fallback: "viewport" })`는 iPhone·iPad에서 표준 Fullscreen API를
52
+ 호출하지 않고 페이지 안 전체창 확대로 바로 진입한다. Mac·Windows·Android와 그 외 환경은
53
+ 표준 API를 사용하며, 미지원·요청 거부 시 전체창 확대로 전환하지 않는다.
54
+ 기기 판정은 기존 `checkAppleDevice`와 iPhone·iPad UA를 사용하고, Macintosh UA의 iPad는
55
+ `maxTouchPoints > 1`로 보완한다. UA·터치 정보에 기반한 판정이므로 위장된 UA까지 보장하지 않는다.
56
+ `isFullscreenSupported`와 `isFullscreen`은
53
57
  표준 API의 의미를 유지하며, 전체창 확대는 `isViewportExpanded`로 구분한다.
54
- 옵션을 생략하면 기존 동작을 유지하고, 표준 API 요청이 거부된 경우에는 확대하지 않는다.
58
+ 옵션을 생략하면 iPhone·iPad도 표준 API만 사용한다.
59
+ 전체창 확대는 현재 연결된 대상만 허용하며, 다른 viewer가 전체화면·전체창 확대를 소유하면
60
+ 진입하지 않는다.
55
61
 
56
62
  서비스는 같은 `targetRef` container에 `isViewportExpanded`에 따른 viewport 고정,
57
63
  크기·안전 영역·반응형 스타일을 적용한다. 브라우저 UI를 숨기거나 기기 방향을 강제하지 않는다.
@@ -65,6 +71,26 @@ Escape, 대상 교체·제거 또는 unmount로 종료하면 기존 overflow·in
65
71
  다른 부모로 이동하거나 영상을 다시 만들지 않는다. 같은 document에서는 한 Hook만
66
72
  전체창 확대를 소유한다.
67
73
 
74
+ 뷰어의 영상 하단에는 `CCTV.Video.Overlay.Footer.FullscreenButton`을 명시적으로 조립한다.
75
+ 센서 배지는 왼쪽, 버튼은 오른쪽에 배치되며 `isExpanded`에 따라 제공된 아이콘과
76
+ `크게 보기`/`작게 보기` 문구를 전환한다. `expandLabel`·`collapseLabel`로 서비스 번역을
77
+ 주입한다. 캠 리스트나 기본 footer에 자동으로 추가되지 않는다.
78
+
79
+ ```tsx
80
+ <CCTV.Video.Overlay.Footer.Container activeTitle={false}>
81
+ {metrics}
82
+ <CCTV.Video.Overlay.Footer.FullscreenButton
83
+ isExpanded={isFullscreen || isViewportExpanded}
84
+ onToggle={toggleFullscreen}
85
+ expandLabel={t("cctv.fullscreen.expand")}
86
+ collapseLabel={t("cctv.fullscreen.collapse")}
87
+ />
88
+ </CCTV.Video.Overlay.Footer.Container>
89
+ ```
90
+
91
+ 모달 헤더의 원형 닫기는 `ModalHeaderCloseButton`을 공개 entry에서 재사용할 수 있다.
92
+ 기본 Dialog 헤더도 같은 32px 아이콘을 사용하며 닫기 action은 기존 계약을 유지한다.
93
+
68
94
  ## Styles
69
95
 
70
96
  CSS consumer는 dependency order대로 각 public entry를 로드한다.
package/dist/styles.css CHANGED
@@ -615,7 +615,17 @@
615
615
  }
616
616
 
617
617
  .uds-modal-header-close-button {
618
+ display: inline-flex;
619
+ align-items: center;
620
+ justify-content: center;
621
+ flex: 0 0 auto;
622
+ width: 32px;
623
+ height: 32px;
624
+ padding: 0;
625
+ border: 0;
626
+ background: transparent;
618
627
  font-size: 0;
628
+ cursor: pointer;
619
629
  }
620
630
 
621
631
  .uds-modal-footer {
@@ -2230,6 +2240,59 @@
2230
2240
  margin-top: var(--spacing-gap-2);
2231
2241
  }
2232
2242
 
2243
+ .cctv-video-overlay-footer:has(> .cctv-fullscreen-button) {
2244
+ min-height: 56px;
2245
+ justify-content: space-between;
2246
+ flex-wrap: wrap;
2247
+ gap: var(--spacing-gap-4);
2248
+ }
2249
+ .cctv-video-overlay-footer:has(> .cctv-fullscreen-button) .cctv-video-metrics {
2250
+ margin: 0;
2251
+ min-width: 0;
2252
+ flex-wrap: wrap;
2253
+ gap: var(--spacing-gap-4);
2254
+ }
2255
+ .cctv-video-overlay-footer:has(> .cctv-fullscreen-button) .cctv-video-metric {
2256
+ height: 36px;
2257
+ padding: var(--spacing-padding-2) var(--spacing-padding-5);
2258
+ border-radius: 8px;
2259
+ background: rgba(23, 24, 28, 0.68);
2260
+ backdrop-filter: blur(3px);
2261
+ font-size: var(--font-heading-xsmall-size);
2262
+ line-height: 1.5;
2263
+ }
2264
+
2265
+ .cctv-fullscreen-button {
2266
+ flex: 0 0 auto;
2267
+ display: inline-flex;
2268
+ align-items: center;
2269
+ justify-content: center;
2270
+ gap: var(--spacing-gap-4);
2271
+ margin-left: auto;
2272
+ min-height: 44px;
2273
+ padding: 0;
2274
+ border: 0;
2275
+ background: transparent;
2276
+ color: var(--color-common-100);
2277
+ font-family: inherit;
2278
+ font-size: var(--font-heading-small-size);
2279
+ font-weight: 600;
2280
+ line-height: 1.4;
2281
+ letter-spacing: 0.2px;
2282
+ white-space: nowrap;
2283
+ cursor: pointer;
2284
+ pointer-events: auto;
2285
+ }
2286
+ .cctv-fullscreen-button svg {
2287
+ width: 24px;
2288
+ height: 24px;
2289
+ flex: 0 0 24px;
2290
+ }
2291
+ .cctv-fullscreen-button:focus-visible {
2292
+ outline: 2px solid currentColor;
2293
+ outline-offset: 4px;
2294
+ }
2295
+
2233
2296
  .service-inquiry-form {
2234
2297
  display: flex;
2235
2298
  flex-direction: column;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -67,16 +67,16 @@
67
67
  "react-hook-form": "^7.84.0",
68
68
  "sass": "^1.101.7",
69
69
  "typescript": "6.0.3",
70
- "@uniai-fe/react-hooks": "0.3.0",
71
70
  "@uniai-fe/eslint-config": "0.4.2",
72
71
  "@uniai-fe/tsconfig": "0.2.0",
73
- "@uniai-fe/uds-foundation": "0.6.0",
72
+ "@uniai-fe/next-devkit": "0.4.0",
73
+ "@uniai-fe/react-hooks": "0.3.0",
74
74
  "@uniai-fe/uds-primitives": "0.12.5",
75
- "@uniai-fe/util-jotai": "0.3.0",
75
+ "@uniai-fe/uds-foundation": "0.6.0",
76
+ "@uniai-fe/util-functions": "0.4.3",
76
77
  "@uniai-fe/util-api": "0.2.1",
78
+ "@uniai-fe/util-jotai": "0.3.0",
77
79
  "@uniai-fe/util-next": "0.5.0",
78
- "@uniai-fe/next-devkit": "0.4.0",
79
- "@uniai-fe/util-functions": "0.4.3",
80
80
  "@uniai-fe/util-rtc": "0.2.1"
81
81
  },
82
82
  "scripts": {
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import FullscreenOnIcon from "../../../../img/fullscreen-on.svg";
4
+ import FullscreenOffIcon from "../../../../img/fullscreen-off.svg";
5
+ import type { CctvFullscreenButtonProps } from "../../../../types";
6
+
7
+ /**
8
+ * CCTV 뷰어 하단 확대·축소 버튼.
9
+ * @component
10
+ * @desc 서비스가 소유한 확대 상태와 action을 표시하며 자체 확대 대상이나 상태를 만들지 않는다.
11
+ * @param {CctvFullscreenButtonProps} props 확대 상태, action과 번역 문구
12
+ * @return {React.ReactElement} 아이콘과 문구를 함께 표시하는 버튼
13
+ */
14
+ export default function CCTVFullscreenButton({
15
+ isExpanded,
16
+ onToggle,
17
+ expandLabel = "크게 보기",
18
+ collapseLabel = "작게 보기",
19
+ }: CctvFullscreenButtonProps) {
20
+ const Icon = isExpanded ? FullscreenOffIcon : FullscreenOnIcon;
21
+
22
+ return (
23
+ <button
24
+ type="button"
25
+ className="cctv-fullscreen-button"
26
+ aria-pressed={isExpanded}
27
+ onClick={() => void onToggle()}
28
+ >
29
+ <Icon width={24} height={24} aria-hidden="true" focusable="false" />
30
+ <span>{isExpanded ? collapseLabel : expandLabel}</span>
31
+ </button>
32
+ );
33
+ }
@@ -5,6 +5,7 @@ import CCTVVideoOverlayFooter from "./footer/Container";
5
5
  import CCTVVideoOpenButton from "./footer/OpenButton";
6
6
  import CCTVVideoMetric from "./footer/Metric";
7
7
  import CCTVVideoMetrics from "./footer/Metrics";
8
+ import CCTVFullscreenButton from "./footer/FullscreenButton";
8
9
  import CCTVVideoCloseButton from "./header/CloseButton";
9
10
  import CCTVVideoOverlayHeader from "./header/Container";
10
11
  import CCTVVideoLiveState from "./header/LiveState";
@@ -22,6 +23,7 @@ export const CCTVVideoOverlay = {
22
23
  Footer: {
23
24
  Container: CCTVVideoOverlayFooter,
24
25
  OpenButton: CCTVVideoOpenButton,
26
+ FullscreenButton: CCTVFullscreenButton,
25
27
  Metric: CCTVVideoMetric,
26
28
  Metrics: CCTVVideoMetrics,
27
29
  },
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { checkAppleDevice } from "@uniai-fe/util-functions/runtime-env";
4
5
  import type {
5
6
  UseCctvFullscreenParams,
6
7
  UseCctvFullscreenReturn,
@@ -13,14 +14,14 @@ const viewportOwners = new WeakMap<Document, HTMLElement>();
13
14
  * CCTV; service-owned viewer container fullscreen state
14
15
  * @hook
15
16
  * @desc 대상 element의 표준 Fullscreen API 지원 여부와 실제 document 상태를 동기화한다.
16
- * 요청 실패는 기존 viewer를 유지하며, 외부 종료 후에는 진입 전 focus를 복원한다.
17
+ * 외부 종료 후에는 진입 전 focus를 복원하며, 표준 API 요청 실패는 기존 viewer를 유지한다.
17
18
  * 지원 환경에서는 진입 후 가로 방향을 요청하고 종료·대상 제거 시 기본 방향 정책으로 돌려준다.
18
19
  * 방향 요청 실패는 전체화면 성공에 영향을 주지 않으며 종료 후 세로 방향을 강제하지 않는다.
19
- * fallback이 "viewport"이면 표준 API 미지원 시 전체창 확대 상태와 배경 scroll·focus를 관리한다.
20
+ * fallback이 "viewport"이면 iPhone·iPad에서 표준 API 대신 전체창 확대와 배경 scroll·focus를 관리한다.
21
+ * 그 외 환경과 옵션을 생략한 호출은 표준 API를 사용한다.
20
22
  * 실제 확대 layout은 서비스가 isViewportExpanded로 적용하며 영상 DOM을 이동하거나 다시 만들지 않는다.
21
- * 표준 API 요청 거부는 전체창 확대로 전환하지 않는다.
22
- * @param {UseCctvFullscreenParams} options 미지원 환경의 확대 정책
23
- * @property {"viewport"} [options.fallback] "viewport"는 전체창 확대를 허용한다. 생략하면 표준 API만 사용한다.
23
+ * @param {UseCctvFullscreenParams} options iPhone·iPad의 확대 정책
24
+ * @property {"viewport"} [options.fallback] "viewport"는 iPhone·iPad에서 전체창 확대를 사용한다. 생략하면 표준 API만 사용한다.
24
25
  * @return {UseCctvFullscreenReturn} 대상 ref callback과 전체화면 상태·action
25
26
  */
26
27
  export function useCctvFullscreen({
@@ -148,7 +149,7 @@ export function useCctvFullscreen({
148
149
  ]);
149
150
 
150
151
  /**
151
- * 표준 API를 우선하며 미지원일 때만 선택한 전체창 확대를 시작한다.
152
+ * iPhone·iPad의 선택한 전체창 확대와 나머지 환경의 표준 전체화면을 구분한다.
152
153
  */
153
154
  const enterFullscreen = useCallback(async () => {
154
155
  const target = targetElementRef.current;
@@ -163,18 +164,19 @@ export function useCctvFullscreen({
163
164
  }
164
165
 
165
166
  const ownerDocument = target.ownerDocument;
166
- if (viewportOwners.has(ownerDocument)) return false;
167
+ const deviceNavigator = ownerDocument.defaultView?.navigator;
168
+ const userAgent = deviceNavigator?.userAgent ?? "";
169
+ // iPad의 데스크톱 UA는 Mac과 같으므로 Macintosh UA에 한해 다중 터치 정보를 보완한다.
170
+ const isIPhoneOrIPad: boolean =
171
+ checkAppleDevice(userAgent) &&
172
+ (/\((?:iPhone|iPad)(?:;|\))/i.test(userAgent) ||
173
+ (/Macintosh/i.test(userAgent) &&
174
+ (deviceNavigator?.maxTouchPoints ?? 0) > 1));
175
+ const useViewport: boolean = fallback === "viewport" && isIPhoneOrIPad;
167
176
  const isNativeSupported =
168
177
  ownerDocument.fullscreenEnabled &&
169
178
  typeof target.requestFullscreen === "function";
170
- if (
171
- !isNativeSupported &&
172
- (fallback !== "viewport" ||
173
- !target.isConnected ||
174
- ownerDocument.fullscreenElement)
175
- ) {
176
- return false;
177
- }
179
+ if (!useViewport && !isNativeSupported) return false;
178
180
  const activeElement = ownerDocument.activeElement;
179
181
  const ElementConstructor = ownerDocument.defaultView?.HTMLElement;
180
182
  entryFocusRef.current =
@@ -182,7 +184,13 @@ export function useCctvFullscreen({
182
184
  ? activeElement
183
185
  : null;
184
186
 
185
- if (!isNativeSupported) {
187
+ if (useViewport) {
188
+ if (
189
+ !target.isConnected ||
190
+ ownerDocument.fullscreenElement ||
191
+ viewportOwners.has(ownerDocument)
192
+ )
193
+ return false;
186
194
  const restores: (() => void)[] = [];
187
195
  viewportOwners.set(ownerDocument, target);
188
196
 
@@ -279,15 +287,9 @@ export function useCctvFullscreen({
279
287
 
280
288
  try {
281
289
  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
290
  const orientation = target.ownerDocument.defaultView?.screen.orientation;
290
291
  if (
292
+ targetElementRef.current === target &&
291
293
  target.ownerDocument.fullscreenElement === target &&
292
294
  typeof orientation?.lock === "function" &&
293
295
  typeof orientation.unlock === "function"
@@ -0,0 +1,6 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M8.99993 14.2004C9.04899 14.2004 9.09686 14.2056 9.14348 14.2141C9.14832 14.2149 9.15331 14.215 9.15813 14.216C9.17033 14.2185 9.18227 14.2218 9.19426 14.2248C9.20597 14.2277 9.21783 14.2301 9.22942 14.2336C9.27183 14.2463 9.31236 14.2632 9.35149 14.2824C9.35998 14.2866 9.36852 14.2906 9.37688 14.2951C9.40408 14.3097 9.42981 14.3264 9.45501 14.3439C9.49353 14.3707 9.53101 14.4004 9.56536 14.4348C9.62917 14.4986 9.67899 14.571 9.71672 14.6476C9.73599 14.6868 9.75281 14.7273 9.76555 14.7697C9.77403 14.798 9.7798 14.8268 9.78508 14.8557C9.79366 14.9026 9.79973 14.9508 9.79973 15.0002V19.0002C9.7997 19.442 9.4417 19.7999 8.99993 19.8C8.55812 19.8 8.20016 19.442 8.20012 19.0002V16.9308L3.56536 21.5656C3.25297 21.878 2.74692 21.8779 2.4345 21.5656C2.12209 21.2532 2.1221 20.7472 2.4345 20.4348L7.06926 15.8H4.99993C4.55812 15.8 4.20016 15.442 4.20012 15.0002C4.20012 14.5584 4.5581 14.2004 4.99993 14.2004H8.99993Z" fill="white"/>
3
+ <path d="M18.9999 14.2004C19.4417 14.2004 19.7997 14.5584 19.7997 15.0002C19.7997 15.442 19.4417 15.7999 18.9999 15.8H16.9306L21.5654 20.4348C21.8777 20.7472 21.8777 21.2532 21.5654 21.5656C21.253 21.878 20.7469 21.878 20.4345 21.5656L15.7997 16.9308V19.0002C15.7997 19.442 15.4417 19.7999 14.9999 19.8C14.5581 19.8 14.2002 19.442 14.2001 19.0002V15.0002C14.2001 14.9508 14.2052 14.9025 14.2138 14.8557C14.2191 14.8268 14.2249 14.7979 14.2333 14.7697C14.246 14.7273 14.2629 14.6867 14.2822 14.6476C14.3199 14.5708 14.3706 14.4987 14.4345 14.4348C14.481 14.3883 14.5321 14.3492 14.5859 14.3166C14.643 14.2819 14.7042 14.2532 14.7695 14.2336C14.781 14.2301 14.7929 14.2277 14.8046 14.2248C14.8166 14.2218 14.8286 14.2185 14.8407 14.216C14.8456 14.215 14.8506 14.2149 14.8554 14.2141C14.9023 14.2055 14.9506 14.2004 14.9999 14.2004H18.9999Z" fill="white"/>
4
+ <path d="M2.4345 2.43475C2.74692 2.12233 3.25294 2.12233 3.56536 2.43475L8.20012 7.06952V5.00018C8.20012 4.55836 8.5581 4.20038 8.99993 4.20038C9.44172 4.20042 9.79973 4.55838 9.79973 5.00018V9.00018C9.79973 9.0493 9.79356 9.09707 9.78508 9.14374C9.7842 9.14858 9.7841 9.15356 9.78313 9.15839C9.76944 9.22655 9.74654 9.29106 9.71672 9.35175C9.71256 9.36024 9.70852 9.36877 9.70403 9.37714C9.68943 9.40435 9.67274 9.43005 9.6552 9.45526C9.62847 9.49364 9.59959 9.53138 9.56536 9.56561C9.53112 9.59985 9.49339 9.62872 9.45501 9.65546C9.42979 9.673 9.40409 9.68969 9.37688 9.70429C9.36851 9.70877 9.35998 9.71281 9.35149 9.71698C9.31233 9.73622 9.27187 9.75311 9.22942 9.76581C9.21782 9.76928 9.20598 9.77167 9.19426 9.7746C9.18226 9.7776 9.17034 9.78093 9.15813 9.78339C9.1533 9.78436 9.14833 9.78446 9.14348 9.78534C9.12312 9.78904 9.10283 9.79396 9.08196 9.79608L8.99993 9.79999H4.99993C4.55812 9.79999 4.20016 9.44198 4.20012 9.00018C4.20012 8.55836 4.5581 8.20038 4.99993 8.20038H7.06926L2.4345 3.56561C2.12209 3.2532 2.1221 2.74717 2.4345 2.43475Z" fill="white"/>
5
+ <path d="M20.4345 2.43475C20.7469 2.12237 21.253 2.12235 21.5654 2.43475C21.8777 2.74716 21.8777 3.2532 21.5654 3.56561L16.9306 8.20038H18.9999C19.4417 8.20042 19.7997 8.55838 19.7997 9.00018C19.7997 9.44196 19.4417 9.79995 18.9999 9.79999H14.9999C14.9505 9.79999 14.9023 9.79391 14.8554 9.78534C14.8506 9.78445 14.8456 9.78436 14.8407 9.78339C14.8285 9.78092 14.8166 9.77761 14.8046 9.7746C14.7929 9.77165 14.7811 9.76929 14.7695 9.76581C14.7043 9.74623 14.6429 9.71836 14.5859 9.68378C14.5321 9.65113 14.481 9.6121 14.4345 9.56561C14.3704 9.50147 14.32 9.42885 14.2822 9.35175C14.2524 9.2911 14.2294 9.22648 14.2157 9.15839C14.2148 9.15356 14.2147 9.14858 14.2138 9.14374C14.2053 9.09712 14.2001 9.04925 14.2001 9.00018V5.00018C14.2001 4.55836 14.5581 4.20038 14.9999 4.20038C15.4417 4.20042 15.7997 4.55838 15.7997 5.00018V7.06952L20.4345 2.43475Z" fill="white"/>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M8.43457 14.4347C8.74698 14.1223 9.25302 14.1223 9.56543 14.4347C9.87784 14.7471 9.87782 15.2531 9.56543 15.5656L4.93066 20.2003H7C7.44183 20.2003 7.7998 20.5583 7.7998 21.0001C7.7998 21.4419 7.44183 21.7999 7 21.7999H3C2.95055 21.7999 2.90244 21.7939 2.85547 21.7853C2.85063 21.7844 2.84565 21.7843 2.84082 21.7833C2.82861 21.7809 2.81669 21.7776 2.80469 21.7745C2.79298 21.7716 2.78112 21.7692 2.76953 21.7657C2.72712 21.753 2.68659 21.7362 2.64746 21.7169C2.57079 21.6792 2.49839 21.6294 2.43457 21.5656C2.37042 21.5014 2.32003 21.4288 2.28223 21.3517C2.25248 21.291 2.22947 21.2264 2.21582 21.1583C2.21485 21.1535 2.21475 21.1485 2.21387 21.1437C2.20542 21.0971 2.2002 21.0492 2.2002 21.0001V17.0001C2.2002 16.5583 2.55817 16.2003 3 16.2003C3.44183 16.2003 3.7998 16.5583 3.7998 17.0001V19.0695L8.43457 14.4347Z" fill="white"/>
3
+ <path d="M14.4346 14.4347C14.747 14.1223 15.253 14.1223 15.5654 14.4347L20.2002 19.0695V17.0001C20.2002 16.5583 20.5582 16.2003 21 16.2003C21.4418 16.2003 21.7998 16.5583 21.7998 17.0001V21.0001C21.7998 21.0492 21.7936 21.097 21.7852 21.1437C21.7843 21.1485 21.7842 21.1535 21.7832 21.1583C21.7695 21.2265 21.7466 21.291 21.7168 21.3517C21.7126 21.3602 21.7086 21.3687 21.7041 21.3771C21.6895 21.4043 21.6728 21.43 21.6553 21.4552C21.6285 21.4936 21.5997 21.5313 21.5654 21.5656C21.5014 21.6296 21.4285 21.6791 21.3516 21.7169C21.3124 21.7362 21.2719 21.753 21.2295 21.7657C21.2179 21.7692 21.2061 21.7716 21.1943 21.7745C21.1823 21.7775 21.1704 21.7809 21.1582 21.7833C21.1534 21.7843 21.1484 21.7844 21.1436 21.7853C21.1232 21.789 21.1029 21.7939 21.082 21.796L21 21.7999H17C16.5582 21.7999 16.2002 21.4419 16.2002 21.0001C16.2002 20.5583 16.5582 20.2003 17 20.2003H19.0693L14.4346 15.5656C14.1222 15.2531 14.1222 14.7471 14.4346 14.4347Z" fill="white"/>
4
+ <path d="M7 2.20032C7.44183 2.20032 7.7998 2.5583 7.7998 3.00012C7.7998 3.44195 7.44183 3.79993 7 3.79993H4.93066L9.56543 8.43469C9.87784 8.7471 9.87782 9.25313 9.56543 9.56555C9.25301 9.87797 8.74699 9.87797 8.43457 9.56555L3.7998 4.93079V7.00012C3.7998 7.44195 3.44183 7.79993 3 7.79993C2.55817 7.79993 2.2002 7.44195 2.2002 7.00012V3.00012C2.2002 2.95073 2.2053 2.9025 2.21387 2.85559C2.21915 2.82676 2.22493 2.79788 2.2334 2.76965C2.24611 2.72728 2.263 2.68667 2.28223 2.64758C2.29718 2.61718 2.31396 2.58741 2.33301 2.55872C2.33635 2.55367 2.34029 2.54903 2.34375 2.54407C2.37045 2.50576 2.40039 2.46887 2.43457 2.43469C2.4985 2.37077 2.57064 2.32011 2.64746 2.28235C2.68655 2.26312 2.72716 2.24624 2.76953 2.23352C2.78111 2.23005 2.79299 2.22767 2.80469 2.22473C2.81667 2.22172 2.82863 2.2184 2.84082 2.21594C2.84564 2.21497 2.85063 2.21487 2.85547 2.21399C2.90238 2.20543 2.95061 2.20032 3 2.20032H7Z" fill="white"/>
5
+ <path d="M21 2.20032C21.0491 2.20032 21.0969 2.20554 21.1436 2.21399C21.1484 2.21487 21.1534 2.21497 21.1582 2.21594C21.1704 2.21839 21.1823 2.22174 21.1943 2.22473C21.206 2.22766 21.2179 2.23006 21.2295 2.23352C21.2719 2.24619 21.3124 2.26315 21.3516 2.28235C21.4287 2.32015 21.5013 2.37055 21.5654 2.43469C21.6293 2.49852 21.6791 2.5709 21.7168 2.64758C21.7361 2.68671 21.7529 2.72724 21.7656 2.76965C21.7741 2.79791 21.7799 2.82672 21.7852 2.85559C21.7937 2.90256 21.7998 2.95067 21.7998 3.00012V7.00012C21.7998 7.44195 21.4418 7.79993 21 7.79993C20.5582 7.79993 20.2002 7.44195 20.2002 7.00012V4.93079L15.5654 9.56555C15.253 9.87793 14.747 9.87793 14.4346 9.56555C14.1222 9.25314 14.1222 8.74711 14.4346 8.43469L19.0693 3.79993H17C16.5582 3.79993 16.2002 3.44195 16.2002 3.00012C16.2002 2.55829 16.5582 2.20032 17 2.20032H21Z" fill="white"/>
6
+ </svg>
@@ -76,3 +76,61 @@
76
76
  .cctv-viewer-desktop-pagination-container {
77
77
  margin-top: var(--spacing-gap-2);
78
78
  }
79
+
80
+ // 전체화면 action을 명시적으로 조립한 뷰어에만 하단 배치를 적용한다.
81
+ .cctv-video-overlay-footer:has(> .cctv-fullscreen-button) {
82
+ min-height: 56px;
83
+ justify-content: space-between;
84
+ flex-wrap: wrap;
85
+ gap: var(--spacing-gap-4);
86
+
87
+ .cctv-video-metrics {
88
+ margin: 0;
89
+ min-width: 0;
90
+ flex-wrap: wrap;
91
+ gap: var(--spacing-gap-4);
92
+ }
93
+
94
+ .cctv-video-metric {
95
+ height: 36px;
96
+ padding: var(--spacing-padding-2) var(--spacing-padding-5);
97
+ border-radius: 8px;
98
+ background: rgb(23 24 28 / 68%);
99
+ backdrop-filter: blur(3px);
100
+ font-size: var(--font-heading-xsmall-size);
101
+ line-height: 1.5;
102
+ }
103
+ }
104
+
105
+ .cctv-fullscreen-button {
106
+ flex: 0 0 auto;
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ gap: var(--spacing-gap-4);
111
+ margin-left: auto;
112
+ min-height: 44px;
113
+ padding: 0;
114
+ border: 0;
115
+ background: transparent;
116
+ color: var(--color-common-100);
117
+ font-family: inherit;
118
+ font-size: var(--font-heading-small-size);
119
+ font-weight: 600;
120
+ line-height: 1.4;
121
+ letter-spacing: 0.2px;
122
+ white-space: nowrap;
123
+ cursor: pointer;
124
+ pointer-events: auto;
125
+
126
+ svg {
127
+ width: 24px;
128
+ height: 24px;
129
+ flex: 0 0 24px;
130
+ }
131
+
132
+ &:focus-visible {
133
+ outline: 2px solid currentColor;
134
+ outline-offset: 4px;
135
+ }
136
+ }
@@ -14,12 +14,12 @@ import type { UseFormReturn } from "react-hook-form";
14
14
  import type { CctvBaseContext } from "./context";
15
15
 
16
16
  /**
17
- * CCTV; 전체화면 미지원 환경의 확대 정책
18
- * @property {"viewport"} [fallback] "viewport"는 표준 API 미지원 시 서비스의 전체창 확대 스타일을 사용한다. 생략하면 표준 API만 사용한다.
17
+ * CCTV; iPhone·iPad의 확대 정책
18
+ * @property {"viewport"} [fallback] "viewport"는 iPhone·iPad에서 표준 API 대신 서비스의 전체창 확대 스타일을 사용한다. 그 외 환경과 생략한 호출은 표준 API만 사용한다.
19
19
  */
20
20
  export interface UseCctvFullscreenParams {
21
21
  /**
22
- * "viewport"는 미지원 환경의 전체창 확대를 허용한다. 생략하면 기존 viewer를 유지한다.
22
+ * "viewport"는 iPhone·iPad에서 전체창 확대로 바로 진입한다. 그 외 환경과 생략한 호출은 표준 API만 사용한다.
23
23
  */
24
24
  fallback?: "viewport";
25
25
  }
@@ -377,3 +377,29 @@ export interface CctvViewerDesktopContainerProps {
377
377
  */
378
378
  onNext?: () => void;
379
379
  }
380
+
381
+ /**
382
+ * CCTV 뷰어 전체화면 버튼 props.
383
+ * @property {boolean} isExpanded 표준 전체화면 또는 전체창 확대 여부
384
+ * @property {() => void | Promise<boolean>} onToggle 확대 상태 전환 action
385
+ * @property {string} [expandLabel] 확대 문구
386
+ * @property {string} [collapseLabel] 축소 문구
387
+ */
388
+ export interface CctvFullscreenButtonProps {
389
+ /**
390
+ * 표준 전체화면 또는 전체창 확대 여부
391
+ */
392
+ isExpanded: boolean;
393
+ /**
394
+ * 기존 전체화면 Hook의 전환 action
395
+ */
396
+ onToggle: () => void | Promise<boolean>;
397
+ /**
398
+ * 서비스 번역을 주입하는 확대 문구
399
+ */
400
+ expandLabel?: string;
401
+ /**
402
+ * 서비스 번역을 주입하는 축소 문구
403
+ */
404
+ collapseLabel?: string;
405
+ }
@@ -3,7 +3,7 @@
3
3
  import clsx from "clsx";
4
4
 
5
5
  import type { ModalHeaderCloseButtonProps } from "../../../types";
6
- import CloseIcon from "../../../img/close.svg";
6
+ import CloseIcon from "../../../img/close-circle.svg";
7
7
 
8
8
  /**
9
9
  * Modal Header CloseButton; 모달 헤더 닫기 버튼
@@ -28,9 +28,11 @@ export function ModalHeaderCloseButton({
28
28
  onClick={onClick}
29
29
  >
30
30
  <CloseIcon
31
- width={20}
32
- height={20}
33
- viewBox="0 0 20 20"
31
+ width={32}
32
+ height={32}
33
+ viewBox="0 0 32 32"
34
+ aria-hidden="true"
35
+ focusable="false"
34
36
  preserveAspectRatio="xMidYMid meet"
35
37
  />
36
38
  </button>
@@ -0,0 +1,3 @@
1
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M16 2.66699C23.3638 2.66699 29.333 8.6362 29.333 16C29.333 23.3638 23.3638 29.333 16 29.333C8.6362 29.333 2.66699 23.3638 2.66699 16C2.66699 8.6362 8.6362 2.66699 16 2.66699ZM20.7539 11.2461C20.3374 10.8296 19.6627 10.8296 19.2461 11.2461L16 14.4922L12.7539 11.2461C12.3374 10.8297 11.6626 10.8297 11.2461 11.2461C10.8297 11.6626 10.8297 12.3374 11.2461 12.7539L14.4922 16L11.2461 19.2461C10.8298 19.6627 10.8296 20.3374 11.2461 20.7539C11.6626 21.1702 12.3374 21.1701 12.7539 20.7539L16 17.5078L19.2461 20.7539C19.6626 21.1703 20.3374 21.1703 20.7539 20.7539C21.1703 20.3374 21.1703 19.6626 20.7539 19.2461L17.5078 16L20.7539 12.7539C21.1704 12.3374 21.1703 11.6626 20.7539 11.2461Z" fill="#686B7E"/>
3
+ </svg>
@@ -24,3 +24,5 @@ export {
24
24
  modalStackAtom,
25
25
  };
26
26
  export type * from "./types";
27
+
28
+ export { ModalHeaderCloseButton } from "./components/core/header/CloseButton";
@@ -24,7 +24,17 @@
24
24
  overflow: auto;
25
25
  }
26
26
  .uds-modal-header-close-button {
27
+ display: inline-flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ flex: 0 0 auto;
31
+ width: 32px;
32
+ height: 32px;
33
+ padding: 0;
34
+ border: 0;
35
+ background: transparent;
27
36
  font-size: 0;
37
+ cursor: pointer;
28
38
  }
29
39
 
30
40
  .uds-modal-footer {